背景
Garfish
是字节跳动 web infra
团队推出的一款微前端框架
包含构建微前端系统时所需要的基本能力,任意前端框架均可使用。接入简单,可轻松将多个前端应用组合成内聚的单个产品
因为当前对 Garfish
的解读极少,而微前端又是现代前端领域相当重要的一环,因此写下本文,同时也是对学习源码的一个总结
本文基于 garfish#0d4cc0c82269bce8422b0e9105b7fe88c2efe42a 进行解读
学习源码
1 | git clone https://github.com/modern-js-dev/garfish.git |
然后打开https://localhost:8090/
即可看到演示项目
基本使用
主应用
1 | export const GarfishInit = async () => { |
其中关键点是 Config
参数, 其所有参数都是可选的,一般比较重要的几个参数为:
basename
子应用的基础路径,默认值为 /,整个微前端应用的 basename。设置后该值为所有子应用的默认值,若子应用 AppInfo 中也提供了该值会替换全局的 basename 值domGetter
子应用挂载点。如'#submodule'
apps
需要主要参数如name
,entry
,activeWhen(路由地址)
此函数运行之后,Garfish会自动进行路由劫持功能。根据路由变化
子应用
以react17为例:
1 | import { reactBridge, AppInfo } from '@garfish/bridge-react'; |
其中:
RootComponent
是子应用的主要逻辑reactBridge
是garfish导出的一个封装函数。大概的逻辑就是把react的一些特有写法映射到garfish
的通用生命周期,包含render
和destroy
源码解读
那么简单了解了一些garfish的基本使用方案,我们就来看看garfish
在此过程中到底做了什么。
从Garfish.run
开始:
garfish/packages/core/src/garfish.ts
1 | run(options: interfaces.Options = {}) { |
其中移除插件等内容,最重要的是registerApp
调用,用于将配置注册到实例中
接下来的代码会移除无关紧要的代码,仅保留核心逻辑
1 | registerApp(list: interfaces.AppInfo | Array<interfaces.AppInfo>) { |
看上去仅仅是一些配置设定,那么所谓的路由绑定是从哪里发生的呢?这一切其实早就暗中进行了处理。
1 | export type { interfaces } from '@garfish/core'; |
当调用 import Garfish from 'garfish';
时, 使用的是默认创建好的全局Garfish实例。该逻辑简化版大概如下:
1 | import { GarfishRouter } from '@garfish/router'; |
其中核心逻辑为:
- 如果本地已经有
Garfish
实例,则直接从本地拿。(浏览器环境用于子应用,也可以从这边看出garfish
并不支持其他的js环境 - 创建Garfish实例,并安装插件:
GarfishRouter
路由劫持能力GarfishBrowserVm
js运行时沙盒隔离GarfishBrowserSnapshot
浏览器状态快照
- 在window上设置全局
Garfish
对象并标记__GARFISH__
, 注意该变量为只读
其中安全和样式隔离的逻辑我们暂且不看,先看其核心插件 GarfishRouter
的实现
插件系统
Garfish
自己实现了一套插件协议,其本质是pubsub模型的变种(部分生命周期的emit阶段增加了异步操作的等待逻辑)。
我们以Garfish
最核心的插件 @garfish/router
为学习例子,该代码的位置在: garfish/packages/router/src/index.ts
1 | export function GarfishRouter(_args?: Options) { |
一个插件的结构形如 (context: Garfish) => Plugin
其中 Plugin
类型为一个对象,包含各个阶段的生命周期以及name
/version
等插件信息描述属性。
以 router
插件为例,其作用在bootstrap
和registerApp
两个生命周期阶段
生命周期定义可以在这里看到: garfish/packages/core/src/lifecycle.ts
以 Garfish.run
视角来看,执行顺序为: beforeBootstrap -> beforeRegisterApp -> registerApp -> bootstrap -> ...
因此我们先看registerApp
的逻辑。
registerApp 阶段
1 | this.hooks.lifecycle.registerApp.emit(currentAdds); |
Garfish 执行 registerApp
函数 完毕后触发 registerApp
生命周期hook, 将当前注册的子应用列表发送到事件回调
garfish/packages/router/src/index.ts
1 | { |
插件接收到子应用列表, 将依次调用:
router.registerRouter
注册到路由列表,其中会把不存在activeWhen
属性的子应用过滤initRedirect
初始化重定向逻辑
garfish/packages/router/src/context.ts
1 | export const RouterConfig: Options = { |
在registerRouter
阶段仅仅是将子应用注册
1 | export const initRedirect = () => { |
在initRedirect
阶段则是调用linkTo
函数去实现一个跳转,这里具体细节比较复杂。可以简单理解为子应用版页面跳转
1 | // 重载指定路由 |
bootstrap 阶段
1 | this.hooks.lifecycle.bootstrap.emit(this.options); |
1 | { |
bootstrap
阶段主要构造路由配置,并调用listenRouterAndReDirect(listenOptions)
来进行路由的代理/拦截
其中主要需要关心的active
操作(即子应用挂载逻辑)做了以下事情:
- 调用
Garfish.loadApp
将子应用挂载到子应用挂载节点上(Promise 同步加载) - 在
Garfish.apps
记录该app - 注册到 unmounts 记录销毁逻辑
1 | /** |
1 | export const registerRouter = (Apps: Array<interfaces.AppInfo>) => { |
registerRouter
没有什么特殊的,仅仅管理路由状态
接下来看一下listen()
函数做的事情:
1 | export const listen = () => { |
initRedirect
我们之前看过了,现在我们主要看normalAgent
的实现
garfish/packages/router/src/agentRouter.ts
1 | export const normalAgent = () => { |
normalAgent
做了以下事情:
- 通过
rewrite
函数重写history.pushState
和history.pushState
rewrite
函数则是在调用以上方法的前后增加了一些当前情况的快照,如果url
/state
发生变化则触发__GARFISH_BEFORE_ROUTER_EVENT__
事件
- 对
popstate
事件增加监听 - 调用
addRouterListener
增加路由监听回调。监听方法基于浏览器内置的事件系统,事件名:__GARFISH_BEFORE_ROUTER_EVENT__
综上, router
通过监听history
的方法来执行副作用调用linkTo
函数,而linkTo
函数则通过一系列操作将匹配的路由调用active
方法,将不匹配的路由调用deactive
方法以实现类型切换
这时候我们再回过头来看一下active
函数的实现
1 | async function active( |
其核心代码则是调用了Garfish.loadApp
方法来执行加载操作。
应用加载
接下来我们看一下loadApp
函数
garfish/packages/core/src/garfish.ts
1 | loadApp( |
该函数做了以下操作:
- 首先执行
asyncLoadProcess
来异步加载app,如果app正在加载则返回该Promise - 使用
generateAppOptions
计算全局+本地的配置,并通过黑名单过滤掉一部分的无用参数(filterAppConfigKeys) - 如果当前app已加载则直接返回缓存后的内容
- 如果是第一次加载,则执行
processAppResources
进行请求, 请求的地址为entry
指定的地址。 - 当请求完毕后创建
new App
对象,将其放到内存中 - 应用插件/记录缓存/发布生命周期事件等
接下来我们看核心函数, processAppResources
的实现
1 | export async function processAppResources(loader: Loader, appInfo: AppInfo) { |
首先根据appInfo.entry
调用loader.load
函数,生成一个entryManager
。如果entry指向的是html地址则获取静态数据后拿取js,link,modules
,如果entry指向的是一个js地址则伪造一个仅包含这段js的js资源。最后的返回值是一个 [resourceManager, resources, isHtmlMode]
的元组。
其中resourceManager
的大概结构如下:
loader.load
的本质上就是发请求获取数据然后把请求到的纯文本转化成结构化,如果是html则对html声明的资源进行进一步的请求获取。这边就不再赘述。
我们回到loadApp
函数的实现。
之后,代码根据processAppResources
获取到的[resourceManager, resources, isHtmlMode]
信息来创建一个new App
;
1 | appInstance = new App( |
new App
的过程中没有任何逻辑,仅仅是一些变量的定义。值得注意的是在此过程中会对插件系统做一些初始化设定
garfish/packages/core/src/module/app.ts
1 | export class App { |
到这一步为止,我们还在做一些准备工作:
- 从远程获取资源
- 将纯文本解析成结构化对象和AST
- 进一步获取js/css的实际代码
接下来我们需要一个调用方能够帮助我们将获取到的资源执行并挂载到dom上。
这时候我们就需要回到我们的router
插件。还记得我们的GarfishRouter.bootstrap.active
里的代码么?
garfish/packages/router/src/index.ts
1 | export function GarfishRouter(_args?: Options) { |
当我们第一次执行到call
函数时,会执行app.mount()
函数来实现应用的挂载。
我们看下app.mount()
的实现:
garfish/packages/core/src/module/app.ts
1 | export class App { |
mount
主要实现以下操作:
- 生命周期的分发:
beforeMount
,afterMount
- 状态变更:
this.active
,this.mounting
,this.display
- 调用
this.compileAndRenderContainer
执行编译- 调用
this.renderTemplate
渲染同步代码片段 - 返回
asyncScripts
函数用于在下一个宏任务(task) 执行异步js代码片段
- 调用
- 在每一个异步片段过程中都尝试执行
stopMountAndClearEffect
来判断当前状态,以确保状态的准确性(用于处理在异步代码执行过程中被取消的问题)
我们看一下renderTemplate
的逻辑:
1 | export class App { |
- 调用
createAppContainer
函数创建一些空白的容器dom, 注意此时还没有挂载到界面上:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24export function createAppContainer(appInfo: interfaces.AppInfo) {
const name = appInfo.name;
// Create a temporary node, which is destroyed by the module itself
let htmlNode: HTMLDivElement | HTMLHtmlElement =
document.createElement('div');
const appContainer = document.createElement('div');
if (appInfo.sandbox && appInfo.sandbox.strictIsolation) {
htmlNode = document.createElement('html');
const root = appContainer.attachShadow({ mode: 'open' });
root.appendChild(htmlNode);
// asyncNodeAttribute(htmlNode, document.body);
dispatchEvents(root);
} else {
htmlNode.setAttribute(__MockHtml__, '');
appContainer.appendChild(htmlNode);
}
appContainer.id = `${appContainerId}_${name}_${createKey()}`;
return {
htmlNode,
appContainer,
};
}- 如果开启了
sandbox
和strictIsolation
配置则进行严格的隔离(使用appContainer.attachShadow
)来创建ShadowDOM
- 如果开启了
- 调用
addContainer
来将代码挂载容器组件到文档中, 通过执行domGetter
来获取父容器节点1
2
3
4
5
6
7private async addContainer() {
// Initialize the mount point, support domGetter as promise, is advantageous for the compatibility
const wrapperNode = await getRenderNode(this.appInfo.domGetter);
if (typeof wrapperNode.appendChild === 'function') {
wrapperNode.appendChild(this.appContainer);
}
} - 调用
entryManager.createElements(customRenderer, htmlNode);
来实际创建节点。使用1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40// Render dom tree
createElements(renderer: Renderer, parent: Element) {
const elements: Array<Element> = [];
const traverse = (node: Node | Text, parentEl?: Element) => {
let el: any;
if (this.DOMApis.isCommentNode(node)) {
// Filter comment node
} else if (this.DOMApis.isText(node)) {
el = this.DOMApis.createTextNode(node);
parentEl && parentEl.appendChild(el);
} else if (this.DOMApis.isNode(node)) {
const { tagName, children } = node as Node;
if (renderer[tagName]) {
el = renderer[tagName](node as Node);
} else {
el = this.DOMApis.createElement(node as Node);
}
if (parentEl && el) parentEl.appendChild(el);
if (el) {
const { nodeType, _ignoreChildNodes } = el;
// Filter "comment" and "document" node
if (!_ignoreChildNodes && nodeType !== 8 && nodeType !== 10) {
for (const child of children) {
traverse(child, el);
}
}
}
}
return el;
};
for (const node of this.astTree) {
if (this.DOMApis.isNode(node) && node.tagName !== '!doctype') {
const el = traverse(node, parent);
el && elements.push(el);
}
}
return elements;
}traverse
函数对自身进行树节点遍历,将ast树转换为dom树并挂载到parent
上- 注意有意思的一点是他是在遍历
ast
过程中的同时执行appendChild
方法加载到dom树上而不是将节点生成完毕后一次性加载(也许是因为操作都是在一个task中所以浏览器会一次性执行?)
- 注意有意思的一点是他是在遍历
总结
综上,garfish
完成了一次远程获取目标代码 => 解析成ast => 然后再从ast转换成dom树的过程。
将一段远程的页面/js加载到当前页面的固定位置