微前端在网易LOFTER中后台业务中的实践(一)——微前端沙箱及微前端应用平台

·  阅读 797
微前端在网易LOFTER中后台业务中的实践(一)——微前端沙箱及微前端应用平台

引言

前端现在技术更新很快,不管是 基础框架的react、vue,还是ui框架 antd等,几乎每年都有较大等更新,但是又往往不会完全向下兼容。

我们在开发后台应用时候就遇到这个问题,我们的后台是基于 antd v2.x版本的,最新的antd 都是v4.x了,我们想升级,但是一更新就发现大量的不向下兼容问题,几乎所有老功能都得修改,庞大的工作量,立刻让我们觉得这种升级方法太过痛苦,我们就需需要一种更平滑过渡的方案。

这个时候微前端自然而然的冒了出来,这个方案看上去正是我们需要的。

什么是微前端

微前端借鉴了后端微服务的概念。把前端应用也拆分成若干个小的子应用,并用主应用加以控制和协调。使得子应用既能独立运行,又能组装成一个大型单页应用。

中间提到了2个新的概念:

  • 子应用: 一些垂直、内聚的业务页面合集。
  • 主应用: 单页面的基座,组合多个 子应用 ,形成一个对外输出的完整应用。

我们的微前端技术主要就涵盖2个方面:一个是如何让 子应用顺利运行的浏览器沙箱技术,另一个是 如何管理和发布的子应用的应用平台设计。

微前端的技术体系

微前端的常见解决方案

为了第一次看微前端的同学,我们先来看下大家解决前端巨石应用往往使用的是以下2种解决方案:

  1. 【import() 动态模块加载】 例如:webpack lazy load,webpack5 的 module federation,,通过动态的script引入代码。整个前端构建流程其实和之前相似,唯一的限制可能是需要统一的技术栈及css命名空间,来处理相互兼容的问题。
  2. 【浏览器沙箱技术】 常用的有 proxy 代理全局对象快照等办法,使不同的工程代码可以同时运行。优势是 更好的兼容性,带来的问题 自然是更高的实现难度,以及可能对打包构建及部署的修改问题。

第一种方案比较常见,我们可以对比第二种沙箱技术来看各自的优劣势。 浏览器沙箱,最大的好处是隔离。比如我们开发中使用了 antd,随着版本的推移,我们发现antd 升级到了v4,我们缺很难一次性修改掉老代码来升级。让我们可以同时运行 antd-v2 和 antd-v4,这就是 【浏览器沙箱技术】 最大的优势了。 相对劣势也可以想到,antd-v2 和 antd-v4同时运行的JS文件体积,运行后通用组件、通用模块复用的难度等等,都是沙箱技术后续需要进一步优化的方向。

重点:同学们可能之前看到的大部分是在说这2个比较的各种优劣势。本文其实后续就会介绍下 使用浏览器沙箱技术的实现要点,及我们继续结合前端工作流特点,如何进一步提升开发的效率。

常用的微前端框架

  1. qiankun 市面上相对比较早的微前端框架。优点是支持快照方式的沙箱,有较好的兼容性。现在也在探索基于Shadow DOM的沙箱隔离。缺点是对代码有一定的入侵,需要代码中注入专属的生命周期。暂时不支持使用者对沙箱功能的扩展。
  2. hot-chocolate github.com/NeteaseLoft… 我们自己研发的微前端框架,主要基于 Shadow DOM + Proxy的沙箱隔离模式,有较好的隔离效果、几乎没有代码入侵、支持多应用同时运行。同时支持使用是注入自定义插件,给用户带来更多可能。缺陷是对低版本浏览器的兼容性。

Shadow DOM + Proxy技术搭建前端沙箱

样式隔离 Shadow Dom

什么是Shadow DOM

可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起;

Shadow DOM 是个很有意思,很有用的功能,用来做样式隔离非常棒。

Shadow Dom 的能力

  1. Shadow DOM 内部的元素始终不会影响到它外部的元素
  2. Shadow DOM 可以有完成的Dom;
  3. 支持使用link引入外部样式而不影响全局;直接使用~

Shadow Dom 的不足

  1. js 运行环境不隔离 Shadow Dom 对js是无能为力的,这个就需要我们后面的js沙箱来配合完成了;

浏览器支持情况

基本没有问题,我们又主要是针对后台应用,用户以chrome为主,完全不用担心

关键API

Shadow Dom 的api不多,主要是下面2个:

  1. Element. attachShadow(shadowRootInit: { mode, delegatesFocus }) 给指定的元素挂载一个Shadow DOM,并返回ShadowRoot attachShadow 参数

  2. mode Shadow DOM 的模式,open 或者 closed。 定义了 shadow root 的内部实现是否可被 JavaScript 访问及修改

  3. delegatesFocus 当设置为 true 时, 指定减轻自定义元素的聚焦性能问题行为. 当shadow DOM中不可聚焦的部分被点击时, 让第一个可聚焦的部分成为焦点, 并且shadow host(影子主机)将提供所有可用的 :focus 样式.

  4. ShadowRoot Shadow DOM 子树的根节点, 它与文档的主 DOM 树分开渲染 ShadowRoot API:

    1. delegatesFocus 返回一个boolean值表明在 shadow 添加时 delegatesFocus 是否被设置
    2. mode Shadow DOM 的模式,open 或者 closed。定义了 shadow root 的内部实现是否可被 JavaScript 访问及修改
    3. Host Shadow DOM 附加的宿主 DOM 元素
    4. 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 使用的注意事项
  1. 在 ECMAScript 5 严格模式中该标签已被禁止, 平时业务代码中非必须的话,请谨慎使用with语句;
  2. with解决了代码中window等全局变量的被直接访问问题,更多的属性访问和函数访问如何处理?

请看下一章Proxy

Proxy

什么是 Proxy

Proxy 用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)

const p = new Proxy(target, handler);
复制代码

主要就3个点

  1. target 被代理的对象;
  2. handler  行为的捕捉器;
  3. 返回值p,这个p上的操作才是能真正被捕捉到的,我们需要把它放入with;

handler

  1. handler.get
  2. handler.set
  3. handler.has
  4. 其他....

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 的约束

  1. 如果要访问的目标属性是不可写以及不可配置的,则返回的值必须与该目标属性的值相同,如: window.window 我们必须处理,要不然很容易被绕过;
  2. 如果要访问的目标属性没有配置访问方法,即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步操作

  1. window, document,setTimeout 等等特殊属性

    if (prop = 'xxx') {
      value = ...
    }
    复制代码

    针对性处理,比如document 有自己proxy; setTimeout 需要被监听,为了卸载时能完全卸载;

  2. 子应用自己挂载在window 上的属性

    if (
      typeof value === 'undefined' &&
      prop in obj
    ) {
      value = obj[prop];
    }
    复制代码

    优先返回子应用自己挂载的属性,保证正确性

  3. 原始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个功能:

  1. 静态资源托管(我们这里其实使用 **【对象存储 + cdn】**做为我们的资源托管服务)
  2. 配置更新, 使用 html + hash 的模式,即能充分利用缓存,还能有版本管理

更新后的前端工作流

子应用上传方式

得益于hot-chocolate的无侵入性。我们不需要在webpack里使用任何插件,也就意味着,你可以使用任何打包方式,比如vite、rollup或者其他前端打包工具。 我们需要做事情只有一个:在webpack打包结束后,把生成的文件,保持原有的目录结构,整个上传。就可以通过 hot-chocolate的 htmlRemote+ htmlRoot来实现自动的加载。完全不入侵打包流程。

应用平台的发布流程

平台流程.png 我们都接入了统一的应用平台(线上版)。把不同 测试环境和线上环境 作为 2个主应用看待。主应用又是支持新建了,理论上我们就可以快速的创建多个主应用和子应用。并通过管理界面进行 一个多对多的关联配置即可。解放了代码的书写。

如图所示,我们现在的开发发布流程变成了一下三步:

  1. 开发:保持了原有的开发流程。并支持使用工程自己的打包方案。
  2. 开发完成后:再使用平台上传工具,把打包的输出文件直接上传到应用平台。
  3. 发布:我们只需要在应用平台上 选择正确的主应用和子应用,然后切换对应的版本即可,发布非常迅速。而且每个版本的数据都有记录,可以实现快速的回滚,非常方便记录。

保留了工程完整的开发和构建流程。只是在开发完成后,添加上传和发布的流程。使得对子应用代码几乎无修改,即可接入。

结尾

我们搭建微前端的几个关键技术点和实现流程分享完了。微前端是一个适用于一定复杂度工程的优化方案。同学们通过这篇文章可以了解到微前端的实现方式。帮助大家可以根据自己的需求去判断如何使用。欢迎大家留言和讨论,我们一起学习进步。

本文发布自网易元气事业部前端团队,文章未经授权禁止任何形式的转载。欢迎与我们交流前端相关的技术问题和经验,同时,团队以及部门正在招聘前端、服务端以及客户端各岗位的开发人员,以上都可以联系LofterFrontendTeam@corp.netease.com进行交流。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改