微前端时代思考与实践

3,178 阅读14分钟

前言

技术和架构方案不同,技术可以凭空出现突然爆火没有征兆。但方案或架构一定是为了解决某个问题而出现的,实践之前,请务必先要去搞清楚它是否可以解决当前问题,再者调研是否适合团队,考虑工程价值与产品价值,请不要盲目追求。

原文地址

微前端

熟悉它的人更喜欢称它为前端微服务。

定义

“微前端是一种架构风格,其中众多独立交付的前端应用组合成一个大型整体。”

为什么出现

在传统模式开发中,例如阿里云、腾讯云的控制台。维护一个大型的中后台并且快速迭代是一件很困难的事情,因为它们普遍都有下面几个问题。

  • 技术栈过于陈旧,应用不可维护的问题,想象一下你公司最老的项目突然让你新增feature,用的是jQuery也还好,但用的是Angular1甚至Java Web,透着网线都能感觉到你的痛。
  • 体积过于庞大,从一个普通应用演变成一个巨石应用( Frontend Monolith ),10W+行代码的祖传项目编译后即使抽离了dll,主包也起码要5M以上,编译慢且开发体验极差。
  • 技术栈单一,无法满足业务需求。每个框架都有其优点,择其长处利用之岂不美哉?
  • 重构代价大,无法步进式重构,即每次只重构一个模块,并且不影响现有版本的稳定性。只能一次性发布所有模块,风险大。

有没有一种方案能够解决这些问题?

借鉴服务端微服务的设计思想,前端微服务化就出现了。它虽然解决不了全部,但能尽小减轻负担和风险。它的实现更像是将整个项目变成一个“组件”,平台可以自由的组装这些组件。简而言之,单一的单体应用转变为多个小型前端应用聚合为一的应用。

微服务化之前

alt
微服务化之后
alt

解决了什么问题

模块复杂度可控,团队独立自治

每个模块(微服务)由一个开发团队完全掌控,易于管理和维护,快速整合业务。虽然可能会让各个团队的工作愈加分裂,但是只要控制在合理水平上还是利大于弊的。

alt

独立开发与部署,子仓库独立

就像微服务一样,每个模块都具备独立运行的能力,这也代表可以独立部署。通过逐渐缩减每次部署的覆盖面降低风险。

alt

更具扩展能力,增量升级

年份陈旧的大型前端应用的技术栈掌握的技术人员大多不在岗位上,到了重写整个前端应用的时候一次性重写整个应用风险太大,能够以增量式的风格来重写、升级、迭代,一点点换掉老的应用,同时在不受单体架构拖累的前提下为客户提供新功能。而且理论上来说可以支持大型单页应用无限拓展。虽然不具备SPA应用天然的优势,但是也摆脱了强耦合的应用技术栈。

技术栈无关,创新自主

主框架不限制接入应用的技术栈,如果我们想尝试新的技术或者是基于性能上有更好的实现,完全具备自主权。

现有的微前端方案有:

适合什么样的场景

答案很明显:准备祖传的项目。

单个团队没有理由采用微前端,还有需要快速开发的应用或者粒度较小的小型应用也不适用。

HOW DO

但也面临一些问题和挑战。

  • 如何在一个页面里渲染多种技术栈。
  • 不同技术栈模块之间如何通信。
  • 如何结合不同技术栈的路由,使其正确触发;hash与history模式处理;
  • 应用加载及生命周期管理。
  • 如何隔离应用,也就是沙盒应用。
  • 在考虑打包优化情况下每个项目如何打包,合并到一起。
  • 微服务化后如何进行业务开发。
  • 多个团队间应该如何协作。

如何在一个页面里渲染多种技术栈。

构建时集成

Single-SPA 它可以帮助我们在同一个页面使用多种框架((React、Vue、AngularJS、svelte、Ember等多个框架)。并且每个独立模块的代码可以做到按需加载、独立运行,其工作机制是命中到prefix时激活相应入口应用。

使用 registerApplication 注册应用,签名

appName: string
应用程序名称

applicationOrLoadingFn: () => <Function | Promise>
必须是一个加载函数,要么返回已加载的应用,要么返回一个Promise。

activityFn: (location) => boolean
必须是纯函数。这个函数使用 window.location 作为第一个参数,当应用处于激活状态时返回状态对应的值。

customProps?: Object = {}
props 将在每个生命周期方法期间传递给应用。

最后通过 singleSpa.start() 启动。

import * as singleSpa from 'single-spa';
const appName = 'reactapp';
// 加载 React 应用入口文件
const loadingFunction = () => import('./react/app.js');

// 当前路由为/reactapp时为true
const activityFunction = location => location.pathname.startsWith('/reactapp');

// 注册应用
singleSpa.registerApplication(appName, loadingFunction, activityFunction ,{ token: 'xxx'});

// 启动single-spa
singleSpa.start();

single-spa内置了四个生命周期 Hook,分别是bootstrap, mount, unmount, unload,每个生命周期必须返回 Promise 或者是 asyncFunction.

// app1.js
let domEl;
const gen = () => Promise.resolve();

export function bootstrap(props) {
    return gen().then(() => {
          // 首次安装时会被调用一次,也就是路由命中的时候
            domEl = document.createElement('div');
            domEl.id = 'app1';
            document.body.appendChild(domEl);
            console.log('app1 is bootstrapped!')
        });
}

export function unload(props) {
  return gen().then(() => {
      // 卸载注册应用,可以理解为删除,只有主动调用 unloadApplication 才会触发,相对应的是bootstrap
      console.log('app1 is unloaded!');
    });
}

export function mount(props) {
    return gen().then(() => {
          // mounted Component
            domEl.textContent = 'App1.js mounted'
            console.log('app1 is mounted!')
        });
}
export function unmount(props) {
    return gen().then(() => {
          // unmounted Component
            domEl.textContent = '';
            console.log('app1 is unmounted!')
        })
}

这样一个简单的应用就完成了。光说不练假把式,从无到有写一个支持react, angular, vue, svelte 的demo。

先定义HTML结构

<div class="micro-container">
  <div class="navbar">
    <ul>
      <a onclick="singleSpaNavigate('/react')">
        <li>React App</li>
      </a>
      <a onclick="singleSpaNavigate('/vue')">
        <li>Vue App</li>
      </a>
      <a onclick="singleSpaNavigate('/svelte')">
        <li>Svelte App</li>
      </a>
      <a onclick="singleSpaNavigate('/angular')">
        <li>Angular App</li>
      </a>
    </ul>
  </div>
  <div id="container">
    <div id="react-app"></div>
    <div id="vue-app"></div>
    <div id="angular-app"></div>
    <div id="svelte-app"></div>
  </div>
</div>

上面的div.micro-container称为容器应用。每个页面除了包含一个容器应用外,还有可能包含多个micro-frontendsingleSpaNavigate 方法是single-spa内置的导航Api,可以在已注册的application之间执行 url Navigation ,而且无需处理 event.preventDefault pushState方法等。然后再定义 entry,以下是伪结构。

├─.babelrc
├─assets
│ └─styles
├─index.html
├─package.json
├─src
│ ├─angular
│ │ ├─app.js
│ │ ├─root.component.ts
│ │ ├─components
│ │ └─routes
│ ├─baseApplication
│ │ └─index.js // register Application
│ ├─react
│ │ ├─app.js
│ │ ├─components
│ │ ├─root.component.js
│ │ └─routes
│ ├─svelte
│ │ ├─app.js
│ │ ├─components
│ │ ├─root.component.svelte
│ │ └─routes
│ └─vue
│   ├─app.js
│   ├─components
│   ├─root.component.vue
│   └─routes
├─tsconfig.json
└─webpack.config.js
// src/baseApplication/index.js
import * as singleSpa from 'single-spa';

singleSpa.registerApplication('react', () => import ('../react/app.js'), pathPrefix('/react'));
singleSpa.registerApplication('vue', () => import ('../vue/app.js'), pathPrefix('/vue'));
singleSpa.registerApplication('angular', () => import ('../angular/app.js'), pathPrefix('/angular'));
singleSpa.registerApplication('svelte', () => import ('../svelte/app.js'), pathPrefix('/svelte'));

singleSpa.start();

function pathPrefix(prefix) {
  return function(location) {
    return location.pathname.startsWith(`${prefix}`);
  }
}

以React 和 Vue 为例,当应用被import后,抛出的 boostrapmountunmount 会被执行,

// src/vue/app.js
import Vue from 'vue/dist/vue.min.js';
import singleSpaVue from 'single-spa-vue';
import router from './router';
import Loading from './Loading';

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    router,
    el:'#vue-app',
    template: `
      <div id="vue-app">
        <router-view></router-view>
      </div>
    `,
    loadRootComponent: Loading
  },
});

export const bootstrap = (props) => {
  console.log('vue-app is bootstrap')
  return vueLifecycles.bootstrap(props);
}
export const mount = (props) =>  {
  console.log('vue-app is Mounted')
  return vueLifecycles.mount(props);
}
export const unmount = (props) =>  {
  console.log('vue-app is unMounted')
  return vueLifecycles.unmount(props);
}

bootstrapmount 这些钩子就不凑字数了,自行补上...

// src/react/app.js
import React from 'react';
import ReactDOM from 'react-dom';
import singleSpaReact from 'single-spa-react';
import Root from '@React/root.component.js';

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: Root,
  domElementGetter: () => document.getElementById('react-app') // 节点getter
});
// ...other
// src/svelte/app.js
import singleSpaSvelte from 'single-spa-svelte';
import AppComponent from './root.component.svelte';

const svelteLifecycles = singleSpaSvelte({
  component: AppComponent,
  domElementGetter: () => document.getElementById('svelte-app'),
});
// ...other

single-spa已经提供了大部分主流框架的对接工具库,内部对其做了适应工作,将 entry baseApplicationcommon-dependencies 注入到 html,如果只需要单一版本的话则把它放在公共依赖。

// webpack.config.js
entry: {
    'baseApplication': 'src/baseApplication/index.js',
    'common-dependencies': [
      'core-js/client/shim.min.js',
      '@angular/common',
      '@angular/compiler',
      '@angular/core',
      '@angular/platform-browser-dynamic',
      '@angular/router',
      'reflect-metadata',
      'react',
      'react-dom',
      'react-router',
      'react-router-dom',
      "vue",
      "vue-router",
      "svelte",
      "svelte-routing"
    ],
  },
  plugins:[
     new HTMLWebpackPlugin({
      template: path.resolve('index.html'),
      inject: true,
      chunksSortMode: 'none'
    }),
  ]

源码已经上传到github

借助 single-spa 提供的 Events 钩子,可以实现子应用的 LiftCycle Hooks。从而在子应用 boostrap与 unmount 进行全局变量冻结之类的事情避免变量污染。

window.addEventListener('single-spa:before-routing-event',evt => {
    'route Event事件发生之前(hashchange,popstate或triggerAppChange之后都会触发)';
});

'single-spa:routing-event'       => 'route Event事件后触发'

'single-spa:app-change'          => 'app change'

'single-spa:no-app-change'       => '与app-change相反,app nochange 时触发'

'single-spa:before-first-mount'  => '挂载第一个应用前'

'single-spa:first-mount'         => '挂载第一个应用后'

创建一个 setDefaultMountedApp 方法,其功能为指定默认挂载的 App。

function setDefaultMountedApp(path) {
  window.addEventListener(`single-spa:no-app-change`, () => {
    const activedApps = getMountedApps()
    if (activedApps.length === 0) {
      navigateToUrl(path)
    }
  }, {
    once: true
  })
}

alt

这里由于使用了single-spa从而避免了刷新页面造成的子应用404问题。我们成功从应用分发路由到路由分发应用,似乎是达到想要的效果,之前的问题真的解决了吗?

alt

打包结果

alt

目前加载的是 /react,却依赖了整个公共依赖包,随着业务复杂,项目中组件库与其他库迅速发生“滚雪球效应”,依赖包体积的增大代表 FCP(First Contentful Paint) 也随之变长,即便在一个页面内实现渲染了多种技术栈,其根本意义还是属于大型整体应用、解耦性差、不能独立部署,未对各应用进行隔离,一旦某个应用崩溃仍然会引发整体应用崩溃。所以问题还是存在着,只是以另一种形式体现,

这种方式被称为构建时集成,它通常会生成一个可部署的 Javascript 包,虽然我们可以从各种应用中删除重复依赖。但这意味着我们修改 app 的任何功能时都必须重新编译和发布所有微前端。这种齐步走的发布流程在微服务里已经够让我们好受了,所以强烈建议不要用它来实现微前端架构。好不容易实现了解耦和独立,别在发布阶段又绕回去。

问题回到本质上,我们的目的就将应用分离解耦,集成部署的同时也支持独立运行、独立部署,我们得在运行时中也集成微前端。

运行时集成

除了使用原生JavaScript,运行时集成通常三种方式实现:

  • iframe
  • web Component
  • SystemJs

iframe

<iframe id="micro-frontend-container"></iframe>
<script type="text/javascript">
  const microFrontendsByRoute = {
    '/': 'https://browse.example.com/index.html',
    '/order-food': 'https://order.example.com/index.html',
    '/user-profile': 'https://profile.example.com/index.html',
  };

  const iframe = document.getElementById('micro-frontend-container');
  iframe.src = microFrontendsByRoute[window.location.pathname];
</script>

优点

简单、粗暴,天生自带沙盒,适用于三方业务引入。

缺点

SEO差;页面响应速度慢;灵活性差;路由深层连接复杂;使用postMessage进行消息通信侵入性太强;双滚动条;iframe内部的DOM获取页面高度;遮罩无法覆盖外部;刷新回到iframe首页等问题。这种先甜后苦后人背锅的事情我们可做不来,强烈不推荐。

web Component

web Component 由四个部分组成,

  • Custom elements 自定义元素
  • Shadow DOM 隔离样式
  • HTML templates 模板
  • HTML Imports 导入

这里有个简单的Demo

目前React、Preact、Vue、Angular 对 Web component 都有支持,例如

class SearchBar extends HTMLElement {
  constructor() {
    super();
    this.mountPoint = document.createElement('div');
    this.attachShadow({ mode: 'open' }).appendChild(this.mountPoint); // 指定 open 模式
  }

  connectedCallback() {
    const initialValue = this.getAttribute('initialValue') || '';
    ReactDOM.render(<input value={initialValue} placeholder="Search..." />, this.mountPoint);
  }

  disconnectedCallback() {
    console.log(`${this.nodeName} is Remove`);
  }
}
customElements.define('search-bar', SearchBar);

// index.html
<search-bar defaultValue="field" />

connectedCallback 在被插入到DOM时执行,其时机相当于 React.componentDidMount。与之对应的是disconnectedCallback——React.componentWillUnMount.

这样我们就可以通过创建 app 自定义应用组件,根据路由动态插入。

<script src="https://base.xxx.com/bundle.js"></script>
<script src="https://order.xxx.com/bundle.js"></script>
<script src="https://profile.xxx.com/bundle.js"></script>

<div id="root-contariner"></div>

<script type="text/javascript">
  const webComponentsByRoute = {
    '/': 'base-dashboard',
    '/order-food': 'order-food',
    '/user-profile': 'user-profile',
  };
  const webComponentType = webComponentsByRoute[window.location.pathname];

  const root = document.getElementById('root-contariner');
  const webComponent = document.createElement(webComponentType);
  root.appendChild(webComponent);
</script>

优点

满足所有需求

缺点

  1. 侵入性大,相当于重写现有的所有前端应用,不适用于过渡。
  2. 生态尚未建立完善,手动造轮子耗时。
  3. 组件间通信问题随着业务复杂随之也变得难以管理。
  4. 仍然是兼容性问题,我们不需要“弃车保帅”。

SystemJs

SystemJs 是一个模块加载器,支持AMD、CommonJS、ES6等各种格式的JS模块动态加载。搭配 single-spa 再好不过。

首先将各个子应用抽离出来,概览结构如下:

├─cra-ts-app
│ ├─config
│ ├─images.d.ts
│ ├─package.json
│ ├─public
│ ├─scripts
│ ├─src
│ │ ├─index.css
│ │ ├─index.tsx
│ │ └─registerServiceWorker.ts
│ ├─tsconfig.json
│ ├─tsconfig.prod.json
│ ├─tsconfig.test.json
│ ├─tslint.json
├─nav // 导航栏
│ ├─package.json
│ ├─src
│ │ ├─app.js
│ │ └─root.component.js
│ └─webpack.config.js
├─package.json
├─portal // 入口
│ ├─index.html
│ ├─index.js
│ ├─package.json
│ └─webpack.config.js
├─react
│ ├─package.json
│ ├─src
│ │ ├─app.js
│ │ ├─main.js
│ │ ├─root.component.js
│ │ ├─routes
│ └─webpack.config.js
├─rts
│ ├─build
│ ├─package.json
│ ├─postcss.config.js
│ ├─public
│ ├─src
│ │ ├─app.tsx
│ │ ├─index.tsx
│ │ └─views
│ ├─tsconfig.json
│ └─types
├─svelte
│ ├─package.json
│ ├─src
│ │ ├─app.js
│ │ ├─root.component.svelte
│ │ └─routes
│ └─webpack.config.js
├─vts
│ ├─babel.config.js
│ ├─package.json
│ ├─public
│ ├─src
│ │ ├─App.vue
│ │ ├─assets
│ │ ├─components
│ │ ├─main.ts
│ │ ├─registerServiceWorker.ts
│ │ ├─router
│ │ ├─shims-tsx.d.ts
│ │ ├─shims-vue.d.ts
│ │ ├─store
│ │ └─views
│ ├─tsconfig.json
└─vue
  ├─package.json
  ├─src
  │ ├─app.js
  │ ├─app.vue
  │ ├─components
  │ ├─main.js
  │ ├─root.component.vue
  │ ├─router.js
  │ ├─routes
  └─webpack.config.js

alt

最终八个技术栈或版本各不相同的子应用,每个子应用可以单独作为一个仓库存在并管理,portal 作为一个入口项目,用于整合和注册各应用,Portal 也是一个主项目,给它的定位是资源加载框架, Nav 作为导航路由,其他的应用作为子应用。

框架应用的本质是一个中心化部件,越简单也就越稳定,所以不要在Portal中做任何UI及业务逻辑。可以在 Portal 来做一些系统级公共支持,e.g. 登录验证、权限管理、鉴权、性能监控、错误调用栈上报等。

alt

portal 主应用代码如下:

import { getMountedApps, registerApplication, start, navigateToUrl, getAppNames } from 'single-spa';
import SystemJS from 'systemjs/dist/system' // 0.20.24 DEV!!!

const apps = [
  { name: 'nav', url: true, entry: '//localhost:5005/app.js', customProps: {} },
  { name: 'react', url: '/react', entry: '//localhost:5001/app.js', customProps: {} },
  { name: 'vue', url: '/vue', entry: '//localhost:5002/app.js', customProps: {} },
  { name: 'svelte', url: '/svelte', entry: '//localhost:5003/app.js', customProps: {} },
  { name: 'react-ts', url: '/rts', entry: '//localhost:5006/app.js', customProps: {} },
  { name: 'cra-ts', url: '/crats', entry: '//localhost:5007/app.js', customProps: {} },
  { name: 'vts', url: '/vts', entry: '//localhost:5008/vts/index.js', customProps: {} },
]

/**
 * RegisterApp
 * @returns
 */
async function registerAllApps() {
  await Promise.all(apps.map(registerApp))
  await setDefaultMountedApp('/react');
  start();
}

registerAllApps();

/**
 * set default App
 * @param {*} path default app path
 */
function setDefaultMountedApp(path) {
  window.addEventListener(`single-spa:no-app-change`, (evt) => {
    const activedApps = getMountedApps()
    if (activedApps.length === 0 && evt.target.location.pathname === '/') {
      navigateToUrl(path)
    }
  }, {
    once: true
  })
}

/**
 * register App
 * @param {*} name App Name
 * @param {*} url visit Url
 * @param {*} entry entry file
 * @param {*} customProps custom Props
 */
function registerApp({ name, url, entry, customProps = {} }) {
  // 可以通过customProps来传递store与用户权限之类
  return registerApplication(name, () => SystemJS.import(entry), pathPrefix(url), customProps);
}

改动不大,上文说到过独立运行、独立部署,这套方案目前还是不完全的,想搭建一个符合要求的微前端架构,通过动态获取各子应用的入口写入 Portal 主应用中,以及路由、打包后的公共依赖抽离等等。

美团使用的方案就是类似 用微前端的方式搭建类单页应用

  • 发布最新的静态资源文件
  • 重新生成entry-xx.js和index.html(更新入口引用)
  • 重启前端服务

我理想中微前端的单个子应用应该还具备单独作为一个项目产品上线,所以需要将入口文件分离,single-spa 子应用入口 与 普通应用分离,方式有很多,比如双入口文件处理,或者双打包配置,但是这种不仅麻烦容易出错而且比我想象中的还要复杂,不仅仅是方案上的问题,试想一下,某个子应用拿出来单步部署,而登录及鉴权系统在 Portal 其某个子应用中,难道又要将两个项目合并成一个新的微前端?想想也就觉得自己搞笑。

除此之外,这套方案存在一些问题,e.g.

  • 使用 @vue/cli 路由动态import Component,返回的其实是一个html。
  • 旧项目可能涉及到多entry。
  • 子应用卸载后样式未清理。
  • 公共依赖仍未抽离。
  • 入口只能是单个 JavaScript 包,打包出来的 JS Entry 包太大,不能利用 code Splitting 分包利用并行资源加载。

后来借鉴了 qiankun 针对这几个问题则使用 HTML Entry 的方式。即以 {entry:'//localhost:5001/index.html'}的形式引入;它可以很轻松的解决上述大部分问题。

function render({ appContent, loading }) {
  ReactDOM.render(<Framework loading={loading} content={appContent} />, document.getElementById('container'));
}
render({ loading: true });

function genActiveRule(routerPrefix) => location => location.pathname.startsWith(routerPrefix);

const appGroup = [
  { name: 'react app', entry: '//localhost:7100', render, activeRule: genActiveRule('/react') },
  { name: 'react15 app', entry: '//localhost:7101', render, activeRule: genActiveRule('/react15') },
  { name: 'vue app', entry: '//localhost:7102', render, activeRule: genActiveRule('/vue') },
]
// 注册应用集
registerMicroApps(appGroup);

registerMicroApps 大概实现如下

let microApps: RegistrableApp[] = [];

export function registerMicroApps<T extends object = {}>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles: LifeCycles<T> = {},
  opts: RegisterMicroAppsOpts = {},
) {
  const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = lifeCycles;
  const { fetch } = opts;
  microApps = [...microApps, ...apps];

  let prevAppUnmountedDeferred: Deferred<void>;

  apps.forEach(app => {
    const { name, entry, render, activeRule, props = {} } = app;

    registerApplication(
      name,

      async ({ name: appName }) => {
        await frameworkStartedDefer.promise;

        // 获取入口 html 模板及脚本加载器 及 资源Domain
        const { template: appContent, execScripts, assetPublicPath } = await importEntry(entry, { fetch });
        // 卸载完后再加载
        if (await validateSingularMode(singularMode, app)) {
          await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
        }
        // 第一次加载设置应用可见区域 dom 结构
        // 确保每次应用加载前容器 dom 结构已经设置完毕
        render({ appContent, loading: true });

        let jsSandbox: Window = window;
        let mountSandbox = () => Promise.resolve();
        let unmountSandbox = () => Promise.resolve();

        if (useJsSandbox) {
          const sandbox = genSandbox(appName, assetPublicPath);
          jsSandbox = sandbox.sandbox;
          mountSandbox = sandbox.mount;
          unmountSandbox = sandbox.unmount;
        }

        await execHooksChain(toArray(beforeLoad), app);

        // eval
        let { bootstrap: bootstrapApp, mount, unmount } = await execScripts(jsSandbox);
        // ...other
        return {
          bootstrap: [bootstrapApp],
          mount: [
            async () => {
              if ((await validateSingularMode(singularMode, app)) && prevAppUnmountedDeferred) {
                return prevAppUnmountedDeferred.promise;
              }
              return undefined;
            },
            async () => execHooksChain(toArray(beforeMount), app),
            async () => render({ appContent, loading: true }),
            mountSandbox,
            mount,
            async () => render({ appContent, loading: false }),
            async () => execHooksChain(toArray(afterMount), app),
            async () => {
              if (await validateSingularMode(singularMode, app)) {
                prevAppUnmountedDeferred = new Deferred<void>();
              }
            },
          ],
          unmount: [
            async () => execHooksChain(toArray(beforeUnmount), app),
            unmount,
            unmountSandbox,
            async () => execHooksChain(toArray(afterUnmount), app),
            async () => render({ appContent: '', loading: false }),
            async () => {
              if ((await validateSingularMode(singularMode, app)) && prevAppUnmountedDeferred) {
                prevAppUnmountedDeferred.resolve();
              }
            },
          ],
        };
      },

      activeRule,
      props,
    );
  });
}

从HTML模板中提取出所有脚本样式等资源,样式直接写入html template,在沙盒部分的处理上,qiankun 利用 Proxy 劫持了对 window 的操作,使其作用到一个空字典上,在 bootstrap 及 mount 生命周期之前分别get全局状态打下快照,并使用 Map 记录下来,避免污染了全局对象,这样在沙盒 unmount 的时候也不需要手动去销毁,至于怎样将脚本默认 window 指向这个空字典也很简单,通过eval将 window 指向 window.proxy 也就是空字典。

geval(`;(function(window){;${inlineScript}\n}).bind(window.proxy)(window.proxy);`);

关于css隔离,由于重写了html Entry,之前的内嵌样式也自然不复存在了。其实还有一种隔离 css 的方式,与 BEM 相同,通过 postcss 去设置子应用内 class 前缀,同时支持第三方库,至于css-module就不说了,兼容性问题,比如我司还有jquery项目,这你让谁给我转去?/手动滑稽

然后剩下的就是 Lifecycle 内部的处理了。

function execHooksChain<T extends object>(hooks: Array<Lifecycle<T>>, app: RegistrableApp<T>): Promise<any> {
  if (hooks.length) {
    return hooks.reduce((chain, hook) => chain.then(() => hook(app)), Promise.resolve());
  }

  return Promise.resolve();
}

如果采用JS Entry的方式会浪费更多时间与精力去优化。最终采用了HTML Entry的方式,简直像极了HTMLless。

这种完全将项目独立出去的方案虽然能避免很多问题,但是也存在一个性能优化上的问题——公共依赖,如果十个子应用都是用同一技术栈,那么在打包时即使依赖抽离子应用之间也毫无关系,这其实并没有一个好的解决方案,像React、React-DOM、Svelte、Vue之类占据大部分体积的包应该建立一个公共依赖池,把他们挂载在同一CDN下外链加载并通过extenals引入。

e.g.

A子应用React@16.10.1 + B子应用 React@16.10.2 => A+BReact@16.10.2

由于修订号保持向下兼容,修复问题但不影响特性,只要次版本号相同,修订号保持向上兼容则功能相同,利用CDN缓存尽最大程度的避免重复依赖的资源加载,也可以采用资源映射表之类的方法。

最后就是跨应用通信了,大部分人习惯Redux之类全局状态管理库的存在,但是为了降低耦合度,我们应该避免去应用间通信,如果必要的话,Custom Events 可以做到,但一定要把握好这个度。另一种方式就是以Portal主应用 bridge 向下传递数据和回调。

可能有人觉得我前面扯了一大堆到头来全部推翻感情浪费时间,“知其然而不知其所以然”,总不能知道什么是好的就直接拿来用都不知道好在哪吧?适合自己的方案才是最好的,但是没有实践,怎会知道合不合适?

123102034565_01

最后附上使用qiankun写的一个case 以及repo

参考