问题:(原问题
一个产品线的多个前端应用如何复用代码/基础组件?
common模块 被 module-a 、 module-b引用, 不同模块分别独立部署

提取common

参考资料:腾讯文档-速度与激情:Webpack5 & 极速编译

对于一个大型项目来说,笔者会抽取很多公共的组件来提高项目间的模块共享
但是,这些模块之间,难免会有一些共同依赖,比如 React、ReactDOM,JQuery 之类的基础库。
这样,就容易造成一个问题,公共组件抽取后,项目体积膨胀了。
随着公共组件的增多,项目体积的膨胀变得十分可怕。

翻译一下:一股脑把代码丢到common里,然后common打包,业务再引common有什么不好?
common大而全,不能按需引用吗?

===》
在传统打包模型上,笔者摸索出了一套简单有效的方法,
对于公共组件,笔者使用 external,将这些公共部分抠出来,变成一个残疾的组件。

image.png

但是,随着组件的增多,共享组件的 Host 增多,这样的方式带来了一些问题:

  1. Component 需要为 Host 专门打包,它不是一个可以独立运行的组件,每一个运行该 Component 的 Host 必须携带完整的运行时,否则 Component 就需要为不同的 Host 打出不同的残疾包。

  2. Component 与 Component 之间如果存在较大的共享模块,无法通过 external 解决。

方案:
一个模块对应一个仓库,common也单独为一个仓库
一、multi-repo,就是一个应用一个git仓库。这时候要想复用基础组件:
1. 直接复制黏贴。显然行不通,有任何的bug修复就要手动同步
2. npm包。看起来只要npm重装最新的包就好了,但是怎么把包更新推送给下游应用,而且这些更新对数量众多的下游应用难道不会有影响吗,版本管理是一个大问题

multi-repo:npm包

正常的代码共享需要将依赖作为 Lib 安装到项目,进行 Webpack 打包构建再上线
(放到nodemodules里
对于项目 Home 与 Search,需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中。

这种方式是:引入包 + 本地编译打包进本地的bundle里

mono-repo:lerna

所有模块放在一个仓库
二、 mono-repo,把所有前端应用都堆在一个git仓库里。现如今用的是yarn workspace和lerna。要复用一个包我直接link安装就好了,因为都在一个仓库里,所有更新改动也能得到及时的反馈。但是这给构建和部署带来了巨大的挑战:
难点1.common的更新如何做到定向更新依赖他的下游模块,而不是全部更新 —lerna可以解决,依据拓扑排序按顺序构建
有一个公共包c,a依赖了它但是b没有,假设c更新了,那如何只对a做构建、测试、部署而不影响到b
难点2.不同的业务模块可能对应不同的技术栈,放在一个仓库里是否合适
不同子应用的技术选型要因地制宜。a应用可能是一个帮助文档手册类型,觉得静态站适合用了gatsby;b应用可能是一个论坛,用ssr比较好想用nextjs。放在一起总感觉不太纯粹

下面是回答给出的一些解决方案:
1、字节大佬: monorepo的思路 是对的 可以继续往下探索

Monorepo 可以一定程度解决重复安装和修改困难的问题,但依然需要走本地编译

lerna 将一个仓库变成了结构上的多个仓库,如果按照默认的使用方式,每个仓库都会有自己的编译配置,单个项目的编译变成了多个项目的联编联调,修改配置和增量优化都会变得比较困难。

使用 lerna 的目的是使各个子包相对独立,但是在整个项目的编译调试中,往往需要的是所有包的集合

笔者就可以忽略掉这个子包间的物理隔离,把子仓库作为子目录来看待
所以,如何解决子包间的引用问题(不依赖lerna

  1. /** package/layout/src/xxx.ts **/
  2. import { Stream } from '@core/model'; # 业务引用core
  3. // do something

==> 可以通过 webpack 配置中 resolve 的 alias 属性来达到相应效果:

  1. {
  2. resolve: {
  3. alias: {
  4. '@core/model': 'word/package/model/src/',
  5. }
  6. }
  7. }

mono-repo:externals+umd

参考资料:探索webpack4与webpack5多项目公共代码复用架构

externals: 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。

从 CDN 引入 jQuery,而不是把它打包:

  1. # index.html
  2. <script
  3. src="https://code.jquery.com/jquery-3.1.0.js"
  4. integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
  5. crossorigin="anonymous"
  6. ></script>
  7. # webpack.config.js
  8. module.exports = {
  9. //...
  10. externals: {
  11. jquery: 'jQuery',
  12. },
  13. };
  14. # 业务使用
  15. import $ from 'jquery';
  16. $('.my-element').animate(/* ... */);

externals还可以接受函数

  1. // webpack.config.js
  2. module.exports = {
  3. //...
  4. externals: [
  5. function ({ context, request }, callback) {
  6. if (/^yourregex$/.test(request)){
  7. // 使用 request 路径,将一个 commonjs 模块外部化
  8. return callback(null, 'commonjs ' + request);
  9. }
  10. // 继续下一步且不外部化引用
  11. callback();
  12. },
  13. ],
  14. };

一般我们使用的是 umd 构建版本,它会兼容 commonjs、commonjs2、amd、window 等方案,在我们的浏览器环境中,它会绑定一个 React 变量到 window 上:

image.png

externals 的作用在于:当 webpack 进行构建时,碰到 import React from ‘react’ 与 import ReactDOM from ‘react-dom’ 导入语句时会避开 node_modules 而去 externals 配置的映射上去找,而这个映射值( ReactDOM 与 React )正是在 window 变量上找到的。

最理想的情况,我的公共部分就一个 Header 组件!
假如将它独立构建成一个 umd 包,以 externals 的形式配置,通过 import Header from ‘header’; 导入,
然后作为组件使用

实际情况:

  1. import { PredefinedRole, PredefinedClient } from '@core/common/public/enums/base';
  2. import { isReadOnlyUser } from '@core/common/public/moon/role';
  3. import { setWebICON } from '@core/common/public/moon/base';
  4. import ErrorBoundary from '@core/common/public/wrapper/errorBoundary';
  5. import OutClick from '@core/common/public/utils/outClick';
  6. import { combine } from '@core/common/entry/fetoolkit';
  7. import { getExtremePoint } from '@core/common/public/utils/map';
  8. import { cookie } from '@core/common/public/utils/storage';
  9. import Header from '@common/containers/header/header';
  10. import { ICommonStoreContainer } from '@common/interface/store';
  11. import { cutTextForSelect } from '@common/public/moon/format';
  12. import { withAuthority } from '@common/hoc';
  13. ......

而 externals 上面的配置方式只支持转换下面这种情况,它只是完全匹配了模块名:

  1. import React from 'react'; => 'react': 'React' => e.exports = React;
  2. import ReactDom from 'react-dom'; => 'react-dom': 'ReactDOM' => e.exports = ReactDOM;

所以问题转换为了,如何将众多的’@common/public/moon/format’ 转换为externals
难道一个个写?
而’@common/public/moon/format’可能在配了alias的情况下,会变为’@format’ 又怎么办?
所幸,我们externals还接受函数!
image.png

  1. // webpack.config.js
  2. module.exports = {
  3. //...
  4. externals: [
  5. // function ({ context, request }, callback)
  6. // request就是 import { withAuthority } from '@common/hoc'的 '@common/hoc'
  7. function ({ context, request }, callback) {
  8. if (/^yourregex$/.test(request)){
  9. // 使用 request 路径,将一个 commonjs 模块外部化
  10. return callback(null, 'commonjs ' + request);
  11. }
  12. // 继续下一步且不外部化引用
  13. callback();
  14. },
  15. ],
  16. };

函数的功能在于:可以自由控制任何 import 语句!
request可以拿到所有的 import from ‘xxxxx’;的xxx引用

  1. // isDevelopment 本地开发时不管,还是在nodemodules上拿
  2. function(context, request, callback) {
  3. if (/^@common\/?.*$/.test(request) && !isDevelopment) {
  4. return callback(
  5. null,
  6. // replace(/\//g, '.') 将引用路径中的'/'替换为'.'
  7. request.replace(/@common/, '$common').replace(/\//g, '.')
  8. );
  9. }
  10. if (/^@moon$/.test(request) && !isDevelopment) {
  11. return callback(null, '$common.Moon');
  12. }
  13. if (/^@http$/.test(request) && !isDevelopment) { // 针对alias的进行补全
  14. return callback(null, '$common.utils.http');
  15. }
  16. callback();
  17. }
  18. // callback表示 externals去window上找模块时
  19. // 比如 $common.Moon,它就会去找 window.$common.Moon 。

由于很多引入common的是这样的

  1. import $http, { Api } from '@http';
  2. import Header from '@common/containers/header/header';
  3. import { CommonStore } from '@common/store';
  4. import { timeout } from '@packages/@core/common/public/moon/base';
  5. import * as Enums2 from '@common/public/enums/enum';
  6. import { Localstorage } from '@common/utils/storage';
  7. 那么就需要挂在window上的模块的数据结构是
  8. {
  9. public: {enums:{enum:Module}},
  10. containers: {header: {header: {}}}
  11. ....
  12. }

下一步要做的:构建这种层级结构的 $common 对象
如何构建?编译入口导出一个相应结构的对象即可!

  1. // webpack.config.js
  2. output: {
  3. filename: "public.js",
  4. chunkFilename: 'app/public/chunks/[name].[chunkhash:8].js',
  5. libraryTarget: 'window', # output还可以执行这些字段。。
  6. library: '$common', # library名字
  7. libraryExport: "default",
  8. },
  9. entry: "../packages/@core/common/entry/index.tsx",
  1. # entry
  2. import * as baseEnum from '../public/enums/base';
  3. export default {
  4. public: {
  5. enums: {
  6. base: baseEnum,
  7. // ....
  8. },
  9. }
  10. };

一旦新增了公共文件给其它项目使用,就必须维护进这个文件

引用:子页面应用引用的是打包后的 public.js
image.png

总结一下:
1、mono仓库,common在一个文件下,有单独的webpack.config.js
每当common有更新,会重新打包,打包生成的文件是作为基础js, 作为externals挂在window上,
每个子应用都引入了这些必备的js文件

当common更新,子不用打包,因为是动态的js外联

externals的作用就是支持了模块化
即 import jq from jq
而不是 jq.xxx 直接在window上用

本质上是和 cdn没区别的,只不过cdn在外链的基础上 加了 cdn,所以还需要考虑缓存的问题

所以cdn的方案,归根结底就是两个解决难点:
1、cdn的缓存:common更新了代码,cdn上马上更新,cdn更新了之后 业务代码页面加载出的就是cdn上最新的;
解决方案:
way1、后端代理,如nginx配置 拉最新的cdn;业务代码引用的是jq.js,cdn上的:jq.[hash].js
针对
way2、cdn强制刷新,可视化界面可以操作;当点击之后,会将cdn全部变为失效;

2、全量打包进sdk了,但是引入还是按需?
貌似不行,因为都是得挂在window上
但是为了解决 业务代码上的不改动,如 import { Localstorage } from ‘@common/utils/storage’;
Localstorage模块对应的是window.common.utils.storage
但是不能改业务代码吧?

所以加了externals的配置,为了这样的引用能找到这样的代码模块,所以又构建了数据结构

反问,这样的折腾一番,和单独抽离common,常规做法的:
将公共部分作为组件直接构建进每个页面级项目
有什么优越性?
常规的弊端:
1、构建冗余,每个页面级项目构建时都会将其打包进去,无端浪费加载带宽
比如dialog(32kb),每个仓库都新增(32kb),
2、如果公共部分做了修改,此时所有引用它的项目全部要重新构建发版!

那fis的非主流打包框架是怎么做的?
common只打包一次,但是,得是运行时找资源拼接+载入

CDN方案

2、阿里大佬:
取决于公共基础库的粒度有多大
if 粒度小: 考虑将组件打包到同一个.js文件,并发送到带有版本号的cdn地址上,应用程序可以选择远程异步加载组件,并且可以通过配置,精确地控制每个应用的版本号;
else if 粒度较大但又和业务解耦,可以参考1的方式,区别在于,可以考虑把组件打包成单独的.js文件;
else 粒度较大且和业务耦合,可以将组件视为一个子应用,
通过微前端(可参考qiankun)的方式,将其打包成一个bundle,通过生命周期决定mount到哪个节点上,控制entry入口文件的版本即可。

总的来说,子应用的版本管理绝大多数是基于配置的,在这点上也可以考虑和动态配置中心打通。
以上的建议在做spa的时候较为容易,做ssr时可能还要考虑整个页面的拼接。

总结下就是:
way1、将common的文件每次更新后存到cdn上,业务侧通过异步加载的方式引入
版本的控制: common的每次产出放在cdn上的都对应版本号,而业务侧通过配置可以控制引入的版本号

微前端

else 粒度较大且和业务耦合,可以将组件视为一个子应用,
通过微前端(可参考qiankun)的方式,将其打包成一个bundle,通过生命周期决定mount到哪个节点上,控制entry入口文件的版本即可。
way2、微前端的方式

image.png
由于微前端还要考虑样式冲突、生命周期管理,所以本文只聚焦在资源加载方式上。微前端一般有两种打包方式:

  1. 子应用独立打包,模块更解耦,但无法抽取公共依赖等。
  2. 整体应用一起打包,很好解决上面的问题,但打包速度实在是太慢了,不具备水平扩展能力。

UMD 方式共享模块

真正 Runtime 的方式可能是 UMD 方式共享代码模块,即将模块用 Webpack UMD 模式打包,并输出到其他项目中。这是非常普遍的模块共享方式:
image.pngimage.png

对于项目 Home 与 Search,直接利用 UMD 包复用一个模块。

TODO: 学习一下

怪不得之前配excel打包有那么多打包方式的选择。。。
应该是属于模块化知识点

但这种技术方案问题也很明显,就是包体积无法达到本地编译时的优化效果,且库之间容易冲突:
不能把没用到的代码在构建过程中删掉,因为打包 UMD 的时候没有分析依赖,即不能利用tree shaking

mono-repo:资源映射表

3、厂内贴吧的解决方式
mutil-repo
更新是靠的 后端资源索引
common每次更新 产出 资源映射表 源码->产出文件
而业务代码的引用每次是 引用的 common的源码,所以依靠后端解析出 业务侧引入的common的源码资源对应的最新的build文件

难点:1、webpack 插件 支持 资源分析 并产出约定格式的 资源映射表
解析特定的语法 import ‘common:xxx/‘ 那就是 common的文件
2、后端解析前端build.html里 的 common引用, 负责对着映射表 找出common最新的build文件,并做一个替换。然后吐出完成common资源替换的html文件
3、资源加载顺序的控制
common资源加载完之后 才能业务代码执行


multi-repo:腾讯文档SL

腾讯文档前解决方案:
为了能在不支持ES6代码的环境下快速引入React来加速需求开发
我们想出了一个所谓的Script-Loader(下面会简称SL)的模式。

image.png

参考jquery的引入方式,我们用另外一个项目去实现这些功能:代码打包成ES5代码,对外提供很多接口;
然后在各个品类页,引入我们提供的加载脚本内部会自动去加载文件,获取每个模块的js文件的CDN地址并且加载。
猜想:

内部使用的时候 require(‘jq’); 实际上会有一个机制 实现 require(‘xxxxx.cdn.jq.js’);

在这种模式下,每次发布,我们只需要去发布各个改动的模块以及最新的配置文件,其他品类就能获得自动更新。

这种模式下的问题:品类代码和SL的代码共享问题 即 重复引用的问题
Excel品类改造后使用了React;SL的模块A、模块B、模块C也各自引入了React
那么在加载excel的时候,其实会加载4份react;

external不是万能的解决方案,因为不是common所有组件都适合挂在window上;(react可以)

基于这些问题,我们目前的选择是一种折中方案:
我们把可以配置全局环境的包提取出来每个模块指明依赖,(模块A指明依赖React、Redux等)
然后在SL内部,加载模块代码之前会去检测依赖依赖加载完成才会加载执行实际模块代码

(大概意思就是 加载模块A本身之前要加载的依赖,会有个依赖集合,避免重复)

问题:需要手动去维护这样的依赖
每个共享包实际上你都是需要单独打包成一个CDN文件,
为的是当依赖检测失败的时候,可以有一个兜底加载文件
因此,实际上目前也只有React包做了这个共享。

所以问题转变为了:(或者说是问题清晰本质确定了)
核心问题就变成了品类代码和SL如何做到代码共享。对于其他项目来说,其实也就是多应用如何做到代码共享。

其实总结下上面的思路
都是在打包层面
想的是
way1:此处引用了common:xx
1、打包阶段时候,找到目标包,打包产出
2、打包之后,根据common关键字,找到之后,替换
3、common打包之后,他就在那里,业务代码自己去找,cdn方案

而webpack5是不是在runtime,动态加载的解决思路?

而腾讯文档下一步的思路是:
从webpack入手,去实现这样的一个插件帮我们解决这个问题。核心思路就是hook webpack的内部require函数

image.png
探究了源码之后,得到的知识是:webpack如何解决 静态的chunk 和 动态的chunk之间的模块管理问题
动态的chunk里引用了common模块,此时的common会被加载两次吗?

不会,webpack的解决方式是jsonp加载了异步chunk后,会把自身引入的模块 添加进mainchunk里维护的module里,这样解决了重复打包的问题

那这样的解决思路,会给腾讯文档现有的困境带来什么思路启发呢?

image.png
现在是业务excel、common两个仓库,所以webpack两个打包,会分别生成
两个mianchunk和各自的异步chunk
再确定下我们一直在追踪要解决的问题哈:

那么到这里,核心问题就变成了品类代码和SL如何做到代码共享。对于其他项目来说,其实也就是多应用如何做到代码共享。

react的引用,可以靠识别,手动维护成external挂在window的方式,解决重复引用的问题;
但是这样的方案不适合 不适合挂在window上的代码,
公共模块不能都external上,一股脑的给业务用(业务用的时候不用打包了)——还是业务是按需的
所以想要的是,代码共享。

1、 common打包了,业务会自动更新(cdn机制可以满足)

在各个品类页,引入我们提供的加载脚本内部会自动去加载文件,获取SL每个模块的js文件的CDN地址并且加载。

2、业务更新的时候,访问的都是最新的common代码
3、资源不会重复打包(common和业务都打包了react),也不会多余打包(common的redux,业务可能不需要)

image.png
知道webpack的打包原理 + 现有困境诉求,研究到这一步了,腾讯文档是怎么想的?

现在是业务excel、common两个仓库,所以webpack两个打包,会分别生成
两个mianchunk和各自的异步chunk

那问题的核心就是如何打通两个mainChunk的modules?

在webpack的框架限制下面,如何快速的实现这个,我们也一直在思考方案,目前想到的方案如下:

SL模块内部的webpack_require被我们hack,每次在modules里面找不到的时候,我们去Excel的modules里面去找,这样需要把Excel的modules作为全局变量

SL维护的是少量模块,excel看做是动态加载的bundle;
当SL依赖了动态载入的excel的某模块时,去excel里找到之后 将其模块添加到自己的模块里;

但是对于Excel不存在的模块我们需要怎么处理?

这种很明显就是运行时环境,我们需要做好加载时的失败降级处理,但是这样就会遇到同步转异步的问题,本来你是同步引入一个模块的,但是如果它在Excel的modules不存在的时候,你就需要先一步加载这个module对应的chunk,变成了类似动态加载,但是你的代码还是同步的,这样就会有问题。

SL模块加载时:
需要将依赖前置,也就是说在加载SL模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在则依次去加载,所有依赖就位后才开始执行自己。

说到底就是控制 先common 再业务模块的依赖问题嘛


mono-repo:webpack5多联邦

image.png

这个方案是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力

让应用具备模块化输出能力,其实开辟了一种新的应用形态,即 “中心应用”
这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用:

所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,更好的集成到主应用中

总结

一个很有意思的总结

  1. share librarynpmcdn;
  2. share module: umd, commonjs,amd; component;
  3. share package: npm;
  4. share bundle: dll?
  5. share subapp: iframe、微前端、federationPlugin

webpack5联邦提出后,一个评论

前端是个很奇怪的领域,如果当初大家能多关注下fis及map的机制,或者rails的一些东西,可能有些问题早就可以得到很好的解决,可惜了国内的一些框架。

越来越佩服公司的大牛同事了,这就是云组件的概念啊!