问题:(原问题
一个产品线的多个前端应用如何复用代码/基础组件?
common模块 被 module-a 、 module-b引用, 不同模块分别独立部署
提取common
参考资料:腾讯文档-速度与激情:Webpack5 & 极速编译
对于一个大型项目来说,笔者会抽取很多公共的组件来提高项目间的模块共享,
但是,这些模块之间,难免会有一些共同依赖,比如 React、ReactDOM,JQuery 之类的基础库。
这样,就容易造成一个问题,公共组件抽取后,项目体积膨胀了。
随着公共组件的增多,项目体积的膨胀变得十分可怕。
翻译一下:一股脑把代码丢到common里,然后common打包,业务再引common有什么不好?
common大而全,不能按需引用吗?
===》
在传统打包模型上,笔者摸索出了一套简单有效的方法,
对于公共组件,笔者使用 external,将这些公共部分抠出来,变成一个残疾的组件。
但是,随着组件的增多,共享组件的 Host 增多,这样的方式带来了一些问题:
Component 需要为 Host 专门打包,它不是一个可以独立运行的组件,每一个运行该 Component 的 Host 必须携带完整的运行时,否则 Component 就需要为不同的 Host 打出不同的残疾包。
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
/** package/layout/src/xxx.ts **/
import { Stream } from '@core/model'; # 业务引用core
// do something
==> 可以通过 webpack 配置中 resolve 的 alias 属性来达到相应效果:
{
resolve: {
alias: {
'@core/model': 'word/package/model/src/',
}
}
}
mono-repo:externals+umd
参考资料:探索webpack4与webpack5多项目公共代码复用架构
externals: 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。
从 CDN 引入 jQuery,而不是把它打包:
# index.html
<script
src="https://code.jquery.com/jquery-3.1.0.js"
integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
crossorigin="anonymous"
></script>
# webpack.config.js
module.exports = {
//...
externals: {
jquery: 'jQuery',
},
};
# 业务使用
import $ from 'jquery';
$('.my-element').animate(/* ... */);
externals还可以接受函数
// webpack.config.js
module.exports = {
//...
externals: [
function ({ context, request }, callback) {
if (/^yourregex$/.test(request)){
// 使用 request 路径,将一个 commonjs 模块外部化
return callback(null, 'commonjs ' + request);
}
// 继续下一步且不外部化引用
callback();
},
],
};
一般我们使用的是 umd 构建版本,它会兼容 commonjs、commonjs2、amd、window 等方案,在我们的浏览器环境中,它会绑定一个 React 变量到 window 上:
externals 的作用在于:当 webpack 进行构建时,碰到 import React from ‘react’ 与 import ReactDOM from ‘react-dom’ 导入语句时会避开 node_modules 而去 externals 配置的映射上去找,而这个映射值( ReactDOM 与 React )正是在 window 变量上找到的。
最理想的情况,我的公共部分就一个 Header 组件!
假如将它独立构建成一个 umd 包,以 externals 的形式配置,通过 import Header from ‘header’; 导入,
然后作为组件使用
实际情况:
import { PredefinedRole, PredefinedClient } from '@core/common/public/enums/base';
import { isReadOnlyUser } from '@core/common/public/moon/role';
import { setWebICON } from '@core/common/public/moon/base';
import ErrorBoundary from '@core/common/public/wrapper/errorBoundary';
import OutClick from '@core/common/public/utils/outClick';
import { combine } from '@core/common/entry/fetoolkit';
import { getExtremePoint } from '@core/common/public/utils/map';
import { cookie } from '@core/common/public/utils/storage';
import Header from '@common/containers/header/header';
import { ICommonStoreContainer } from '@common/interface/store';
import { cutTextForSelect } from '@common/public/moon/format';
import { withAuthority } from '@common/hoc';
......
而 externals 上面的配置方式只支持转换下面这种情况,它只是完全匹配了模块名:
import React from 'react'; => 'react': 'React' => e.exports = React;
import ReactDom from 'react-dom'; => 'react-dom': 'ReactDOM' => e.exports = ReactDOM;
所以问题转换为了,如何将众多的’@common/public/moon/format’ 转换为externals
难道一个个写?
而’@common/public/moon/format’可能在配了alias的情况下,会变为’@format’ 又怎么办?
所幸,我们externals还接受函数!
// webpack.config.js
module.exports = {
//...
externals: [
// function ({ context, request }, callback)
// request就是 import { withAuthority } from '@common/hoc'的 '@common/hoc'
function ({ context, request }, callback) {
if (/^yourregex$/.test(request)){
// 使用 request 路径,将一个 commonjs 模块外部化
return callback(null, 'commonjs ' + request);
}
// 继续下一步且不外部化引用
callback();
},
],
};
函数的功能在于:可以自由控制任何 import 语句!
request可以拿到所有的 import from ‘xxxxx’;的xxx引用
// isDevelopment 本地开发时不管,还是在nodemodules上拿
function(context, request, callback) {
if (/^@common\/?.*$/.test(request) && !isDevelopment) {
return callback(
null,
// replace(/\//g, '.') 将引用路径中的'/'替换为'.'
request.replace(/@common/, '$common').replace(/\//g, '.')
);
}
if (/^@moon$/.test(request) && !isDevelopment) {
return callback(null, '$common.Moon');
}
if (/^@http$/.test(request) && !isDevelopment) { // 针对alias的进行补全
return callback(null, '$common.utils.http');
}
callback();
}
// callback表示 externals去window上找模块时
// 比如 $common.Moon,它就会去找 window.$common.Moon 。
由于很多引入common的是这样的
import $http, { Api } from '@http';
import Header from '@common/containers/header/header';
import { CommonStore } from '@common/store';
import { timeout } from '@packages/@core/common/public/moon/base';
import * as Enums2 from '@common/public/enums/enum';
import { Localstorage } from '@common/utils/storage';
那么就需要挂在window上的模块的数据结构是
{
public: {enums:{enum:Module}},
containers: {header: {header: {}}}
....
}
下一步要做的:构建这种层级结构的 $common 对象
如何构建?编译入口导出一个相应结构的对象即可!
// webpack.config.js
output: {
filename: "public.js",
chunkFilename: 'app/public/chunks/[name].[chunkhash:8].js',
libraryTarget: 'window', # output还可以执行这些字段。。
library: '$common', # library名字
libraryExport: "default",
},
entry: "../packages/@core/common/entry/index.tsx",
# entry
import * as baseEnum from '../public/enums/base';
export default {
public: {
enums: {
base: baseEnum,
// ....
},
}
};
一旦新增了公共文件给其它项目使用,就必须维护进这个文件
引用:子页面应用引用的是打包后的 public.js
总结一下:
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、微前端的方式
由于微前端还要考虑样式冲突、生命周期管理,所以本文只聚焦在资源加载方式上。微前端一般有两种打包方式:
- 子应用独立打包,模块更解耦,但无法抽取公共依赖等。
- 整体应用一起打包,很好解决上面的问题,但打包速度实在是太慢了,不具备水平扩展能力。
UMD 方式共享模块
真正 Runtime 的方式可能是 UMD 方式共享代码模块,即将模块用 Webpack UMD 模式打包,并输出到其他项目中。这是非常普遍的模块共享方式:
对于项目 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)的模式。
参考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函数
探究了源码之后,得到的知识是:webpack如何解决 静态的chunk 和 动态的chunk之间的模块管理问题
动态的chunk里引用了common模块,此时的common会被加载两次吗?
不会,webpack的解决方式是jsonp加载了异步chunk后,会把自身引入的模块 添加进mainchunk里维护的module里,这样解决了重复打包的问题
那这样的解决思路,会给腾讯文档现有的困境带来什么思路启发呢?
现在是业务excel、common两个仓库,所以webpack两个打包,会分别生成
两个mianchunk和各自的异步chunk
再确定下我们一直在追踪要解决的问题哈:
那么到这里,核心问题就变成了品类代码和SL如何做到代码共享。对于其他项目来说,其实也就是多应用如何做到代码共享。
react的引用,可以靠识别,手动维护成external挂在window的方式,解决重复引用的问题;
但是这样的方案不适合 不适合挂在window上的代码,
公共模块不能都external上,一股脑的给业务用(业务用的时候不用打包了)——还是业务是按需的
所以想要的是,代码共享。
即
1、 common打包了,业务会自动更新(cdn机制可以满足)
在各个品类页,引入我们提供的加载脚本,内部会自动去加载文件,获取SL每个模块的js文件的CDN地址并且加载。
2、业务更新的时候,访问的都是最新的common代码
3、资源不会重复打包(common和业务都打包了react),也不会多余打包(common的redux,业务可能不需要)
知道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多联邦
这个方案是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。
让应用具备模块化输出能力,其实开辟了一种新的应用形态,即 “中心应用”,
这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用:
所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,更好的集成到主应用中
总结
一个很有意思的总结
share library:npm、cdn;
share module: umd, commonjs,amd; component;
share package: npm;
share bundle: dll?
share subapp: iframe、微前端、federationPlugin?
webpack5联邦提出后,一个评论
前端是个很奇怪的领域,如果当初大家能多关注下fis及map的机制,或者rails的一些东西,可能有些问题早就可以得到很好的解决,可惜了国内的一些框架。
越来越佩服公司的大牛同事了,这就是云组件的概念啊!