共计 30125 个字符,预计需要花费 76 分钟才能阅读完成。
-
深入浅出微前端
- 背景
-
什么是微前端
- 微前端劣势
- 微前端解决方案
-
为什么不是 TA
- 为什么不是 iframe
- 为什么不是 Web Component
- 为什么不是 ESM
-
SingleSpa
-
SystemJS 应用
- 新建我的项目并配置
- 编写 js、html 代码
- 查看 dest 目录
-
SystemJS 原理
- 外围办法 -register
- 外围办法 -import
-
SingleSpa 应用
- 创立基座
- 创立 vue 我的项目
- 创立 react 我的项目
- 启动我的项目
-
SingleSpa 原理
- 原生 Demo
- 外围办法 -registerApplication
- 状态机
- 外围办法 -start
- 外围逻辑 -reroute
- 欠缺外围逻辑 -reroute
- SingleSpa 小结
-
-
qiankun
-
qiankun 应用
- 提供基座
- 提供 Vue 子利用
- 提供 React 子利用
- 查看最终成果
-
qiankun 原理
- registerMicroApps
- start
- prefetch
- loadApp
- createSandboxContainer
- Proxy Sandbox
- Snapshot Sandbox
- Style Shadow Dom Sandbox
- Style Scope Sandbox
- 父子利用通信形式
- qiankun 小结
-
- 总结
<!– END doctoc generated TOC please keep comment here to allow auto update –>
深入浅出微前端
长文正告⚠️,目标是通过从应用到实现,一层层分析微前端。
文章首发于 @careteen/micro-fe,转载请注明起源即可。
背景
在微前端呈现之前,一个零碎的前端开发模式根本都是单仓库,蕴含了所有的性能、代码 …
很多企业也根本在物理上进行了利用代码隔离,履行单个利用单个库,闭环部署更新测试环境和正式环境。
比方咱们公司的权限治理后盾,首页中列举了各个系统的入口,每个零碎由独自仓库治理,点击具体零碎,关上新窗口进行拜访。
因为多个利用一级域名统一,应用不同二级域名辨别。cookie
寄存在一级域名下,所以各利用能够借此实现用户信息的一致性。然而对于 头部、左侧菜单 通用的模块,以及多个利用之间如何实现资源共享?
咱们尝试采纳 npm 包模式 对头部、左侧菜单 抽离成 npm 包的模式进行治理和应用。然而却带来了 公布效率低下 的问题;
如果须要迭代 npm 包内的逻辑业务,须要先公布 npm 包之后,再每个应用了该 npm 包的利用都更新一次 npm 包版本,再各自构建公布一次,过程繁琐。如果波及到的利用更多的话,破费的人力和精力就更多了。
不仅如此,咱们可能还有上面几个诉求:
- 不同团队间开发同一个利用技术栈不同怎么办?
- 心愿每个团队都能够独立开发,独立部署怎么办?(上述形式尽管能够解决,然而体验不好)
- 我的项目中还须要老的利用代码怎么办?
什么是微前端
在 2016 年,微前端的概念诞生。micro-frontends 中定义 Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently.
翻译成中文为 用来构建可能让 多个团队 独立交付我的项目代码的 古代 web app 技术,策略以及实际办法
。
微前端也是借鉴后端微服务的思维。微前端就是将不同的性能依照不同的纬度拆分成多个子利用。通过主利用来加载这些子利用。
微前端的外围在于 先拆后合。
微前端劣势
- 同步更新
- 增量降级
- 简略、解耦的代码库
- 独立开发、部署
微前端解决方案
- 基座模式:通过搭建基座、配置核心来治理子利用。如基于
single spa
的qiankun
计划。 - 自组织模式:通过约定进行相互调用,但会遇到解决第三方依赖的问题。
- 去核心模式:脱离基座模式,每个利用之间都能够批次分享资源。如基于
webpack5 module federation
实现的EMP 微前端计划
,能够实现多个利用彼此共享资源。
为什么不是 TA
为什么不是 iframe
qiankun 技术圆桌
中有一篇对于微前端 Why Not Iframe 的思考,上面贴一下 iframe
的优缺点
- iframe 提供了浏览器原生的硬隔离计划,不论是款式隔离、js 隔离这类问题通通都能被完满解决。
- url 不同步。浏览器刷新 iframe url 状态失落、后退后退按钮无奈应用。
- UI 不同步,DOM 构造不共享。设想一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时咱们要求这个弹框要浏览器居中显示,还要浏览器 resize 时主动居中..
- 全局上下文齐全隔离,内存变量不共享。iframe 内外零碎的通信、数据同步等需要,主利用的 cookie 要透传到根域名都不同的子利用中实现免登成果。
- 慢。每次子利用进入都是一次浏览器上下文重建、资源从新加载的过程。
因为这些起因,最终大家都舍弃了 iframe 计划。
为什么不是 Web Component
MDN Web Components 由三项次要技术组成,它们能够一起应用来创立封装性能的定制元素,能够在你喜爱的任何中央重用,不用放心代码抵触。
- Custom elements(自定义元素):一组 JavaScript API,容许您定义 custom elements 及其行为,而后能够在您的用户界面中依照须要应用它们。
- Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 离开出现)并管制其关联的性能。通过这种形式,您能够放弃元素的性能公有,这样它们就能够被脚本化和款式化,而不必放心与文档的其余局部发生冲突。
- HTML templates(HTML 模板):
<template> 和 <slot>
元素使您能够编写不在出现页面中显示的标记模板。而后它们能够作为自定义元素构造的根底被屡次重用。
官网提供的示例 web-components-examples。
然而兼容性很差,查看 can i use WebComponents。
为什么不是 ESM
ESM
即ES Module
,是一种前端模块化伎俩。他能做到微前端的几个外围点
- 无技术栈限度:ESM 加载的只是 js 内容,无论哪个框架,最终都要编译成 js,因而,无论哪种框架,ESM 都能加载。
- 利用独自开发:ESM 只是 js 的一种标准,不会影响利用的开发模式。
- 多利用整合:只有将微利用以 ESM 的形式裸露进去,就能失常加载。
- 近程加载模块: ESM 可能间接申请 cdn 资源,这是它与生俱来的能力。
然而惋惜的是兼容性不好,查看 can i use import。
SingleSpa
查看 single-spa
配置文件 rollup.config.js 可得悉,应用了 rollup
做打包工具,并采纳的 system
模块标准做输入。
感兴趣可查看对 @careteen/rollup 的繁难实现。
那咱们就很有必要先介绍下 SystemJS
的相干常识。
SystemJS 应用
SystemJS
是一个通用的模块加载器,它能在浏览器上动静加载模块。微前端的外围就是加载微利用,咱们将利用打包成模块,在浏览器中通过 SystemJS
来加载模块。
下方示例寄存在 @careteen/micro-fe/system.js,感兴趣能够返回调试。
新建我的项目并配置
装置依赖
$ mkdir system.js
$ yarn init
$ yarn add webpack webpack-cli webpack-dev-server babel-loader @babel/core @babel/preset-env @babel/preset-react html-webpack-plugin -D
$ yarn add react react-dom
配置 webpack.config.js
文件,采纳 system.js
模块标准作为output.libraryTarget
,并不打包react/react-dom
。
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = (env) => {
return {
mode: "development",
output: {
filename: "index.js",
path: path.resolve(__dirname, "dest"),
libraryTarget: env.production ? "system" : "",
},
module: {
rules: [
{
test: /\.js$/,
use: {loader: "babel-loader"},
exclude: /node_modules/,
},
],
},
plugins: [
!env.production &&
new HtmlWebpackPlugin({template: "./public/index.html",}),
].filter(Boolean),
externals: env.production ? ["react", "react-dom"] : [],};
};
配置 .babelrc
文件
{
"presets":[
"@babel/preset-env",
"@babel/preset-react"
]
}
配置 package.json
文件
"scripts": {
"dev": "webpack serve",
"build": "webpack --env production"
},
编写 js、html 代码
新建 src/index.js
入口文件
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(
<h1>hello system.js</h1>,
document.getElementById('root')
)
新建 public/index.html
文件,以 cdn 的模式引入 system.js
,并且将react/react-dom
作为前置依赖配置到 systemjs-importmap
中。
<!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>system.js demo</title>
</head>
<body>
<script type="systemjs-importmap">
{
"imports": {
"react": "https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.production.min.js",
"react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"
}
}
</script>
<div id="root"></div>
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.10.1/system.min.js"></script>
<script>
System.import("./index.js").then(() => {});
</script>
</body>
</html>
而后命令行运行
$ npm run dev # or build
关上浏览器拜访,可失常显示文本。
查看 dest 目录
察看 dest/index.js
文件,可发现通过 system.js
打包后会依据 webpack
配置而先 register
预加载 react/react-dom
而后返回 execute
执行函数。
System.register(["react","react-dom"], function(__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {
return {
setters: [// ...],
execute: function() {// ...}
};
});
并且咱们在应用时是通过 System.import("./index.js").then(() => {});
这个模式。
基于上述察看,咱们理解到 system.js
两个外围api
- System.import:加载入口文件
- System.register:预加载
上面将做个繁难实现。
SystemJS 原理
下方实现原理代码寄存在 @careteen/micro-fe/system.js/dest/index.html,感兴趣能够返回调试。
首先提供构造函数,并将 window
的属性存一份,目标是查找对 window
属性进行的批改。
function SystemJS() {}
let set = new Set();
const saveGlobalPro = () => {for (let p in window) {set.add(p);
}
};
const getGlobalLastPro = () => {
let result;
for (let p in window) {if (set.has(p)) continue;
result = window[p];
result.default = result;
}
return result;
};
saveGlobalPro();
外围办法 -register
实现 register
办法,次要是对前置依赖做存储,不便前面加载文件时取值加载。
let lastRegister;
SystemJS.prototype.register = function (deps, declare) {
// 将本次注册的依赖和申明 裸露到内部
lastRegister = [deps, declare];
};
应用 JSONP
提供 load
创立 script
脚本函数。
function load(id) {return new Promise((resolve, reject) => {const script = document.createElement("script");
script.src = id;
script.async = true;
document.head.appendChild(script);
script.addEventListener("load", function () {
// 加载后会拿到 依赖 和 回调
let _lastRegister = lastRegister;
lastRegister = undefined;
if (!_lastRegister) {resolve([[], function () {}]); // 示意没有其余依赖了
}
resolve(_lastRegister);
});
});
}
外围办法 -import
实现 import
办法,传参为 id
即入口文件,加载入口文件后,解析查看 dest 目录中的setters 和 execute
。
因为 react
和 react-dom
会给全局削减属性 window.React
,window.ReactDOM
属性,所以能够通过 getGlobalLastPro
获取到这些新增的依赖库。
SystemJS.prototype.import = function (id) {return new Promise((resolve, reject) => {const lastSepIndex = window.location.href.lastIndexOf("/");
const baseURL = location.href.slice(0, lastSepIndex + 1);
if (id.startsWith("./")) {resolve(baseURL + id.slice(2));
}
}).then((id) => {
let exec;
// 能够实现 system 模块递归加载
return load(id)
.then((registerition) => {let declared = registerition[1](() => {});
// 加载 react 和 react-dom 加载结束后调用 setters
// 调用执行函数
exec = declared.execute;
return [registerition[0], declared.setters];
// {setters:[],execute:function(){}}
})
.then((info) => {
return Promise.all(info[0].map((dep, i) => {var setter = info[1][i];
// react 和 react-dom 会给全局削减属性 window.React,window.ReactDOM
return load(dep).then((r) => {// console.log(r);
let p = getGlobalLastPro();
// 这里如何获取 react 和 react-dom?
setter(p); // 传入加载后的文件
});
})
);
})
.then(() => {exec();
});
});
};
上述简略实现了 system.js
的外围办法,可正文掉 cdn 引入模式,应用本人实现的进行测试,可失常展现。
let System = new SystemJS();
System.import("./index.js").then(() => {});
SingleSpa 应用
下方示例代码寄存在 @careteen/micro-fe/single-spa,感兴趣能够返回调试。
装置脚手架,不便疾速创立利用。
$ npm i -g create-single-spa
创立基座
$ create-single-spa base
在 src/careteen-root-config.js
文件中新增下体面利用配置
registerApplication({
name: "@careteen/vue", // 利用名字
app: () => System.import("@careteen/vue"), // 加载的利用
activeWhen: ["/vue"], // 门路匹配
customProps: {name: 'single-spa-base',},
});
registerApplication({
name: "@careteen/react",
app: () => System.import("@careteen/react"),
activeWhen: ["/react"],
customProps: {name: 'single-spa-base',},
});
start({urlRerouteOnly: true, // 全副应用 SingleSpa 中的 reroute 治理路由});
提供 registerApplication
办法注册并加载利用,start
办法启动利用
查看 src/index.ejs
文件
<script type="systemjs-importmap">
{
"imports": {"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js"}
}
</script>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js" as="script">
<script>
System.import('@careteen/root-config');
</script>
可得悉须要 single-spa
作为前置依赖,并且实现 preload
预加载,最初加载基座利用System.import('@careteen/root-config');
。
上面持续应用脚手架创立子利用
创立 vue 我的项目
$ create-single-spa slave-vue
此处抉择 vue3.x
版本。新建 vue.config.js
配置文件,配置开发端口号为3000
module.exports = {
devServer: {port: 3000,},
}
还须要批改src/router/index.js
const router = createRouter({history: createWebHistory('/vue'),
routes,
});
在基座中配置
<script type="systemjs-importmap">
{
"imports": {
"@careteen/root-config": "//localhost:9000/careteen-root-config.js",
"@careteen/slave-vue": "//localhost:3000/js/app.js"
}
}
</script>
创立 react 我的项目
$ create-single-spa slave-react
批改开发端口号为4000
"scripts": {"start": "webpack serve --port 4000",}
创立上面路由
import {BrowserRouter as Router, Route, Link, Switch, Redirect} from 'react-router-dom'
import Home from './components/Home.js'
import About from './components/About.js'
export default function Root(props) {
return <Router basename="/react">
<div>
<Link to="/">Home React</Link>
<Link to="/about">About React</Link>
</div>
<Switch>
<Route path="/" exact={true} component={Home}></Route>
<Route path="/about" component={About}></Route>
<Redirect to="/"></Redirect>
</Switch>
</Router>
}
在基座中配置 react/react-dom
以及@careteen/react
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
"react":"https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.production.min.js",
"react-dom":"https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"
}
}
</script>
<script type="systemjs-importmap">
{
"imports": {
"@careteen/root-config": "//localhost:9000/careteen-root-config.js",
"@careteen/slave-vue": "//localhost:3000/js/app.js",
"@careteen/react": "//localhost:4000/careteen-react.js"
}
}
</script>
启动我的项目
$ cd base && yarn start
$ cd ../slave-vue && yarn start
$ cd ../slave-react && yarn start
浏览器关上 http://localhost:9000/
手动输出 http://localhost:9000/vue/ 并能够切换路由
手动输出 http://localhost:9000/react/ 并能够切换路由
SingleSpa 原理
下方原理实现代码寄存在 @careteen/micro-fe/single-spa/single-spa,感兴趣能够返回调试。
从 single spa
应用中,能够发现次要是两个办法 registerApplication
和start
。
先新建 single-spa/example/index.html
文件,应用 cdn 的模式应用single-spa
原生 Demo
<!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>my single spa demo</title>
<script src="https://cdn.bootcdn.net/ajax/libs/single-
spa/5.9.3/umd/single-spa.min.js"></script>
</head>
<body>
<!-- 切换导航加载不同的利用 -->
<a href="#/a">a 利用 </a>
<a href="#/b">b 利用 </a>
<!-- 源码中 single-spa 是用 rollup 打包的 -->
<script type="module">
const {registerApplication, start} = singleSpa;
// 接入协定
let app1 = {
bootstrap: [
// 这货色只执行一次,加载完利用,不须要每次都反复加载
async (customProps) => {
// koa 中的中间件 vueRouter4 中间件
console.log("app1 启动~1", customProps);
},
async () => {console.log("app1 启动~2");
},
],
mount: async (customProps) => {console.log("app1 mount");
},
unmount: async (customProps) => {console.log("app1 unmount");
},
};
let app2 = {
bootstrap: [async () => {console.log("app2 启动~1");
},
async () => {console.log("app2 启动~2");
},
],
mount: async () => {console.log("app2 mount");
},
unmount: async () => {console.log("app2 unmount");
},
};
const customProps = {name: "single spa"};
// 注册微利用
registerApplication(
"app1", // 这个名字能够用于过滤避免加载反复的利用
async () => {return app1;},
(location) => location.hash == "#/a",
customProps
);
registerApplication(
"app2", // 这个名字能够用于过滤避免加载反复的利用
async () => {return app2;},
(location) => location.hash == "#/b",
customProps
);
start();
</script>
</body>
</html>
对 package.json
做如下配置
"scripts": {"dev": "http-server -p 5000"}
而后运行
$ cd single-spa
$ yarn
$ yarn dev
关上 http://127.0.0.1:5000/example 点击切换 a b 利用查看打印后果
外围办法 -registerApplication
接着去实现外围办法
新建single-spa/src/single-spa.js
export {registerApplication} from './applications/apps.js';
export {start} from './start.js';
新建single-spa/src/applications/app.js
import {reroute} from "../navigation/reroute.js";
import {NOT_LOADED} from "./app.helpers.js";
export const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
const registeration = {
name: appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED,
};
apps.push(registeration);
reroute();}
保护数组 apps
寄存所有的子利用,每个子利用须要的传参如下
- appName: 利用名称
- loadApp: 利用的加载函数 此函数会返回 bootstrap mount unmount
- activeWhen: 以后什么时候激活 location => location.hash == ‘#/a’
- customProps: 用户的自定义参数
- status: 利用状态
将子利用保留到 apps
中,后续能够在数组里晒选须要的 app 是加载 还是 卸载 还是挂载
还须要调用 reroute
,重写门路,后续切换路由要再次做这些事,这也是single-spa
的外围。
状态机
NOT_LOADED(未加载)
为利用的默认状态,那利用还存在哪些状态呢?
新建 single-spa/src/applications/app.helpers.js
寄存所有状态
export const NOT_LOADED = "NOT_LOADED"; // 利用默认状态是未加载状态
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 正在加载文件资源
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 此时没有调用 bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 正在启动中, 此时 bootstrap 调用结束后,须要示意成没有挂载
export const NOT_MOUNTED = "NOT_MOUNTED"; // 调用了 mount 办法
export const MOUNTED = "MOUNTED"; // 示意挂载胜利
export const UNMOUNTING = "UNMOUNTING"; // 卸载中,卸载后回到 NOT_MOUNTED
// 以后利用是否被挂载了 状态是不是 MOUNTED
export function isActive(app) {return app.status == MOUNTED;}
// 门路匹配到才会加载利用
export function shouldBeActive(app) {
// 如果返回的是 true 就要进行加载
return app.activeWhen(window.location);
}
于此同时还是提供几个办法判断以后利用所处状态。
而后再提供依据 app
状态对所有注册的 app 进行分类
// `single-spa/src/applications/app.helpers.js`
export function getAppChanges() {
// 拿不到所有 app 的?const appsToLoad = []; // 须要加载的列表
const appsToMount = []; // 须要挂载的列表
const appsToUnmount = []; // 须要移除的列表
apps.forEach((app) => {const appShouldBeActive = shouldBeActive(app); // 看一下这个 app 是否要加载
switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {appsToLoad.push(app); // 没有被加载就是要去加载的 app,如果正在加载资源 阐明也没有加载过
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (appShouldBeActive) {appsToMount.push(app); // 没启动柜,并且没挂载过 阐明等会要挂载他
}
break;
case MOUNTED:
if (!appShouldBeActive) {appsToUnmount.push(app); // 正在挂载中然而门路不匹配了 就是要卸载的
}
default:
break;
}
});
return {appsToLoad, appsToMount, appsToUnmount};
}
而后开始实现 single-spa/src/navigation/reroute.js
的外围办法
import {getAppChanges,} from "../applications/app.helpers.js";
export function reroute() {
// 所有的外围逻辑都在这里
const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
return loadApps();
function loadApps() {
// 获取所有须要加载的 app, 调用加载逻辑
const loadPromises = appsToLoad.map(toLoadPromise); // 调用加载逻辑
return Promise.all(loadPromises)
}
}
于此同时再提供工具办法,不便解决传参进来的生命周期钩子是数组的场景
function flattenFnArray(fns) {fns = Array.isArray(fns) ? fns : [fns];
return function (customProps) {
return fns.reduce((resultPromise, fn) => resultPromise.then(() => fn(customProps),
Promise.resolve());
};
}
实现原理相似于koa 中的中间件
,将多个 promise 组合成一个 promise 链。
再提供 toLoadPromise
,只有当子利用是NOT_LOADED
的时候才须要加载,并应用flattenFnArray
将各个生命周期进行解决
function toLoadPromise(app) {return Promise.resolve().then(() => {if (app.status !== NOT_LOADED) {return app;}
app.status = LOADING_SOURCE_CODE;
return app.loadApp().then((val) => {let { bootstrap, mount, unmount} = val; // 获取利用的接入协定,子利用裸露的办法
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(bootstrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
return app;
});
});
}
外围办法 -start
而后实现single-spa/src/start.js
import {reroute} from "./navigation/reroute.js";
export let started = false;
export function start() {
started = true; // 开始启动了
reroute();}
外围逻辑 -reroute
接着须要对 reroute
办法进行欠缺,将不须要的组件全副卸载,将须要加载的组件去 加载 -> 启动 -> 挂载
,如果曾经加载结束,那么间接启动和挂载。
export function reroute() {const { appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
if (started) { // 启动利用
return performAppChanges();}
function performAppChanges() {appsToUnmount.map(toUnmountPromise);
appsToLoad.map(app => toLoadPromise(app).then((app) => tryBootstrapAndMount(app)))
appsToMount.map(appToMount => tryBootstrapAndMount(appToMount))
}
}
其外围就是 卸载须要卸载的利用 -> 加载利用 -> 启动利用 -> 挂载利用
而后提供toUnmountPromise
,标记成正在卸载,调用卸载逻辑,并且标记成 未挂载。
function toUnmountPromise(app) {return Promise.resolve().then(() => {
// 如果不是挂载状态 间接跳出
if (app.status !== MOUNTED) {return app;}
app.status = UNMOUNTING;
return app.unmount(app.customProps).then(() => {app.status = NOT_MOUNTED;});
});
}
以及 tryBootstrapAndMount
,提供a/b
利用的切换
// a -> b b->a a->b
function tryBootstrapAndMount(app, unmountPromises) {return Promise.resolve().then(() => {if (shouldBeActive(app)) {return toBootStrapPromise(app).then((app) =>
unmountPromises.then(() => {capturedEventListeners.hashchange.forEach((item) => item());
return toMountPromise(app);
})
);
}
});
}
实现 toBootStrapPromise
启动利用
function toBootStrapPromise(app) {return Promise.resolve().then(() => {if (app.status !== NOT_BOOTSTRAPPED) {return app;}
app.status = BOOTSTRAPPING;
return app.bootstrap(app.customProps).then(() => {
app.status = NOT_MOUNTED;
return app;
});
});
}
实现 toMountPromise
加载利用
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;
});
});
}
上述实现了子利用各个状态的切换逻辑,上面还须要将路由进行重写。
新建single-spa/src/navigation/navigation-events.js
,监听 hashchange 和 popstate,门路变动时从新初始化利用。
import {reroute} from "./reroute.js";
function urlRoute() {reroute();
}
window.addEventListener("hashchange", urlRoute);
window.addEventListener("popstate", urlRoute);
须要对浏览器的事件进行拦挡,其实现形式和 vue-router
相似,应用 AOP
的思维实现的。
因为子利用外面也可能会有路由零碎,须要先加载父利用的事件,再去调用子利用。
const routerEventsListeningTo = ["hashchange", "popstate"];
export const capturedEventListeners = {hashchange: [],
popstate: [],};
const originalAddEventListener = window.addEventListener;
const originalRemoveEventLister = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
if (routerEventsListeningTo.includes(eventName) &&
!capturedEventListeners[eventName].some((l) => fn == l)
) {return capturedEventListeners[eventName].push(fn);
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, fn) {if (routerEventsListeningTo.includes(eventName)) {return (capturedEventListeners[eventName] = capturedEventListeners[eventName].filter((l) => fn != l));
}
return originalRemoveEventLister.apply(this, arguments);
};
须要对跳转办法进行拦挡,例如 vue-router 外部会通过 pushState() 不改门路改状态,所以还是要解决下。如果门路不一样,也须要重启利用。
function patchedUpdateState(updateState, methodName) {return function() {
const urlBefore = window.location.href;
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;
if (urlBefore !== urlAfter) {window.dispatchEvent(new PopStateEvent("popstate"));
}
return result;
}
}
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState')
提供触发事件的办法
export function callCapturedEventListeners(eventArguments) { // 触发捕捉的事件
if (eventArguments) {const eventType = eventArguments[0].type;
// 触发缓存中的办法
if (routingEventsListeningTo.includes(eventType)) {capturedEventListeners[eventType].forEach(listener => {listener.apply(this, eventArguments);
})
}
}
}
欠缺外围逻辑 -reroute
改变 reroute
逻辑,启动实现须要调用callAllEventListeners
,利用卸载结束也须要调用callAllEventListeners
。
export function reroute() {const { appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
if (started) {return performAppChanges();
}
return loadApps();
function loadApps() {const loadPromises = appsToLoad.map(toLoadPromise);
return Promise.all(loadPromises).then(callAllEventListeners); // ++
}
function performAppChanges() {let unmountPromises = Promise.all(appsToUnmount.map(toUnmountPromise)).then(callAllEventListeners); // ++
appsToLoad.map((app) =>
toLoadPromise(app).then((app) =>
tryBootstrapAndMount(app, unmountPromises)
)
);
appsToMount.map((app) => tryBootstrapAndMount(app, unmountPromises));
}
}
上述代码曾经实现了基本功能
$ cd single-spa
$ yarn
$ yarn dev
关上 http://127.0.0.1:5000/example 点击切换 a b 利用查看打印后果,体现同原生 Demo 的后果。
SingleSpa 小结
single-spa 提供了主利用作为基座,通过路由匹配加载不同子利用的模式。具备如下长处
- 技术栈无关:独立开发、独立部署、增量降级、独立运行时
- 提供生命周期概念:负责调度子利用的生命周期,挟持 url 变动事件和函数,url 变动时匹配对应子利用,并执行生命周期流程
然而依然存在一些问题
- 款式隔离 :子利用款式可能影响主利用,须要通过相似于
BEM
约定式计划解决。 - JS 隔离 :奴才利用共用
DOM、BOM
API,例如在window
上赋值同一个同名变量,将相互影响,也须要有隔离计划。
qiankun
qiankun 的灵感来自并基于single-spa
,有以下几个特点。
- 简略: 任意 js 框架均可应用。微利用接入像应用接入一个 iframe 零碎一样简略,但理论不是 iframe。
- 齐备: 简直蕴含所有构建微前端零碎时所须要的根本能力,如 款式隔离、js 沙箱、预加载等。
- 生产可用: 已在蚂蚁内外禁受过足够大量的线上零碎的考验及打磨,健壮性值得信 赖。
在 single-spa
的根底上,qiankun
还实现了如下个性
- 应用
import-html-entry
取代system.js
加载子利用 - 提供多种款式隔离计划
- 提供多种 JS 隔离计划
qiankun 应用
下方示例代码寄存在 @careteen/micro-fe/qiankun,感兴趣能够返回调试。
上面实例采纳 react
作为基座,并提供一个 vue
子利用和一个 react
子利用
提供基座
$ create-react-app base
$ yarn add react-router-dom qiankun
提供 /vue 和 /react
路由
import {BrowserRouter as Router, Link} from "react-router-dom";
function App() {
return (
<div className="App">
<Router>
<Link to="/vue">vue 利用 </Link>
<Link to="/react">react 利用 </Link>
</Router>
<div id="container"></div>
</div>
);
}
export default App;
在 src/registerApps.js
中配置两个子利用入口
import {registerMicroApps, start} from "qiankun";
const loader = (loading) => {console.log(loading);
};
registerMicroApps(
[
{
name: "slave-vue",
entry: "//localhost:20000",
container: "#container",
activeRule: "/vue",
loader,
},
{
name: "slave-react",
entry: "//localhost:30000",
container: "#container",
activeRule: "/react",
loader,
},
],
{beforeLoad: () => {console.log("加载前");
},
beforeMount: () => {console.log("挂载前");
},
afterMount: () => {console.log("挂载后");
},
beforeUnmount: () => {console.log("销毁前");
},
afterUnmount: () => {console.log("销毁后");
},
}
);
start({
sandbox: {
// experimentalStyleIsolation:true
strictStyleIsolation: true,
},
});
运行命令,关上 http://localhost:3000/ 拜访,上面将持续
yarn start
提供 Vue 子利用
$ vue create slave-vue
新建 vue.config.js
配置文件,设置 publicPath
保障子利用动态资源都是像 20000 端口上发送的,设置 headers
跨域保障父利用能够拜访到。
qiankun
没有应用 single-spa
所应用 system.js
模块标准,而打包成 umd
模式,在 qiankun
外部应用了 fetch
去加载子利用的文件内容。
module.exports = {
publicPath: '//localhost:20000',
devServer: {
port: 20000,
headers:{'Access-Control-Allow-Origin': '*'}
},
configureWebpack: {
output: {
libraryTarget: 'umd',
library: 'slave-vue'
}
}
}
应用 qiankun
和single-spa
相似,须要在入口文件依照约定导出特定的生命周期函数bootstrap、mount、unmount
。
并且提供 独立拜访 和接入到主利用 两种场景。次要是借助 window.__POWERED_BY_QIANKUN__
字段判断是否在 qiankun 主利用下。
import {createApp} from 'vue';
import {createRouter, createWebHistory} from 'vue-router';
import App from './App.vue';
import routes from './router';
let history;
let router;
let app;
function render(props = {}) {history = createWebHistory('/vue');
router = createRouter({
history,
routes
});
app = createApp(App);
let {container} = props;
app.use(router).mount(container ? container.querySelector('#app') : '#app')
}
if (!window.__POWERED_BY_QIANKUN__) { // 独立运行本人
render();}
export async function bootstrap() {console.log('vue3 app bootstraped');
}
export async function mount(props) {console.log('vue3 app mount',);
render(props)
}
export async function unmount() {console.log('vue3 app unmount');
history = null;
app = null;
router = null;
}
运行命令,关上 http://localhost:20000/ 可独立拜访
$ yarn serve
提供 React 子利用
$ create-react-app slave-react
$ yarn add @rescripts/cli -D
借助 @rescripts/cli
改 react 的配置.rescriptsrc.js
输入和 vue 我的项目一样也采纳 umd
模块标准。
module.exports = {webpack:(config)=>{
config.output.library = 'slave-react';
config.output.libraryTarget = 'umd';
config.output.publicPath = '//localhost:30000/';
return config;
},
devServer:(config)=>{
config.headers = {'Access-Control-Allow-Origin': '*'};
return config;
}
}
而后在 .env
中将端口号进行批改
PORT=30000
WDS_SOCKET_PORT=30000
同 vue 子利用配置
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render(props = {}) {let { container} = props;
ReactDOM.render(<App />,
container ? container.querySelector('#root') : document.getElementById('root')
);
}
if (!window.__POWERED_BY_QIANKUN__) {render();
}
export async function bootstrap() {}
export async function mount(props) {render(props)
}
export async function unmount(props) {const { container} = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.getElementById('root'))
}
scripts
脚本须要做批改
"scripts": {
"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test",
"eject": "rescripts eject"
},
运行命令,关上 http://localhost:30000/ 可独立拜访
$ yarn start
查看最终成果
在主利用中配置款式隔离
start({
sandbox: {
// experimentalStyleIsolation:true
strictStyleIsolation: true,
},
});
浏览器关上 http://localhost:3000/ 点击vue 利用
点击react 利用
,可察看父子利用款式互不影响。
qiankun 原理
通过应用 qiankun
可察看到其 API
和single-spa
差不多。上面将大抵理解下 qiankun
的实现原理。
剖析代码在 @careteen/qiankun,外面有大量正文。
registerMicroApps
从入口注册办法 registerMicroApps
开始。
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>, // 须要注册的利用
lifeCycles?: FrameworkLifeCycles<T>, // 对应的生命周期
) {
// 过滤注册反复的利用
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
// 将须要注册的新利用,循环顺次注册
unregisteredApps.forEach((app) => {const { name, activeRule, loader = noop, props, ...appConfig} = app;
// 理论还是调用 single-spa 的注册函数
registerApplication({
name,
app: async () => {loader(true); // 设置 loading
await frameworkStartedDefer.promise; // 期待 start 办法被调用
const {mount, ...otherMicroAppConfigs} = (
// 加载利用,获取生命周期钩子
await loadApp({name, props, ...appConfig}, frameworkConfiguration, lifeCycles)
)();
// 调用 mount
return {mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
理论还是调用 single-spa
的注册函数registerApplication
,只不过多做了过滤注册反复的利用。
start
export function start(opts: FrameworkConfiguration = {}) {
// prefetch 是否反对预加载
// singular 是否反对单例模式
// sandbox 是否反对沙箱
frameworkConfiguration = {prefetch: true, singular: true, sandbox: true, ...opts};
const {
prefetch,
sandbox,
singular,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;
if (prefetch) { // 预加载策略
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 开启沙箱
if (sandbox) {
// 如果不反对 Proxy 则降级到快照沙箱 loose 示意应用快照沙箱
if (!window.Proxy) {console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
frameworkConfiguration.sandbox = typeof sandbox === 'object' ? {...sandbox, loose: true} : {loose: true};
// Proxy 下若为非单例模式 则会报错
if (!singular) {
console.warn('[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
);
}
}
}
// 启动利用,最终理论调用 single spa 的 start 办法
startSingleSpa({urlRerouteOnly});
started = true;
// 启动后,将 promise 状态改为胜利态
frameworkStartedDefer.resolve();}
qiankun
提供 预加载、单例模式、开启沙箱 配置。在开启沙箱时,会优先应用 Proxy
代理沙箱,如果浏览器不反对,则降级应用 Snapshot
快照沙箱。
在应用代理沙箱时,如果浏览器不反对 Proxy
且开启了单例模式,则会报错,因为在快照沙箱下应用单例模式会存在问题。具体上面会提到
prefetch
export function doPrefetchStrategy(apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts,
) {const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));
if (Array.isArray(prefetchStrategy)) {
// 加载第一个利用
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
}
// ...
}
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
// 监听第一个利用的
window.addEventListener('single-spa:first-mount', function listener() {
// 过滤所有没加载的 app
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) === NOT_LOADED);
if (process.env.NODE_ENV === 'development') {const mountedApps = getMountedApps();
console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
}
// 没加载的 app 全副须要预加载
notLoadedApps.forEach(({entry}) => prefetch(entry, opts));
// 移除监听的事件
window.removeEventListener('single-spa:first-mount', listener);
});
}
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}
// 应用 requestIdleCallback 在浏览器闲暇工夫进行预加载
requestIdleCallback(async () => {
// 应用 import-html-entry 进行加载资源
// 其外部实现 是通过 fetch 去加载资源
const {getExternalScripts, getExternalStyleSheets} = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
监听第一个加载的利用:过滤所有没加载的 app,将其预加载。
应用 requestIdleCallback
在浏览器闲暇工夫进行预加载;应用 import-html-entry
进行加载资源,其外部实现 是通过 fetch
去加载资源,取代 single-spa
采纳的 system.js
模块标准加载资源。
requestIdleCallback
在react fiber 架构
中有应用到,感兴趣的可返回浏览器任务调度策略和渲染流程查看。
loadApp
当执行 start
办法后,会去执行 registerApplication
中的 loadApp
加载子利用。
其实现代码较多,能够返回 qiankun/loader.ts/loadApp 查看实现,有正文表明大略流程。总结下来次要做了如下几件事
- 通过
importEntry
办法拉取子利用 - 在拉取的模板外面包一层
div
, 减少css
款式隔离,提供shadowdom
、scopedCSS
两种形式 - 将模板进行挂载
- 创立
js
沙箱 , 取得沙箱开启和沙箱敞开办法 - 合并出
beforeUnmount
、afterUnmount
、afterMount
、beforeMount
、beforeLoad
办法。减少qiankun
标识 - 顺次调用
beforeLoad
办法 - 在沙箱中执行脚本,获取子利用的生命周期
bootstrap
、mount
、unmount
、update
-
格式化子利用的
mount
办法和unmount
办法。- 在
mount
执行前挂载沙箱、顺次执行beforeMount
,之后调用mount
办法,将 全局通信办法传入。mount 办法执行结束后执行afterMount
unmount
办法会优先执行beforeUnmount
钩子,之后开始卸载
- 在
- 削减一个
update
办法
createSandboxContainer
接下来是如何实现创立沙箱
创立沙箱会先判断浏览器是否反对 Proxy
,如果反对并不是useLooseSandbox
模式,则应用 代理沙箱实现 ,如果不反对则采纳 快照沙箱
Proxy Sandbox
class ProxySandbox {constructor() {
const rawWindow = window
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {set(target, p, value) {target[p] = value
return true
},
get(target, p) {return target[p] || rawWindow[p]
},
})
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox()
let sandbox2 = new ProxySandbox()
window.name = '搜狐焦点'
((window) => {
window.name = '智能话机'
console.log(window.name)
})(sandbox1.proxy)
((window) => {
window.name = '识客宝'
console.log(window.name)
})(sandbox2.proxy)
其原理次要是代理原生 window
,在取值时优先从proxy window
上获取,如果没有值再从 实在 window
上获取;在赋值时只改变 proxy window
,进而达到和主利用隔离。这只是繁难实现,qiankun
的 ProxySandbox 实现。
Snapshot Sandbox
[源码实现代码](https://github.com/careteenL/…
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const prop in obj) {
// patch for clearInterval for compatible reason, see #1490
if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {callbackFn(prop);
}
}
}
// ...
active() {
// 记录以后快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {this.windowSnapshot[prop] = window[prop];
});
// 复原之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
次要是对 window
的所有属性进行了一个拍照。存在的问题就是多实例的状况会凌乱,所以在浏览器不反对 Proxy
且设置非单例的状况下,qiankun
会报错。
Style Shadow Dom Sandbox
源码实现代码
当设置 strictStyleIsolation=true
时,会开启 Shadow Dom
款式沙箱。体现如下,会包裹一层shadow dom
,做到真正意义上的款式隔离,但毛病就是子利用想要复用父利用的款式时做不到。
Style Scope Sandbox
源码实现代码
qiankun
也提供设置 experimentalStyleIsolation=true
开启 scope
款式隔离,体现如下,应用 div
包裹子利用,并将子利用的顶级款式加上 子利用名称
前缀进行款式隔离。其中还将标签选择器加上[data-qainkun]="slave-name"
。
父子利用通信形式
源码实现代码
基于公布订阅实现。
-
setGlobalState:更新 store 数据
- 对输出 state 的第一层属性做校验,只有初始化时申明过的第一层(bucket)属性才会被更改
- 批改 store 并触发全局监听
-
onGlobalStateChange:全局依赖监听
- 收集 setState 时所须要触发的依赖
- offGlobalStateChange:登记该利用下的依赖
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
return {onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {if (!(callback instanceof Function)) {console.error('[qiankun] callback must be function!');
return;
}
if (deps[id]) {console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
}
deps[id] = callback;
if (fireImmediately) {const cloneState = cloneDeep(globalState);
callback(cloneState, cloneState);
}
},
setGlobalState(state: Record<string, any> = {}) {if (state === globalState) {console.warn('[qiankun] state has not changed!');
return false;
}
const changeKeys: string[] = [];
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(Object.keys(state).reduce((_globalState, changeKey) => {if (isMaster || _globalState.hasOwnProperty(changeKey)) {changeKeys.push(changeKey);
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
return _globalState;
}, globalState),
);
if (changeKeys.length === 0) {console.warn('[qiankun] state has not changed!');
return false;
}
emitGlobal(globalState, prevGlobalState);
return true;
},
offGlobalStateChange() {delete deps[id];
return true;
},
};
}
qiankun 小结
- 基于
single spa
的下层封装 - 提供
shadow dom
和scope
款式隔离计划 - 解决
proxy sandbox
和snapshot sanbox
js 隔离计划 - 基于
公布订阅
更好的服务于react setState
- 还提供 @umijs/plugin-qiankun 插件能在
umi
利用下更好的接入
总结
除了 single-spa
这种基于底座的微前端解决方案,[webpack5 module federation]()webpack5 的联邦模块也能实现,YY 团队的 EMP 基于此实现了 去核心模式,脱离基座模式,每个利用之间都能够批次分享资源。能够通过这篇文章尝尝鲜,前面再持续钻研。