微前端源码剖析

微前端源码剖析

为什么需要微前端?

  • 将不同的功能按照不同维度拆分成多个子应用(将子应用打包成一个个 lib),当路径切换时通过主应用来加载这些子应用当路径切换时通过主应用来加载这些子应用当路径切换时通过主应用来加载这些子应用

  • 核心:先拆->后合

微前端解决的问题?

  • 不同团队(技术栈不同),开发同一个应用
  • 每个团队开发的模块独立开发,独立部署
  • 增量迁移

实现微前端技术方案?

  • 实现微前端考虑要点

    • 如何进行应用拆分
    • 如何进行应用通信
    • 如何进行应用隔离
  • 技术方案

    • iframe

      • 通过 postMessage 通信
      • 自带的沙箱机制可以进行应用隔离
      • 缺点:用户体验差,弹框只能在 iframe 中、内部刷新切换状态丢失等
    • Web Components

      • 将前端应用程序分解为自定义 html 元素
      • 通过 CustomEvent 通信
      • Shadow DOM 天生的作用域隔离
      • 缺点:兼容问题、学习成本、调试困难
    • single-spa

      • 通过路由劫持,采用 SystemJS,子应用暴露固定钩子 bootstrap、mount、unmount 接入协议
      • 基于 props 进行通信
      • 无沙箱机制,需要自己实现 JS 沙箱和 CSS 沙箱
      • 缺点:学习成本、无沙箱机制、应用改造、资源重复加载问题
    • Module federation

      • 通过模块联邦将组件打包导出
      • 共享模块通信
      • 无 CSS 沙箱和 JS 沙箱
      • 缺点:需要 webpack5
      • 百度 EMP

微前端框架

  • qiankun(基于 single-spa)

    • 沙箱(sandbox、快照、window.proxy)
    • 样式隔离(shadowDom、scopedCss)
  • micro-app(基于 webComponent)

    • webComponent
      • 允许用户自定义一个元素,名字随便起
      • shadowDom 样式隔离是 webComponent 的一部分
      • 支持组件的模版、插槽、生命周期、属性等

你能描述下qiankun微前端框架的工作原理吗?

  1. 应用加载:qiankun通过动态创建script标签加载子应用的入口文件,加载完成后执行子应用暴露出来的生命周期函数

  2. 生命周期管理:qiankun要求子应用都需要暴露出bootstrap、mout和unmount三个生命周期函数,bootstrap在应用加载时被调用,mout函数在应用启动时调用,unmount在应用卸载时调用

  3. js沙箱隔离:qiankun通过Proxy对象创建了一个javascript沙箱,通过重新window对象的相关属性和方法,用于隔离子应用的全局变量,防止子应用间的全局变量污染

  4. 通信机制:qiankun提供了全局的通信机制,props传参、onGlobalStateChange、offGlobalStateChange、setGlobalState等API

简易版 single-spa 源码实现

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>

<body>
<a onclick="go('#/a')">a应用</a>
<a onclick="go('#/b')">b应用</a>
<!-- navigation-event.js -->
<script>
// 对用户的路径切换 进行劫持,劫持后,重新调用reroute方法,进行计算应用的加载
function urlRoute() {
reroute(arguments);
}

window.addEventListener('hashchange', urlRoute);
window.addEventListener('popstate', urlRoute); // 浏览器历史切换的时候会执行此方法

// 但是当路由切换的时候 我们触发single-spa的addEventLister, 应用中可能也包含addEventLister

// 需要劫持原生的路由系统,保证当我们加载完后再切换路由

const capturedEventListeners = {
hashchange: [],
popstate: [],
};

const listentingTo = ['hashchange', 'popstate'];
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;

window.addEventListener = function (eventName, callback) {
// 有要监听的事件, 函数不能重复
if (
listentingTo.includes(eventName) &&
!capturedEventListeners[eventName].some(
(listener) => listener === callback
)
) {
return capturedEventListeners[eventName].push(callback);
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, callback) {
// 有要监听的事件, 函数不能重复
if (listentingTo.includes(eventName)) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== callback);
return;
}
return originalRemoveEventListener.apply(this, arguments);
};

function callCaptureEventListeners(e) {
if (e) {
const eventType = e[0].type;
if (listentingTo.includes(eventType)) {
capturedEventListeners[eventType].forEach((listener) => {
listener.apply(this, e);
});
}
}
}

function patchFn(updateState, methodName) {
return function () {
const urlBefore = window.location.href;
const r = updateState.apply(this, arguments); // 调用此方法 确实发生了路径的变化
const urlAfter = window.location.href;

if (urlBefore !== urlAfter) {
// 手动派发popstate事件
window.dispatchEvent(new PopStateEvent('popstate'));
}
return r;
};
}

window.history.pushState = patchFn(window.history.pushState, 'pushState');

window.history.replaceState = patchFn(
window.history.replaceState,
'replaceState'
);
</script>

<!-- app.helpers.js -->
<script>
// app status
const NOT_LOADED = 'NOT_LOADED'; // 没有被加载
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'; // 路径匹配了 要去加载这个资源
const LOAD_ERROR = 'LOAD_ERROR';

// 启动的过程
const NOT_BOOTSTRAPED = 'NOT_BOOTSTRAPED'; // 资源加载完毕了 需要启动,此时还没有启动
const BOOTSTRAPING = 'BOOTSTRAPING'; // 启动中
const NOT_MOUNTED = 'NOT_MOUNTED'; // 没有被挂载

// 挂载流程
const MOUNTING = 'MOUNTING'; // 正在挂载
const MOUNTED = 'MOUNTED'; // 挂载完成

// 卸载流程
const UNMOUNTING = 'UNMOUNTING'; // 卸载中

// 加载正在下载应用 LOADING_SOURCE_CODE,激活已经运行了

// 看一下这个应用是否正在被激活
function isActive(app) {
return app.status === MOUNTED; // 此应用正在被激活
}
// 看一下此应用是否被激活
function shouldBeActive(app) {
return app.activeWhen(window.location);
}

function getAppChanges() {
const appsToLoad = [];
const appsToMount = [];
const appsToUnmount = [];

apps.forEach((app) => {
let appShouldBeActive = shouldBeActive(app);
switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
// 1) 标记当前路径下 哪些应用要被加载
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPED:
case BOOTSTRAPING:
case NOT_MOUNTED:
// 2) 当前路径下 哪些应用要被挂在
if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
// 3) 当前路径下 哪些应用要被卸载
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
default:
break;
}
});

return { appsToLoad, appsToMount, appsToUnmount };
}
</script>

<!-- lifecycles -->
<script>
// load
function flattenArrayToPromise(fns) {
fns = Array.isArray(fns) ? fns : [fns];
return function (props) {
// redux
return fns.reduce(
(rPromise, fn) => rPromise.then(() => fn(props)),
Promise.resolve()
);
};
}
function toLoadPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_LOADED) {
// 此应用加载完毕了
return app;
}
app.status = LOADING_SOURCE_CODE; // 正在加载应用

// loadApp 对于之前的内容 System.import()
return app.loadApp(app.customProps).then((v) => {
const { bootstrap, mount, unmount } = v;
app.status = NOT_BOOTSTRAPED;
app.bootstrap = flattenArrayToPromise(bootstrap);
app.mount = flattenArrayToPromise(mount);
app.unmount = flattenArrayToPromise(unmount);

return app;
});
});
}

// mount
function toMountPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_MOUNTED) {
return app;
}
return app.mount(app.customProps).then(() => {
app.status = MOUNTED;
return app;
});
});
}

// unmount
function toUnmountPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== MOUNTED) {
return app;
}
app.status = UNMOUNTING;
// app.unmount 方法用户可能写的是一个数组。。。。。
return app.unmount(app.customProps).then(() => {
app.status = NOT_MOUNTED;
});
});
}

// bootstrap
function toBootstrapPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_BOOTSTRAPED) {
// 此应用加载完毕了
return app;
}
app.status = BOOTSTRAPING;

return app.bootstrap(app.customProps).then(() => {
app.status = NOT_MOUNTED;
return app;
});
});
}
</script>

<!-- reroute -->
<script>
// 后续路径变化 也需要走这里, 重新计算哪些应用被加载或者写在

let appChangeUnderWay = false;
let peopleWaitingOnAppChange = [];
function reroute(event) {
// 如果多次触发reroute 方法我们可以创造一个队列来屏蔽这个问题
if (appChangeUnderWay) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
});
});
}
// 获取app对应的状态 进行分类
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
// 加载完毕后 需要去挂载的应用
if (started) {
appChangeUnderWay = true;
// 用户调用了start方法 我们需要处理当前应用要挂载或者卸载
return performAppChange();
}
// 先拿到应用去加载 -》
return loadApps();
function loadApps() {
// 应用的加载
return Promise.all(appsToLoad.map(toLoadPromise)).then(
callEventListener
); // 目前我们没有调用start
}
function performAppChange() {
// 将不需要的应用卸载掉, 返回一个卸载的promise
// 1) 稍后测试销毁逻辑
const unmountAllPromises = Promise.all(
appsToUnmount.map(toUnmountPromise)
);

// 流程加载需要的应用 -》 启动对应的应用 -》 卸载之前的 -》 挂载对应的应用

// 2) 加载需要的应用(可能这个应用在注册的时候已经被加载了)

// 默认情况注册的时候 路径是 /a , 但是当我们start的时候应用是/b
const loadMountPromises = Promise.all(
appsToLoad.map((app) =>
toLoadPromise(app).then((app) => {
// 当应用加载完毕后 需要启动和挂载,但是要保证挂载前 先卸载掉来的应用
return tryBootstrapAndMount(app, unmountAllPromises);
})
)
);

// 如果应用 没有加载 加载 -》启动挂载 如果应用已经加载过了 挂载
const MountPromises = Promise.all(
appsToMount.map((app) =>
tryBootstrapAndMount(app, unmountAllPromises)
)
);

function tryBootstrapAndMount(app, unmountAllPromises) {
if (shouldBeActive(app)) {
// 保证卸载完毕在挂载
return toBootstrapPromise(app).then((app) =>
unmountAllPromises.then(() => toMountPromise(app))
);
}
}

return Promise.all([loadMountPromises, MountPromises]).then(() => {
// 卸载完毕后
callEventListener();
appChangeUnderWay = false;
if (peopleWaitingOnAppChange.length > 0) {
peopleWaitingOnAppChange = []; // 多次操作 我缓存起来,。。。。
}
});
}

function callEventListener() {
callCaptureEventListeners(event);
}
}
</script>

<!-- registerApplication -->
<script>
const apps = [];
function registerApplication(appName, loadApp, activeWhen, customProps) {
const registeration = {
name: appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED,
};
apps.push(registeration);

reroute(); // 重写路由
}
</script>

<!-- start -->
<script>
let started = false; // 默认没有调用start方法
function start() {
started = true; // 用户启动了
reroute();
}
</script>

<!-- 入口 -->
<script type="module">
let app1 = {
bootstrap: [
async () => console.log('app1 bootstrap1'),
async () => console.log('app1 bootstrap2'),
],
mount: [
async (props) => {
// new Vue().$mount()...
console.log('app1 mount1', props);
},
async () => {
// new Vue().$mount()...
console.log('app1 mount2');
},
],
unmount: async (props) => {
console.log('app1 unmount');
},
};
let app2 = {
bootstrap: async () => console.log('app2 bootstrap1'),
mount: [
async () => {
// new Vue().$mount()...
return new Promise((resolve, reejct) => {
setTimeout(() => {
console.log('app2 mount');
resolve();
}, 1000);
});
},
],
unmount: async () => {
console.log('app2 unmount');
},
};
registerApplication(
'a',
async () => app1,
(location) => location.hash.startsWith('#/a'),
{ a: 1 }
);
registerApplication(
'b',
async () => app2,
(location) => location.hash.startsWith('#/b'),
{ a: 1 }
);

// 开启路径的监控,路径切换的时候 可以调用对应的mount unmount
start();

// 这个监控操作 应该被延迟到 当应用挂挂载完毕后再行
window.addEventListener('hashchange', function () {
console.log(window.location.hash, 'p----');
});
</script>

<script>
function go(url) {
// 用户调用pushState replaceState 此方法不会触发逻辑reroute
history.pushState({}, null, url);
}
</script>
</body>
</html>

评论