手写微前端

前言

微前端框架的目的是将大型项目解耦,拆分为主子工程,各个子应用相互独立且不受技术栈的限制,便于管理、维护和开发,本文将以 qiankun为例,解析实现原理

回顾

主应用,注册子应用,微前端的运行原理和 SPA 很相似

1
2
3
4
5
6
7
8
9
10
11
12
// main.js
registerMicroApps([
// 当匹配到 activeRule 的时候,请求 entry 资源,渲染到 container 中
{
name: 'app-react',
entry: '//localhost: 9001',
container: '#subapp-container',
activeRule: '/subapp/app-react',
},
// ...
])
start();

子应用如何接入?

子应用不需要安装任何包,但需要导出三个必要的生命周期函数。bootstrap 渲染之前;mount 渲染函数; unmount 卸载函数。

注意:生命周期函数必须返回 Promise

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
// main.js vue
// ...
let instance = null;
function render(props) {
const {container } = props
instance = new Vue({
render: (h) => h(app)
}).$mount(container ? container.querySelector('#app') : '#app')
}
// ...
export async function bootstrap() {
console.log('[vue] vue app bootstrap')
}

export async function mount(props) {
console.log('[vue] vue app mount')
render(props)
}

export async function unmount () {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
}
// 独立运行时
if (!window._POWERED_BY_QIANKUN_) {
mount({})
}
  1. 允许跨域

打包工具需要增加如下配置

webpack, 子应用必须要打包成一个库文件,库的格式必须是 umd

1
2
3
4
5
6
7
8
9
const packageName = require('./package.json').name;

module.exports = {
output: {
library: `${packageName}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},
};

运行原理

1
2
3
4
5
6
7
8
9
10
11
12
13
let _apps = []

export const getApps = () => _apps
export const registerMicroApps = (apps) => {
apps = apps
}

export const start = () => {
// 监视路由变化
rewriteRouter()
// 初始执行匹配
handleRouter()
}

监视路由变化

  • hash 路由 window.hashchange
  • history 路由
    history.go / history.back/ history.forward => 使用 popstate 事件

popstate 只能监视到以上几个,但是切换到不同的子应用属于pushState (添加历史记录),而 pushState replaceState 需要通过函数重写的方式进行劫持:

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
let preRouter = '' // 上一个路由
let nextRoute = window.location.pathname //下一个路由

export const getPreRoute = () => prevRoute
export const getNextRoute = () => nextRoute

export const rewriteRouter = () => {
// 1. 监视路由变化
// popstate 触发时已经完成导航了
window.addEventListener('popstate', () => {
prevRoute = nextRoute // 这里比较绕需要理解 prevRoute nextROute 的变化
nextROute = window.location.pathname

handleRouter()
})

const rawPushState = window.history.pushState
window.history.pushState = (...args) => {
//导航前
preRoute = window.location.pathname
rawPushState.apply(window.history, args)
// 导航后
nextRoute = window.location.pathname
console.log('监视到 pushState 变化了')
handleRouter()
}

const rawReplaceState = window.history.replaceState
window.history.replaceState = (...args) => {
//导航前
preRoute = window.location.pathname
rewReplaceState.apply(window.history, args)
//导航后
nextRoute = window.location.pathname
console.log('监视到 repalceState 变化了')
handleRouter()
}
}

处理路由变化

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
export const handleRouter = () => {
const apps = getApps()
// 0.先卸载上一个应用
const prevApp = apps.find(item => {
return getPrevRoute().startsWith(item.activeRule)
})
const nextApp = apps.find(item => {
return getNextRoute().startsWith(item.activeRule)
})
console.log('handleRouter')
// 1.拿到路由路径
console.log(window.location.pathname)
// 2.去apps中查找 apps 见主工程的main.js文件

const app = apps.find(item => window.location.pathname.startWith(item.activeRule))

// 如果有上一个应用,先销毁
if (prevApp) {
await unmount(prevApp)
}

if (!app) {
return
}
// 3.加载子应用
const container = document.querySelector(app.container)
const { template, getExternalScripts, execScripts } = await importHTML(app.entry)
container.appendChild(template)

// 4.配置全局变量
window._POWRED_BY_QIANKUN_ = true
window.__INJECTED_PUBLIC_PATH__ = app.entry + '/'

const appExports = await execScripts()

app.bootstrap = appExports.bootstrap
app.mount = appExports.mount
app.unmount = appExports.unmount

await bootstrap(app)

await mount(app)

}

async function bootstrap (app) {
app.bootstrap && await app.bootstrap()
}

async function mount (app) {
app.mount && await app.mount({
container: document.querySelector(app.container)
})
}

async function unmount (app) {
app.unmount && await app.unmount({
container: document.querySelector(app.container)
})
}

资源获取

1
export const fetchResource = url => fetch(url).then(res => res.text())

当节点被替换了,但是页面不会显示出来,这是为何?

  1. 客户端渲染需要通过执行Javascript 来生成内容
  2. 处于浏览器的安全考虑,innerHTML 中的 js 代码不会执行

解决办法:手动加载子应用的 script, 使用 eval 或者 new Function

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
41
42
43
export const importHTML = async (url) => {
const html = await fetchResource(url)
const template = document.createElement('div')
template.innerHTML = html

const scripts = template.querySelectorAll('script')

// 获取所有 script 标签的代码: [代码,代码]
async function getExterrnalScripts () {
return Promise.all(Array.from(scripts.map(script => {
const src = script.getAttribute('src')
if (!src) {
return Promise.resolve(script.innerHTML)
} else {
return fetchResource(
src.startswith('http') ? src : `${url}${src}`
)
}
})))
}

// 获取并执行所有的的 script 脚本代码
async function execScripts () {
const scripts = await getExternalScripts()

scripts.forEach(code => {
// eval 执行的代码可以访问外部变量
eval(code)
})
// 这样需要知道每个子应用暴露出来的名字
// return window['app-vue2-app']
// 所以手动构造 CommandJS 环境
const module = { exports: {}}
const exports = module.exports
return module.exports

}
return {
template,
getExternalScripts,
execScripts
}
}

关于图片加载不出来

webpack 打包时,publicPath 默认为 /,一种做法时写死域名,但是不推荐,好的写法是 webpack 支持运行时的 publicPath

在子应用入口文件加载

1
2
3
4
if (window.__POWERED_BY_QIANKUN__) {
// 由主应用进行注入 window.__INJECTED_PUBLIC_PATH__ 设置为子应用的 entry
__Webpack_public_path__ = window.__INJECTED_PUBLIC_PATH__
}

CSS 样式隔离

qiankun 通过shadow-dom来进行样式隔离

1
2
3
4
5
6
start({
sandbox: {
// stricStyleIsolation: true // 使用shadow dom解决冲突
// experimentalStyleIsolation: true // 通过添加唯一性的选择器解决样式冲突
}
})

JS 沙箱

解决 js 变量的冲突,qiankun 作沙箱隔离主要分为三种:

  • legacySandBox
  • proxySandBox
  • snapshotSandBox。

其中 legacySandBox、proxySandBox 是基于 Proxy API 来实现的,在不支持 Proxy API 的低版本浏览器中,会降级为 snapshotSandBox。在现版本中,legacySandBox 仅用于 singular 单实例模式,而多实例模式会使用 proxySandBox。

legacySandBox

legacySandBox 的核心思想是什么呢?legacySandBox 的本质上仍是操做 window 对象,可是他会存在三个状态池,分别用于子应用卸载时还原主应用的状态和子应用加载时还原子应用的状态:浏览器

  • addedPropsMapInSandbox: 存储在子应用运行时期间新增的全局变量,用于卸载子应用时还原主应用全局变量;
  • modifiedPropsOriginalValueMapInSandbox:存储在子应用运行期间更新的全局变量,用于卸载子应用时还原主应用全局变量;
  • currentUpdatedPropsValueMap:存储子应用全局变量的更新,用于运行时切换后还原子应用的状态;

咱们首先看下 Proxy 的 getter / setter:

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
const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
// 建立对fakeWindow的劫持,fakeWindow就是咱们传递给自执行函数的window对象
const proxy = new Proxy(fakeWindow, {
set(_: Window, p: PropertyKey, value: any): boolean {
// 运行时的判断
if (sandboxRunning) {
// 若是window对象上没有这个属性,那么就在状态池中记录状态的新增;
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);

// 若是当前 window 对象存在该属性,而且状态池中没有该对象,那么证实改属性是运行时期间更新的值,记录在状态池中用于最后window对象的还原
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
const originalValue = (rawWindow as any)[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}

// 记录全局对象修改值,用于后面子应用激活时还原子应用
currentUpdatedPropsValueMap.set(p, value);
(rawWindow as any)[p] = value;

return true;
}

return true;
},

get(_: Window, p: PropertyKey): any {
// iframe的window上下文
if (p === "top" || p === "window" || p === "self") {
return proxy;
}

const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
});

接下来看下子应用沙箱的激活 / 卸载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 子应用沙箱激活
active() {
// 经过状态池,还原子应用上一次写在前的状态
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}

this.sandboxRunning = true;
}

// 子应用沙箱卸载
inactive() {
// 还原运行时期间修改的全局变量
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 删除运行时期间新增的全局变量
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

this.sandboxRunning = false;
}

因此,总结起来,legacySandBox 仍是会操做 window 对象,可是他经过激活沙箱时还原子应用的状态,卸载时还原主应用的状态来实现沙箱隔离的

proxySandBox

在 qiankun 中,proxySandBox 用于多实例场景。什么是多实例场景,这里我简单提下,通常咱们的中后台系统同一时间只会加载一个子应用的运行时。可是也存在这样的场景,某一个子应用聚合了多个业务域,这样的子应用每每会经历多个团队的多个同窗共同维护本身的业务模块,这时候即可以采用多实例的模式聚合子模块(这种模式也能够叫微前端模块)。

image-20220802024542701

回到正题,和 legacySandBox 最直接的不一样点就是,为了支持多实例的场景,proxySandBox 不会直接操做 window 对象。而且为了不子应用操做或者修改主应用上诸如 window、document、location 这些重要的属性,会遍历这些属性到子应用 window 副本(fakeWindow)上,咱们首先看下建立子应用 window 的副本:

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
function createFakeWindow(global: Window) {
// 这里qiankun给咱们了一个知识点:在has和check的场景下,map有着更好的性能 :)
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;

// 从window对象拷贝不可配置的属性
// 举个例子:window、document、location这些都是挂在Window上的属性,他们都是不可配置的
// 拷贝出来到fakeWindow上,就间接避免了子应用直接操做全局对象上的这些属性方法
Object.getOwnPropertyNames(global)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
// 若是属性不存在或者属性描述符的configurable的话
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
if (descriptor) {
// 判断当前的属性是否有getter
const hasGetter = Object.prototype.hasOwnProperty.call(
descriptor,
"get"
);

// 为有getter的属性设置查询索引
if (hasGetter) propertiesWithGetter.set(p, true);

// freeze the descriptor to avoid being modified by zone.js
// zone.js will overwrite Object.defineProperty
// const rawObjectDefineProperty = Object.defineProperty;
// 拷贝属性到fakeWindow对象上
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});

return {
fakeWindow,
propertiesWithGetter,
};
}

接下来看下 proxySandBox 的 getter/setter:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
const rawWindow = window;
// window副本和上面说的有getter的属性的索引
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);

const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) =>
fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);

const proxy = new Proxy(fakeWindow, {
set(target: FakeWindow, p: PropertyKey, value: any): boolean {
if (sandboxRunning) {
// 在fakeWindow上设置属性值
target[p] = value;
// 记录属性值的变动
updatedValueSet.add(p);

// SystemJS属性拦截器
interceptSystemJsProps(p, value);

return true;
}

// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的状况下应该忽略错误
return true;
},

get(target: FakeWindow, p: PropertyKey): any {
if (p === Symbol.unscopables) return unscopables;

// 避免window.window 或 window.self 或window.top 穿透sandbox
if (p === "top" || p === "window" || p === "self") {
return proxy;
}

if (p === "hasOwnProperty") {
return hasOwnProperty;
}

// 批处理场景下会有场景使用,这里就很少赘述了
const proxyPropertyGetter = getProxyPropertyGetter(proxy, p);
if (proxyPropertyGetter) {
return getProxyPropertyValue(proxyPropertyGetter);
}

// 取值
const value = propertiesWithGetter.has(p)
? (rawWindow as any)[p]
: (target as any)[p] || (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},

// 还有一些对属性作操做的代码我就不一一列举了,能够自行查阅源码
});

接下来看下 proxySandBox 的 激活 / 卸载:

1
2
3
4
5
6
7
8
9
10
11
active() {
this.sandboxRunning = true;
// 当前激活的子应用沙箱实例数量
activeSandboxCount++;
}

inactive() {
clearSystemJsProps(this.proxy, --activeSandboxCount === 0);

this.sandboxRunning = false;
}

可见,由于 proxySandBox 不直接操做 window,因此在激活和卸载的时候也不须要操做状态池更新 / 还原主子应用的状态了。相比较看来,proxySandBox 是现阶段 qiankun 中最完备的沙箱模式,彻底隔离了主子应用的状态,不会像 legacySandBox 模式下在运行时期间仍然会污染 window。

snapshotSandBox

最后一种沙箱就是 snapshotSandBox,在不支持 Proxy 的场景下会降级为 snapshotSandBox,如同他的名字同样,snapshotSandBox 的原理就是在子应用激活 / 卸载时分别去经过快照的形式记录/还原状态来实现沙箱的。

源码很简单,直接看源码:

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
active() {
if (this.sandboxRunning) {
return;
}


this.windowSnapshot = {} as Window;
// iter方法就是遍历目标对象的属性而后分别执行回调函数
// 记录当前快照
iter(window, prop => {
this.windowSnapshot[prop] = window[prop];
});

// 恢复以前运行时状态的变动
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});

this.sandboxRunning = true;
}

inactive() {
this.modifyPropsMap = {};

iter(window, prop => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变动,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});

this.sandboxRunning = false;
}

总结起来,对当前的 window 和记录的快照作 diff 来实现沙箱


手写微前端
http://example.com/2022/08/01/手写微前端/
作者
lyric
发布于
2022年8月1日
许可协议