Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
closertb opened this issue Jun 1, 2020 · 2 comments
Open

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

closertb opened this issue Jun 1, 2020 · 2 comments
Labels
FE Engineering 前端工程化相关 JS 原生JS相关

Comments

@closertb
Copy link
Owner

closertb commented Jun 1, 2020

前言

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

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

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

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

巨婴系统技术栈: Dva + Antd

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

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

方案设计

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

20200528165839

基座系统除了提供基座功能,即系统的登录、权限获取、子系统的加载、公共组件共享、公共库的共享,还提供了一个基本所有业务人员都会使用的业务功能:用户授(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);
}

// 搜集所有页面的model
importAll(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.registerApp的push操作进行拦截
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 重命名为 webpackJsonpCollect
config.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' }));

子项目开发热加载

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

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

总结

这篇文章,本身就是个总结。如果有什么疑惑,欢迎一起讨论。

@closertb closertb added FE Engineering 前端工程化相关 JS 原生JS相关 labels Jun 9, 2020
@tonystarkguo
Copy link

子项目如何打包可否分享下?

@closertb
Copy link
Owner Author

子项目如何打包可否分享下?

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
FE Engineering 前端工程化相关 JS 原生JS相关
Projects
None yet
Development

No branches or pull requests

2 participants