Kun

Kun

IT学徒、技术民工、斜杠青年,机器人爱好者、摄影爱好 PS、PR、LR、达芬奇潜在学习者


共 279 篇文章


  前端进阶(九)-微应用框架

qiankun

安装

yarn add qiankun 
npm i qiankun -S

注册微应用并启动:

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);

start();

微应用分为有 webpack 构建和无 webpack 构建项目,有 webpack 的微应用(主要是指 Vue、React、Angular)需要做的事情有:

  1. public-path.js 文件,用于修改运行时的 publicPath
  2. 微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。
  3. 在入口文件最顶部引入 public-path.js,修改并导出三个生命周期函数。
  4. 修改 webpack 打包,允许开发环境跨域和 umd 打包。

qiankun微应用的加载过程

首先读取 entry 的 HTML 文件,解析其中的内容。

抽取 HTML 文件内的 JS 文件请求,使用重构后的 fetch 请求进行 JS 资源拉取,并且对返回的 JS 代码进行沙箱隔离,上下文设置。

抽取 HTML 文件内的 CSS 文件请求,使用重构后的 fetch 请求进行 CSS 资源拉取,并且对返回的 CSS 代码进行样式沙箱隔离(shadow DOM)。

react app接入qiankun

src 目录新增 public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

设置 history 模式路由的 base

js沙箱

简单来说,多实例场景同时又有多个不同 react 版本加载,这个问题是依赖新的沙箱机制的,具体思路就是所有子应用的全局变量变更都是在闭包中产生的,不会真正回写到 window 上,这样就能避免多实例之间的污染了

https://github.com/umijs/qiankun/pull/130#issuecomment-549847590

qiankun有三大沙箱

SanpshotSandbox:快照沙箱。

它的原理是:把主应用的 window 对象做浅拷贝,将 window 的键值对存成一个 Hash Map。之后无论微应用对 window 做任何改动,当要在恢复环境时,把这个 Hash Map 又应用到 window 上就可以了。

微应用 mount 时

  • 先把上一次记录的变更 modifyPropsMap 应用到微应用的全局 window,没有则跳过
  • 浅复制主应用的 window key-value 快照,用于下次恢复全局环境

微应用 unmount 时

  • 将当前微应用 windowkey-value快照key-value 进行 Diff,Diff 出来的结果用于下次恢复微应用环境的依据
  • 将上次快照的 key-value 拷贝到主应用的 window 上,以此恢复环境

SnapshotSandbox 有一个问题:每次微应用 unmount 时都要对每个属性值做一次 Diff,类似这样

如果有 1000 个属性就要对比 1000 次,不是那么优雅。

LegacySandbox 的想法则是 通过监听对 window 的修改来直接记录 Diff 内容,因为只要对 window 属性进行设置,那么就会有两种情况:

  • 如果是新增属性,那么存到 addedMap
  • 如果是更新属性,那么把原来的键值存到 prevMap,把新的键值存到 newMap

通过 addedMap, prevMapnewMap 这三个变量就能反推出微应用以及原来环境的变化,qiankun 也能以此作为恢复环境的依据。

前面两种沙箱都是 单例模式 下使用的沙箱。也即一个页面中只能同时展示一个微应用,而且无论是 set 还是 get 依然是直接操作 window 对象。

为了避免真实的 window 被污染,qiankun 实现了 ProxySandbox。它的想法是:

  • 把当前 window 的一些原生属性(如document, location等)拷贝出来,单独放在一个对象上,这个对象也称为 fakeWindow
  • 之后对每个微应用分配一个 fakeWindow
  • 当微应用修改全局变量时:

    • 如果是原生属性,则修改全局的 window
    • 如果不是原生属性,则修改 fakeWindow 里的内容
  • 微应用获取全局变量时:

    • 如果是原生属性,则从 window 里拿
    • 如果不是原生属性,则优先从 fakeWindow 里获取

这样一来连恢复环境都不需要了,因为每个微应用都有自己一个环境,当在 active 时就给这个微应用分配一个 fakeWindow,当 inactive 时就把这个 fakeWindow 存起来,以便之后再利用

https://juejin.cn/post/7148075486403362846#heading-2

cssinjs

https://blog.csdn.net/qq_21567385/article/details/122656654

与wujie对比

https://github.com/Tencent/wujie/issues/247

Micro App

Micro app是京东开发的微前端app框架,2021年7月开源

qiankun 还是有一些缺点的:

  • 项目的侵入性依然很强。
  • qiankun 在沙箱方面依然有不少坑。qiankun基本上都是在修复沙箱相关的问题

micro-app之前,业内已经有一些开源的微前端框架,比较流行的有2个:single-spaqiankun

single-spa是通过监听 url change 事件,在路由变化时匹配到渲染的子应用并进行渲染,这个思路也是目前实现微前端的主流方式。同时single-spa要求子应用修改渲染逻辑并暴露出三个方法:bootstrapmountunmount,分别对应初始化、渲染和卸载,这也导致子应用需要对入口文件进行修改。因为qiankun是基于single-spa进行封装,所以这些特点也被qiankun继承下来,并且需要对webpack配置进行一些修改。

micro-app并没有沿袭single-spa的思路,而是借鉴了WebComponent的思想,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent组件,从而实现微前端的组件化渲染。并且由于自定义ShadowDom的隔离特性,micro-app不需要像single-spaqiankun一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。

Micro App的特点:

  • 使用简单。 将功能封装到 WebComponent 中
  • 零依赖。 无依赖、更高的扩展性
  • 兼容所有框架 技术栈无关

安装micro app

yarn add @micro-zoe/micro-app

在入口处引入

// index.js
import microApp from '@micro-zoe/micro-app'

microApp.start()

分配一个路由给子应用

// router.js
import { BrowserRouter, Switch, Route } from 'react-router-dom'
import MyPage from './my-page'

export default function AppRoute () {
  return (
    <BrowserRouter>
      <Switch>
        // 👇 非严格匹配,/my-page/* 都指向 MyPage 页面
        <Route path='/my-page'>
          <MyPage />
        </Route>
      </Switch>
    </BrowserRouter>
  )
}

在应用中嵌入子应用

// my-page.js
export function MyPage () {
  return (
    <div>
      <h1>子应用</h1>
      // name(必传):应用名称
      // url(必传):应用地址,会被自动补全为http://localhost:3000/index.html
      // baseroute(可选):基座应用分配给子应用的基础路由,就是上面的 `/my-page`
      <micro-app name='app1' url='http://localhost:3000/' baseroute='/my-page'></micro-app>
    </div>
  )
}

子应用设置基础路由

// router.js
import { BrowserRouter, Switch, Route } from 'react-router-dom'

export default function AppRoute () {
  return (
    // 👇 设置基础路由,子应用可以通过window.__MICRO_APP_BASE_ROUTE__获取基座下发的baseroute,如果没有设置baseroute属性,则此值默认为空字符串
    <BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
      ...
    </BrowserRouter>
  )
}

在webpack-dev-server的headers中设置跨域支持

devServer: {
  headers: {
    'Access-Control-Allow-Origin': '*',
  }
},

数据通信

数据通信也是 micro-app 的一大亮点,在使用层面上比 qiankun 的 EventBus 要好用一点

父传子

在基座应用的容器组件里添加一个 microApp.setData 就好了

// ReactMicroApp
const ReactMicroApp = () => {
  const [microAppState, setMicroAppState] = useState(microApp.getData('react-app'));

  // 发送数据给 react-app
  const sendReactAppData = () => {
    // setData的值必须为对象
    microApp.setData('react-app', {
      name: `react-app 随机数: ${Math.random()}`
    })
  }

  return (
    <div>
      <h1>react-app</h1>
      <button onClick={sendReactAppData}>基座 => react-app</button>

      <p>子应用消息 {microAppState ? microAppState.msg : '无'}</p>

      <micro-app
        name='react-app'
        url='http://localhost:3001/'
        baseroute='/react-app'
      >
      </micro-app>
    </div>
  )
}

微应用在接收数据的时候也是一样通过事件监听addDataListener来获取

// react-app App.js
function App() {
  const [mainAppData, setMainAppData] = useState( window.microApp.getData());

  // 监听基座数据
  useEffect(() => {
    if (window.microApp) {
      const dataListener = (data) => {
        console.log('主应用传的数据', data);
        setMainAppData(data);
      }

      window.microApp.addDataListener(dataListener)
      return () => {
        window.microApp.clearDataListener()
      }
    }
  })

  return (
    <div>
      <p>主应用的数据:{mainAppData ? mainAppData.name : '无'}</p>

      <BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
        <div>
          <header className={styles.header}>
            <Link to="/">react-app Home</Link>
            <Link to="/about">react-app About</Link>
          </header>

          <Routes>
            <Route path="/" element={<Home/>}/>
            <Route path="/about" element={<About/>}/>
          </Routes>
        </div>
      </BrowserRouter>
    </div>
  );
}

子传父时微应用发送数据也是一行 window.microApp.dispatch 就 OK 了,而且入参必须也为对象

// react-app App.js
function App() {
  const [mainAppData, setMainAppData] = useState( window.microApp.getData());

  useEffect(() => {
    if (window.microApp) {
      const dataListener = (data) => {
        console.log('主应用传的数据', data);
        setMainAppData(data);
      }

      window.microApp.addDataListener(dataListener)
      return () => {
        window.microApp.clearDataListener()
      }
    }
  })

  // 发送数据给基座
  const sendToMain = () => {
    window.microApp.dispatch({msg: `我是 react-app 随机数 ${Math.random()}`})
  }

  return (
    <div>
      <p>主应用的数据:{mainAppData ? mainAppData.name : '无'}</p>

      <button onClick={sendToMain}>给基座应用发送</button>

      <BrowserRouter basename={window.__MICRO_APP_BASE_ROUTE__ || '/'}>
        <div>
          <header className={styles.header}>
            <Link to="/">react-app Home</Link>
            <Link to="/about">react-app About</Link>
          </header>

          <Routes>
            <Route path="/" element={<Home/>}/>
            <Route path="/about" element={<About/>}/>
          </Routes>
        </div>
      </BrowserRouter>
    </div>
  );
}

基座监听的方式有点hacky

虽然没有用到 jsxCustomEvent,但是也一定要 import 进来,且不能把注释给干掉!

// ReactMicroApp.js
import microApp from '@micro-zoe/micro-app'
import {useState} from "react";
/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event'

const ReactMicroApp = () => {
  const [microAppState, setMicroAppState] = useState(microApp.getData('react-app'));

  const sendReactAppData = () => {
    microApp.setData('react-app', {
      name: `react-app 随机数: ${Math.random()}`
    })
  }

  const onDataChange = (e) => {
    console.log('react-app onDataChange', e);
    console.log('react-app 数据', e.detail.data);
    setMicroAppState(e.detail.data);
  }

  return (
    <div>
      <h1>react-app</h1>
      <button onClick={sendReactAppData}>基座 => react-app</button>

      <p>子应用消息 {microAppState ? microAppState.msg : '无'}</p>

      <micro-app
        name='react-app'
        url='http://localhost:3001/'
        baseroute='/react-app'
        onDataChange={onDataChange}
      >
      </micro-app>
    </div>
  )
}

渲染模式

  • 默认模式:每次都按顺序执行一次 JS,具有幂等性
  • umd 模式:只在初次渲染时执行所有 JS,对于需要频繁切换微应用的项目可以提高其性能

共享静态资源

使用 globalAssets 共享资源:

// index.js
import microApp from '@micro-zoe/micro-app'

microApp.start({
  globalAssets: {
    js: ['js地址1', 'js地址2', ...], // js地址
    css: ['css地址1', 'css地址2', ...], // css地址
  }
})

或者使用global属性

<link rel="stylesheet" href="xx.css" global>
<script src="xx.js" global></script>

还有对资源的过滤

<link rel="stylesheet" href="xx.css" exclude>
<script src="xx.js" exclude></script>
<style exclude></style>

生命周期

micro app提供了不同在微应用的生命周期可以执行不同的命令

/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event'

const App = () => {
  return (
    <micro-app
      name='xx'
      url='xx'
      onCreated={() => console.log('micro-app元素被创建')}
      onBeforemount={() => console.log('即将被渲染,只在初始化时执行一次')}
      onMounted={() => console.log('已经渲染完成,只在初始化时执行一次')}
      onAfterhidden={() => console.log('已卸载')}
      onBeforeshow={() => console.log('即将重新渲染,初始化时不执行')}
      onAftershow={() => console.log('已经重新渲染,初始化时不执行')}
      onError={() => console.log('渲染出错')}
    />
  )
}

应用之间跳转

  • window.history
  • 通过数据通信控制跳转
  • 传递路由实例方法

隔离

JS 方面使用 Proxy 拦截了用户全局操作的行为,防止对 window 的访问和修改,避免全局变量污染。

CSS 方面有两种隔离:

  • 默认添加 CSS 选择器前缀
  • ShadowDOM

元素隔离方面,micro-app 模拟实现了类似 ShadowDom 的功能,元素不会逃离 <micro-app> 元素边界,子应用只能对自身的元素进行增、删、改、查的操作。

其他功能

keepAlive

保持微应用的状态 Keep-Alive

<micro-app name='xx' url='xx' keep-alive></micro-app>

prefetch

micro app vs qiankun

qiankun侵入式较强

EMP

基于webpack(repack)和模块联邦实现微前端

systemjs

在微前端架构中,微应用被打包为模块,但浏览器不支持模块化,需要使用 systemjs 实现浏览器中的模块化。

systemjs 是一个用于实现模块化的 JavaScript 库,有属于自己的模块化规范。

在开发阶段我们可以使用 ES 模块规范,然后使用 webpack 将其转换为 systemjs 支持的模块。

// webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");

module.exports = (env) => {
  return {
    mode: "development",
    output: {
      filename: "index.js",
      path: path.resolve(__dirname, "dist"),
      // 注意,这个libraryTarget是必须的
      libraryTarget: env.production ? "system" : "",
    },
    module: {
      rules: [
        {
          test: /.js$/,
          use: { loader: "babel-loader" },
          exclude: /node_modules/,
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        template: "./src/index.html",
      })
    ],
    // 这里不为空就行,如若是vue,改为[vue]即可
    externals: env.production ? ["react", "react-dom"] : [],
  };
};

dist文件夹中会有两个文件,一个index.js,另一个是index.html

// index.js
System.register(
  ["react-dom", "react"],
  function (__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {
    var __WEBPACK_EXTERNAL_MODULE_react_dom__ = {};
    var __WEBPACK_EXTERNAL_MODULE_react__ = {};
    Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react_dom__, "__esModule", {
      value: true,
    });
    Object.defineProperty(__WEBPACK_EXTERNAL_MODULE_react__, "__esModule", {
      value: true,
    });
    return {
      setters: [
        function (module) {
          Object.keys(module).forEach(function (key) {
            __WEBPACK_EXTERNAL_MODULE_react_dom__[key] = module[key];
          });
        },
        function (module) {
          Object.keys(module).forEach(function (key) {
            __WEBPACK_EXTERNAL_MODULE_react__[key] = module[key];
          });
        },
      ],
      execute: function() { 这里是项目的打包后可执行的代码 },
    }
  },
);

经过很轻微地简化,就是如上代码了,这个文件就一个register函数,这就是SystemJs的要求,这个脚本的内容改为被一个register函数包裹,然后第一个参数就是不被打包进来但通过特定方式进行加载(下文会讲到这个特定的加载方式),第二个参数一个函数,主要用来返回我们的项目代码,其中WEBPACKEXTERNALMODULEreactdomWEBPACKEXTERNALMODULE_react则分别对应第一个参数中的需要特定方式加载回来的模块。

结合改造后的html文件和js文件,整个流程可以总结为以下这几步。

  1. 打包生成System.register(依赖列表, 回调函数返回值有一个setters和execute)
  2. react,react-dom 加载后调用setters 将对应的结果赋予给webpack
  3. 调用execute,执行页面渲染

再往细了的讲,我们调用setters,其实就是将需要特定方式加载的包的内容保存到一个execute能调用到的地方。

我们把这种加载机制往微前端方向去思考下,我们将html文件和excute类比为基座,将额外加载的react和react-dom比作子应用,excute调用react/react-dom类比做渲染子应用的内容,是不是觉得微前端通了?事实上,single-spa正是这么做的。

wujie

腾讯方案:wujie

无界微前端方案基于 webcomponent 容器 + iframe 沙箱,能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 框架支持、应用共享等用户的核心诉求

上述微前端方案问题:

qiankun 方案

qiankun 方案是基于 single-spa 的微前端方案。

特点

  1. html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本;
  2. 完备的沙箱方案,js 沙箱做了 SnapshotSandbox、LegacySandbox、ProxySandbox 三套渐进增强方案,css 沙箱做了 strictStyleIsolation、experimentalStyleIsolation 两套适用不同场景的方案;
  3. 做了静态资源预加载能力;

不足

  1. 适配成本比较高,工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作;
  2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重;
  3. 无法同时激活多个子应用,也不支持子应用保活;
  4. 无法支持 vite 等 esmodule 脚本运行;
  5. 主子路由存在依赖

micro-app 是基于 webcomponent + qiankun sandbox 的微前端方案。

特点

  1. 使用 webcomponet 加载子应用相比 single-spa 这种注册监听方案更加优雅;
  2. 复用经过大量项目验证过 qiankun 的沙箱机制也使得框架更加可靠;
  3. 组件式的 api 更加符合使用习惯,支持子应用保活;
  4. 降低子应用改造的成本,提供静态资源预加载能力;

不足

  1. 接入成本较 qiankun 有所降低,但是路由依然存在依赖;(虚拟路由已解决)
  2. 多应用激活后无法保持各子应用的路由状态,刷新后全部丢失;(虚拟路由已解决)
  3. css 沙箱依然无法绝对的隔离,js 沙箱做全局变量查找缓存,性能有所优化;
  4. 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离;
  5. 对于不支持 webcompnent 的浏览器没有做降级处理;

EMP 方案是基于 webpack 5 module federation 的微前端方案。

特点

  1. webpack 联邦编译可以保证所有子应用依赖解耦;
  2. 应用间去中心化的调用、共享模块;
  3. 模块远程 ts 支持;

不足

  1. 对 webpack 强依赖,老旧项目不友好;
  2. 没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉;
  3. 子应用保活、多应用激活无法实现;
  4. 主、子应用的路由可能发生冲突

wujie不支持三层嵌套:https://github.com/Tencent/wujie/issues/859

如果你觉得我的文章对你有帮助的话,希望可以推荐和交流一下。欢迎關注和 Star 本博客或者关注我的 Github