基于 Webpack Module Federation,这可能是一个比较优雅的微前端解决方案

8,239 阅读14分钟

2021.12.24 更新:阅读本文后,可参考:webpack module federation 核心原理

Webpack Module Federation 特性(下面统称 mf)是 webpack 5 一个重要的亮点,它在模块共享(最基本的能力)、用户开发体验(例如 umi 的 mfsu)等领域都有建树。

但网上许多文章大多是只是停留在了如何去配置插件,且对于该机制在微前端架构的实践很少,我在最近的一段时间内对此做了研究,过程中有了一些总结与踩坑(本文探讨的重点)。同时我把这些能力封装成了一个工具库 mf-lite.

其特性有:

📦 开箱即用:你只需要执行几行命令即可拉取相应的模板代码并把项目跑起来,包括基座应用和微前端应用,无需处理构建工具的复杂配置。

🤩 typescript 支持:模块的生产者和消费者均可自动生成/消费相关的 typescript 类型定义。

🚀 舒适的开发体验:开发体验与常规应用一致、完美接入 qiankun 微前端沙箱库、基座和微应用开发都支持热更新,类型定义的生成也不会打断正常的开发流程。

🔨 独立开发与部署:基于提供的代理工具,微应用开发者在单独开发微应用时,无需启动基座或者其它微应用。

🌟 轻量的项目模板:脚手架生成的初始项目只保留微前端相关的核心依赖,其它第三方库的选型(如 ui 组件库、状态管理库)交由开发者全权管理。

具体的使用方式可进入链接查看 README,本文不再介绍。下面主要讲解这个工具库内部如何运作,如果本文对您有帮助欢迎给该库一个 star,如有批评建议也欢迎指出。

审视与封装 mf 的配置

常规资源共享方式

微前端架构的一个核心是资源共享,可以是 react 或者 ant-design 这种比较大的包,基座和微应用都可能会引用,如果不加以处理,会重复打包,大大降低性能,我们希望做到这种公共依赖的复用。

之前,如果要做到资源共享可能会采用以下方式:

  • 通过引入 CDN ,将 package 实例挂载到全局变量来实现,在 webpack 中配置 external 将相应模块的请求指向全局变量,但我觉得这种把所有公共依赖挂载到全局的方式并不是很优雅:
  • 第一,CDN 导入的全局变量名称没有一个合理的规范(一般由库的开发者决定),有可能造成全局变量的污染。
  • 第二,某些 CDN 资源在使用时你可能要了解一些上下文,例如 vue.js 的相关文档:安装 | Vue.js,另外,并不是所有的第三方库都提供单文件的 CDN 资源,于是你可能要自行打包成单文件 bundle。
  • 第三,开发者可能需要手工排布相关 <script> 标签排列顺序以解决它们之间的相互依赖关系。
  • 去 hack webpack 的模块机制 -- 我见过的有将子应用所有对指定公共依赖的请求全部转换成 require() 函数(利用 webpack 的 node 模式),然后基座去 hack 这个 require() 函数,基座另外维护一个全局的 module 对象,通过 require 函数的参数来分发即可。

有了 mf,就可以优雅地解决资源共享的问题。

利用 ModuleFederationPlugin 进行共享

根据开发经验,共享代码常见的粒度有两种类型:

  • shared package,例如 reactreact-dommobx 这些第三方包。
  • shared module,一般是基座代码暴露出来的模块,例如一些全局变量、全局状态实例或者没必要单独拆包的公共性组件、函数。

expose 暴露共享代码

本节假设读者已经了解了 ModuleFederationPlugin(下面简称 mf-plugin) 的基本使用。

回顾一下相关配置,假如要共享 src/shared-utils.ts 这个文件,可能毫不犹豫地想到下面的代码:

new ModuleFederationPlugin({
  name: 'app1'
  exposes: {
    './shared-utils': 'src/shared-utils.ts'
  },
}),

消费方如微应用如此操作即可:

import {aaa, bbb, ccc} from 'app1/shared-utils'

但是如果是像 reactreact-dom 这样的第三方包该如何处理呢?如果看过一些文章,可能会想到插件有一个 shared 选项,有依赖共享的用途。

shared 选项适合我们的需求吗?

来看下面的一个 demo,审视一下是否符合咱们的需求:

// 基座应用
new ModuleFederationPlugin({
  name: 'base-app'
  shared: ['react', 'react-dom'],
}),

// 微应用
new ModuleFederationPlugin({
  name: 'micro-app'
  shared: ['react', 'react-dom'],
}),

其中 base-app(生产者) 和 micro-app(消费者) 都有一个 shared 对象。这个 DEMO 的意义是:

  • 若 micro-app 调用了 base-app 的一个远程 React 组件, 这个 React 组件势必依赖base-app 的 React。
  • 但如果 micro-app 已经加载了 React,就可以直接复用(不考虑配置了包版本的情况) micro-app 的 React 而无需加载远程的。

这样的逻辑并不太适合当前的微前端架构,因为一个微前端项目的基座(提供方)必然是先加载的,消费者是后加载的,而且他们共存在同一个 web 页面上

所以上面的 shared 机制在就失去了意义, 因为 React、ReactDOM 它必然会加载两次,无法做到消费方复用提供方的 package。

实际上,这种机制适合 base-app 作为一个远程组件、公共函数仓库,供其它 app 消费的场景,base-app 本身不参与页面的构建

看来最终还是得利用 expose 选项来做,那么问题就变成了 “如何寻找 react、ReactDOM 这种 package 的绝对路径”

node 有个 API,叫做 require.resolve('模块名称'),用来查询某个模块的完整路径。它会从当前目录的 node_modules 开始搜索、直到根目录,于是可以如此配置:

new ModuleFederationPlugin({
  name: 'app1'
  exposes: {
    './react': require.resolve('react'),
    './react-dom': require.resolve('react-dom'),
  },
}),
// 调用者通过 import 'app1/react' 来调用 app1 暴露出来的 React

不过,通过 require.resolve 获得的 package 入口是 package.json 中 main 的指定入口,采用的模块方案是 CommonJS,这就有些问题:

  • 对于 webpack 这个打包工具来说,优先使用 esm 来加载更合适(例如对 treeshaking 能力的支持)。
  • 很多第三方库都提供了 esm 的构建版本。
  • 实际上,webpack 在加载模块时也会优先去寻找 esm 版本,cjs 是兜底方案。通过在 package.json 配置 module 字段,打包工具即可自动识别(该字段不是 package.json 官方字段,而是打包工具特有的识别字段),很多第三方库都有配置。

事实上,webpack 官方有提供一个高度配置的 enhanced-resolve 包,可以用来解析上面的规则,使用方法如下:

import { CachedInputFileSystem, ResolverFactory } from 'enhanced-resolve';
import * as fs from 'fs';

const myResolver = ResolverFactory.createResolver({
  fileSystem: new CachedInputFileSystem(fs, 4000),
  conditionNames: ['node'],
  extensions: ['.js', '.json', '.node'],
  useSyncFileSystemCalls: true,
  mainFields: ['esm', 'module', 'main'],
});

// 获取 antd 包入口路径,优先 esm,兜底 cjs
myResolver.resolveSync({}, process.cwd(), "antd")

假设主应用要暴露 react,就可以这样做:

new ModuleFederationPlugin({
  name: 'app1'
  exposes: {
    './react': myResolver.resolveSync({}, process.cwd(), "react")
  },
})

接着,微应用只需要使用 import React from 'app1/react' 就可以使用了。

保证第三方包的公共依赖指向远程模块

上面的逻辑似乎一切正常,但有一个巨大的 bug:

  • 业务代码里面可以使用 app1/react 来调用基座应用暴露出来的模块。
  • 但是不能保证业务代码的依赖(例如 ReactDOM)指向 app1/react ,如果不处理,必然会造成重复打包。
  • 那么怎么防止这种情况?看来得让整个项目对公共模块(如 react)的引用全部指向远程模块路径,即 app1/react
  • 通过查阅 webpack 相关文档,NormalModuleReplacementPlugin 提供了模块请求重定向的能力,需要如此使用即可将消费者所有关于 react 的请求重定向到远程模块(其实这个机制很像 webpack 的 external,只不过指向的不再是之前的全局变量而是 webpack 内部能处理的远程模块):
// 只是拿 react 举个例子,第二个回调函数参数应该由用户传来的配置进行封装
new webpack.NormalModuleReplacementPlugin(/(.*)/, ((resource) => {
  // 如果请求的 resource 是 react,则指向 app1/react
  if (resource.request === 'react') {
    resource.request = 'app1/react';
  }
}))

在实际开发中开发者一般不会直接去折腾 webpack 配置,开发者仅仅在某个配置文件(一般放在项目的根目录)声明需要 share 的 package 或者是 module 来生成相应的配置,例如:

// 配置文件 app-config.ts
const config: MicroAppConfig = {
  // 需要暴露出来的 module 或者 package,可以是基座应用,也可以是微应用
  exposes: [
    // 提供给外界的 package 或者 module
    // 它的每一项可以是一个字符串,这表示消费的 package,
    // 例如 react react-dom
    'react',
    'react-dom',

    // 它也可以是个对象,name 表示模块的名称,path 表示该模块路径
    // 强烈建议基于 node 的 path 模块使用绝对路径
    // 通过设置 type: 'module',这样生成类型定义时就会将其包括在内
    // 如果没有指定 type 那么它还是会被看做一个 package
    {
      name: 'shared-utils',
      path: path.resolve(sourcePath, 'utils', 'shared-utils.ts'),
      type: 'module'
    }
  ],

  // 需要消费的远程应用(例如基座或者另一个微应用)
  remotes: [
    {
      // 远程应用的名称
      name: 'base_app',
      // 远程应用根路径
      url: 'http://localhost:8080/',
      // 需要消费的远程 package
      // 它是一个字符串,表示消费的 package, 例如 react react-dom

      // 一旦在这里声明了某个 package,
      // 该应用的所有针对他们的 import 都会被重定向到远程应用

      // 例如 import React from 'react' 
      // 会被转换成 import React from 'base_app/react'
      sharedLibraries: [
        'react',
        'react-dom',
        'react/jsx-dev-runtime',
        'react-router',
        'react-router-dom',
        'react-router-config'
      ]
    }
  ]
};

export default config;

封装的具体实现难度不大,在这里略去不表,感兴趣可查看 mf-lite 的源码。

typescript 环境下处理远程模块类型定义的生成与消费

类型定义的生成

上面说到可以通过如下的方法来调用远程 module,但很明显 base_app/shared-utils 只有 webpack 可以识别,它在正常的 typescript 开发环境下就会报错:

import add, { sayHello } from 'base_app/shared-utils';

解决方式是创建一个 typescript 声明文件,通过 declare module 的方式来编写远程模块的类型定义,例如:

// module name: base_app/shared-utils
declare module 'base_app/shared-utils' {
    export const add: (a: number, b: number) => number;
    export const sayHello: () => void;
    export default add;
    export { };
}

很明显,需要一个自动化工具来生成相关的类型定义,我在 GitHub 上搜了一圈,dts-bundle-generator(2022年11月08日 补充:微软的 APIExtractor 可能功能更强,更符合需求) 可以满足需求,它可以指定一个 ts 文件作为 entry,然后打包成单文件的类型定义,例如这份代码:

export const add = (a: number, b: number) => {
  return a + b;
};

export const sayHello = () => {
  console.log('hello world!');
};

export default add;

将生成:

export declare const add: (a: number, b: number) => number;
export declare const sayHello: () => void;
export default add;

export {};

可以看出接下来只需要包裹一个 declare module 就可以了,但是这样会报错:

TS1038: A 'declare' modifier cannot be used in an already ambient context.

ambient 有点抽象,它的意思是没有定义实现的声明语句(declarations that don't define an implementation),例如下面三个都是 ambient:

  • declare var $: any(声明常量)
  • declare class C { foo(); } (声明 class)
  • declare module "foo" { .. }(声明 module)

也就是说,declare 关键字不能在这些 ambient 中使用,所以咱们需要删除里面所有的 declare 关键词,并把他们替换成 export 。一个比较优雅的方案应该是调用 TypeScript Compiler API 对相关节点进行 CRUD,这里使用了一个第三方库 ts-morph来处理(本质是对 TypeScript Compiler API 的封装),代码如下:

import { ModuleDeclarationKind, Project, SyntaxKind } from 'ts-morph';

export interface FileOptions {
  // 声明文件路径
  path: string;

  // 声明文件模块名称
  moduleName: string;
}

/**
 * 打包类型定义文件
 *
 * @author yuzhanglong
 * @date 2021-10-03 19:28:19
 * @param fileOptions 文件相关选项,可参考上面的类型定义
 */
export const bundleModuleDeclare = (fileOptions: FileOptions[]) => {
  const project = new Project();

  const content = [];

  fileOptions.forEach(file => {
    // 添加源代码
    const source = project.addSourceFileAtPath(file.path);

    // 遍历每一个子节点,如果是 SyntaxKind.DeclareKeyword(即 declare 关键词),进行文本替换
    source.forEachDescendant(item => {
      if (item.getKind() === SyntaxKind.DeclareKeyword) {
        // 删除即可, 需要判断是不是第一个节点,否则会报异常
        item.replaceWithText(item.isFirstNodeOnLine() ? 'export' : '');
      }
    });

    // 备份根节点
    const baseStatements = source.getStructure().statements;

    // 移除现存的所有节点
    source.getStatements().forEach(res => res.remove());

    // 创建一个 module declaration,将上面备份的根节点插入之
    source.addModule({
      name: `'${file.moduleName}'`,
      declarationKind: ModuleDeclarationKind.Module,
      hasDeclareKeyword: true,
      statements: baseStatements,
    });

    // 格式化代码
    source.formatText();

    // 补充一些注释
    content.push(`// module name: ${file.moduleName}\n\n`);
    content.push(source.getText());
    content.push('\n');
  });

  return content.join('');
};

但是,这样生成的文件还是美中不足,假如代码的入口文件依赖了 react,那么该库打包成 bundle 的结果只能内联一个 /// <reference types="react" /> 而不是将 react 及其依赖也打包进来(虽然有相关选项,但是本人实测有 bug),如果消费侧没有 @types/react 依赖,则需要额外进行这些类型定义的安装。

一个低成本的解法是生产侧手动维护类型定义依赖的名称,并写在相关配置文件中,消费侧生成类型定义时读取它们后写入 package.json 并调用 yarn install 安装相关第三方类型定义依赖。

类型定义的提供与消费

上面已经实现了类型定义的生成,现在来讨论如何处理类型定义的生产与消费。

我们可以将生成好的 .d.ts 文件和打包产物一起上传到 CDN,然后提供相关命令行接口让子应用一键拉取所有远程模块的类型定义。

而对于开发环境也是类似道理,只不过地址变成了 localhost 而已,事实上,webpack-dev-server 就是个静态资源服务器。

另外,还要支持当代码变动,相关类型定义也应该做到热重载。

通过实现一个 webpack plugin(不妨起名叫 EmitMfExposeWebpackPlugin),就可以轻松解决这些问题,其实现代码如下,其中 emitMfExposeDeclaration 是基于用户配置对上面的类型定义生成函数的封装,具体实现本文这里不详细展开,读者可自行查阅源码。

import webpack from 'webpack';
import * as path from 'path';
import { MicroAppConfig } from './micro-fe-app-config';
import { emitMfExposeDeclaration } from './emit-mf-expose-declaration';


interface EmitMfExposeWebpackPluginOptions {
  // app 配置
  appConfig: MicroAppConfig;

  // 输出内容的基础路径,如果没有指定则为 compilation.compiler.outputPath
  // 由于 serve 模式 build 模式输出位置不同,这个选项是有必要的,降低开发成本
  outputBasePath?: string;
}

/**
 * 向 build 打包产物注入类型定义的 webpack-plugin
 *
 * @author yuzhanglong
 * @date 2021-10-03 19:31:02
 */
export class EmitMfExposeWebpackPlugin {
  private readonly config: EmitMfExposeWebpackPluginOptions;

  constructor(config: EmitMfExposeWebpackPluginOptions) {
    this.config = config;
  }

  apply(compiler: webpack.Compiler) {
    const { appConfig, outputBasePath } = this.config;

    // afterEmit 生命周期的时机:输出 asset 到 output 目录之后
    // 实践证明,它不会阻塞 webpack dev-server 的流程,不会影响开发体验。
    compiler.hooks.afterEmit.tap('EmitMfExposeWebpackPlugin', async (compilation) => {
      if (appConfig) {
        // 拿到本项目的 outputPath
        const { outputPath } = compilation.compiler;
        // 生成相关目录
        const target = path.resolve(outputBasePath ?? outputPath, 'mf-expose-types');
        console.log('[mf-lite] compiling shared remote module declarations...');

        // 基于用户的配置 appConfig 生成类型定义
        await emitMfExposeDeclaration(appConfig, target);
      }
    });
  }
}

于是可以这样使用之(注意区分 serve 模式还是 build 模式):

// 写入共享模块(非 package)的类型定义
new EmitMfExposeWebpackPlugin({
  // 基座或者微应用配置
  appConfig: microAppConfigManager.config,
  outputBasePath: isBuildMode ? undefined : assetPublicPath,
}),

outputBasePath 指的是类型定义文件被输出的根路径,它:

  • 在 build 模式下,它是 webpack 内部获取到的 outputPath,也就是打包产物根路径。
  • 在 serve 模式下,它是用户代码目录下的 public 目录,webpack-dev-server 作为一个静态资源服务器,会将 public 目录下所有的文件进行托管。

但是这样会带来一个小问题,在更新代码 -> webpack 热更新 -> 写入类型定义到 public 目录的这一流程中,最后一步会导致整个页面强制刷新!原因是 webpack-dev-server 底层默认会通过 chokidar 这个库会监听 public 目录下的内容,于是查阅 chokidar 相关文档,得到如下配置:

const config = {
  devServer: {
    // ....
    static: {
      watch: {
        ignored: (f: string) => {
          // 生成的类型定义不要监听,否则会引发全局的 reload 使 HMR 失去意义
          return f.endsWith('.d.ts');
        },
      },
    },
    //.....
  },
}

至此,提供方的实现已经完成。

消费方就很简单了。可以通过 http 请求库例如 axios 来下载类型定义到一个统一的目录下,并把这个能力封装到命令行工具中:

// 命令行封装略去,不浪费笔墨,读者可自行查看源码
export const generateMfExposeDeclaration = async (appConfig: MicroAppConfig) => {
  const declareTypeRoot = path.resolve(sourcePath, 'types', 'mf-remotes');
  await fs.ensureDir(declareTypeRoot);

  for (const { name, url: remoteUrl } of appConfig.remotes) {
    const targetFileName = `${name}-exposes.d.ts`;
    // example: https://base-40kkvlqeq-yzl.vercel.app/mf-expose-types/exposes.d.ts
    const remote = url.resolve(remoteUrl, 'mf-expose-types/exposes.d.ts');
    console.log(`fetching remotes types declarations from ${remote}...`);
    const declarations = await axios.get(remote);
    await fs.writeFile(path.resolve(declareTypeRoot, targetFileName), declarations.data);
  }
  console.log('Done!');
};

利用代理实现微应用在远程基座环境下独立开发

为什么需要代理

微前端架构另的一个核心是微应用的独立开发,也就是需要做到:

  • 微应用的开发者在开发相应微应用时无需关心基座的逻辑、无需额外启动基座或者其它微应用的开发模式。
  • 上面的说法,按实际案例来说,就是在同一个网页下,远程的代码和用户本地运行的代码可以共存,开发者可以轻松指定某个微应用指向远程资源还是本地 localhost。请看下图的一个 web 页面,我们需要把浏览器所有关于微应用 2 的请求全部转发到本地 localhost:8080,基座、微应用 1 仍然使用远程的。

基于中间人攻击实现 HTTP 代理

特别注意:开发者完全可以使用 Charles 一类的工具,无须重复造轮子,本小节只是顺便讲解一下相关的实现原理。

不难看出,我们需要一个开发一个代理工具来实现上述需求,如何实现呢?

其核心原理是在用户浏览器和真实请求服务器之间增加了一个中间人(可以理解成中间人攻击),中间人截获用户给他的通信,然后作出选择性的传递,也可以对内容进行修改。

浏览器对代理服务器的请求有两种类型:

  • HTTP 请求走普通代理,可以在 request 事件中截获:
proxyServer.on('request', async (req: IncomingMessage, res: ServerResponse) => {
// handlers 为一系列处理请求、转发请求的中间件函数  
   await handlers({
     req: req,
     res: res,
     protocol: 'http',
   });
 });
  • HTTPS 请求走隧道代理,隧道代理和普通代理的机制不同,他可以在真实服务器和浏览器端建立一个 TCP 隧道,传递两边的内容,代理本身不做任何处理也无法进行处理。如果我们要代理的请求为 https 协议,浏览器会发送一个 CONNECT 请求,具体可以如下的代码实现截获(这份代码可以在node 官网文档中查到):
// 创建 HTTP 隧道代理
const proxy = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('okay');
});

proxy.on('connect', (req, clientSocket, head) => {
  // 连接到源服务器
  const { port, hostname } = new URL(`http://${req.url}`);
  const serverSocket = net.connect(port || 80, hostname, () => {
    clientSocket.write('HTTP/1.1 200 Connection Established\r\n' +
                    'Proxy-agent: Node.js-Proxy\r\n' +
                    '\r\n');
    serverSocket.write(head);
    serverSocket.pipe(clientSocket);
    clientSocket.pipe(serverSocket);
  });
});

接下来只要另开一个 HTTP 服务器(下文称为 proxy server),然后让上面的两种类型请求指向 proxy server 即可,接着让 proxy server 和真实的服务器通信、并原样返回内容即可。

处理 websocket 请求

上面的内容只处理了 HTTP 和 HTTPS 请求,那么 websocket 该如何处理呢?

事实上,WebSocket 握手使用 HTTP Upgrade 头以从 HTTP 协议更改为 WebSocket 协议,所以我们基于之前的 http 和 https server 稍作特化处理就行,在 node 下,可以通过 server.on('upgrade') 来捕获:

// 在收到 https 升级请求时做些什么
this.httpsServer.on('upgrade', async (req: IncomingMessage, socket: Socket, head: Buffer) => {
// handlers 为一系列处理请求、转发请求的中间件函数  
await handlers({
    req: req,
    socket: socket,
    head: head,
    protocol: 'wss',
  });
});

handlers 函数的实现比较复杂,略去不表,感兴趣的读者可以自行查看源码。

解决连接不安全问题

使用基于上述原理实现的代理工具在浏览器侧会显示连接不安全,这是因为 HTTPS 服务器无法生成由系统内置的 CA 签发的证书,解决此问题的方案很简单,那就是让系统认证 proxy 自己的 CA(需要用户手动安装),然后用 proxy 的 CA 为每一个请求的域名签发证书。

这样一来,利用自签名证书,客户端和 proxy 服务器的请求就能够建立"安全"的 HTTPS 连接,也就不会出现浏览器上的警告。

实际应用踩坑

上面的一切虽然看起来很美好,但是由于一些第三方库的性质,还需要踩一些坑:

React HMR plugin 兼容

在 module federation 环境下,由于微应用的 react 是消费基座的,所以 React HMR(通过官方 react-refresh 来实现)不能开箱即用,无论是基座还是微应用。下面给出我的解决方案:

  • 保证基座热更新生效:设置 optimization.runtimeChunk = 'single',值 "single" 会创建一个在所有生成 chunk 之间共享的运行时文件。上文中提到,module federation 模式会额外生成一个 entry 来服务暴露出来的远程模块,这就导致项目会有两个 runtime 实例从而引发冲突。如此配置既解决了 bug 又减小了打包体积。

  • 保证微应用热更新生效:微应用除了配置 optimization.runtimeChunk = 'single',还需要为 react refresh 重新注入远程应用的 ReactDOM

import { injectIntoGlobalHook } from 'react-refresh/cjs/react-refresh-runtime.development';
import ReactDOM from 'react-dom';

declare global {
  interface Window {
  __REACT_DEVTOOLS_GLOBAL_HOOK__: any;
  }
}

/**
* 基于 webpack module Federation 架构下子应用的 react refresh 补丁
*
* @author yuzhanglong
* @date 2021-09-27 22:19:36
*/
export const injectBaseReactRefresh = () => {
  // Injects the react refresh replacing the one from the base app
  injectIntoGlobalHook(window);
  // Injects the react-dom instance again
  window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject(ReactDOM);
};

对齐 qiankun 框架的 html entry 机制

微前端架构的一大特性是 js、css 隔离,对于隔离沙箱的能力,我选用了 qiankun 这个第三方库,但在 module federation 的环境下不能做到开箱即用,会出现无法找到生命周期函数的问题

其原因是 qiankun 底层依赖的 import-html-entry 会取所有 scripts 里面排在最后的 script 作为 entry。(具体代码可查看这里)。通过 html-webpack-plugin 导出的 HTML,一般情况下是 main entry 在最后。

但是在 mf 环境下,webpack 会生成一个额外的 entry 排在 main 的后面。 从而导致拿不到 main 入口的生命周期函数。

有两种方案:

  • html-webpack-plugin 有一个相关的选项可以将输出的 script 文件进行排序,可以将 main 入口排在最后。
  • import-html-entry 会读取 script 标签中是否有 entry 属性,于是可以 写一个自定义 plugin hook 到 html-webpack-plugin 相应的生命周期去改写 script 标签属性。

很明显第二种方法是最佳的,打乱 script 标签的顺序可能会导致依赖顺序的问题,事实证明,mf 独有的 entry 需要在 main entry 之后执行,否则会出现异常。

我们需要再写一个 webpack plugin (不妨称为 AddEntryAttributeWebpackPlugin),如下所示:

import webpack from 'webpack';
import { Hooks } from 'html-webpack-plugin';

/**
 * 向 html-webpack-plugin 导出的 HTML 模板 script 添加属性
 *
 * @author yuzhanglong
 * @date 2021-10-10 02:31:52
 */
export class AddEntryAttributeWebpackPlugin {
  private readonly entryMatchCallback;

  constructor(matchCallback: (src: string) => boolean) {
    this.entryMatchCallback = matchCallback;
  }

  apply(compiler: webpack.Compiler) {
    compiler.hooks.compilation.tap('AddEntryAttributeWebpackPlugin', (compilation) => {
      // 通过最终的 webpack 配置的 plugins 属性,根据插件的 constructor.name 拿到 html-webpack-plugin 实例
      const HtmlWebpackPluginInstance: any = compiler.options.plugins
        .map(({ constructor }) => constructor)
        .find(constructor => constructor && constructor.name === 'HtmlWebpackPlugin');


      if (HtmlWebpackPluginInstance) {
        // 获取 html-webpack-plugin 所有的 hooks
        const hooks = HtmlWebpackPluginInstance.getHooks(compilation) as Hooks;

        // 在插入标签之前做些什么
        hooks.alterAssetTagGroups.tap(
          'AddEntryAttributeWebpackPlugin', (data) => {
            // 拿到所有的标签,如果是 script 标签,并且满足我们的匹配函数,则将其 attributes['entry'] 设为 true
            data.headTags.forEach(tag => {
              if (tag.tagName === 'script' && this.entryMatchCallback(tag.attributes?.src)) {
                // eslint-disable-next-line no-param-reassign
                tag.attributes.entry = true;
              }
            });
            return data;
          },
        );
      }
    });
  }
}

使用方法如下:

// 假设输出 main chunk 为 main.[hash].chunk.js。
// 提示:输出 chunk 的名称规范化可以在 output.filename 中配置

new AddEntryAttributeWebpackPlugin((src => {
  return !!(src.match(/main.(.*).bundle.js$/) || src.match('main.bundle.js'));
})),

至此,整个项目的流程基本跑通,开发者可运用在自己的项目中。

最后,再次推荐一下这个项目 mf-lite, 他将上面所有的操作都封装成一系列单独的库。

如果对本文有疑问欢迎提出。