• 深入浅出微前端

    • 背景
    • 什么是微前端

      • 微前端劣势
      • 微前端解决方案
    • 为什么不是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 spaqiankun计划。
  • 自组织模式:通过约定进行相互调用,但会遇到解决第三方依赖的问题。
  • 去核心模式:脱离基座模式,每个利用之间都能够批次分享资源。如基于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

ESMES 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

因为reactreact-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应用中,能够发现次要是两个办法registerApplicationstart

先新建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"; // 此时没有调用bootstrapexport const BOOTSTRAPPING = "BOOTSTRAPPING"; // 正在启动中,此时bootstrap调用结束后,须要示意成没有挂载export const NOT_MOUNTED = "NOT_MOUNTED"; // 调用了mount办法export const MOUNTED = "MOUNTED"; // 示意挂载胜利export const UNMOUNTING = "UNMOUNTING"; // 卸载中, 卸载后回到NOT_MOUNTED// 以后利用是否被挂载了 状态是不是MOUNTEDexport 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->bfunction 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、BOMAPI,例如在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'    }  }}

应用qiankunsingle-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=30000WDS_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可察看到其APIsingle-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模块标准加载资源。

requestIdleCallbackreact fiber 架构中有应用到,感兴趣的可返回浏览器任务调度策略和渲染流程查看。

loadApp

当执行start办法后,会去执行registerApplication中的loadApp加载子利用。

其实现代码较多,能够返回qiankun/loader.ts/loadApp查看实现,有正文表明大略流程。总结下来次要做了如下几件事

  • 通过 importEntry 办法拉取子利用
  • 在拉取的模板外面包一层 div ,减少 css 款式隔离,提供shadowdomscopedCSS两种形式
  • 将模板进行挂载
  • 创立 js 沙箱 ,取得沙箱开启和沙箱敞开办法
  • 合并出 beforeUnmountafterUnmountafterMountbeforeMountbeforeLoad 办法。减少 qiankun 标识
  • 顺次调用 beforeLoad 办法
  • 在沙箱中执行脚本, 获取子利用的生命周期 bootstrapmountunmount 、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 domscope款式隔离计划
  • 解决proxy sandboxsnapshot sanboxjs隔离计划
  • 基于公布订阅更好的服务于react setState
  • 还提供@umijs/plugin-qiankun插件能在umi利用下更好的接入

总结

除了single-spa这种基于底座的微前端解决方案, [webpack5 module federation]()webpack5的联邦模块也能实现,YY团队的EMP基于此实现了去核心模式,脱离基座模式,每个利用之间都能够批次分享资源。能够通过这篇文章尝尝鲜,前面再持续钻研。