探索 webpack5 新特性 Module federation 在騰訊文檔的應用

原文: 探索webpack5新特性Module federation在騰訊文檔的應用 | AlloyTeam
做者:TAT.jay

前言:javascript

webpack5的使人激動的新特性Module federation可能並不會讓不少開發者激動,可是對於深受多應用傷害的騰訊文檔來講,倒是着實讓人眼前一亮,這篇文章就帶你瞭解騰訊文檔的困境以及Module federation能夠如何幫助咱們走出這個困境。

0x1 騰訊文檔的困境

1.1 多應用場景背景

騰訊文檔從功能層面上來講,用戶最熟悉的可能就是word、excel、ppt、表單這四個大品類,四個品類彼此獨立,可能由不一樣的團隊主要負責開發維護,那從開發者角度來講,四個品類四個倉庫各自獨立維護,好像事情就很簡單,可是現實狀況實際上卻複雜不少。咱們來看一個場景:html

通知中心的需求

image-20200329125332068

對於複雜的權限場景,爲了讓使用者能快速能得到最新狀態,咱們實際上有一個通知中心的需求,在pc的樣式大體就是上圖裏面的樣子。這裏是在騰訊文檔的列表頁看到的入口,實際上在上面提到的四大品類裏面,都須要嵌入這樣的一個頁面。前端

那麼問題來了,爲了最小化這裏的開發和維護成本,確定是各個品類公用一套代碼是最好的,那最容易想到的就是使用獨立npm包的方式來引入。確實,騰訊文檔的內部不少功能如今是使用npm包的方式來引入的,可是實際上這裏會遇到一些問題:java

問題一:歷史代碼

騰訊文檔的歷史很複雜,簡而言之,在剛開始的時候,代碼裏面是不支持寫ES6的,因此沒辦法引入npm包。一時半會想改造完成是不現實的,產品需求也不會等你去完成這樣的改造node

問題二:發佈效率

這裏的問題實際上也是如今咱們使用npm包的問題,其實仍是咱們懶,想投機取巧。以npm包的方式引入的話,一旦有改動,你須要改5個倉庫(四個品類+列表頁)去升級這裏的版本,實際上發佈成本是蠻大的,對於開發者來講其實也很痛苦react

1.2 咱們的解決方案

爲了能在不支持ES6代碼的環境下快速引入React來加速需求開發,咱們想出了一個所謂的Script-Loader(下面會簡稱SL)的模式。jquery

總體架構如圖:webpack

image-20200329131110457

簡單來講就是,參考jquery的引入方式,咱們用另一個項目去實現這些功能,而後把代碼打包成ES5代碼,對外提供不少接口,而後在各個品類頁,引入咱們提供的加載腳本,內部會自動去加載文件,獲取每一個模塊的js文件的CDN地址而且加載。這樣作到各個模塊各自獨立,而且全部模塊和各個品類造成獨立。git

在這種模式下,每次發佈,咱們只須要去發佈各個改動的模塊以及最新的配置文件,其餘品類就能得到自動更新。github

這個模式並不必定適合全部項目,也不必定是最好的解決方案,從如今的角度來看,有點像微前端的概念,可是實際上卻也是有區別的,這裏就不展開了。這種模式目前確實能解決騰訊文檔這種多應用複用代碼的需求。

1.3 遇到的問題

這種模式本質上目前沒有很嚴重的問題,可是有一個很痛點一直困擾咱們,那就是品類代碼和SL的代碼共享問題。舉個例子:

Excel品類改造後使用了React,SL的模塊A、模塊B、模塊C引入了React

由於SL的模塊之間是各自獨立的,因此React也是各自打包的,那就是說當你打開Excel的時候,若是你用了模塊A、B、C,那你最終頁面會加載四份React代碼,雖然不會帶上什麼問題,可是對於有追求的前端來講,咱們仍是想去解決這樣的問題。

解決方案: External

對於React來講,咱們能夠默認品類是加載了React,因此咱們直接把SL裏面的React配置爲External,這樣就不會打包了,可是實際上狀況沒有這麼簡單:

問題一:模塊可能獨立頁面

就以上面的通知中心來講,在移動端上面就不是嵌入的了,並且獨立頁面,因此這個獨立頁面須要你手動引入React

問題二:公共包不匹配

簡單來講,就是SL依賴的包,在品類裏面可能並無使用,例如Mobx或者Redux

問題三:不是全部包均可以直接配置External

這裏的問題是說像React這種包咱們能夠經過配置External爲window.React來達到共用,可是不是全部包均可以這樣的,那對於不能配置爲全局環境的包來講,還無法解決這裏的代碼共享問題

基於這些問題,咱們目前的選擇是一種折中方案,咱們把能夠配置全局環境的包提取出來,每一個模塊指明依賴,而後在SL內部,加載模塊代碼以前會去檢測依賴,依賴加載完成纔會加載執行實際模塊代碼。

這種方式有很大問題,你須要手動去維護這樣的依賴,每一個共享包實際上你都是須要單獨打包成一個CDN文件,爲的是當依賴檢測失敗的時候,能夠有一個兜底加載文件。所以,實際上目前也只有React包作了這個共享。

那麼到這裏,核心問題就變成了品類代碼和SL如何作到代碼共享。對於其餘項目來講,其實也就是多應用如何作到代碼共享

0x2 webpack的打包原理

爲了解決上面的問題,咱們實際上想從webpack入手,去實現這樣的一個插件幫咱們解決這個問題。核心思路就是hook webpack的內部require函數,在這以前咱們先來看一下webpack打包後的一些原理,這個也是後面理解Module federation的核心。若是這裏你比較熟悉,也能夠快速跳過到第三節,可是不熟悉的同窗仍是建議認真瞭解一下。

2.1 chunk和module

webpack裏面有兩個很核心的概念,叫chunk和module,這裏爲了簡單,只看js相關的,用筆者本身的理解去解釋一下他們直接的區別:

module:每個源碼js文件其實均可以當作一個module

chunk:每個打包落地的js文件其實都是一個chunk,每一個chunk都包含不少module

默認的chunk數量其實是由你的入口文件的js數量決定的,可是若是你配置動態加載或者提取公共包的話,也會生成新的chunk。

2.2 打包代碼解讀

有了基本理解後,咱們須要去理解webpack打包後的代碼在瀏覽器端是如何加載執行的。爲此咱們準備一個很是簡單的demo,來看一下它的生成文件。

src
---main.js
---moduleA.js
---moduleB.js

/**
* moduleA.js
*/
export default function testA() {
    console.log('this is A');
}


/**
* main.js
*/
import testA from './moduleA';

testA();

import('./moduleB').then(module => {

});

很是簡單,入口js是main.js,裏面就是直接引入moduleA.js,而後動態引入 moduleB.js,那麼最終生成的文件就是兩個chunk,分別是:

  1. main.jsmoduleA.js組成的bundle.js
  2. `moduleB.js組成的0.bundle.js

若是你瞭解webpack底層原理的話,那你會知道這裏是用mainTemplate和chunkTemplate分別渲染出來的,不瞭解也不要緊,咱們繼續解讀生成的代碼

import變成了什麼樣

整個main.js的代碼打包後是下面這樣的

(function (module, __webpack_exports__, __webpack_require__) {

    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */
    var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*!         ./moduleA */ "./src/moduleA.js");


    Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__["default"])();

    __webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB             */ "./src/moduleB.js")).then(module => {

    });

})

能夠看到,咱們的直接import moduleA最後會變成webpack_require,而這個函數是webpack打包後的一個核心函數,就是解決依賴引入的。

webpack_require是怎麼實現的

那咱們看一下webpack_require它是怎麼實現的:

function __webpack_require__(moduleId) {
    // Check if module is in cache
    // 先檢查模塊是否已經加載過了,若是加載過了直接返回
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    // 若是一個import的模塊是第一次加載,那以前必然沒有加載過,就會去執行加載過程
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
}

若是簡化一下它的實現,其實很簡單,就是每次require,先去緩存的installedModules這個緩存map裏面看是否加載過了,若是沒有加載過,那就從modules這個全部模塊的map裏去加載。

modules從哪裏來的

那相信不少人都有疑問了,modules這麼個相當重要的map是從哪裏來的呢,咱們把bundle.js生成的js再簡化一下:

(function (modules) {})({
    "./src/main.js": (function (module, __webpack_exports__, __webpack_require__) {}),
    "./src/moduleA.js": (function (module, __webpack_exports__, __webpack_require__) {})
});

因此能夠看到,這實際上是個當即執行函數,modules就是函數的入參,具體值就是咱們包含的全部module,到此,一個chunk是如何加載的,以及chunk如何包含module,相信你們必定會有本身的理解了。

動態引入如何操做呢

上面的chunk就是一個js文件,因此維護了本身的局部modules,而後本身使用沒啥問題,可是動態引入咱們知道是會生成一個新的js文件的,那這個新的js文件0.bundle.js裏面是否是也有本身的modules呢?那bundle.js如何知道0.bundle.js裏面的modules

先看動態import的代碼變成了什麼樣:

__webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB             */ "./src/moduleB.js")).then(module => {

});

從代碼看,實際上就是外面套了一層webpck_require.e,而後這是一個promise,在then裏面再去執行webpack_require。

實際上webpck_require.e就是去加載chunk的js文件0.bundle.js,具體代碼就不貼了,沒啥特別的。

等到加載回來後它認爲bundle.js裏面的modules就必定會有了0.bundle.js包含的那些modules,這是如何作到的呢?

咱們看0.bundle.js究竟是什麼內容,讓它擁有這樣的魔力:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push(
    [
        [0],
        {
            "./src/moduleB.js": (function (module, __webpack_exports__, __webpack_require__) {})
        }
    ]
);

拿簡化後的代碼一看,你們第一眼想到的是jsonp,可是很遺憾的是它不是一個函數,卻只是向一個全局數組裏面push了本身的模塊id以及對應的modules。那看起來魔法的核心應該是在bundle.js裏面了,事實的確也是如此。

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

bundle.js的裏面,咱們看到這麼一段代碼,其實就是說咱們劫持了push函數,那0.bundle.js一旦加載完成,咱們豈不是就會執行這裏,那不就能拿到全部的參數,而後把0.bundle.js裏面的全部module加到本身的modules裏面去!

2.3 總結一下

若是你沒有很理解,能夠配合下面的圖片,再把上面的代碼讀幾遍。

image-20200329143727089

其實簡單來講就是,對於mainChunk文件,咱們維護一個modules這樣的全部模塊map,而且提供相似webpack_require這樣的函數。對於chunkA文件(多是由於提取公共代碼生成的、或者是動態加載)咱們就用相似jsonp的方式,讓它把本身的全部modules添加到主chunk的modules裏面去。

2.4 如何解決騰訊文檔的問題?

基於這樣的一個理解,咱們就在思考,那騰訊文檔的多應用代碼共享能不能解決呢?

具體到騰訊文檔的實際場景,就是以下圖:

image-20200329143446668

由於是獨立的項目,因此webpack打包也是有兩個mainChunk,而後有各自的chunk(其實這裏會有chunk覆蓋或者chunk裏面的module覆蓋問題,因此id要採用md5)。

那問題的核心就是如何打通兩個mainChunk的modules

若是是自由編程,我想你們的實現方式可就太多了,可是在webpack的框架限制下面,如何快速的實現這個,咱們也一直在思考方案,目前想到的方案以下:

SL模塊內部的webpack_require被咱們hack,每次在 modules裏面找不到的時候,咱們去Excel的 modules裏面去找,這樣須要把Excel的 modules做爲全局變量

可是對於Excel不存在的模塊咱們須要怎麼處理?

這種很明顯就是運行時環境,咱們須要作好加載時的失敗降級處理,可是這樣就會遇到同步轉異步的問題,原本你是同步引入一個模塊的,可是若是它在Excel的modules不存在的時候,你就須要先一步加載這個module對應的chunk,變成了相似動態加載,可是你的代碼仍是同步的,這樣就會有問題。

因此咱們須要將依賴前置,也就是說在加載SL模塊後,它知道本身依賴哪些共享模塊,而後去檢測是否存在,不存在則依次去加載,全部依賴就位後纔開始執行本身。

0x3 webpack5的Module federation

說實話,webpack底層仍是很複雜的,在不熟悉的狀況下並且定製程度也不能肯定,因此咱們也是遲遲沒有去真正作這個事情。可是偶然的機會了解到了webpack5的Module federation,經過看描述,感受和咱們想要的東西很像,因而咱們開始一探究竟!

3.1 Module federation的介紹

關於Module federation是什麼,有什麼做用,如今已經有一些文章去說明,這裏貼一篇,你們能夠先去了解一下

Module federation allows a JavaScript application to dynamically run code from another bundle/build, on both client and server

簡單來講就是容許運行時動態決定代碼的引入和加載。

3.2 Module federation的demo

咱們最關心的仍是Module federation的的實現方式,才能決定它是否是真的適合騰訊文檔。

這裏咱們用已有的demo:

module-federation-examples/basic-host-remote

在此以前,仍是須要向你們介紹一下這個demo作的事情

app1
---index.js 入口文件
---bootstrap.js 啓動文件
---App.js react組件

app2
---index.js 入口文件
---bootstrap.js 啓動文件
---App.js react組件
---Button.js react組件

這是文件結構,其實你能夠當作是兩個獨立應用app1和app2,那他們以前有什麼愛恨情仇呢?

/** app1 **/
/**
* index.js
**/
import('./bootstrap');

/**
* bootstrap.js
**/
import('./bootstrap');
import App from "./App";
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<App />, document.getElementById("root"));


/**
* App.js
**/
import('./bootstrap');
import React from "react";

import RemoteButton from 'app2/Button';

const App = () => (
  <div>
    <h1>Basic Host-Remote</h1>
    <h2>App 1</h2>
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
    </React.Suspense>
  </div>
);

export default App;

我這裏只貼了app1的js代碼,app2的代碼你不須要關心。代碼沒有什麼特殊的,只有一點,app1的App.js裏面:

import RemoteButton from 'app2/Button';

也就是關鍵來了,跨應用複用代碼來了!app1的代碼用了app2的代碼,可是這個代碼最終長什麼樣?是如何引入app2的代碼的?

3.3 Module federation的配置

先看咱們的webpack須要如何配置:

/**
 * app1/webpack.js
 */
{
    plugins: [
        new ModuleFederationPlugin({
            name: "app1",
            library: {
                type: "var",
                name: "app1"
            },
            remotes: {
                app2: "app2"
            },
            shared: ["react", "react-dom"]
        })
    ]
}

這個其實就是Module federation的配置了,大概能看到想表達的意思:

  1. 用了遠程模塊app2,它叫app2
  2. 用了共享模塊,它叫shared

remotes和shared仍是有一點區別的,咱們先來看效果。

生成的html文件:

<html>
  <head>
    <script src="app2/remoteEntry.js"></script>
  </head>
  <body>
    <div id="root"></div>
  <script src="app1/app1.js"></script><script src="app1/main.js"></script></body>
</html>

ps:這裏的js路徑有修改,這個是能夠配置的,這裏只是代表從哪裏加載了哪些js文件

app1打包生成的文件:

app1/index.html
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app1/src_bootstrap.js

ps: app2你也須要打包,只是我沒有貼app2的代碼以及配置文件,後面須要的時候會再貼出來的

最終頁面表現以及加載的js:

image-20200329152614947

從上往下加載的js時序實際上是頗有講究的,後面將會是解密的關鍵:

app2/remoteEntry.js
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app2/src_button_js.js
app1/src_bootstrap.js

這裏最須要關注的其實仍是每一個文件從哪裏加載,在不去分析原理以前,看文件加載咱們至少有這些結論:

  1. remotes的代碼本身不打包,相似external,例如app2/button就是加載app2打包的代碼
  2. shared的代碼本身是有打包的

Module federation的原理

在講解原理以前,我仍是放出以前的一張圖,由於這是webpack的文件模塊核心,即便升級5,也沒有發生變化

image-20200329152252834

app1和app2仍是有本身的modules,因此實現的關鍵就是兩個modules如何同步,或者說如何注入,那咱們就來看看Module federation如何實現的。

3.3.1 import變成了什麼
// import源碼
import RemoteButton from 'app2/Button';

// import打包代碼 在app1/src_bootstrap.js裏面
/* harmony import */
var app2_Button__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( /*! app2/Button */ "?ad8d");
/* harmony import */
var app2_Button__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/ __webpack_require__.n(app2_Button__WEBPACK_IMPORTED_MODULE_1__);

從這裏來看,咱們好像看不出什麼,由於仍是正常的webpack_require,難道說它真的像咱們以前所設想的那樣,重寫了webpack_require嗎?

遺憾的是,從源碼看這個函數是沒有什麼變化的,因此核心點不是這裏。

可是你注意看加載的js順序:

app2/remoteEntry.js
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app2/src_button_js.js // app2的button居然先加載了,比咱們的本身啓動文件還前面
app1/src_bootstrap.js

回想上一節咱們本身的分析

因此咱們須要將依賴前置,也就是說在加載SL模塊後,它知道本身依賴哪些共享模塊,而後去檢測是否存在,不存在依次去加載,因此依賴就位後纔開始執行本身。

因此它是否是經過依賴前置來解決的呢?

3.3.2 main.js文件內容

由於html裏面和app1相關的只有兩個文件:app1/app1.js以及app1/main.js

那咱們看看main.js到底寫了啥

(() => { // webpackBootstrap
    var __webpack_modules__ = ({})

    var __webpack_module_cache__ = {};

    function __webpack_require__(moduleId) {

        if (__webpack_module_cache__[moduleId]) {
            return __webpack_module_cache__[moduleId].exports;
        }
        var module = __webpack_module_cache__[moduleId] = {
            exports: {}
        };
        __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
        return module.exports;
    }
    __webpack_require__.m = __webpack_modules__;

    __webpack_require__("./src/index.js");
})()

能夠看到區別不大,只是把以前的modules換成了webpack_modules,而後把這個modules的初始化由參數改爲了內部聲明變量。

那咱們來看看webpack_modules內部的實現:

var __webpack_modules__ = ({

    "./src/index.js": ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
        __webpack_require__.e( /*! import() */ "src_bootstrap_js").then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap */ "./src/bootstrap.js"));
    }),

    "container-reference/app2": ((module) => {
        "use strict";
        module.exports = app2;
    }),

    "?8bfd": ((module, __unused_webpack_exports, __webpack_require__) => {
        "use strict";
        var external = __webpack_require__("container-reference/app2");
        module.exports = external;
    })
});

從代碼看起來就三個module:

./src/index.js 這個看起來就是咱們的app1/index.js,裏面去動態加載bootstrap.js對應的chunk src_bootstrap_js
container-reference/app2 直接返回一個全局的app2,這裏感受和咱們的app2有關係
?8bfd 這個字符串是咱們上面提到的app2/button對應的文件引用id

那在加載src_bootstrap.js以前加載的那些react文件還有app2/button文件都是誰作的呢?經過debug,咱們發現祕密就在webpack_require__.e("src_bootstrap_js")這句話

在第二節解析webpack加載的時候,咱們得知了:

實際上webpck_require.e就是去加載chunk的js文件 0.bundle.js,等到加載回來後它認爲 bundle.js裏面的 modules就必定會有了 0.bundle.js包含的那些 modules

也就是說原來的webpack_require__.e平淡無奇,就是加載一個script,以至於咱們都不想去貼出它的代碼,可是此次升級後一切變的不同了,它成了關鍵中的關鍵!

3.3.3 webpack_require__.e作了什麼
__webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
    }, []));
};

看代碼,的確發生了變化,如今底層是去調用webpack_require.f上面的函數了,等到全部函數都執行完了,才執行promise的then

那問題的核心又變成了webpack_require.f上面有哪些函數了,最後發現有三個函數:

一:overridables

/* webpack/runtime/overridables */
__webpack_require__.O = {};
var chunkMapping = {
    "src_bootstrap_js": [
        "?a75e",
        "?6365"
    ]
};
var idToNameMapping = {
    "?a75e": "react-dom",
    "?6365": "react"
};
var fallbackMapping = {
    "?a75e": () => {
        return __webpack_require__.e("vendors-node_modules_react-dom_index_js").then(() => () => __webpack_require__("./node_modules/react-dom/index.js"))
    },
    "?6365": () => {
        return __webpack_require__.e("vendors-node_modules_react_index_js").then(() => () => __webpack_require__("./node_modules/react/index.js"))
    }
};
__webpack_require__.f.overridables = (chunkId, promises) => {}

二:remotes

/* webpack/runtime/remotes loading */
var chunkMapping = {
    "src_bootstrap_js": [
        "?ad8d"
    ]
};
var idToExternalAndNameMapping = {
    "?ad8d": [
        "?8bfd",
        "Button"
    ]
};
__webpack_require__.f.remotes = (chunkId, promises) => {}

三:jsonp

/* webpack/runtime/jsonp chunk loading */
var installedChunks = {
    "main": 0
};


__webpack_require__.f.j = (chunkId, promises) => {}

這三個函數我把核心部分節選出來了,其實註釋也寫得比較清楚了,我仍是解釋一下:

  1. overridables 可覆蓋的,看代碼你應該已經知道和shared配置有關
  2. remotes 遠程的,看代碼很是明顯是和remotes配置相關
  3. jsonp 這個就是原有的加載chunk函數,對應的是之前的懶加載或者公共代碼提取
3.3.4 加載流程

知道了核心在webpack_require.e以及內部實現後,不知道你腦子裏是否是對整個加載流程有了必定的思路,若是沒有,容我來給你解析一下

  1. 先加載src_main.js,這個沒什麼好說的,注入在html裏面的
  2. src_main.js裏面執行webpack_require("./src/index.js")
  3. src/index.js這個module的邏輯很簡單,就是動態加載src_bootstrap_js這個chunk
  4. 動態加載src_bootstrap_js這個chunk時,通過overridables,發現這個chunk依賴了react、react-dom,那就看是否已經加載,沒有加載就去加載對應的js文件,地址也告訴你了
  5. 動態加載src_bootstrap_js這個chunk時,通過remotes,發現這個chunk依賴了?ad8d,那就去加載這個js
  6. 動態加載src_bootstrap_js這個chunk時,通過jsonp,就正常加載就行了
  7. 全部依賴以及chunk都加載完成了,就去執行then邏輯:webpack_require src_bootstrap_js裏面的module:./src/bootstrap.js

到此就一切都正常啓動了,其實就是咱們以前提到的依賴前置,先去分析,而後生成配置文件,再去加載

看起來一切都很美好,但其實仍是有一個關鍵信息沒有解決!

3.3.5 如何知道app2的存在

上面的第4步加載react的時候,由於咱們本身實際上也打包了react文件,因此當沒有加載的時候,咱們能夠去加載一份,也知道地址

可是第五步的時候,當頁面歷來沒有加載過app2/Button的時候,咱們去什麼地址加載什麼文件呢?

這個時候就要用到前面咱們提到的main.js裏面的webpack_modules

var __webpack_modules__ = ({
        
    "container-reference/app2": 
        ((module) => {
            "use strict";
            module.exports = app2;
        }),
        
    "?8bfd":
        ((module, __unused_webpack_exports, __webpack_require__) => {
        "use strict";
            var external = __webpack_require__("container-reference/app2");
            module.exports = external;
        })
});

這裏面有三個module,咱們還有 ?8bfd、container-reference/app2 沒有用到,咱們再看一下remotes的實現

/* webpack/runtime/remotes loading */
var chunkMapping = {
    "src_bootstrap_js": [
        "?ad8d"
    ]
};
var idToExternalAndNameMapping = {
    "?ad8d": [
        "?8bfd",
        "Button"
    ]
};
__webpack_require__.f.remotes = (chunkId, promises) => {
    if (__webpack_require__.o(chunkMapping, chunkId)) {
        chunkMapping[chunkId].forEach((id) => {
            if (__webpack_modules__[id]) return;
            var data = idToExternalAndNameMapping[id];
            promises.push(Promise.resolve(__webpack_require__(data[0]).get(data[1])).then((factory) => {
                __webpack_modules__[id] = (module) => {
                    module.exports = factory();
                }
            }))
        });
    }
}

當咱們加載src_bootstrap_js這個chunk時,通過remotes,發現這個chunk依賴了?ad8d,那在運行時的時候:

id = "?8bfd"
data = [
   "?8bfd",
   "Button"
]
// 源碼
__webpack_require__(data[0]).get(data[1])
// 運行時
__webpack_require__('?8bfd').get("Button")

結合main.js的module ?8bfd的代碼,那最終就是app2.get("Button")

這不就是個全局變量嗎?看起來有些蹊蹺啊!

3.3.6 再看app2/remoteEntry.js

咱們好像一直忽略了這個文件,它是第一個加載的,必然有它的做用,帶着對全局app2有什麼蹊蹺的疑問,咱們去看了這個文件,果真發現了玄機!

var app2;
app2 =
    (() => {
        "use strict";
        var __webpack_modules__ = ({
            "?8619": ((__unused_webpack_module, exports, __webpack_require__) => {
                var moduleMap = {
                    "Button": () => {
                        return __webpack_require__.e("src_Button_js").then(() => () => __webpack_require__( /*! ./src/Button */ "./src/Button.js"));
                    }
                };
                var get = (module) => {
                    return (
                        __webpack_require__.o(moduleMap, module) ?
                        moduleMap[module]() :
                        Promise.resolve().then(() => {
                            throw new Error("Module " + module + " does not exist in container.");
                        })
                    );
                };
                var override = (override) => {
                    Object.assign(__webpack_require__.O, override);
                }

                __webpack_require__.d(exports, {
                    get: () => get,
                    override: () => override
                });
            })
        });
        return __webpack_require__("?8619");
    })()

若是你細心看,就會發現,這個文件定義了全局的app2變量,而後提供了一個get函數,裏面實際上就是去加載具體的模塊

因此app2.get("Button")在這裏就變成了app2內部定義的get函數,隨後執行本身的webpack_require

是否是有種煥然大悟的感受!

原來它是這樣在兩個獨立打包的應用之間,經過全局變量去創建了一座彩虹橋!

固然,app2/remoteEntry.js是由app2根據配置打包出來的,裏面實際上就是根據配置文件的導出模塊,生成對應的內部modules

你可能忽略的bootstrap.js

細心的讀者若是注意的話,會發現,在入口文件index.js和真正的文件app.js之間多了一個bootstrap.js,並且裏面內容就是異步加載app.js

那這個文件是否是多餘的,筆者試了一下,直接把入口換成app.js或者這裏換成同步加載,整個應用就跑不起來了

其實從原理上分析後也是能夠理解的:

由於依賴須要前置,而且等依賴加載完成後才能執行本身的入口文件,若是不把入口變成一個異步的chunk,那如何去實現這樣的依賴前置呢?畢竟實現依賴前置加載的核心是webpack_require.e

3.3.7 總結

至此,Module federation如何實現shared和remotes兩個配置我相信你們都有了理解了,其實仍是逃不過在第二節末尾說的問題:

  1. 如何解決依賴問題,這裏的實現方式是重寫了加載chunk的webpack_require.e,從而前置加載依賴
  2. 如何解決modules的共享問題,這裏是使用全局變量來hook

總體看起來實現仍是挺巧妙的,不是webpack核心開發者,估計不能想到這樣解決,實際上改動也是蠻大的。

這種實現方式的優缺點其實也明顯:

優勢:作到代碼的運行時加載,並且shared代碼無需本身手動打包

缺點:對於其餘應用的依賴,其實是強依賴的,也就是說app2有沒有按照接口實現,你是不知道的

至於網上一些其餘文章所說的app2的包必須在代碼裏面異步使用,這個你看前面的demo以及知道原理後也知道,根本沒有這樣的限制!

0x4 總結

對於騰訊文檔來講,實際上更須要的是目前的shared能力,對一些常見的公共依賴庫配置shared後就能夠解決了,可是也只是理想上的,實際上仍是會遇到一些可見的問題,例如:

  1. 不一樣的版本生成的公共庫id不一樣,仍是會致使重複加載
  2. app2的remotEntry更新後如何獲取最新地址
  3. 如何獲知其餘應用導出接口

可是至少帶來了解決這個問題的但願,remotes配置也讓咱們看到了多應用共享代碼的可能,因此仍是會讓人眼前一亮,期待webpack5的正式發佈!

最後,若是有寫的不正確的地方,歡迎斧正~


AlloyTeam 歡迎優秀的小夥伴加入。
簡歷投遞: alloyteam@qq.com
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)

clipboard.png

相關文章
相關標籤/搜索