引言
前端现在技术更新很快,不管是 基础框架的react、vue,还是ui框架 antd等,几乎每年都有较大等更新,但是又往往不会完全向下兼容。
我们在开发后台应用时候就遇到这个问题,我们的后台是基于 antd v2.x版本的,最新的antd 都是v4.x了,我们想升级,但是一更新就发现大量的不向下兼容问题,几乎所有老功能都得修改,庞大的工作量,立刻让我们觉得这种升级方法太过痛苦,我们就需需要一种更平滑过渡的方案。
这个时候微前端自然而然的冒了出来,这个方案看上去正是我们需要的。
什么是微前端
微前端借鉴了后端微服务的概念。把前端应用也拆分成若干个小的子应用,并用主应用加以控制和协调。使得子应用既能独立运行,又能组装成一个大型单页应用。
中间提到了2个新的概念:
- 子应用: 一些垂直、内聚的业务页面合集。
- 主应用: 单页面的基座,组合多个 子应用 ,形成一个对外输出的完整应用。
我们的微前端技术主要就涵盖2个方面:一个是如何让 子应用顺利运行的浏览器沙箱技术,另一个是 如何管理和发布的子应用的应用平台设计。
微前端的技术体系
微前端的常见解决方案
为了第一次看微前端的同学,我们先来看下大家解决前端巨石应用往往使用的是以下2种解决方案:
- 【import() 动态模块加载】 例如:webpack lazy load,webpack5 的 module federation,,通过动态的script引入代码。整个前端构建流程其实和之前相似,唯一的限制可能是需要统一的技术栈及css命名空间,来处理相互兼容的问题。
- 【浏览器沙箱技术】 常用的有 proxy 代理、全局对象快照等办法,使不同的工程代码可以同时运行。优势是 更好的兼容性,带来的问题 自然是更高的实现难度,以及可能对打包构建及部署的修改问题。
第一种方案比较常见,我们可以对比第二种沙箱技术来看各自的优劣势。 浏览器沙箱,最大的好处是隔离。比如我们开发中使用了 antd,随着版本的推移,我们发现antd 升级到了v4,我们缺很难一次性修改掉老代码来升级。让我们可以同时运行 antd-v2 和 antd-v4,这就是 【浏览器沙箱技术】 最大的优势了。 相对劣势也可以想到,antd-v2 和 antd-v4同时运行的JS文件体积,运行后通用组件、通用模块复用的难度等等,都是沙箱技术后续需要进一步优化的方向。
重点:同学们可能之前看到的大部分是在说这2个比较的各种优劣势。本文其实后续就会介绍下 使用浏览器沙箱技术的实现要点,及我们继续结合前端工作流特点,如何进一步提升开发的效率。
常用的微前端框架
- qiankun 市面上相对比较早的微前端框架。优点是支持快照方式的沙箱,有较好的兼容性。现在也在探索基于Shadow DOM的沙箱隔离。缺点是对代码有一定的入侵,需要代码中注入专属的生命周期。暂时不支持使用者对沙箱功能的扩展。
- hot-chocolate github.com/NeteaseLoft… 我们自己研发的微前端框架,主要基于 Shadow DOM + Proxy的沙箱隔离模式,有较好的隔离效果、几乎没有代码入侵、支持多应用同时运行。同时支持使用是注入自定义插件,给用户带来更多可能。缺陷是对低版本浏览器的兼容性。
Shadow DOM + Proxy技术搭建前端沙箱
样式隔离 Shadow Dom
什么是Shadow DOM
可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起;
Shadow DOM 是个很有意思,很有用的功能,用来做样式隔离非常棒。
Shadow Dom 的能力
- Shadow DOM 内部的元素始终不会影响到它外部的元素;
- Shadow DOM 可以有完成的Dom;
- 支持使用link引入外部样式而不影响全局;直接使用~
Shadow Dom 的不足
- js 运行环境不隔离 Shadow Dom 对js是无能为力的,这个就需要我们后面的js沙箱来配合完成了;
浏览器支持情况
基本没有问题,我们又主要是针对后台应用,用户以chrome为主,完全不用担心
关键API
Shadow Dom 的api不多,主要是下面2个:
-
Element. attachShadow(shadowRootInit: { mode, delegatesFocus }) 给指定的元素挂载一个Shadow DOM,并返回ShadowRoot attachShadow 参数
-
mode Shadow DOM 的模式,open 或者 closed。 定义了 shadow root 的内部实现是否可被 JavaScript 访问及修改
-
delegatesFocus 当设置为 true 时, 指定减轻自定义元素的聚焦性能问题行为. 当shadow DOM中不可聚焦的部分被点击时, 让第一个可聚焦的部分成为焦点, 并且shadow host(影子主机)将提供所有可用的 :focus 样式.
-
ShadowRoot Shadow DOM 子树的根节点, 它与文档的主 DOM 树分开渲染 ShadowRoot API:
- delegatesFocus 返回一个boolean值表明在 shadow 添加时 delegatesFocus 是否被设置
- mode Shadow DOM 的模式,open 或者 closed。定义了 shadow root 的内部实现是否可被 JavaScript 访问及修改
- Host Shadow DOM 附加的宿主 DOM 元素
- innerHTML Shadow DOM 内部的 DOM 树
使用 innerHTML的问题
看到ShadowRoot有innerHTML,一开始很惊喜,以为直接拼接字符串就可以了。 但是我们很快就发现一个问题:
shadowRoot.innerHTML = `
<html>
<head>xxx</head>
<body>xxx</body>
</html>
`;
如果我们直接这么使用, 并不会产生 head和body的节点,这样就不完全了。但是我们多次尝试后发现可以这么添加:
const fragment = document.createDocumentFragment();
const htmlNode = document.createElement('html')
const headNode = document.createElement('head')
const bodyNode = document.createElement('body')
htmlNode.appendChild(head);
htmlNode.appendChild(body);
fragment.appendChild(htmlNode);
shadowRoot.appendChild(fragment);
appendChild 真棒!!!~~~~
JS隔离 Proxy
做完Shadow Dom生成的html和css,接下来就是重点的js沙箱了
with 语句
with 语句用于设置代码在特定对象中的作用域
这些定义看上去有点迷茫,我们看个例子理解下with
例子来啦:
var o = { a: 1, b: 2 };
with (o) {
console.log(a); // 输出:1
b = 3;
}
console.log(o); // 输出 {a:1,b:3}
看例子就容易理解多了,把 o 替换成我们需要的 window 就有劫持的感觉了,如下:
const fakeWindow = {
window: fakeWindow, document, ....
};
with (fakeWindow) {
... // 你的运行代码
}
with 使用的注意事项
- 在 ECMAScript 5 严格模式中该标签已被禁止, 平时业务代码中非必须的话,请谨慎使用with语句;
- with解决了代码中window等全局变量的被直接访问问题,更多的属性访问和函数访问如何处理?
请看下一章Proxy
Proxy
什么是 Proxy
Proxy 用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)
const p = new Proxy(target, handler);
主要就3个点
- target 被代理的对象;
- handler 行为的捕捉器;
- 返回值p,这个p上的操作才是能真正被捕捉到的,我们需要把它放入with;
handler
- handler.get
- handler.set
- handler.has
- 其他....
handler 很多就不一一列举了,感兴趣的同学可以看 这里
来个Proxy例子
const person = new Proxy({}, {
has: function (obj, prop) {
return prop in obj || prop === 'age';
},
get: function (obj, prop) {
let value = obj[prop];
if (prop === 'age' && !value) {
value = 0;
}
return value;
},
set: function (obj, prop, value) {
if (prop === 'age') {
if (typeof value !== 'number') {
throw new TypeError('年龄必须是数字')
}
if (value < 0) {
throw new RangeError('年龄不能小于0')
}
}
obj[prop] = value;
// 表示成功
return true;
}
});
person.age = 'aaa';
// 抛出异常: Uncaught TypeError: 年龄必须是数字
person.age = -1;
// 抛出异常: Uncaught TypeError: 年龄不能小于0
console.log(person.age);
// 0
console.log('age' in person);
// true
handler.get 的约束
- 如果要访问的目标属性是不可写以及不可配置的,则返回的值必须与该目标属性的值相同,如: window.window 我们必须处理,要不然很容易被绕过;
- 如果要访问的目标属性没有配置访问方法,即get方法是undefined的,则返回值必须为undefined。
于是我们这么设计我们的 fakeWindow:
const globalWindow = window;
const fakeGlobal = new Proxy({}, {
has: function (obj, prop) {
// 这个返回true很重要
// 返回 true 可以防止 直接使用 a=1 这种不带var|let|const的赋值语法导致的变量提升
return true;
},
get: function (obj, prop) {
let value;
if (prop = 'document') {
value = ...
}
if (prop = 'xxx') {
value = ...
}
if (
typeof value === 'undefined' &&
prop in obj
) {
value = obj[prop];
}
if (
typeof value === 'undefined' &&
prop in obj
) {
value = obj[prop];
}
return value;
},
set: function (obj, prop, value) {
fakeWindow[prop] = value;
// 表示成功
return true;
}
});
new Proxy操作
const fakeGlobal = new Proxy({},..)
被代理的使用是空对象,保证的是绕过 handler.get的约束,可以代理 window.window的情况~
get捕捉器里主要有3步操作
-
window, document,setTimeout 等等特殊属性
if (prop = 'xxx') { value = ... }
针对性处理,比如document 有自己proxy; setTimeout 需要被监听,为了卸载时能完全卸载;
-
子应用自己挂载在window 上的属性
if ( typeof value === 'undefined' && prop in obj ) { value = obj[prop]; }
优先返回子应用自己挂载的属性,保证正确性
-
原始window上的原有属性
if ( typeof value === 'undefined' && prop in obj ) { value = obj[prop]; }
兜底返回window下的内容,保证可用性
应用平台改良前端工程构建发布体系
为什么要搭建应用平台
不管是使用的我们的hot-chocolate还是qiankun,我们都会发现,我们需要为主应用写上一大堆子应用的配置。 比如这么一个配置:
[
{
name: 'app1',
// ...
},
{
name: 'app2',
// ...
},
{
name: 'app3',
// ...
}
]
我们实际的业务中,可能远不只3个子应用。如果每次子应用发布的时候都需要在代码中修改手动这个配置文件,比如添加hash、或者增加配置项,就会使得发布流程非常浮复杂。 既然微前端脱胎于微服务,那微前端的配置,是不是可以有个配置平台。当有这个想法的时候,应用平台就脱颖而出。
应用平台要做什么
我们再来看浏览器中前端的本质是什么。 现在的前端和后端分离后往往依赖于 nodejs 或者 nginx 之类的部署静态资源。那分离后的前端说白了,需要一个地方存放我们的各种js+css文件。
那 配置入库 + 静态资源托管, 单纯的前端体系一应俱全,发布流程也完美接上,从此过上了完全不一样的发布流程,前端同学们,再也不用为每个工程申请一堆部署环境,进行一系列部署配置了。
于是我们搭了一个平台,主应用再也不用每次和子应用同步发布拉~
说到应用平台其实也不复杂,主要实现一下2个功能:
- 静态资源托管(我们这里其实使用 **【对象存储 + cdn】**做为我们的资源托管服务)
- 配置更新, 使用 html + hash 的模式,即能充分利用缓存,还能有版本管理
更新后的前端工作流
子应用上传方式
得益于hot-chocolate的无侵入性。我们不需要在webpack里使用任何插件,也就意味着,你可以使用任何打包方式,比如vite、rollup或者其他前端打包工具。 我们需要做事情只有一个:在webpack打包结束后,把生成的文件,保持原有的目录结构,整个上传。就可以通过 hot-chocolate的 htmlRemote+ htmlRoot来实现自动的加载。完全不入侵打包流程。
应用平台的发布流程
我们都接入了统一的应用平台(线上版)。把不同 测试环境和线上环境 作为 2个主应用看待。主应用又是支持新建了,理论上我们就可以快速的创建多个主应用和子应用。并通过管理界面进行 一个多对多的关联配置即可。解放了代码的书写。
如图所示,我们现在的开发发布流程变成了一下三步:
- 开发:保持了原有的开发流程。并支持使用工程自己的打包方案。
- 开发完成后:再使用平台上传工具,把打包的输出文件直接上传到应用平台。
- 发布:我们只需要在应用平台上 选择正确的主应用和子应用,然后切换对应的版本即可,发布非常迅速。而且每个版本的数据都有记录,可以实现快速的回滚,非常方便记录。
保留了工程完整的开发和构建流程。只是在开发完成后,添加上传和发布的流程。使得对子应用代码几乎无修改,即可接入。
结尾
我们搭建微前端的几个关键技术点和实现流程分享完了。微前端是一个适用于一定复杂度工程的优化方案。同学们通过这篇文章可以了解到微前端的实现方式。帮助大家可以根据自己的需求去判断如何使用。欢迎大家留言和讨论,我们一起学习进步。
本文发布自网易元气事业部前端团队,文章未经授权禁止任何形式的转载。欢迎与我们交流前端相关的技术问题和经验,同时,团队以及部门正在招聘前端、服务端以及客户端各岗位的开发人员,以上都可以联系LofterFrontendTeam@corp.netease.com进行交流。