GithubHelp home page GithubHelp logo

careteenl / micro-fe Goto Github PK

View Code? Open in Web Editor NEW
28.0 2.0 9.0 3.45 MB

💪 深入浅出微前端

HTML 19.53% JavaScript 58.56% Shell 0.52% EJS 7.79% Vue 11.81% CSS 1.78%
single-spa qiankun micro-frontend systemjs

micro-fe's Introduction

Table of Contents generated with DocToc

深入浅出微前端

cover

长文警告⚠️,目的是通过从使用到实现,一层层剖析微前端。

文章首发于@careteen/micro-fe,转载请注明来源即可。

背景

在微前端出现之前,一个系统的前端开发模式基本都是单仓库,包含了所有的功能、代码...

很多企业也基本在物理上进行了应用代码隔离,实行单个应用单个库,闭环部署更新测试环境和正式环境。

比如我们公司的权限管理后台,首页中罗列了各个系统的入口,每个系统由单独仓库管理,点击具体系统,打开新窗口进行访问。

admin-panel

由于多个应用一级域名一致,使用不同二级域名区分。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 技术,策略以及实践方法

micro-service

微前端也是借鉴后端微服务的**。微前端就是将不同的功能按照不同的纬度拆分成多个子应用。通过主应用来加载这些子应用。

微前端的核心在于先拆后合

微前端优势

  • 同步更新
  • 增量升级
  • 简单、解耦的代码库
  • 独立开发、部署

微前端解决方案

  • 基座模式:通过搭建基座、配置中心来管理子应用。如基于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

web-component

为什么不是ESM

ESMES Module,是一种前端模块化手段。他能做到微前端的几个核心点

  • 无技术栈限制: ESM加载的只是js内容,无论哪个框架,最终都要编译成js,因此,无论哪种框架,ESM都能加载。
  • 应用单独开发: ESM只是js的一种规范,不会影响应用的开发模式。
  • 多应用整合: 只要将微应用以ESM的方式暴露出来,就能正常加载。
  • 远程加载模块: ESM能够直接请求cdn资源,这是它与生俱来的能力。

但是可惜的是兼容性不好,查看can i use import

es-module

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

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/[email protected]/lib/system/single-spa.min.js"
    }
  }
</script>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/[email protected]/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

create-single-spa-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

create-single-spa-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/[email protected]/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/

single-spa-base

手动输入 http://localhost:9000/vue/ 并可以切换路由

single-spa-vue

手动输入 http://localhost:9000/react/ 并可以切换路由

single-spa-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应用查看打印结果

my-single-spa-result

核心方法-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-status

新建single-spa/src/applications/app.helpers.js存放所有状态

export const NOT_LOADED = "NOT_LOADED"; // 应用默认状态是未加载状态
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 正在加载文件资源
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 此时没有调用bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 正在启动中,此时bootstrap调用完毕后,需要表示成没有挂载
export const NOT_MOUNTED = "NOT_MOUNTED"; // 调用了mount方法
export const MOUNTED = "MOUNTED"; // 表示挂载成功
export const UNMOUNTING = "UNMOUNTING"; // 卸载中, 卸载后回到NOT_MOUNTED

// 当前应用是否被挂载了 状态是不是MOUNTED
export function isActive(app) {
  return app.status == MOUNTED;
}

// 路径匹配到才会加载应用
export function shouldBeActive(app) {
  // 如果返回的是true 就要进行加载
  return app.activeWhen(window.location);
}

于此同时还是提供几个方法判断当前应用所处状态。

然后再提供根据app状态对所有注册的app进行分类

// `single-spa/src/applications/app.helpers.js`
export function getAppChanges() {
  // 拿不到所有app的?
  const appsToLoad = []; // 需要加载的列表
  const appsToMount = []; // 需要挂载的列表
  const appsToUnmount = []; // 需要移除的列表
  apps.forEach((app) => {
    const appShouldBeActive = shouldBeActive(app); // 看一下这个app是否要加载
    switch (app.status) {
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) {
          appsToLoad.push(app); // 没有被加载就是要去加载的app,如果正在加载资源 说明也没有加载过
        }
        break;
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (appShouldBeActive) {
          appsToMount.push(app); // 没启动柜, 并且没挂载过 说明等会要挂载他
        }
        break;
      case MOUNTED:
        if (!appShouldBeActive) {
          appsToUnmount.push(app); // 正在挂载中但是路径不匹配了 就是要卸载的
        }
      default:
        break;
    }
  });
  return { appsToLoad, appsToMount, appsToUnmount };
}

然后开始实现single-spa/src/navigation/reroute.js的核心方法

import {
  getAppChanges,
} from "../applications/app.helpers.js";
export function reroute() {
  // 所有的核心逻辑都在这里
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
  return loadApps();
  function loadApps() {
  // 获取所有需要加载的app,调用加载逻辑
  const loadPromises = appsToLoad.map(toLoadPromise); // 调用加载逻辑
    return Promise.all(loadPromises)
  }
}

于此同时再提供工具方法,方便处理传参进来的生命周期钩子是数组的场景

function flattenFnArray(fns) {
  fns = Array.isArray(fns) ? fns : [fns];
  return function (customProps) {
    return fns.reduce(
      (resultPromise, fn) => resultPromise.then(() => fn(customProps),
      Promise.resolve()
    );
  };
}

实现原理类似于koa中的中间件,将多个promise组合成一个promise链。

再提供toLoadPromise, 只有当子应用是NOT_LOADED 的时候才需要加载,并使用flattenFnArray将各个生命周期进行处理

function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== NOT_LOADED) {
      return app;
    }
    app.status = LOADING_SOURCE_CODE;
    return app.loadApp().then((val) => {
      let { bootstrap, mount, unmount } = val; // 获取应用的接入协议,子应用暴露的方法
      app.status = NOT_BOOTSTRAPPED;
      app.bootstrap = flattenFnArray(bootstrap);
      app.mount = flattenFnArray(mount);
      app.unmount = flattenFnArray(unmount);

      return app;
    });
  });
}

核心方法-start

然后实现single-spa/src/start.js

import { reroute } from "./navigation/reroute.js";
export let started = false;
export function start() {
  started = true; // 开始启动了
  reroute();
}

核心逻辑-reroute

接着需要对reroute方法进行完善,将不需要的组件全部卸载,将需要加载的组件去加载-> 启动 -> 挂载,如果已经加载完毕,那么直接启动和挂载。

export function reroute() {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
  if (started) { // 启动应用
    return performAppChanges();
  }
  function performAppChanges() { 
    appsToUnmount.map(toUnmountPromise);
    appsToLoad.map(app => toLoadPromise(app).then((app) => tryBootstrapAndMount(app)))
    appsToMount.map(appToMount => tryBootstrapAndMount(appToMount))
  }
}

其核心就是卸载需要卸载的应用-> 加载应用 -> 启动应用 -> 挂载应用

然后提供toUnmountPromise,标记成正在卸载,调用卸载逻辑 , 并且标记成 未挂载。

function toUnmountPromise(app) {
  return Promise.resolve().then(() => {
    // 如果不是挂载状态 直接跳出
    if (app.status !== MOUNTED) {
      return app;
    }
    app.status = UNMOUNTING;
    return app.unmount(app.customProps).then(() => {
      app.status = NOT_MOUNTED;
    });
  });
}

以及tryBootstrapAndMount,提供a/b应用的切换

// a -> b b->a a->b
function tryBootstrapAndMount(app, unmountPromises) {
  return Promise.resolve().then(() => {
    if (shouldBeActive(app)) {
      return toBootStrapPromise(app).then((app) =>
        unmountPromises.then(() => {
          capturedEventListeners.hashchange.forEach((item) => item());
          return toMountPromise(app);
        })
      );
    }
  });
}

实现toBootStrapPromise启动应用

function toBootStrapPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== NOT_BOOTSTRAPPED) {
      return app;
    }
    app.status = BOOTSTRAPPING;
    return app.bootstrap(app.customProps).then(() => {
      app.status = NOT_MOUNTED;
      return app;
    });
  });
}

实现toMountPromise加载应用

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

上述实现了子应用各个状态的切换逻辑,下面还需要将路由进行重写。

新建single-spa/src/navigation/navigation-events.js,监听hashchange和popstate,路径变化时重新初始化应用。

import { reroute } from "./reroute.js";

function urlRoute() {
  reroute();
}
window.addEventListener("hashchange", urlRoute);
window.addEventListener("popstate", urlRoute);

需要对浏览器的事件进行拦截,其实现方式和vue-router类似,使用AOP的**实现的。

因为子应用里面也可能会有路由系统,需要先加载父应用的事件,再去调用子应用。

const routerEventsListeningTo = ["hashchange", "popstate"];
export const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};
const originalAddEventListener = window.addEventListener;
const originalRemoveEventLister = window.removeEventListener;

window.addEventListener = function (eventName, fn) {
  if (
    routerEventsListeningTo.includes(eventName) &&
    !capturedEventListeners[eventName].some((l) => fn == l)
  ) {
    return capturedEventListeners[eventName].push(fn);
  }
  return originalAddEventListener.apply(this, arguments);
};

window.removeEventListener = function (eventName, fn) {
  if (routerEventsListeningTo.includes(eventName)) {
    return (capturedEventListeners[eventName] = capturedEventListeners[
      eventName
    ].filter((l) => fn != l));
  }
  return originalRemoveEventLister.apply(this, arguments);
};

需要对跳转方法进行拦截,例如 vue-router内部会通过pushState() 不改路径改状态,所以还是要处理下。如果路径不一样,也需要重启应用。

function patchedUpdateState(updateState, methodName) {
  return function() {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;
    if (urlBefore !== urlAfter) {
      window.dispatchEvent(new PopStateEvent("popstate"));
    }
    return result;
  }
}
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState')

提供触发事件的方法

export function callCapturedEventListeners(eventArguments) { // 触发捕获的事件
  if (eventArguments) {
    const eventType = eventArguments[0].type;
    // 触发缓存中的方法
    if (routingEventsListeningTo.includes(eventType)) {
      capturedEventListeners[eventType].forEach(listener => {
        listener.apply(this, eventArguments);
      })
    }
  } 
}

完善核心逻辑-reroute

改动reroute逻辑,启动完成需要调用callAllEventListeners,应用卸载完毕也需要调用callAllEventListeners

export function reroute() {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
  if (started) {
    return performAppChanges();
  }
  return loadApps();

  function loadApps() {
    const loadPromises = appsToLoad.map(toLoadPromise);
    return Promise.all(loadPromises).then(callAllEventListeners); // ++
  }
  function performAppChanges() {
    let unmountPromises = Promise.all(appsToUnmount.map(toUnmountPromise)).then(callAllEventListeners); // ++

    appsToLoad.map((app) =>
      toLoadPromise(app).then((app) =>
        tryBootstrapAndMount(app, unmountPromises)
      )
    );
    appsToMount.map((app) => tryBootstrapAndMount(app, unmountPromises));
  }
}

上述代码已经实现了基本功能

$ cd single-spa
$ yarn
$ yarn dev

打开 http://127.0.0.1:5000/example 点击切换a b应用查看打印结果,表现同原生Demo的结果。

my-single-spa-result

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

qiankun-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=30000
WDS_SOCKET_PORT=30000

同vue子应用配置

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

function render(props = {}) {
  let { container } = props;
  ReactDOM.render(<App />,
    container ? container.querySelector('#root') : document.getElementById('root')
  );
}
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
export async function bootstrap() {

}
export async function mount(props) {
  render(props)
}

export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.getElementById('root'))
}

scripts脚本需要做修改

"scripts": {
  "start": "rescripts start",
  "build": "rescripts build",
  "test": "rescripts test",
  "eject": "rescripts eject"
},

运行命令,打开 http://localhost:30000/ 可独立访问

$ yarn start

查看最终效果

在主应用中配置样式隔离

start({
  sandbox: {
    // experimentalStyleIsolation:true
    strictStyleIsolation: true,
  },
});

浏览器打开 http://localhost:3000/ 点击vue应用

qiankun-result-vue

点击react应用,可观察父子应用样式互不影响。

qiankun-result-react

qiankun原理

通过使用qiankun可观察到其APIsingle-spa差不多。下面将大致了解下qiankun的实现原理。

分析代码在@careteen/qiankun,里面有大量注释。

registerMicroApps

从入口注册方法registerMicroApps开始。

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

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

接下来是如何实现创建沙箱

qiankun-createSandboxContainer

创建沙箱会先判断浏览器是否支持Proxy,如果支持并不是useLooseSandbox模式,则使用代理沙箱实现,如果不支持则采用快照沙箱

Proxy Sandbox

qiankun-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,进而达到和主应用隔离。这只是简易实现,qiankunProxySandbox实现

Snapshot Sandbox

源码实现代码

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,做到真正意义上的样式隔离,但缺点就是子应用想要复用父应用的样式时做不到。

qiankun-css-shadow-dom

Style Scope Sandbox

源码实现代码

qiankun也提供设置experimentalStyleIsolation=true开启scope样式隔离,表现如下,使用div包裹子应用,并将子应用的顶级样式加上子应用名称前缀进行样式隔离。其中还将标签选择器加上[data-qainkun]="slave-name"

qiankun-css-scope qiankun-css-scope-2

父子应用通信方式

源码实现代码

基于发布订阅实现。

  • 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 federationwebpack5的联邦模块也能实现,YY团队的EMP基于此实现了去中心模式,脱离基座模式,每个应用之间都可以批次分享资源。可以通过这篇文章尝尝鲜,后面再继续研究。

micro-fe's People

Contributors

careteenl avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.