前言
最近在使用 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 去哪里找,就只能是通过 host
和 remote
配置 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 和异步加载这里可能和源码有出入,但也是尽可能地想把原理给白话出来,主要为了理解。