微前端時代思考與實踐

今天是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

參考

相關文章
相關標籤/搜索