微前端实现原理

109 阅读5分钟

注册应用

往主应用注册子应用列表,保存到内部的appList中。之后配合路由拦截功能,根据activeRule读取对应的子应用信息。访问对应的入口地址获取html内容进行加载。 修改子应用的打包方式为umd模式,这样好配合后面JS运行获取导出的子应用事件及沙箱环境

// 子应用结构
[
  {
    name: 'react app', // 全局名称
    entry: '//localhost:7100', // 子应用服务访问地址
    container: '#yourContainer', // 在主应用内展示的容器 id
    activeRule: '/yourActiveRule', // 路由拦截的前缀地址
  },
]

// 修改打包方式
output: {
  library: `${packageName}-[name]`,
  libraryTarget: 'umd',
  jsonpFunction: `webpackJsonp_${packageName}`,
},

路由拦截

子应用是通过切换访问地址来变更的,所以就需要对路由的变化进行监听。主要分为API和浏览器切换的监听

  • 重写pushState/replaceState()方法,在执行时触发原方法子应用切换
    • 可以内部直接执行切换函数,也可通过触发自定义事件来执行切换
  • 监听popstate事件实现在浏览器前进/回退及hash变动时,也触发子应用的切换
  • 其他:在路由切换时会保存两个记录window.__ORIGIN_APP__window.__CURRENT_SUB_APP__为新旧两次的activeRule
// *拦截并重写 pushState/replaceState 方法
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);

// *监听 hash/hsitory 事件。浏览器前进/回退时触发
window.addEventListener('popstate', function () {
  // *执行对应的子应用
  turnApp();
});

切换子应用

在路由变化时进行应用的切换,从__ORIGIN_APP__获取上一个应用。销毁它的沙箱信息及触发destoryed钩子函数。从__CURRENT_SUB_APP__获取当前应用信息,执行beforeLoad钩子函数并加载内容。

  • 销毁上一个应用的沙箱对象,执行上一个应用destoryed钩子函数
  • 执行主应用和新子应用的beforeLoad生命周期钩子
  • 执行loadHtml方法,加载子应用的内容
    • 解析对应的html页面内容,获取dom文本和script/css信息
    • dom挂载到对应的容器id
    • 使用沙箱模式执行js/css内容

运行及隔离

通过上一步已经挂载相应的DOM节点,但还未运行JS代码。而JS字符串大致有两种运行方式。

  • eval(js string)
  • new Function(js string)()
export const performScriptForFunction = (script, appName, global) => {
  window.__CURRENT_PROXY__ = global;
  // *在自运行函数内执行,这样可以控制访问的 window
  const scriptText = `
    return ((window) => {
      ${script}
      return window['${appName}']
    })(window.__CURRENT_PROXY__)
  `;

  return new Function(scriptText)();
};

JS沙箱

开发中经常需要在window上扩张一下功能,而服务会在多个子应用之间切换。那么为了防止它们之间修改了同一个window属性,所以需要对运行环境进行沙箱隔离。

  • 快照沙箱 SnapShotSandbox (兼容老浏览器)
    • 先拷贝一份原始的window对象所有的属性,之后上一个应用卸载时再用备份恢复
/**
 * 快照沙箱
 * 把 window 对象的所有属性都备份一遍
 * 之后在销毁时在恢复它
 */
export class SnapShotSandbox {
  constructor() {
    // 1.代理对象
    this.proxy = window;
    this.active();
  }

  // 沙箱激活
  active() {
    // 创建一个沙箱快照
    this.snapshot = new Map();
    // 遍历全局环境进行记录。相当于把当前 window 对象整个备份一遍
    for (const key in window) {
      this.snapshot[key] = window[key];
    }
  }

  // 沙箱销毁
  inactive() {
    for (const key in window) {
      // 把和备份不相同的内容,用备份的恢复
      if (window[key] !== this.snapshot[key]) {
        // 还原操作
        window[key] = this.snapshot[key];
      }
    }
  }
}
  • 代理沙箱 ProxySanbox
    • 创建一个假的window出来,如果设置值就设置在fakeWindow上,这样就不会影响全局变量了。在取值时就判断属性是存在于fakeWindow上还是window
export class ProxySandbox {
  proxy;
  running = false;
  constructor() {
    const fakeWindow = Object.create(null); // 子应用的沙箱容器
    this.proxy = new Proxy(window, {
      get(target, key) {
        // 执行函数时,重新指定一下 this -> window 。这样才能把对象指回去防止意外
        if (typeof target[key] === 'function') {
          return target[key].bind(target);
        }
        // 如果代理上没有,就去原本上查找
        return fakeWindow[key] || target[key];
      },

      set(target, key, value) {
        if (this.running) {
          fakeWindow[key] = value;
        }
        return true;
      },
    });

    this.active();
  }

  active() {
    this.running = true;
  }

  inactive() {
    this.running = false;
  }
}

css隔离

  • 在支持shadowDOM情况下,将整个DOM放入创建的shadow内实现严格的样式隔离
// *从真实节点获取 innerHTML 并删除,然后放入 shadowDOM 内
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow;

if (appElement.attachShadow) {
  shadow = appElement.attachShadow({ mode: 'open' });
} else {
  // 兼容处理
  shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
  • 或是改写子应用的所有样式,在前面添加一个特殊选择器来限制其范围。并将外联<link>样式改为内联的<style>
    • 需要配合MutationObserver去监听新增加<style>标签并添加特殊的选择器前缀
<style>
  div[data-micro=appName] .abc {}
</style>

<div data-micro="appName">
  <p class="abc">子应用的节点内容</p>
</div>

<script>
// 原本的
const styleNodes = document.querySelector('style');
// 临时的
const _styleNode = document.createElement('style');
// 只有挂载到 body 后才有 StyleSheet 属性
document.body.appendChild(_styleNode);

// 拷贝一份内容
const textNode = document.createTextNode(styleNodes.textContent);
_styleNode.appendChild(textNode);

let newCSS = '';
// cssRules 是个类数组对象,包含 { type, cssText, selectorText } 等信息
Array.prototype.slice
  .call(_styleNode.sheet.cssRules, 0)
  .forEach((cssRule) => {
    let { cssText } = cssRule;
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) => {
      return `[data-micro=appName] ${selectors}`;
    });
    newCSS += cssText;
  });

// 在替换原本 <style> 内容
styleNodes.textContent = newCSS;

// 再清除临时 style
_styleNode.removeChild(textNode);
</script>

完善功能

预加载

当用户在浏览器当前子应用时,可以在空闲阶段去加载其他未加载子应用。这样可以提高后续访问的效率。

  • 定义全局的resource_cache资源缓存对象,将子应用的dom/js/css等信息按name保存。当后续访问到该应用时,先判断resource_cache上是否存在。
  • 配合requestIdleCallback函数在浏览器空闲阶段,去访问appList上还未加载的应用资源进行保存

全局通信

  • 可通过创建自定义的事件通信函数,并挂载到window上以供访问
  • 或是创建个闭包性质的全局store,并挂载到window上以供访问
// 事件通信
export class Custom {
  on(name, cb) {
    window.addEventListener(name, (e) => {
      cb(e.detail);
    });
  }

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

// 全局 store
export const createStore = (initDate = {}) =>
  (() => {
    let store = initDate;
    const observers = []; // 管理所有订阅者,依赖

    // 获取 store
    const getStore = () => store;

    // 更新 store
    const updte = (value) => {
      if (value !== store) {
        const oldValue = store;
        store = value;

        // 通知所有的订阅者,监听 store 的变化
        observers.forEach(async (item) => await item(store, oldValue));
      }
    };

    // 添加订阅者
    const subscribe = (fn) => {
      observers.push(fn);
    };

    return {
      getStore,
      updte,
      subscribe,
    };
  })();