微前端重构

最近把组内的一个比较大的微前端项目做了下重构。之前是基于 single-spa 结合软链接子应用代码来统一打包,存在以下问题:

  • 基座有额外的维护成本。基座需要适配所有子应用的打包配置,比如 less-loader、ts-loader,这样造成的情况是新增应用的成本较大,有未知的修改基座的成本在,因为可能会修改构建配置
  • 软链接子应用代码的形式,造成打包逻辑较为复杂,中途构建出错需要删除干净所有的软链
  • 不支持子项目使用移动端适配插件,比如 px2rem
  • 不支持子项目定义全局样式,会造成 css 污染
  • css 隔离和 js 沙箱配置难度大

综上,重构目标有几点

  • 去除基座的维护成本。将统一打包改为子应用分别打包,但是会相应地增加一些重复的公共依赖
  • 更靠谱的前端编译。优化前端打包流程,去除软链接,减少前端构建出错的情况
  • 更独立的子项目,涉及样式定义、移动端适配等
  • 更容易接入新的子应用,支持 css 隔离和 js 沙箱

重构步骤

应用部署方式

共用一个端口,通过二级目录划分子应用,主应用通过一级目录路由访问对应的子应用

部署方式参考 https://qiankun.umijs.org/zh/cookbook#%E5%A6%82%E4%BD%95%E9%83%A8%E7%BD%B2

基座引入 qiankun

基座不限技术栈,可以基于 CRA5 新建

yarn add qiankun

修改入口文件 index.ts

import { registerMicroApps, start, setDefaultMountApp } from "qiankun";

registerMicroApps([
  {
    name: "reactApp",
    // 需要确保斜线结尾,防止资源加载异常
    entry: "/project/app1/",
    container: "#app1-root",
    activeRule: "/app1",
  },
  {
    name: "vueApp",
    entry: "/project/app2/",
    container: "#app2-root",
    activeRule: "/app2",
  },
]);

// 启动 qiankun
start({
  // 去除baidu脚本的跨域限制
  // 注意:这种方式只支持异步引入baidu sdk,不支持在script标签引入
  excludeAssetFilter: (url) => {
    return url.indexOf("api.map.baidu.com") !== -1;
  },
});
// 设置默认启动应用
setDefaultMountApp("/app1");

调整 index.html

// ...
<div id="root"></div>
<div id="app1-root"></div>
<div id="app2-root"></div>

异步引入 baidu jssdk

去除 script 标签引入的代码

<!--引入百度地图api-->
<script
  type="text/javascript"
  src="https://api.map.baidu.com/api?v=3.0&ak=你的密钥"
></script>

改成以下方式:

function loadBaiduJssdk(url) {
  return new Promise((resolve, reject) => {
    const scriptEl = document.createElement("script");
    scriptEl.type = "text/javascript";
    scriptEl.src = url;
    document.body.appendChild(scriptEl);
    scriptEl.onload = resolve;
    scriptEl.onerror = reject;
  });
}
// 异步加载
async function initMap() {
  await loadBaiduJssdk("https://api.map.baidu.com/api?v=3.0&ak=你的密钥");
}

子应用配置

CRA5 新增配置

// config-overrides.js
const setOutputForQiankun = () => (config) => {
  config.output.library = `${name}`;
  config.output.libraryTarget = "umd";
  config.output.globalObject = "window";
  return config;
};
// ...
module.exports = {
    webpack: override(
        setOutputForQiankun(),
        // ...
    )
    devServer: overrideDevServer((config) => {
        config.historyApiFallback = true;
        config.open = false;
        config.headers = {
          "Access-Control-Allow-Origin": "*",
        };
        return config;
    }),
}

在 src 下新增 pubilc-path.js(ts 也行)

if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

调整入口文件(src/index.ts),暴露钩子函数。注意根节点要与 container 保持一致(也需要调整 public/index.html,默认根节点 id 为 root)

import "public-path.js";
// ...
function getSubRootContainer(container) {
  return container
    ? container.querySelector("#app1-root")
    : document.querySelector("#app1-root");
}

function render(props) {
  const { container } = props;
  ReactDOM.render(<App />, getSubRootContainer(container));
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

export async function bootstrap() {
  console.log("react app bootstraped");
}

export async function mount(props) {
  console.log("props from main framework", props);
  render(props);
}

export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(getSubRootContainer(container));
}

以上代码基于 react16

react18 可以参考 https://github.com/ice-lab/icestark/issues/581

路由配置

basename 和 qianiun 的配置保持一致,如果是 qiankun 访问,需要追加主应用配置的路由前缀,参考如下

// app.js
// ...
return (
  <Provider store={STORE}>
    <Router
      basename={
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        window.__POWERED_BY_QIANKUN__ ? "/app1" : process.env.REACT_APP_BASEURL
        // 在环境变量中增加路由前缀 REACT_APP_BASEURL
        // 和 activeRule 对齐,也就是 /app1
      }
        <Suspense fallback={null}>
          <Routes>
             <Route path="/" element={<MainView />}
             {/* ...其他路由 */}
             {/* 路由重定向 */}
             <Route path="*" element={<Navigate to="/" />}
          </Routes>
        </Suspense>
  </Provider>
)

构建加速

因为项目的特殊性,每一次构建都要构建所有子项目,因此可以使用缓存,缓存各项目在主分支上最近可用的 build 包,主要是根据 commitId 判断缓存的有效性

最终达到的效果是只会构建有修改的子项目,其余子项目沿用缓存,无需重新安装依赖和构建

优化 TODO

  • 理论上还能做 node_modules 的缓存
  • qiankun 提取公共依赖,减少子项目重复安装

参考