Udesk为什么要做微前端?

微前端是最近这两年比较热的一个概念,随着前端框架的流行以及前端工程化的程度越来越高,多数公司都会采取前后端分离的方式,来构建自己的单页面应用系统。Udesk在2017年初便开始实践微前端了,只不过当时微前端这个概念还没有被广泛提起。虽然现在微前端已经深入人心,也有一些大公司把微前端做的比较工程化了,比如蚂蚁金服的  qiankun  ,美团也有微前端的内部实践,不过在那个时候还没有这些工具。

我们当时正处在前端技术栈迁移的阶段,希望把主要开发框架从Ember逐步迁移到React,但又不希望把现有系统彻底重写,所以需要一种方式能够逐步增量更换技术栈。由于Udesk业务和客户量都发展很快,把整个产品线都停下来完全做系统重写是不可能的,所以我们希望这个过程能够是渐进、增量式的,新功能要与 Ember主系统无缝集成,而且要保持SPA的使用体验,不能做页面刷新。

除了技术方面的需求,在业务也有很迫切的需求。从2016年开始,Udesk拉起了多条新业务线,前端团队也很快扩大到了近40人的大团队,每一个业务线都是独立的SaaS产品。新业务产品需要与核心客服系统有机集成起来,甚至新产品之间也要相互融合。另外,客服系统已经比较庞大,需要对系统能够按业务模块进行拆分解耦,子应用要能独立开发、部署、运维,提高系统的灵活性和弹性。

前端项目有哪些痛点?

随着前端系统规模的变大,在系统打包尺寸、构建效率和业务分离等方面都给我们提出了新的挑战。虽然Webpack提供了很多机制来尽量减少包的尺寸,比如 minify  tree-shaking  dll split chunks HappyPack dynamic import 等。其中最接近系统分治理念的就是 dynamic import 做代码分割,一般的做法都是根据路由把系统分成多个子包,主包只包含首页面渲染需要用到的代码,这样主包的尺寸就会大幅减少,从而提高了首屏加载的速度。

但这些还是解决仅仅 单一系统 的问题,对于一些  ToB 的大型系统或者跨地域的团队来说,这些是还是不够的。他们需要解决下面这些痛点:

  1. 业务分离,系统分治解耦;

  2. 团队技术栈不同,异构系统集成;

  3. 按业务进一步拆分bundle;

  4. 单个大型系统构建时间长;

  5. 业务子系统独立开发、部署、限制错误边界;

  6. 低代码场景,需要嵌入用户侧自定义代码;

上面这些从根本上来说还是一个系统分治的架构思想,无论我们怎么优化webpack,当一个系统业务模块很多的时候,我们优化带来的尺寸下降,还是很快会被大量的业务代码所淹没,主系统仍然是一个庞大的 Frontend Monolith (前端单体) 。当然除了代码尺寸问题,更重要的是所有业务线耦合在一起,一块编译、一块部署,一旦发现一个问题,整个系统需要做回滚,那些没有问题的业务模块也会受到影响,如果有的团队是在异地,甚至跨时区的话,协同部署更是很难的事情。

如果系统能被分割为N个子应用的话,不但可以按需嵌入到主系统,我们还可以把多个子应用按业务相关性进行自由组合,就可以形成满足不同客户场景的多种 解决方案 ,这对于  ToB 业务来说还是有很大优势的。

微前端还能提供解决历史技术栈的一剂良方,对于一个大型系统来说,变更技术栈是一个很难的事情,意味着使用新的语言或框架把所有历史代码重写。一方面开发工作量十分巨大,但这还不是最重要的,时间成本才是最难承受的。假如老系统重写需要两年时间,那我们是停下主业务两年,等待新版本完成再上线吗?这恐怕是任何一个公司都无法承受的。如果采用微前端,我们可以把系统拆分为多个子业务模块,完成一个模块就上线一个模块,系统迭代速度大大加快,而且同时也有时间响应老系统的开发需求。还有一个优势就是,新模块可以使用最新的技术栈。由于现代框架都很轻,而且模块化和隔离性都做的很好,完全可以在一个页面中运行多个框架实例,甚至两个不同版本的框架。所以在新模块中可以使用各种新框架,方便团队的技术升级,和新技术探索。

架构图

技术选型

由于保持单页面的使用体验是我们的核心诉求之一,所以前面两个方案很快就被淘汰了,接下来我们就主攻 jsSDK 这个方向。最后在填了很多的坑之后,最终子系统被成功嵌入到了主系统中,而且效果也是非常不错的。每个子系统独立开发、部署、运维,发版节奏完全自己控制。主系统按需动态加载子系统,动态渲染并挂载,与主系统虽然跨了技术框架,但仍可以完美融合,并且浏览器前进、后退、甚至刷新页面后都可以正常还原子页面,与一个系统的单页面使用体验无异。

实现方案

下面说一下微前端子应用sdk的具体实现过程,为了缩短篇幅,仅用代码展示了一些最小化的demo,但足以说明整个的工作原理。

先给大家展示一张流程图,展示了子应用sdk动态加载以及与主系统进行交互的过程。主、子系统的动态加载和交互的过程是通过主系统的`sdk-loader`组件来完成的,可以动态加载sdk文件以及做sdk的缓存,在下面还有详细介绍。

SDK工作流程图

主入口文件,SDKClass

class SDKClass {

constructor(options) {

// 可以让主系统传递各种sdk需要的options,例如多语言、鉴权信息、以及事件回调等

this.options = options || {};

this.isDestroyed = false;

}


// 主渲染方法,把sdk内容渲染到指定的父系统容器中

renderTo(container) {

if (container == null) {

container = document.body;

}

this._sdkContainer = document.createElement("div");

container.appendChild(this._sdkContainer);

// SDK的内容定义在RootApp中

render(<RootApp />, this._sdkContainer);

}


// 销毁sdk实例,以及渲染的所有dom元素

destroy() {

if (this.isDestroyed) {

return;

}

this.isDestroyed = true;

unmountComponentAtNode(this._sdkContainer);

this._sdkContainer.parentNode.removeChild(this._sdkContainer);

}

}


export default SDKClass;

从代码中可以看出来,一个sdk的核心就是一个 SDKClass ,通过webpack打包成  UMD  规范的包,这样既可以被主系统已 amd cmd 模块系统导入,也可以直接在页面中引入,通过 window 全局变量来访问。主系统拿到 SDKClass 后,需要进行实例化,然后调用sdk实例的 renderTo 方法把内容渲染到指定容器中。由于没有导出 SDKClass 外的任何信息,所以一个SDK是一种很 “干净” 的引入,不会污染主系统或与主系统的代码产生冲突,也就是代码隔离性比较好。

renderTo

这是sdk的主入口,我们可以把sdk的内容定义在 RootApp 中,渲染到主系统的容器中。在 RootApp 中,我们一般会按照路由来划分sdk子系统的多个模块,同时把根路由设置为主系统对应模块的页面路由的地址。这样当主系统加载完sdk的js并且实例化调用 renderTo 方法时,sdk内部的路由系统会自动从根路由下渲染子页面。如果主系统有多个父页面需要嵌入同一个sdk的话,则可以通过sdk的构造函数在 options 中把 根路由 动态传递进来。

class RootApp extends React.Component {

constructor(props) {

this.state.context = {};

}

render() {

return (

<React.StrictMode>

<ContextProvider value={this.state.context}>

<NewVersionMonitor />

<Router>

<Route path="/path/to/owner/page" component={}>

<Route path="page-a" component={ComponentA} />

<Route path="page-b" component={ComponentB} />

<Route path="page-c" component={ComponentC} />

</Route>

</Router>

</ContextProvider>

</React.StrictMode>

);

}

}

destroy

在单页面应用系统中,我们需要特别注意内存管理,防止内存泄露。在主系统中每次进入动态模块,都会实例化一个sdk的示例,而sdk实例就是一个完整的web应用,里面包含了dom元素以及很重的组件状态信息,所以当模块切走时,需要把当前的sdk实例销毁掉,释放相应内存。同时,在sdk内部,也要利用 destroy 的时机,把所使用的外部数据也要相应销毁。

双向交互

有时候父子系统之间有比较强的互操作,主系统要响应子系统中的某些事件,做出相应处理,或者在主系统的某个事件中,触发子系统的一些动作。这些其实也比较容易实现,由于sdk是在主系统中实例化的,主系统可以持有对sdk实例的引用,然后调用其公开方法,来实现 父调子 的动作。至于 子调父 的动作,可以在实例化sdk时,通过 options 传递一些事件回调过去,在sdk中合适的时机进行触发,或者 SDKClass 实现观察者模式,可以让主系统注册相应的事件,这样事件管理更规范一些。

class SDKClass {

on(eventName, handler) { }

off(eventName) { }

}

那如果一个宿主页面中,引用两种SDK,而两个SDK要进行相互通信的话,那该怎么办呢?其实也没有什么特别的,宿主页面应该承担两个 内聚 的sdk实例之间通信的责任。由于A、B两个SDK都是宿主页面实例化出来的,所以宿主页面也就保持着 a b 两个实例的引用,我们可以让宿主页面监听 a 的事件,然后在 event handler 中调用 b 实例的方法,可以实现 a b 之间的互操作。

sdk-loader组件

我们在流程图环节提到了 sdk-loader 组件,这其实是在主系统中封装一个组件,把 sdk 的动态加载、缓存、自动销毁、无污染传递 SDKClass 等特性都封装了一下,可以在主系统中方便使用。但这是一个可选项,如果我们不实现   sdk-loader 的话,也可以把sdk的 js css 事先引入主页面中,可以直接获取sdk通过 UMD 规范导出的 SDKClass 的全局变量,实例化并调用 renderTo 渲染。请注意,关于 revisionUrl 的解释,在下一节中有详细的描述。

class SdkLoader extends React.Component {

static defaultProps = {

jsUrl: "",

cssUrl: "",

revisionUrl: "",

onLoaded: null, // function(SDKClass) { }

onDestroying: null,

}

componentDidMount() {

fetch(`${revisionUrl}?v=${Date.now()}`).then((resp) => {

return resp.text();

}).then((version) => {

let scriptDom = document.createElement('script');

Object.assign(scriptDom, {

type: "text/javascript",

async: true,

src: `${jsUrl}?v=${version || Date.now()}`

});

document.getElementsByTagName('head')[0].appendChild(scriptDom);


let styleDom = document.createElement("link");

Object.assign(styleDom, {

rel: "stylesheet",

type: "text/css",

href: `${cssUrl}?v=${version || Date.now()}`

});

document.getElementsByTagName('head')[0].appendChild(styleDom);

});

}

}

revisionUrl有什么用?

主系统是通过 jsUrl 来加载sdk的,但如果sdk修改内容重新编译的话,有可能存在浏览器缓存问题。为了避免浏览器使用旧缓存,我们通常会在加载js时后面增加上一个动态参数,比如 script.src = this.props.jsUrl+"?v="+Date.now() 。但如果使用时间戳作为参数的话,也有一个副作用,就是每次加载都会使用一个新的 url  进行加载,这样每次都会重新下载js文件,即便是内容完全一致。当文件没有变化的时候,我们希望能利用浏览器缓存,避免重复加载。所以比较理想的情况是,如果sdk没有改动,我们希望浏览器尽量使用缓存,如果sdk改动了内容,我们希望浏览器能自动缓存失效,加载新版本。

为了实现这样的效果,我们可以在构建sdk时,引入一个“版本”文件,也就是   revisionUrl ,里面存放的是sdk的编译版本号。至于版本号的生成策略,可以根据自己的实际情况来设定。这样的话,如果sdk发版,则编译版本文件必然发生变化,否则编译版本内容始终保持不变。然后我们优化一下 sdk.js 的加载策略,先加载 revisionUrl 文件,获取编译版本号,然后再以编译版本号为动态参数加载 sdk.js ,这样基本上就实现了我们想要的效果。当然加载版本文件时,我们最好以`时间戳`为动态参数,以确保每次获取编译版本时都不存在缓存问题,同时由于版本号文件的尺寸非常小,相对于加载 sdk.js 文件来说代价基本就可以忽略不计了。

当然,有人可能会说,webpack打包出来的文件都会加上文件 hash ,这样不就很容易避免js缓存问题了?在开发一个web应用的时候,这确实是一个普遍遵循的最佳实践。但如果是对外界提供的sdk的话,如果 jsUrl 文件名带 hash 的话,每次改动都会改变文件名,而 jsUrl 又是配置在主系统中的,就会导致父系统频繁跟着改 jsUrl 、发版,两个系统又都耦合在一起了。

那有没有一个办法,既能最大程度复用浏览器缓存,而又不必每次都多发一个   version 串行化请求呢?其实是可以做到的,但有一个前提,就是父子系统团队比较紧密,捆绑在一个 CI & CD 管道中。子系统先构建,然后获取子系统的主入口的 jsUrl ,然后自动写入到主系统的配置文件中,再启动主系统的构建过程。这样两个系统就可以联动编译了,由于 jsUrl 里面包含了 hash ,我们在 sdk-loader 中直接使用 jsUrl 加载就可以了。如果你的团队不是紧密关联,而是松散关联的话,就无法进行自动化联合构建了。

所以没有一个绝对完美的解决方案,要根据自己的实际情况进行权衡,选择一个相对最合适的方案。

如何传递SDKClass

在前面提到了 无污染传递SDKClass ,具体是什么意思呢?由于子系统sdk是在主系统的 sdk-loader 中动态创建 script 标签来加载的,要与主系统传递数据只能通过 window 全局变量来实现,这样是会存在全局变量污染的。当然,现代浏览器在逐渐支持 [JavaScript modules] 特性,允许我们加载一个Javascript模块,但毕竟是新特性,浏览器兼容性不是太好,暂时还不能使用。其实我们可以借助另外一个很少使用的  api  document.currentScript ,可以在sdk文件内部获取加载的 script 标签对象,然后通过这个 dom 对象来做一些数据传递。

sdk.js

class SDKClass { }


// 1. 向主系统传递SDKClass

if (document.currentScript) {

document.currentScript._sdkClass = SDKClass;

}

sdk-loader

let script = document.createElement('script');

script.type = 'text/javascript';

script.async = true;

script.src = this.props.jsUrl;

script.onload = function () {

// 2. 接收sdk实例,而不污染全局变量

let sdkClass = this._sdkClass;

}

CSS隔离性

在SDK中渲染出来的内容也是带样式的,那如何做到sdk释放的样式与主系统不冲突呢?也就是如何保证CSS隔离性呢?

首先在SDK中不限制 css 预处理器的使用,无论是 less 还是 Sass 都是在编译阶段起作用的,所以不影响最终输出css的内容。当然css文件还是样式的主要载体,我们在写css样式时要注意,不要写全局样式,或者引入 normalize.css 这种全局样式重置库,否则势必会与主系统的样式体系产生冲突。如果一定要写一些全局性的 class ,或者全局设置一些 dom 的样式,建议在所有样式的最外层再包装一层作用域,比如:

.sample-sdk {

div {

line-height: 1.5;

}

.bold {

font-weight: bold;

}

.center {

text-align: center;

}

}

把所有的样式都封装在 .sample-sdk 内部的话,所有全局样式也就变成了“局部样式”。然后我们在SDK的 renderTo 方法中,在整个内容最外层渲染一个父容器,把 class 设置为 sample-sdk ,这样里面的样式就都会起作用了。而父系统中,没有任何父系统的元素包含在 sample-sdk 中,所以不会干扰父系统的样式。

当然,我们更推荐大家在SDK中使用 CSS Module ,在webpack中引入自己框架相相应的插件就可以了。由于 CSS Module 的  class 是被 hash化 的,名字随机性很好,与主系统样式隔离的效果非常好。

异常上报

一般前端系统中会引入异常上报机制,例如sentry等,确保前端发生脚本异常时,能上报到监控系统中。当引入sdk时,脚本错误会跨越系统边界,能够同时被主系统和sdk子系统捕获。为了区分两个系统中发生的脚本异常,我们可以通过 error.stack 中获取错误初次发生的文件,如果是sdk的 jsUrl 则是sdk的错误边界,否则则是主系统的。

新版本通知

由于主系统和sdk子系统的开发周期都是隔离的,所以有可能发生用户在主系统渲染了sdk时,而sdk更新了新编译版本,我们最好能显示一个新版本提醒,这样用户可以知道更新了新版本,然后刷新页面以加载新版本sdk。

这个功能也不难实现,我们可以在sdk中定期轮询自己的 revisionUrl ,当发现编译版本内容变更时,便可以在sdk顶部或其它合适的地方显示一个新版本提示,提醒用户刷新页面加载新版本。当然,用户也不是一定要刷新页面来加载新版本sdk的。我们可以先卸载掉当前sdk实例,然后再重新加载新版本sdk重新渲染,并自动导航到用户最后停留的界面。甚至,我们基于sdk服务端配置的一些策略,实现倒计时 强制 自动更新也是可能的,在这里就不赘述了。

多版本管理

当第一个版本的sdk开发完成后,如果我们需要继续添加新特性该怎么办?是直接修改,然后再重新打包发布吗?这个时候需要分两种情况来看,第一种,如果父子系统是一一对应的关系,且都同属于一个大系统,或者说子系统就是父系统的一个模块,那这么做问题不大。第二种情况是,sdk是一个公开的系统,会被多个外部系统嵌入,甚至作为一个开源项目,那就不能随便修改了。这个时候我们需要跟 npm 的管理方法类似,需要维护多个版本,也就是说,最好每次新增特性,甚至修复bug都生成一个新的版本。这样发布新版本就不会影响到正在使用老版本的外部系统了。那些引用了某个版本的外部系统,也不会因为某个新版本的改动而受到影响,是否升级新版本完全由自己来决定。

我们需要在自己的构建系统中,引入一个真正的 version 文件,每次 publish  一个新版本,需要升级一下版本号,然后把打包的所有内容防止在该版本对应的目录下,也就是说,我们最终的输出目录,第一层目录应该是 版本 文件夹,每个版本的所有文件都应放置在相应版本目录下。而我们发布给外界的   jsUrl 以及 cssUrl 应该是包含了版本的,比如:http://www.sample-code.com/sdk/v1.0/static/js/main.js

总结

以上便是Udesk在微前端方面摸索和实践的过程,借助于微前端的思想,我们可以把多个子应用拆分为独立的sdk项目,甚至可以实现前端框架的渐进式替换,即把某一个小模块或者某个页面使用新框架实现,再动态替换掉主系统的某个模块,而不用等待系统都替换完以后再上线,几乎不影响正常的迭代速度。大家可以参考这里提供的思路,实现自己的微前端解决方案,可以进一步根据自己系统的需求,再完善一些细节,以更贴近自己的业务。

引用文献

https://micro-frontends.org/

https://medium.com/@somnath.mondol/adopting-micro-frontends-architecture-12005d8c9e65

相关文章