微前端时代思考与实践

今天是12月31号,算上掘金推送的时差,就提早跨年了,祝你们元旦快乐,新的一年,我不祝你一路顺风,我祝你乘风破浪。javascript

前言

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

原文地址html

微前端

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

定义

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

为何出现

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

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

有没有一种方案可以解决这些问题?node

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

微服务化以前 jquery

alt
微服务化以后
alt

解决了什么问题

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

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

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

参考

相关文章
相关标签/搜索