手写 webpack 模块加载与理解 module federation 原理

1,033 阅读4分钟

前言

最近在使用 qiankun 构建子应用的时候,遇到了父子应用使用了相同 lib 的导致打包出来两份 ,增加了代码体积。为了优化这个问题,官方文档目前建议使用 webpack 的 external,但是需要共享的包很多,大量在 html 引入外部 js 导致页面首屏时间增长,且私下与 qiankun 开发沟通,目前最优方案还是使用 module federation 去共享模块,以下简称 mf。所以为了更深刻理解 webpack 的模块加载机制以理解 mf 的原理,故根据 webpack 模块加载原理自行手写实现一个简易的 webpack 模块加载。

实现的代码跟源代码有比较大的差别,但是思路大致是相同的。为了本地可以跑,所以在异步加载模块这里仅提供实现的伪代码。

// 入口执行函数
function entry(modules) {
  // 已经安装的 module
  const installedModules = {};
  // 已经加载好的 chunk
  const installedChunks = {};
  
  // 核心引用 module 的函数
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      // 因为已经安装/执行过了,所以别的文件再引用的时候,直接导出这个 module 对外暴露的属性,即 exports
      return installedModules[moduleId].exports;
    }
    
    // 初始化模块数据
    const module = installedModules[moduleId] = {
      moduleId,
      installed: false, // 标记是否已经加载
      exports: {} // 初始模块为空
    }
    
    // 执行模块内的代码
    modules[moduleId].call(null, module, module.exports, __webpack_require__);
    // 将模块的加载状态改为 true
    module.installed = true;
    
    // 返回模块导出的数据,方便外部在 __webpack_require__(id) 的时候,直接可以拿到模块导出的数据
    return module.exports;
  }
  
  // 记录 ``webpack`` 配置中的 publicPath
  __webpack_require__.p = '/';
  
  // 异步加载 chunk 脚本
  __webpack_require__.e = function (chunkId) {
    
    // 假如已经加载过了
    if (installedModules[chunkId] && installedModules[chunkId].installed) return Promise.resolve();
  
    installedModules[chunkId] = {};
    
    const promise = new Promise(function(resolve, reject) {
      installedModules[chunkId].resolve = resolve;
    });
    
    return new Promise((res) => {
      // 通过往head头部插入script标签异步加载到chunk代码
      const script = document.createElement('script');
      script.src = `${__webpack_require__.p}${chunkId}`;
  
      // 模块是否加载成功
      script.onload = () => {
        installedModules[chunkId].installed = flag
        // 加载完了返回 chunkId 供外部拿到 modules
        res(chunkId);
      };
      // 插入 script 标签
      document.head.appendChild(script);
    });
  };
  
  // webpackJsonp 就是用来连接异步加载 chunk 和 modules 之间的方法
  // 通过 webpackJsonp 将 chunk 内的 module 插入到 modules 中,__webpack_require__ 去引用
  // 这个 module
  function webpackJsonpCallback(chunk) {
    const chunkId = chunk[0]; // chunkId
    const modules = chunk[1]; // 该 chunk 包含的 module
    
    if (installedChunks[chunkId]) {
      return;
    }
  
    installedChunks[chunkId] = chunk;
    
    Object.entries(modules).forEach(([k, v]) => {
      modules[k] = v;
    })
  }
  
  const webpackJsonp =  window['webpackJsonp'] || [];
  webpackJsonp.push = webpackJsonpCallback;
  
  // 因为我们在 ``webpack`` config 内设置的 entry 入口是 './src/home',所以在这我们赋值入口给 __webpack_require__.s,以备其他地方使用
  // 同时执行 __webpack_require__ 去加载 './src/home'
  return __webpack_require__(__webpack_require__.s = './src/home.vue')
}

假如有这么一个 home.vue 组件,里面需要加载 Header 组件
/*
template
* <div>我是主页
*   <Header/>
* </div>

script

return {
   name: 'Header',
   setup() {
     console.log('im home')
   }
}

style
header { background: green; }
* */

const modules = {
  './src/home.vue': function(module, __webpack_exports__, __webpack_require__) {
    
    const loaders = {
      './src/home.vue?vue-template-loader': function() {
        // new Vue(xxx).mount('#app');
        const container = document.getElementById('app');
        // vue template 解析
        const ele = document.createElement('div');
        ele.innerText = '我是主页';
        container.appendChild(ele);
  
        // vue template 解析 Header 发现他是一个 module 引用,所以使用 __webpack_require__
        ele.appendChild(__webpack_require__('./src/header'));
  
        // 假如是一个异步加载的 chunk
        //__webpack_require__.e('./src/header').then(() => {
            // todo ...
        //})
      },
      // 源码中是 css-loader 先将样式转为 css 然后再用 style loader 插入 head 内,这里为了简便合成一步
      './src/home.vue?style-loader?css-loader': function() {
        const cssText = 'header { background: green; }';
        const style = document.createElement('style');
        style.innerHTML = cssText;
        document.getElementsByTagName('head')[0].appendChild(style);
      },
      './src/home.vue?babel-loader': function() {
        console.log('im home')
        
        // 源码上是导出这个 Vue 实例对象, 例如
        // return {
        //   name: 'Header',
        //   setup() {
        //     console.log('im home')
        //   }
        // }
      }
    }
  
    Object.entries(loaders).forEach(([k, v]) => v());
  },
  './src/header': function(module, __webpack_exports__, __webpack_require__) {
    const ele = document.createElement('header');
    ele.innerText = '我是头部';
    // 源码 vue 解析 template 的时候导出的是一个 render 函数,这里为了理解导出一个该 render 出来的元素
    module.exports = ele;
  }
}

// 异步模块
const asyncModule = () => {
  window['webpackJsonp'].push(['1'], {
    './src/page1': function(module, __webpack_exports__, __webpack_require__) {
      const ele = document.createElement('main');
      ele.innerText = '我是 page1';
      module.exports = ele;
    }
  })
}

entry(modules);

简单理解 module federation 原理

比如我们现在有一个 host 3000, remote 3001 ,在 host 的 router 里面有

path: '/child',
name: 'ChildApp',
// ``host `` 去消费 ``remote`` childApp 名字里面的 ChildAppHome chunk
component: () => import('childApp/ChildAppHome'),

remote 应用暴露出 remoteEntry.js ,然后在 host 的 html 的顶部引入,在 remoteEntry 里有

window.childApp = {
  moduleMap = {
    "./ChildAppHome": () => {
        return __webpack_require__.e("src_components_ChildHome_vue").then(() => () => (__webpack_require__( "./src/components/ChildHome.vue")));
     }
  }
}

那么 import('childApp/ChildAppHome') 就会被解析成在 window 下拿 childApp 这个对象,然后在这个对象里面,通过 ChildAppHome 这个标识找到的 chunkId 去到 3001上去加载这个 ChildAppHome 的 chunk,从而完成异步加载。

再者,share 的 lib,比如 vue 这些,打包出来的 remote chunk 里面肯定是不会有 vue 的代码,那么 chunk 里面的 vue 去哪里找,就只能是通过 hostremote 配置 ModuleFederationPlugin 的时候表明那些 lib 是共享,必须要配置,不然子应用就不知道怎么去找到 vue 然后渲染 remote chunk 里面的 vue 节点,在配置了 shared 之后,比如配置了 share vue 和 vue-router ,host 应用在加载的时候就会将 host 内原本将 vue 和 vue-router 打包在 vendor 里面的内容,拆分出来为:

vendors-node_modules_vue-router_dist_vue-router_esm_js.js
vendors-node_modules_vue_runtime-dom_dist_runtime-dom_esm-bundler_js.js

如此 remote 就知道拿 vue 的时候去 share 的 vendor 里面拿了。

总结

查阅了比较多文档,总结了本地的项目实践的出来的结论。在 mf 和异步加载这里可能和源码有出入,但也是尽可能地想把原理给白话出来,主要为了理解。