webpack5之Module Federation输出文件解析

595 阅读11分钟

背景

随着前端业务的需求日益剧增,我们不得不将巨石前端应用拆分成多个微前端应用。随之而来的问题则是各个微前端应用之间不可避免带来的协作关系。目前现有的解决方案有iframe,npm,基于微前端架构的应用通信等一些列方案,但都存在一定的弊端。具体优缺点本文再此处不做分析。大家有兴趣可以去搜索下目前已有的一系列文章。都已经做好了清晰的对比。本文具体还是对module federation进行一定的分析。

什么是Module Federation

先来说说什么是Module Federation 官方解释:

Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually. This is often known as Micro-Frontends, but is not limited to that.

解释如下:一个应用可以由多个独立的构建组成。这些独立的构建之间没有依赖关系,他们可以独立开发、部署。

使用 module federation,我们可以在一个 javascript 应用中动态加载并运行另一个 javascript 应用的代码,并实现应用之间的依赖共享。

概括来说就是:它能允许一个线上部署的项目在运行时加载其他线上部署项目中的组件

基础配置

以下分析 可参考 react-typescript-module-federation 项目使用

模块提供方配置

webpack.config.js


const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
          // 容器名称
          name: "app1",
          // 导出远程使用入口文件名
          filename: "remoteEntry.js",
          // 导出模块
          exposes: {
            "./CounterAppOne": "./src/components/CounterAppOne",
          },
          // 共享模块
          shared: {
            react: { singleton: true },
            "react-dom": {
              singleton: true
            },
          },
        }),
    ]
}

模块接收方配置

webpack.config.js


const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            //  容器名称
            name: "container",
            // 远程模块
            remotes: {
              app1: 'app1@http://localhost:3001/remoteEntry.js',
            },
            // 共享模块
            shared: {
              react: { singleton: true, eager: true, requiredVersion: deps.react },
              "react-dom": {
                singleton: true
              },
            },
        })
    ]
}

远程模块调用

const CounterAppOne = React.lazy(() => import("app1/CounterAppOne"));

const App = () => (
                    <React.Suspense fallback={<Spinner size="xl" />}>
                            <CounterAppOne />
                    </React.Suspense>
);

export default App;

名词解释

  • host 主应用,消费共享模块

  • Remote 远程应用,提供共享模块

代码分析

共享模块的实现

以配置的共享模块react为例,我们在host组件的引入react处打上debugger

定位到加载react的地方,可以看到。加载react是引用webpack的__webpack_require__方法

进入__webpack_require__,可以看到除去一些热更的逻辑,最终调用的还是require的方法

webpack_require

而这个require方法就是最通用的__webpack_require__,首先会从cache缓存中查找是否包含有react的模块,如果没有则就会从__webpack_modules__中去寻找。然后将模块存入__webpack_module_cache__缓存中,下次再进入则可直接从缓存中获取。而后获取模块的工厂函数factory,最后通过Function.prototype.call来执行获取到的工厂函数。 那么__webpack_modules__中的react又是从哪里获取的呢?

查找react模块的引入

为了查找react模块是从什么时候引入的,我们从项目的入口文件来查看,先在bootstrapt上打上debugger

可以看到在输出的文件中,加载bootstrap之前会优先去使用__webpack_require__.e加载react,react-dom等的共享模块,使用promiseAll,再资源全部加载完毕后再去加载bootstrap模块

debugger;
Promise.all(/*! import() */[__webpack_require__.e("webpack_sharing_consume_default_react_react"), __webpack_require__.e("webpack_sharing_consume_default_react-dom_react-dom"), __webpack_require__.e("src_bootstrap_tsx")]).then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap */ "./src/bootstrap.tsx"));

进入__webpack_require__.e ,trackBlockingPromise为webpack实现的一些自定义promise方法暂且不关注,直接看require.e方法

fn.e = function (chunkId) {
    return trackBlockingPromise(require.e(chunkId));
};

require.e为__webpack_require__.e,webpack_require.e中会遍历__webpack_require__.f方法,

__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中的方法

webpack_require.f

这三个方法分别为:

  • consumes 是共享模块的解决方案,用于在运行时加载并安装依赖的共享模块 import react from 'react'
  • remotes 是远程模块的解决方案,用于在运行时加载并安装依赖的共享远程 import ('app1/count')
  • j 实现了异步 chunk 路径的拼接、缓存、异常处理三个方面的逻辑 import('./orderlist');

虽然,每一个require.e都会调用这三个方法,但是在方法内部都进行了一些逻辑判断,只有对应模块进入相应方法后才会执行后续的逻辑操作,如:consumes, webpack已经在输出文件中写死了哪些模块是远程共享模块,只有匹配到对应模块才会执行后续逻辑

如此处:chunkMaping是需要共享模块的入口模块,已经共享模块名,

__webpack_require__.o(chunkMapping, chunkId) 方法是判断chunkId 是否在chunkMapping之中,因此如果是普通模块走到这里就结束了,只有以上需要共享资源的模块才会进入consumes

Consumes的逻辑为:

  1. 判断installedModules是否已经有当前模块,存在则表示当前模块已经加载,则直接使用installedModules返回
  1. 若模块尚未加载 则调用moduleToHandlerMappingid返回加载模块的工厂函数的promise,
  1. 加载promise成功后 则installedModules赋值promise.then(onFactory),因此,回到步骤1中,后续如果installedModules有值,则不需要加载逻辑,直接使用当前promise表示加载模块结果。
  1. 而onFactory中将promise返回的模块工厂函数赋值给__webpack_require__.m (就是__webpack_modules__) ,这就和上述的 webpack_require方法形成了闭环,后续再加载react模块就是使用当前从远程资源中请求的模块内容中获取。

那么 这边至关重要的就是步骤2中的moduleToHandlerMapping方法返回加载模块工厂函数的promise,他是如何加载的呢

moduleToHandlerMapping

var moduleToHandlerMapping = {
    "webpack/sharing/consume/default/react/react": () => (loadSingletonVersionCheckFallback("default", "react", [1,17,0,2], () => (__webpack_require__.e("vendors-node_modules_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/react/index.js"))))))),
    "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersionCheckFallback("default", "react-dom", [1,17,0,2], () => (__webpack_require__.e("vendors-node_modules_react-dom_index_js").then(() => (() => (__webpack_require__(/*! react-dom */ "./node_modules/react-dom/index.js"))))))),
    "webpack/sharing/consume/default/@chakra-ui/react/@chakra-ui/react": () => (loadStrictVersionCheckFallback("default", "@chakra-ui/react", [1,1,8,1], () => (Promise.all([__webpack_require__.e("vendors-node_modules_chakra-ui_react_dist_chakra-ui-react_esm_js"), __webpack_require__.e("webpack_sharing_consume_default_emotion_react_emotion_react"), __webpack_require__.e("webpack_sharing_consume_default_emotion_styled_emotion_styled-webpack_sharing_consume_default-748821")]).then(() => (() => (__webpack_require__(/*! @chakra-ui/react */ "./node_modules/@chakra-ui/react/dist/chakra-ui-react.esm.js"))))))),
    "webpack/sharing/consume/default/@emotion/react/@emotion/react": () => (loadStrictVersionCheckFallback("default", "@emotion/react", [1,11,7,1], () => (Promise.all([__webpack_require__.e("vendors-node_modules_emotion_serialize_dist_emotion-serialize_browser_esm_js-node_modules_emo-000f10"), __webpack_require__.e("vendors-node_modules_emotion_react_dist_emotion-react_browser_esm_js")]).then(() => (() => (__webpack_require__(/*! @emotion/react */ "./node_modules/@emotion/react/dist/emotion-react.browser.esm.js"))))))),
    "webpack/sharing/consume/default/@emotion/styled/@emotion/styled": () => (loadStrictVersionCheckFallback("default", "@emotion/styled", [1,11,6,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules_emotion_serialize_dist_emotion-serialize_browser_esm_js-node_modules_emo-000f10"), __webpack_require__.e("vendors-node_modules_emotion_styled_dist_emotion-styled_browser_esm_js")]).then(() => (() => (__webpack_require__(/*! @emotion/styled */ "./node_modules/@emotion/styled/dist/emotion-styled.browser.esm.js"))))))),
    "webpack/sharing/consume/default/framer-motion/framer-motion": () => (loadStrictVersionCheckFallback("default", "framer-motion", [1,5,6,0], () => (__webpack_require__.e("vendors-node_modules_framer-motion_dist_es_index_mjs").then(() => (() => (__webpack_require__(/*! framer-motion */ "./node_modules/framer-motion/dist/es/index.mjs")))))))
};

可以看到moduleToHandlerMapping是个模块映射,通过loadSingletonVersionCheckFallback加载模块

var loadSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
    if(!scope || !__webpack_require__.o(scope, key)) return fallback();
    return getSingletonVersion(scope, scopeName, key, version);
});

loadSingletonVersionCheckFallback方法是通过init方法生成的高阶函数

var init = (fn) => (function(scopeName, a, b, c) {
    var promise = __webpack_require__.I(scopeName);
    if (promise && promise.then) return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], a, b, c));
    return fn(scopeName, __webpack_require__.S[scopeName], a, b, c);
});

从此处可以看出 最终返回的请求模块内容的promise还是通过__webpack_require__.I得到的结果,其中有一个参数scopeName在此处为'default',为默认值,这个值可以设置当前的共享模块存放在哪个域(范围)之中,可以通过config中的shareScope来进行配置

webpack_require.I

__webpack_require__.I = (name, initScope) => {
    if(!initScope) initScope = [];
    // handling circular init calls
    var initToken = initTokens[name];
    if(!initToken) initToken = initTokens[name] = {};
    if(initScope.indexOf(initToken) >= 0) return;
    initScope.push(initToken);
    // only runs once
    if(initPromises[name]) return initPromises[name];
    // creates a new share scope if needed
    if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
    // runs all init snippets from all modules reachable
    var scope = __webpack_require__.S[name];
    var warn = (msg) => (typeof console !== "undefined" && console.warn && console.warn(msg));
    // 容器名称
    var uniqueName = "container";
    var register = (name, version, factory, eager) => {
            var versions = scope[name] = scope[name] || {};
            var activeVersion = versions[version];
            /**
            * 1. 当前版本不存在
            * 2. 当前版本未加载
            * 两个来源版本的eager不同,则当前的判断eager强制加载必须为真
            * 若两个来源版本eager相同,则当前容器版本的字符串名称必须大于之前加载的(不是很理解为什么要这么处理)
            * 满足以上条件则重新赋值scrope中的共享模块内容
            */
           if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager };
    };
    var initExternal = (id) => {
            var handleError = (err) => (warn("Initialization of sharing external failed: " + err));
            try {
                    var module = __webpack_require__(id);
                    if(!module) return;
                    var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
                    if(module.then) return promises.push(module.then(initFn, handleError));
                    var initResult = initFn(module);
                    if(initResult && initResult.then) return promises.push(initResult['catch'](handleError));
            } catch(err) { handleError(err); }
    }
    var promises = [];
    switch(name) {
            case "default": {
                    register("@chakra-ui/react", "1.8.1", () => (Promise.all([__webpack_require__.e("vendors-node_modules_chakra-ui_react_dist_chakra-ui-react_esm_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react"), __webpack_require__.e("webpack_sharing_consume_default_emotion_react_emotion_react"), __webpack_require__.e("webpack_sharing_consume_default_emotion_styled_emotion_styled-webpack_sharing_consume_default-748821"), __webpack_require__.e("webpack_sharing_consume_default_react-dom_react-dom")]).then(() => (() => (__webpack_require__(/*! ./node_modules/@chakra-ui/react/dist/chakra-ui-react.esm.js */ "./node_modules/@chakra-ui/react/dist/chakra-ui-react.esm.js"))))));
                    register("@emotion/react", "11.7.1", () => (Promise.all([__webpack_require__.e("vendors-node_modules_emotion_serialize_dist_emotion-serialize_browser_esm_js-node_modules_emo-000f10"), __webpack_require__.e("vendors-node_modules_emotion_react_dist_emotion-react_browser_esm_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react"), __webpack_require__.e("node_modules_babel_runtime_helpers_esm_extends_js-_fbd70")]).then(() => (() => (__webpack_require__(/*! ./node_modules/@emotion/react/dist/emotion-react.browser.esm.js */ "./node_modules/@emotion/react/dist/emotion-react.browser.esm.js"))))));
                    register("@emotion/styled", "11.6.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules_emotion_serialize_dist_emotion-serialize_browser_esm_js-node_modules_emo-000f10"), __webpack_require__.e("vendors-node_modules_emotion_styled_dist_emotion-styled_browser_esm_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react"), __webpack_require__.e("webpack_sharing_consume_default_emotion_react_emotion_react"), __webpack_require__.e("node_modules_babel_runtime_helpers_esm_extends_js-_fbd71")]).then(() => (() => (__webpack_require__(/*! ./node_modules/@emotion/styled/dist/emotion-styled.browser.esm.js */ "./node_modules/@emotion/styled/dist/emotion-styled.browser.esm.js"))))));
                    register("framer-motion", "5.6.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules_framer-motion_dist_es_index_mjs"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! ./node_modules/framer-motion/dist/es/index.mjs */ "./node_modules/framer-motion/dist/es/index.mjs"))))));
                    register("react-dom", "17.0.2", () => (Promise.all([__webpack_require__.e("vendors-node_modules_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! ./node_modules/react-dom/index.js */ "./node_modules/react-dom/index.js"))))));
                    register("react", "17.0.2", () => (__webpack_require__.e("vendors-node_modules_react_index_js").then(() => (() => (__webpack_require__(/*! ./node_modules/react/index.js */ "./node_modules/react/index.js"))))));
                    initExternal("webpack/container/reference/app1");
                    initExternal("webpack/container/reference/app2");
            }
            break;
    }
   if(!promises.length) return initPromises[name] = 1;
            return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
    };
}

这段的内容比较复杂,我们看到关键处register,register中的第一个参数为共享模块的名称,第二个参数为共享模块的版本号,第三个参数就是加载共享模块的工厂函数。register方法会将这些信息注入到__webpack_require__.S中。当我们使用到共享模块的时候,则就从__webpack_require__.S获取对应的模块版本使用

这也是为什么我们必须把入口文件改为import远程引入,因为在最开始的时候__webpack_require__.S还未注册共享模块的内容,则就无法引用共享模块的资源

那么,host项目的共享模块已经注入到__webpack_require__.S之中,那么远程模块又是如何注入到__webpack_require__.S之中呢?

initExternal

可以看到除了register方法之外,switch之中还有一个initExternal,他用于请求远程模块的remoteEntry.js的jsonp,且注册模块。

进入到initExternal,可以看到他同样是使用__webpack_require__加载模块,与之不同的是,

此模块加载的内容是一个远程模块,通过jsonp请求remote应用的remoteEntry入口,将返回的结果输出到一个promise给module.exports,

此处我们也可以把remoteEntry是一个module,app就是module的输出内容,其中此处的app1就是我们在配置文件中定义的容器名称。

而在返回回来的module结果中,init 又立马执行了module的init的方法,在此处remoteEntry里面就对共享模块已经,远程模块进行了注册,注册到了host的webpack之中。

var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
if(module.then) return promises.push(module.then(initFn, handleError));

remoteEntry.js中的init

在这里,init方法又执行了__webpack_require__.I方法,在其中,对共享模块进行到了注册,此处的shareScope是之前的__webpack_require__.S[name]的引用值,因此,修改remoteEntry.js的__webpack_require__.S[name]也修改了host应用中的__webpack_require__.S[name],就实现了,host应用和remote应用共同维护一个__webpack_require__.S[name]。当需要使用共享模块的时候,都是从__webpack_require__.S之中去获取,就实现了模块的共享。

获取对应版本共享模块

从consumes的逻辑可知,我们以上的操作仅是把模块注册到了shareScope当中,我们还未拿到模块的factory,我们的factory是需要取shareScope中获取的。那么就留下了两个问题:

  1. 当host模块和remote相同的时候,我们使用的是哪个域的库 (主要使用register这段逻辑)
if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager };

image.png 2. 当有多个版本在shareScope中的时候,我们该获取哪个版本的库

var findSingletonVersionKey = (scope, key) => {
    var versions = scope[key];
    return Object.keys(versions).reduce((a, b) => {
        return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
    }, 0);
};

不同版本的获取逻辑就比较简单,仅当所需要的版本未加载,scope存在版本号大于所需版本的时候,使用较大版本,其余都使用传入版本号。注意:此处的 versionLt(a, b) 是一个基于 semver 规范的版本比较函数。

至此 我们就彻底获取到module的factory返回给consumes方法中的onFactory方法,完成了module的注册。

远程模块的实现

在调用远程模块的时候,上述的共享模块也已经加载完毕remoteEntry.js。远程模块加载的时候,直接使用remoteEntry返回的module中的get方法,获取远程模块的module并执行。

具体步骤为:

  1. 请求远程模块使用__webpack_require__.f中的remote方法

首先会从idToExternalAndNameMapping获取远程模块的相关信息

var idToExternalAndNameMapping = {
    "webpack/container/remote/app1/CounterAppOne": [
        "default", // scopeName 共享范围
        "./CounterAppOne", // 共享组件名称
        "webpack/container/reference/app1" // 共享来源模块
        ],
    "webpack/container/remote/app2/CounterAppTwo": [
        "default",
        "./CounterAppTwo",
        "webpack/container/reference/app2"
    ]
};

getScope 记录了已经加载的远程模块,当二次进入,说明远程模块已经加载,就不再走注册的逻辑了

  • onError 错误处理
  • handleFunction 一个方法执行流程,兼容返回各种返回值,传入当前方法和下一个执行方法,无论是当前结果是promise还是普通的内容,都能流转到下一个方法执行,这样就能按顺序执行以下的3个方法
  • onExternal 请求资源初始化remoteEntry.js中的内容
  • onInitialized 使用remoteEntry.js的模块get方法获取对应模块的工厂函数
  • onFactory 将工厂函数注册入__webpack_modules__

首先执行

handleFunction(__webpack_require__,"webpack/container/reference/app1") (加载刚才在remoteEntry.js中获取的模块信息)-> onExternal:handleFunction(__webpack_require__.I,"default") (避免没有共享模块未注册初始化的信息内容)-> onInitialized:handleFunction(external.get,"./CounterAppOne") 执行remoteEntry中的get方法,返回getScope,为'./CounterAppOne' 模块的加载方法。

var moduleMap = {
        "./CounterAppOne": () => {
                return Promise.all([__webpack_require__.e("vendors-node_modules_react_jsx-runtime_js"), __webpack_require__.e("webpack_sharing_consume_default_chakra-ui_react_chakra-ui_react"), __webpack_require__.e("src_components_CounterAppOne_tsx")]).then(() => (() => ((__webpack_require__(/*! ./src/components/CounterAppOne */ "./src/components/CounterAppOne.tsx")))));
        }
};
var get = (module, getScope) => {
        __webpack_require__.R = getScope;
        getScope = (
                __webpack_require__.o(moduleMap, module)
                        ? moduleMap[module]()
                        : Promise.resolve().then(() => {
                                throw new Error('Module "' + module + '" does not exist in container.');
                        })
        );
        __webpack_require__.R = undefined;
        return getScope;
};

最后通过onFactory,方法,把返回的工厂函数注册到__webpack_modules__模块中,这就和上述的 __webpack_require__方法形成了闭环,后续需要,就和普通的module一样从__webpack_modules__获取

var onFactory = (factory) => {
    data.p = 1;
    __webpack_modules__[id] = (module) => {
        module.exports = factory();
    }
};

至此就实现了Module Federation核心的两大功能