微前端
- 由一个主入口(基座)和多个子项目组成,基座内动态导入子项目,根据不同情况展示不同内容
- 优点:
- 可以将多个子应用进行拆分,不限技术栈
- 每个子应用都可以单独开发、单独部署
- 存在问题:
- 父子通信、兄弟通信
- 样式隔离、代码隔离、异常隔离
single-spa
- 依赖于SystemJs的一种方案
子应用需暴露bootstrap、mount、unmount三个钩子
(接入协议)
- 缺点:没有沙盒机制
SystemJs
- 一种模块加载规范,可以动态加载模块
SystemJs示例
<!-- public/index.html -->
<div id="root"></div>
// webpack.config.js
const HtmlPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = (env) => {
return {
mode: 'development',
externals: env.production ? ["react", "react-dom"] : [],
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist"),
// 指定生产模式下采用systemjs 模块规范
libraryTarget: env.production ? "system" : "",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [
"@babel/preset-env",
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
],
},
},
},
],
},
plugins: [
// 生产环境下不生成html
!env.production &&
new HtmlPlugin({
template: "./public/index.html",
}),
].filter(Boolean),
devServer: {
port: 8888,
},
};
};
// src/index.js
// react应用
import { createRoot } from "react-dom/client";
const App = () => {
return <div>hello</div>;
};
const root = createRoot(document.querySelector("#root"));
root.render(<App />);
"scripts": {
"dev": "webpack serve",
"build": "webpack --env production"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.21.4",
"@babel/preset-env": "^7.21.4",
"@babel/preset-react": "^7.18.6",
"babel-loader": "^9.1.2",
"html-webpack-plugin": "^5.5.0",
"webpack": "^5.78.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.2"
}
<!-- 基座 -->
<!-- dist/index.html -->
<!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>基座</title>
</head>
<body>
我是基座,下边是子应用
<script type="systemjs-importmap">
{
"imports":{
"react-dom":"https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js",
"react":"https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.development.js"
}
}
</script>
<div id="root"></div>
<script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.14.1/system.min.js"></script>
<script>
System.import('./index.js').then(() => {
console.log('模块加载完毕');
})
</script>
</body>
</html>
- npm run dev 可以单独启动项目
- npm run build 将react应用打包成system模块
- 直接打开dist/index.html即可预览
SystemJs实现
- 分析:
- 导出一个System实例
- 调用import导入文件,返回promise示例
- 加载完成后执行then方法
- 规范
- 公有模块使用
type为systemjs-importmap的script
标签写入,导入前会去按照子模块依赖去找,编写格式如上
边demo所示
- 上述demo编译完成
js的伪代码
,梳理加载流程
System.register(
["react-dom", "react"],
function (webpackDynamicExport, systemContext) {
var webpackExternalModuleReactDom = {};
var webpackExternalModuleReact = {};
return {
setters: [
function (module) {
Object.keys(module).forEach(function (key) {
webpackExternalModuleReactDom[key] = module[key];
});
},
function (module) {
Object.keys(module).forEach(function (key) {
webpackExternalModuleReact[key] = module[key];
});
},
],
execute: function () {
webpackDynamicExport(
(() => {
function webpackRequire(moduleId) {
return "react入口内容";
}
var webpackExports = webpackRequire("./src/index.js");
return webpackExports;
})()
);
},
};
}
);
- import方法执行会拿到上述内容,然后自上而下执行
- 调用
register
方法,先去script模板中查找依赖
(register第一个参数)- 实现SystemJs
// ------------公有数据和方法-----------
// 模块
const newMapUrl = {};
/**
* 解析systemjs-importmap内容
*/
function processScripts() {
Array.from(document.querySelectorAll("script")).forEach((script) => {
if (script.type === "systemjs-importmap") {
// 格式化模板内容,取出模块
const imports = JSON.parse(script.innerHTML).imports;
// 依次放入newMapUrl中
Object.entries(imports).forEach(
([key, value]) => (newMapUrl[key] = value)
);
}
});
}
/**
* 简单处理资源路径
*/
function getId(id) {
if (id.startsWith("./")) {
// 如果是以./开头就拼接前面的内容
const lastSepIndex = location.href.lastIndexOf("/");
const baseURL = location.href.slice(0, lastSepIndex + 1);
return baseURL + id.slice(2);
}
// 否则直接用
return id;
}
// 一个过渡变量,存储register函数的参数
let lastRegister; //[[依赖1、依赖2],fn]
/**
* 加载模块
*/
function load(id) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
// 模板内的模块或这sdk
script.src = newMapUrl[id] || id;
script.async = true;
document.head.appendChild(script);
script.addEventListener("load", () => {
// 加载成功会自动执行register函数,会将register的参数存在lastRegister上
// 拷贝加载模块参数
let _lastRegister = lastRegister;
lastRegister = undefined; //置空
// 返回
resolve(_lastRegister);
});
});
}
// 保存window上的属性(快照)
let set = new Set();
// 将window现有key存在set里
function saveGlobalProperty() {
for (let k in window) {
set.add(k);
}
}
// 先建立一次快照
saveGlobalProperty();
// 拿到刚才添加的(不在快照里的)属性值
function getLastGlobalProperty() {
// 看下window上新增的属性
for (let k in window) {
if (set.has(k)) continue;
set.add(k);
return window[k]; // 我通过script新增的变量
}
}
// 基类------------------
function SystemJs() {}
SystemJs.prototype.import = function (id) {
return Promise.resolve(processScripts())
.then(() => getId(id))
.then((id) => {
let execute;
return load(id)
.then((register) => {
// register参数[[依赖1,依赖2], fn]
// register[1]接收一个函数(目前没啥用),会返回一个object
// setters也是数组,存的是函数,有几个依赖,就对应几个setters[i]
// execute 里就是最终要执行内容
let { setters, execute: exe } = register[1](() => {});
// 存在顶层,后边调用
execute = exe;
// 返回[[依赖1,依赖2],[加载函数1,加载函数2]]
return [register[0], setters];
})
.then(([registeration, setters]) => {
return Promise.all(
registeration.map((dep, i) => {
// 加载依赖
return load(dep).then(() => {
// 拿刚才添加到window上的属性值
const property = getLastGlobalProperty();
// 赋值给依赖函数
setters[i](property);
});
})
);
}).then(()=>{
execute();
});
});
};
SystemJs.prototype.register = function (deps, declare) {
// 存一下
lastRegister = [deps, declare];
};
// 暴露一个变量System给全局
const System = new SystemJs();
完整执行步骤
- 拿到全局System实例
- 执行import方法
格式化script模板内容,并存一下
- 补全路径
- 加载子应用文件
- 子应用加载完成会自上而下执行,通过实例上的register存下参数
[deps数组,fn]
执行fn
,拿到依赖赋值函数数组(简称setDepFn,与deps一一对应
)与最终要执行的渲染函数
- 依次加载deps并执行setDepFn设置依赖变量
- 最终执行渲染函数,渲染页面
使用single-spa
- 官方文档
- 全局安装
npm install create-single-spa -g
// 常用命令
// 创建一个文件名为xxx的项目
create-single-spa xxx
// 创建子应用
single-spa application / parcel
// 创建基座
single-spa root config
- 创建一个基座
- 创建react子应用
- 给react添加路由
- cd react-progect
// pnpm i react-router-dom
// react根组件
import { BrowserRouter, Route, Routes, Link } from 'react-router-dom';
const Home = () => {
return <div>Home</div>
}
const About = () => {
return <div>About</div>
}
export default function Root(props) {
return (
<BrowserRouter basename='react'>
<Link to="/" style={{marginRight:20}}>Home React</Link>
<Link to="/about">About React</Link>
<Routes>
<Route>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Route>
</Routes>
</BrowserRouter>
)
}
// react-progect/webpack.config 指定下端口号
...
merge(defaultConfig, {
devServer: {
port: 3001,
},
// modify the webpack config however you'd like to by adding to this object
})
...
- npm run start:standalone 启动react项目
- 刚才起的名字是cc和reactdemo分别对应配置文件的这里
- 而且react的主入口名称是两者拼接,所以编译包默认也是这种格式
- cd ../root
- npm run start 直接就跑起来了
- 进来是这个页面,框架默认提供了一个示例模块,我们可以删掉
- 目录:
- src/root-root-config.ts为配置文件
- src/index.ejs 为模版文件,内容与上边的没啥区别,只是使用了ejs,可以简单理解为正常开发的index.html
// registerApplication为导入模块方法
// 上图对应的就是这块
// name匹配script里的key,会去加载value
// app 为加载的js,可指定为key,也能使sdk链接
// activeWhen可以为string、array、function,会去匹配路由,为true则加载app
registerApplication({
name: "@single-spa/welcome",
app: () =>
System.import<LifeCycles>(
"https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
),
activeWhen: ["/"],
});
// 不想要默认的,删掉即可
// 给他改成activeWhen: "/welcom",后边demo会用到
// 新增的模块也是在这里加
- 将react应用添加到基座里
// src/root-root-config.ts
registerApplication({
name: "@root/react",
app: () => System.import<LifeCycles>('@root/react'),
activeWhen: (location) => location.pathname.startsWith('/react'),
});
<!-- src/index.ejs -->
<script type="systemjs-importmap">
{
"imports": {
"@root/root-config": "//localhost:9000/root-root-config.js",
"@root/react": "//localhost:3001/cc-reactdemo.js"
}
}
</script>
...
<main>
<!-- 跳转到react应用 -->
<a onClick="go('/react')">去react</a>
<a onClick="go('/welcom')">去welcom</a>
</main>
<script>
// 写个跳转方法
function go(url) {
history.pushState({}, null, url)
}
</script>
- 现在点击react或welecom会加载不同子模块
与直接搭react框架的区别
- 正常情况下,我们通常会通过
createRoot创建一个root实例
,将dom插入到root实例对应的真实dom中
- 但是到目前,我们只写了route,没有插入dom
- react根入口代码
// 默认代码
// cc-reactdemo.tsx
import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";
const lifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: Root,
errorBoundary(err, info, props) {
return null;
},
});
// 接入协议
export const { bootstrap, mount, unmount } = lifecycles;
- 看element元素表就明白了,在加载当前模块的时候,会先创建一个div,然后将当前内容插入到div中去(自动化)
参数传递(父传子)
- 在根里面添加state,通过registerApplication传递即可
- 在组件中通过根组件中通过props获取
// 基座入口
const state = { age: 0 };// 数据
registerApplication({
name: "@root/react",
app: () => System.import<LifeCycles>('@root/react'),
activeWhen: (location) => location.pathname.startsWith('/react'),
customProps: state // 在这传递
});
// 子项目入口
export default function Root(props) {
console.log("root拿到的参数", props);
return (
<BrowserRouter basename='react'>
...
</BrowserRouter>
)
}
- 效果
- 共享数据
- 数据不可变
接入协议
- bootstrap、mount、unmount
- 可以是一个promise,也可以是一个
[]
,如果是[]
内部可以放多个promise实例,会依次执行- 首次加载执行 bootrap
- 加载完成执行 mount
- 销毁实例执行 unmount
- 例子:a、b两个子应用,先展示a,再展示b,再展示a
<script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script>
<body>
<a onclick="go('#/a')">a应用</a>
<a onclick="go('#/b')">b应用</a>
<script>
function go(url) { // 用户调用pushState replaceState 此方法不会触发逻辑reroute
history.pushState({}, null, url)
}
</script>
</body>
<script>
let { registerApplication, start } = singleSpa
let app1 = {
bootstrap: [
async () => console.log('a bootstrap1'),
async () => console.log('a bootstrap2')
],
mount: [
async (props) => {
// new Vue().$mount()...
console.log('a mount1', props)
},
async () => {
// new Vue().$mount()...
console.log('a mount2')
}
],
unmount: async (props) => {
console.log('a unmount')
}
}
let app2 = {
bootstrap: async () => console.log('b bootstrap1'),
mount: [
async () => {
// new Vue().$mount()...
return new Promise((resolve, reejct) => {
setTimeout(() => {
console.log('b mount')
resolve()
}, 1000)
})
}
],
unmount: async () => {
console.log('b unmount')
}
}
// 当路径是#/a 的时候就加载 a应用
// 所谓的注册应用 就是看一下路径是否匹配,如果匹配则“加载”对应的应用
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>
qiankun
- 官方文档
- 拥有沙盒机制
- 拥有父子通信
- 更加轻量,接入方便
- 区别下:
create-single-spa是脚手架,qiankun是第三方包
- 子应用主入口需要导出接入协议: bootstrap、mount、unmount
- 钩子执行需要返回promise实例,可以直接使用
async
也可return Promise实例
使用
- 官网已经有很详细的教程了
- 我们可以根据项目进行改造
npm i create-react-app -g 安装react脚手架
create-react-app root 生成 (基座react) 样板代码
create-react-app son1 生成 (微前端1react) 样板代码
create-react-app son2 生成 (微前端2react) 样板代码
cd root
npm i react-router-dom qiankun -S
// 后边需要改一些配置,但yarn eject 会把整个包解出来,比较丑,所以使用rescripts/cli来merge配置
cd son1 && npm i @rescripts/cli --force 安装依赖配置
cd son2 && npm i @rescripts/cli --force
// 把两个子的package.json中的scripts改成如下格式
"start": "rescripts start",
"build": "rescripts build",
- 给son1和son2添加配置文件
// .rescriptsrc.js
// rescripts会读取此文件
module.exports = {
webpack: (config) => {
config.output.libraryTarget = "umd";
// output名字
config.output.library = "m-react"; // 打包的格式是umd格式
return config;
},
devServer: (config) => {
config.open = false;
config.headers = {
"Access-control-Allow-Origin": "*",
};
return config;
},
};
// .env
// 环境变量配置文件,设置默认端口与websocket端口
// 两个son的.env区别开来就行了,比如另一个8888
PORT=40000
WDS_SOCKET_PORT=40000
- 现在在两个son里分别执行
npm run start 就可以单独启动子项目了
- 进入基座(root)中,
npm run start 即可启动基座
- 目前只是react默认页面,没有引入子项目
- 导入子项目
- 在
src
目录下新建目录registerApps.js
- 在index.js中导入即可
// registerApps.js
import { registerMicroApps, start, initGlobalState } from "qiankun";
const loader = (loading) => {
console.log("加载状态", loading);
};
// 状态
const actions = initGlobalState({
name: "xxx",
age: 30,
});
// 状态改变执行此函数newVal为新值,oldVal为旧值
actions.onGlobalStateChange((newVal, oldVal) => {
console.log("parent", newVal, oldVal);
});
registerMicroApps(
[
{
name: "son1",// 不重复即可
// son1启动的入口是40000端口,回去/默认会加载index.html
entry: "//localhost:40000/",
activeRule: "/react1", // 当路径是 /react1的时候启动
container: "#container", // 应用挂载的位置
loader,
props: { a: 1, util: {} }, // 父传子,会合并到子的props种
},
{
name: "son2",
entry: "//localhost:8888", // 默认react启动的入口是10000端口
activeRule: "/react2", // 当路径是 /react2的时候启动
container: "#container", // 应用挂载的位置
loader,
props: { a: 1, util: {} },// 父传子,会合并到子的props种
},
],
{
beforeLoad() {
console.log("before load");
},
beforeMount() {
console.log("before mount");
},
afterMount() {
console.log("after mount");
},
beforeUnmount() {
console.log("before unmount");
},
afterUnmount() {
console.log("after unmount");
},
}
);
start();// 启动
- 现在还缺少子项目的接入协议
- 加一下
- 分别给
son1/src/index.js
和son2/src/index.js
加下
...
export async function bootstrap(props) {
console.log("bootstrap", props);
}
export async function mount(props) {
console.log("mount", props);
}
export async function unmount(props) {
console.log("unmount", props);
}
- 目前项目可以整体运行了
- 基座默认效果
- 进入son2效果
- 可以看到两个问题:
- 加载son2把基座的导航栏
儿子1 儿子2
给搞没了- 中间的图片加载异常了
资源加载问题
基座地址为localhost:3001
,子1项目为localhost:8888
,而目前我们是在3001中加载子1,所以相对地址为基座地址,浏览器自动补全后就找不到资源了
- 解决办法:
- qiankun默认会导出一个环境变量,
__POWERED_BY_QIANKUN__
如果为true就代表在基座中运行,那么我们给webpack添加默认路径即可- 默认路径qiankun也给了一个变量
__INJECTED_PUBLIC_PATH_BY_QIANKUN__
- 将这个逻辑放在一个js中,在入口文件引入即可
放在所有import最上边
// public-path.js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 资源加载异常问题就解决了,图片加载出来了
渲染问题
- 目前是将基座容器给直接替换了
- son2的js加载完成,会执行render
- render默认找的是document.getElementById('root'),与基座一样
- 那么我们需要区分下环境,在基座中让其渲染到
指定容器.#root
,在dev环境就在document.#root
中
// son2/src/index.js
function render(props) {
const container = props?.container;
const root = ReactDOM.createRoot(
container
? container.querySelector("#root")
: document.getElementById("root")
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
if (!window.__POWERED_BY_QIANKUN__) {
// 如果不为true,就独立运行
render();
}
export async function mount(props) {
console.log("mount", props);
// 在基座中,走这个
render(props);
}
- props中的container就是父传递给子的渲染容器,会把加载到的html放到里面,所以里面也会有root
- 这个是qiankun的沙盒,具体实现后边分析
- 指定容器的代码在基座里,上边有打注释
- 加载正常的图示
- 沙盒结构
通信
- 在基座中注册
- 通过onGlobalStateChange事件接收更新回调
- 通过setGlobalState触发回调执行
// root/registerApps.js
import { initGlobalState } from "qiankun";
// 初始化state实例
const actions = initGlobalState({
age: 30,
});
// 基座中通过actions注册响应回调
actions.onGlobalStateChange((newVal, oldVal) => {
console.log("parent", newVal, oldVal);
});
// 更新状态,触发onGlobalStateChange事件
actions.setGlobalState({ name: "jw2" });
- 子应用使用,通过props
// son2/src/index.js
export async function mount(props) {
props.onGlobalStateChange((newVal, oldVal) => {
console.log("child", newVal, oldVal);
});
render(props); // 把props透传给App,可以在App里通过props调用,如下
// props.setGlobalState({ name: "jw2" });
}
自定义加载
- 有一些内容是不需要匹配路由的,比如公共的弹窗页面等等,通常是点击某个按钮触发加载
- 使用
loadMicroApp
方法- 示例:点击挂载按钮,加载static
import React from "react";
import { BrowserRouter, Link } from "react-router-dom";
import { loadMicroApp } from "qiankun";
// root/src/App.js
function App() {
const containerRef = React.createRef();
// 加载事件
const handleShow = () => {
loadMicroApp({
name: "m-static",
entry: "http://localhost:9999",
container: containerRef.current,
});
};
return (
<div className="App">
<BrowserRouter>
<Link to="/react">React应用</Link>
<Link to="/vue">Vue应用</Link>
</BrowserRouter>
<button onClick={handleShow}>点击挂载static</button>
<div ref={containerRef}></div>
<div id="container"></div>
</div>
);
}
- static如下,启动9999端口返回即可
import React from "react";
import "./App.css";
import { BrowserRouter, Link } from "react-router-dom";
import { loadMicroApp } from "qiankun";
function App() {
const containerRef = React.createRef();
const handleShow = () => {
loadMicroApp({
name: "m-static",
entry: "http://localhost:9999",
container: containerRef.current,
});
};
return (
<div className="App">
<BrowserRouter>
<Link to="/react1">儿子1</Link>
<Link to="/react2">儿子2</Link>
</BrowserRouter>
<button onClick={handleShow}>点击挂载static</button>
<div ref={containerRef}></div>
<div id="container"></div>
</div>
);
}
export default App;
css隔离
- 现在给子应用添加点样式,将a标签变为红色
a {color:red}
- 子的样式作用到父上了
- 如果不期望这么做,可以在start中添加一个配置
start({
sandbox: {
experimentalStyleIsolation: true,
},
});
- 现在就隔离了,会在类名前添加限制
- 或者将子应用使用影子dom的形式插入,也可以达到隔离的效果
- 缺点:限制过于大了,外界无法访问内部dom,无法对子dom进行手动操作
// 配置
start({
sandbox: {
strictStyleIsolation: true,
},
});
qiankun沙箱原理
- 分为两部分:样式隔离、数据隔离
样式隔离 webComponent
- 微应用样式不应该影响到主应用和其他应用
- webComponent文档
影子dom
- 为容器添加一个影子dom
- 示例:
<body>
<div>全局</div>
</body>
// 不处理的情况
const template = `<div id="qiankun-xxx">
<div id="inner">abc</div>
<style>
div {
color: red
}
</style>
</div>`;
const container = document.createElement('div');
// 赋值
container.innerHTML = template;
document.body.appendChild(container)
- 可以看到页面中
两个div标签都变成红色
了,也就是说子应用影响到父应用
了
- 现在换一种方式,使用
影子dom的形式去插入子应用
// 模板
const template = `<div id="qiankun-xxx">
<div id="inner">abc</div>
<style>
div {
color: red
}
</style>
</div>`;
const container = document.createElement('div');
// 赋值
container.innerHTML = template;
// 拿到#qiankun-xxx
const appElement = container.firstChild;
// 拿到旧dom
let oldContent = appElement.innerHTML;
// 清空dom
appElement.innerHTML = '';
// 开启影子dom,外界不可访问
let shadow = appElement.attachShadow({ mode: 'closed' });
shadow.innerHTML = oldContent; // 放到影子dom中
document.body.appendChild(container);
console.log('模拟外部是否能拿到',appElement.shadowRoot);//null
console.log(shadow.querySelector('#inner'));// div#inner
console.log(appElement.shadowRoot === shadow);// false
- 现在就没有影响到主应用,样式只在子应用生效
- 缺点:如果
attachShadow的mode位closed
则无法在外部拿到影子dom内部的内容
,那么我们就无法对其进行一些额外的操作
,比如在主应用操作子应用的某个dom节点
,我们可以设置为open,在open状态下可以拿到
template自定义标签名
- 通过自定义标签的形式去生成dom
- template默认不展示,需要我们通过js去操作,内部通常配合插槽使用
- dom行间的slot对应插槽的name即可,会自动归位
- 类似于vue的template,可以理解为是一个
模板组件
,我们只需要设定一下某个标签的渲染逻辑即可
<body>
<button>全局按钮,测试样式</button>
<my-button>
<span slot="a">按钮1号</span>
<span slot="b">icon</span>
<span slot="c">文案</span>
</my-button>
<my-button>
<span slot="a">按钮2号</span>
</my-button>
<template id="btn">
<style>
button {
color: red;
}
</style>
<button>
<slot name="a">a默认</slot>
</button>
<slot name="b">b默认</slot>
<p>
<slot name="c">c默认</slot>
</p>
</template>
</body>
<script>
// 声明my-button标签的渲染逻辑
customElements.define('my-button',
class extends HTMLElement {
constructor() {
super();
// 拿到template模板内容
const template = document
.getElementById('btn')
.content;
// 给my-button标签添加影子dom(template的内容)
const shadowRoot = this.attachShadow({ mode: 'open' })
.appendChild(template.cloneNode(true));
}
});
</script>
类名限制
- qiankun内部是将link变为style行间插入的
- 如果开启css严格模式
experimentalStyleIsolation
,会给每个类名加上一个前缀
,那么只要保证类名的唯一,就可以保证样式为私有性- 缺点:
如果手动写了这个类型,那么无法保障私有性
- 如下:
div[data-qiankun="son1"] { margin: 0px; }
div[data-qiankun="son1"] code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; }
数据隔离
- 子应用数据不应污染全局window
快照沙箱
- 缺点:占用内存较多,存了全量window
- 优点:无兼容问题
class SnapshotSandbox {
constructor() {
this.modifyPropsMap = {};// 存储全局哪些属性被修改了
}
active() {
// 快照
this.windowSnapShot = {};
// 给window拍照
Object.keys(window).forEach(prop => {
this.windowSnapShot[prop] = window[prop];
});
// 将旧状态添加回来
Object.keys(this.modifyPropsMap).forEach(prop => {
window[prop] = this.modifyPropsMap[prop];
})
}
inactive() {
// 清空上次引用
this.modifyPropsMap = {};
Object.keys(window).forEach(prop => {
// 如果window属性与快照中不相同,代表改了
// 那么现有window的key和value给到modifyPropsMap,后续添加状态会用到
// windowSnapShot是没改动的,赋值给window即可,达到还原效果
if (window[prop] !== this.windowSnapShot[prop]) {
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapShot[prop];
}
})
}
}
let sandbox = new SnapshotSandbox();
sandbox.active();// 启用沙箱
window.a = 100;
window.b = 200;
console.log(window.a, window.b);//100 200
sandbox.inactive();// 清除在window新增状态,并存起来
console.log(window.a, window.b);//undefined undefined
sandbox.active();// 如果有上次的状态,添加回来
console.log(window.a, window.b);//100 200
proxy单例沙箱
- 相较快照沙箱而言,节约内存
- 只存改动相关的值
- 兼容性问题,但vue3都用上了,感觉也没什么影响
class LegacySandbox {
constructor() {
this.modifyPropsMap = new Map();// window上的原始值,改动才存
this.addedPropsMap = new Map();// 新增的
this.currentPropsMap = new Map();// 最新的key、value
const fakeWindow = Object.create(null);
const proxy = new Proxy(fakeWindow, {
get: (target, key, recevier) => {
return window[key];
},
set: (target, key, value) => {
if (!window.hasOwnProperty(key)) {
// 添加的属性,window上不存在
this.addedPropsMap.set(key, value)
} else if (!this.modifyPropsMap.has(key)) {
// 保存修改的前的值
// 改了才存window原始值
this.modifyPropsMap.set(key, window[key])
}
// 所有的修改操作都保留了一份最新的
this.currentPropsMap.set(key, value);
window[key] = value;// 修改成最新的内容
}
});
this.proxy = proxy;
}
setWindowProp(key, value) {
if (value == undefined) {
delete window[key];
} else {
window[key] = value;
}
}
active() {
this.currentPropsMap.forEach((value, key) => {
// 将记录的值加到window上
this.setWindowProp(key, value);
})
}
inactive() {
// 将添加的值删掉
this.addedPropsMap.forEach((value, key) => {
this.setWindowProp(key);
})
// 将原始值赋值回去
this.modifyPropsMap.forEach((value, key) => {
this.setWindowProp(key, value);
})
}
}
// 如何使用
let shadbox = new LegacySandbox();
(function (window) {
window.a = 100;
console.log(window.a) // 100
// shadbox.inactive();
})(shadbox.proxy)
console.log(window.a)// 100
// 单实例可以在切换后通过inactive去关闭,否则会window数据会混乱
let shadbox2 = new LegacySandbox();
(function (window) {
window.a = 200;
console.log(window.a)// 200
// shadbox.inactive();
})(shadbox.proxy)
console.log(window.a)// 200
proxy多例沙箱
- 上面两个沙箱的使用场景为单子应用,如同时展示多个子应用则会出现数据混乱(相互影响)
- 单子应用在切换时,将上一个的
inactive执行,清除数据变更
- 但多实例并没有切换
- 原理:
- 将变更的对象改为
proxy对应的object,不在window上改动
- 那么window上始终是默认的,子应用中始终是私有的
class ProxySandbox {
constructor() {
this.running = false;
const fakeWindow = Object.create(null);
this.proxy = new Proxy(fakeWindow, {
get: (target, key) => {
return key in target ? target[key] : window[key]
},
set: (target, key, value) => {
if (this.running) {
target[key] = value;
}
return true;
}
})
}
active() {
if (!this.running) this.running = true;
}
inactive() {
this.running = false;
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
sandbox1.active();// 开启
sandbox2.active();// 开启
(function (window) {
window.a = 100;
console.log('sandbox1', window.a);
})(sandbox1.proxy);
console.log(window.a);// undefined
(function (window) {
window.a = 200;
console.log('sandbox2', window.a);
})(sandbox2.proxy);
console.log(window.a)// undefined
总结qiankun流程
- 加载子应用
- 将html处理,把heade变为qiankun-heade
- 按照配置内容
处理css
,不管怎样都是将css(无论是link还是style
)处理成style行内
- 隐藏js脚本
- 按照
配置决定是否使用影子dom
- 按照配置决定沙箱形式
- 默认proxy单例
- 如不支持proxy,则降级为快照沙箱
- 如开启同时加载多个子项目配置,则使用proxy多例沙箱
wujie(无界)
- 基于WebComponent和iframe
WebComponent的用作样式隔离,iframe做js沙箱
- 具备通信功能
- 不需要配置接入协议
方便很多
- 官网wujie-micro.github.io/doc/
使用wujie
- 通过startApp渲染子应用
// 将渲染方法抽离成公共组件
import { useRef, useEffect } from "react";
import { startApp, destroyApp } from "wujie";
export default function WujieReact(props) {
const myRef = useRef(null);
let destroy = null;
const startAppFunc = async () => {
destroy = await startApp({
...props,
el: myRef.current,
});
};
useEffect(() => {
startAppFunc();
return () => {
if (destroy) {
destroyApp(destroy);
}
};
});
const { width, height } = props;
return <div ref={myRef}></div>;
}
// 使用组件
import WujieReact from './components/wujie-react.js'
export default function Page1(){
return <WujieReact
name='ReactApp'
url="http://localhost:3001"
sync={true}
></WujieReact>
}
// 渲染
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import Page1 from './Page1.js';
import Page2 from './Page2.js';
function App() {
return (
<div className="App">
<BrowserRouter>
<Link to="/react">React应用</Link>
<Link to="/vue">vue应用</Link>
<Routes>
<Route path="/react" element={<Page1 />}></Route>
<Route path="/vue" element={<Page2 />}></Route>
</Routes>
</BrowserRouter>
</div>
);
}
export default App;
- 现在就可以跑起来了,子应用中不需要任何配置
- 看一下渲染结构
- 采用了
webCpmponent渲染html内容,做了影子dom处理
- 现在给子应用加一个css,让a标签变为红色
- 可以看到并没有污染到父级
- js抽离出来,放在iframe里了
简易wujie
注意
:因wujie将js放在iframe中,将html放在影子dom中,那么document指向iframe的上下文,而iframe中没dom,所以找不到内容
- 内部将其代理到影子dom上下文中
- 因为是在iframe中执行,也不需要处理window属性了,
<!DOCTYPE html>
<html lang="zn-CN">
<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>测试wujie</title>
</head>
<body>
<div>
主应用内容
<p>
<a href="jiavascript:;">父级a标签,测试是否被污染</a>
</p>
</div>
<!-- 子应用插入点 -->
<div id="container"></div>
</body>
</html>
<script>
const str = `<!DOCTYPE html>
<html lang="zn-CN">
<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>子</title>
<style>
a {
color: red;
}
</style>
</head>
<body>
<div id="inner">子应用内容</div>
<a href="/a">a</a>
</body>
</html>`;
const container = document.querySelector('#container');
const strScript = `
window.a = 100; // 此属性不会影响父应用
console.log(window.a); // 100
const ele = document.querySelector('#inner')
console.log(ele);
`;
function createIframe() {
const iframe = document.createElement('iframe');
iframe.src = 'about:blank'
document.body.appendChild(iframe);
return iframe
}
function createSandbox() {
const sandbox = {
iframe: createIframe(), // 创建了一个iframe沙箱
shadowRoot: null
}
return sandbox
}
function injectTemplate(sandbox, template) {
const wrapper = document.createElement('div');
wrapper.innerHTML = template;
sandbox.shadowRoot.appendChild(wrapper)
}
function runScirptInSandbox(sandbox, script) {
const iframeWindow = sandbox.iframe.contentWindow;
// 创建script标签
const scriptElement = iframeWindow.document.createElement('script');
// 获取head
const headElement = iframeWindow.document.querySelector('head');
// 写入内容
scriptElement.textContent = script;
// 现在找内容会去iframe里找
// 但是我们的dom插入到了 基座提供的标签的影子dom中
// 那么找不到dom
// 所以将iframe的querySelector劫持,并且代理给影子dom
Object.defineProperty(iframeWindow.Document.prototype, 'querySelector', {
get() {
return new Proxy(sandbox.shadowRoot['querySelector'], {
// 劫持函数执行
apply(target, thisArgs, args) {
// 让他到影子dom的上下文中去找
return thisArgs.querySelector.apply(sandbox.shadowRoot, args)
}
})
}
})
// 放入iframe
headElement.appendChild(scriptElement);
}
class WujieApp extends HTMLElement {
connectedCallback() {
let sandbox = createSandbox(); // 创建沙箱
sandbox.shadowRoot = this.attachShadow({ mode: 'open' }); //给wujie-app添加影子dom
injectTemplate(sandbox, str); // 将样式和内容渲染到影子dom中
runScirptInSandbox(sandbox, strScript);// 将js放到iframe沙箱中
console.log('渲染完毕');
}
}
window.customElements.define('wujie-app', WujieApp)
function renderWujie() {
container.appendChild(document.createElement('wujie-app'))
}
renderWujie();
console.log(window.a);//全局不受影响,undefined
</script>
wujie通信
- 通信文档
- props、window、eventBus
- props
- 主应用数据变更,刷新视图,重新渲染子应用
- 会频繁刷新会白屏严重,适合做固定数据的传递