前言
在开发过程中项目B要引用项目A中的组件,并在后面的迭代中保持一致。这个时候你能想到方式有哪些?这些方式的优劣之处在哪里?作为技术选型,你会采取哪一种,为什么采用这种方式?
面对灵魂三问,如果你无法回答。那么你可以细细读下下面介绍的几种模块共享的方式,根据自己项目的实际情况出发,选择最合适的方式。
npm或者git仓库
首先我们想到的肯定是npm或git仓库的方式。这种方式管理的资源一般不会经常性变动,否则重新打包发布,再在项目中拉去资源调试起来是一件很痛苦的事。因此这种资源都实现的是一些抽象性
的功能,例如组件库等。偏业务性的功能无法适用,存在一定局限性,这里就不做展开性讨论。
git submodules
git submodules字面理解就是子模块的意思。我们把需要共享的资源放在子仓库中,主项目通过拉取子仓库的代码,从而达到资源共享(主项目保存的是子模块的索引
)
如何使用git submodules?
- 添加子模块
# 直接clone
git submodule add <子项目地址>
# 指定文件目录
git submodule add <子项目地址> <目录地址>
添加成功后我们会看到子模块的目录,还有.gitmodules
的文件,使用 cat
或 vim
查看内容发现里面存放有子模块的信息:
[submodule "common"]
path = common
url = https://xxx.git
这时候我们的主项目结构
@后面是一串hash,它相当与是子仓库的版本,每次我们修改并提交submodule后,hash就会发生变化。最后,提交添加的子模块到主目录。
-
更新子模块
在主目录下执行:
git submodule update --remote common
-
删除子模块
当我们要删除子模块时在主目录下执行:
git submodule deinit common
git rm common
执行 git submodule deinit common
命令的实际效果,是自动在 .git/config
中删除了以下内容:
[submodule "common"]
url = https://xxx.git
执行 git rm common
的效果,是移除了 common
文件夹,并自动在 .gitmodules
中删除了以下内容:
[submodule "common"]
path = common
url = https://xxx.git
最后将结果提交
-
clone含子模块的项目
当你第一次clone含submodule的项目时,你需要执行:
git clone xxxxx.git --recurse-submodules
--recursive
相当与递归调用。
除此之外另一种方式也是可行的
git submodule init
git submodule update
部署 git submodules
在jenkins
上部署git submodules时
相对于npm或者git仓库,submodule的调试起来更加方便,但是仍然会有依赖包的冗余 。
monorepo
什么是monorepo?
monorepo全称是monolithic repository,其对应的是multirepo(git submodules是其中的典型)。monorepo是将所有项目放在一个仓库维护,这样公共模块可以提升至根级从而直接达到物理上的共享。
monorepo相较于git submodules最大的优势在于统一了工作流,去除了冗余的node_module等。
怎么使用monorepo?
一般主流采用的都是yarn
的workspace
和lerna
来管理monorepo。
Lerna
是一个管理多个 npm 模块的工具,是 Babel 自己用来维护自己的 Monorepo 并开源出的一个项目。优化维护多包的工作流,解决多个包互相依赖,且发布需要手动维护多个包的问题。
- 首先全局安装
lerna
:
//安装lerna
npm i -g lerna
//这里有两种管理模式:Fixed/Locked mode (default),Independent mode
//fixed模式是默认模式,在该模式下所有的 packages 都会遵循一个版本号,该版本号维护在 lerna.json 的 version 字段,这种模式的问题在于:当有一个 major //变更的时候,所有 packages 都会都会有一个新的 major 版本
//Independent mode:可以单独发版,更灵活
lerna init --independent
- 修改package.json中的配置
//可以采用统配符,
{
...
private:true;
workspaces:["packages/*"]
}
- 安装依赖
1.yarn workspaces add package:给所有应用都安装依赖
2.yarn workspace project add package:给某个应用安装依赖
3.yarn add -W -D package:给根应用安装依赖
- 清除node_modules
lerna clean # 清理所有的node_modules
- 删除依赖(对应上面的安装依赖)
yarn workspace packageB remove packageA
yarn workspaces remove lodash
yarn remove -W -D typescript
- 项目构建
//简单构建项目
yarn workspace project build
//构建项目之间存在相互依赖,yarn的workspace未实现这种拓扑排列规则,幸运的是lerna支持按照拓扑排序规则执行命令
lerna run --stream --sort build
- 项目发布
lerna publish
基本上不是太复杂的项目,以上的命令就足够满足开发了。
采用monorepo尽管有很多收益,但所面临的问题也很突出:
- 统一构建工具所带来更高的要求。
- 仓库体积过大,维护成本也高。
想要了解更多相关知识:
联邦模块(Module Federation)
什么是联邦模块?
我们在webpack5官网里可以看到,里面并没有直接定义,而是讲了创造它的动机:
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。
这通常被称作微前端,但并不仅限于此。
这里我们可以看出联邦模块就是为了解决多个应用之间代码共享的问题。它是通过webpack原生提供ModuleFederationPlugin
插件来实现的,从指定的远程应用中动态加载对应的资源。
如何使用联邦模块?
这里有一个核心概念要记住:一个应用可能是host(消费其他 remote),也可以是remote(供其他应用消费),也可能两者皆是。于是应用与应用之间形成了一个去中心化
的网络集群共享资源。
如何区分host还是remote:
- host应用要配置remote列表和shared模块。
- remote应用需要配置提供的模块(exposes)。
一个完整的配置如下:
new ModuleFederationPlugin({
//应用的别名
name: 'app1',
//入口文件名, remote 应用供 host 应用消费时,remote 应用提供的远程文件的文件名。
filename: 'remote-entry.js',
//定义了 remote 应用如何将输出内容暴露给 host 应用。name是暴露给外部应用的变量名; type是暴露变量的方式,默认{ type: "var", name: //options.name }
library: {
type: 'xxx',
name: 'xxx'
},
//被当前 host 应用消费的 remote 应用。
//value 为 remote 应用的对外输出及url,格式必须严格遵循: 'obj@url'。其中, obj 对应 remote 应用中 library 中的 name 配置项, url 对应 //remote 应用中 remoteEnter 文件的链接
remotes: {
app2: 'app2@xxxx',
app3: 'app3@xxxx',
...
},
//被host消费的内容, key 为输出内容在 host 应用中的相对路径,value 为输出内容的在当前应用的相对路径
exposes: {
'./Button': './src/Button',
...
},
//应用间的公用依赖,webpack在加载的时候会先判断本地应用是否存在对应的包,若是不存在,则加载远程应用的依赖包
shared: {
'react': {
// 应该提供给共享作用域的模块。如果在共享范围中没有发现共享模块或版本无效,还充当回退模块。默认为属性名
import: 'xxx',
//是否开启单例模式。默认为false
singleton: true,
//指定共享依赖的版本,默认值为当前应用的依赖版本
requiredVersion: 'xxx',
//是否需要严格的版本控制,默认为false
strictVersion: 'xxx',
//共享依赖的别名, 默认值值 shared 配置项的 key 值
shareScope: 'xxx',
//所用共享依赖的作用域名称,默认为 default
packageName: 'xxx',
// 用这个名称在共享范围中查找模块
sharedKey: 'xxx',
// 是否立即加载模块而不是异步加载
eager: true
}
},
//所用共享依赖的作用域名称,默认为 default。
shareScope: 'xxx'
})
官方是以react为例子,vue的配置略有区别(这里需注意必须采用webpack
,如果采用vuecli
不生效)
// 请确保引入这个插件!
new VueLoaderPlugin(),
new HTMLWebpackPlugin({
template: path.resolve(__dirname, './public/index.html')
}),
new ModuleFederationPlugin({
// 提供给其他服务加载的文件
filename: "remoteEntry.js",
// 唯一ID,用于标记当前服务
name: "app1",
library: { type: "var", name: "app1" },
// 需要暴露的模块,使用时通过 `${name}/${expose}` 引入
exposes: {
'./Header': "./src/components/Header.vue",
},
shared:{
vue: {
singleton: true,
},
}
})
]
原理
我们看app1打包后生成的文件:
webpack根据
moduleFederationPlugin
中的 配置生成了remoteEntry-chunk,expose-chunk和share-chunk。
remoteEntry-chunk
中包含了runtime的模块,expose-chunk
中包含可被 host 应用使用的Header 组件,share-chunk中包含了共享的vue
.
remoteEntry.js中container entry包含了三个部分:
- moduleMap:通过expose生成的模块集合
- get:host通过该函数可以拿到remote中的组件
- init:host通过该函数将share-chunk注入remote中
我们再看app2中的main-chunk:
第一段代码在_webpack_modules__ 中定义了 "webpack/container/reference/app1" 模块及执行方法 .
第二段代码_webpack_require__.I中定义了register和initExternal方法,在初始化的过程中会先注册依赖,然后动态加载 app1 的 remoteEntry.js.
然后我们梳理下整个app2的加载过程:
- 加载 main-chunk 对应的 js 文件
- 执行main-chunk,初始化webpack内部的变量和方法
- 执行index.js,触发 _webpack_require_.I 的执行,注册依赖并动态加载app1的remoteEntry.js
- 执行remoteEntry.js,返回一个包含 get、init 的全局变量 app1
- 先执行app1.init,使用 app2 应用的 _webpack_require_.S 初始化 app1 应用的 _webpack_require_.S,然后执行app1.get,获取header组件
关于跨框架
前面的例子都是在同框架中的,如果react要直接调用vue的组件是会报错的:
这时候我们需要用到插件vuera(可以实现vue与react相互引用),通过包装一层
Wrapper
组件,然后通过ref
将组件挂载在warapper
上。
总结
联邦模块相对与其他的模块共享方式最大的好处就是共享的代码是runtime引入,减小了打包的体积,加快了打包速度。
其次,联邦模块去中心化的应用集群概念可以实现跨应用的微前端解决方案 ,典型框架如EMP
。EMP
相对于qiankun
共享的颗粒度更小,可以达到组件级别,我们一般用于微组件,而qiankun
一般是作为微应用来使用。
附上联邦模块的demo地址:github.com/breezeJACK/…
最后
以上介绍的是我们常用的几种模块共享方式,笔者只是粗略的讲解了其中使用方法,第一次写技术文章,其中如果有什么纰漏或不足之处,还望大家指正。
技术架构之路道阻且长,行则将至,愿与君共勉之。
公众号:胡哥教你学前端
掘金号:Breeze同学