Udesk微前端之路

栏目: IT技术 · 发布时间: 5年前

Udesk微前端之路

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 业务来说还是有很大优势的。

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

Udesk微前端之路

架构图

技术选型

Udesk微前端之路

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

实现方案

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

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

Udesk微前端之路

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

Udesk微前端之路


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Cracking the Coding Interview

Cracking the Coding Interview

Gayle Laakmann McDowell / CareerCup / 2015-7-1 / USD 39.95

Cracking the Coding Interview, 6th Edition is here to help you through this process, teaching you what you need to know and enabling you to perform at your very best. I've coached and interviewed hund......一起来看看 《Cracking the Coding Interview》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具