也许这才是你想要的微前端方案

185 阅读9分钟

微前端 是当下的前端热词,稍具规模的团队都会去做技术探索,作为一个不甘落后的团队,我们也去做了。也许你看过了 Single-Spa , qiankun 这些业界成熟方案,非常强大:JS 沙箱隔离、多栈支持、子应用并行、子应用嵌套,但仔细想想它真的适合你吗?

对于我来说,太重了,概念太多,理解困难。先说一下背景,我们之所以要对我司的小贷管理后台做微前端改造,主要基于以下几个述求:

  • 系统从接手时差不多 30 个页面,一年多时间,发展到目前 150 多个页面,并还在持续增长;
  • 项目体积变大,带来开发体验很差,打包构建速度很慢(初次构建,1 分钟以上);
  • 小贷系统开发量占整个 web 组 50%的人力,每个迭代都有两三个需求在这一个系统上开发,代码合并冲突,上线时间交叉。带来的是开发流程管理复杂;
  • 业务人员是分类的,没有谁会用到所有的功能,每个业务人员只拥有其中 30%甚至更少的功能。但不得不加载所有业务代码,才能看到自己想要的页面;

所以和市面上很多前端团队引入微前端的目的不同的是,我们是  ,而更多的团队是  。所以本方案适合和我目的一致的前端团队,将自己维护的 巨婴系统 瓦解,然后通过微前端"框架"来聚合,降低项目管理难度,提升开发体验与业务使用体验。

巨婴系统技术栈: Dva + Antd

方案参考美团一篇文章:微前端在美团外卖的实践

在做这个项目的按需提前加载设计时,自己去深究过 webpack 构建出的项目代码运行逻辑,收获比较多:webpack 打包的代码怎么在浏览器跑起来的?, 不了解的可以看看

方案设计

基于业务角色,我们将巨婴系统拆成了一个基座系统和四个子系统(可以按需扩展子系统),如下图所示:

基座系统 除了提供基座功能,即系统的登录、权限获取、子系统的加载、公共组件共享、公共库的共享,还提供了一个基本所有业务人员都会使用的业务功能:用户授(guan)信(li)。

子系统 以静态资源的方式,提供一个注册函数,函数返回值是一个 Switch 包裹的组件与子系统所有的 models。

路由设计

子系统以组件的形式加载到基座系统中,所以路由是入口,也是整个设计的第一步,为了区分基座系统页面和子系统页面,在路由上约定了下面这种形式:

// 子系统路由匹配,伪代码function Layout(layoutProps) {  useEffect(() => {      const apps = getIncludeSubAppMap();      // 按需加载子项目;      apps.forEach(subKey => startAsyncSubapp(subKey));  }, []);  return (    <HLayout {...props}>      <Switch>          {/* 企业用户管理 */}          <Route exact path={Paths.PRODUCT_WHITEBAR} component={pages.ProductManage} breadcrumbName="企业用户管理" />          {/* ...省略一百行 */}          <Route path="/subPage/" component={pages.AsyncComponent} />      </Switch>    </HLayout>}

即只要以 subPage 路径开头,就默认这个路由对应的组件为子项目,从而通过 AsyncComponent 组件去异步获取子项目组件。

异步加载组件设计

路由设计完了,然后异步加载组件就是这个方案的灵魂了,流程是这样的:

  • 通过路由,匹配到要访问的具体是那个子项目;
  • 通过子项目 id,获取对应的 manifest.json 文件;
  • 通过获取 manifest.json,识别到对应的静态资源(js,css)
  • 加载静态资源,加载完,子项目执行注册
  • 动态加载 model,更新子项目组件

直接上代码吧,简单明了,资源加载的逻辑后面再详讲, 需要注意的是 model 和 component 的加载顺序 :

export default function AsyncComponent({ location }) {  // 子工程资源是否加载完成  const [ayncLoading, setAyncLoaded] = useState(true);  // 子工程组件加载存取  const [ayncComponent, setAyncComponent] = useState(null);  const { pathname } = location;  // 取路径中标识子工程前缀的部分, 例如 '/subPage/xxx/home' 其中 xxx 即子系统路由标识  const id = pathname.split('/')[2];  useEffect(() => {    if (!subAppMapInfo[id]) {      // 不存在这个子系统,直接重定向到首页去      goBackToIndex();    }    const status = subAppRegisterStatus[id];    if (status !== 'finish') {      // 加载子项目      loadAsyncSubapp(id).then(({ routes, models }) => {        loadModule(id, models);        setAyncComponent(routes);        setAyncLoaded(false);        // 已经加载过的,做个标记        subAppRegisterStatus[id] = 'finish';      }).catch((error = {}) => {        // 如果加载失败,显示错误信息        setAyncLoaded(false);        setAyncComponent(          <div style={{            margin: '100px auto',            textAlign: 'center',            color: 'red',            fontSize: '20px'          }}          >            {error.message || '加载失败'}          </div>);      });    } else {      const models = subappModels[id];      loadModule(id, models);      // 如果能匹配上前缀则加载相应子工程模块      setAyncLoaded(false);      setAyncComponent(subappRoutes[id]);    }  }, [id]);  return (    <Spin spinning={ayncLoading} style={{ width: '100%', minHeight: '100%' }}>      {ayncComponent}    </Spin>  );}

子项目设计

子项目以静态资源的形式在基座项目中加载,需要暴露出子系统自己的全部页面组件和数据 model;然后在打包构建上和以前也稍许不同,需要多生成一个 manifest.json 来搜集子项目的静态资源信息。

子项目暴露出自己自愿的代码长这样:

// 子项目资源输出代码import routes from './layouts';const models = {};function importAll(r) {  r.keys().forEach(key => models[key] = r(key).default);}// 搜集所有页面的 modelimportAll(require.context('./pages', true, /model.js$/));function registerApp(dep) {  return {    routes, // 子工程路由组件    models, // 子工程数据模型集合  };}// 数组第一个参数为子项目 id,第二个参数为子项目模块获取函数(window["registerApp"] = window["registerApp"] || []).push(['collection', registerApp]);

子项目页面组件搜集:

import menus from 'configs/menus';import { Switch, Redirect, Route } from 'react-router-dom';import pages from 'pages';function flattenMenu(menus) {  const result = [];  menus.forEach((menu) => {    if (menu.children) {      result.push(...flattenMenu(menu.children));    } else {      menu.Component = pages[menu.component];      result.push(menu);    }  });  return result;}// 子项目自己路径分别 + /subpage/xxx const prefixRoutes = flattenMenu(menus);export default (  <Switch>    {prefixRoutes.map(child =>      <Route        exact        key={child.key}        path={child.path}        component={child.Component}        breadcrumbName={child.title}      />    )}    <Redirect to="/home" />  </Switch>);

静态资源加载逻辑设计

开始做方案时,只是设计出按需加载的交互体验:即当业务切换到子项目路径时,开始加载子项目的资源,然后渲染页面。但后面感觉这种改动影响了业务体验,他们以前只需要加载数据时 loading,现在还需要承受子项目加载 loading。所以为了让业务尽量小的感知系统的重构,将 按需加载 换成了 按需提前加载 。简单点说,就是当业务登录时,我们会去遍历他的所有权限菜单,获取他拥有那些子项目的访问权限,然后提前加载这些资源。

遍历菜单,提前加载子项目资源:

// 本地开发环境不提前按需加载if (getDeployEnv() !== 'local') {  const apps = getIncludeAppMap();  // 按需提前加载子项目资源;  apps.forEach(subKey => startAsyncSubapp(subKey));}

然后就是 show 代码的时候了,思路参考 webpackJsonp ,就是通过拦截一个全局数组的 push 操作,得知子项目已加载完成:

import { subAppMapInfo } from './menus';// 子项目静态资源映射表存放:/** * 状态定义: * '': 还未加载 * ‘start’:静态资源映射表已存在; * ‘map’:静态资源映射表已存在; * 'init': 静态资源已加载; * 'wait': 资源加载已完成, 待注入; * 'finish': 模块已注入;*/export const subAppRegisterStatus = {};export const subappSourceInfo = {};// 项目加载待处理的 Promise hash 表const defferPromiseMap = {};// 项目加载待处理的错误 hash 表const errorInfoMap = {};// 加载 css,js 资源function loadSingleSource(url) {  // 此处省略了一写代码  return new Promise((resolove, reject) => {    link.onload = () => {      resolove(true);    };    link.onerror = () => {      reject(false);    };  });}// 加载 json 中包含的所有静态资源async function loadSource(json) {  const keys = Object.keys(json);  const isOk = await Promise.all(keys.map(key => loadSingleSource(json[key])));  if (!isOk || isOk.filter(res => res === true) < keys.length) {    return false;  }  return true;}// 获取子项目的 json 资源信息async function getManifestJson(subKey) {  const url = subAppMapInfo[subKey];  if (subappSourceInfo[subKey]) {    return subappSourceInfo[subKey];  }  const json = await fetch(url).then(response => response.json())    .catch(() => false);  subAppRegisterStatus[subKey] = 'map';  return json;}// 子项目提前按需加载入口export async function startAsyncSubapp(moduleName) {  subAppRegisterStatus[moduleName] = 'start'; // 开始加载  const json = await getManifestJson(moduleName);  const [, reject] = defferPromiseMap[moduleName] || [];  if (json === false) {    subAppRegisterStatus[moduleName] = 'error';    errorInfoMap[moduleName] = new Error(`模块:${moduleName}, manifest.json 加载错误`);    reject && reject(errorInfoMap[moduleName]);    return;  }  subAppRegisterStatus[moduleName] = 'map'; // json 加载完毕  const isOk = await loadSource(json);  if (isOk) {    subAppRegisterStatus[moduleName] = 'init';    return;  }  errorInfoMap[moduleName] = new Error(`模块:${moduleName}, 静态资源加载错误`);  reject && reject(errorInfoMap[moduleName]);  subAppRegisterStatus[moduleName] = 'error';}// 回调处理function checkDeps(moduleName) {  if (!defferPromiseMap[moduleName]) {    return;  }  // 存在待处理的,开始处理;  const [resolove, reject] = defferPromiseMap[moduleName];  const registerApp = subappSourceInfo[moduleName];  try {    const moduleExport = registerApp();    resolove(moduleExport);  } catch (e) {    reject(e);  } finally {    // 从待处理中清理掉    defferPromiseMap[moduleName] = null;    subAppRegisterStatus[moduleName] = 'finish';  }}// window.registerApp.push(['collection', registerApp])// 这是子项目注册的核心,灵感来源于 webpack,即对 window.registerApppush 操作进行拦截export function initSubAppLoader() {  window.registerApp = [];  const originPush = window.registerApp.push.bind(window.registerApp);  // eslint-disable-next-line no-use-before-define  window.registerApp.push = registerPushCallback;  function registerPushCallback(module = []) {    const [moduleName, register] = module;    subappSourceInfo[moduleName] = register;    originPush(module);    checkDeps(moduleName);  }}// 按需提前加载入口export function loadAsyncSubapp(moduleName) {  const subAppInfo = subAppRegisterStatus[moduleName];  // 错误处理优先  if (subAppInfo === 'error') {    const error = errorInfoMap[moduleName] || new Error(`模块:${moduleName}, 资源加载错误`);    return Promise.reject(error);  }  // 已经提前加载,等待注入  if (typeof subappSourceInfo[moduleName] === 'function') {    return Promise.resolve(subappSourceInfo[moduleName]());  }  // 还未加载的,就开始加载,已经开始加载的,直接返回  if (!subAppInfo) {    startAsyncSubapp(moduleName);  }  return new Promise((resolve, reject = (error) => { throw error; }) => {    // 加入待处理 map 中;    defferPromiseMap[moduleName] = [resolve, reject];  });}

这里需要强调一下子项目有两种加载场景:

  • 从基座页面路径进入系统, 那么就是 按需提前加载 的场景, 那么 startAsyncSubapp 先执行,提前缓存资源;
  • 从子项目页面路径进入系统, 那就是 按需加载 的场景,就存在 loadAsyncSubapp 先执行,利用 Promise 完成发布订阅。至于为什么 startAsyncSubapp 在前但后执行,是因为 useEffect 是组件挂载完成才执行;

至此,框架的大致逻辑就交代清楚了,剩下的就是优化了。

其他难点

其实不难,只是怪我太菜,但这些点确实值得记录,分享出来共勉。

公共依赖共享

我们由于基座项目与子项目技术栈一致,另外又是拆分系统,所以共享公共库依赖,优化打包是一个特别重要的点,以为就是 webpack 配个 external 就完事,但其实要复杂的多。

antd 构建

antd 3.x 就支持了 esm,即按需引入,但由于我们构建工具没有做相应升级,用了 babel-plugin-import 这个插件,所以导致了两个问题,打包冗余与无法全量导出 antd Modules。分开来讲:

  • 打包冗余,就是通过 BundleAnalyzer 插件发现,一个模块即打了 commonJs 代码,也打了 Esm 代码;
  • 无法全量导出,因为基座项目不知道子项目会具体用哪个模块,所以只能暴力的导出 Antd 所有模块,但 babel-plugin-import 这个插件有个优化,会分析引入,然后删除没用的依赖,但我们的需求和它的目的是冲突的;

结论:使用 babel-plugin-import 这个插件打包 commonJs 代码已经过时, 其存在的唯一价值就是还可以帮我们按需引入 css 代码;

项目公共组件共享

项目中公共组件的共享,我们开始尝试将常用的组件加入公司组件库来解决,但发现这个方案并不是最理想的,第一:很多组件和业务场景强相关,加入公共组件库,会造成组件库臃肿;第二:没有必要。所以我们最后还是采用了基座项目收集组件,并统一暴露:

function combineCommonComponent() { const contexts = require.context('./components/common', true, /.js$/); return contexts.keys().reduce((next, key) => {   // 合并 components/common 下的组件   const compName = key.match(/\w+(?=/index.js)/)[0];   next[compName] = contexts(key).default;   return next; }, {});}

webpackJsonp 全局变量污染

如果对 webpack 构建后的代码不熟悉,可以先看看开篇提到的那篇文章。

webpack 构建时,在开发环境 modules 是一个对象,采用文件 path 作为 module 的 key; 而正式环境,modules 是一个数组,会采用 index 作为 module 的 key。
由于我基座项目和子项目没有做沙箱隔离,即 window 被公用,所以存在 webpackJsonp 全局变量污染的情况,在开发环境,这个污染没有被暴露,因为文件 Key 是唯一的,但在打正式包时,发现 qa 环境子项目无法加载,最后一分析,发现了 window.webpackJsonp 环境变量污染的 bug。

最后解决的方案就是子项目打包都拥有自己独立的 webpackJsonp 变量,即将 webpackJsonp 重命名,写了一个简单的 webpack 插件搞定:

// 将 webpackJsonp 重命名为 webpackJsonpCollectconfig.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' }));

子项目开发热加载

基座项目为什么会成为基座,就因为他迭代少且稳定的特殊性。但开发时,由于子项目无法独立运行,所以需要依赖基座项目联调。但做一个需求,要打开两个 vscode,同时运行两个项目,对于那个开发,这都是一个不好的开发体验,所以我们希望将 dev 环境作为基座,来支持本地的开发联调,这才是最好的体验。

将 dev 环境的构建参数改成开发环境后,发现子项目能在线上基座项目运行,但 webSocket 通信一直失败,最后找到原因是 webpack-dev-sever 有个 host check 逻辑,称为主机检查,是一个安全选项,我们这里是可以确认的,所以直接注释就行。

子项目其实和一般的项目,打包没什么异样,主要三点变动:

  • 导出了 manifest.json;
  • 增加了 externals 的配置,以复用主项目的依赖,减少子项目体积;
  • 将 webpackJsonp 这个关继字重命名,避免全局变量污染;

其实上面都已经提到了,只是由于项目是公司的,自己没额外写 demo,所以没给 demo 项目