npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

spike-micro-web

v1.0.1

Published

微前端框架

Readme

从零到一实现微前端框架

前言

公司的项目是使用微前端架构,为了彻底搞懂微前端原理,笔者尝试自己写一个微前端框架。

微前端是目前比较火的一种技术架构,它的好处和应用场景,很多优秀的文章已经讲述了,这个笔者就不过多地介绍。

微前端框架执行流程图

我们平时写代码,所有的逻辑都在脑子,是比较混乱,如果将这个逻辑绘制成流程图,会大大的得到简化。

开发一个框架时,设计到很多的功能,并且相互关联。我们可以先将这些功能点,使用流程图的方式串联起来,再实现具体的功能,最后再根据逻辑关联起来。

下面我们先来看看,有哪些功能点。

功能点:
  • 路由劫持
  • 应用生命周期
  • 子应用加载器
  • 沙箱的设计
  • 通信的设计

然后我们理清这些功能点的逻辑,再将它们关联起来。就绘制成了一张流程图。

有了这个流程图,我们就知道代码的执行逻辑。这样就大大降低了开发难度。

微前端框架执行流程

实现路由劫持

我们需要监听到路由的变化,才可以切换子应用。

对于SPA(单页面应用)架构的项目,使用的路由分为两种:

  1. history模式
  2. hash模式

对于这两种路由可以分别使用下面的方式监听:

  1. popstate
  2. hashchange

除此之外,pushStatereplaceState,也会造成路由的变化,因此我们需要重写这两个函数。接下来看代码:

const rewriteRouter = () => {
  window.history.pushState = patchRouter(window.history.pushState, 'micro_push');
  window.history.replaceState = patchRouter(window.history.replaceState, 'micro_replace');
  // 添加路由跳转事件监听
  window.addEventListener('micro_push', turnApp);
  window.addEventListener('micro_replace', turnApp);
}

/**
 * 劫持event,触发自定义事件
 * @param {*} event 
 * @param {*} ListenerName 
 * @returns 
 */
export const patchRouter = (event, ListenerName) => {
  return function() {
    // 创建一个自定义事件
    const e = new Event(ListenerName);
    // 执行event事件, this -> window.history
    event.apply(this, arguments);
    // 通过dispatchEvent来触发自定义事件
    window.dispatchEvent(e);
  }
}

上面的代码做的事情很简单,就是在执行pushStatereplaceState方法时,触发我们的自定义事件,这样我们就能监听到pushStatereplaceState所导致的路由的变化。

应用生命周期

在实现了路由劫持后,我们需要考虑子应用加载的过程。在这个过程中,需要考虑到可以让使用者在不同的时期,执行自己的逻辑。这就需要我们暴露相应的生命周期。

如果想增加一些生命周期也是可以的,这边笔者开始定义生命周期。

对于主应用来说,我们定为三个生命周期:

  1. beforeLoad:挂载子应用前
  2. mounted:挂载子应用后
  3. destoryed:卸载子应用后

对于子应用来说,我们也定为三个生命周期:

  1. bootstrap:加载子应用
  2. mount:渲染子应用
  3. unmount:卸载子应用

下面我们来看下,执行生命周期时的顺序。

export const lifecycle = async () => {
  const prevApp = findAppByRoute(window.__ORIGIN_APP__);
  const nextApp = findAppByRoute(window.__CURRENT_HISTORY_PREFIX__);
  if(prevApp) {
    // 销毁代理沙箱
    prevApp.sandBox && prevApp.sandBox.inactive();
    await unmount(prevApp);
  }
  if(!nextApp) {
    return;
  }
  await bootstrap(nextApp);
  await mount(nextApp);
}

// 装载应用
export const bootstrap = async (app) => {
  await runMainLifecycle('beforeLoad', app);
  // 获取子应用的dom结构
  await htmlLoader(app);
  app && await app.bootstrap();
}

// 渲染应用
export const mount = async (app) => {
  app && await app.mount(app);
  await runMainLifecycle('mounted', app)
}

// 卸载应用
export const unmount = async (app) => {
  app && app.unmount && await app.unmount(app);
  await runMainLifecycle('destoryed', app)
}

// 执行主应用生命周期
export const runMainLifecycle = async (type, app) => {
  const mainLifecycle = getMainLifecycle();
  // 因为主应用里配置的生命周期是一个数组,所以需要执行数组中的所有内容
  await Promise.all(mainLifecycle[type].map(async item => await item(app)));
}

上面的代码中,有一些部分还没有介绍到。可以先忽略,下面会详细介绍。

这里主要做的事情就是,安照上面定义的生命周期的顺序,执行相应的函数。

子应用加载器

这里就到了微前端的核心部分,加载子应用。

我们先梳理好思路:

  1. 获取到子应用HTML的内容
  2. 读取HTMLscriptlink标签的资源
  3. HTML中的dom,插入到container容器中
  4. 执行scriptlink标签的资源

下面我们来看代码:

import { fetchUrl } from '../utils/fetchResources';
import {sandbox} from '../sandbox';
// 设置缓存
const cache = {};

// 解析 js 内容
export const getResources = (dom, app) => {
  const scriptUrls = [];
  const scripts = [];

  function deepParse(element) {
    const children = element.children;
    const parent = element.parentNode;

    // 处理script标签中的js文件
    if(element.nodeName.toLowerCase() === 'script') {
      const src = element.getAttribute('src');
      if(!src) {
        // 在script标签中写的内容
        let script = element.innerHTML;
        scripts.push(script);
      }else {
        if(src.startsWith('http')) {
          scriptUrls.push(src);
        }else {
          scriptUrls.push(`http:${app.entry}${src}`);
        }
      }

      if (parent) {
        let comment = document.createComment('此 js 文件已被spike-micro-web替换');
        // 在 dom 结构中删除此文件引用
        parent.replaceChild(comment, element);
      }
    }

    // 处理link标签中的js文件
    if(element.nodeName.toLowerCase() === 'link') {
      const href = element.getAttribute('href');
      if(href.endsWith('.js')) {
        if (href.startsWith('http')) {
          scriptUrls.push(href);
        } else {
          // fetch 时 添加 publicPath
          scriptUrls.push(`http:${app.entry}${href}`);
        }
      }
    }

    for (let i = children.length - 1; i > 0; i--) {
      deepParse(children[i]);
    }
  }

  deepParse(dom);

  return [scriptUrls, scripts, dom.outerHTML]
}

// 解析html
export const parseHtml = async(app) => {
  if(cache[app.name]) return cache[app.name];

  const div = document.createElement('div');
  let scriptsArray = [];

  div.innerHTML = await fetchUrl(app.entry);

  const [scriptUrls, scripts, elements] = getResources(div, app);

  const fetchedScript = await Promise.all(scriptUrls.map(url => fetchUrl(url)));


  scriptsArray = scripts.concat(fetchedScript);

  cache[app.name] = [elements, scriptsArray];

  return [elements, scriptsArray];
}

// 加载和渲染html
export const htmlLoader = async (app) => {
  const [dom, scriptsArray] = await parseHtml(app);

  const container = document.querySelector(app.container);
  if(!container) {
    throw new Error(`${app.container} 的容器不存在,请查看是否正确指定!`);
  }

  container.innerHTML = dom;

  scriptsArray.map((item) => {
    sandbox(item, app.name);
  });
}

沙箱的设计

微前端架构有多个子应用,为了保证不相互影响,因此需要进行数据隔离。通常有两种沙箱的方案:

  1. 快照沙箱
  2. Proxy代理沙箱
快照沙箱

首先来看快照沙箱的原理。在挂载子应用前,记录当前window上的内容。尽管子应用在window上做修改。然后在销毁子应用前,将window还原成记录的内容。

下面我们来看代码:

// 快照沙箱
export class SnapShotSandBox {
  constructor() {
    this.proxy = window;
    this.active();
  }
  active() {
    this.snapshot = new Map(); // 创建 window 对象的快照
    for (const key in window) {
      if (window.hasOwnProperty(key)) {
        // 将window上的属性进行拍照
        this.snapshot[key] = window[key];
      }
    }
  }
  inactive() {
    for (const key in window) {
      if (window.hasOwnProperty(key)) {
        // 将上次快照的结果和本次window属性做对比
        if (window[key] !== this.snapshot[key]) {
          // 还原window
          window[key] = this.snapshot[key];
        }
      }
    }
  }
}
Proxy代理沙箱

代理沙箱,我们采用Proxy特性,对window对象做代理。将代理对象设为子应用的window对象。如果子应用新增key,则放在一个空对象上。在销毁子应用时,情况对象上的属性。

下面我们来看代码:

let defaultValue = {}


export class ProxySandBox {
  constructor() {
    this.active()
  }

  active() {
    this.proxy = new Proxy(window, {
      set(target, name, value) {
        defaultValue[name] = value;
        return true;
      },

      get(target, name) {
        if( typeof target[ name ] === 'function' && /^[a-z]/.test( name ) ){
          return target[ name ].bind && target[ name ].bind( target );
        }else{
          return defaultValue[name] || target[ name ];
        }
      }
    });
  }

  inactive() {
    defaultValue = {}
  }
}

通信的设计

我们考虑到开发业务时,不可避免的需要应用间的通信。父子通信,子父通信,子应用间通信。

我们设计了3种通信方式:

  • props传递
  • 全局store
  • CustomEvent自定义事件
props传递

在注册子应用时,可以将数据放进去。子应用在mountunmount阶段会接收到注册app的值。

全局store

我们需要调用createStore创建一个store,会返回getStoreupdateStoresubscribe三个方法。

getStore可以回去全局store。需要通信的地方先subscribe订阅一下。当updateStore时,会执行所有订阅函数。

下面我们来看代码:

export const createStore = (initData = {}) => {
  let store = initData;
  let observers = [];
  const getStore = () => store;

  const updateStore = (newValue) => new Promise((resolve) => {
    if(newValue !== store) {
      let oldValue = store;
      store = newValue;
      resolve(store);
      observers.forEach(fn => fn(newValue, oldValue));
    }
  })

  const subscribe = (fn) => observers.push(fn);

  return {
    getStore,
    updateStore,
    subscribe
  }
}
CustomEvent自定义事件

顾名思义,这里使用了CustomEvent。我们需要先监听一个事件,然后在需要通信的时候通过CustomEvent触发这个事件。

下面我们来看代码:

export class Custom {
  on (eventName, cb) {
    window.addEventListener(eventName, function(e) {
      cb(e.detail)
    });
  }

  emit(eventName, data) {
    const event = new CustomEvent(eventName, {
      detail: data
    })
    window.dispatchEvent(event);
  }
}

总结

通过自己实现一个微前端框架,尽管是简陋的版本。我们也深入理解了其中原理,了解为何这样实现。当我们使用其他微前端框架时,也能很快上手,清楚具备哪些能力。