-
Notifications
You must be signed in to change notification settings - Fork 296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
手把手教你写一个简易的微前端框架 #31
Labels
Comments
学习了 🐂 |
通过Proxy+Function+with这种形式建立的安全沙箱,如果code中的函数内通过this仍然可以访问到window,请问有什么好的办法解决这种写法造成的沙箱逃逸吗? 如下示例代码:
|
目前确实没有好的办法... |
此问题已解决,在严格模式下执行用户模块代码即可。 |
越看越觉得微前端的各种实现很 hack,项目复杂度一旦上来就会有各种问题。 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
最近看了几个微前端框架的源码(single-spa、qiankun、micro-app),感觉收获良多。所以打算造一个迷你版的轮子,来加深自己对所学知识的了解。
这个轮子将分为五个版本,逐步的实现一个最小可用的微前端框架:
每一个版本的代码都是在上一个版本的基础上修改的,所以 V5 版本的代码是最终代码。
Github 项目地址:https://github.com/woai3c/mini-single-spa
V1 版本
V1 版本打算实现一个最简单的微前端框架,只要它能够正常加载、卸载子应用就行。如果将 V1 版本细分一下的话,它主要由以下两个功能组成:
监听页面 URL 变化,切换子应用
一个 SPA 应用必不可少的功能就是监听页面 URL 的变化,然后根据不同的路由规则来渲染不同的路由组件。因此,微前端框架也可以根据页面 URL 的变化,来切换到不同的子应用:
这可以通过重写两个 API 和监听两个事件来完成:
其中
pushState()
、replaceState()
方法可以修改浏览器的历史记录栈,所以我们可以重写这两个 API。当这两个 API 被 SPA 应用调用时,说明 URL 发生了变化,这时就可以根据当前已改变的 URL 判断是否要加载、卸载子应用。当用户手动点击浏览器上的前进后退按钮时,会触发
popstate
事件,所以需要对这个事件进行监听。同理,也需要监听hashchange
事件。这一段逻辑的代码如下所示:
从上面的代码可以看出来,每次 URL 改变时,都会调用
loadApps()
方法,这个方法的作用就是根据当前的 URL、子应用的触发规则去切换子应用的状态:这段代码的逻辑也比较简单:
根据当前 URL、子应用的触发规则来判断是否要加载、卸载子应用
为了支持不同框架的子应用,所以规定了子应用必须向外暴露
bootstrap()
mount()
unmount()
这三个方法。bootstrap()
方法在第一次加载子应用时触发,并且只会触发一次,另外两个方法在每次加载、卸载子应用时都会触发。不管注册的是什么子应用,在 URL 符合加载条件时就调用子应用的
mount()
方法,能不能正常渲染交给子应用负责。在符合卸载条件时则调用子应用的unmount()
方法。上面是一个简单的子应用注册示例,其中
activeRule()
方法用来判断该子应用是否激活(返回true
表示激活)。每当页面 URL 发生变化,微前端框架就会调用loadApps()
判断每个子应用是否激活,然后触发加载、卸载子应用的操作。�何时加载、卸载子应用
首先我们将子应用的状态分为三种:
bootstrap
,调用registerApplication()
注册一个子应用后,它的状态默认为bootstrap
,下一个转换状态为mount
。mount
,子应用挂载成功后的状态,它的下一个转换状态为unmount
。unmount
,子应用卸载成功后的状态,它的下一个转换状态为mount
,即卸载后的应用可再次加载。现在我们来看看什么时候会加载一个子应用,当页面 URL 改变后,如果子应用满足以下两个条件,则需要加载该子应用:
activeRule()
的返回值为true
,例如 URL 从/
变为/vue
,这时子应用 vue 为激活状态(假设它的激活规则为/vue
)。bootstrap
或unmount
,这样才能向mount
状态转换。如果已经处于mount
状态并且activeRule()
返回值为true
,则不作任何处理。如果页面的 URL 改变后,子应用满足以下两个条件,则需要卸载该子应用:
activeRule()
的返回值为false
,例如 URL 从/vue
变为/
,这时子应用 vue 为失活状态(假设它的激活规则为/vue
)。mount
,也就是当前子应用必须处于加载状态(如果是其他状态,则不作任何处理)。然后 URL 改变导致失活了,所以需要卸载它,状态也从mount
变为unmount
。�API 介绍
V1 版本主要向外暴露了两个 API:
registerApplication()
,注册子应用。start()
,注册完所有的子应用后调用,在它的内部会执行loadApps()
去加载子应用。registerApplication(Application)
接收的参数如下:一个完整的示例
现在我们来看一个比较完整的示例(代码在 V1 分支的 examples 目录):
演示效果如下:
![请添加图片描述](https://camo.githubusercontent.com/e911d7b0ce6bbbae378636bb0aae7b50a3672cb7096c89ca2313f9363ce19074/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f64626362323935396633363634376230396238396366343637313632343439312e676966)
小结
V1 版本的代码打包后才 100 多行,如果只是想了解微前端的最核心原理,只看 V1 版本的源码就可以了。
V2 版本
V1 版本的实现还是非常简陋的,能够适用的业务场景有限。从 V1 版本的示例可以看出,它要求子应用提前把资源都加载好(或者把整个子应用打包成一个 NPM 包,直接引入),这样才能在执行子应用的
mount()
方法时,能够正常渲染。举个例子,假设我们在开发环境启动了一个 vue 应用。那么如何在主应用引入这个 vue 子应用的资源呢?首先排除掉 NPM 包的形式,因为每次修改代码都得打包,不现实。第二种方式就是手动在主应用引入子应用的资源。例如 vue 子应用的入口资源为:
那么我们可以在注册子应用时这样引入:
这种方式也不靠谱,每次子应用的入口资源文件变了,主应用的代码也得跟着变。还好,我们有第三种方式,那就是在注册子应用的时候,把子应用的入口 URL 写上,由微前端来负责加载资源文件。
“自动”加载资源文件
现在我们来看一下如何自动加载子应用的入口文件(只在第一次加载子应用时执行):
上面代码的逻辑:
script
style
的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的script
style
的内容document.head
下,script
代码直接执行下面再详细描述一下这四步是怎么做的。
一、拉取 HTML 内容
代码逻辑很简单,使用 ajax 发起一个请求,得到 HTML 内容。
![在这里插入图片描述](https://camo.githubusercontent.com/443014b1573a41f9d830b82cf12dfb8d346804496f19bd0f21cd02781559fcdc/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f64343934646161383361356134373330383032343866626536376338666232332e706e67)
上图就是一个 vue 子应用的 HTML 内容,箭头所指的是要提取的资源,方框标记的内容要赋值给子应用所挂载的 DOM。
二、解析 HTML 并提取 style script 标签内容
这需要使用一个 API DOMParser,它可以直接解析一个 HTML 字符串,并且不需要挂到 document 对象上。
提取标签的函数
extractScriptsAndStyles(node: Element, app: Application)
代码比较多,这里就不贴代码了。这个函数主要的功能就是递归遍历上面生成的 DOM 树,提取里面所有的style
script
标签。三、添加 style 标签,执行 script 脚本内容
这一步比较简单,将所有提取的
style
标签添加到document.head
下:js 脚本代码则直接包在一个匿名函数内执行:
四、将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下
为了保证子应用正常执行,需要将这部分的内容保存起来。然后每次在子应用
mount()
前,赋值到所挂载的 DOM 下。现在我们已经可以非常方便的加载子应用了,但是子应用还有一些东西需要修改一下。
子应用需要做的事情
在 V1 版本里,注册子应用的时候有一个
loadApp()
方法。微前端框架在第一次加载子应用时会执行这个方法,从而拿到子应用暴露的三个方法。现在实现了pageEntry
功能,我们就不用把这个方法写在主应用里了,因为不再需要在主应用里引入子应用。但是又得让微前端框架拿到子应用暴露出来的方法,所以我们可以换一种方式暴露子应用的方法:
这样微前端也能拿到每个子应用暴露的方法,从而实现加载、卸载子应用的功能。
另外,子应用还得做两件事:
如果子应用是基于 webpack 进行开发的,可以这样配置:
一个完整的示例
示例代码在 examples 目录。
V3 版本
V3 版本主要添加以下两个功能:
隔离子应用 window 作用域
在 V2 版本下,主应用及所有的子应用都共用一个 window 对象,这就导致了互相覆盖数据的问题:
为了避免这种情况发生,我们可以使用 Proxy 来代理对子应用 window 对象的访问:
从上述代码可以看出,用 Proxy 对一个空对象做了代理,然后把这个代理对象作为子应用的 window 对象:
window.xxx
属性时,就会被这个代理对象拦截。它会先看看子应用的代理 window 对象有没有这个属性,如果找不到,就会从父应用里找,也就是在真正的 window 对象里找。那么问题来了,怎么让子应用里的代码读取/修改 window 时候,让它们访问的是子应用的代理 window 对象?
刚才 V2 版本介绍过,微前端框架会代替子应用拉取 js 资源,然后直接执行。我们可以在执行代码的时候使用 with 语句将代码包一下,让子应用的 window 指向代理对象:
卸载时清除子应用 window 作用域
当子应用卸载时,需要对它的 window 代理对象进行清除。否则下一次子应用重新加载时,它的 window 代理对象会存有上一次加载的数据。刚才创建 Proxy 的代码中有一行代码
this.injectKeySet.add(key)
,这个injectKeySet
是一个 Set 对象,存着每一个 window 代理对象的新增属性。所以在卸载时只需要遍历这个 Set,将 window 代理对象上对应的 key 删除即可:记录绑定的全局事件、定时器,卸载时清除
通常情况下,一个子应用除了会修改 window 上的属性,还会在 window 上绑定一些全局事件。所以我们要把这些事件记录起来,在卸载子应用时清除这些事件。同理,各种定时器也一样,卸载时需要清除未执行的定时器。
下面的代码是记录事件、定时器的部分关键代码:
下面这段是清除事件、定时器的关键代码:
缓存子应用快照
之前提到过子应用每次加载的时候会都执行
mount()
方法,由于每个 js 文件只会执行一次,所以在执行mount()
方法之前的代码在下一次重新加载时不会再次执行。举个例子:
上面是子应用入口文件的代码,在第一次执行 js 代码时,子应用可以读取
window.name
这个属性的值。但是子应用卸载时会把name
这个属性清除掉。所以子应用下一次加载的时候,就读取不到这个属性了。为了解决这个问题,我们可以在子应用初始化时(拉取了所有入口 js 文件并执行后)将当前的子应用 window 代理对象的属性、事件缓存起来,生成快照。下一次子应用重新加载时,将快照恢复回子应用上。
生成快照的部分代码:
恢复快照的部分代码:
隔离子应用元素作用域
我们在使用
document.querySelector()
或者其他查询 DOM 的 API 时,都会在整个页面的 document 对象上查询。如果在子应用上也这样查询,很有可能会查询到子应用范围外的 DOM 元素。为了解决这个问题,我们需要重写一下查询类的 DOM API:将查询范围限定在子应用挂载容器的 DOM 下。另外,子应用卸载时也需要恢复重写的 API:
除了查询 DOM 要限制子应用的范围,样式也要限制范围。假设在 vue 应用上有这样一个样式:
当它作为一个子应用被加载时,这个样式需要被修改为:
实现代码也比较简单,需要遍历每一条 css 规则,然后替换里面的
body
、html
字符串:V4 版本
V3 版本实现了 window 作用域隔离、元素隔离,在 V4 版本上我们将实现子应用样式隔离。
第一版
我们都知道创建 DOM 元素时使用的是
document.createElement()
API,所以我们可以在创建 DOM 元素时,把当前子应用的名称当成属性写到 DOM 上:这样所有的 style 标签在创建时都会有当前子应用的名称属性。我们可以在子应用卸载时将当前子应用所有的 style 标签进行移除,再次挂载时将这些标签重新添加到
document.head
下。这样就实现了不同子应用之间的样式隔离。移除子应用所有 style 标签的代码:
第一版的样式作用域隔离完成后,它只能对每次只加载一个子应用的场景有效。例如先加载 a 子应用,卸载后再加载 b 子应用这种场景。在卸载 a 子应用时会把它的样式也卸载。如果同时加载多个子应用,第一版的样式隔离就不起作用了。
第二版
由于每个子应用下的 DOM 元素都有以自己名称作为值的
single-spa-name
属性(如果不知道这个名称是哪来的,请往上翻一下第一版的描述)。所以我们可以给子应用的每个样式加上子应用名称,也就是将这样的样式:
改成:
这样一来,就把样式作用域范围限制在对应的子应用所挂载的 DOM 下。
给样式添加作用域范围
现在我们来看看具体要怎么添加作用域:
主要有以上五种情况。
通常情况下,每一条 css 选择符都是一个 css 规则,这可以通过
style.sheet.cssRules
获取:拿到了每一条 css 规则之后,我们就可以对它们进行重写,然后再把它们重写挂载到
document.head
下:核心代码在
getNewSelectorText()
上,这个函数给每一个 css 规则都加上了[single-spa-name=${app.name}]
。这样就把样式作用域限制在了对应的子应用内了。效果演示
大家可以对比一下下面的两张图,这个示例同时加载了 vue、react 两个子应用。第一张图里的 vue 子应用部分字体被 react 子应用的样式影响了。第二张图是添加了样式作用域隔离的效果图,可以看到 vue 子应用的样式是正常的,没有被影响。
V5 版本
V5 版本主要添加了一个全局数据通信的功能,设计思路如下:
window.spaGlobalState
,所有应用都可以对这个全局对象进行监听,每当有应用对它进行修改时,会触发change
事件。下面是实现了第一点要求的部分关键代码:
下面是实现了第二点要求的部分关键代码:
以上两段代码都有一个相同的地方,就是在保存监听回调函数的时候需要和对应的子应用关联起来。当某个子应用卸载时,需要把它关联的回调函数也清除掉。
全局数据修改示例代码:
全局事件示例代码:
总结
至此,一个简易微前端框架的技术要点已经讲解完毕。强烈建议大家在看文档的同时,把 demo 运行起来跑一跑,这样能帮助你更好的理解代码。
如果你觉得我的文章写得不错,也可以看看我的其他一些技术文章或项目:
The text was updated successfully, but these errors were encountered: