微前端

135 阅读12分钟

微前端

  • 由一个主入口(基座)和多个子项目组成,基座内动态导入子项目,根据不同情况展示不同内容
  • 优点:
    • 可以将多个子应用进行拆分,不限技术栈
    • 每个子应用都可以单独开发、单独部署
  • 存在问题:
    • 父子通信、兄弟通信
    • 样式隔离、代码隔离、异常隔离

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即可预览

image.png

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
  • 创建一个基座

image.png

  • 创建react子应用

image.png

  • 给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分别对应配置文件的这里

image.png

  • 而且react的主入口名称是两者拼接,所以编译包默认也是这种格式

image.png image.png

  • cd ../root
  • npm run start 直接就跑起来了
  • 进来是这个页面,框架默认提供了一个示例模块,我们可以删掉

image.png

  • 目录:
    • 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会加载不同子模块

image.png

image.png

与直接搭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中去(自动化)

image.png

参数传递(父传子)

  • 在根里面添加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>
  )
}
  • 效果
    • 共享数据
    • 数据不可变

image.png

接入协议

  • 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>

image.png

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.jsson2/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);
}
  • 目前项目可以整体运行了
  • 基座默认效果

image.png

  • 进入son2效果
  • 可以看到两个问题:
    • 加载son2把基座的导航栏儿子1 儿子2给搞没了
    • 中间的图片加载异常了

image.png

资源加载问题

  • 基座地址为localhost:3001,子1项目为localhost:8888,而目前我们是在3001中加载子1,所以相对地址为基座地址,浏览器自动补全后就找不到资源了

image.png

  • 解决办法:
    • 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__;
}
  • 资源加载异常问题就解决了,图片加载出来了

image.png

渲染问题

  • 目前是将基座容器给直接替换了
  • 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的沙盒,具体实现后边分析
  • 指定容器的代码在基座里,上边有打注释
  • 加载正常的图示

image.png

  • 沙盒结构

image.png

通信

  • 在基座中注册
  • 通过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;

image.png

css隔离

  • 现在给子应用添加点样式,将a标签变为红色
a {color:red}
  • 子的样式作用到父上了

image.png

  • 如果不期望这么做,可以在start中添加一个配置
start({
  sandbox: {
    experimentalStyleIsolation: true,
  },
});
  • 现在就隔离了,会在类名前添加限制

image.png

  • 或者将子应用使用影子dom的形式插入,也可以达到隔离的效果
    • 缺点:限制过于大了,外界无法访问内部dom,无法对子dom进行手动操作
// 配置
start({
  sandbox: {
    strictStyleIsolation: true,
  },
});

image.png

qiankun沙箱原理

  • 分为两部分:样式隔离、数据隔离

样式隔离 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标签都变成红色了,也就是说子应用影响到父应用

image.png

  • 现在换一种方式,使用影子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

image.png

  • 现在就没有影响到主应用,样式只在子应用生效
  • 缺点:如果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>

image.png

类名限制
  • 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里了

image.png

image.png

简易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>

image.png

image.png

wujie通信

  • 通信文档
  • props、window、eventBus
  • props
    • 主应用数据变更,刷新视图,重新渲染子应用
    • 会频繁刷新会白屏严重,适合做固定数据的传递