一个 wujie 基座,完整内容查看微前端主仓库:https://github.com/cgfeel/zf-micro-app
wujie 和 qiankun、micro-app 的不同解决方案:
| 对比项 | wujie |
micro-app |
wujie |
|---|---|---|---|
| 渲染容器 | shadowDOM、iframe 容器 |
shadowDOM、iframe 容器 |
single-spa |
script |
沙箱 iframe |
proxy、沙箱 iframe |
proxy、快照中实现 |
css |
渲染容器 | scopedCSS、渲染容器 |
scopedCSS |
优点:天然隔离
- 直接使用
iframe,不需要遍历css计算scoped
亮点:
- 理论上
wujie可以把任何对外提供访问的网页做成子应用 - 对于不支持
proxy和shadowDOM的情况提供iframe降级方案
缺点:
- 对
React v18并不友好,严格模式下会产生协议错误,见:issue [查看] - 路由同步并不友好,子应用路由只能通过
search同步到网页链接中国,不能使用pathname
疑惑:wujie 频繁操作 Dom 直接影响 js 性能
- 比如说默认的重建模式下,
wuijie每次切换应用就是一次注销和重建
渲染原理:
| 分类 | 原理 |
|---|---|
wujie |
拉取 template 放入 shadowRoot,将容器挂载到指定节点 |
micro-app |
创建 web component 拉取资源,替换标签为自定义组件,由 Dom tree 渲染组件 |
qiankun |
基于 single-spa,拉取 template,劫持 url 经过计算将资源渲染到指定容器 |
micro-app也支持shadowDom和iframe沙箱,但需要在start时手动启用
总结分成 3 个部分:
项目全部整合在:
/wujie[查看]
包含项目:
react-project:通过create-react-app搭建的子应用 [查看]static-project:自定义静态应用 [查看]substrate:通过create-react-app搭建的基座主应用 [查看]vue-project:通过vue-cli搭建的子应用 [查看]
先回顾下 micro-app 基座流程:
- 入口文件
start配置启动项 - 指定页面插入自定义组件
<micro-app />
micro-app会将加载的资源注入web component
wujie 可以不使用 start 设置入口配置:
- 创建一个公共的组件
Wujie.tsx[查看] - 通过
startApp启动子应用并挂载到指定的ref节点,见:startApp[查看] - 通过调用组件的方式加载子应用,例如:
react子应用 [查看]
wujie不需要通过start强制配置启动,但提供setupApp用来缓存配置,见:文档 [查看]
珠峰的课程中有个错误:
- 官方文档不建议手动注销
destroyApp子应用,如果还需要使用子应用的话 [查看] startApp会返回一个方法destroy,可以直接用于注销应用,而不用传给destroyApp,同样也不建议主动注销,会导致下次打开该子应用有白屏时间
修改子应用:
可选:对于 umd 模式加载应用
通过以上了解初步印象是:Tencent 对通信非常偏爱
- 比如:应用和基座通信,沙箱和容器
Dom通信、proxy和沙箱window通信等等 - 除此之外在其它产品也能看出来,比如:小程序
postMessage,还有alloy-worker[查看]
简单实现 iframe 和 shadowRoot 通信,详细见项目中的源码:
整体分 4 部分:
index.html:基座html文件template:子应用要运行的css和html,要放入shadowDOM中strScript:子应用要执行的脚本字符,要放入iframe中createCustomElement:主应用自定义组件
createCustomElement 流程分 4 部分:
createSandbox:创建沙箱attachShadow:创建shadowRootinjectTemplate:将css和html注入shadowRootrunScriptInSandbox:将js注入iframe
容器分 2 个:
shadowRoot:直接将css和html全部打包到div注入shadowRootiframe:创建一个script元素,将执行的js作为元素内容注入iframe的head
在 script 添加到 iframe 之前:
- 需要在
script中劫持Dom查询方法,将上下文指向shadowRoot
流程:
- 通过
Object.defineProperty劫持iframe.contentWindow.Document.prototype.querySelector - 返回一个
Proxy对象,代理sandbox.shadowRoot.querySelector - 在
Proxy中通过apply纠正上下文this指向shadowRoot
Object.defineProperty 劫持对象会执行两次 [查看]
第一次:由 iframe 中的子应用发起 document.querySelector
- 通过
Object.defineProperty劫持Dom查询方法并返回Proxy对象 - 在
Proxy对象首次apply时,参数thisArgs指向劫持的对象iframeWindow.Document.prototype - 返回
thisArgs.querySelector相当于iframeWindow.Document.prototype.querySelector.apply(sandbox.shadowRoot, args) - 通过
apply将上下文指向sandbox.shadowRoot
第二次:由于 Proxy 对象再次调用了 iframe 的 querySelector,于是再次 Object.defineProperty
- 此时返回的
Proxy对象方法apply中thisArgs指向sandbox.shadowRoot - 返回
thisArgs.querySelector相当于:sandbox.shadowRoot.querySelector.apply(sandbox.shadowRoot, args) - 由于这次是通过
sandox发起querySelector,将不再被iframe劫持
可以打开调试窗口
sources在Proxy对象的apply方法中打上断点,刷新查看每次执行的上下文thisArgs的变化
劫持对象场景发散:
- 浮窗:劫持
shadowRoot下的body,创建Dom对象添加到body下 iframe中history对象:实现同步同步 [查看]
这部分将通过
wujie源码解读在下方总结
和 qiankun 解读一样,为了便于阅读以官方版本 9733864b0b5e27d41a2dc9fac216e62043273dd3 为准 [查看]
总结中链接指向相关源码和文档,每条信息都提供了关键词,可以打开链接复制并搜索关键词,查看上下文对照理解。
wujie 提供了 4 个包,分别为 [查看]:
封装的包是作为可选使用的单个组件,目前不提供
vue组件总结
由于总结会很长,所以我将整个流程总结精简放在前面:
preloadApp预加载(可选)[查看]startApp根据实例情况决定初始化还是切换应用 [查看]- 首次启动和切换重建模式的应用,会
destroy销毁后重新初始化 [查看] - 声明实例,创建沙箱
iframe、proxy代理、EventBus通信等 [查看] importHTML加载资源 [查看]processCssLoader处理css-loader[查看]active激活应用:将template注入容器 [查看]start启动应用:将script注入沙箱iframe,发起通知事件和mount[查看]- 返回
destroy以便手动销毁 [查看]
阅读建议,如果你做好准备阅读以下内容,这样可以提高效率:
- 链接指向源码或相关说明,记录中罗列了关键词,可以通过复制查找定位
- 建议按照流程线去阅读
比如说:
- 首次加载应用:从
startApp开始,忽略已存在应用实例的情况,只看首次创建实例 - 切换应用:从
startApp开始,只看存在应用实例的情况 - 预加载:从
preloadApp开始看
wujie 和 micro-app 组件定义不同处:
| 分类 | micro-app |
wujie |
|---|---|---|
| 挂载方式 | 手动挂载 web component 到指定 components tree 中渲染 |
自动挂载,active 激活应用时通过 createWujieWebComponent 创建自定义组件,挂载到指定容器 [查看] |
| 自定义组件名 | 支持 | 不支持 |
| 接受的属性 | name、url、iframe 等配置,见:文档 [查看] |
仅支持 WUJIE_APP_ID [查看] |
attributeChangedCallback |
检查子应用 name 和 url 属性变更 [查看] |
不支持,但是子应用的 name 和 url 可以作为 React 组件的 props,更新后重新渲染并挂载到容器 |
connectedCallback |
使用组件自增编号添加当前组件到映射表,并发起应用挂载 | 根据应用名拿到实例,打补丁后将 shadowRoot 绑定到实例中 [查看] |
disconnectedCallback |
用组件编号从映射表中下线组件,并发起应用卸载 | 根据应用名拿到实例并发起卸载 [查看] |
| 自定义更新组件 | 规则组件内部定义好了,只接受 name 和 url 的变更则 |
一旦更新应用就一定是重新渲染 |
| 组件用途 | 应用通信、资源容器、派发事件、决定启动和注销方式 | 资源容器 |
| 优点 | 强大 | 简单,几乎不用关心 web component |
| 缺点 | 功能上分工不清晰,MicroAppElement 处理完之后 CreateApp 还要做一遍对应操作,如:组件和应用分别 mount |
很粗暴,只存在挂载和卸载,一旦更新就一定是销毁后重新挂载,效率不高 |
目录:shadow.ts - defineWujieWebComponent [查看]
声明 WujieApp 自定义组件,在入口文件中默认执行 [查看]
- 因此在项目中引入
wujie的时候就已经定义了web component
- 设置
shadowRoot模式为open - 通过
getWujieById使用属性WUJIE_APP_ID拿到应用实例,见:idToSandboxCacheMap[查看] - 通过
patchElementEffect为shadowRoot打补丁 [查看] - 将
shadowRoot绑定到实例上
几个概念名词:
web component:通过WujieApp定义的组件,这里定义的组件名是wujie-appshadowRoot:shadowDom的根节点,与之相似有document是Dom的根节点shadowRoot.host:返回shadowRoot附加到Dom元素的引用,即:web componentshadowRoot.host.parentElement:web component的父节点,用于获取shadowRoot挂载节点shadowRoot.firstChild:shadowRoot下第一个元素,在wujie中是html元素documet.documentElement:document下的根元素,如:html元素
以上几个对象将会在
wujie中高频出现
目录:index.ts - startApp [查看]
分 3 步:
- 获取、更新配置信息
- 存在沙箱实例就切换或销毁应用
- 不存在沙箱实例或被销毁的应用,创建新的沙箱实例
运行应用分为 3 个模式:
alive保活模式:启动和切换应用不会销毁实例 [查看]umd单例模式:切换应用时通过window.__WUJIE_MOUNT重新渲染 [查看]- 重建模式:创建应用前,如果存在应用实例会将其先注销,见:
destroy[查看]
有关 wujie 的运行模式,见:文档 [查看]
备注:
umd在文档中称为单例模式,为了和qiankun、micro-app对齐,以下统称umd模式- 如果在切换应用时看到瞬间白屏,建议使用
alive模式或umd模式
从映射表 idToSandboxCacheMap 获取已记录的实例和配置 [查看]:
getWujieById:使用应用名获取应用实例,不存在返回nullgetOptionsById:使用应用名获取已缓存的配置,不存在返回null
配置只能通过
setupApp缓存,见:文档 [查看]
合并并提取配置:
- 通过
mergeOptions合并参数提供的配置和缓存的配置 - 解构并提取必要的配置,关于配置见:文档 [查看]
应用场景:
| 模式 | 预加载后启动 | 预执行后启动 | 切换应用 |
|---|---|---|---|
alive |
运行 | 运行 | 运行 |
umd |
销毁 | 运行 | 运行 |
| 重建 | 销毁 | 销毁 | 销毁 |
没有预加载初次
startApp不存在应用实例,所有模式都必须创建实例 [查看]
渲染前的准备:
- 通过
getPlugins更新实例的plugins,见:特殊属性 -plugins插件集合 [查看] - 获取沙箱
window用于获取子应用挂载方法__WUJIE_MOUNT,不存在则为undefined - 如果是预加载后
startApp,需要等待runPreload执行完毕 [查看]
和 micro-app 的 keep-alive 模式一样:
- 优点:切换路由不销毁应用实例,路由、状态不会丢失,在没有生命周期管理的情况下,减少白屏时间
- 缺点:多个菜单栏跳转到子应用的不同页面,不同菜单栏无法跳转到指定子应用路由
第一步:active 激活应用
第二步:start 应用
预加载但是没有 exec 预执行的情况下需要 start 应用:
- 调用生命周期中的
beforeLoad,见:文档 [查看] - 通过
importHTML提取需要加载的script[查看] - 将提取的方法
getExternalScripts传入sandbox.start启动应用 [查看]
预加载后加载的资源已注入容器,启动时不用重新注入,但 importHTML 存在重复调用:
- 然而应用中的静态资源已提前加载,不受重复调用再次请求,见:资源缓存集合 [查看]
第三步:alive 加载完成
- 调用生命周期中的
activated并返回子应用注销函数sandbox.destroy - 这里存在
activated调用 2 次的情况,见:6.预加载中的bug[查看]
通过 umd 切换应用的条件:
- 子应用存在
__WUJIE_MOUNT方法挂载到沙箱window - 包含:通过
exec预执行后startApp,或完成首次startApp后每次切换回应用
第一步:重新加载资源
unmount 卸载应用 [查看]:
- 清空容器、清理路由和事件,将应用还原至初始状态
- 否则
active时可能会对容器中的元素重复监听事件,见:容器在哪清除 [查看]
active 激活应用 [查看]:
- 无论是
preloadApp已加载过资源,还是切换应用,容器都会在unmount时清空 - 激活应用时会再次同步路由,并重新将资源注入容器
rebuildStyleSheets 恢复动态加载和打补丁的样式 [查看]:
umd模式切换应用后,只触发__WUJIE_MOUNT函数挂载应用- 应用中动态添加以及打补丁的样式需要通过
styleSheetElements收集并恢复 [查看] - 完整的样式加载流程,见:应用中的样式如何加载 [查看]
第二步:挂载应用
和 mount 挂载 umd 模式的应用是一样的,见:umd 方式启动 [查看]
- 挂载前使用沙箱
window调用生命周期beforeMount - 挂载应用,从沙箱
window调用子应用__WUJIE_MOUNT - 挂载后使用沙箱
window调用生命周期afterMount - 激活
mountFlag表明已挂载,避免重复挂载,然后返回destroy注销方法 [查看]
条件匹配的应用会注销已缓存实例后,再重新创建新实例:
- 包括重新提取资源、替换资源、转变为
html注入容器,挂载容器,等一系列操作 - 因此可能会导致短暂白屏的现象,要避免这种情况建议使用
alive或umd模式
注销应用后再次创建实例,会优先使用缓存提取并加载资源 [查看]
这一过程和 preloadApp 预加载应用流程是一样的 [查看]:
| 流程 | 描述 | preloadApp |
startApp |
|---|---|---|---|
addLoading |
启动应用时添加 loading [查看] |
不需要 | 需要 |
sandbox |
通过 WuJie 声明实例,见:constructor [查看] |
需要 | 需要 |
beforeLoad |
传递沙箱 window 调用生命周期,见:文档 [查看] |
需要 | 需要 |
importHTML |
提取应用资源 [查看] | 需要 | 需要 |
processCssLoader |
处理 css-loader,并更新已提取的资源 [查看] |
需要 | 需要 |
active |
激活应用 [查看] | 需要 | 除了预加载提供的参数外,还包括:sync 同步路由、el 挂载容器 |
start |
启动应用 [查看] | 仅在提供 exec 预加载时才执行 |
需要 |
destroy |
返回注销方法 [查看] | 不返回 | 仅在 start 正常情况下返回,见:bug [查看] |
同样适用于 preloadApp:启动或预加载应用时不提供 name 和 url 怎么处理?
虽然在
ts中已却明要求为必填参数,但如果ignore强制忽略或提供空字符怎么办?
micro-app 中只有都符合要求才开始挂载组件:
- 在
defineElement的attributeChangedCallback中检查name和url两个属性
| 分类 | 加载方式 | 场景 |
|---|---|---|
| 应用内的静态样式 | processCssLoader [查看] |
preloadApp、首次 startApp 初始化实例 |
手动配置 css-loader |
processCssLoaderForTemplate [查看] |
alive 模式首次激活、其它模式每次激活 |
| 应用内的动态样式 | rewriteAppendOrInsertChild [查看] |
alive 和 umd 首次激活、重建模式每次激活 |
umd 模式恢复样式 |
rebuildStyleSheets [查看] |
切换 umd 应用、umd 预执行后启动 |
加载的顺序从上至下,单例应用是通过动态加载样式的
切换应用时如何加载样式:
alive模式:切换应用不会销毁实例,所以下次激活时不用重复加载样式umd模式:切回应用时通过__WUJIE_MOUNT渲染,之前加载的样式需要调用rebuildStyleSheets恢复 [查看]- 重建模式:每次启动都会重新注入样式
umd不存在初次加载,和重建模式一样都需要实例初始化后激活应用,动态加载样式
start:通过execQueue队列加载:js-loader、子应用静态script[查看]rewriteAppendOrInsertChild:动态添加chunk script[查看]
加载顺序从上至下:单例应用入口
script通过start注入沙箱,然后动态加载chunk script
切换应用时如何加载 script:
alive模式:切换应用不会销毁实例,切回应用时不用重复加载scriptumd模式:unmount卸载应用时只清空shadowRoot不清空沙箱,切回应用时不用重复加载script- 重建模式:每次启动都会重新注入
script
degrade降级时umd模式不清空容器,因为iframe移动后事件自动销毁(来自备注)
目录:index.ts - preloadApp [查看]
参数:preOptions,见官方文档 [查看]
preloadApp 预加载通过宏任务 requestIdleCallback 空闲时处理,不处理的情况有 2 个:
- 应用实例已存在,说明已加载过了
- 当前网址
search中能够找到预加载的应用名,此时需要直接加载
相同应用不能重复预加载,否则会造成误判,如:
active时shadowRoot存在但找不到el挂载点
- 通过
getOptionsById获取配置信息cacheOptions - 通过
mergeOptions合并参数preOptions和cacheOptions,优先采用preOptions - 从合并的
options中提取配置用于预加载
配置信息只能通过
setupApp缓存,如果没有缓存则返回null,见:文档 [查看]
- 将拿到的配置信息通过
Wujie声明实例sandbox - 通过
runPreload为实例挂起一个微任务preload
微任务挂载在实例上
sandbox.preload,在startApp时会通过await确保预加载已完成才能继续加载应用,这种方式和qiankun中的frameworkStartedDefer原理是一样的
- 使用沙箱
window调用生命周期beforeLoad,见:文档 [查看] - 通过
importHTML获取应用资源,此时资源中的样式和script都替换成注释 [查看] - 通过
processCssLoader处理css-loader并加载资源中的静态样式替换对应注释 [查看] - 激活应用
active[查看] - 根据配置
exec决定是否启动应用start[查看]
默认 exec 不会预执行:
- 从
importHTML提取getExternalScripts并执行,见:发挥的作用 [查看] - 通过
await会将此前已经提交微任务的队列作为上下文同步任务执行
对比文档会发现 preloadApp 的配置和 startApp 差别挺大:
- 但是可以通过
setupApp提前缓存配置,所以单纯从文档对比就失去意义了 - 从声明实例比较,
preloadApp和startApp提供的参数是一样的 - 只有
active激活应用时参数各有不同
预加载缺少 loading:
- 预加载的应用不需要
loading,而startApp会通过addLoading创建loading[查看] - 有没有
loading将会决定renderElementToContainer注入资源时是否清空挂载节点 [查看]
预加载不需要提供挂载容器 el:
- 沙箱的
iframe将作为临时的容器,应用会在active激活时注入沙箱 - 而沙箱
iframe在页面中是不可见的,因此也看不到预加载的应用
startApp时会通过active从沙箱中销毁容器,或取出挂载到指定节点,见:容器在哪清除 [查看]
引发了一个思考:
- 把所有的子应用全部预加载到
iframe中,会不会对基座的document产生影响 - 答案是不会,对此做了一个测试:10w 表单在不同容器下的表现 [查看]
- 仅
preloadApp支持的配置项,exec会在预加载时启动应用start - 和
startApp一样,也会将子应用中的script插入沙箱iframe,调用mount等相关事件和方法
在 micro-app 中也有预加载,区别在于:
| 分类 | micro-app |
wujie |
|---|---|---|
| 加载方式 | 通过 start 配置 preFetchApps,或通过 microApp.preFetch |
只能通过 preloadApp |
| 仅加载静态资源 | 支持 | 不支持 |
| 将载静态资源解析成可执行代码 | 解析并处理资源,不渲染 | 除了 script 和动态加载的样式,全部注入容器暂存沙箱中不可见 |
| 执行代码并在后台渲染 | 支持 | active 注入资源,start 启动应用 |
| 关闭沙箱和样式作用域 | 可选 | 不支持 |
| 关闭子应用请求的自动补全 | 可选 | 不支持 |
micro-app预加载参考,见:microApp.start- 注 ⑥ [查看]micro-app预执行主要体现在沙箱对预渲染的处理,见:WithSandBox默认沙箱 - 预渲染部分 [查看]
问题 1:activated 重复调用
预加载 alive 模式的应用,默认 exec 不预执行,在启动应用时生命周期 activated 会调用 2 次:
start应用时队列执行mount调用 1 次start之后返回destroy前调用 1 次
问题 2:缺失必要的参数检查
- 见:
startApp的bug[查看]
预加载优化有 4 点:
- 提前缓存应用入口资源,加载并缓存资源中的样式和
script[查看] - 提前将
template注入沙箱body作为临时容器,见:创建容器渲染资源 [查看] - 提前将
script注入沙箱head,见:start[查看] - 提前收集样式元素打补丁,见:
styleSheetElements[查看]
不同模式下预加载对应的优化点:
| 模式 | 预加载 | 预执行 |
|---|---|---|
alive |
1、2 | 1、2、3 |
umd |
1 | 1、3、4 |
| 重建 | 1 | 1 |
umd 模式预加载补充说明:
- 和重建模式一样,预加载后
startApp会销毁实例,只能用于提前缓存资源 - 预执行后
startApp会清空容器重新注入资源 - 只有
umd需要用到styleSheetElements恢复样式,其它模式只记录不使用
除此之外可以通过 setupApp 提前缓存配置,见:文档 [查看]
- 这样避免预加载和启动应用时重复填写配置信息
wujie 中的预加载机制和 qiankun、micro-app 不同点:
| 模式 | qiankun |
micro-app |
wujie |
|---|---|---|---|
| 手动预加载 | prefetchApps |
microApp.preFetch |
preloadApp |
| 预加载启动配置 | prefetch |
preFetchApps |
❎ 不支持 |
| 分级加载 | ❎ 不支持 | 细粒度最好 | 支持 |
| 优先权 | 预加载 | 预加载 | startApp [查看] |
- 关于分级加载在上述已经总结,见:通过
exec预执行 [查看]
由于优先权不同,会直接影响到应用的加载机制、生命周期调用等情况,具体需要根据实际开发来看
目录:sandbox.ts - WuJie [查看]
用于创建应用实例,和 micro-app 的 CreateApp 的作用是一样的 [查看]:
| 分类 | micro-app |
wujie |
|---|---|---|
| 创建实例 | CreateApp:应用实例类 [查看] |
WuJie:应用实例类,也是沙箱实例类 |
| 映射表 | appInstanceMap 应用实例映射表,和组件映射表不同 |
idToSandboxCacheMap 实例映射表,可以通过应用名从映射表获取实例 [查看] |
| 映射表添加方式 | appInstanceMap.set |
addSandboxCacheWithWujie [查看] |
| 加载资源 | 自动:构造函数调用 loadSourceCode [查看] |
手动:加载提取 template 后通过 active 注入资源到容器 [查看] |
| 启动沙箱 | 构造函数调用 createSandbox [查看] |
构造函数调用 iframeGenerator [查看] |
Proxy |
Proxy、iframe |
Proxy 或降级时用 localProxy [查看] |
手动 start |
不支持手动启动 | startApp 或 preloadApp 时调用应用 start 方法 [查看] |
mount 应用 |
自动:由组件或资源加载完毕决定,在 mount 中会 start 沙箱 [查看] |
仅支持由 start 方法通过队列执行挂载 [查看] |
unmount 应用 |
由组件 disconnectedCallback 发起 [查看] |
组件 disconnectedCallback [查看]、手动销毁 destroy [查看] |
| 复杂度 | 分了 3 类,组件实例:MicroAppElement [查看],应用实例:CreateApp [查看],沙箱实例:IframeSandbox 或 WithSandBox [查看] |
只要关心 WuJie 应用实例、组件实例几乎可以忽略 |
| 优点 | 支持多种隔离方案,自动加载资源、配置沙箱、挂载应用 | 简单,只有 iframe 作为沙箱天然隔离,支持容器降级处理 |
| 缺点 | 过于复杂,从语意上看有的方法在 3 个实例上相互重叠,容易混淆;不支持容器降级处理 | 过于零散,缺乏逻辑抽象分离,源码 bug 有点多 |
无论是
wujie还是micro-app在解读的分支源码中都存在不同的逻辑问题
idToSandboxMap:appInstanceMap应用实例映射表 [查看]appEventObjMap:EventBus事件映射表 [查看]mainHostPath主应用origin
这里做了个判断:
| 所在环境 | 嵌套情况 | 注入方式 |
|---|---|---|
| 基座创建应用实例 | 作为子应用 | 通过 window.__WUJIE.inject 从上一层获取整个注入对象 |
| 基座创建应用实例 | 最顶层基座 | 声明最初要注入的对象 this.inject |
子应用通过 window 调用 |
作为子应用 | window.__WUJIE.inject[name] 从上一层获取对应的映射表 |
这样无论是子应用还是基座,最终拿到的
inject对象都是同一个,见:appEventObjMap[查看]
见:Wujie 实例中关键属性 [查看]
列举几个关键属性:
degrade 主动降级
- 由配置提供,如果没提供也会根据
wujieSupport以当前环境决定是否降级
plugins 插件系统
- 通过
getPlugins拍平插件,并合并默认插件返回数组,见:特殊属性 - plugins 插件集合 [查看]
bus 事件通信
- 通过
EventBus进行通信,依赖appEventObjMap确保父子通信对象唯一性,见:EventBus[查看] - 将
bus绑定在实例属性provide,在应用内可以通过window.$wujie?.bus通信,见:常规属性 [查看]
- 通过
appRouteParse提取应用urlElement、appHostPath、appRoutePath[查看] - 获取基座的
origin:mainHostPath - 通过
iframeGenerator初始化沙箱iframe[查看]
degrade |
代理 | 说明 |
|---|---|---|
true |
降级代理 | localGenerator [查看] |
false |
非降级代理 | proxyLocation [查看] |
区别:
| 分类 | localGenerator |
proxyLocation |
|---|---|---|
window |
沙箱 window |
proxyWindow |
document |
proxyDocument |
proxyDocument |
location |
proxyLocation |
proxyLocation |
通过流程图了解两个代理的区别 [查看]:
proxyWindow在degrade降级下不可用 [查看]proxyLocation在degrade下子应用内不能通过location调用 [查看]proxyDocument的差别,见:localGenerator-proxyDocument[查看]
流程:
- 通过代理方法拿到上述代理对象绑定在
wujie实例中,见:代理在哪调用 [查看]
在添加实例到映射表之前要将 proxyLocation 绑定在 provide,这样:
- 子应用就可以通过
window.$wujie.location去调用proxyLocation - 在
WuJie构造函数中provide绑定了bus和location,见:常规属性 [查看]
最后通过 addSandboxCacheWithWujie 缓存当前实例添加到映射表,见:idToSandboxCacheMap [查看]
参数只有 1 个 options 对象,包含以下属性:
template:注入到容器的应用资源el:容器挂载节点,预加载时不提供挂载节点,容器挂载到沙箱bodyprops:需要传入应用的数据alive:是否用保活模式激活应用fetch:自定义加载方法,不提供采用全局window的fetch方法replace:用于替换资源,存在bug,见:通过配置替换资源 [查看]
分 5 部分:
- 更新配置应用信息 [查看]
- 处理子应用
fetch[查看] - 路由同步 [查看]
- 将
template注入容器,如果容器不存在则需要创建新容器 [查看] - 完成激活应用:样式打补丁、更新
provide[查看]
注入容器分为 3 种情况:
degrade主动降级:无论切换应用还是初始化都会创建新的iframe容器shadowRoot切换应用:只有alive模式更换挂载节点,其它模式重新注入资源shadowRoot应用初始化:创建容器后注入资源
有 2 种情况会 active 激活应用:
在 active 激活应用时容器节点变更有 4 种情况:
| 场景 | 容器 | degrade 降级 |
el 容器挂载点 |
容器挂载位置 |
|---|---|---|---|---|
| 预加载应用、初次启动应用 | iframe |
true |
没有 | 沙箱 iframe |
| 每次启动应用 | iframe |
true |
已提供 | el 容器挂载点 |
| 预加载应用、初次启动应用 | shadowDom |
false |
没有 | 沙箱 iframe |
| 每次启动应用 | shadowDom |
false |
已提供 | el 容器挂载点 |
关于容器的操作,见:容器在哪清除 [查看]
第一步:用参数 options 更新实例,相关属性,见:实例中关键属性 [查看]
需要额外说明的属性:
hrefFlag:设置为false表明当前容器来自基座,见:特殊属性 [查看]provide:绑定在this.provide.props,应用中通过window.$wujie.props获取activeFlag:表明应用已激活
第二步:等待 iframe 初始化 await this.iframeReady
见 iframeGenerator - iframeReady [查看]
需要等待 iframeReady 的场景:
- 除了
alive和umd模式切换应用时iframeReady已加载完毕,其它情况都有可能需要等待 - 如果加载顺利的话,
iframeReady会在active之前加载完毕,见:iframeGenerator[查看]
在
qiankun中有个frameworkStartedDefer,用途是一样的,见:startSingleSpa[查看]
- 都是先发起一个微任务后,继续执行后续流程;
- 在启动应用时会等待微任务执行完毕,才开始挂载应用
仅限提供 fetch 配置,原因:
- 配置重写
fetch函数时:相对路径通过基座的url进行补全 - 子应用
fetch时:相对路径通过沙箱中base元素进行补全
于是:
- 重写
fetch函数通过getAbsolutePath指向proxyLocation[查看] - 将重写的
fetch绑定到沙箱window和应用实例中
但是基座加载资源时用不到:
importHTML:提取应用资源,fetch还未指向proxyLocationprocessCssLoaderForTemplate:手动加载样式,针对所有应用,通常会指定一个公共资源路径
加载资源通常是绝对路径;当配置没有提供
fetch时,会默认从全局对象中获取
子应用不需要补全路径:
- 子应用
fetch相对路径时,通过base元素自动转换成绝对链接
只适合通过基座修改 fetch 这一场景:
- 因为
fetch是在基座的作用域下,拿不到应用的base元素 - 如果子应用
fetch是相对路径,需使用proxyLocation通过getAbsolutePath补全
比如说在配置 fetch 的情况下:
- 通过基座统一获取
authorization作为为每个应用当独fetch请求鉴权 - 子应用中通过相对路径获取本地资源,将路径根据不同的应用进行匹配
补充:
以下属性用于同步路由:
| 同步方法 | sync |
url |
prefix |
|---|---|---|---|
syncUrlToIframe [查看] |
同步路由来自当前路由还是资源入口链接 | 资源入口链接 | 短路径集合 |
syncUrlToWindow [查看] |
是否同步路由到基座 | 不需要 | 短路径集合 |
只有
url是必选属性,其它都可选;active的url存在bug,见:特殊属性 [查看]
执行过程,从左到右:
| 执行方式 | syncUrlToIframe |
syncUrlToWindow |
|---|---|---|
alive 预执行 |
执行 | 执行 |
alive 预执行后 startApp |
不执行 | 执行 |
alive 切换应用 |
不执行 | 执行 |
| 其它模式 | 执行 | 执行 |
嵌套顺序:
- 先从基座至上而下,然后应用从下往上
- 基座嵌套基座,也是这样层层传递
alive模式再次启动,不执行syncUrlToIframe是因为初始化时已同步,之后只需同步路由到主应用
通过 template 更新 this.template,作为需要注入容器的资源。
非重建模式实例已存在时,
startApp不需要提供资源,因为初次激活应用时template已绑定
用 iframe 作为容器,应用中的弹窗由于在 iframe 内部将无法覆盖整个页面,见:文档 [查看]
关联属性 degradeAttrs,补充文档没有的说明:
- 在
wujie中所有的iframe容器只设置了宽高100%,这并不能够适应实际情况 - 使用这个配置可以通过
style适配容器节点的样式 - 同样适用于
locationHrefSet拦截location.href的劫持容器 [查看]
degradeAttrs 是一个固定的配置,如何根据应用配置不同的属性:
- 通过
setupApp根据应用保存不同的配置,见:文档 [查看] - 通过不同的组件分开
startApp,从而配置不同的属性
同理也适用于其它固定的配置信息,需要根据不同的应用做出差异化的表现
不要试图通过样式去匹配不同 iframe 容器的 id:
- 当通过
locationHrefSet创建持容器时,设定好的样式会随iframe容器的id一同丢失
主动降级分 3 个部分:
- 创建
iframe容器并挂载到指定节点 - 销毁沙箱记录,为创建的
iframe新容器打补丁 - 注入
template到iframe容器中
为了便于理解,整个总结中将容器及相关对象划分如下:
- 沙箱
iframe:用于存放应用script的沙箱,见:iframeGenerator[查看] iframe容器:降级时存放应用资源的容器,其中script会被注释 [查看]- 劫持容器:通过
locationHrefSet劫持子应用中通过location.href跳转的页面 [查看] shadowRoot:默认情况下存放应用资源的容器,其中script会被注释 [查看]
相应的
iframeWindow、iframeBody、iframeDocument全部为沙箱iframe中的对象
第一步:创建 iframe
rawDocumentQuerySelector获取沙箱iframeBodyinitRenderIframeAndContainer创建iframe容器挂载到指定节点 [查看]
iframe 容器的挂载点:
el:startApp时通过配置指定iframeBody:preloadApp临时存放在沙箱中
如果
startApp没有提供el挂载节点,也会存放在沙箱iframeBoody中。此时应用不会报错但不可见。
第二步:更新容器,销毁 iframeBody
- 将挂载的节点绑定到
this.el - 若配置了
el容器,清空iframeBody,确保渲染容器只有 1 个,见:容器在哪清除 [查看] patchEventTimeStamp:修复vue的event.timeStamp问题onunload:当销毁子应用时主动unmount子应用
this.el 挂载节点有啥用:
| 流程 | 执行方法 | 用途 |
|---|---|---|
active [查看] |
renderElementToContainer |
将容器添加到挂载点 |
start [查看] |
removeLoading [查看] |
删除 loading 状态 |
mount [查看] |
removeLoading [查看] |
删除 loading 状态 |
destroy [查看] |
clearChild |
清空挂载节点 |
processAppForHrefJump [查看] |
renderElementToContainer |
应用内前进后退时替换容器 |
renderElementToContainer 在以下情况通过第三方调用执行
active应用:degrade时通过initRenderIframeAndContainer[查看]processAppForHrefJump前进时通过renderIframeReplaceApp[查看]
onunload 是一个废弃的方法,随时可能被浏览器弃用
- 用于
iframe容器在Dom中销毁时卸载应用,相当于web component的disconnectedCallback[查看]
第三步:注入 template 到容器中
在降级状态下每次 active 应用时通过 this.document 记录 iframe 容器
- 主要用于区分是否是初次加载,以及记录、恢复事件 [查看]
- 无论是初次加载还是切换应用,降级状态都会新建
iframe容器,即便alive模式也不例外
注入 template 有 3 种情况:
分支 1 - alive 模式下切换应用
- 将
this.document中的html根元素替换iframe容器中的html根元素 - 在保活场景恢复所有元素事件,见:记录、恢复
iframe容器事件 [查看]
分支 2 - 非 alive 模式下切换应用
- 通过
renderTemplateToIframe将template注入创建iframe[查看] recoverDocumentListeners非保活场景需要恢复根节点的事件,防止react16监听事件丢失,见:记录、恢复iframe容器事件 [查看]
分支 3 - 初次渲染
- 通过
renderTemplateToIframe将template注入创建iframe[查看]
至此整个降级过程完成,直接返回不再执行下面流程
第一步:挂载容器用到指定节点
降级时通过 this.document 来区分初次加载还是切换应用,而默认状态通过 this.shadowRoot 来区分
注入 template 有 3 种情况:
分支 1:切换应用
- 通过
renderElementToContainer将this.shadowRoot.host挂载到指定节点 [查看]
切换应用只有 umd 模式需要重新注入资源:
alive模式:完成切换后直接返回,不再继续执行资源注入容器的流程umd模式:虽然实例存在shadowRoot,但active前会通过unmount清空容器 [查看]- 重建模式:
active前会随应用一同销毁,不存在shadowRoot,也不走这个分支
分支 2:初次加载
- 获取
iframeBody,如果没有提供挂载节点,作为备用 - 通过
createWujieWebComponent创建自定义组件:wujie-app - 通过
renderElementToContainer将创建的组件挂载到指定容器 [查看]
shadowRoot在创建web component时候绑定到实例,见:connectedCallback[查看]
分支 3: 预加载应用
- 预加载也是初次加载和
分支 2流程一模一样 - 不同的是预加载不提供挂载节点
el,而是用iframeBody作为临时挂载节点 - 预加载之后
startApp如果没有销毁实例的情况下,会按照分支 1执行流程
第二步:注入 template 到容器中
- 通过
renderTemplateToShadowRoot将template渲染到shadowRoot[查看] - 包括
umd模式和重建模式,注入template之前shadowRoot仅仅是个空壳
alive初次加载的时候也需要注入资源到shadowRoot
注入资源后会发生什么:
startApp添加的loading因为资源注入而撑开挂载节点,变得可见 [查看]- 由于当前只注入了已注释
script的静态资源,而对于单例应用来说此时还未渲染 - 需要等到
start启动应用,将入口script添加到沙箱iframe后才会渲染应用 [查看]
如何撑开节点:
- 降级容器通过
createIframeContainer设置iframe宽高 [查看] shadowRoot通过renderTemplateToShadowRoot添加div撑开元素 [查看]
- 通过
patchCssRules为子应用样式打补丁 [查看] - 更新
this.provide.shadowRoot
this.provide 是子应用中 window 全局对象中的 $wujie,见:文档 [查看]:
- 在实例构造时通过
patchIframeVariable将其注入沙箱window[查看] shadowRoot仅限非降级状态下才能提供
在子应用中获取根节点:
| 分类 | iframe 容器 |
shadowRoot 容器 |
|---|---|---|
shadowRoot |
不存在 | window.$wujie.shadowRoot |
| 容器根节点 | window.__WUJIE.document |
window.__WUJIE.shadowRoot |
沙箱 document |
Node.prototype.ownerDocument |
Node.prototype.ownerDocument |
容器中所有元素 document 一定是沙箱 iframe.contentDocument:
- 因为每个元素都通过
patchElementEffect打了补丁 [查看]
而在子应用中 document 的 property 则会指向 proxyDocument,因为:
- 沙箱初始化时通过
patchDocumentEffect劫持了iframeWindow.Document.prototype[查看] - 劫持的属性会在
get时指向proxyDocument[查看]
而
proxyDocument只有获取script指向沙箱iframe.contentDocument,其余全部指向容器,比如iframe容器的document,或是shadowRoot
为此沙箱 iframe 初始化时保留了沙箱 document 4 个原始方法:
- 通过
initIframeDom绑定在沙箱iframe.contentWindow[查看]
head 和 body:
- 沙箱下的
head、body通过patchDocumentEffect劫持指向proxyDocument[查看] - 而
proxyDocument中的head、body指向沙箱document - 而容器中的
head、body通过patchRenderEffect重写了Dom操作 [查看]
为了方便拿到容器的 head 和 body
shadowRoot:通过实例sandbox.shadowRoot['head'|'body']获取iframe容器:通过实例sandbox['head'|'body']获取
子应用中也可以直接从
document['head'|'body']获取
启动应用不提供 el 挂载点
- 虽然在
ts已明确必须提供el挂载点,但是如果ignore或js项目就没提供怎么办呢?
触发情况:
- 受影响:非重建模式切换
shadowRoot容器的应用 - 不受影响:预加载、初次启动、降级渲染,会将沙箱
iframe作为备用容器
解决办法:
- 和
micro-app组件挂载一样做条件判断,条件不满足的情况直接返回不做任何渲染
无论容器是 iframe 还是 shadowRoot,都要给容器添加属性 WUJIE_APP_ID 值为应用名,用途:
- 通过
querySelector查找iframe[${WUJIE_APP_ID}="${id}"]找到iframe容器 - 通过自身属性
WUJIE_APP_ID获取应用实例
属性是由
wujie内部实现,使用者无需手动添加,这里写出来是作为增加对wujie的了解
添加标签 WUJIE_APP_ID 都来自 active 激活应用时创建容器:
createIframeContainer:创建iframe容器 [查看]createWujieWebComponent:创建自定义组件wujie-app
参数:
getExternalScripts:返回加载应用中静态script集合的函数 [查看]
返回:
- 类型
Promise<void>的微任务,通过await确保应用成功启动
如果 this.iframe 被销毁的情况会直接返回不再处理:
this.iframe只有在销毁应用destroy设为null[查看]
start 调用场景有 3 个:
执行
start启动应用前必须先active激活应用 [查看]
整个 start 的流程就是对 this.execQueue 队列的收集和提取并执行:
- 在队列中
push进来的都是同步的执行方法,执行队列通过shift实现先入先出 - 在队列下标的每个方法中有可能存在微任务和宏任务,但执行顺序看所在执行的队列前后顺序
- 因为每个队列的执行都是在上一个队列执行过程中通过
shift提取并执行
this.execQueue.push 共计 7 处:
beforeScriptResultList:插入代码前通过插件添加的scriptsyncScriptResultList+deferScriptResultList:子应用中同步script,包含deferthis.mount:基座主动调用mount方法domContentLoadedTrigger:触发DOMContentLoaded事件afterScriptResultList:插入代码后插件添加的scriptdomLoadedTrigger:触发loaded事件- 返回
Promise:所有的execQueue队列执行完毕,start才会在最后resolve
有 1 处存在即执行:
asyncScriptResultList:子应用中带有async的script
还有一种特殊情况,动态加载应用中的样式和
script[查看]
总共 8 处,然后根据用途还可以细分如下
必须会添加到队列有 4 处:
this.mount、domContentLoadedTrigger、domLoadedTrigger、返回的Promise对象
根据集合添加到队列有 3 处:
beforeScriptResultList,见:文档 [查看]afterScriptResultList,见:文档 [查看]syncScriptResultList+deferScriptResultList:提取子应用的script
beforeScriptResultList和afterScriptResultList下标类型文档介绍有限,建议查看源码类型 [查看]
提取子应用的 script:
通过 getExternalScripts 得到 scriptResultList [查看]
声明 3 个集合:
syncScriptResultList:同步代码asyncScriptResultList:async无需保证加载顺序,所以不用放入执行队列deferScriptResultList:defer需要保证加载顺序并且在触发DOMContentLoaded前完成
遍历 scriptResultList 根据属性分类添加到上述 3 个集合,关于属性见:processTpl 提取资源 [查看]
无论是同步代码还是异步代码,
getExternalScripts提取的script都是应用中的静态资源,而不是动态添加的script;而像React和Vue这样的单例应用通常只暴露一个静态的script作为入口,其余的script和样式动态添加,见:rewriteAppendOrInsertChild[查看]
遍历的集合下标是 promise 有 2 处:
- 同步和异步代码执行:
syncScriptResultList、asyncScriptResultList - 共同点:集合中的每一个方法都返回
Promise、需要在微任务中执行insertScriptToIframe[查看] - 不同点:
syncScriptResultList需要等待队列按顺序提取执行,asyncScriptResultList遍历同时立即发起微任务
插入队列 execQueue 的方法是同步任务:
- 在阅读执行队列前需要说明的是,所有队列都是在上下文中
push - 所有
push的方法都是同步任务,而方法中允许发起微任务或宏任务 - 即便是最后返回的
Promise,也是在Promise方法中同步插入执行的队列
无论怎么添加队列,最终都是通过 this.execQueue.shift()() 从头部弹出插入队列的方法并执行
开始执行:
- 执行队列从 334 行开始,按照上下文主动提取并发起执行,见:源码 [查看]
asyncScriptResultList不加入队列,会以Promise微任务的形式在当前上下文执行完毕后依次执行
需要说明的是:
- 开始提取
execQueue是在start返回Promise之前执行,队列方法和Promise内部方法是上下文 - 所以队列开始时,返回的
Promise还没有将最后要执行的队列插入execQueue
循环插入队列共有 3 处:
- 分别是:
beforeScriptResultList、syncScriptResultList+deferScriptResultList、afterScriptResultList - 每个队列通过
insertScriptToIframe注入script到沙箱iframe[查看] - 注入
script之后再将window.__WUJIE.execQueue.shift()()注入沙箱iframe - 这样每个
push的队列,会在沙箱iframe加载完script后通过shift提取下一个任务并执行
主动插入队列有 4 处:
mount、domContentLoadedTrigger、domLoadedTrigger、返回的Promise- 会在执函数末尾添加
this.execQueue.shift()?.();提取并执行接下来的队列
如果没有主动配置 fiber 为 false 的情况下:
- 除最后返回的
Promise之外,所有的队列将包裹在宏任务requestIdleCallback中空闲执行 - 但是每个队列的执行,必须是在上一个队列结束后通过
shift提取并执行
无论队列中执行的是上下文,还是微任务,亦或者是宏任务,最终都需要按照队列顺序来
在
WuJie实例中通过this.requestIdleCallback执行空闲加载,它和requestIdleCallback的区别在于,每次执行前先判断实例是否已销毁沙箱iframe
只有 1 种情况可以无视队列顺序:
asyncScriptResultList:子应用中异步加载的script
而最后返回的 promise 也只做 1 件事:
- 插入最终执行的队列,在队列的方法中将执行
resolve通知外部start完成
队列有 3 处微任务:
asyncScriptResultList:异步代码syncScriptResultList+deferScriptResultList:同步代码- 返回的
Promise对象
只有异步代码是立即添加微任务,其它按照
execQueue队列顺序等待提取并执行
fiber 没有关闭的情况下有 7 处宏任务:
- 除了通过返回的
Promise插入末尾的队列,都会通过requestIdleCallback插入宏任务
执行的顺序按照
execQueue队列先后顺序执行
执行顺序如下:
asyncScriptResultList遍历异步代码,将微任务放入微队列等待执行- 334 行开始提取第 1 个队列并执行
this.execQueue.shift()() - 执行
beforeScriptResultList,如果存在的话 - 执行
syncScriptResultList+deferScriptResultList,如果存在的话 - 依次执行
mount、domContentLoadedTrigger - 执行
afterScriptResultList,如果存在的话 - 执行
domLoadedTrigger - 通过返回的
Promise方法中执行最后添加到execQueue的方法
asyncScriptResultList 执行顺序:
- 会在
execQueue队列中第 1 个微任务或宏任务之前完成所有异步代码注入
因为异步代码属于微任务,上下文必然会优先执行
fiber 模式,第 1 微任务或宏任务:
beforeScriptResultList存在的话,第 1 个队列是宏任务,否则继续往下看- 同步代码存在的话,第 1 个队列是微任务,否则继续往下看
- 以上都不存在的话会通过
mount发起第一个宏任务
fiber模式下队列按照顺序执行完 1 个,提取下个队列再执行
关闭 fiber 模式,第 1 个微任务或宏任务:
beforeScriptResultList存在外联script,第 1 个宏任务由onload发起- 同步代码存在,第 1 个微任务由
Promise发起,否则继续往下看 afterScriptResultList存在外联script,第 1 个宏任务由onload发起- 在最后返回的
Promise对象resolve完成任务前执行asyncScriptResultList
顺序从上至下有 1 条满足后面的就不用再看
虽然关闭了 fiber,但队列中的任务依旧是按照顺序,执行完 1 个,提取下个队列再执行
- 队列的方法是同步的,虽然方法内部可能会发起微任务
为什么外联 script 是宏任务:
- 队列中无论是
appendChild还是dispatchEvent都是同步操作 - 只有通过
src加载的script会通过宏任务onload回调执行execQueue.shift()()
start在返回Promise之前,队列中只有同步方法会存在问题,见:start启动应用的bug[查看]
为什么关注 asyncScriptResultList 异步代码执行顺序:
- 因为
execQueue队列中所有同步的方法、微任务、宏任务,都按照队列先后顺序 - 通过异步代码可以作为参考对象,很好的了解整个队列的执行顺序
- 如果异步代码不存在,执行顺序依旧没变,忽略异步代码微任务集合,继续往下执行微任务或宏任务
一道思考题:应用中的
script是怎么注入到沙箱iframe,见:队列前的准备 [查看]
关于微任务队列:
在 micro-app 有一个 injectFiberTask,见 micro-app 源码分析中注 ⑭ [查看],对比如下:
| 对比项 | wujie |
micro-app |
|---|---|---|
| 添加队列 | 根据不同类型,手动添加每一组队列 | injectFiberTask |
| 集合对象 | execQueue |
fiberLinkTasks |
| 添加方式 | push |
push |
| 执行方式 | this.execQueue.shift()?.(),在当前队列提取下一个队列并执行 |
serialExecFiberTasks,通过 array.reduce 拍平队列依次执行 |
| 立即执行 | asyncScriptResultList,遍历集合添加到微任务中执行 |
调用 injectFiberTask 时提供 fiberTasks 为 null |
比较而言
micro-app的injectFiberTask,更简洁、抽象,灵活度也更高
问题 1:执行前最后返回的 resolve 并没有插入队列
- 如果
execQueue除了最后返回的Promise对象之外,没有微任务也没有宏任务 - 那么返回的
Promise内部方法中插入execQueue末尾的队列永远无法执行
原因:
- 开始提取并执行队列的方法,相对于返回的
Promise函数优先执行,它们是上下文关系 - 如果返回的
Promise之前全部都是上下文同步关系,那么当队列执行完毕后,才会将Promise中的队列插入execQueue - 这样就意味着永远不会执行末尾队列中的
resove,因此start被中断
产生问题的前提必须以下 2 个条件全满足:
- 手动关闭
fiber - 静态应用没有
script
preloadApp 出现问题的场景:
- 预加载本身不会导致问题,因为预加载默认不会
start,即便配置exec启动应用start,问题也会发生在startApp切换应用时
startApp 启动应用 start 问题的场景:
| 触发条件 | 包含模式 | 问题 |
|---|---|---|
| 预加载后启动 | alive 模式应用 |
生命周期 activated 可能会不执行,destroy 不返回 |
| 预执行后启动 | 所有模式 | 卡在 await sandbox.preload 暂停不再执行 |
| 初次启动 | umd 和重建模式 |
destroy 不返回 |
| 切换应用 | 重建模式 | destroy 不返回 |
关于预加载和预执行,见:
preloadApp[查看]
fiber 下能正常执行:
- 除了最后返回的
Promise,所有队列都通过宏任务requestIdleCallback中执行 start返回的Promise内部函数属于上下文,优先于宏任务添加到队列
fiber只能提供手动配置关闭,默认为true
静态提取和动态注入的 script 能正常执行:
- 静态提取的
script将作为同步或异步代码,每一个队列都是微任务 - 动态注入的
script前提一定是来自同步或异步代码队列操作
手动注入 script 且不为 async,将根据情况决定:
- 包含外联
script:下个队列将通过onload宏任务提取并执行,能正常执行 - 仅有内联
script:将导致问题 1
手动注入带有
async属性的script将导致问题 2
问题 2:通过手动注入 script 打断队列
- 如果
beforeScriptResultList或afterScriptResultList存在async属性的script - 将导致无法提取执行下一个队列,造成
execQueue队列后面的script将不能插入沙箱
原因:
- 沙箱注入外联
script后,会根据async去判断要不要执行下一条队列
排除范围:
processTpl提取带有async的外联script,将作为异步代码注入沙箱,不影响队列 [查看]rewriteAppendOrInsertChild动态添加的script不存在async属性 [查看]
问题 2 的场景包含了问题 1,此外因打断 nextScriptElement 从而导致后续队列无法执:
- 执行顺序见:队列执行顺序 [查看]
设计初衷:
- 因为异步代码
asyncScriptResultList和execQueue队列集合是没有关系 - 但异步代码也是通过
insertScriptToIframe将script插入沙箱iframe - 如果异步代码也去调用
execQueue.shift()(),可能会造成队列执行顺序错乱
复现问题 1:没有 script
static-app:创建一个没有script,没有style的静态子应用 [查看]- 添加一个
StaticPage.tsx页面组件,关闭fiber,不添加js-loaders[查看] - 应用组件
Wujie.tsx:添加startApp返回的函数destroy并打印 [查看]
复现结果:
- 点开
static应用,打开调试面板,刷新页面什么都没返回 - 点开
react应用,返回destroy方法
复现问题 1:存在 async 的 script 可以正常注入
复现结果:
- 子应用中
script的async会通过异步集合asyncScriptResultList添加到沙箱iframe中 asyncScriptResultList不会影响execQueue
修复问题 1:
- 源码 334 行,第 1 个执行队列
this.execQueue.shift()();前主动添加一个微任务 [查看] - 这样确保队列中最少包含 1 个微任务,而返回的
Promise内部函数会在队列结束前执行 - 这样确保了队列最后能够顺利
resolve
this.execQueue.push(() => Promise.resolve().then(
() => this.execQueue.shift()?.()
));
this.execQueue.shift()();
复现问题 2:手动注入 script 打断 execQueue 队列
- 复现前确保
react应用正常,复制一份ReactPage.tsx作为BeforePage.tsx[查看] - 配置手动注入
script:要求带有src和async
复现结果:
BeforePage.tsx应用加载过程中被jsBeforeLoaders打断不会mount应用
修复问题 2:
- 遍历
beforeScriptResultList和afterScriptResultList时去掉script的async,如下:
beforeScriptResultList.forEach(({ async, ...beforeScriptResult }) => {})
afterScriptResultList.forEach(({ async, ...afterScriptResult }) => {})
由于目前还在研究阶段,没有对官方提 PR。
关于 bug 的总结:
- 使用
wujie过程中谨慎关闭fiber,默认是不会关闭fiber的 - 不要在
beforeScriptResultList或afterScriptResultList传入带有async属性的对象,虽然ScriptObjectLoader这个对象是允许配置async的,虽然官方在文档中也并没有说async是可选配置,但是擅自添加async在源码中是有逻辑问题的
execFlag 设置为 true,表示已启动应用:
execFlag会在destroy设null,从这里知道注销应用后只能重新创造应用实例
通过 importHTML 包装方法 getExternalScripts 提取要注入沙箱的静态 script 集合 [查看]
getExternalScripts返回的script集合中,属性contentPromise是一个微任务- 这也就是为什么同步代码和异步代码都是通过微任务将
script添加到沙箱中执行的原因
为了保证其顺序,也因此不管是微任务还是宏任务,都要求在上一个队列执行完后提取执行下一个队列
一道思考题:应用中的 script 是怎么注入到沙箱 iframe
- 通过
importHTML提取应用资源 [查看] - 通过
processTpl提取资源中的样式和script,并替换成注释 [查看] - 通过
processCssLoader加载样式并还原到入口资源 [查看] - 通过
active将处理的入口资源注入容器 [查看] - 通过
patchRenderEffect为容器打补丁 [查看] - 通过
start提取script加入队列,其中包括应用入口script[查看] - 通过
insertScriptToIframe将队列中的script注入沙箱iframe[查看] - 由于已打补丁,通过
rewriteAppendOrInsertChild处理动态添加的script[查看] - 再次执行
insertScriptToIframe将动态添加的script注入沙箱iframe[查看]
React入口script将作为同步代码在微任务中注入沙箱,然后通过微任务动态加载chunk script
iframeWindow 提取沙箱的 window,用于注入 script
- 同时绑定
__POWERED_BY_WUJIE__到沙箱window,便于子应用确认运行环境
执行队列之前会通过 removeLoading 关闭 loading 状态:
- 关于加载状态,见:启动应用时添加、删除
loading[查看]
删除 loading 的条件:
- 没有提供
__WUJIE_UNMOUNT的所有模式,因为start不能像active那样判断当前应用是初次加载还是切换应用
umd 模式初次启动会重复调用 removeLoading:
- 第 1 遍:在
execQueue队列提取执行前,__WUJIE_UNMOUNT还没有挂载 - 第 2 遍:将应用入口
script注入沙箱后,发起mount挂载应用 [查看]
重复删除
loading只能导致重复执行,不会出现使用上的问题
1. 主动调用 mount 方法
- 见:
mount挂载应用 [查看]
2. 触发 DOMContentLoaded 事件
- 创建
DOMContentLoaded自定义事件,分别由沙箱document和沙箱window触发
3. 触发 loaded 事件
- 自定义事件
readystatechange,由沙箱document触发 - 自定义事件
load,由沙箱window触发
4. 返回 Promise
- 通过在返回的
Promise函数中添加队列最后要执行的任务 resolve释放返回的微任务,用于通知start完毕
单例应用会将静态 script 作为入口 script,然后动态加载样式和 script,但根据打包工具不同,入口 script 注入方式也不同。
| 打包工具 | 入口 script |
注入方式 |
|---|---|---|
vite |
外联 module |
外联 module |
react-create-app |
带有 defer 的外联 script |
内联 script 并忽略 defer |
umijs |
外联 script |
内联 script |
注入方式见:
getExternalScripts[查看]
无论是哪种类型 script,都会作为同步代码注入沙箱:
- 每个同步代码队列都是一个微任务
fiber开启状态下每个微任务都会发起宏任务requestIdleCallback
无论微任务还是宏任务,下个队列一定是在当前任务结束后发起
动态加载资源根据类型不同加载方式也不一样:
| 分类 | fiber |
加载方式 | 注入方式 |
|---|---|---|---|
| 内联样式 | -- | 无需载 | 上下文同步 |
| 外联样式 | -- | getExternalStyleSheets 发起微任务 |
上下文同步 |
外联 script |
默认配置 | getExternalScripts 发起微任务 |
requestIdleCallback 宏任务 |
外联 script |
手动关闭 | getExternalScripts 发起微任务 |
上下文同步 |
内联 script |
默认配置 | 无需载 | requestIdleCallback 宏任务 |
内联 script |
手动关闭 | 无需载 | 上下文同步 |
默认情况下在 React 应用中:
- 样式:会添加一个空的内联样式,然后上下文同步注入样式内容
script:无需加载,通过宏任务注入内联script
如果应用中不存在
chunk,那么仅需提取静态的入口script,不存在动态加载
执行顺序:
- 只有同步上下文的情况在当前任务中执行
- 微任务会在下一个队列发起的微任务或宏任务前执行
- 宏任务会在下一个队列发起的宏任务之前执行
因为每执行一个队列,同步任务会发起微任务,
fiber会发起宏任务
如果当前任务中通过微任务发起宏任务怎么做:
- 微任务会在下一个队列发起的微任务或宏任务前执行
- 微任务发起的宏任务会在队列执行的任务之后,下一个宏任务之前开始执行
触发场景:
- 只能在应用
start时通过execQueue队列执行mount[查看]
不执行挂载的情况:
| 模式 | 判断条件 | 说明 |
|---|---|---|
alive |
execFlag |
只有还未激活时才会通过 start 执行 mount |
umd |
mountFlag |
已挂载后将不再重复操作 |
只有初次加载才会调用
mount,而重建模式每次都是初次加载
无论什么模式挂载应用是为了提取执行下一个队列 execQueue,除此之外:
alive模式:通过沙箱window调用生命周期activatedumd模式:发起__WUJIE_MOUNT,调用生命周期,设置已挂载
activated会重复调用,见:预加载中的bug[查看]
因此除了 execQueue 提取执行下个队列外,mount 只适用于 umd 模式初次加载应用
从沙箱 window 中检测到 __WUJIE_MOUNT 才能执行当前流程
- 应用
start时,由同步代码注入沙箱挂载__WUJIE_MOUNT方法 [查看]
mount方法会在同步代码之后在队列中调用,但是执行方式略有差异,见下文
流程:
- 再次关闭挂载节点
loading状态,见:启动应用时添加、删除loading[查看] - 使用沙箱
window调用生命周期beforeMount - 调用子应用的
__WUJIE_MOUNT去渲染应用 - 使用沙箱
window调用生命周期afterMount - 设置
mountFlag避免重复挂载
删除
loading存在重复执行的情况,见:队列前的准备 - 关闭加载状态 [查看]
fiber 模式下 __WUJIE_MOUNT 都能正常执行:
- 入口
script注入沙箱后,无论同步还是异步,都会在mount前绑定__WUJIE_MOUNT - 因为
fiber模式下mount包裹在宏任务requestIdleCallback中
非 fiber 模式下,同步绑定 __WUJIE_MOUNT 正常执行:
- 同样会优先绑定
__WUJIE_MOUNT,因为他们是上下文关系
非 fiber 模式下,异步绑定 __WUJIE_MOUNT 入口文件是外联 script 正常执行:
- 因为发起渲染后,通过
onload在下个宏任务中执行mount
否则入口文件是内联 script 根据同步代码中最后一个队列决定:
| 最后 1 个队列 | 执行情况 | 说明 |
|---|---|---|
入口 script |
不执行渲染 | 同步提取执行 mount 时,微任务中的 __WUJIE_MOUNT 还未绑定 |
非入口 script |
正常执行 | 说明入口文件在此之前已绑定 __WUJIE_MOUNT 到沙箱 window |
关于入口
script是内联还是外联,参考:动态加载样式和script[查看]
因此建议:
- 生产过程中,请谨慎关闭
fiber - 如果没有必要的情况,请勿异步挂载
__WUJIE_MOUNT和__WUJIE_UNMOUNT
解决办法:
- 在
processTpl提取入口文件后,追加一个空的script[查看] - 这样在入口
script注入后,至少还有 1 个微任务,确保异步发起__WUJIE_MOUNT先挂载
由于在源码备注中提到异步渲染,所以对于不同的绑定方式做了不同的说明
- 使用沙箱
window调用生命周期activated - 这里存在
activated调用 2 次的情况,见:预加载中的bug[查看]
就目前来看这一步是多余的,除了
alive预执行,activated都会在startApp中调用 [查看]
this.execQueue.shift()?.()
- 这是所有模式必须做的流程,也是重建模式在
mount时唯一做的事 - 综上所述,
mount挂载应用似乎只关心umd初次渲染应用
卸载流程分为 3 部分:
重建模式只做这一步
- 使用沙箱
window触发生命周期deactivated
准备卸载 umd 模式子应用,要求:
mountFlag状态已挂载,见:Wujie实例中关键属性 [查看]- 子应用沙箱
window中已绑定__WUJIE_UNMOUNT - 不是
alive模式并且不是hrefFlag劫持容器,见:特殊属性 [查看]
umd 模式卸载和挂载流程一致,见:umd 方式启动 [查看]
- 使用沙箱
window触发生命周期beforeUnmount - 调用子应用挂载在
window上的__WUJIE_UNMOUNT - 使用沙箱
window触发生命周期afterUnmount mountFlag标记为未挂载
和挂载应用不同的是:
this.bus.$clear:清空子应用所有订阅的通信,见:WuJie实例中关键属性 [查看]- 非降级渲染需要清空
shadowRoot下所有元素,并清理记录在实例head、body的事件 - 最后将实例的
head、body下的元素全部删除
下次 active 激活应用时,将重新注入删除的资源,重新绑定清空的事件:
关于事件清理,见:
shadowRoot容器事件 [查看]
流程 1、2、3 分别对应上述归纳 3 类流程:
| 模式 | 卸载场景 | 流程 1 | 流程 2 | 流程 3 |
|---|---|---|---|---|
alive |
容器注销 | 执行 | 执行 | 不执行 |
umd |
容器注销 | 执行 | 不执行 | 执行 |
| 重建模式 | 容器注销、destroy |
执行 | 不执行 | 不执行 |
容器注销触发的方式:
alive 模式 unmount 时只做了 3 件事:
activeFlag失活、清理路由、触发生命周期deactivated- 不清理容器,也不注销应用,下次切换回应用时也不需要重复加载资源
startApp 触发应用 umount 的场景:
| 模式 | 卸载场景 | 流程 1 | 流程 2 | 流程 3 |
|---|---|---|---|---|
umd 切换应用 |
active 前 unmount |
执行 | 不执行 | 执行 |
umd 预执行后启动 |
active 前 unmount |
执行 | 不执行 | 执行 |
umd 预加载后启动 |
应用实例 destroy |
执行 | 不执行 | 不执行 |
| 重建模式存在实例 | 应用实例 destroy |
执行 | 不执行 | 不执行 |
| 重建模式初始实例 | 不执行 unmount |
不执行 | 不执行 | 不执行 |
alive |
不执行 unmount |
不执行 | 不执行 | 不执行 |
存在应用实例的情况下,umd 模式和重建模式会重复 umount:
| 操作 | 注销方式 | 重建模式 | umd 模式 |
alive 模式 |
|---|---|---|---|---|
| 切出 | 容器销毁,见:disconnectedCallback [查看] |
✅ | ✅ | ❗️ |
| 切回 | 通过 startApp 发起 unmount [查看] |
❎ | ✅ | ❎ |
| 切回 | 注销实例 destroy [查看] |
✅ | ❎ | ❎ |
- 容器注销
alive模式也会unmount,但不卸载资源 - 其余模式应用切出、切回都会执行一次
unmount
前提条件:应用实例已存在
idToSandboxCacheMap[查看]
其它触发应用 umount 的场景:
关于 onunload:
- 仅存在降级时
iframe容器,用于代替web component中的disconnectedCallback[查看] - 监听
popstate后退,会根据hrefFlag决定是否重新渲染并监听onunload[查看]
劫持容器通过 renderIframeReplaceApp,在注销渲染容器时发起 unmount [查看]:
- 之后,浏览器后退,还原渲染容器到挂载点,无需
unmount - 之后,浏览器前进,再次注销渲染容器发起
unmount - 之后,通过基座切回应用,参考上述:存在应用实例的情况下,不同模式的操作方式
应用通过
locationHrefSet发起的劫持,劫持容器本身是不需要unmount[查看]
沙箱 iframe
所有模式下都在 destroy 注销实例时设置为 null:
- 重建模式,除了初次
startApp之外每次一次启动就是一次destroy - 除了
alive模式,预加载没有预执行的情况下,首次startApp都会destroy
清除后的沙箱只能通过创建
WuJie实例才能重建 [查看]
容器 iframe
容器 iframe 会将 document 绑定到应用实例同名属性:
- 用于分辨应用是否为初次
active,和沙箱一样只在destroy时清除
但每次 active 应用就是对容器一次重建:
alive模式:将document的html元素添加到新容器- 其它模式:重新注入
template到新容器
那实例中的 document 存在的意义是什么呢:
alive模式,区别切换和首次加载,且换应用不需要注入资源- 其它模式记录容器
document上的事件,下次激活应用还原到新容器,见:事件恢复 [查看]
降级模式下,切换和首次加载应用的区别在于
iframe容器是否恢复事件
降级预加载 iframe 容器会添加到沙箱 body 中:
- 注入资源前会根据提供的挂载点
el,将清空沙箱body - 这样确保启动的子应用资源只会存放在新建的
iframe渲染容器里
预执行,容器处理方式也和预加载是一样的
容器 shadowRoot
和沙箱 iframe 一样,只要不是 destroy 就不会清除:
alive模式,不会自动清除容器,重新激活应用时也不需要再次注入资源umd模式,unmount时会清空容器,下次激活时重新注入资源 [查看]- 重建模式,每次切换应用
active前都会destroy后重建实例
shadowRoot的存在和iframe容器中的document一样,用于区分是否为初次加载
初次加载,通过 shadowRoot.host 挂载容器到指定节点:
| 执行方式 | 挂载节点 |
|---|---|
| 预执行 & 预加载 | 沙箱 body |
初次 startApp |
配置节点 el |
挂载之后会通过
renderTemplateToShadowRoot注入资源到容器 [查看]
预加载后 startApp,容器怎么处理:
| 模式 | 挂载节点 | 容器资源 |
|---|---|---|
alive |
将 shadowRoot.host 从沙箱 body 移动到 el 配置节点 |
不销毁不清空也不重新注入 |
| 其它模式 | destroy 注销应用后重建实例,将容器挂载到 el 配置节点 |
重新注入资源 |
预执行后 startApp,容器怎么处理:
umd模式:active之前会先通过unmount清空shadowRoot,然后重新注入资源- 其它模式:和预加载一样
劫持容器 iframe
由 locationHrefSet 劫持子应用 location.href 创建的容器 [查看]
劫持容器本身不会主动清除,只能通过重新渲染,从而在 Dom tree 中移除:
- 由基座路由变更导致基座重新渲染
- 或由浏览器前进后退导致重新渲染
degrade模式下因为存在bug不会劫持,因此不存在劫持容器
劫持容器的恢复有 2 个办法:
- 因为基座路由变更,可以通过
popstate前进恢复劫持容器 [查看] - 通过劫持子应用
location.href重建劫持容器
极端情况:
degrade预加载,正常启动;或者正常预加载,degrade启动
degrade 由实例构造时决定:
alive预加载时决定degradeumd预执行将保留预加载时的实例,包括degradeumd预加载不预执行,startApp后销毁实例,然后使用新的配置重建- 重建模式每次都会
destroy实例,然后使用新的配置重建
按照上面的规则决定实例最终会采用什么容器,从而保证能够正常加载渲染容器。这些容器该怎么注销、怎么清空请参考上述总结。
在子应用渲染完毕之后,提取子应用所有的样式,筛选挂载到外部:
- 兼容
:root选择器样式到:host选择器上,即获取样式改名后新增到容器head下 - 将
@font-face定义到shadowRoot外部,即插入应用shadowRoot.host末尾
为什么打补丁?
shadowRoot作为跟元素匹配的是伪类是:host,见:MDN [查看]- 在
shadowDom中不能解析@font-face,需要将其转移到document下
放入位置有什么讲究:
:host改名即可,放入容器的head会自动生效@font-face放入doocument下即可,但为了便于管理放在shadowRoot.host里面
补丁样式清空的方式,见:单独总结 [查看]
调用场景:
不会执行操作的情况:
degrade降级:没有shadowRoot,iframe容器也不存在兼容样式的问题- 配置
cssIgnores作为外联加载的样式:只提取内联样式打补丁 - 入口资源中包含
ignore属性的静态样式:将被注释代替 WUJIE_DATA_ATTACH_CSS_FLAG已处理过不处理
为什么处理过不再处理:
- 提取
:host样式之后,会将其存入集合styleSheetElements[查看] umd模式,下次切换应用会通过rebuildStyleSheets恢复样式 [查看]alive模式,资源没有变化不需要任何处理- 重建模式,每一次启动都是一次新的实例,所有流程重新来一遍
注意:
patchCssRules只能根据容器shadowRoot提取所有样式元素打补丁- 而对于容器中动态添加的样式,需要通过
handleStylesheetElementPatch来处理 [查看]
准确来说
patchCssRules是通过沙箱的iframe.contentDocument来获取所有的style元素,由于容器所有元素的ownerDocument都指向iframe.contentWindow.document,因此可以从沙箱document可以获取所有style元素
流程和 handleStylesheetElementPatch 中宏任务的回调函数是一样的 [查看]:
- 通过
getPatchStyleElements从提供的stylesheet中提取指定的样式 - 若存在
hostStyleSheetElement::host样式元素,将其插入shadowRoot.head - 若存在
fontStyleSheetElement:字体样式元素,将其插入shadowRoot.host末尾 - 如果通过上述任意样式打过补丁,标记
WUJIE_DATA_ATTACH_CSS_FLAG避免下次重复执行
篇幅太长单独整理了一篇,见:wujie 中 patchCssRules 存在重复加载的 Bug [查看]
在这里不得不吐槽一下,wujie 处理样式真的很零乱:
| 方法 | 样式类型 | 用途 |
|---|---|---|
1. processTpl [查看] |
静态样式 | 将资源中的静态样式替换成注释 |
2. processCssLoader [查看] |
静态样式 | 加载从资源中提取的静态样式并替换资源中对应的注释 |
3. processCssLoaderForTemplate [查看] |
静态样式 | 手动添加样式到应用头部和尾部 |
4. patchCssRules [查看] |
所有类型 | 为容器中已存在的样式打补丁 |
5. rewriteAppendOrInsertChild [查看] |
动态样式 | 拦截应用动态添加样式 |
6. patchStylesheetElement [查看] |
动态样式 | 劫持处理样式元素的操作 |
7. handleStylesheetElementPatch [查看] |
动态样式 | 为动态样式打补丁 |
- 对于方法:2、3,对比加载
script,方法全部归纳在start[查看] - 对于打补丁的方法:4、7,执行的过程是一样的
除此之外以下方法执行过程也高度相似:
| 方法或流程 | 参考对象 | 相同点 |
|---|---|---|
startApp 的实例初始化 |
预加载中 runPreload |
提取资源,实例初始化、active、start,见:对比 [查看] |
umd 切换应用 [查看] |
应用 mount [查看] |
执行生命周期方法、挂载应用 |
getCssLoader |
getJsLoader |
唯一的区别是提取插件的属性名,见:通过配置替换资源 [查看] |
createIframeContainer |
renderIframeReplaceApp |
见:创建 iframe 容器 [查看] |
renderTemplateToShadowRoot [查看] |
renderTemplateToIframe [查看] |
创建 html 元素、手动插入样式、修正容器 parentNode,重写容器方法 |
patchElementEffect - baseURI [查看] |
getCurUrl,见:源码 [查看] |
都是通过 proxyLocation 获取 protocol + host + pathname |
仅限于 umd 模式切换应用,或预执行后启动应用:
- 由于
umd模式初次start之后,再次启动不会重新注入执行script - 因此应用中的动态样式也不会重新注入,需要在
__WUJIE_MOUNT前通过styleSheetElements恢复样式
styleSheetElements的样式收集来自 3 处 [查看]
恢复方式:
- 遍历
styleSheetElements集合中的样式元素,注入到容器的head元素下 - 通过
patchCssRules为恢复的样式打补丁 [查看]
为样式打补丁存在重复加载的
Bug,见:单独总结 [查看]
- 如果容器挂载点
el存在的话,通过clearChild讲其子集全部清空 - 从沙箱
window中找到__WUJIE_EVENTLISTENER__,清除记录的事件 - 找到沙箱挂载点,删除沙箱
iframe元素 - 通过
deleteWujieById从映射表中删除实例,见:idToSandboxCacheMap[查看]
__WUJIE_EVENTLISTENER__ 清除事件:
- 由于在
patchIframeEvents中重写了沙箱window的removeEventListener[查看] - 当向沙箱发起删除事件时,会先清空记录然后执行
removeEventListener删除事件
原因见:转发
window事件 [查看]
这里只列举部分关键的属性:
| 属性 | 定义 | constructor 初始化 |
destroy 注销 |
|---|---|---|---|
activeFlag |
实例已激活 | undefined,通过 active 激活 [查看] |
通过 unmount 失活 [查看] |
bus |
通信对象,使用 appEventObjMap 获取事件映射表,通过 inject 实现父子应用指向同一个对象 [查看] |
EventBus [查看] |
null |
degrade |
主动降级,用 iframe 作为应用容器 |
通过配置文件在构造函数中声明 | 不处理 |
elementEventCacheMap |
当降级时用于保存应用中所有元素事件 [查看] | WeakMap,在构造函数中通过 iframeGenerator 发起记录 [查看] |
null |
execFlag |
应用启动状态 | undefined,通过 start 激活 [查看] |
null |
execQueue |
start 应用中的任务队列 [查看] |
[] |
null |
id |
应用名列 | name,字符类型 |
不处理 |
mountFlag |
umd 模式挂载应用 |
undefined,应用 mount 后为 true,unmount 后为 false |
null |
provide |
为子应用提供通信 bus、代理的 location,可选对象:传递数据 props、shadowRoot 容器,见:文档 [查看] |
在构造函数里提供 bus、location,在 active 中提供 props 和 shadowRoot |
null |
styleSheetElements |
收集应用中动态添加的样式,:host 和字体补丁样式 [查看] |
[] |
null |
sync |
单向同步路由,见:文档 [查看] | udefined,只在 active 时通过配置文件设置 [查看] |
不处理 |
template |
string 类型,记录通过 processCssLoader 处理后的资源,在 alive 或 umd 模式下切换应用时可保证资源一致性 [查看] |
undefined,只在 active 时候记录 |
不处理 |
像
degrade和plugin这样在实例化就定义好值,除了销毁后设置null,不能中途更新值。也就是说对于像alive模式的应用,预加载配置的信息,不会因为startApp配置不同,而加载应用发生改变
hrefFlag:通过 iframe 加载子应用 url
这里的 iframe 既不是沙箱 iframe,也不是容器 iframe,而是:
- 专门用来加载通过
location.href跳转链接时,临时建立一个iframe来替换容器 - 比如子应用通过
location.href转向第三方页面,这时就新建iframe充当临时容器
存储的值类型为
boolean,仅用于表明当前是否为劫持容器
属性值的更新:
constructor构建,默认值:undefined[查看]destroy销毁:null[查看]active激活应用:false[查看]locationHrefSet拦截子应用:设置true[查看]popstate前进时,页面来自locationHrefSet拦截:true[查看]popstate后退时,从locationHrefSet拦截的页面离开:false[查看]
因此得出:
hrefFlag标记时,表示当前应用的链接并非来自基座- 只有子应用内通过
location.href修改当前页面链接才会拦截触发
由于
locationHrefSet存在bug,因此仅限来自非降级模式下的子应用 [查看]
用途:
unmount注销应用:umd模式决定是否要卸载应用 [查看]clearInactiveAppUrl是否清理路由:也是unmount时触发 [查看]popstate后退时:判断是否是从locationHrefSet拦截的页面离开 [查看]
el:挂载容器
通常来自配置文件设定挂载节点,但是下面情况除外:
preloadApp预加载:挂载到沙箱iframe的bodystartApp加载应用不提供el:挂载到沙箱iframe的bodystartApp切换应用不提供el:直接报错
属性值的更新:
constructor构建,默认值:undefined[查看]destroy销毁:null[查看]active激活应用:配置指定的el节点,否则为沙箱iframe的body[查看]
用途:
url:应用入口链接
无论是预加载还是启动应用都必须提供的配置,然而说 url 特殊是因为,这个属性在构造和 active 时允许分别赋值,那么这就可能造成如下问题。
问题 1:构造函数和 active 提供的 url不一样
- ❎ 目前不可能,源码中只要声明实例,那么随后一定会使用相同的
url加载资源,active激活应用
问题 2:preloadApp 和 startApp 时 url 不一样呢
- ✅ 有可能
不过目前来说这个问题影响有限:
- 可能会造成主应用通过
syncUrlToIframe同步路由时,子应用的pathname错误 [查看]
为什么?
- 对于重建模式,每次都会销毁实例重建,
url以startApp提供的配置为准 [查看] - 其它不销毁实例的模式下,
active设置url后仅做了同步路由的操作 [查看] - 其它关于路由、
location等操作已在构造函数完成 [查看] - 而应用资源则在
active之前已通过importHTML加载完毕 [查看]
只能说 url 分开赋值有可能造成隐患,如何彻底杜绝呢?
active取消赋值url,直接从this.url中获取,因为构造函数已赋值了
url 使用的场景:
WuJie:构造函数实例化 [查看]initBase:初始化设置沙箱base元素 [查看]importHTML:加载应用资源 [查看]active:激活应用同步路由 [查看]syncUrlToIframe:同步基座的路由到子应用 [查看]
顺序从上至下,只列举了和
url直接关联的方法,不包含衍生对象,例如:proxyLocation[查看]
那 preloadApp 和 startApp 提供的应用名不一样呢?
- 那就作为不同的应用加载了,
wujie按照应用名来划分应用
plugins:插件集合
wujie 的插件系统,类型为 Array<plugin>,见:文档 [查看]
属性值的更新:
constructor构建:Array<plugin>destroy销毁:null
构建时通过 getPlugins 确保至少包含 2 个默认插件。
插件 1:cssBeforeLoaders - 内联样式,见:源码 [查看]
插件 2:cssLoader - cssRelativePathResolve,见:源码 [查看]
参数:
code:样式代码或空字符src:内联样式是空字符,外联样式为链接相对路径,或绝对路径base:从proxyLocation中提取:host+pathname
目的:
- 修改内联样式中的相对路径资源链接,按照子应用入口链接转换为绝对路径
code 样式根据 ignore 来决定样式加载方式:
ignore:code为空字符,作为外联样式通过浏览器加载,见:getExternalStyleSheets[查看]- 其它情况:全部提供样式代码,即便是外联样式也会通过
fetchAssets加载 [查看]
对于
cssExcludes排除的样式不会作为应用中的样式进行处理
因此:
ignore:由于提供的是空字符,不做任何处理- 其他情况:匹配换样式中的相对路径,替换为绝对路径
通过外联加载的样式,引用的资源需要设为绝对路径或
base64,否则会因为路径不对找不到资源
调用场景:
处理前会根据样式 src 计算 baseUrl:
- 空字符采用应用的
base,如内联样式 - 其余参考
getAbsolutePath,样式的src为url,应用入口链接为base[查看]
之后再提取样式中的路径,去匹配 baseUrl:
- 空字符:
baseUrl,这样是错误情况,拿到的是baseUrl,得不到真实资源 - 其余参考
getAbsolutePath,资源链接为url,baseUrl为base[查看]
最后说说正则:
/(url\((?!['"]?(?:data):)['"]?)([^'")]*)(['"]?\))/g
//g:说明是匹配样式全局中所有的内容
第一层分成 3 个括号,先看后 2 个:
(['"]?\))包含:')、")、)([^'")]*)包含:除双引号或单引号之外所有内容
第 1 个括号里面的正则:
(url\(.*):以url(开头(?!):负前瞻规则['"]?:最多有 1 个单引号或双引号
负前瞻规则 exp1(?!exp2) 解读:
- 查找后面不是
exp2的exp1 - 这里的
exp2是:['"]?(?:data):,开头最多 1 个引号 +data:
?:表示非捕获分组,匹配的值不会保存,和它相反的是捕获分组()
连起来看 replace 中第二个函数中的参数:
_m:匹配的全部字符,这里用不上pre:以url(开头可以跟着 1 个引号,但不能是"data:、'data:、data:url:post之前所有的内容post:')、")、)
从这能看出来匹配的是
url(.*)的资源路径,但不能是base64
最终将 url 替换成绝对路径返回:
pre+absoluteUrl+post
非降级 degrade 情况下代理:window、document、location
目录:entry.ts - proxyGenerator [查看]
参数:
iframe:沙箱iframeurlElement:将子应用入口链接通过appRouteParse转换成HTMLAnchorElement对象 [查看]mainHostPath:基座originappHostPath:子应用origin
返回 1 对象,包含 3 个属性:
proxyWindow:代理沙箱window对象,降级代理localGenerator不提供proxyDocument:代理空对象,但根据情况选择不同容器进行劫持或操作proxyLocation:代理空对象,但根据情况使用子应用链接或沙箱location劫持或操作
分别对 get、set、has 做了代理,在提供的流程图中可以看到 [查看]:
proxyWindow是包裹在script module下工作的- 即子应用中的
script对window等全局对象操作都指向同一个proxyWindow - 这样即便是沙箱
window也不会受到应用污染 - 而对于不同的应用,各自应用下的
script都指向各自应用的proxyWindow
get 操作按照获取的 property 返回相应对象
返回 proxyLocation [查看]:
location
返回自身 proxyWindow:
selfwindow:必须是全局window描述中存在get属性
从沙箱 window 获取 property 直接返回,不需要绑定 this:
__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__:见:initIframeDom[查看]__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__:见:initIframeDom[查看]- 通过
getOwnPropertyDescriptor获取property描述信息为不可配置且不可写
通过 getTargetValue 获取沙箱 window 的属性 [查看]:
- 符合
setFnCacheMap要求的属性,需要绑定this为沙箱window[查看] - 不符合
setFnCacheMap要求直接从沙箱window中找到属性并返回,找不到返回undefined - 全局
window描述信息中不存在get属性,从沙箱中获取属性window
set 操作
直接绑定在沙箱 window 对象上:
- 绑定前会通过
checkProxyFunction将符合要求的方法存入映射表,见:setFnCacheMap[查看] - 以便下次
get操作时,直接从缓存表中获取
has 操作
从 proxyWindow 中判断是否存在对象:
proxyWindow是沙箱window的代理,可直通过in判断属性是否存在
一道思考题:proxyWindow 中的 document 指向谁?
会通过 getTargetValue 从沙箱 window 中直接获取 document 属性
proxyWindow和proxyLocation可以包裹在script module中,但是proxyDocument不行,因为Dom本身是从上至下的树状结构
衍生问题:沙箱 document 如何指向 proxyDocument
- 通过
patchDocumentEffect进行拦截,见:proxyDocument在哪调用 [查看]
代理的是一个空对象 {},且只有 get 取值:
- 在
get操作中,第一个参数也称为_fakeDocument(假的),不会从这个对象上做任何操作
取值前的准备工作:
- 从全局
window上获取:document,从应用实例上获取:shadowRoot容器、proxyLocation - 从沙箱
window上获取原生方法:rawCreateElement创建元素、rawCreateTextNode创建文本
在获取对象前需要确保
shadowRoot已实例化,否则通过stopMainAppRun输出警告并抛出错误终断执行
代理 createElement 和 createTextNode:
- 代理劫持
document上对的方法,并将其返回作为子应用的对应的方法
在 Proxy 中通过 apply 在调用时代理操作行为:
- 根据
property决定使用rawCreateElement还是rawCreateTextNode - 执行方法时通过
apply绑定沙箱iframe.contentDocument作为this,透传参数arg - 通过
patchElementEffect为创建的每个Dom打补丁后并返回 [查看]
备注:
- 在应用中所有的
createElement、createTextNode都会通过沙箱iframe - 而
appendChild、insertBefore都会通过shadowRoot - 这是因为创建元素时需要通过
patchElementEffect打补丁,而最终是要在shadowRoot容器中挂载
代理 documentURI 和 URL:
- 返回
proxyLocation的href
代理:通过标签获取元素
- 包含:
getElementsByTagName、getElementsByClassName、getElementsByName - 劫持
shadowRoot.querySelectorAll返回Proxy对象 - 在返回的对象中通过
apply去处理子应用获取代理方法后,处理执行结果并返回
如果上下文 this 不是 iframe.contentDocument:
- 直接从上下文中获取元素,说明当前操作的
Dom对象没有打补丁指向沙箱document
如果 getElementsByTagName 获取所有的 script:
- 返回
iframe.contentDocument.scripts,因为所有的script存放在沙箱iframe中
其它情况全部在 shadowRoot 获取,但是获取前需要转换下参数:
getElementsByTagName:不需要处理getElementsByClassName:转换成元素类名.{$arg}getElementsByName:转换成元素属性名[name="${arg}"]
代理:getElementById
- 劫持
shadowRoot.querySelector返回Proxy对象 - 在返回的对象中通过
apply去处理子应用获取代理方法后,处理执行结果并返回
如果上下文 this 不是 iframe.contentDocument:
- 直接从上下文中获取元素,说明当前操作的
Dom对象没有打补丁指向沙箱document
否则:
- 转换参数去匹配
querySelector做查询,如:[id="${arg}"] - 优先从
shadowRoot去查询,找不到再去沙箱iframe中查询,因为获取的有可能是script
代理:查询方法
- 包含:
querySelector、querySelectorAll - 劫持
shadowRoot对应方法返回Proxy对象 - 在返回的对象中通过
apply去处理子应用获取代理方法后,处理执行结果并返回
如果上下文 this 不是 iframe.contentDocument:
- 直接从上下文中获取元素,说明当前操作的
Dom对象没有打补丁指向沙箱document
否则:
- 优先从
shadowRoot查询,查询不到再去沙箱iframe中查询 - 但不会去查沙箱
iframe中的base元素,因为他会影响路由
从上面可以明确知道,存放
script一定是沙箱iframe,其它元素一定是shadowRoot,否则就会匹配错乱
代理:查询 html 元素
- 返回的一定是
shadowRoot容器中的shadowRoot.firstElementChild
代理:查询元素集合
在 shadowRoot 容器中通过 querySelectorAll 去匹配相应的元素集合:
forms:formimages:imglinks:a
代理:documentProxyProperties
包含的元素见:源码 [查看],分别如下:
ownerProperties、shadowProperties:
- 如果
property是activeElement,且shadowRoot中不存在的情况下返回shadowRoot.body - 其它一律从
shadowRoot中返回对应的属性
shadowMethods:
- 通过
getTargetValue优先从shadowRoot获取,否者从全局document中获取 [查看]
documentProperties:
- 直接从全局
document中获取
documentMethods:
- 通过
getTargetValue从全局document中获取 [查看]
proxyDocument 下遗漏了 location
子应用中从 document 拿到的 location 和 window.loaction 不一致,见:proxyLocation [查看]
解决办法和 proxyWindow 一样,增加 location 拦截:
const { shadowRoot, proxyLocation } = iframe.contentWindow.__WUJIE;
if (propKey === "location") {
return proxyLocation;
}
同样需要在沙箱 iframe 初始化时增加指向,第 545 行 [查看]:
ownerProperties.concat('location').forEach((propKey) => {
// ...
});
代理的是一个空对象 {},在 get 和 set 中:
- 第一个对象也称为
_fakeDocument(假的),不会从这个对象上做任何操作 - 因此读取属性从
iframe.contentWindow.location对象中获取
设计初衷:
- 在沙箱
iframe初始化时已将src设为和基座同域,由此决定了沙箱location - 而在沙箱中更新链接或
history,也需要确保更新的url和基座同域 - 但是子应用中通过
location读取属性时,则需要保持和资源入口链接同域
概念名词:
proxyLocation:在沙箱中包裹在script module中作为代理的location,见:流程图 [查看]- 沙箱
location:沙箱iframe下的location对象
这样就意味着:
- 应用中所有的内联
script的location操作都指向proxyLocation - 应用的
window指向proxyWindow,而proxyWindow的location指向proxyLocation - 而沙箱
iframe读取操作的location对象,不会受到来自proxyLocation对象任何污染
location 不同的指向:
- 外联
script和script module:沙箱location - 内联
script根据环境和获取的对象不同,指向也不同
| 分类 | shadowRoot |
iframe 容器 |
|---|---|---|
window.location |
proxyLocation |
沙箱 location |
document.location |
沙箱 location |
沙箱 location |
location |
proxyLocation |
沙箱 location |
详细见
proxyLocation的问题 [查看]
沙箱 location 和 proxyLocation 有什么不同:
- 沙箱
location和基座同域 proxyLocation按照子应用入口链接决定location
如何确保正确拿到子应用入口链接的 location:
- 不要通过
document获取location - 降级容器下只能通过
window.__WUJIE.proxyLocation获取
拦截的方法:
get:取值set:赋值ownKeys:枚举所有属性getOwnPropertyDescriptor获取描述信息
get 取值
- 拦截子应用读取
location对象中所有属性和方法
从子应用入口链接获取信息,包含:
host、hostname、protocol、port、origin
获取 href:将 origin 修改为子应用
- 获取沙箱
iframe的location.href,返回之前要将主应用的origin替换为子应用的origin - 因为沙箱
iframe和基座同域,而应用中资源的url是基于子应用的origin
屏蔽 reload 的 bug:
- 初衷:毕竟辛苦加载的
script,不能因为replad清空了 - 原因:子应
reload用会因为自身的src是基座的origin,重新加载基座造成错误 - 问题:但同时也阉割子应用
location.reload功能 - 修复:正确的做法应该是转发自全局
window.location.reload
处理 replace 的 bug,先解读流程:
- 代理沙箱的
location.replace在apply中将更新replace操作的url - 更新
url的方式:将子应用的origin替换为基座origin - 目的:保持沙箱
iframe和基座同源
replace 的条件:
| 条件 | 通常做法 |
|---|---|
只替换带有子应用 origin 的绝对路径 |
通常使用相对路径,毕竟线上线下 host 不一样,但相对路径也存在问题 |
只拦截 location.replace 不拦截 history.replace |
对于单例应用来说通常是由 history 来负责路由 replace,这个操作在沙箱初始化时由 patchIframeHistory 做了拦截 [查看] |
问题:
- 拦截后所有链接跳转是在沙箱
iframe下进行的 - 假定
replace跳转到子应用首页,最终会替换url为基座首页 - 导致沙箱
iframe链接跳转到基座首页,从而引发子应用的沙箱去加载基座,产生问题
复现:
vue-project子应用中复现了问题 [查看]- 运行方法:启动子应用和基座,选择
Vue 应用,点击进入应用中about路由 - 点击按钮 "replace go home" 查看错误演示
怎么修复:
- 拦截
location.replace,检测跳转的链接是应用内部路由还是外部链接 - 外部链接通过
locationHrefSet创建临时的劫持容器 [查看] - 应用内部路由通过
history.replace进行处理,如下演示
iframeWindow.history.replaceState(null, "", args[0])
子应用的
history在沙箱iframe初始化时已经打补丁了,见:patchIframeHistory[查看]
其它情况:
- 通过
getTargetValue直接从沙箱location中获取 [查看]
set 赋值
赋值会绑定新的值到沙箱 location 对应的 property 上,但 href 除外
href 赋值操作
方法:
- 拦截操作并通过
locationHrefSet创建一个新的iframe代替渲染容器 [查看]
结果:
- 用
iframe替换子应用容器,并更新当前url中对应的search - 由于拦截的很直接粗暴,切换会很突兀,需要通过
degradeAttrs进行适配 [查看]
这意味着实际使用过程中,哪怕没有考虑需要 degrade 主动降级:
- 但只要应用中存在
location.href更新页面链接,就需要添加degradeAttrs配置
设计初衷:
- 可能出于单例应用的考量,所有链接都是基座的子应用,哪怕跳到第三方页面也不能离开基座
- 比如说后台管理,子应用中有个第三方查快递的链接,通常情况可能就跳转走了,但是在
wujie中会将其也作为子应用挂载到指定节点
问题是:
- 通常跳转链接不都是通过
HTMLAnchorElement元素吗,这种情况是没有拦截的 - 既然这样,那
location.href不应该是转发给全局location.href赋值更新吗?
ownKeys 枚举所有属性
- 从沙箱
location中获取所有property,但不包括被屏蔽的reload
getOwnPropertyDescriptor 获取描述信息
返回信息包含有:
enumerable:可枚举configurable:可配置writable:不可写,自动补全value:很有可能拿到undefined
关于 value 的 bug:
- 这里通过
this取值,而this是_fakeLocation空对象,所以有可能是undefined - 当然空对象也有原型链,例如:
toString是可以拿到的,但这就和location无关了
降级情况下 document、location 的代理,window 采用沙箱 window,也不需要包裹 script module,直接使用沙箱 iframe 做隔离,见:流程图 [查看]
目录:proxy.ts - localGenerator [查看]
参数:
iframe:沙箱iframeurlElement:将子应用入口链接通过appRouteParse转换成HTMLAnchorElement对象 [查看]mainHostPath:基座originappHostPath:子应用origin
返回 1 对象,包含 2 个属性:
proxyDocument:代理空对象,但是会从渲染容器和全局document中获取属性proxyLocation:代理空对象,但是会从沙箱location和子应用入口链接获取属性
和 proxyGenerator 相同,见:proxyGenerator - proxyDocument [查看]
- 创建元素和文本:
createElement、createTextNode - 代理
documentURI和URL getElementsByTagName:通过标签获取元素集合,包含获取script集合getElementById:通过id获取元素,先容器再沙箱
和 proxyGenerator 不同:
proxyGenerator通过Proxy拦截对象做代理locationHrefSet通过Object.defineProperties劫持空对象做代理documentProxyProperties处理方式不同
关于
documentProxyProperties,见:源码 [查看]
为什么降级后代理采用 Object.defineProperties:
- 因为
Proxy不兼容IE
documentProxyProperties 的处理:
- 遍历集合中的属性,劫持容器
document中对应的属性 - 如果是可执行的方法,绑定
this为容器document并返回,否则直接返回属性
容器中所有的元素通过 patchElementEffect 将 ownerDocument 指向沙箱 document [查看]:
- 所以无论是
documentProxyProperties包含的属性,还是不需要考虑的属性,都可以直接从容器document中获取,因为在此之前,它们的ownerDocument已指向沙箱iframe.contentDocument
和 proxyGenerator 相同,见:proxyGenerator - proxyLocation [查看]
- 从子应用入口链接获取信息:
host、hostname、protocol、port、origin - 获取
href:用主应用的origin替换为子应用的origin - 设置
href:会通过locationHrefSet创建一个新的iframe代替应用容器 [查看] - 屏蔽
reload,当然屏蔽导致的问题也一样,见:proxyGenerator-proxyLocation[查看] - 遍历
location属性绑定在proxyLocation,如果是isCallable方法,绑定this为沙箱的location[查看]
和 proxyGenerator 不同:
- 不拦截
replace,也不存在replace带来的问题,见:proxyGenerator-proxyLocation[查看] - 因为
location方法并没有window那么多,不需要通过setFnCacheMap缓存绑定的方法 [查看] - 降级后的
proxyLocation不会捆绑在子应用中,见:proxyLocation的问题 [查看]
仅在 insertScriptToIframe 注入 script 到沙箱 iframe 时,包裹模块用到 [查看]
degrade降级、外联script、script module不包裹模块,不提供proxyWindow
那 degrade 降级时真的不需要代理 window 吗?
- 并不是,至少
location就不是 - 降级后
iframe的location存在哪些问题?见:proxyLocation的问题 [查看]
以下属性在降级情况的确不用 proxyWindow:
| 属性 | 非降级模式 | degrade 降级 |
|---|---|---|
self |
proxyWindow |
沙箱 window |
window |
全局 window 描述信息存在 get 属性为 proxyWindow |
沙箱 window |
以下属性无论降不降级都从沙箱 window 中获取:
- 全局
window描述信息不存在get属性 __WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__、__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__- 不可配置不可重写的属性
- 通过
getTargetValue从沙箱window获取属性 [查看]
为什么降级后容器 iframe 的 window 从沙箱 iframe 中获取:
- 因为
script是注入并运行在沙箱iframe中
来自沙箱 document 打补丁有 2 处,见:patchDocumentEffect [查看]
- 遍历
documentProxyProperties集合,劫持沙箱document属性,见:源码 [查看] - 劫持沙箱
body、head,从proxyDocument里返回Dom元素,见:源码 [查看]
在 patchDocumentEffect 打补丁时如何指向 proxyDocument:
- 遍历
documentProxyProperties匹配documet的属性集合 - 每一项通过
Object.defineProperty劫持iframeWindow.Document.prototype上对应的属性 - 将其
get操作指向proxyDocument中对应的属性
在 proxyDocument 收到请求后怎么处理:
- 按照
get的属性返回相应对象,参考:代理空对象作为proxyDocument[查看]
关于 documentProxyProperties 集合:
- 集合中涵盖了
document需要劫持的属性,包括createElement、createTextNode等需要劫持过程中特殊处理的属性 - 在
Proxy的get中会先匹配处理特殊指定的属性,将其结果返回 - 然后再遍历
documentProxyProperties批量定义的属性进行处理,避免因冲突覆盖已处理的代理属性
对于降级的
proxyDocument则通过modifyLocalProperties排除已定义的特殊属性,见:源码 [查看]
| 调用场景 | 模式 | 用途 |
|---|---|---|
getCurUrl 必要参数 |
通用 | 获取应用的 url 传递给 loader 插件 |
patchElementEffect 给应用元素打补丁 [查看] |
通用 | 让元素 baseURI 通过 proxyLocation 获取 |
proxyDocument [查看] |
通用 | 代理中属性 documentURI、URL 的指向 |
proxyWindow [查看] |
非降级 | 代理中属性 location 的指向 |
insertScriptToIframe [查看] |
非降级 | 包裹注入沙箱的 script 作为 location |
其中
getCurUrl和patchElementEffect中的baseURI的操作方式一模一样,见:重复提取样式的bug[查看]
问题 1:在 wujie 子应用中谨慎使用 location
- 如果是取值那么在
shadowRoot中正常,如果要跳转、更新location建议使用history - 否则可能会因为
location.replace或者更新location.href造成意外的结果
问题 2:在降级模式下的 location 和非降级模式下不一致
降级模式下子应用和基座的 location 也不是同一个对象,对比如下:
| 分类 | 非降级模式 | degrade 子应用 |
degrade 基座 |
|---|---|---|---|
location |
proxyLocation |
沙箱 location |
proxyLocation |
url 获取 |
子应用入口链接 | host 和基座同域 |
子应用入口链接 |
host |
子应用 | 基座同域 | 子应用 |
reload |
屏蔽 | 不屏蔽 | 屏蔽 |
href 更新 |
创建 iframe 代替容器 |
在沙箱 iframe 跳转 |
目前用不到 |
replace |
替换绝对路径和基座同域 | 不做任何处理 | 目前用不到 |
原因参考:
proxyLocation在哪里调用 [查看]
要怎么修复:
- 我的想法是在
proxyWindow劫持location指向proxyLocation - 但是降级后的
iframe容器使用的是沙箱window,而不是proxyWindow - 这样就需要从
patchWindowEffect着手打补丁了 [查看]
通过 getOwnPropertyNames 打补丁,见:源码 [查看]
const { degrade, proxyLocation } = iframeWindow__WUJIE;
Object.getOwnPropertyNames(iframeWindow).forEach((key) => {
// 新增补丁
if (key === "location" && degrade) {
Object.defineProperty(iframeWindow, key, {
get: () => proxyLocation,
});
}
});
如果存在降级,通过
Object.defineProperty劫持并指向proxyLocation
复现问题:
- 在基座中找到:
/src/pages/VuePage.tsx[查看] - 在组建中添加
degrade属性,运行切换到vue应用,点击about切换到页面 - 这个时候看到拿到的
url是http://localhost:3000/about - 单独打开子应用拿到的
url是http://localhost:8080/about - 去掉
degrade拿到的url是http://localhost:8080/about
目录:proxy.ts - locationHrefSet [查看]
参数:
iframe:沙箱iframevalue:拦截location.href更新的链接,无论相对链接还是绝对链接,也可以是第三方链接appHostPath:子应用origin
目的:
- 创建
iframe加载拦截的链接,挂载到指定节点,用来代替当前渲染容器
操作流程
从实例中提取以下对象:
- 渲染容器:
shadowRoot、document,根据degrade决定要替换的容器 id,用处 1:降级时从Dom中找到iframe容器,用处 2:更新链接,从search找到当前应用degradeAttrs:来自启动配置,用于劫持容器能够适配页面url:通常情况下是location.href更新的链接,但是相对路径需要转换一下
转换相对路径:
- 通过
anchorElementGenerator将链接转换成HTMLAnchorElement对象 [查看] - 提取
appHostPath子应用origin+ 提供链接的pathname+search+hash作为url
执行替换有 3 步:
- 标记
hrefFlag以便点击后退时还原渲染容器 - 替换渲染容器为新建的
iframe pushUrlToWindow推送指定url到主应用路由 [查看]
若是 degrade 主动降级,替换 iframe 容器:
- 通过
rawDocumentQuerySelector(原生方法),拿到沙箱body - 通过
renderElementToContainer将渲染容器中的html元素添加到沙箱body中 [查看] - 通过
renderIframeReplaceApp创建新的iframe替换渲染容器 [查看]
非 degrade 降级则替换 shadowRoot:
- 通过
renderIframeReplaceApp创建新的iframe替换渲染容器 [查看]
存在的 bug
以上描述仅在正常情况,不巧 locationHrefSet 也有 bug:
- 因为降级模式下不使用
proxyLocation[查看] - 因此也不会拦截
location.href的更新,导致在iframe容器中并不会因此创建劫持容器
复现和修复:
- 和
proxyLocation解决方法一致,见:proxyLocation的问题 [查看]
目录:sync.ts - pushUrlToWindow [查看]
参数:
id:应用名url:跳转的链接,来自locationHrefSet[查看]
调用场景:
- 只有
locationHrefSet拦截子应用location.href - 也说明监听
popstate时检测前进的页面,只能来自pushUrlToWindow推送的更新 [查看]
流程:
- 通过
anchorElementGenerator拿到HTMLAnchorElement对象 [查看] - 通过
getAnchorElementQueryMap拿到search的键值对 [查看] - 根据当前应用名
id将值更新为encodeURIComponent的url - 将更新后的键值对还原成字符更新
url.searh - 通过
window.history.pushState更新记录,以便浏览器回退还原容器
围绕提取应用资源归纳相关的方法
加载应用资源、提取资源的方法
目录:entry.ts - importHTML [查看]
用于加载和处理资源内容,相当于:
除了
qiankun使用的是import-html-entry,其它都是单独开发的 [查看]
参数为包含 3 个属性的 params 对象:
url:资源连接html:静态资源,提供则优先使用opts:包含加载和处理HTML的相关配置
opts 包含 4 个可选属性:
fetch:自定义的fetch,没有提供则使用全局window提供的fetchplugins:应插件,见:文档 [查看]loadError:应用加载资源失败后触发,startApp时配置fiber:空闲加载,默认为true
在
importHTML中会用到的plugins见下面总结:1. 提取必要的配置
由于 importHTML 中只能通过 processTpl 提取静态资源 [查看]
- 因此上述
plugins提到的外联script和外联样式也都是静态资源
最终返回 Promise<htmlParseResult>,其中 htmlParseResult 包含:
template:处理后的资源内容assetPublicPath:资源路径,见:defaultGetPublicPath[查看]getExternalScripts:加载应用中静态script的包装方法 [查看]getExternalStyleSheets:加载应用中静态样式的包装方法 [查看]
返回的
Promise会根据plugins是否不存在htmlLoader来缓存结果,见:资源缓存集合 [查看]
调用场景有 3 处:
预加载后会重复执行(执行顺序从上至下):
| 预加载流程 | alive 模式 |
重建模式 |
|---|---|---|
声明实例 WuJie [查看] |
❎ 映射在 idToSandboxCacheMap [查看] |
✅ |
调用生命周期 beforeLoad,见:文档 [查看] |
✅ | ✅ |
加载资源 importHTML [查看] |
❎ 存储在实例 template [查看] |
✅ |
提取资源 processTpl [查看] |
❎ 存储在实例 template [查看] |
✅ |
处理样式 processCssLoader [查看] |
❎ 存储在实例 template [查看] |
✅ |
激活应用 active [查看] |
✅ | ✅ |
getExternalScripts 加载 script [查看] |
✅ 通过 start 再次获取 [查看] |
✅ 通过 start 再次获取 [查看] |
- 预加载最后会通过
getExternalScripts提前获取script[查看] - 预执行则是将
getExternalScripts传入start提前获取script并注入沙箱 [查看]
umd 预执行后启动和 alive 流程一样的,不同在于:
- 不重复调用生命周期
beforeLoad,不重复调用start启动应用 - 而是通过
mount发起挂载 [查看]
然而
umd预执行时通过start已发起了mount,所以startApp时需要先unmount后再次mount
以上重复执行的流程中,资源会在首次加载时存入缓存
- 之后重复调用将会优先从缓存中获取,见:资源缓存集合 [查看]
1. 提取必要的配置
- 从
opts提取:fetch、fiber、plugins、loadError,见上述总结 htmlLoader:作为替换资源入口template的方法,见:文档 [查看]- 通过
getEffectLoaders提取plugins - 声明一个资源路径计算函数
getPublicPath,见:defaultGetPublicPath[查看]
htmlLoader 声明规则:
- 提供
plugin但不提供htmlLoader,通过compose获取的函数,直接返回传入的资源 [查看] - 通过
plugin提供htmlLoader,通过compose依次使用自定义的htmlLoader替换资源 [查看] - 不提供
plugins使用defaultGetTemplate直接返回传入的资源
getEffectLoaders 提取的 plugins 包含:
jsExcludes:外联script排除列表,见:文档 [查看]cssExcludes:外联样式排除列表,见:文档 [查看]jsIgnores:外联script忽略列表,见:文档 [查看]cssIgnores:外联样式忽略列表,见:文档 [查看]
getEffectLoaders提取的资源通过reduce最终拷贝返回一个新的Array<string | RegExp>对象
通过 ignore 匹配的列表资源,将使用外联的方式加载资源,这样有效解决跨域问题:
对于子应用中静态资源标记
ignore的策略是用注释替换,将不会作为应用中的资源加载
2. 获取资源
通过 getHtmlParseResult 获取资源,接受 3 个参数:
url:资源远程链接html:现有的资源htmlLoader:从声明的配置透传过来,见上述总结
提供 html 时优先使用,否则通过 fetch 获取资源链接
getHtmlParseResult相当于micro-app中的extractSourceDom[查看]
3. 处理返回资源
- 应用入口链接通过
getPublicPath获取资源路径链接,见:defaultGetPublicPath[查看] - 使用
processTpl将资源中的样式和script提取出来,并用注释替换 [查看] - 返回
Promise<htmlParseResult>见上述总结
qiankun 和 micro-app 通过 __webpack_public_path__ 配置资源路径:
qiankun:通过window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__获取,见:示例 [查看]micro-app:通过window.__MICRO_APP_PUBLIC_PATH__获取,见:示例 [查看]
相对来说
wujie对子应用的倾入是最少的
4. 包装获取样式和 script 的方法
在返回的对象中包含了 2 个包装方法:
提取的资源、样式、
script来自processTpl[查看]
提取资源传递的参数:
- 提取的资源集合:如
script集合或样式集合 fetch:透传自opts中的参数loadError:外联资源加载失败通知方法,来自配置的可选参数fiber:是否空闲加载,仅限script加载
提供的资源筛选规则:
- 通过插件排除的外联资源将直接被过滤,如:
jsExcludes、cssExcludes - 通过插件忽略的外联资源将添加
ignore属性,如:jsIgnores、cssIgnores
5. 从缓存中提取资源
重建模式下,每次切换应用就是一次实例初始化,会重复调用 importHTML 提取资源,此时会尽量通过缓存获取资源,见:资源缓存集合 [查看]
embedHTMLCache:应用入口资源缓存,仅限没有提供htmlLoaderstyleCache:缓存所有外联样式,包括静态提取和动态加载scriptCache:缓存所有外联script,包括静态提取和动态加载
alive和umd模式再次切换应用时,不会重复调用importHTML
alive 模预加载后启动也会重复调用 importHTML:
umd 模式预加载后启动也会重复调用 importHTML:
- 因为
umd首次加载方法__WUJIE_MOUNT还没有挂载到沙箱window
重复加载资源可以缓存,但存在重复处理资源的问题,如:processTpl [查看]
- 除了重建模式可以通过预加载配置
exec预执行解决这个问题 [查看]
6. 从 fetch 看兼容性
fetch是不兼容IE的,在文档描述通过degrade实现容器和proxy兼容,见:文档 [查看]- 为了解决请求兼容的问题,可以自行配置
fetch通过ajax请求资源,见:文档 [查看]
有 2 个场景需要手动配置
fetch:① 兼容IE,② 统一authentication
目录:template.ts - processTpl [查看]
用于从加载内容中提取出 scripts 和样式,相当于:
micro-app中的flatChildren,见:micro-app源码分析,注 ⑭ [查看]
接受 3 个参数:
tpl:字符类型,要提取的源内容baseURI:来自importHTML中的资源路径assetPublicPath[查看]postProcessTemplate:可选参数,用于返回前更新提取的资源,目前没有用到,可忽略
返回一个 TemplateResult 对象包含 4 个属性,见:源码 [查看]
template:替换样式和script为注释后的资源scripts:提取的script集合,类型和getExternalScripts返回对象一致 [查看]styles:提取的样式集合,类型和getExternalStyleSheets返回对象一致 [查看]entry:入口script,不存在则是null,目前没有用到,可忽略
函数内部作了 2 件事:
- 声明对象用于收集提取的资源,分别是:
scripts、styles、entry、moduleSupport - 替换
tpl,按照replace分组替换资源绑定在template
1.替换备注:
全部替换为空字符
2.提取或替换 link 标签:
有 2 个情况会将 link 标签替换为备注:
ref="stylesheet"引入的外联样式- 除了字体以外,所有
preload|prefetch|modulepreload模式下外联资源
以上情况都不符合,会原封不动将数据返回,对于
link标签不做替换,例如:favicon
引入的样式,替换备注有 2 种方式:
genIgnoreAssetReplaceSymbol:带有ignore属性的外联样式genLinkReplaceSymbol:替换非ignore的外联引入样式
带有
ignore属性的外联样式替换备注后将彻底废弃不再还原
预加载和空闲加载的资源替换备注的方式:
genLinkReplaceSymbol:第 2 个参数为true,替换备注后将彻底废弃不再还原- 因为静态样式会通过
getExternalStyleSheets在渲染前实现预加载,不需要通过浏览器重复预加载 [查看]
在
getEmbedHTML时会因提供的参数不同,而忽略还原这部分资源 [查看]
通过 rel 区分引入的资源类型
stylesheet:引入的外联样式,需要加载并还原的静态样式preload:预加载资源modulepreload用于预加载esModule,不匹配linkprefetch:空闲加载资源
浏览器通常不加载不存在
rel属性的link元素,关于这个特性用codepen做了演示 [查看]
收集外联样式只有 1 种情况:
- 非
ignore静态引入样式:记录src在styles集合中
通过
processCssLoader仅还原收集在styles集合的样式 [查看]
外联样式收集的 src 校正:
- 绝对路径不变,相对路径通过
getEntirePath基于入口资源路径baseURI转为绝对路径 - 参考
new URL,资源链接为entry,baseURI为base[查看]
补充说明:
processTpl提取的外联样式,全部是应用中的静态样式- 动态样式需要通过
patchRenderEffect重写方法拦截写入 [查看]
那预加载的链接资源,比如 NextJS 中预加载的链接,不是被注释了?
- 的确,对于屏蔽预加载资源这块的优化还是应该再考虑的
- 比如说只有当
stylesheet匹配到外联样式,且又存在预加载资源的情况进行屏蔽 - 其他的情况修正
link预加载相对路径后,替换入口资源template
3.提取或替换 style 内联样式:
所有内联样式都会被注释替换,替换注释有 2 种:
genIgnoreAssetReplaceSymbol:带有ignore属性的内联样式,注释中的url为style filegetInlineStyleReplaceSymbol:非ignore的内联样式,备注中按照集合中的索引替换成备注
收集内联样式只有 1 种情况:
- 非
ignore静态引入样式:记录样式代码为content在styles集合中
4.提取或替换 script:
先声明以下对象:
scriptIgnore:提取的script带有ignore属性isModuleScript:判断是否是esModuleisCrossOriginScript:提取的script存在跨域行为crossorigincrossOriginType:跨域类型,只取anonymous不发送凭据,use-credentials发送凭据,否则为空字符moduleScriptIgnore:被忽略的esModulematchedScriptTypeMatch:提取带有type属性的script,不存在为nullmatchedScriptType:script的type值,不存在为undefined
esModule 有 2 种情况会被忽略:
- 览器支持
esModule:但script带有属性nomodule - 浏览器不支持
esModule:但isModuleScript为true,即当前script是esModule
外联 script 还需要声明 3 个对象:
matchedScriptEntry:匹配带有entry的scriptmatchedScriptSrcMatch:匹配带有src的scriptmatchedScriptSrc:提取外联script的src值
matchedScriptSrcMatch值应该放入if模块外部声明, 用于减少匹配次数
所有外联 script 是不包含 type="text/ng-template" 的:
ng-template也是内联script,只是允许包含src属性的template,见:issue[查看]
内联 script 还需要声明 2 个对象:
code:内联script的代码内容isPureCommentBlock:script每一行为空,或者是以//开头的单例注释
不替换 script 为注释的情况有 3 种:
| 类型 | 条件 | 原因 |
|---|---|---|
| 所有 | isValidJavaScriptType 检测不符合要求 |
说明不是可执行的 script |
外联 script |
存在多个入口 script |
entry 和 matchedScriptEntry 同时存在 |
外联 script |
src 属性值为空 |
没有资源链接 |
除了多个入口
script会抛出Error,其他情况都直接发挥资源不做处理
entry 入口资源按照 matchedScriptEntry 决定设置为外联 script 的 src:
- 但
entry仅作为导出对象的属性,目前没有被调用
用注释替换 script 有 4 种:
| 注释 | 匹配条件 | 注释方式 |
|---|---|---|
genIgnoreAssetReplaceSymbol |
scriptIgnore |
优先提供 src,否则用 js file |
genModuleScriptReplaceSymbol |
moduleScriptIgnore |
优先提供 src,否则用 js file,除此之外提供第 2 个参数 moduleSupport |
genScriptReplaceSymbol |
有 src 值的外联 script |
src 属性值,以及异步或延迟属性,不存在为空字符 |
inlineScriptReplaceSymbol |
内联 script |
统一注释信息 |
注释的函数见源码,匹配条件见上述声明对象
关于 script 的注释:
- 不同的注释只能作为源码参考,加载的
script最终注入的是沙箱,而替换成注释的资源注入的是渲染容器
收集 script 情况有 2 种,外联 script、内联 src,都要求:
- 不能是
scriptIgnore:带有ignore属性 - 不能是
moduleScriptIgnore:被忽略的esModule
除此之外内联
script还要求:存在代码code且不能全部为空或行注释
外联 script 插入集合的对象:
{
src: matchedScriptSrc,
module: isModuleScript,
crossorigin: !!isCrossOriginScript,
crossoriginType: crossOriginType,
attrs: parseTagAttributes(match),
}
赋值属性的对象见上述总结
外联 script 收集的 src 校正:
- 绝对路径不变,相对路径通过
getEntirePath基于入口资源路径baseURI转为绝对路径
和上述:外联样式收集的
src校正,处理方式是一样的
parseTagAttributes 提取属性:
<script(.*)>标签中所有带有=的属性,将其作为键值对象返回- 因此对于只有属性名的
async和defer,不会提取也不会恢复
因丢失 async 和 defer 造成的问题:
async:不会有任何问题,作为异步代码注入沙箱,见:队列执行顺序 [查看]defer:操作Dom不存在问题,因为在此之前资源已通过active注入 [查看]defer:多个静态script相互依赖,可能因defer丢失而立即执行,从而找不到依赖
外联 script 中存在 async 或 defer 属性,会在上述插入集合的对象中添加 2 个属性:
{
async: isAsyncScript,
defer: isDeferScript,
}
提取的 async 和 defer 有什么用:
内联 script 插入集合的对象和外联 script 基本一样,不同在于:
src:空字符content:代码code- 不存在属性
async和defer
类型为
type="text/ng-template"也会作为内联script进行收集
目录:entry.ts - processCssLoader [查看]
处理 css-loader 来自备注,主要做了 3 件事:
- 提取插件中的
css-loader为每个提取的样式集合项添加微任务替换样式 [查看] - 通过
getEmbedHTML加载样式,执行css-loader[查看] - 通过
replace替换资源,目前存在问题,无效
参数:
sandbox:应用实例,用于获取proxyLocation、replace以及cssLoader[查看]template:已替换资源为注释的应用入口资源,见:processTpl[查看]getExternalStyleSheets:通过importHTML获取的包装方法,用于提取静态样式 [查看]
触发场景有 3 个:
预加载应用时会将应用的资源提取并替换样式后,保存到实例
template中,alive模式的应用启动时无需再次提取样式
第一步:获取并更新样式集合
- 通过
getCurUrl获取应用的base url,为proxyLocation的:origin+pathname - 通过
compose获取cssLoader[查看] - 通过
getExternalStyleSheets遍历样式集合,为每一个contentPromise添加一个微任务 [查看]
微任务只做 1 件事:
- 用
cssLoader替换已加载的样式,见:文档 [查看]
第二步:替换资源中的样式:
- 通过
getEmbedHTML将样式元素替换对应的注释 [查看] - 更新的资源返回
processCssLoader 中应用实例的 replace 不可用:
- 因为执行时应用实例还没有绑定
replace方法,见:通过配置替换资源 [查看]
目录:entry.ts - getEmbedHTML [查看]
仅限应用中的静态样式替换:
- 在
processTpl中提取资源中的样式,替换成特定的注释 [查看] - 之后通过
getEmbedHTML将提取的样式加载后,替换对应的注释,修正回来
动态添加的样式通过
rewriteAppendOrInsertChild拦截并注入容器,不需要替换注释
参数:
template:应用资源,源码是any实际是string,因为processCssLoader传过来就是字符 [查看]styleResultList:通过importHTML提取的styles集合,见:getExternalStyleSheets[查看]
返回:
- 用静态样式替换掉注释后的资源对象,类型为
Promise<string>
通过 Promise.all 批量处理 style 集合中每一项的 contentPromise:
| 资源类型 | 替换的注释 | 处理方式 |
|---|---|---|
外联样式 - ignore |
genLinkReplaceSymbol |
用 link 元素加载样式并替换 |
外联样式 - 非 ignore |
genLinkReplaceSymbol |
用内联样式替换 |
| 内联样式 | getInlineStyleReplaceSymbol |
用内联样式替换 |
在应用资源中,带有
ignore属性的静态样式将被彻底注释,不做任何操作
目录:entry.ts - fetchAssets [查看]
参数:
src:资源链接,用于fetch请求用cache:缓存集合,包含:scriptCache、styleCache[查看]fetch:配置提供的加载的方法,没有提供采用全局window默认提供的fetchcssFlag:加载的资源是否是样式,否则就作为scriptloadError:配置时提供,可选参数,见:文档 [查看]
返回:
- 通过
fetch返回的Promise<string>
调用场景:
只做 2 件事:
- 从
cache缓存集合中获取加载资源 - 缓存集合不存在资源,通过
fetch加载后缓存到cache然后返回
缓存机制:
- 通过
fetch获取资源后,将Promise保存到cache中,键值为url - 这样使用
url再次请求时,将优先通过缓存处理
使用
codepen按照fetchAssets做的演示 [查看]
捕获失败:
- 两种情况,通过
catch捕获请求失败,通过status判断服务器反馈状态异常
默认情况下
fetch不会把服务器反馈的异常状态认为是错误
失败怎么做:
- 根据
cssFlag资源类型打印错误,并通过loadError发起通知 - 更新缓存中的键名
url值为null,以便下次加载时能够重新加载
每次返回请求前都会先将
Promise保存到cache,失败后需要更新为null
更新缓存会影响返回的 Promise 吗?
- 不会,从这点说明
return赋值返回的对象一定是等号右边的对象
// 这里返回的一定是等号右边的 `Promise`,而不是 `cache`
return (cache[key] = Promise.resolve());
有 2 个同名的方法,为了做区分这里称呼为:加载方法和 importHTML 中的包装方法
1. 加载方法 getExternalScripts
应用中所有 script 加载、缓存的方法,包括 importHTML 中静态 script 提取,也是包装 getExternalScripts 作为属性返回
目录:entry.ts - getExternalScripts [查看]
参数:
scripts:提取的script集合,包含的script可以是外联也可以是内联fetch:透传给fetchAssets的请求方法 [查看]loadError:资源加载失败通知,并非必选参数,同样透传给fetchAssetsfiber:是否空闲时间加载资源,默认是true
返回:
- 提取
script结果的集合ScriptResultList[],见:源码 [查看]
主要做的 1 件事:
- 遍历
script集合,为每一项增加一个Promise类型的属性contentPromise
contentPromise 内联 script 加载情况:
- 全部在
Promise中返回代码字符,包括存在module等其它属性 - 内联
script虽然判断了ignore,但是不存在这种情况,见下方ignore说明
contentPromise 外联 script 加载情况:
module:在Promise中以空字符返回ignore:限async或defer非module将通过fetchAssets加载资源,否则在Promise中返回空字符- 其它情况都会通过
fetchAssets加载资源 [查看]
Promise 返回空字符的情况,之后会通过 insertScriptToIframe 作为外联 script 加载 [查看]:
- 因为
contentPromise只能决定script中的content content不存在的话,会通过src去加载script,这样通过浏览器机制有效避开跨域问题- 其中包含了:所有外联的
module,非async和defer的ignore
ignore 通 fetchAssets 加载 async 或 defer,仅限提取静态 script:
- 动态添加外联
script忽略了此属性 - 手动添加带有
src的js-loader,不会加载而是作为外联script注入沙箱
需要强调的是能够被提取的静态 script,一定是通过手动标记的 ignore:
- 因为包含
ignore属性的静态script将被注释替换不再恢复
这可能是开发人员的遗漏,因为文档中描述
ignore的设计就是为了解决跨域请求资源的问题,而避开使用fetchAssets加载资源,见:文档 [查看]
通过 fetchAssets 不同的加载方式:
async或defer下使用fiber决定是否通过宏任务requestIdleCallback空闲加载- 其它全部直接加载
加载外联 script 时传递给 fetchAssets 的参数:
src:资源链接scriptCache:用于缓存script加载的资源,见:资源缓存集合 [查看]fetch:透传自身参数fetchcssFlag:不是样式资源,全部设为falseloadError:透传自身参数loadError
除此之外做了什么:
module非async的script,需要标记defer为true- 在
start启动应用时,会将其作为同步代码 [查看]
调用场景:
importHTML:包装后作为返回对象的属性,用于加载应用中静态script,下面会详细说明rewriteAppendOrInsertChild:处理应用中动态加载的script[查看]
单例应用如
React通常会静态加载入口文件,然后动态注入script
手动配置的 js-loader 不会通过 getExternalScripts 加载资源:
- 包含
jsBeforeLoaders、jsAfterLoaders
根据以上总结,以下情况将通过 src 在沙箱中加载 script:
- 类型为
module的外联script,无论是静态提取还是动态添加 - 手动配置
ignore非async或defer的外联script - 手动配置
js-loader的外联script
关于 ignore 的补充:
- 应用中提取的静态
script存在ignore属性将被注释,资源不会被收集,无论内联还是外联 - 应用中动态添加的
script,不收集元素ignore属性,除了jsIgnores都加载为内联script - 通过
jsIgnores手动忽略外联script,但不忽略async或defer非module的外联script ignore的script将在Promise返回空字符,需要通过src加载script
由此得出在 getExternalScripts 中加载的 script:
- 内联
script不存在ignore,因为加载前被筛选出去,或无法匹配jsIgnores - 外联
script通过jsIgnores添加的ignore
通过 src 加载 script 需要注意:
- 外联
script没有包裹proxyLocation,调用location是建立在基座url上 [查看] - 需要通过
window.$wujie.location来代替location
2. importHTML 中的包装方法
只能用于应用中的静态 script 加载,例如入口文件
目录:entry.ts - importHTML - getExternalScripts [查看]
调用方法不需要提供参数,但内部会将以下参数传递给加载方法 getExternalScripts:
scripts:筛选后的静态script集合fetch:自定义方法,没有提供则使用全局window提供的fetchloadError:加载失败通知方法,配置时提供,可选参数fiber:透传importHTML的参数,配置时提供,默认true[查看]
scripts 筛选规则:
- 内联的静态
script都允许 - 外联的
script不在jsExcludes配置列表中,见:文档 [查看] - 所有符合要求且匹配
jsIgnores的外联script,需要标记ignore为true,见:文档 [查看]
scripts 从哪里来:
- 由
processTpl从提取的入口资源筛选出静态script[查看]
调用场景:
发挥的作用:
- 在
importHTML包裹getExternalScripts方法确保不会立即执行 [查看] - 而在调用场景中通过
await可以确保执行前优先发起任务加载script
发起的任务由 scripts 集合中的 contentPromise 决定:
- 类型为
Promise的微任务,将确保资源resolve - 类型为
fetchAssets返回的微任务,将确保发起请求
为了很好的理解区别,用
codepen做了一个演示 [查看]
那注入 script 到沙箱时,fetchAssets 还没有加载完怎么办?
- 在
start时应用中的资源将被分配到同步和异步代码中执行 [查看] - 无论是同步代码还是异步代码,都是
Promise队列,必须等待上条执行完毕后才能发起新的微任务
有 2 个同名的方法,为了做区分这里称呼为:加载方法和 importHTML 中的包装方法
1. 加载方法 getExternalStyleSheets
应用中所有样式加载、缓存的方法,包括 importHTML 中静态样式提取,也是包装 getExternalStyleSheets 作为属性返回
目录:entry.ts - getExternalStyleSheets [查看]
参数:
styles:提取的样式集合,包含的样式可以是外联也可以是内联fetch:透传给fetchAssets的请求方法 [查看]loadError:资源加载失败通知,并非必选参数,同样透传给fetchAssets
返回:
- 提取样式结果的集合
StyleResultList[],见:源码 [查看]
主要做的 1 件事:
- 遍历样式集合,为每一项增加一个
Promise类型的属性contentPromise
contentPromise 加载情况有 4 种:
| 分类 | 条件 | 处理方式 |
|---|---|---|
content 内联样式 |
无 | 在 Promise 中以内联代码返回 |
src 为元素 |
包含标签 <> |
提取元素中的样式,以 Promise 返回内联代码 |
src 外联样式 |
ignore |
在 Promise 中以空字符返回 |
src 外联样式 |
无 | 通过 fetchAssets 加载资源 [查看] |
src作为元素outHTML,虽然看上去不合理,但这是用于和内联样式区分的唯一办法,目前没有用到
加载外联样式时传递给 fetchAssets 的参数:
src:资源链接styleCache:用于缓存样式加载的资源,见:资源缓存集合 [查看]fetch:透传自身参数fetchcssFlag:样式资源设为trueloadError:透传自身参数loadError
除此之外做了什么:
- 内联样式会将
src更新为空字符,因为存在src为元素outHTML的情况
调用场景:
importHTML:包装后作为返回对象的属性,用于加载应用中静态样式,下面会详细说明rewriteAppendOrInsertChild:处理应用中动态加载的外联样式 [查看]processCssLoaderForTemplate:通过配置手动注入样式到应用头部和尾部 [查看]
应用中动态加载的内联样式不需要调用
getExternalStyleSheets;在单例应用,如React中通常会通过入口文件动态加载样式,以内联的方式将代码注入样式,见:加载流程 [查看]
关于 ignore 的补充:
- 应用中提取的静态样式存在
ignore属性将被注释,资源不会被收集,无论内联还是外联 - 应用中动态添加的样式,不收集元素
ignore属性,除了cssIgnores都能顺利加载 - 通过
cssIgnores将手动忽略外联样式 - 手动忽略
ignore的外联样式将在Promise返回空字符,通过link加载样式
由此得出在 getExternalStyleSheets 中加载的样式:
- 内联样式不存在
ignore,因为加载前被筛选出去,或无法匹配cssIgnores - 外联样式通过
cssIgnores添加的ignore
ignore 在不同场景下的表现:
processTpl:提取静态样式,用注释替换掉样式 [查看]cssIgnores:手动忽略样式,采用link加载样式,见:getEmbedHTML[查看]processCssLoaderForTemplate:手动配置样式,直接跳出不做任何操作 [查看]
2. importHTML 中的包装方法
只能用于应用中的静态样式加载,例如手动为入口 template 添加了静态样式
目录:entry.ts - importHTML - getExternalStyleSheets [查看]
调用方法不需要提供参数,但内部会将以下参数传递给加载方法 getExternalStyleSheets:
styles:筛选后的静态样式集合fetch:自定义方法,没有提供则使用全局window提供的fetchloadError:加载失败通知方法,配置时提供,可选参数
样式筛选规则:
样式从哪里来:
- 由
processTpl从提取的入口资源筛选出静态样式 [查看]
调用场景:
processCssLoader加载样式资源 [查看]
发挥的作用:
- 在
importHTML包裹getExternalStyleSheets方法确保不会立即执行 [查看] - 而在调用场景中通过
await可以确保执行前优先发起任务加载样式
发起的任务由样式集合中的 contentPromise 决定:
- 类型为
Promise的微任务,将确保资源resolve - 类型为
fetchAssets返回的微任务,将确保发起请求
为了很好的理解区别,用
codepen做了一个演示 [查看]
和 script 不同的加载方式:
- 样式加载每一步都由
await确保执行完毕,不存在还没有拿到fetchAssets请求结果的情况
包含 2 个插件和一个启动配置,分别是
css-loader:插件,在运行时对子应用的css文本进行修改,见:文档 [查看]js-loader:插件,在运行时对子应用的script脚本进行替换,见:文档 [查看]replace:启动配置,在运行时处理子应用的html、js、css进行替换,见:文档 [查看]
这些方法将在不同的场景下调用并替换资源
processCssLoader 加载应用中的静态样式替换 template 中的注释 [查看]:
- 为样式资源属性
contentPromise追加一个微任务,通过css-loader替换样式 - 通过
getEmbedHTML将加载的样式替换template中对应的注释 [查看] - 最后通过
replace替换已更新资源后的template
调用
processCssLoader之前就一定会通过importHTML提取资源 [查看]
存在 2 个问题:
replace不能替换应用中的静态样式,只能用css-loader代替replace在processCssLoader不可用
因为 replace 必须在应用 active 时绑定在实例:
- 而
processCssLoader是在active之前调用,执行时应用实例中还不存在方法replace
getCssLoader 柯里化处理运行时的样式:
- 接受 2 个参数,全部来自手动配置:
plugins插件集合、replace用于替换资源 - 返回函数,参数有:
code样式内容、src资源链接、base子应用origin+pathname
处理方式:
- 先通过
replace替换code样式,然后将参数透传给compose返回的函数 compose也是柯里化函数,通过reduce依次调用css-loader替换样式 [查看]
在这里
replace会优先于css-loader执行替换
getCssLoader 调用场景:
getCssLoader时replace会根据配置绑定在实例,因为上述方法都在应用active之后调用
processCssLoaderForTemplate 来自激活应用时渲染容器,见:创建容器渲染资源 [查看]
rewriteAppendOrInsertChild 会通过 patchRenderEffect 重写方法 [查看]
replace的参数只有code,拿不到资源类型,只能根据具体代码进行替换,如果需要更多的信息,建议通过css-loader或js-loader
getJsLoader 柯里化处理运行时的 script:
- 执行方式和
getCssLoader是一样的,唯一的不同是提取plugins的属性名 - 柯里化后传递
replace替换的资源,通过compose将拿到的资源依次调用jsLoader[查看]
在这里
replace会优先于js-loader执行替换
getJsLoader 只能通过 insertScriptToIframe 调用,分别来自以下场景 [查看]:
start启动应用:手动添加script、应用内静态script(含入口文件)[查看]rewriteAppendOrInsertChild:应用中动态添加的script,包括chunk script
应用内动态添加的样式和
script,会先注入静态script到沙箱后,然后再拦截动态添加的样式和script,在单例应用中通常将入口script注入后,进行动态拦截
围绕应用的渲染容器归纳相关的方法,包含:shadowRoot 容器、iframe 容器以及劫持容器
分 2 部分:创建 iframe 容器和挂载容器
创建 iframe 容器:
| 分类 | iframe 容器 |
劫持容器 |
|---|---|---|
| 创建方法 | createIframeContainer,见:源码 [查看] |
renderIframeReplaceApp [查看] |
| 创建方式 | document.createElement("iframe"); |
window.document.createElement("iframe") |
| 默认样式 | height:100%;width:100% |
height:100%;width:100% |
| 设置属性 | 自定义属性、样式、flag_id |
自定义属性、样式、src |
| 执行结果 | 返回 iframe 容器 |
将劫持容器渲染到 el 挂载节点 |
| 调用场景 | initRenderIframeAndContainer,继续往下看 |
locationHrefSet [查看]、processAppForHrefJump [查看] |
为什么要放到一起总结?
- 因为作为容器它们只是调用场景不一样,但创建的方式是一样的
- 从这点也能看出来
wujie的源码相对会micro-app零散很多 [查看]
initRenderIframeAndContainer 挂载容器到指定节点
目录:shadow.ts - initRenderIframeAndContainer [查看]
参数:
id:应用名,透传给createIframeContainer用于给容器添加属性parent:挂载节点,用于挂载容器,来自配置eldegradeAttrs:iframe属性 [查看]
流程:
- 使用
id和degradeAttrs,通过createIframeContainer创建iframe容器 - 通过
renderElementToContainer挂载容器到指定节点 [查看] - 拿到
iframe容器的document,并写入空白html - 将
iframe容器和挂载节点都返回
renderIframeReplaceApp也是通过renderElementToContainer将劫持容器挂载到指定节点,挂载前renderElementToContainer会清空挂载节点,这样就完成了劫持容器替换渲染容器的过程
调用场景:
上述场景会先通过
initRenderIframeAndContainer创建空白容器,之后再注入资源
目录:shadow.ts - renderElementToContainer [查看]
参数:
element:挂载的节点,类型:Element | ChildNodeselectorOrElement:容器,类型:string | HTMLElement
若
selectorOrElement提供字符会通过document.querySelector查找元素,目前没有用到
流程:
- 使用
selectorOrElement通过getContainer定位到容器container - 如果
container存在,且不包含提供的element,将其添加到container - 返回定位的容器
container
上面已说明目前
selectorOrElement只提供HTMLElement,所以container必须存在
需要注意的是:
- 不存在
LOADING_DATA_FLAG节点的情况下,挂载前需要通过clearChild清空容器
什么时候会提供 LOADING_DATA_FLAG:
startApp启动应用时通过addLoading设置,见:启动应用时添加、删除loading[查看]
addLoading时容器就已清空,之后不再需要清空,只需要根据情况删除loading状态
调用场景:
renderIframeReplaceApp:劫持url创建iframe替换容器 [查看]locationHrefSet:降级处理时将iframe容器的html添加到沙箱body[查看]active激活应用时,将shadowRoot添加到挂载节点 [查看]initRenderIframeAndContainer:创建iframe容器添加到挂载点 [查看]processAppForHrefJump监听后退操作:用shadowRoot容器替换劫持容器 [查看]
目录:shadow.ts - renderTemplateToIframe [查看]
参数:
renderDocument:降级iframe容器的documentiframeWindow:沙箱的windowtemplate:通过active透传过来的应用入口资源 [查看]
调用场景全部来自 degrade 降级下 active 激活应用 [查看]
- 首次
aclive:将应用资源注入iframe容器 - 非
alive模式再激活:同首次激活一样,每次激活都会新建iframe容器
umd再次激活时,使用的template来自首次active绑定在实例的资源
alive 再次激活不会用到 renderTemplateToIframe:
- 将通过实例的
document将首次注入容器的html元素,replace新容器中的html元素
流程和 renderTemplateToShadowRoot 一样 [查看]
目录:shadow.ts - renderTemplateToShadowRoot [查看]
参数:
shadowRoot:注入的容器iframeWindow:沙箱的windowtemplate:通过active透传过来的应用入口资源 [查看]
调用场景
- 来自非
degrade降级下active激活应用 [查看]
所有模式首次激活都会将 template 注入 shadowRoot,再次激活规则如下:
aclive模式:切换容器挂载点,不重新注入资源umd模式:激活应用前会清空shadowRoot,激活时重新注入template- 重建模式:销毁重建应用实例,每次激活都会注入
template
alive 再次激活不会用到 renderTemplateToShadowRoot:
- 容器会绑定在实例属性
shadowRoot中,再次激活直接挂载到指定节点el
流程和 renderTemplateToIframe 一样:
| 分类 | renderTemplateToIframe |
renderTemplateToShadowRoot |
|---|---|---|
renderTemplateToHtml [查看] |
创建 html 元素 |
创建 html 元素 |
processCssLoaderForTemplate [查看] |
手动添加样式 | 手动添加样式 |
注入 html |
替换容器 html 元素 |
appendChild 到容器 |
修复 parentNode |
需要 | 需要 |
patchRenderEffect [查看] |
给容器打补丁 | 给容器打补丁 |
如何修复 parentNode:
- 通过
Object.defineProperty劫持容器html元素的parentNode,指向沙箱document
不同在于:
| 分类 | renderTemplateToIframe |
renderTemplateToShadowRoot |
|---|---|---|
| 容器根节点 | iframe.document |
shadowRoot |
| 指向实例属性 | this.document |
this.shadowRoot |
容器 head |
this.document.head |
this.shadowRoot.head |
容器 body |
this.document.body |
this.shadowRoot.body |
遮罩层 shade |
不支持 | 作为在容器 html 第一个子元素 |
因此
patchRenderEffect打补丁的容器对象也不一样 [查看]
关于 head、body:
- 容器的
head、body主要用于容器事件、元素操作的代理和劫持 - 除此之外无论是
iframe还是shadowRoot,都有一个实例的head、body,用于渲染子应用的template,见:renderTemplateToHtml[查看]
遮罩层 shade:
- 在容器中不可见,为了撑开容器用来展示应用中的弹窗和浮层,将作为
html节点下第 1 个元素 - 由于在
iframe容器中无法撑开容器区域,所以仅限shadowRoot
目录:shadow.ts - renderTemplateToHtml [查看]
参数:
iframeWindow:沙箱的windowtemplate:通过active透传过来的应用入口资源 [查看]
返回:
- 完成渲染并更新资源的
html元素
调用场景:
做了 3 件事:
- 通过沙箱
document创建一个html元素,并将template作为innerHTML - 遍历
html下所有可见元素,通过patchElementEffect为每个元素打补丁 [查看] - 获取所有
a、img、source元素,修正资源相对路径
优化 umd 模式加载的应用,将head 和 body绑定在应用实例中:
- 组件多次渲染,
head和body必须一直使用同一个来应对被缓存的场景(来自备注) - 初次声明节点会绑定到应用实例,再次调用
renderTemplateToHtml,将通过replaceHeadAndBody恢复最初记录的节点到html元素
在末尾可能是担心不存在 head 或 body 的情况进行了补全:
- 但目前来看似乎做了多余的工作
- 为
html元素设置innerHTML时候,会根据情况自动补全head和body - 并且会丢弃外部包裹的
html根元素及其它相关声明
用
code做了一个静态的资源补全的演示 [查看]
修正相对路径的细节:
只有当元素存在资源属性时才会通过 getAbsolutePath 转换资源路径 [查看]
有 2 种情况:
- 绝对路径:原封不动返回绝对路径
- 相对路径:返回:
baseUrl/相对路径
baseUrl通过patchElementEffect处理指向proxyLocation:origin+pathname[查看]
容器中所有元素都通过沙箱 document 创建,因为:
- 通过沙箱
window获取应用实例,以便做出对应操作,例如:记录head和body patchElementEffect通过沙箱的window为每个元素打补丁
目录:shadow.ts - processCssLoaderForTemplate [查看]
参数:
sandbox:应用实例,作用:① 获取沙箱document创建元素;② 为加载样式透传实例属性html:由renderTemplateToHtml渲染的html元素 [查看]
样式通过
getExternalStyleSheets加载,需要的参数也绑定在实例属性中 [查看]
返回:
- 将更新后的
html元素作为Promise返回,无论是resolve成功,还是reject拒绝
先提取 3 组 plugins:
cssLoader:用于每条样式加载成功后自定义处理,见:文档 [查看]cssBeforeLoaders:插入应用容器head头部的样式,见:文档 [查看]cssAfterLoaders:插入应用容器body尾部的样式,见:文档 [查看]
其中
cssLoader通过getCssLoader柯里化返回函数,见:通过配置替换资源 [查看]
其中 cssLoader 和 cssBeforeLoaders 会在应用实例初始化时添加 2 个默认的 plugin:
- 见:提取配置初始化属性 [查看]
除此之外:
- 从实例中获取
proxyLocation,通过getCurUrl拿到应用origin+pathname作为应用入口链接 - 在
cssLoader中需要提供:加载的样式内容、样式的链接,应用入口链接
提取样式的步骤和 processCssLoader 提取应用静态样式一样 [查看]
- 通过
getExternalStyleSheets为每个手动样式添加一个Promise对象contentPromise - 通过
Promis.all加载所有contentPromise,提取列表中每一项src、content - 遍历加载后的列表,创建一个内联样式,通过
cssLoader替换样式后,作为内联样式内容 - 根据加载的样式类型决定将样式插入
head头部,还是body尾部 - 通过
Promise.all将最终处理的html元素返回
手动添加样式元素忘记通过 patchElementEffect 打补丁了 [查看]
- 原因应该是:手动添加的样式,在子应用中不会匹配做相应操作
应用中的元素如何打补丁:
目录:
addLoading 添加 loading
参数:
el:挂载容器的节点,配置时提供loading:加载状态的HTMLElement,配置时提供,应该是可选类型
wujie的tsconfig.json并没按照严格来申明,很多类型不太正确
调用场景:
startApp启动应用 [查看]
用途:
- 清空挂载节点、给节点中添加
loading元素
流程:
- 通过
getContainer根据el获取挂载节点,并使用clearChild清空节点 - 通过
getComputedStyle获取当前挂载节点的样式,根据样式更新节点:定位、overflow、属性 - 创建一个
loading元素添加到挂载节点里
根据挂载节点的 position 执行不同的操作:
| 行为 | static |
relative、sticky |
|---|---|---|
记录 position |
标签 CONTAINER_POSITION_DATA_FLAG |
不记录 |
记录 overflow |
标签 CONTAINER_OVERFLOW_DATA_FLAG |
标签 CONTAINER_OVERFLOW_DATA_FLAG |
更新 position |
relative |
不更新 |
更新 overflow |
hidden |
hidden |
其它
position状态不做处理
创建 loadingContainer 元素:
- 作为
loading父节点,添加样式position为absolute,居中展示 - 添加标签
LOADING_DATA_FLAG,避免激活应用时通过renderElementToContainer清空挂载点 [查看] - 将参数
loading添加到loadingContainer,没有就使用默认的svg,之后将整个元素添加到挂载节点
此时的 loading 是不可见的:
- 因为父级的
position不是static,且overflow会隐藏子集,自身又没有高度 - 子集只有一个
absolute的loadingContainer无法撑开挂载节点的高度
什么时候可见:
active激活应用时,通过renderTemplateToShadowRoot或renderTemplateToIframe,将容器添加到挂载节点撑开节点高度时 [查看]
在哪清除:
- 只能通过
removeLoading,继续往下看
removeLoading 删除 loading
参数:
el:挂载容器的节点,配置时提供
做了 3 件事:
- 根据添加的标签还原
position和overflow - 删除添加的标签:
CONTAINER_POSITION_DATA_FLAG、CONTAINER_OVERFLOW_DATA_FLAG - 删除
loadingContainer元素
调用场景:
规则:
alive和umd只有初次加载会调用start,重建模式每次启动都会startmount也必须是通过start发起挂载,且仅限umd初次启动
umd模式初次加载会重复调用removeLoading[查看]
仅用于 degrade 主动降级
目录:
iframe.ts-recordEventListeners[查看]iframe.ts-recoverEventListeners[查看]iframe.ts-recoverDocumentListeners[查看]
1. recordEventListeners:记录容器中所有事件
参数:
iframeWindow:沙箱window
流程:
- 重写沙箱
Node节点属性addEventListener和removeEventListener - 根据操作从实例
elementEventCacheMap映射表中添加或删除记录,见:降级容器事件 [查看] - 然后再监听或删除子应用中
Node节点相关事件
记录事件方式:
- 键名:
Node监听事件的节点 - 键值:包含:
type、handle、options的数组集合
若删除监听的事件后,发现数组集合空了,同时在映射表中删除当前监听的事件节点
调用场景:
initIframeDom:初始化iframe的dom结构 [查看]
2. recoverEventListeners:恢复容器中所有元素事件
参数:
rootElement:iframe容器中html元素iframeWindow:沙箱window
流程:
- 通过
createTreeWalker拿到rootElement下所有Element节点 - 遍历元素通过
elementEventCacheMap获取事件监听对象,记录到一个新的WeakMap对象上 [查看] - 将拿到事件集合重新在节点上监听
- 将筛选赋值后的
WeakMap更新实例映射表elementEventCacheMap[查看]
调用场景:
alive模式切换应用时通过active激活时恢复事件 [查看]
umd:每次都清空容器,重建模式:每次都销毁实例,因此不存在恢复容器中元素监听的事件
3. recoverDocumentListeners:恢复容器 document 事件
参数:
oldRootElement:切换应用前active时绑定容器的html元素newRootElement:再次激活时active重新创建的容器html元素iframeWindow:沙箱window
流程:
- 和
recoverEventListeners一样,不同的是仅恢复容器document的监听事件
调用场景:
umd模式切换应用时通过active激活时恢复事件 [查看]
重建模式每次
startApp都是销毁后重新声明实例,不存在事件恢复,见:创建新的沙箱实例 [查看]
目的:
- 防止
react16监听事件丢失(来自备注),React 16及之前的版本事件记录在document
React 16之后事件绑定在应用根节点,如:#root;umd启动应用后会通过__WUJIE_MOUNT重新委托事件捕获
不需要恢复 document 事件:
shadowRoot:因为本身就是根节点,degrade降级时每次都是新建iframe容器- 重建模式:每次激活都是新的应用实例
degrade降级下,alive模式通过recoverEventListeners恢复事件
围绕 patch* 打补丁归纳方法
目录:effect.ts - patchRenderEffect [查看]
参数:
render:shadowRoot或降级容器documentid:应用名称,用于透传给重写方法中获取实例degrade:主动降级,非降级模式记录事件
调用场景全部来自 active 时渲染容器 [查看]
| 调用方法 | 用途 | 提供的容器 |
|---|---|---|
renderTemplateToShadowRoot [查看] |
渲染资源到 shadowRoot |
shadowRoot |
renderTemplateToIframe [查看] |
渲染资源到 iframe 容器 |
容器 document |
调用场景来自
active,但执行拦截是通过start启动应用添加入口script后
必做:劫持对象重写方法
| 劫持方法 | render |
render.head |
render.body |
重写方法 |
|---|---|---|---|---|
appendChild |
❎ | ✅ | ✅ | rewriteAppendOrInsertChild [查看] |
insertBefore |
❎ | ✅ | ✅ | rewriteAppendOrInsertChild [查看] |
removeChild |
❎ | ✅ | ❎ | rewriteRemoveChild [查看] |
contains |
✅ | ✅ | ❎ | rewriteContains [查看] |
选做:非降级情况下,通过 patchEventListener 记录容器 body、head 事件
- 事件会记录在监听对象的属性
_cacheListeners上,目的和意义见:容器事件 [查看]
记录、删除容器事件
提供两个方法:
都接受 1 个参数:
element:容器的head或body
要求:
umd模式,且非degrapde降级,原因见:容器事件 [查看]
重写 [body|head].addEventListener:
- 将事件和回调方法记录在映射表
listenerMap,之后添加element监听事件 - 记录名称为事件类型,记录的值是回调集合的数组
监听事件中的参数
options仅用于发起监听,不在记录中缓存,因为记录事件的目的是为了卸载应用时删除对应事件
重写 [body|head].removeEventListener:
- 通过事件和回调方法从映射表
listenerMap中删除对应的事件,之后删除element监听事件 - 如果删除事件后,记录事件类型对应的集合为空数组,将其从映射表中删除
将 listenerMap 绑定在 element 对象属性 _cacheListeners:
unmount时会通过removeEventListener删除绑定在容器head、body上的事件- 切换应用时,在注入资源时通过初次记录在实例中的
head、body重新渲染,并再次记录事件
清理和重新记录仅限
umd模式,原因见:容器事件 [查看]
removeEventListener 删除事件:
- 遍历映射表
listenerMap,拿到type和回调方法集合,依次从element中取消事件
目录:iframe.ts - patchElementEffect [查看]
参数:
element:要打补丁的html元素、Node节点、ShadowRootiframeWindow:沙箱window,用于获取实例和沙箱中指定对象
内部补丁 1:baseURI
- 通过
proxyLocation定位到当前应用的protocol+host+pathname
用途:
- 通过获取元素的
baseURI去纠正子应用中带有相对路径的资源,比如:a、img等 - 使其路径相对于子应用,而不是基座
内部补丁 2:ownerDocument
- 指向当前沙箱
iframe.contentDocument
用途:
- 让渲染容器所有的元素根节点都指向沙箱
document,容器中创建的元素都需要通过沙箱document - 通过
ownerDocument可以从容器元素直接获取沙箱document用来创建元素
内部补丁 3:_hasPatch
表明已给元素打过补丁,不用再打补丁
外部补丁:patchElementHook
通过 execHooks 提取 plugins,提供则使用 patchElementHook 为每个元素打补丁,见:文档 [查看]
手动配置的外部补丁可覆盖内部补丁
目录:iframe.ts - patchIframeVariable [查看]
参数:
iframeWindow:沙箱window,用于子应用绑定全局属性wujie:应用实例appHostPath:子应用的origin
添加的属性:
__WUJIE:指向应用实例wujie__WUJIE_PUBLIC_PATH__:子应用的origin+/$wujie:子应用的provide,见:WuJie实例中关键属性 [查看]__WUJIE_RAW_WINDOW__:指向沙箱window
目录:iframe.ts - patchIframeHistory [查看]
参数:
iframeWindow:沙箱window,用于 ① 获取history;② 纠正链接appHostPath:子应用的originmainHostPath:基座的origin
调用场景:
initIframeDom:初始化iframe的dom结构 [查看]
设计初衷:
- 劫持
history之前,会通过initBase修正应用中相对路径,基于子应用:origin+pathname[查看] - 通过
history跳转时,需要拦截链接替换为基座origin后,更新沙箱iframe的url - 通过
updateBase根据沙箱的url更新base元素,重新基于子应用 [查看]
劫持 history 的方法:
pushState:插入记录replaceState:替换记录
流程有 3 步:
- 计算得到
mainUrl,通过rawHistoryPushState原生方法更新history记录 - 通过
updateBase更新呢base元素,用于修正应用中相对路径的基础链接 [查看] - 通过
syncUrlToWindow同步子应用路由到基座,以hash形式存在 [查看]
若更新
history记录中没有提供url,只执行rawHistoryPushState更新记录,不切换链接
mainUrl 计算方式:
url:来自子应用更新history记录的链接,链接基于子应用originbaseUrl:基座origin+ 沙箱的pathname+search+hashentry:将url中子应用origin替换为空,得到计划更新的:pathname+search+hashmainUrl:通过getAbsolutePath基于entry和baseUrl获取链接 [查看]
只要
entry不为空,baseUrl默认忽略search+hash,见:new URL[查看]
rawHistoryPushState.call 指定的上下文是沙箱的 history:
- 这样就为
syncIframeUrlToWindow中监听沙箱的popstate和hashchange提供了支持 [查看] - 当沙箱路由的
hash改变,或是前进后退时,就会发起同步路由的操作
目录:iframe.ts - patchIframeEvents [查看]
参数:
iframeWindow:沙箱window,用于重写事件监听
调用场景:
initIframeDom:初始化iframe的dom结构 [查看]
重写的沙箱 window 方法:
addEventListener:添加监听事件removeEventListener:删除监听事件
1. 通过 execHooks 提取并执行插件函数
addEventListener:windowAddEventListenerHook见:文档 [查看]removeEventListener:windowRemoveEventListenerHook见:文档 [查看]
目的:
- 子应用的
Dom渲染在容器中,script在 沙箱iframe中运行 - 当子应用需要监听
window事件时,可以通过插件从基座添加全局监听对象
2. __WUJIE_EVENTLISTENER__ 记录转发事件
目的:
记录和删除方法:
- 通过
set记录一个对象,包含:type、listener、options,确保每一条记录唯一性 - 删除事件时遍历集合,对照:
type、listener、options将其删除
3. 执行回调方法
无论事件怎么转发、记录最终都会通过原生方法执行操作:
rawWindowAddEventListener:原生添加事件rawWindowRemoveEventListener:原生删除事件
执行的方法都会提供 type、listener、options,不同的是上下文 window 指向:
| 参考条件 | window 指向 |
|---|---|
options.targetWindow 存在 |
options.targetWindow |
事件包含在 appWindowAddEventListenerEvents 中,见:源码 [查看] |
优先采用 options.targetWindow,不存在采用沙箱 window |
__WUJIE_RAW_WINDOW__ 存在,见:patchIframeVariable [查看] |
沙箱 window |
| 其它情况 | 全局 window |
优先权:
targetWindow> 沙箱window> 全局window
targetWindow 从哪来的:
- 手动配置,在
mdn文档中EventTarget的options并不包含targetWindow[查看]
例如需要在子应用中监听全局 window 的 popstate:
window.addEventListener('popstate', () => {}, { target: window.parent });
当然也包含注入
message等方法,用于父子应用相互通信
4. 会造成事件重复监听吗
存在重复监听但不影响使用,例如 resize:
- 通过
execHooks转发给全局window处理事件 - 沙箱
iframe同样也会addEventListener,但由于沙箱iframe不可见,所以除了removeEventListener之外沙箱不会执行任何resize事件
不存在重复监听,例如:DOMContentLoaded:
- 沙箱
iframe中用于监听沙箱window对象 - 若使用
execHooks转发事件,相当于在全局window上手动监听,选择权在使用者
存在歧义怎么办,比如 message 父子通信,既可以是全局 window,也可以是沙箱 window:
- 这个时候
targetWindow就能够很好的解决问题了
目录:iframe.ts - patchWindowEffect [查看]
参数:
iframeWindow:沙箱的window,用于绑定、重写属性和事件
做了 3 件事:
- 将全局
window上的属性绑定到沙箱window - 将全局
window上的事件回调用沙箱window做劫持 - 通过插件
windowPropertyOverride给沙箱window打补丁
绑定 window 上的属性
内部定义函数 processWindowProperty:
- 从沙箱提取指定的属性
key,然后从全局window上获取值,绑定到沙箱window上
判定前需要通过 isConstructable 来判断,提供的属性是否可以实例化 [查看]:
| 条件 | 绑定方式 | 上下文 |
|---|---|---|
| 可实例化的构造函数 | 直接绑定 | 实例化后的对象 |
| 不能实例化的函数 | 通过 bind 指定上下文 |
全局 window |
非函数,包括 undefined |
直接绑定 | 沙箱 window |
方法:
- 通过
Object.getOwnPropertyNames遍历沙箱window匹配属性名执行绑定
问题来了:这难道不就是
qiankun中被诟病的性能低下的快照吗?
匹配 ①:getSelection:
- 通过
Object.defineProperty指向沙箱document.getSelection - 用处:修正应用中文本范围或光标的当前位置
- 原理:容器负责渲染,沙箱负责执行
script
容器中的元素通过
patchElementEffect指向沙箱document[查看]
匹配 ②:windowProxyProperties 集合包含的属性,见:源码 [查看]
- 集合中包含的属性通过
processWindowProperty绑定到沙箱window
匹配 ③:windowRegWhiteList 正则匹配属性规则,见:源码 [查看]
- 通过
processWindowProperty绑定到沙箱window,执行前需确保全局window属性存在
绑定全局 window 上所有的 on 开头的事件
- 监听除了
onload、onbeforeunload、onunload之外所有on开头的事件 - 通过
Object.getOwnPropertyNames遍历全局window筛选匹配的事件
流程:
- 通过
Object.getOwnPropertyDescriptor从沙箱window上获取事件描述信息 - 通过
Object.defineProperty劫持沙箱window上的监听事件 - 通过
set将沙箱window监听的事件绑定到全局window - 通过
get直接返回绑定在全局window上的监听事件
在
set中对于类型为函数的handle通过bind将上下文this指向沙箱window
获取描述事件信息用处:
enumerable:判断是否可枚举set:重写方法前判断事件是否可写或描述中存在set,不满足设为undefined
重写方法的意义,举个例子:
// 子应用内 `window` 指向 `proxyWindow`,降级情况直接使用沙箱 `window`
(function(window, self, global, location) {
window.onfocus = () => {
// proxyWindow 通过 getTargetValue 将 onfocus 指向沙箱 window
// 由于是箭头函数,不会绑定上下文,上下文为当前函数模块
this;
}
}).bind(window.__WUJIE.proxy)(
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxyLocation,
);
// proxyWindow 通过 `getTargetValue` 指向沙箱 `window`
const proxyWindow = new Proxy(iframe.contentWindow, {
get: (target: Window, p: PropertyKey): any => {
// 其他代码省略...
return getTargetValue(target, p);
}
});
// 通过 `Object.getOwnPropertyDescriptor` 将事件绑定在基座 `window`
Object.defineProperty(iframeWindow, e, {
enumerable: descriptor.enumerable,
configurable: true,
get: () => window[e],
set:
descriptor.writable || descriptor.set
? (handler) => {
window[e] = typeof handler === "function" ? handler.bind(iframeWindow) : handler;
}
: undefined,
});
// 上面的代码相当于如下:上下文改变了,但事件触发来自基座 `window`
window.onfocus = () => {
this; // 这里 this 指向沙箱 window
}
- 这样当基座触发
window.onfocus时,就会调用来自子应用的监听事件 - 子应用中只需为
window绑定事件方法,不用关心window指向,和单独执行一样处理
通过插件 windowPropertyOverride 打补丁
官方文档遗漏了这个插件,原理和其它插件一样,通过 execHooks 提取 plugins 并执行:
- 执行时会将沙箱
window传过去,用于手动打补丁
目录:iframe.ts - patchDocumentEffect [查看]
参数:
iframeWindow:沙箱的window,用于 ① 为沙箱Document打补丁,② 提取应用实例
1. 处理 addEventListener 和 removeEventListener
沙箱运行 script,渲染是在容器、操作在基座,需劫持沙箱 document,按情况分别指向容器和基座。
重写方法:
iframeWindow.Document.prototype.addEventListener:沙箱document监听事件iframeWindow.Document.prototype.removeEventListener:沙箱document删除事件
应用中
script运行在沙箱,document也指向沙箱document
1.1. 记录事件
声明 2 个 WeakMap 类型的映射表,见:记录沙箱 document 上的事件 [查看]
handlerCallbackMap:记录监听的方法
- 使用
handle从集合中获取回调对象callback,不存在则通过bind修正后记录handle - 删除:通过
handle查找对应的callback,将其删除
修正上下文:若
handle是函数通过bind指向沙箱document,否则直接记录handle
handlerTypeMap:记录监听的事件
- 用
handle获取事件类型集合typeList,不存在将事件类型保存在数组中记录到集合 - 否则判断集合中是否包含事件类型,不包含则插入后更新记录,包含则不做任何操作
- 删除记录则是从集合中删除事件类型,若删除后集合为空,将其从映射表中删除
记录中只有 callback 是有必要的:
- 用于转发事件时作为回调对象,可以是函数也可以是一个包含
handleEvent的对象
handlerTypeMap目前除了记录外,没有其它用途,见:记录沙箱document上的事件 [查看]
1.2. 通过 execHooks 提取并执行插件函数
用于转发沙箱 document 上的事件到基座,因为部分操作来自基座,而不是沙箱 iframe
1.3. 执行添加或删除事件监听
无论添加还是删除事件,都要提供参数:type、callback、options,不同的是监听对象:
| 条件 | 监听对象 |
|---|---|
appDocumentAddEventListenerEvents,见:源码 [查看] |
沙箱 document |
degrade 降级,见:提取配置初始化属性 [查看] |
降级容器 document |
mainDocumentAddEventListenerEvents,见:源码 [查看] |
基座 document |
mainAndAppAddEventListenerEvents 事件互斥,见:源码 [查看] |
基座 document、shadowRoot |
| 其它 | shadowRoot |
如果基座也是子应用会怎样:
- 监听对象是:沙箱
iframe、降级document、shadowRoot,保持不变 - 监听对象是基座
window,监听或删除事件时会再次被重写,继续向上调用直至最顶层
无论监听对象如何改变,对于回调方法中的上下文始终遵循 callback:
- 沙箱
iframe:通过bind修正上下文 - 和监听对象相同:包含
handleEvent方法的回调对象
为此写了一个简单的演示,见:
codepen[查看]
2. 处理 onEvent
重新定义沙箱 document 中 on 开头的事件,将其绑定在容器指定对象中
| 子应用绑定对象 | 容器 | 监听对象 |
|---|---|---|
沙箱 document |
降级 iframe |
容器 document |
沙箱 document |
shadowRoot |
容器 html 元素 |
举例:
// 在子应用中绑定事件到 `document`,无论是否降级都指向沙箱 `documet`
(function(window, self, global, location) {
document.onscroll = function() {
this; // 沙箱 document
}
}).bind(window.__WUJIE.proxy)(
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxy,
window.__WUJIE.proxyLocation,
);
// 通过 `Object.getOwnPropertyDescriptor` 将事件绑定在不同的容器上
Object.defineProperty(iframeWindow.Document.prototype, e, {
enumerable: descriptor.enumerable,
configurable: true,
// 根据 degrade 从指定容器获取事件
get: () => (sandbox.degrade ? sandbox.document[e] : sandbox.shadowRoot.firstElementChild[e]),
set:
descriptor.writable || descriptor.set
? (handler) => {
// 绑定上下文为沙箱 document
const val = typeof handler === "function" ? handler.bind(iframeWindow.document) : handler;
// 根据 degrade 决定事件监听的容器
sandbox.degrade ? (sandbox.document[e] = val) : (sandbox.shadowRoot.firstElementChild[e] = val);
}
: undefined,
});
// `degrade` 中相当于挂载事件到 `iframe` 容器的 `document` 上
sandbox.document.onscroll = function() {};
// 非 `degrade` 相当于挂载到 `shadowRoot` 的 `html` 元素上
sandbox.shadowRoot.firstElementChild.onscroll = function() {};
以上示例中,当拦截事件后监听对象从沙箱
document转变为容器document,但是监听事件的回调函数中this仍旧为沙箱document
由于要绑定事件到监听对象上,所以必须从指定对象提取 2 个集合:
elementOnEvents:沙箱html元素上所有on开头的事件documentOnEvent:沙箱document元素上所有on开头的事件,但要排除onreadystatechange
取沙箱
iframe指定对象的property绑定到容器相同对象上,类型相同的元素包含的property也相同
为了兼容不同的容器节点,取 2 个属性集合的交集:
- 排除的事件继续往下看,见:4. 处理
document专属事件
流程和 patchWindowEffect 中处理 onEvent 一样 [查看]:
- 通过
Object.getOwnPropertyDescriptor从沙箱Document获取事件描述信息 - 通过
Object.defineProperty劫持沙箱Document上的监听事件 - 通过
set将沙箱Document监听的事件绑定到容器指定节点 - 通过
get直接返回绑定在容器节点上的监听事件
在
set中对于类型为函数的handle通过bind将上下文this指向沙箱document
获取描述信息的用处:
enumerable:判断是否可枚举set:重写方法前判断事件是否可写或描述中存在set,不满足设为undefined
3. 获取沙箱 document 属性时指向 proxyDocument
可以通过流程图了解沙箱 document 和 proxyDocument 的关系 [查看]
属性来自:
documentProxyProperties,见:源码 [查看]
流程和 onEvent 基本一样:
- 通过
Object.getOwnPropertyDescriptor从沙箱Document获取属性描述信息 - 通过
Object.defineProperty劫持沙箱Document上的属性 - 通过
get直接从proxyDocument返回对应属性值 - 通过描述信息,决定
enumerable是否可枚举
不同在于:不能通过
set重写属性值
proxyDocument 会根据容器不同略有差异:
在降级的
iframe容器中对比了proxyDocument差异
4. 处理 document 专属事件
根据渲染的方式,将沙箱 document 转发给容器或基座 document:
| 渲染方式 | 转发对象 |
|---|---|
shadowRoot |
基座 document |
降级 iframe |
降级容器 document |
属性来自:
documentEvents,见:源码 [查看]
流程和 onEvent 一样:
- 通过
Object.getOwnPropertyDescriptor从沙箱Document获取事件描述信息 - 通过
Object.defineProperty劫持沙箱Document上的监听事件 - 通过
set将沙箱Document转发监听事件到指定document对象 - 通过
get直接从转发的document对象上获取获取监听事件
在
set中对于类型为函数的handle通过bind将上下文this指向沙箱document
5. 处理 head 和 body
流程和:3. 获取沙箱 document 属性时指向 proxyDocument,基本一样:
- 遍历
ownerProperties集合劫持head和body,见:源码 [查看] - 从沙箱
document中劫持对象设置为不可重写,get时指向proxyDocument
除此之外还设置
body和head可枚举,可配置
6. 运行插件钩子函数
文档没提,流程和 windowPropertyOverride 一样,见:patchWindowEffect [查看]
- 通过
execHooks提取并执行插件documentPropertyOverride,将沙箱window作为参数传过去打补丁
为容器中每个元素重写 3 个方法
目录:iframe.ts - patchNodeEffect [查看]
getRootNode:使用原生方法获取 document,之后根据容器返回根节点
| 容器 | options |
根节点 |
|---|---|---|
shadowRoot |
composed 为 true |
全局 document |
shadowRoot |
不设置 composed,或为 false |
沙箱 document |
沙箱 iframe |
忽略 | 沙箱 document |
降级 iframe |
忽略 | 降级 document |
getRootNode 原理拆解:
shadowRoot及容器下的元素默认拿到的是shadowRoot,需要将其指正为沙箱documetshadowRoot下通过composed为true拿到的是全局document,无需修正
问题是:降级的
iframe容器不需要统一修正吗?
appendChild:在元素内追加子元素、insertBefore:在元素之前插入元素
- 通过原生方法动态添加元素,然后使用
patchElementEffect为元素打补丁 [查看] - 返回添加的元素
修复资源元素的相对路径问题(来自备注)
目录:iframe.ts - patchRelativeUrlEffect [查看]
参数:
iframeWindow:沙箱的window,用于 ① 透传给fixElementCtrSrcOrHref,② 提取资源接口
流程:
- 通过
fixElementCtrSrcOrHref拦截元素资源属性设置,修正相对路径为绝对路径 [查看]
修复的元素:
HTMLImageElement:图片srcHTMLAnchorElement:链接hrefHTMLSourceElement:媒体srcHTMLLinkElement:资源hrefHTMLScriptElement:脚本srcHTMLMediaElement:音视频src
通过重写方法、劫持元素原型,对资源属性中相对路径转化为绝对地址
目录:utils.ts - fixElementCtrSrcOrHref [查看]
参数:
iframeWindow:沙箱window,用于获取沙箱Element原生属性setAttributeelementCtr:资源元素接口attr:资源属性,如:src
目的:
- 来自子应用动态设置资源链接,通过
getAbsolutePath重新配置最终的链接 [查看]
处理链接有 2 种情况:
- 相对路径:按照
baseURI取转换为绝对路径 - 绝对路径或是
hash:不处理直接返回
baseURI为子应用origin+pathname,见:patchElementEffect[查看]
调用场景:
patchRelativeUrlEffect:修复动态添加元素资源 [查看]
重写 setAttribute:
- 要求设置的属性和
attr一致,获取绝对路径后通过原生方法更新属性
赋值更新时通过 defineProperty 劫持资源属性:
- 通过
getOwnPropertyDescriptor获取资源属性描述信息 set时通过getAbsolutePath转换资源路径、get时通过原生方法获取
围绕沙箱 iframe 归纳相关的方法
向沙箱 iframe 中插入 script,包含静态提取和动态添加
目录:iframe.ts - insertScriptToIframe [查看]
参数:
scriptResult:需要插入的script对象iframeWindow:沙箱window,用于 ① 获取沙箱document,② 获取应用实例,③ 回调透传rawElement:动态添加的script元素,用于setTagToScript打标记,可选参数 [查看]
scriptResult 有 2 个类型:
| 类型 | 来自 | 缺少属性 |
|---|---|---|
ScriptObject,见:源码 [查看] |
静态提取、动态添加的 | callback |
ScriptObjectLoader,见:源码 [查看] |
手动配置 jsLoader |
defer、ignore |
其中
defer、ignore在这里用不到,函数中会强制断言为ScriptObjectLoader。。。
调用场景有 2 个:
rewriteAppendOrInsertChild来自渲染资源到容器时调用patchRenderEffect[查看]
整个函数围绕 2 个对象展开:
scriptElement:根据提供的对象,创建script元素插入沙箱iframenextScriptElement:执行完毕后插入到沙箱,用于提取并执行下个队列,见:start[查看]
1. 获取配置
从 scriptResult 提取配置,详细见:processTpl 提取资源 - 4.提取或替换 script [查看]
src:script链接module:是否为esModule模块content:script内容crossorigin:是否跨域crossoriginType:跨域类型,包含"" | "anonymous" | "use-credentials"async:是否为异步加载,在这里只有一个用途,异步情况下不提取并执行下一个队列attrs:script元素中的属性键值对象callback:手动设置,会在注入script后调用,见:文档 [查看]onload:外联script完成加载或加载失败时调用
以上配置全部为可选类型,按照 content 划分如下:
content |
src |
类型 | textContent |
|---|---|---|---|
| 存在且不为空 | 不设置 | 内联 script |
按条件包装代码 |
| 不存在或为空 | 存在且不为空 | 外联 script |
空字符 |
| 不存在或为空 | 不存在或为空 | script 不加载 |
空字符 |
只有非
degrade降级下,内联script包裹在proxy模块内执行,其他情况script直接运行在沙箱下,见:流程图 [查看]
通过沙箱 iframe 获取应用实例,并提取对象:replace、plugins、proxyLocation:
- 用于获取
jsLoader替换content,见:通过配置替换资源 [查看] proxyLocation会通过getCurUrl得到链接为:子应用:origin+pathname- 将
content、src、得到的链接,作为参数透传给jsLoader用于替换要注入的script内容
除此之外
proxyLocation还用于作为proxy模块中的location
script 注入类型:
| 来源 | 元素类型 |
|---|---|
jsLoader 手动提供的外联 script |
外联 script |
jsIgnores 屏蔽 fetch 加载的外联 script |
外联 script |
jsIgnores 屏蔽 script 带有 async 或 defer 属性且非 esModule |
内联 script |
类型为 module 的外联 script |
外联 script |
带有 ignore 属性的静态 script,见:processTpl [查看] |
注释元素 |
不允许的 esModule,见:processTpl [查看] |
注释元素 |
jsExcludes 屏蔽 script |
排除不注入 |
其它类型的 script |
内联 script |
2. 配置 script
2.1. 为 scriptElement 添加属性:
| 属性 | 条件 |
|---|---|
注入 script 的键值对 attrs |
为了避免冲突,不能和 scriptResult 属性同名 |
src |
链接不为空的外联 script |
crossorigin |
跨域的外联 script,属性值为 crossoriginType |
type |
注入的 script 类型为 module |
async |
丢弃 |
defer |
丢弃 |
属性冲突举例:
scriptResult计算环境不支持esMoudle,而attr设置属性module
2.2. content 存在且不为空,作为内联 script:
内联 script 存在两种情况,见:流程图 [查看]
- 包裹在函数模块内:非降级
degrade不是esModule - 直接注入沙箱:
degrade或esModule
将整个 script 内容包裹在一个函数模块里:
document通过patchDocumentEffect打补丁拦截沙箱document[查看]
修复 webpack 当 publicPath 为 auto 无法加载资源的问题:
- 通过
Object.getOwnPropertyDescriptor获取scriptElement属性src的描述信息 - 当描述信息不存在时,或描述信息的类型不可以更改时需要修复问题
- 修复方式:通过
defineProperty定义scriptElement属性src
仅限内联
script需要根据情况修复,外联script本身拥有src属性
2.3. content 不存在或为空,作为外联 script:
设置属性:src,crossorigin 为 crossoriginType,如果属性存在的话
外联
script和esModule一样,不会包裹在proxy module中,见:流程图 [查看]
2.4. script 补充操作:
- 根据
module决定是否添加type属性为module - 设置
textContent,外联script也会设置,但script会优先采用src - 设置
nextScriptElement的代码,用于script插入完成后,提取并执行下一个队列
degrade和module决定是否包裹模块,而打包方式不同将决定应用入口script的上下文 [查看]
3. 声明注入 script 的方法
声明函数 execNextScript,用于注入 scriptElement 到容器:
- 指定沙箱
head作为容器,只要async不存在,就会执行appendChild
这里判断
async是存在问题的,见:start启动应用的bug[查看]
声明函数 afterExecScript,用于完成注入后执行:
- 触发
onload:用于通知script已完成加载 - 触发
execNextScript:提取执行下一个队列,见:执行队列 [查看]
onload 添加方式:
问题:外联
script注入沙箱加载失败后,触发的也是onload,回调函数和参数没做区分
错误的情况:注入 script 代码是 html 格式
- 错误条件:① 内联
script;②degrade降级或是esModule - 处理方法:输出
error,调用execNextScript以便执行下个队列
原因:
js作为内联script加载失败,或jsLoader加载失败
问题:加载 script 失败,但包裹在函数模块内就不算加载失败了
- 会导致注入
script执行错误,正确的做法应该将判断位置紧邻jsLoader下
let code = jsLoader(content, src, getCurUrl(proxyLocation));
// 紧邻 jsLoader,同时需要提升相关对象
nextScriptElement.textContent =
"if(window.__WUJIE.execQueue && window.__WUJIE.execQueue.length){ window.__WUJIE.execQueue.shift()()}";
const container = rawDocumentQuerySelector.call(iframeWindow.document, "head");
const execNextScript = () => !async && container.appendChild(nextScriptElement);
if (/^<!DOCTYPE html/i.test(code)) {
error(WUJIE_TIPS_SCRIPT_ERROR_REQUESTED, scriptResult);
return execNextScript();
}
// 添加属性以及内联 `script` 包裹在函数模块,挪后执行...
打标记:
原因:
- 注入沙箱的
script都是重建的,通过打标记的方式和动态创建的script关联起来 - 否则注入
script之后,在应用内继续操作创建的元素,沙箱中script没有任何效果
4. 注入 script 到沙箱
为外联 script 添加回调函数:
- 要求:
script带有src,content为空 - 满足条件无论是
onload还是onerror都会调用afterExecScript
问题:外联
script无论加载成功还是失败,都回调相同的方法,无法区分
注入操作:
- 在容器中添加
scriptElement,使用沙箱window调用callback通知完成注入 - 通过
execHooks提取并执行appendOrInsertElementHook,见:文档 [查看] - 对于内联
script元素无法触发onload,直接调用afterExecScript
callback只能通过jsLoader配置
目录:iframe.ts - iframeGenerator [查看]
js 沙箱,来自备注:
- 创建和主应用同源的
iframe,路径携带了子路由的路由信息 iframe必须禁止加载html,防止进入主应用的路由逻辑
参数:
sandbox:应用实例,用于获取应用id、透传参数打补丁、绑定iframeReady确保沙箱初始化attrs:手动配置iframe元素属性,见:文档 [查看]mainHostPath:基座originappHostPath:子应用的originappRoutePath:子应用的pathname+search+hash
第一步:创建 iframe
创建一个 iframe 元素作为沙箱,并设置属性:
src |
style |
属性集合 | name |
标记 |
|---|---|---|---|---|
mainHostPath |
display: none |
attrs |
应用名 | WUJIE_DATA_FLAG |
将沙箱设置和基座同域的目的是为了子应用能够和基座通信,见:文档 [查看]
将 iframe 添加到 body 末尾,通过 patchIframeVariable 为沙箱 window 添加属性 [查看]
- 来自备注:变量需要提前注入,在入口函数通过变量防止死循环。
第二步:发起微任务
- 发起微任务
stopIframeLoading并绑定到实例属性iframeReady上 [查看] - 返回创建的沙箱
iframe
iframeReady 用于确保 iframe 完成初始化,因此会在下个 fetch 拿到结果前执行完毕:
importHTML [查看] |
processCssLoader [查看] |
active [查看] |
|---|---|---|
通过 src 加载应用资源 |
已完成 | 已完成 |
提供 html 或使用缓存 |
getEmbedHTML 加载样式 [查看] |
已完成 |
提供 html 或使用缓存 |
没有静态样式 | 通过 await 确保完成 |
执行顺序从左到右,每行一种执行方式
因为 fetch 既不是微任务也不是宏任务,在拿到结果前会执行已挂载的微任务和宏任务
- 如果从初始化到替换入口资源
iframeReady都还未完成执行 - 将会在
active中通过await this.iframeReady确保注入资源前沙箱已完成初始化
原因:
stopIframeLoading通过Promise同步函数内部通过setTimeout发起resolve- 而
setTimeout由沙箱加载状态进行递归,因此会在下一个fetch之前完成初始化
沙箱 iframe 的加载变化:
src从about:blank到基座origin,会在document变更的第一时间resolve
iframeReady 都做了什么:
发起宏任务:检测并停止加载 iframe
- 在
stopIframeLoading中通过setTimeout观察document[查看]
由宏任务发起 resolve 添加微任务:给 iframe 打补丁
- 若因
iframe加载导致注入的全局属性丢失,通过patchIframeVariable重新注入 [查看] - 通过
initIframeDom初始化iframe的dom结构 [查看] - 从当前
url查找出是否存在应用名的query,如果没有先更新沙箱的history
通过基座
origin+ 子应用pathname更新history,为了相互通信沙箱需要和基座同域
目录:iframe.ts - initIframeDom [查看]
参数:
iframeWindow:沙箱window,用于:① 打补丁;② 存原生方法wujie:应用实例,用于获取资源链接和degrademainHostPath:基座originappHostPath:子应用origin
第一步:创建新的 html
- 通过
iframeWindow拿到沙箱document - 通过
window.document.implementation.createHTMLDocument创建一个新的空白html元素 - 如果沙箱
iframe中html元素存在就是用新的html替换,否则添加到沙箱document
为什么要创建一个新的 html:
- 因为
initIframeDom之前通过stopIframeLoading检测沙箱document改变 [查看] - 沙箱
document因配置了src,实例化后加载基座origin完成变更 - 因此执行
initIframeDom确保沙箱初始化时html为空
第二步:注入 iframeWindow 全局属性
__WUJIE_RAW_DOCUMENT_HEAD__:指向沙箱head元素
在通过打补丁方式覆盖原生方法前,先记录 Document 几个原生的方法,分别如下:
__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__:querySelector__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__:querySelectorAll__WUJIE_RAW_DOCUMENT_CREATE_ELEMENT__:createElement__WUJIE_RAW_DOCUMENT_CREATE_TEXT_NODE__:createTextNode
第三步:打补丁
initBase:初始化base标签 [查看]patchIframeHistory:劫持沙箱iframe的history[查看]patchIframeEvents:劫持沙箱iframe的EventListener[查看]recordEventListeners:如果degrade降级处理,记录iframe容器事件 [查看]syncIframeUrlToWindow:监听沙箱前进后退 [查看]patchWindowEffect:修正iframeWindow的effect[查看]patchDocumentEffect:修正沙箱document的effect[查看]patchNodeEffect:修正容器节点的effect[查看]patchRelativeUrlEffect:修复动态添加元素资源 [查看]
目的:在沙箱 iframe 中添加一个 base 元素
- 由于
iframeGenerator设置沙箱和基座同域 [查看] - 所以需要在沙箱中通过
base元素,基于应用链接,修正容器中所有资源的相对路径
操作分 2 部分,即:初始化和动态更新
initBase 初始化 base 标签
目录:iframe.ts - initBase [查看]
参数:
iframeWindow:沙箱window,用于获取沙箱document和沙箱初始化的locationurl:子应用的入口链接
沙箱初始化后,proxy 已完成创建,根据 degrade 不同指向也不同
- 非降级:
proxyLocation - 降级:沙箱
location
由于指向对象不同,拿到的结果不同,见:proxyLocation 的问题 [查看]
- 但在
initBase中只获取location.href中的pathname,这个值是相同的
调用场景:
initIframeDom:初始化iframe的dom结构 [查看]
流程:
- 通过
iframeWindow拿到沙箱的document并创建base元素 - 通过
anchorElementGenerator创建 2 个链接对象:基座origin,子应用入口链接 [查看] - 使用子应用
origin+ 基座pathname作为base元素的href,之后插入沙箱head中
初始化沙箱时链接为
mainHostPath:基座origin,而pathname通常为/(随实际情况改变)
updateBase 动态更新 base 标签
目录:iframe.ts - updateBase [查看]
参数:
iframeWindow:沙箱window,用于获取沙箱document和沙箱当前的hrefappHostPath:子应用originmainHostPath:基座origin
调用场景:
patchIframeHistory:劫持沙箱iframe的history[查看]
流程:
- 通过
new URL将沙箱当前的url中mainHostPath替换成appHostPath,作为baseUrl - 调用
iframe原生的方法查找base元素并更新href属性
baseUrl 可能存在 3 个结果:
| 分类 | 说明 | baseUrl |
|---|---|---|
| 相对路径 | mainHostPath 不存在 |
appHostPath + 相对路径 pathame |
| 绝对路径 | mainHostPath 存在,替换为空字符 |
appHostPath + 相对路径 pathame |
| 第三方绝对路径 | mainHostPath 不存在 |
iframeWindow.location.href |
因此为了在更新 base 元素时,最终仍旧以子应用链接为基准
- 只取
pathname再次计算:appHostPath+baseUrl.pathname
防止运行主应用的 js 代码,给子应用带来很多副作用(来自备注)
目录:iframe.ts - stopIframeLoading [查看]
参数:
iframeWindow:沙箱window,用于:① 获取沙箱document,② 停止加载
原因:
- 子应用的
script运行在一个和基座同域的iframe沙箱中 - 设置
src为mainHostPath,即基座origin会主动加载基座 - 所以必须在
iframe实例化完成前,还没有加载完html时停止加载,防止污染子应用
来自文档的提醒 [查看]:
- 若沙箱没有完成实例化就
stop,此时链接为about:blank会导致子应用路由无法运行 - 如果沙箱实例化时采用
document.write擦除,路由的同步功能将失败
由此得出
stopIframeLoading初始化沙箱时,擦除iframe的必要性
流程:
- 记录沙箱初始化时的
document作为oldDoc,此时链接是about:blank - 发起并返回微任务,
Promise同步函数中创建并执行函数loop作为document检测 - 在
loop中发起宏任务,在宏任务执行前会通过上下文加载沙箱iframe到基座 - 在宏任务中获取沙箱当前
document和oldDoc进行比较 - 如果沙箱
iframe没有完成实例化导致document不变,将重新发起一轮loop宏任务 - 直到
iframe完成实例化,document发生改变,停止加载并通过resolve结束当前微任务
由于沙箱
iframe在初始化之前已经设置不可见,所以加载过程也全程不可见
添加 iframe 元素是上下文同步任务,但加载 url 的过程既不是微任务也不是宏任务
- 因此加载
url时一定会优先执行当前已挂载的微任务和宏任务 - 因此
loop反复检测document也就合理了
沙箱 iframe 中监听 popstate 前进后退、hashchange 监听 hash 变化,同步路由到主应用
目录:iframe.ts - syncIframeUrlToWindow [查看]
参数:
iframeWindow:沙箱window,用于添加监听事件
流程:当沙箱路由发生改变通过 syncUrlToWindow 同步到基座 [查看]
调用场景:
patchIframeHistory:拦截子应用路由更新,同步更新沙箱history[查看]iframeGenerator:沙箱初始化最后一步同步路由 [查看]syncUrlToIframe:同步路由到子应用 [查看]
而浏览器的前进后退,以及
url的变更,会导致基座重新渲染,根据情况重新启动子应用
目录:iframe.ts - renderIframeReplaceApp [查看]
参数:
src:计划创建iframe的链接element:要替换的渲染容器的父级挂载点degradeAttrs:创建iframe的属性,由配置提供,见:degrade[查看]
调用场景:
劫持容器和降级的 iframe 容器创建方式是一样的 [查看]:
- 创建
iframe元素,定义一个宽高100%的样式,作为劫持容器 - 通过
setAttrsToElement为iframe添加样式、src、degradeAttrs - 通过
renderElementToContainer清空容器挂载元素,并添加劫持容器 [查看]
围绕应用中的路由、链接归纳相关方法
目录:sync.ts - syncUrlToWindow [查看]
参数:
iframeWindow:沙箱window,用于获取:应用实例、沙箱location
调用场景:
active激活应用时同步路由,见:同步路由 [查看]syncIframeUrlToWindow:监听沙箱window:popstate、hashchange[查看]patchIframeHistory:劫持沙箱history:pushState、replaceState[查看]
应用初始化 active 时,路由会先通过 syncUrlToIframe 同步到子应用 [查看]
- 之后路由再反向同步到主应用,这样做能够实现
prefix短路径替换
整个同步的流程概览:
- 从当前网址中提取并检查
winUrlElement中的queryMap - 更新对比页面网址决定是否需要替换全局
history记录
第一步:提取配置
- 从应用实例中获取:
sync同步路由、id应用名、prefix短路径,见:文档 [查看] - 提取当前的
url转变为链接对象winUrlElement,见:anchorElementGenerator[查看] - 使用
winUrlElement拿到queryMap,见:getAnchorElementQueryMap[查看] - 从沙箱
location中提取pathname+search+hash,作为当前子应用目标路由curUrl - 声明一个变量
validShortPath用于记录匹配的短路径键名
第二步:处理短路径
遍历 prefix 键名拿到短链名 shortPath,根据短路径获取长链接 longPath:
- 要求
curUrl必须以longPath开头,更新validShortPath为shortPath - 更新会取最大
longPath结果,例如:/a/b/c会优先于/a/b
longPath通过startsWith头匹配,不支持正则匹配语法
第三步:同步路由
同步方式:
sync已配置:通过encodeURIComponent更新queryMap[id]的值sync未配置:从queryMap中删除应用对应的值
sync未配置且当前网址search中找不到当前应用名,将在第二步之前返回null不做任何操作
queryMap[id] 更新方式
- 如果
validShortPath匹配到值,优先替换curUrl中longPath部分为{短链名} - 否则使用
curUrl作为路由
第四步:更新路由
- 将同步后的
queryMap还原成url.search,并更新winUrlElement对象 - 当
winUrlElement链接发生改变,通过window.history.replaceState更新当前url
目录:sync.ts - syncUrlToIframe [查看]
参数:
iframeWindow:沙箱window,用于获取:应用实例、沙箱location、沙箱history
调用场景:
active激活应用时同步路由,见:同步路由 [查看]
需要基座和应用都同步路由时,一定会先执行
syncUrlToIframe同步路由到子应用
第一步:获取配置
- 从沙箱
location中提取:pathname、search、hash,用于决定是否更新路由 - 从应用实例中获取:
id、url、sync,execFlag、prefix,用于计算子应用路由 - 从应用实例中获取:
inject得到基座origin,用于为沙箱iframe更新history
同步路由到子应用最终目的,以资源入口链接作为初始路由:
preAppRoutePath当前沙箱路由:pathname+search+hashsyncUrl计算后得出的路由:计算方式继续往下看
默认情况下都会以资源入口链接计算子应用路由,但只有以下这种情况例外:
| 网址中子应用的理由 | sync |
execFlag |
处理方式 |
|---|---|---|---|
通过 prefix 转换的短路径 |
已配置 | 还未启动 | 通过 getSyncUrl 获取子应用路由 [查看] |
使用 getSyncUrl 根据返回的结果,有 4 种情况:
| 返回类型 | 同步的链接 | 说明 |
|---|---|---|
pathname |
匹配的子应用路由 | 正确匹配路由 |
绝对路径的 url |
资源入口链接 | 劫持链接做路由,见 locationHrefSet [查看] |
| 空字符 | 资源入口链接 | 当前网址中找不到和子应用对应的路由 |
| 不存在短路径 | 错误的短路径 | 获取的路由不正确,见:getSyncUrl [查看] |
错误的短路径通常来自手动访问一个不存在的路由,如:
{不存在的短路径名}
其中绝对路径的 url 存在问题,见:processAppForHrefJump [查看]
匹配不到子应用路由时,同样也使用资源入口链接做计算
使用 getSyncUrl 前提:配置 sync 同步路由,且 execFlag 应用还未启动
sync决定了要不要转换路由,若未提供,syncUrlToWindow时将删除网址中子应用路由 [查看]execFlag决定了当前是否为首次加载
不同模式首次加载:
| 模式 | 刷新页面 | 首次加载应用 | 再次加载应用 |
|---|---|---|---|
alive 模式 |
✅ | ✅ | 沙箱不销毁,路由没有变化无需同步 |
umd 模式 |
✅ | ✅ | 沙箱不销毁,使用资源入口链接无需同步 |
| 重建模式 | ✅ | ✅ | 不存在再次加载 |
首次加载包括:初次
startApp、preloadApp
第二步:比较路由进行同步
根据以上方式计算出路由 syncUrl,通过 appRouteParse 转换获取 appRoutePath [查看]
- 同样得到:
pathname+search+hash
比较 preAppRoutePath 和 appRoutePath,若不相等则通过沙箱 replaceState 更新 history
appRoutePath一旦同步,子应用内切换路由,无需再次从基座执行同步,而是自身实现更新
计算结果如果没有变化,则不需要更新
history
目录:sync.ts - clearInactiveAppUrl [查看]
调用场景:
- 仅限
unmount卸载应用 [查看]
清理子应用过期的同步参数:
- 通过
anchorElementGenerator将当前的链接转换为链接对象winUrlElement[查看] - 通过
getAnchorElementQueryMap提取winUrlElement的search转化为键值对 [查看]
遍历 search 对象所有的 key,作为应用名提取并筛选应用,要求:
| 应用实例 | execFlag |
activeFlag |
sync |
hrefFlag |
|---|---|---|---|---|
| 存在 | 已启动 | 通过 unmount 已失活 [查看] |
已配置 | 并非 hrefFlag 劫持链接 [查看] |
将条件匹配的子应用路由从当前网址中删除
筛选后转换为 search 更新 winUrlElement 对象,之后比较全局 location.href:
- 如果不一致,通过
replace替换history记录
目录:utils.ts - appRouteParse [查看]
参数:
url:字符类型的链接
返回:根据传入的链接提取对象包含 3 个属性
urlElement:通过anchorElementGenerator转换链接为HTMLAnchorElement对象 [查看]appHostPath:提取链接originappRoutePath:包含链接的pathname+search+hash
调用场景有 2 个:
将 url 转换为 HTMLAnchorElement 对象
目录:utils.ts - anchorElementGenerator [查看]
参数:
url:链接string类型
若
url为相对路径,创建的链接对象会根据沙箱中base元素决定href[查看]
返回:
HTMLAnchorElement链接对象
目录:utils.ts - getAnchorElementQueryMap [查看]
参数:
anchorElement:HTMLAnchorElement类型的对象
通常传递过来的参数,来自
anchorElementGenerator返回的链接对象 [查看]
返回:
search键值对象:类型{ [key: string]: string }
流程:
- 将链接中
search按照&拆分成数组,遍历并根据=拆分成key和value - 如果
key和value都存在且不为空,则作为键值对添加到对象 - 最后返回键值对象,如果没有任何匹配的键值,返回一个空对象
从基座浏览链接中提取 search,匹配并处理后返回当前应用路由
目录:utils.ts - getSyncUrl [查看]
参数:
id:应用名,用于从search键值对中取出路由prefix:配置的短路径集合,见:文档 [查看]
返回字符类型的子应用路由,有 4 种情况:
pathname:匹配到子应用路由- 绝对路径的
url:劫持href实现的拦截路由,见:pushUrlToWindow[查看] - 空字符:没有匹配到子应用路由
- 返回错误的短路径:因手动范围不存在的路由,下面有描述
调用场景:
syncUrlToIframe:同步主应用路由到子应用 [查看]
提取路由:
- 通过
anchorElementGenerator转换基座链接为HTMLAnchorElement对象 [查看] - 通过
getAnchorElementQueryMap转换基座链接search拿到键值对queryMap[查看] - 使用应用名提取
queryMap拿到子应用路由,通过decodeURIComponent解析路由
如果应用名不存在
queryMap的键名中,拿到的是空字符
处理路由的前提是路由通过 prefix 替换了路由为短路径 project={short-name}:
- 判断依据:提供了
prefix,通过正则匹配路由大括号中间的短路径 - 将短路径从
prefix找到对应的完整路径,替换后返回
若因
queryMap不存在应用名,或因无法通过正则匹配得到undefined,同样返回空字符
存在一个语意上的 bug:
- 正则匹配得到短路径,如果在
prefix集合中不存在短路径名,则原封不动返回
正常加载的情况下不会出现问题,先看同步路由的流程:
| 流程 | 执行方法 | 操作 |
|---|---|---|
| 初次加载,同步路由到子应用 | syncUrlToIframe [查看] |
假定基座路由为 /react |
| 获取需要同步的路由 | getSyncUrl |
找不到 search,返回空字符 |
| 回到同步路由到子应用 | syncUrlToIframe [查看] |
因拿到空字符,采用资源入口链接作为沙箱路由 |
| 同步路由到基座 | syncUrlToWindow [查看] |
假定子应用路由是 /home/path,短路径对应 home,基座路由更新为:/react?project={home} |
| 刷新页面,再次同步路由到子应用 | syncUrlToIframe [查看] |
基座路由为:/react?project={home} |
| 获取需要同步的路由 | getSyncUrl |
匹配返回 /home/path 作为子应用路由 |
| 再次同步路由到基座 | syncUrlToWindow [查看] |
基座路由 search 没有变化,不做更新 |
由此可以看出路由中的短路径都来自 syncUrlToWindow 更新到基座,更新前已通过 prefix 匹配并提取
上面的例子中
{home}应该通过endecodeURIComponent编译,这里为了演示直接展示了
除非手动提供错误的链接,错误举例,手动访问:/react?project={test}
- 应用
project存在,prefix不存在短路径名test,错误返回{test}
此错误会影响到:
syncUrlToIframe,因为拿不到正确的路由,造成子应用渲染失败
目录:utils.ts - getAbsolutePath [查看]
参数:
url:任意字符,包括url、pathname、search、hash、空字符base:参考的url,必须为http开头的绝对路径,必填项hash:提取hash,可选boolean型
base 根据参数 url 有 3 种情况:
url |
base |
结果 |
|---|---|---|
http 开头的绝对路径 |
-- | base 被忽略 |
| 相对路径 | 相对路径 | 报错 |
| 相对路径 | http 开头的绝对路径 |
有效,基于 base 拼接链接 |
以上情况可参考
new URL特性 [查看]
直接返回参数 url 有 2 个情况:
url是空字符,这点和new URL特性是不一致的,URL对象此时会返回basehash为true,且url以#开头
其余返回,见:new URL [查看]
- 透传自身
url和base,获取href值
js 原生 class,在 wujie 中很多资源会用到,包括下面的 defaultGetPublicPath [查看]
参数:
url:表示绝对或相对URL的DOMString,在当前总结中统一描述为entrybase:表示基准URL的字符串,可选参数
特性 1:在不提供 base 的情况下 entry 只能是绝对路径的 URL
示范:
// right
new URL("https://developer.mozilla.org/zh-CN/docs/Web/API/URL/URL");
// Uncaught TypeError: Failed to construct 'URL': Invalid URL
new URL("/zh-CN/docs/Web/API/URL/URL");因此:
- 如果
entry传入的是URL对象,那么也一定是一个绝对路径 - 只有
entry是字符类型的时候才可以作为相对路径,并且需要同时提供base
特性 2:根据 entry 返回资源链接有 4 种不可变的路径和 2 个错误情况
entry 类型 |
base |
返回的 href |
|---|---|---|
URL 对象 |
不考虑 | entry.href |
http 开头绝对路径 |
不考虑 | entry |
以 / 开头相对路径 |
http 开头绝对路径、URL 对象 |
base.origin + entry |
| 空字符 | http 开头绝对路径、URL 对象 |
base.origin + base.pathame |
这里说的路径不变和下面说的可变,指的是计算
url路径的规则
当 base 是相对路径,entry 以下 2 个情况会报错:
- 以
/开头相对路径 - 空字符
特性 3:entry 为非 / 开头的相对路径,将作为可变路径
① 当 entry 以 . 开头,将查找 base.pathname 的资源目录:
.:当前目录..:上级目录
如果不是
/开头,也不是.开头的相对路径,默认和一个.开头行为一样
目录可以通过 / 间隔循环往上查找:
../. |
../.. |
../../.. |
|---|---|---|
| 上级目录 | 上级目录的上级目录 | 依次类推,直到 pathname 为 / |
同样可以省略当面目录中的
.,例如:../.的路径和../一样
② base 根据末尾字符分 2 种情况,假定有这样两个 url:
| 结尾字符 | 链接 | pathname |
末尾填充 |
|---|---|---|---|
/ |
https://github.com/cgfeel/micro-wujie-substrate/ |
/cgfeel/micro-wujie-substrate/ |
index.html |
非 / |
https://github.com/cgfeel/micro-wujie-substrate |
/cgfeel/micro-wujie-substrate |
不填充 |
当
base作为拼接时/cgfeel/micro-wujie-substrate/等同于/cgfeel/micro-wujie-substrate/index.html
③ 结合 entry 和 base,查找 pathname 目录如下:
entry |
/ 结尾 |
非 / 结尾 |
|---|---|---|
. |
/cgfeel/micro-wujie-substrate/ |
/cgfeel/ |
.. |
/cgfeel/ |
/ |
../.. |
/ |
/ |
④ 现在回过头来看 ./name 这样的 entry 可以拆分成 2 部分:
- 第 1 部分:
new URL('./', base),参考上述 3 步计算目录,假定结果为tmpUrl - 第 2 部分:将
tmpUrl+name
/ 填充尾部资源计算得到:
https://github.com/cgfeel/micro-wujie-substrate/ |
|---|
https://github.com/cgfeel/micro-wujie-substrate/ + name |
非 / 填充尾部资源计算得到:
https://github.com/cgfeel/micro-wujie-substrate |
|---|
https://github.com/cgfeel/ + name |
同理
..和../..这样的entry也遵循以上特征,就不一一举例了
特性 4:需要说明的是 base 拼接时会丢弃部分信息:
无论哪种情况当 base 作为 entry 拼接链接时,会丢弃自身的 search 和 hash
- 即便
entry是空字符,base也同样遵循这一特征
目录:utils.ts - defaultGetPublicPath [查看]
参数:
entry:透传自importHTML的应用入口链接
类型为
URL对象或string,但目前只能是string
补充说明:
- 因为调用场景只有
importHTML,透传参数url类型为string[查看] - 在源码中有判断
entry类型是否为object,通过在fetch中使用,得出对象类型只能是URL
参考 new URL 会保留 3 个特性 [查看]
- 和
new URL参数一样:entry作为url,location.href作为base
特性 1. 不存在,entry 可以是相对路径 [查看]
无论 entry 是什么路径,都会将 location.href 作为 base
entry为绝对路径:忽略baseentry为相对路径:通过location.href作为base进行拼接,加载基座路由作为子应用资源目录
特性 2:根据 entry 返回资源链接有 4 种不可变的情况 [查看]
返回的 url 将取 pathname 上一级,如果 pathname 不存在上一级,则得到的是 url.origin + /
const paths = pathname.split("/");
paths.pop();
return `${origin}${paths.join("/")}/`;
相对 URL 特性 2,wujie 中 entry 类型情况如下:
entry 类型 |
使用情况 |
|---|---|
URL 对象 |
不使用,若提供统一错误返回 / |
http 开头绝对路径 |
基本是 |
以 / 开头相对路径 |
不建议,将基座链接作为子应用资源目录 |
| 空字符 | 不建议,将基座链接作为子应用资源目录 |
entry 为 URL 对象存在的问题:
- 应返获取
url.href进行操作,同时保留对其他object的判断返回/
通常情况下应用入口链接是完整的绝对路径,但子应用不同环境下 host 不一样怎么办?
- 配置环境变量,用来区分生成环境和开发环境
特性 3:entry 是非 / 开头相对路径,会根据基座 pathname 提供资源链接 [查看]
同样不建议这样使用,将基座链接作为子应用资源目录
特性 4:base 作为拼接时,将丢弃部分属性 [查看]
如果需要将基座链接作为应用资源目录时,这个特性很重要
syncUrlToWindow同步路由到基座时,会将子应用的路由添加到基座链接的search[查看]- 将基座链接作为子应用资源目录时,会自动过滤掉基座的
search和hash
获取资源链接总结:
- 通常提供的
entry是http开头的绝对路径 - 非绝对路径通常加载基座自身路由作为资源,这种情况使用路由库来处理更合适
| 位置 | 分类 | 链接指向 | 补充说明 |
|---|---|---|---|
| 基座 | window |
基座所在作用域 | 按照全局 window 决定链接 |
| 基座 | 沙箱 iframe 的 src 属性 |
基座 origin |
沙箱和基座同域以便相互通信 |
沙箱 iframe |
location |
随子应用路由变化(下方总结) | 沙箱和基座同域以便相互通信 |
沙箱 iframe |
base 元素 |
子应用 origin + 沙箱路由 |
修正子应用中所有相对路径的资源链接 [查看] |
沙箱 location 随路由变化:
| 执行函数 | 阶段 | location |
|---|---|---|
iframeGenerator [查看] |
初始化 | 基座 origin |
syncUrlToIframe [查看] |
同步路由到子应用 | 基座 origin + 子应用路由 |
patchIframeHistory [查看] |
劫持子应用 history 状态更新 |
基座 origin + 子应用路由 |
问题:沙箱中获取 location
- 假定应用入口链接为
http://localhost:8080/pathname,基座为http://localhost:3000 - 因为沙箱和基座同域,得到结果为:
http://localhost:3000/pathname
于是在 proxyLocation 中做了一次拦截,用来修正取值 [查看]:
但
degrade下沙箱的location指向沙箱window,见:proxyLocation的问题 [查看]
degrade 下沙箱 location 和非降级模式 proxyLocation 取值的区别:
| 相关属性和对象 | proxyLocation |
沙箱 location |
|---|---|---|
host、hostname、protocol、port、origin |
按照子应用的入口资源来 | 按照基座来 |
href |
通过 relace 替换成子应用 origin |
按照基座来 |
replace |
通过 relace 替换成基座 origin,因为沙箱 iframe 和基座同域 |
按照基座来 |
| 其它属性 | 从沙箱 location 中取 |
从沙箱 location 中取 |
fetch 请求 |
相对路径按照 base 元素来补全 |
相对路径按照 base 元素来补全 |
配置并重写 fetch |
相对路径将通过 proxyLocation 来补全 |
相对路径将通过 proxyLocation 来补全 |
fetch无论是通过base元素还是proxyLocation,相对路径都以子应用origin来补全
单例应用,如:React、Vue,子应用通过动态添加 Dom 并完成渲染,围绕这块做总结
目录:effect.ts - handleStylesheetElementPatch [查看]
参数:
stylesheetElement:style元素,带有属性_patcher用于存放宏任务sandbox:应用实例,用于获取degrade、shadowRoot
_patcher属性由handleStylesheetElementPatch添加到style元素
不处理的情况:
degrade降级:没有shadowRoot,iframe容器也不存在兼容样式的问题- 设置
cssIgnores通过浏览器添加的外联样式,只能对内联样式打补丁
用途:
- 和
WuJie类中的patchCssRules一样,为应用样式打补丁 [查看]
包括为什么要打补丁,以及存在的问题都参考
patchCssRules
不同之处:
patchCssRules:获取shadowRoot下所有的内联样式打补丁 [查看]handleStylesheetElementPatch:只处理通过rewriteAppendOrInsertChild动态添加的样式 [查看]
流程:通过防抖的方式将打补丁的宏任务添加到内联样式元素上
- 定义打补丁函数
patcher,计划作为宏任务中执行的方法 - 若元素存在
_patcher属性,通过clearTimeout取消绑定的宏任务避免重复执行 - 通过
setTimeout将宏任务绑定在元素的_patcher属性上
patcher 和 patchCssRules 打补丁的流程一样 [查看]:
- 通过
getPatchStyleElements从提供的stylesheet中提取指定的样式 - 若存在
hostStyleSheetElement::host样式元素,将其插入shadowRoot.head - 若存在
fontStyleSheetElement:字体样式元素,将其插入shadowRoot.host末尾 - 将属性
_patcher设为undefined,允许后续继续操作
调用场景有 6 处:
单页应用动态添加内联样式,打补丁的步骤:
- 通过
active注入资源到容器后,使用patchRenderEffect重写添加Dom的方法 [查看] - 通过
start将入口script添加到沙箱iframe,开始渲染 [查看] - 通过
rewriteAppendOrInsertChild拦截动态添加内容为空的内联样式到Dom中 [查看] - 通过
patchStylesheetElement为动态添加的样式元素操作打补丁 [查看] - 通过
handleStylesheetElementPatch提取动态样式打补丁
外联样式将会加载后作为内联样式添加到应用:
- 然后通过
rewriteAppendOrInsertChild直接打补丁,见源码第 221 行 [查看]
设置
cssIgnores的外联样式将被忽略,不会打补丁
应用中通过原生方法添加的样式,将通过 rewriteAppendOrInsertChild 直接打补丁:
// 动态添加内联样式
const style = document.createElement("style");
style.innerHTML = "body{color:red}";
document.head.appendChild(style);
// 动态添加外联样式
const link = document.createElement("link");
link.setAttribute("rel", "stylesheet");
link.setAttribute("type", "text/css");
link.setAttribute("href", "reset-min.css");
document.head.appendChild(link);原生方法和单例应用动态添加样式不同在于:
- 单例应用会先添加一个内容为空的
style元素,然后再注入样式 - 因此需要通过
patchStylesheetElement来打补丁 [查看]
应用中的动态添加样式,打补丁不同的方式:
| 分类 | 加载方式 | 如何打补丁 |
|---|---|---|
| 外联样式 | 加载后作为内联样式添加到容器 | handleStylesheetElementPatch |
| 外联样式 | 配置 cssIgnores,作为浏览器加载的外联样式 |
不打补丁 |
| 内联样式 | 由单页应用创建空的动态样式 | patchStylesheetElement [查看] |
| 内联样式 | 原生方法动态添加的样式 | handleStylesheetElementPatch |
应用中的静态提取样式,打补丁不同的方式:
| 分类 | 加载方式 | 如何打补丁 |
|---|---|---|
| 所有 | 通过 importHTML 提取,不包含任何 ignore |
patchCssRules [查看] |
| 所有 | 元素包含任何 ignore 属性 |
被注释代替 |
| 外联样式 | 配置 cssIgnores,作为浏览器加载的外联样式 |
不打补丁 |
从上面可以知道动态添加样式来源 start,因此:
alive模式:只有首次启动会动态加载样式umd模式:理论上和alive一样,但是存在问题,见:重复提取样式的bug[查看]- 重建模式:每次启动都会重新动态获取样式
目录:effect.ts - patchStylesheetElement [查看]
参数:
stylesheetElement:style元素,带有属性_hasPatchStyle用于标记是否已劫持cssLoader:插件cssLoader用于替换样式,见:文档 [查看]sandbox:应用实例,用于透传给handleStylesheetElementPatch[查看]curUrl:透传自rewriteAppendOrInsertChild,子应用origin+pathname[查看]
_hasPatchStyle属性由patchStylesheetElement添加到style元素
由于 cssLoader 是通过 getCssLoader 柯里化拿到的函数 [查看]:
- 所以会因没有提供插件而不做处理,但
cssLoader一定是可执行的函数
不处理的情况:
_hasPatchStyle已标记,说明style元素已劫持过了patchStylesheetElement只处理来自应用内动态添加的内联样式,除此之外的样式都不处理
劫持的属性:
- 写入操作:
innerHTML、innerText、textContent - 重写方法:
appendChild - 额外属性:
_hasPatchStyle用于避免重复劫持
第一步:提取原生属性
- 提取属性:
innerHTML、innerText、textContent - 通过
patchSheetInsertRule重写stylesheetElement.sheet.insertRule
为什么重写 insertRule:
- 添加
CSSRule同时,将样式通过innerHTML或innerText写入style元素
因为 umd 模式切换应用后不会重复动态添加样式,解决办法是把样式写入元素:
insertRule 兼容性:
- 现代浏览器都支持、
IE支持到 9,而这正是wujie理论上最低兼容版本 - 对于不兼容的浏览器将忽略操作
第二步:劫持属性读取和写入
包含:innerHTML、innerText、textContent,劫持属性的方式一致:
get操作:用原生方法获取对应的属性set操作:- 用原生方法获取对应的属性执行更新
- 更新前会通过
cssLoader使用更新的样式和curUrl去执行替换操作 - 通过
nextTick发起一个微任务:通过handleStylesheetElementPatch打补丁 [查看]
为什么 cssLoader 不提供样式的 url:
- 因为
patchStylesheetElement处理的是应用内动态添加的内联样式
第三步:重写方法 appendChild
和劫持属性的方法相同:
- 通过
nextTick发起一个微任务:通过handleStylesheetElementPatch打补丁 [查看] - 使用原生的方法
appendChild新增元素
不同在于如果插入的样式是文本,还需要特殊处理:
- 更新前会通过
cssLoader使用更新的样式和curUrl去执行替换操作 - 将更新后的样式插入
style元素后,再次通过patchSheetInsertRule重写insertRule
无论插入的元素是什么类型,最终都要将新增的元素返回
需要说明的是:
- 劫持样式元素的属性打补丁,每次
handleStylesheetElementPatch都会提取完整的样式进行匹配 - 对于动态操作,可能会造成重复执行,但不会影响使用,见:额外说明 [查看]
目录:effect.ts - rewriteAppendOrInsertChild [查看]
接受一个 opt 对象,包含 2 个属性:
rawDOMAppendOrInsertBefore:原生添加Dom的方法,透传自patchRenderEffect[查看]wujieId:应用名,用于获取应用实例
添加 Dom 的方法:
| 重写方法 | 提供方法 | 引用对象 |
|---|---|---|
render.head.appendChild |
rawAppendChild |
Node.prototype.appendChild |
render.body.appendChild |
rawAppendChild |
Node.prototype.appendChild |
render.head.insertBefore |
rawHeadInsertBefore |
HTMLHeadElement.prototype.insertBefore |
render.body.insertBefore |
rawBodyInsertBefore |
HTMLBodyElement.prototype.insertBefore |
重写方法中的
render以及提供方法,见:patchRenderEffect[查看]
rawDOMAppendOrInsertBefore 的类型:
<T extends Node>(newChild: T, refChild?: Node | null) => T;,其中refChild为可选参数- 这样
refChild在appendChild中是无效参数,在insertBefore中是参考元素
返回函数:
- 类型和
rawDOMAppendOrInsertBefore一致,但会在patchRenderEffect通过as断言纠正 [查看] - 即
rawDOMAppendOrInsertBefore提供什么类型,就会断言返回的函数是什么类型
返回函数所需参数:
this:用于typescript指定上下文类型,见:typescript文档 [查看]newChild:添加的节点refChild:替换的节点,可选参数
执行函数返回对象:
- 按照原生方法:
appendChild、insertBefore一样,返回添加的元素 - 但是当添加的元素是
script或是外联样式时,会在沙箱iframe创建注释并返回
原因在于添加元素属于上下文同步操作:
- 添加外联样式通过
getExternalStyleSheets发起微任务 [查看] - 添加外联
script通过getExternalScripts发起微任务 [查看] - 添加内联
script在fiber下通过requestIdleCallback发起宏任务 - 只有内联
script且取消fiber才是同步操作,但返回的仍旧是创建的注释元素
由此得出:
| 动态添加 | ignore |
添加方式 | 注入后如何操作 |
|---|---|---|---|
外联和内联 script |
不匹配 | 加载为内联 script |
findScriptElementFromIframe [查看] |
外联 script |
匹配 | 创建外联 script |
findScriptElementFromIframe [查看] |
| 外联样式 | 匹配 | 元素不变 | 直接操作 |
| 内联样式 | 不匹配 | 元素不变 | 直接操作 |
| 外联样式 | 不匹配 | 加载为内联样式 | 无法关联 |
| 其它元素 | 不匹配 | 元素不变 | 直接操作 |
除了上述罗列的操作方式外,均可以通过给元素添加特定属性,来查找并操作元素
添加过程中,元素不变的情况都会执行以下操作:
rawDOMAppendOrInsertBefore:调用原生方法添加元素execHooks:提取插件appendOrInsertElementHook,调用时传递添加的元素和沙箱window- 按照条件返回添加的元素
元素不变即:拦截并添加的元素为子应用动态创建的元素,在源码中共有 4 处:
// 4 处分别为:不在拦截范围的元素,非样式的 `link`、内联 `style`、非沙箱的 `iframe`
const res = rawDOMAppendOrInsertBefore.call(this, element, refChild);
execHooks(plugins, "appendOrInsertElementHook", element, iframe.contentWindow);
return res;为了便于总结将以上 3 步操作流程称为:添加元素并返回
声明一个 element 将引用自子应用中动态添加的对象 newChild:
- 外联元素:无论加载成功或失败,在触发加载事件后都会更新
element为null - 非外联的元素:通过
rawDOMAppendOrInsertBefore添加到容器后,返回元素
加载外联资源失败怎么处理:
- 通过
manualInvokeElementEvent发起error事件 [查看]
重写的方法根据添加的元素分为 5 种情况:
1. 仅添加元素并打补丁
- 对于
link、style、script、iframe之外的元素全部:添加元素并返回 - 返回前将通过
patchElementEffect为新增元素打补丁 [查看]
2. link:资源元素
link 元素不是样式:
- 添加元素并返回,不做其它处理
- 判定样式的 3 个条件:
rel、type、链接以.css结尾
link是外联样式,将创建一个注释元素并返回
外联样式 href 为空或不在 cssExcludes 列表,返回注释前需要:
- 通过
getExternalStyleSheets处理样式 [查看] - 执行后将得到带有
contentPromise微任务的样式集合,遍历集合追加微任务来添加样式
否则添加注释并返回不做任何处理
提供给 getExternalStyleSheets 参数:
- 样式集合,每个对象包含:
src链接、ignore是否通过浏览器加载,见:文档 [查看] fetch:来自应用实例active打补丁后的fetch[查看]loadError:加载失败通知,手动配置,绑定在应用实例,见:文档 [查看]
ignore 外联样式如何添加:
- 通过
rawDOMAppendOrInsertBefore将外联样式添加到容器,用浏览器加载避免跨域问题
非 ignore 外联样式如何加载:
- 用
parseTagAttributes提取外联样式属性的键值对rawAttrs - 用沙箱
document创建一个内联样式元素 - 从实例获取插件
getCssLoader处理加载后的样式,将其作为内联样式的内容 [查看] - 将内联样式插入集合
styleSheetElements,以便umd模式恢复样式 [查看] - 通过
setAttrsToElement将属性键值对rawAttrs添加到创建的样式 - 通过
rawDOMAppendOrInsertBefore将创建的样式添加到容器 - 通过
handleStylesheetElementPatch为加载后的内联样式打补丁 [查看] - 通过
manualInvokeElementEvent发起load事件 [查看]
3. style:内联样式
- 将内联样式插入集合
styleSheetElements,以便umd模式恢复样式 [查看] - 从实例获取插件
getCssLoader,只有内联样式内容不为空时才执行替换 [查看] - 通过
patchStylesheetElement劫持处理样式元素的属性 [查看] - 通过
handleStylesheetElementPatch为动态添加的内联样式打补丁 [查看] - 添加元素并返回
在 React 中先添加空的内联样式元素,然后根据情况设置元素样式内容:
4. script:动态添加
整体分 3 步骤:
- 通过
setTagToScript为动态添加的script元素打标记 [查看] - 加载
script通过insertScriptToIframe注入沙箱iframe[查看] - 创建一个注释并返回
无论 script 是外联还是内联,都会插入到应用实例队列 execQueue 中执行:
- 提取队列长度,用于判断插入队列后是否要立即执行
- 队列中添加一个函数,调用
insertScriptToIframe将script注入沙箱 - 开启
fiber下会将队列中的函数,包裹在requestIdleCallback执行
为了便于归纳,上述步骤称呼为:插入
execQueue队列中执行
insertScriptToIframe 注入 script 除了提供注入的信息和沙箱 window 外:
- 还会将动态添加的
script作为第三个参数,用于提取元素中的标签值
用于关联动态添加的
script和注入沙箱的script,见:findScriptElementFromIframe[查看]
4.1 加载外联 script
要求存在属性 src,且链接不在 jsExcludes 列表中,见:文档 [查看]
先声明一个注入 script 方法 execScript:
- 要求应用实例中沙箱
iframe存在(只有注销实例沙箱才会被销毁) - 创建
onload方法,用于通过manualInvokeElementEvent发起load事件 [查看] - 通过
insertScriptToIframe注入script[查看]
问题:注入外联
script即便加载失败,也会触发onload,见:3. 声明注入script的方法 [查看]
声明一个 script 属性集合 scriptOptions:
- 集合中的属性和
processTpl提取外联script一样,但不包含:async、defer[查看] - 除此之外通过
jsIgnores按条件添加属性ignore用于浏览器加载
scriptOptions 的使用流程:
- 提供给
getExternalScripts处理后得到带有contentPromise的scriptResult[查看] - 将
scriptResult提供给execScript,会结合onload透传给insertScriptToIframe[查看]
通过 getExternalScripts 加载 script,参数和动态加载外联样式一样,不同在于:
- 集合对象采用
scriptOptions - 从实例中获取
fiber决定是否通过requestIdleCallback空闲加载
getExternalScripts 提取的集合,会通过 dynamicScriptExecStack 发起微任务:
dynamicScriptExecStack = dynamicScriptExecStack.then(() =>
scriptResult.contentPromise.then(() => {})
);
保证集合中的
script以微任务队列的形式,加载完一个发起下一个加载
提取加载的 script 不会立即注入沙箱,而是:插入 execQueue 队列中执行
- 插入队列前需确保应用实例中存在
execQueue(只有实例注销后才会销毁) - 队列方法中不会直接调用
insertScriptToIframe,而是通过execScript发起注入 - 如果注入队列前
execQueue已为空,需要手动提取并执行队列
从注入 script 的过程也能够看出集合中没有 asyc 的原因:
- 如果
ignore匹配的情况下作为外联script注入沙箱 - 由于
async导致加载后不会提取执行下一个队列,见:start启动应用的bug[查看]
应用中动态添加的外联 script 有 2 种情况会使用浏览器加载:
jsIgnores手动匹配,以及module类型的script- 外联
script将不会包裹在proxy module中执行,见:流程图 [查看]
通过
jsExcludes排除的外联script会作为内联script加载,但是由于没有脚本内容,导致插入的沙箱的script是一个空元素。
4.2 加载内联 script
流程和外联基本一致,也是:插入 execQueue 队列中执行
不同在于:
- 插入队列的方法会直接通过
insertScriptToIframe注入script,而不需要加载 - 注入方法
insertScriptToIframe提供的参数不同 [查看]
insertScriptToIframe 参数:
script信息:src为null,content内联代码,attrs提取元素属性键值对- 沙箱
window - 将动态添加的
element作为第 3 个参数,用于关联动态添加和注入的script
React这样的单页应用,通常是入口script为静态的,注入沙箱后动态添加内联chunk script
5. iframe:动态添加
根据动态添加元素的属性 WUJIE_DATA_FLAG 的值,决定如何添加元素:
- 空字符:说明当前子应用是基座,添加的是沙箱
iframe,追加到沙箱html元素末尾 undefined:说明不是沙箱,添加到容器body下,因为拦截的对象就是body和head
也可以将
iframe添加到容器head,但是没有意义
WUJIE_DATA_FLAG 补充说明:
- 这个属性由
iframeGenerator沙箱初始化时创建,并设置值为空字符 [查看] - 判定当前基座是子应用,是因为拦截方法来自
patchRenderEffect,只有子应用才会被重写方法 [查看] - 追加位置通过
ownerDocument判断为沙箱document是因为容器中每个元素都通过patchElementEffect打补丁了 [查看]
结合上述 3 点再来看这个方法就很清晰了:
// 嵌套的子应用的 js-iframe 需要插入子应用的 js-iframe 内部
if (element.getAttribute(WUJIE_DATA_FLAG) === "") {
return rawAppendChild.call(
rawDocumentQuerySelector.call(this.ownerDocument, "html"),
element
);
}目录:effect.ts - rewriteRemoveChild [查看]
接受一个 opts 对象,包含 2 个属性:
rawElementRemoveChild:原生删除Dom的方法,透传自patchRenderEffect[查看]wujieId:应用名,透传给findScriptElementFromIframe获取沙箱iframe和script[查看]
patchRenderEffect提供rawElementRemoveChild时需要通过bind将上下文指向容器head
返回一个方法用于重写 removeChild,方法需要的参数:
child:删除的节点元素
重写方法根据提供的 child 处理并返回:
| 类型 | 元素存在 | 元素不存在 |
|---|---|---|
script |
findScriptElementFromIframe 找到 script 删除并返回元素 [查看] |
返回 null |
非 script |
rawElementRemoveChild 直接删除元素并返回 |
报错 |
rawElementRemoveChild删除元素前需要确保存在于head下
设计初衷:和原生方法 rawElementRemoveChild 目的一样删除 head 下的元素
- 而应用中存在 2 个容器:存放
script的沙箱容器,除了script的应用渲染容器 - 因此需要根据删除的元素,分开查找并删除
沙箱中的 script 全部通过 insertScriptToIframe 重建注入沙箱 [查看]
- 没有特定属性下,应用中只能拿到动态添加的
script而拿不到注入沙箱的script - 于是需要
findScriptElementFromIframe根据提供的元素,找出注入沙箱的script并删除 [查看]
删除动态添加的
script无论是内联还是外联,都会同时为动态添加的script和注入沙箱的script打上相同的标记,见:为动态添加的script打标记 [查看]
删除静态 script 的问题:
- 当注入的
script提取自应用中静态script,是不会打上任何标记的 - 删除元素时发现元素是
script但没有标签,返回null不做任何操作
这个问题也存在手动添加
script,但是这种情况不存在通过子应用删除的场景,可以忽略
如何解决:
- 为静态
script手动加上data-wujie-script-id属性,属性值建议唯一的非纯数字 - 当删除元素时,发现类型为
script且存在标签,在沙箱head中找到并删除
属性值唯一能够准确找到元素,非数字是为了和
setTagToScript默认打标签区分开来 [查看]
缺点是手动,且有侵入性:
- 在
processTpl提供了参数postProcessTemplate用来更新提取的资源 [查看] - 但目前没有用到,且又不是
plugin,所以暂且还不能通过配置为静态提取的资源打标签
postProcessTemplate可能是工作人员为后续更新留下的一个口子
目录:effect.ts - rewriteContains [查看]
接受一个 opts 对象,包含 2 个属性:
rawElementContains:原生查找Dom的方法,透传自patchRenderEffect[查看]wujieId:应用名,透传给findScriptElementFromIframe获取沙箱iframe和script[查看]
patchRenderEffect提供rawElementContains时,通过bind将上下文根据重写方法来自容器head还是容器,纠正this的指向
返回一个方法用于重写 contains,方法需要的参数:
other:查找的节点元素或null
重写方法返回:
- 和原生
rawElementContains一样,上下文找到元素为true否则false
流程和设计初衷、查找方式、存在问题和 rewriteRemoveChild 一样 [查看]
区别在于:
| 分类 | rewriteRemoveChild |
rewriteContains |
|---|---|---|
| 用途 | 删除元素 | 检查是否包含元素 |
| 返回值 | 删除的元素,找不到为 null 或报错 |
boolean |
| 容器上下文 | head |
body、head |
目录:effect.ts - manualInvokeElementEvent [查看]
参数:
element:触发事件的元素,只接受HTMLLinkElement和HTMLScriptElementevent:事件名,目前提供的事件只有load和error
传过来的
element必须是子应用中动态添加的元素,不然就失去转发事件的意义了
调用场景:
rewriteAppendOrInsertChild:动态添加元素 [查看]
设计初衷:
- 在应用中监听
script和样式加载情况时,会通过onload和onerror - 对于动态添加的元素会通过
rewriteAppendOrInsertChild进行拦截 [查看] - 最终注入的元素可能和动态添加的不一样,因此需要从注入的元素转发事件给动态添加的元素
作为子应用内部,正常监听
onload和onerror即可,无需做任何改变
对于添加的元素不同,事件通知方式略有差异:
- 外联
script:无论是否ignore,注入到沙箱后会通过loade调用manualInvokeElementEvent - 内联
script:忽略通知 - 外联样式 -
ignore:直接将动态添加的样式添加到容器,监听事件不变,不需要代理转发 - 外联样式 - 非
ignore:加载后作为内联样式注入容器,然后调用manualInvokeElementEvent - 其它元素:忽略通知
流程:
- 通过
CustomEvent定义事件,并使用patchCustomEvent劫持事件对象添加属性 - 如果动态添加的元素通过
on绑定的事件,执行回调函数,否则通过dispatchEvent派发事件
patchCustomEvent 通过 Object.defineProperties 劫持事件:
- 添加 2 个 属性:
srcElement、target,全部返回动态添加的元素element
目录:effect.ts - findScriptElementFromIframe [查看]
参数:
rawElement:应用中动态添加的scriptwujieId:应用名,用于获取应用实例中的沙箱iframe
返回一个对象包含 2 个属性:
targetScript:注入沙箱的script,没有找到返回nulliframe:沙箱iframe,作为script的容器,用于查找、删除script时使用
调用场景:
设计初衷:
- 对于动态添加的元素会通过
rewriteAppendOrInsertChild进行拦截 [查看] - 注入的
script和动态添加的不一样,因此需要有个方法,能够查找注入沙箱的script
原理:
- 通过
setTagToScript为动态添加的script和最终注入的script打相同标记 [查看]
流程:
- 使用动态添加的
script通过getTagFromScript获取元素上的标签 [查看] - 使用应用名通过
getWujieById获取实例中的沙箱iframe[查看] - 将拿到的标签在沙箱中查找并返回注入的
script,找不到输出警告返回null
对于不可实例化的函数绑定上下文:!isConstructable(fn) && Function.prototype.bind.call(fn, target)
目录:utils.ts - isConstructable [查看]
参数:
fn:任意对象,因为参数已经允许any了
返回:
true:可以实例化,否则false
以下情况都将认为是可实例化的函数:
- 原型
constructor指向自身的普通函数,原型除了constructor外还有其它属性 - 以大写开头的函数:
/^function\b\s[A-Z].*/ - 以
class开头的类
以上可以排除箭头函数,因为箭头函数没有
prototype,转换字符串为() => {}
返回结果前需要从映射表 fnRegexCheckCacheMap 中获取结果:
- 查到结果直接返回,不再正则匹配
fn - 否则将计算的结果保存到映射表后返回
无论计算结果是
true或false都将保存到映射表,以便下次直接获取结果
目录:utils.ts - isCallable [查看]
参数:
fn:任意对象
返回:
true:是函数,否则false
流程:
- 判断
fn是一个函数,会优先从映射表callableFnCacheMap获取 - 映射表中不存在则执行判断,是函数记录到映射表,然后返回判断结果
和
isConstructable不同,isCallable只在明确是函数类型时记录结果
判断中对于 safari 老旧版本做了兼容:
const naughtySafari =
typeof document.all === "function" && typeof document.all === "undefined";目录:utils.ts - isBoundedFunction [查看]
参数:
fn:CallableFunction
目的:判断函数是否来自
Function.prototype.bind,避免重复bind
返回:
- 通过
bind绑定的函数返回true,否则false
通过 bind 返回的函数:函数名称以 bound 开头(注意有个空格),没有 prototype
function originalFunction() {}
const boundFunction = originalFunction.bind(this);
console.log(originalFunction.name); // originalFunction
console.log(boundFunction.name); // bound originalFunction
console.log(originalFunction.prototype); // {}
console.log(boundFunction.prototype); // undefined流程:
- 优先从映射表
boundedMap获取,不存在则判断,将结果记录到映射表并返回
判断方法:
const bounded =
fn.name.indexOf("bound ") === 0 && !fn.hasOwnProperty("prototype");只要 bind 过的函数都返回 true,包括:箭头函数、普通函数
- 但不能通过
bind指定箭头函数上下文,因为箭头函数上下文取决于所在作用域的this
目录:utils.ts - getTargetValue [查看]
参数:
target:源码中是any,实则应该是{ [key: PropertyKey]: any }对象p:源码中是any,实则应该是PropertyKey
返回:
- 对象中找到的属性,没有则是
undefined
优先从映射表 setFnCacheMap 获取对象属性 [查看]
setFnCacheMap |
符合条件的函数 | 其它 |
|---|---|---|
| 存在优先返回 | 不再考虑 | 不再考虑 |
| 不存在 | 绑定上下文后保存在映射表并返回 | 不再考虑 |
| 不存在 | 不符合 | 返回对象属性,若不存在返回 undefined |
符合的条件:
isCallable:只有函数才能通过bind绑定上下文 [查看]!isBoundedFunction:确保函数没有绑定过上下文 [查看]!isConstructable:确保函数不可实例化,因为实例化的函数有自己的上下文 [查看]
补充:当函数通过 bind 绑定过上下文,再次 bind 采用首次绑定的上下文
function abc() {
console.log(this);
}
const c = { name: "c" };
const d = { name: "d" };
const q = abc.bind(c);
const z = q.bind(d);
console.log(q, z); // same as { name: 'c' }只要函数还未 bind 过,且不可 isConstructable 实例化都符合要求 [查看]
| 分类 | 绑定后上下文 |
|---|---|
| 箭头函数 | 不受影响,保持所在作用域 this |
| 普通函数 | 提供的对象 |
为符合条件的属性绑定上下文:
- 通过
Function.prototype.bind.call绑定target为函数上下文 - 将绑定后的函数保存在映射表
setFnCacheMap,以便下次获取 - 将原函数浅拷贝属性到绑定的方法中
- 通过
Object.defineProperty为绑定的方法添加prototype指向原函数的prototype
添加 property 意义:
- 添加原型链,见:
qiankun开发人员的总结 [查看]
需要注意的是箭头函数是没有
prototype,所以也不需要添加原型链
浅拷贝属性是让绑定的方法和原来的方法属性一致,见下方演示:
function exampleFunc() {
console.log("Hello");
}
exampleFunc.customProperty = "I am a custom property";
exampleFunc.customMethod = function () {
console.log("I am a custom method");
};
const boundExampleFunc = Function.prototype.bind.call(exampleFunc, null);
for (const key in exampleFunc) {
boundExampleFunc[key] = exampleFunc[key];
}
console.log(boundExampleFunc.customProperty); // "I am a custom property"
boundExampleFunc.customMethod(); // "I am a custom method"关于 bind.call 速记方法,全部以 call 作为记忆点:
call:立即执行提供的的方法,第一个参数指向this,后面参数透传给执行方法apply:和call一样,不同的是透传的参数是以数组形式bind:可以看做将call柯里化之后返回新的函数
Function.prototype.bind.call 和 bind 一样,不同处:
- 绑定的函数为第 1 个参数,其余参数顺延依次是上下文和透传的参数
bind.call作为prototype适用于绑定不明确的函数,bind适用于绑定已明确的函数
同理 Function.prototype.bind.apply 和 Function.prototype.bind.call 是一样的:
- 绑定的函数为第 1 个参数,不同在于其余的参数全部集合在一个数组中透传过去
为什么要用 getTargetValue:
- 此函数用于
Proxy代理对象get操作时,若不提供get属性会报错 - 正确的做法是从代理的原始对象中找到对应的属性并返回
演示:
// Uncaught TypeError: Cannot create proxy with a non-object as target or handler
const proxyWindow = new Proxy(window);
proxyWindow.addEventListener;
// 正确的方式
const proxyWindow = new Proxy(window, {
get: (target, key) => target[key],
});
proxyWindow.addEventListener;提供一组函数以数组的形式作为参数,通过 reduce 拍平并依次执行
目录:utils.ts - compose [查看]
参数:
fnList:一组执行函数
源码中
fnList的类型是Array<Function>,实际应该是Array<(...args: Array<string>) => string>
返回:
- 返回一个方法,类型和
fnList中的函数是一致的,确保无论如何都能执行
调用场景:
| 调用函数 | 提取 plugin |
用处 |
|---|---|---|
processCssLoader [查看] |
cssLoader,见:文档 [查看] |
替换应用中提取的静态样式 |
importHTML [查看] |
htmlLoader,见:文档 [查看] |
替换应用入口资源 |
getCssLoader [查看] |
cssLoader,见:文档 [查看] |
替换手动注入和动态添加的样式 |
getJsLoader [查看] |
jsLoader,见:文档 [查看] |
替换注入沙箱的 script |
替换的样式和 script 必须是内联的:
- 外联资源传递给
plugin是空字符,也可以返回code,但没有意义,因为优先使用src加载资源 - 应用中的外联资源仅限手动配置
ignore资源集合,默认情况外联资源会加载后作为内联资源注入
执行返回的方法将返回 string,提供的参数也全部是 string:
htmlLoader:仅提供提取的资源html作为参数- 其余的
plugins将提供 3 个参数:code:资源内容,根据plugin提供样式或scriptsrc:资源链接,如果不存在为空字符,例如:内联资源base:子应用origin+pathname
操作原理:
- 通过
reduce将fnList数组中的函数拍平后依次按照顺序执行 - 函数中的参数
code作为初始值,处理并返回字符为下一个函数继续执行
即便 fnList 数组中没有任何函数,也能够将原始的 code 返回
- 因为在
reduce处理中code作为第二个参数,也是预计返回的类型
fnList.reduce(
(newCode, fn) => (isFunction(fn) ? fn(newCode, ...args) : newCode),
code || ""
);由于调用时,传递过来的数组仅仅是通过 map 过滤了 plugins:
- 所以
compose通过reduce遍历数组时,有可能能拿到的是udefined - 对于这种情况直接返回
code为下一个loader替换资源
应用中动态添加的 script 会被 rewriteAppendOrInsertChild 劫持,因此最终注入沙箱的 script 不是同一个对象。在 wujie 中通过打标记的方式相互关联。
1. setTagToScript 添加标记
目录:utils.ts - setTagToScript [查看]
参数:
element:HTMLScriptElement元素tag:设置标记名,选填
使用相同的
tag打标记,能够关联两个不同的script元素
流程:
- 判断
element是否为script元素,是则打上标记WUJIE_SCRIPT_ID - 标记值为
tag,没有提供的话采用自增id
通过打标记 WUJIE_SCRIPT_ID,方便通过:
getTagFromScript:提取script中的标签,见下方详细说明findScriptElementFromIframe:查找注入沙箱的script[查看]
调用场景,执行过程从上至下:
| 执行方法 | 操作方式 | 如何打标记 |
|---|---|---|
rewriteAppendOrInsertChild [查看] |
拦截动态添加的 script |
自增编号 |
insertScriptToIframe [查看] |
创建并注入 script 到沙箱 |
根据动态添加的 script 编号 |
动态添加和注入沙箱的
script标签编号是一致的,原因见:findScriptElementFromIframe[查看]
不需要打标记的情况:
2. getTagFromScript 提取 script 中标记值
目录:utils.ts - getTagFromScript [查看]
参数:
element:HTMLScriptElement元素
流程:
- 判断
element是否为script元素,是则提取标记WUJIE_SCRIPT_ID - 不是
script或属性不存在都返回null
调用场景:
目录:common.ts - idToSandboxCacheMap [查看]
全部无界实例和配置存储 map(来自备注):
- 类型:
new Map<String, SandboxCache>(),应用名为key,实例为SandboxCache
SandboxCache 包含 2 个属性:
添加映射表有 2 个方法,分别为:
addSandboxCacheWithWujie:收集Wujie实例对象,见:将实例添加到映射表 [查看]addSandboxCacheWithOptions:通过setupApp收集应用配置,见:文档 [查看]
通过创建 Wujie 实例添加映射表有 2 个处:
从这里可以知道:
preloadApp:预加载可以极大的提升子应用首次打开速度startApp:根据配置信息和模式来决定在启动应用前是否创建实例setupApp:可以预先为startApp和preloadApp提供配置信息
startApp每次都会从映射表获取实例,但默认的重建模式下,所有实例都会通过destroy注销后重建
获取映射表的方法有 2 个:
getWujieById:通过应用名获取引用实例,如果没有拿到返回nullgetOptionsById:通过应用名获取缓存的实例配置,如果没有拿到返回null
删除映射表的方法只有 1 个:
deleteWujieById:会从映射表idToSandboxCacheMap中删除实例和缓存实例的配置
仅能通过
destroy销毁应用实例时才能删除映射表 [查看]
实例映射表在应用中具有唯一性:
- 通过
window.__WUJIE.inject指向上一级映射表,见:构造函数inject[查看]
目录:event.ts - appEventObjMap [查看]
全部事件存储 map(来自备注):
- 类型:
new Map<String, EventObj>(),实例名为key,监听事件为EventObj key分两种情况:基座以时间戳字符命名、子应用以应用名命名EventObj:是一个事件集合,键名是event_name,键值是监听函数集合的数组
事件映射表关联流程图(点开新窗口放大缩小查看细节):
获取映射表有 3 种方式:
| 获取映射表 | 可用环境 | 补充说明 |
|---|---|---|
import { bus } "wujie"; |
基座,包括子应用中的基座 | 推荐 |
window.$wujie.bus |
子应用 | 推荐 |
window.$wujie.bus |
子应用中的基座 | 可以,但不推荐 |
window.__WUJIE.inject.appEventObjMap |
子应用,包括子应用中的基座 | 不推荐 |
appEventObjMap的作用是映射表不同层级链路引用,作为使用者建议通过bus来处理通信
通过 window.__POWERED_BY_WUJIE__ 判定嵌套在子应用中时,将通过 inject 向上引用:
- 实例中会保存
inject作为链路引用,见:构造函数inject[查看] - 映射表链最底层是
Map对象
适用于实例初始化,以及获取
appEventObjMap映射表
EventBus 的原理概述
从通信方面概述原理,使用方法见:文档 [查看]
通过 $on 收集订阅的事件:
- 构造函数中使用应用名作为
key,从映射表找出事件对象,没有则创建空对象{} - 将事件名和方法按照类型
[event: string]: Array<Function>添加到eventObj
通过 $emit 派发事件:
- 遍历整个映射表,收集事件同名的回调函数,以及所有事件都会触发的函数
- 分别遍历拿到的函数集合,透传提供通信的参数
如果没有提供事件名,或没有匹配到符合要求的函数集合,将输出警告
缺点:事件对象只有 1 级
- 由于子应用是通过
inject注入链一级级往上找,所以无论层级,最终只会有 1 级监听对象 - 不过好在应用实例
idToSandboxCacheMap也只有 1 级,实例名不能重复
可能存在的问题:
- 问题 1:事件重名造成错误订阅,例如:不同的应用都有同名事件
- 问题 2:嵌套自身作为子应用,事件订阅会造成重复监听
解决办法:
- 问题 1:监听的事件名加上应用名作为前缀,使其成为命名空间,如:
{project_name}_{event_name} - 问题 2:这是个无解的问题,但通常会用第三方路由做切换,而不是自我嵌套
除此之外还提供了 props、window 进行通信:
- 用于避免
EventBus承载过多,见:文档 [查看]
不同的通信方式优缺点:
| 通信方式 | 优点 | 定向通信 | 缺点 |
|---|---|---|---|
props |
简单、高效 | 只能指定接收 | 只能从基座向应用传数据 |
window |
灵活、无需配置 | 双向指定 | 跨站问题,会污染全局作用域 |
eventBus |
强大,可指定执行机制 | 不可以 | 效率不高 |
eventBus采用广域通信的方式,只要事件名相同就会收到消息,可以指定参数来进行区分
目录:utils.ts - setFnCacheMap [查看]
以下要求必须全部都满足:
isCallable:必须是一个函数 [查看]!isBoundedFunction:没有通过bind指定过上下文的函数 [查看]!isConstructable:不能是可实例化的函数 [查看]
符合条件的函数:箭头函数、普通函数
存储类型为 WeakMap 的对象:
- 键名:从对象中提取的原始方法
- 键值:通过
bind绑定上下文的方法
通过
bind绑定箭头函数上下文无效,箭头函数的上下文为所在作用域的this
使用场景:
checkProxyFunction:添加方法到映射表getTargetValue:从对象中获取属性 [查看]
checkProxyFunction 从取名上看起来像检查函数,实际会将符合条件的函数绑定到映射表中
- 调用场景也仅有
proxyWidow中设置沙箱的全局属性 [查看]
需要说明的是:无论提供的参数是否符合映射要求,
checkProxyFunction都不会返回任何结果
目录:entry.ts [查看]
资源集合有 3 个,当使用重建模式时,通过资源缓存集合可以避免重复请求资源。
embedHTMLCache:缓存应用入口链接资源
类型为 Partial<Record<string, Promise<htmlParseResult>>>:
- 键名为资源入口链接
- 键值类型为应用静态资源内容
如何收集缓存:
importHTML:加载资源 [查看]
通过插件配置
htmlLoader将不会被缓存,见:文档 [查看]
应用实例中通过 template 缓存入口资源:
- 应用通过
active激活时候记录资源,见:创建容器渲染资源 [查看] - 一样都来自
importHTML,不同的是template的资源已通过processCssLoader还原样式 [查看]
不同模式下缓存使用:
| 场景 | embedHTMLCache |
template |
|---|---|---|
| 初次启动应用 | importHTML 按条件记录 |
应用 active 时记录 |
| 预加载&预执行 | importHTML 按条件记录 |
应用 active 时记录 |
active 预加载后启动 |
存在则使用,但不参与渲染 | 不使用 |
active 模式切换 |
容器切换,不需要缓存 | 使用但不参与渲染 |
umd 模式切换 |
使用 template 恢复,不需要 |
用于恢复容器资源 |
| 重建模式切换 | 存在则使用 | 重新记录 |
alive预加载后资源存储在template中,启动时渲染;而切换应用时仅需挂载容器,不需要缓存
styleCache:缓存外联样式资源
类型为 Partial<Record<string, Promise<string>|null>>:
- 键名是提取的外联样式
src - 如果获取资源成功,键值和
fetchAssets返回类型一致,否则为null[查看]
不缓存的情况:
所有符合要求的外联样式,会加载作为内联样式缓存到
styleCache
加载符合要求的外联样式,并缓存加载结果,包含:
processTpl:提取应用内静态样式 [查看]processCssLoaderForTemplate:手动配置应用样式 [查看]rewriteAppendOrInsertChild:应用中动态添加样式 [查看]
如何收集缓存:
应用实例中通过 styleSheetElements 缓存样式 [查看]
和 styleCache 区别:
| 收集方法 | getExternalStyleSheets |
rewriteAppendOrInsertChild |
patchCssRules |
|---|---|---|---|
| 缓存位置 | styleCache |
styleSheetElements、styleCache |
styleSheetElements |
| 用处 | 处理请求,记录缓存 [查看] | 动态添加样式 [查看] | 打补丁 [查看] |
| 缓存类型 | 静态样式 | 动态样式 | :root 和字体样式 |
rewriteAppendOrInsertChild 将动态添加的样式缓存到 styleSheetElements:
- 对于动态添加的外联样式,还会通过调用
getExternalStyleSheets再缓存一份到styleCache
缓存的使用:
styleCache:通过processCssLoader还原入口资源样式后,记录在实例属性template[查看]styleSheetElements:记录之后通过rebuildStyleSheets恢复样式 [查看]
不同模式下缓存使用:
| 场景 | styleCache |
styleSheetElements |
|---|---|---|
| 初次启动应用 | 缓存所有外联样式 | 收集动态样式和补丁,不参与渲染 |
| 预加载&预执行 | 预加载缓存外联样式 | 预执行收集动态样式,渲染时收集补丁 |
active 预加载后启动 |
容器切换,不需要样式缓存 | 不使用 |
active 模式切换 |
容器切换,不需要样式缓存 | 不使用 |
umd 模式切换 |
仅用于替换手动加载的外联样式 | 用于恢复容器样式 |
| 重建模式切换 | 替换应用中所有外联样式 | 重新记录,不参与渲染 |
styleSheetElements仅限umd模式切换时使用,其它情况只保留记录不使用styleCache缓存所有外联样式,包括静态提取、动态及手动添加,一旦缓存下次直接从缓存中获取
umd模式切换应用,提取静态样式已记录在实例template中,不需要使用styleCache
scriptCache:缓存外联 script 资源
类型为 Partial<Record<string, Promise<string>|null>>:
- 键名是提取的外联
script的src - 如果获取资源成功,键值和
fetchAssets返回类型一致,否则为null[查看]
不缓存的情况:
所有符合要求的外联
script,会加载作为内联script缓存到scriptCache
加载符合要求的外联 script,并缓存加载结果,包含:
processTpl:提取应用内静态script[查看]start:加载手动配置的script,收集并执行队列 [查看]rewriteAppendOrInsertChild:应用中动态添加script[查看]
如何收集缓存:
应用实例中通过 execQueue 作为注入 script 队列,不做缓存 [查看]
scriptCache:缓存所有外联scriptexecQueue:仅用于收集script,提取并注入沙箱iframe
scriptCache 和 execQueue 的使用都取决于应用什么时候 start [查看]
| 场景 | scriptCache |
execQueue |
|---|---|---|
| 初次启动、预执行 | 缓存所有外联 script |
收集并执行队列 |
| 预加载 | 缓存所有外联 script |
不使用 |
active 预加载后启动 |
外联 script 使用缓存 |
收集并执行队列 |
| 重建模式切换 | 外联 script 使用缓存 |
重新收集并执行队列 |
| 其它模式切换 | 不使用 | 不使用 |
scriptCache:仅首次加载时收集外联script,包括静态提取、动态及手动添加,再次加载使用缓存execQueue:首次启动会执行收集提取队列,再次启动仅重建模式需要重新队列并执行注入
因为
execQueue随沙箱一起,只在重建模式下随应用切换销毁重建
预加载后启动会通过 importHTML 重复调用 getExternalScripts 提取 script:
- 原因和解决办法见:
importHTML- 5. 从缓存中提取资源 [查看]
常见属性初始和注销状态见:Wujie 实例中关键属性 [查看]
test
队列收集来自 2 个区域:
| 所在位置 | 用途 |
|---|---|
rewriteAppendOrInsertChild [查看] |
收集应用中动态添加的内联和外联 script,共 2 处 |
start [查看] |
收集队列注入沙箱的 script 以及事件通知共 7 处 |
在单例应用中通常保留一个静态的入口
script,注入沙箱后动态加载chunk script[查看]
scriptCache 缓存外联 script
execQueue 和 scriptCache 用途不一样:
- 但是调用场景都来自启动应用或应用预执行,见:资源缓存集合 [查看]
收集应用中动态添加的样式,:root 以及字体样式,收集的样式以元素类型存储在集合:
- 目的为了
umd模式切换应用时,通过rebuildStyleSheets恢复样式 [查看]
umd模式首次启动后,入口资源以及静态样式会缓存到template中,切换应用时无需重复加载和提取
集合收集有 3 处
注入资源到容器后通过 patchCssRules 打补丁 [查看]
- 仅收集容器中所有
:root和字体样式
收集的样式来自 rewriteAppendOrInsertChild 拦截动态添加的样式 [查看]
link外联样式:下载后创建内联元素记录在集合中style内联样式:直接记录在集合中
styleCache 缓存外联样式
styleSheetElements 和 styleCache 存在重叠的情况:
- 但他们用途不一样,调用场景也不相同,见:资源缓存集合 [查看]
总结记录事件的目的和意义
目的:umd 下卸载应用时清空 head、body 下的事件
- 记录:
patchEventListener,见:patchRenderEffect[查看] - 清除:
removeEventListener,由应用unmount时候触发 [查看] - 条件:
shadowRoot容器、umd模式
为什么记录清空事件:
renderTemplateToHtml将资源转换为html时,会将head和body记录在应用实例 [查看]umd模式切换应用时会还原实例中的head和body,如果卸载时不清空事件会导致重复监听
为啥其它模式不需要:
alive模式:不销毁资源、不记录事件、再次切换应用不重新注入资源、也不需要start- 重建模式:每次都重建容器、重启应用,虽也记录和清理事件,但最终都会通过
destroy彻底销毁
除了
umd模式外,只记录事件,记录的事件清理随同destroy销毁应用一同清理
为什么 iframe 容器不需要记录和清除:
degrade每次激活都会重建iframe容器,iframe移除后事件自动销毁(来自备注)- 相反
iframe容器在alive模式或umd模式下需要记录并恢复事件,往下继续看
为什么只记录和消除 head 和 body:
shadowRoot在unmount时会清空容器、实例head、实例body下所有的元素 [查看]
和 shadowRoot.[body|head]._cacheListeners 目的正好相反:
| 记录对象 | 容器 | 记录事件用途 |
|---|---|---|
_cacheListeners |
shadowRoot |
unmount 清理事件,避免 active 切换应用重复监听 |
elementEventCacheMap |
iframe |
切换应用 active 时恢复记录,以便重新监听 |
流程参考:
active激活应用,见:degrade主动降级渲染 [查看]
切换应用恢复容器事件,是因为:iframe 移除后事件自动销毁(来自备注)
- 事件记录和恢复、适用模式,见:记录、恢复
iframe容器事件 [查看] - 事件清除:每次激活时将使用新的容器代替老的的容器
重建模式每次启动应用都重建容器,不需要用到
elementEventCacheMap
为什么 shadowRoot 不需要记录和恢复:
| 模式 | iframe 容器 |
shadowRoot 容器 |
|---|---|---|
alive |
重建容器,需要恢复所有事件 | 需要将 shadowRoot 重新挂载到 el 节点,不重建也不需要恢复事件 |
umd |
重建容器,需要为 React 16 及以下版本恢复 document 事件 |
根节点 shadowRoot 没变,不需要恢复事件 |
子应用中对 window 上监听的事件,需转发到沙箱 window:
原因:
- 应用中
script包裹在模块中执行,window指向proxyWindow,见:insertScriptToIframe[查看] - 执行事件回调时,需要将上下文指向沙箱
window
关于代理关系,见:
wujie中的代理的图谱 [查看]
degrade 降级时子应用 widnow 就是沙箱 window,同样也会记录事件并修正上下文,因为:
- 原生方法只能通过
call来调用; - 存在通过
options.targetWindow指定上下文 [查看]
因为沙箱运行 script,而渲染在容器,同时有部分事件需要转发给基座,所以需要转发和记录关联的事件。
记录和清理:
patchDocumentEffect:重写记录和清理方法,不支持document销毁前批量清理 [查看]
记录中包含 2 个 WeakMap 类型对象,键名是回调方法 handle,键值不同:
handlerCallbackMap:如果是函数通过bind指向沙箱document,否则等同handlehandlerTypeMap:事件类型集合,如:click和mouseup回调相同,则为['click', 'mouseup']
handle的类型可以是函数、也可以是包含handleEvent方法的对象
如何清理:
- 来自框架自动清理,如:
React 16会自动在document挂载、清理合成事件 - 手动清理自定义在
document上的监听事件
如果没有清理手动监听在
document上的事件,可能会造成内存泄露
无论是自动清理还是手动清理,handlerTypeMap 存在的意义就没那么必要了:
- 毕竟所有的清理方法都不是来自事件记录的对象
handlerCallbackMap 存在的意义:
- 记录已修正的回调对象
handle,使用相同的回调函数,不用重复判断是否要修正上下文
全部在 wujie 入口文件 index.ts 中,当引入 wujie 即会立即执行,见:源码 [查看]
提供给基座与子应用通信,导出对象为 bus,见:文档 [查看]
终止代码运行,前提条件:
window.__WUJIE:说明为子应用,在沙箱iframe初始化时通过patchIframeVariable设置 [查看]!window.__POWERED_BY_WUJIE__:说明此时没有通过start启动应用 [查看]
条件符合的情况下
stopMainAppRun会输出警告,抛出异常
通常情况下子应用是不会检测全局变量的:
- 只有当子应用是基座的时候才会主动检测
start 启动应用注入 script 前一定会先更新沙箱全局变量 __POWERED_BY_WUJIE__:
- 更新后再注入
script,包括:应用入口script注入,到动态加载script,到发起检测 - 正常启动下,沙箱中
__WUJIE一定是存在的且__POWERED_BY_WUJIE__一定是true
假设丢失了 __POWERED_BY_WUJIE__,并且加载过程没有捕获错误:
- 抛出的异常会直至整个应用最顶层,导致基座异常
整个流程围绕 3 点展开:
1. 从 window 监听 popstate
这就意味着监听的对象来自基座:
- 可以是最顶层的基座,也可以是作为子应用的基座,但一定不是沙箱
iframe - 换个说法,当更新沙箱
history后,前进后退是不会由processAppForHrefJump发起事件监听
表格中所在 window 列中,将由 processAppForHrefJump 负责监听事件:
| 方法 | 用途 | window |
沙箱 iframe |
|---|---|---|---|
patchIframeHistory [查看] |
重写应用中路由跳转、同步路由到基座 | replaceState |
pushState、replaceState |
syncIframeUrlToWindow [查看] |
通过 syncUrlToWindow 同步路由到基座 [查看] |
replaceState |
无 |
locationHrefSet [查看] |
通过 pushUrlToWindow 同步路由到基座 [查看] |
pushState |
无 |
constructor [查看] |
通过 iframeGenerator 更新沙箱 history [查看] |
无 | replaceState |
active [查看] |
通过 syncUrlToIframe 同步路由到应用 [查看] |
无 | replaceState |
active [查看] |
通过 syncUrlToWindow 同步路由到基座 [查看] |
replaceState |
无 |
unmount [查看] |
通过 clearInactiveAppUrl 还原基座路由 [查看] |
replaceState |
无 |
如果基座是子应用,本身就在沙箱中,前进后退看 state 对象来自哪里:
- 如果来自基座下的沙箱
history,那么不会通过processAppForHrefJump发起监听popstate - 但应用内的路由更新会通过
syncIframeUrlToWindow同步基座路由 [查看]
可以查看上述表格中沙箱
iframe那一列,全部来自沙箱history
2. 只处理应用内的路由前进和后退
即 search 中应用名保持不变,例如当前路由:?react=/%7B%2F%7D
| 变更路由 | 分类 | 原因 |
|---|---|---|
?react=%7B%2Fabout%7D |
应用内路由 | 应用名 react 没有变化 |
?react=https%3A%2F%2Ftest.com |
应用内的劫持路由 | 应用名 react 没有变化 |
?vue=/%7B%2F%7D |
应用外的路由 | 应用名不再是 react |
需要说明的是
processAppForHrefJump只处理应用内劫持路由,原因往下看第 3 点
比如:应用是 react 以劫持容器渲染,当点击基座链接切出应用后,执行后退操作
- 不会回退到上一个劫持容器页,而是上一个应用的入口页,在使用上会有割裂感
那这难道算不算
bug吗?
原因:
- 执行后退操作,触发
popstate检测后退的路由是http开头 - 执行
renderIframeReplaceApp加载iframe替换子应用 [查看] - 因提供的第 2 个参数挂载节点为
null,导致整个应用空白
为什么挂载节点是 null
- 当切出应用时改变了路由,导致基座组件重新渲染,原先的挂载点销毁
最终如何从空白页变为应用入口页面的:
- 当路由切换回应用时,再次重新渲染组件,按照配置重新加载应用
应用内的路由变更不也会重新渲染组件吗?
- 是的,会按照应用名和路由重新启动一遍,条件一致的情况下视觉上没有变化
- 劫持容器不能还原从
syncUrlToIframe同步路由到应用的源码中也能看出来 [查看]
// 排除href跳转情况
const syncUrl = (/^http/.test(idUrl) ? null : idUrl) || url;
被开发人员排除了,使用入口 url 作为了 history:
- 这点似乎也合理,因为劫持容器除了前进后退是无法还原应用本身的容器
- 当通过后退还原劫持容器,不明所以的人可能都不知道怎么返回最初的页面
应用内路由变化时也会因组件重新渲染销毁挂载节点:
- 但启动应用时会将新的挂载节点通过配置传过去
- 而
processAppForHrefJump在恢复劫持容器时使用的挂载节点在切出应用时已销毁
如果使用非 React 这样单例应用,路由变更不刷新组件是不是能避免这个问题?
- 是个好想法,但这样会产生新的问题,比如路由更新后子应用没反应
3. 只处理应用内劫持路由前进和后退
从上诉总结可以排除以下 popstate 变更的情况:
- 来自沙箱
iframe路由变更不触发当前操作,见上述表格iframe列 - 来自应用外的路由变更触发事件,但还原容器无效,最终由组件重新渲染重启应用
- 来自应用内的路由变更触发事件,但不在当前操作范围,下面将展开说明
以下描述将默认以:history 中包含劫持路由,在应用内执行前进和后退操作
- 关于劫持容器详细说明见:
locationHrefSet[查看]
前进或后退时做了什么:
- 通过当前的
url获取queryMap,见:getAnchorElementQueryMap[查看] - 通过
queryMap筛选获取应用实例集合,遍历集合根据前进或后退重新渲染容器
2 个情况:
| 监听 | 判断依据 | 判定为劫持容器 | 否则应用内路由不操作 |
|---|---|---|---|
| 前进 | queryMap |
找到开头为 http 的链接 |
找到的是非链接的路由 |
| 后退 | herfFlag |
true |
false |
应用内路由跳转流程:
| 应用入口页 | 应用内路由 | http 开头的劫持路由 |
|---|---|---|
| 前进后退都不做处理 | -- | -- |
| -- | ⭕ 后退不处理,前进替换劫持容器 |
-- |
| -- | -- |
- 劫持容器是
iframe网页,内部的链接将不再被劫持记录history,因此只能后退 - 而通过基座链接切换到其它应用,后退将无法还原劫持容器,原因在上述第 2 点已说明
如何判断执行的是前进还是后退:
- 前进:执行后当前路由为
http开头,而popstate到劫持容器只能前进 - 后退:
hrefFlag,只有在劫持容器的情况下为true,而劫持容器只能后退
关于
hrefFlag见:特殊属性 [查看]
不在处理范围的情况下 history 变更,将导致基座下加载应用的组件重新渲染:
- 由于配置信息没有变化,视觉上只看到应用内部因路由更新切换页面
重建模式会因路由变更重新渲染应用,如果因此看到闪屏,建议使用
alive或umd模式
前进时匹配到链接为劫持的 http 怎么做:
| 分类 | iframe 容器 |
shadowRoot 容器 |
|---|---|---|
renderElementToContainer 将容器中 html 元素添加到沙箱 [查看] |
执行 | 不执行 |
renderIframeReplaceApp 创建 iframe 代替当前容器 [查看] |
执行 | 执行 |
标记 hrefFlag 以便后退时能够返回应用 |
执行 | 执行 |
shadowRoot 绑定在应用实例中,iframe 容器只有 document 绑定到实例中
- 一旦容器被销毁,
iframe容器需要通过还原html元素恢复容器 - 因此先将
iframe容器下的html元素转移到沙箱body中
在沙箱
body中除了作为容器html元素临时存放点以外,其余情况都是空的
如果是从应用外部后退,是无法返回到劫持容器:
- 因为切出应用时之前提供容器的挂载点已销毁,无法继续挂载,将会切换到应用入口页
后退时 hrefFlag 存在,shadowRoot 容器怎么做:
- 通过
renderElementToContainer将shadowRoot重新替换挂载到节点 [查看]
后退时 hrefFlag 存在,iframe 容器和降级渲染时操作一样 [查看]:
- 通过
initRenderIframeAndContainer创建iframe沙箱并挂载到指定节点 [查看] - 通过
patchEventTimeStamp修复vue的event.timeStamp问题 - 绑定
onunload到iframe容器上用于销毁时主动unmount应用 - 将之前迁移到沙箱
body中的html元素添加到容器document下 - 将容器
document绑定在应用实例的document上
问题:
- 因为
locationHrefSet存在bug,degrade模式下不能劫持location.href[查看]
- 当引入
wujie的时候通过defineWujieWebComponent确保已定义了web component了 - 而在
active中通过createWujieWebComponent会自动创建组件,无需手动引入 [查看]
在
wujie中只能通过active自动创建web component,不支持手动添加wujie-app到Dom tree
除此之外会默认执行 wujieSupport 进行检测:
- 浏览器不支持
Proxy或CustomElementRegistry输出警告,此时采用degrade模式
WujieReact 是官方提供的封装组件,和基座演示的自定义组件是一样的,见:自定义组件 [查看]
- 官方只提供了 1 个组件,用于启动子应用,见:
index.js[查看]
1. 属性
静态属性:
propTypes:定义组件的属性类型,用于类型检查。bus,setupApp,preloadApp,destroyApp:引入方法和对象,分别用于应用通信、预加载和注销
外部可以直接通过
WujieReact这个类获取静态属性
状态和引用:
state:定义myRef通过ref的方式引入div挂载节点destroy绑定startApp启动应用后返回的注销方法
destroy 定义了但没有使用,如果自行扩展的话可以这样使用:
// 组件卸载时销毁应用
componentWillUnmount() {
this.destroy();
}
// 也可以在需要重新启动应用时调用 `destroy`,例如在 `props` 变更时
componentDidUpdate(prevProps) {
if(needRestart) {
this.destroy();
this.startApp();
}
}
但文档中并不建议手动注销应用,如果后续还需要使用的话 [查看]
此外还定义了 startAppQueue,用于发起微任务但没有使用,若要使用可以这样修改:
startApp = async (props) => {
try {
const { current: el } = this.state.myRef;
this.destroy = await startApp({
...props,
el,
});
} catch (error) {
console.log(error);
}
}
componentDidMount () {
const list = this.props;
list.forEach(props => {
this.startAppQueue = this.startAppQueue.then(() => this.startApp(props))
});
}
2. 方法
异步方法 startApp 用于启动子应用:
- 除了透传
props作为配置以外,还需要将myRef作为应用容器挂载点
生命周期方法:
componentDidMount:在组件挂载后调用startApp方法启动子应用componentDidUpdate:当组件的name或url属性发生变化时重新启动子应用
即使不注销应用也可以重启,在应用实例中会清空容器挂载点,然后根据配置重新挂载容器
render 方法:
- 定义渲染
div元素,通过ref绑定在myRef中,并按照props设置宽和高
文档:
官方 react 组件封装总结:
WujieReact通过react组件生命周期来管理wujie子应用- 通过
startApp方法启动子应用,并在组件更新时重新启动子应用 - 通过静态属性和类型检查确保组件的使用符合预期
建议手动定义组件代替官方提供的组件,因为灵活度更高


