MontoApp(三) - 动态 script 方案

本节继续介绍微前端的另一种方案:动态 script。顾名思义,就是将当前的微应用打包后,将入口的 js 文件通过 script 加载进主应用,从而达到微前端的效果。他可能是目前实现起来最简单的一种方案。

image.png

仓库地址:tutorial/dynamic-script 分支

1. 主应用

我们用 node 启动一个主应用:

app.listen(port.main, host);

并在主应用里写入接口请求:

app.post("/microapps", function (req, res) {
  // 这里可以是管理后台新增菜单后存储到数据库的数据
  // 从而可以通过管理后台动态配置微应用的菜单
  res.json([
    {
      name: "micro1",
      id: "micro1",
      // 这里暂时以一个入口文件为示例
      script: `http://${host}:${port.micro}/micro1.js`,
      style: `http://${host}:${port.micro}/micro1.css`,
      // 挂载到 window 上的启动函数 window.micro1_mount
      mount: "micro1_mount",
      // 挂载到 window 上的启动函数 window.micro1_unmount
      unmount: "micro1_unmount",
      prefetch: true,
    },
    {
      name: "micro2",
      id: "micro2",
      script: `http://${host}:${port.micro}/micro2.js`,
      style: `http://${host}:${port.micro}/micro2.css`,
      mount: "micro2_mount",
      unmount: "micro2_unmount",
      prefetch: true,
    },
  ]);
});

上面配置的这两个子应用,自己各自单独在自己端口启动即可。(这里公用了一个子应用端口,实际情况可能子应用端口不一样)

然后配置主应用的 html,并配置加载功能:

<div class="container">
  <!-- 微应用渲染的插槽 -->
  <div id="micro-app-slot"></div>
</div>
loadScript({ script, id }) {
  return new Promise((resolve, reject) => {
    const $script = document.createElement("script");
    $script.src = script;
    $script.setAttribute("micro-script", id);
    $script.onload = resolve;
    $script.onerror = reject;
    document.body.appendChild($script);
  });
}

loadStyle({ style, id }) {
  return new Promise((resolve, reject) => {
    const $style = document.createElement("link");
    $style.href = style;
    $style.setAttribute("micro-style", id);
    $style.rel = "stylesheet";
    $style.onload = resolve;
    $style.onerror = reject;
    document.head.appendChild($style);
  });
}

removeStyle({ id }) {
  const $style = document.querySelector(`[micro-style=${id}]`);
  $style && $style?.parentNode?.removeChild($style);
}

hasLoadScript({ id }) {
  const $script = document.querySelector(`[micro-script=${id}]`);
  return !!$script;
}

hasLoadStyle({ id }) {
  const $style = document.querySelector(`[micro-style=${id}]`);
  return !!$style;
}

主应用初始化:

init() {
  this.processMicroApps();
  this.navClickListener();
  this.hashChangeListener();
}

processMicroApps() {
  this.getMicroApps().then((res) => {
    this.microApps = res;
    this.prefetchMicroAppStatic();
    this.createMicroAppNav();
  });
}

prefetchMicroAppStatic() {
  const prefetchMicroApps = this.microApps?.filter(
    (microapp) => microapp.prefetch
  );
  prefetchMicroApps?.forEach((microApp) => {
    microApp.script && this.prefetchStatic(microApp.script, "script");
    microApp.style && this.prefetchStatic(microApp.style, "style");
  });
}

createMicroAppNav(microApps) {
  const fragment = new DocumentFragment();
  this.microApps?.forEach((microApp) => {
    // TODO: APP 数据规范检测 (例如是否有 script. mount. unmount 等)
    const button = document.createElement("button");
    button.textContent = microApp.name;
    button.id = microApp.id;
    fragment.appendChild(button);
  });
  nav.appendChild(fragment);
}

其中 getMicroApps 是服务获取微应用列表信息,主应用姑且使用 hashchange 配置路由检测:

hashChangeListener() {
  // 监听 Hash 路由的变化,切换微应用
  // 这里设定一个时刻页面上只有一个微应用
  window.addEventListener("hashchange", () => {
    this.microApps?.forEach(async (microApp) => {
      // 匹配需要激活的微应用
      if (microApp.id === window.location.hash.replace("#", "")) {
        console.time(`fetch microapp ${microApp.name} static`);
        // 加载 CSS 样式
        microApp?.style &&
          !this.hasLoadStyle(microApp) &&
          (await this.loadStyle(microApp));
        // 加载 Script 标签
        microApp?.script &&
          !this.hasLoadScript(microApp) &&
          (await this.loadScript(microApp));
        console.timeEnd(`fetch microapp ${microApp.name} static`);
        window?.[microApp.mount]?.("#micro-app-slot");
        // 如果存在卸载 API 则进行应用卸载处理
      } else {
        this.removeStyle(microApp);
        window?.[microApp.unmount]?.();
      }
    });
  });
}

这里在子应用卸载时没有移除 js,是因为重新加载时顶级作用域会被重新执行,比如 let a = 1; 这样反复声明会报错。 同时,不写 removeScript,js 在 window 上挂载了子应用的 mountunmount 方法也不会造成污染,没必要删除。

2. 子应用改造

当然,各个子应用入口文件要改造,暴露钩子函数:

// micro1.js
// 立即执行的匿名函数可以防止变量 root 产生冲突
(function () {
  let root;

  // 要保证各个子应用名称不一样
  window.micro1_mount = function (slot) {
    // 以下其实可以是 React 框架或者 Vue 框架生成的 Document 元素,这里只是做一个简单的示例
    root = document.createElement("h1");
    root.textContent = "微应用1";
    // 在微应用插槽上挂载 DOM 元素
    const $slot = document.querySelector(slot);
    $slot?.appendChild(root);
  };

  window.micro1_unmount = function () {
    if (!root) return;
    root.parentNode?.removeChild(root);
  };
})();


// micro1.css
h1 {
  color: green;
}

同时子应用也要启动起来:

app.listen(port.micro, host);

这样,script 标签的 src 才能够去找到服务器上子应用的 js 入口文件。

本文用了hash路由配置切换,本身有设计缺陷,实战中需要自己设置更完善的路由方案。

基于以上描述,其具有以下优点:

  • 主应用可以动态增减. 更新子应用的数量,实现动态部署。
  • 微应用可以如正常应用一样进行优化,如代码分割,静态资源分离,CDN加速等。
  • 不需要如NPM库一样,配置额外的配置文件使其打包成依赖库。
  • 主子应用天然同域(除了加载js时要跨域)

其问题也是与npm方案一样:

  • 主应用与微应用的全局变量. CSS样式. 本地存储的数据无法做到很好的隔离。
  • 子应用需要改造适配,同 NPM 方案一样没办法完全解耦合。

与 NPM 方案差异:

  • 本方案不用去除 script
  • 本方案的 script 可以做 chunk,而NPM方案只能做到 Tree Shaking。动态 script 分成了多段 chunk,体积更少了,而 NPM 则是全量构建到包里的 (使用 require 自动引入全部包),此时性能优化上应该是有差异的。

但是感觉NPM方案,只要放入入口文件,是不是也能做到分chunk?比如这个不使用 require('react-micro-app'),而是使用动态引入:

import("react-micro-app").then(chunk => {})

打 chunk 是将懒加载. 公共依赖分离,是 webpack 自动化处理的过程。亲测会报错,目前还不知道可不可行。

Was this page helpful?