听说Umi的 MFSU 比 Vite 都要快?翻源码品一下!

4,562 阅读7分钟

一、前言

大家好,由于最近在研究如何提高项目启动速度,项目是基于webpack的,也找了很多方法。 最后试下来,有这么几种方式可以明显提升构建速度:

  • 使用 webpack5
  • 开启 webpack5 的持久化缓存(真的是相当快)
  • 开启webpack5 的实验特性 lazyCompilation (有点像vite, 访问页面的时候再去编译)

最后发现确实提升非常大, 项目从原本的80s直接降低到20s左右。但是,直到我看到了umi的mfsu,我就不淡定了:

文档地址

先说我自己试过后得出的结论(仅供参考)

  • 由于我们的项目比较大,感觉提升并不明显,与开启cachelazyCompilation 感觉是差不多的,没有专业的计算过时间,但是基本都在20s左右构建完成。

  • 从官方提供的demo来看,mfsu确实非常快,而且不像lazyCompilation那样,进了页面还要编译一下。大家可以下载demo试试。

  • 比Vite快?说实话没感觉出来。

言归正传,MFSU的设计思想还是挺新鲜的。借助了模块联邦,将三方模块提前打包。其实这一点与Vite的预编译挺像的。

好了,下面介绍下webpack5项目如何接入mfsu 以及 mfsu的原理。

二、MFSU接入

如何在没有使用umi的项目中接入呢?首先你的构建工具必须是webpack5。下面跟我一块配置吧:

1. 安装

npm i @umijs/mfsu -D

2. 初始化实例

const { MFSU } = require('@umijs/mfsu')
const webpack = require('webpack')

const mfsu = new MFSU({
  implementor: webpack,
  buildDepWithESBuild: false, // 如果你项目装了esbuild, 可以开启,设置为true
});

3. 添加中间件

module.exports = {
  devServer: {
    // [mfsu] 2. add mfsu middleware
    setupMiddlewares(middlewares, devServer) {
      middlewares.unshift(
        ...mfsu.getMiddlewares()
      )
      return middlewares
    },
  }
}

4. 配置babel插件

// webpack.config.js
 
module.exports = {
  module: {
    rules: [
      // handle javascript source loader
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: [
              // [mfsu] 3. add mfsu babel plugins
              ...mfsu.getBabelPlugins()
            ]
          }
        }
      }
    ]
  }
}

5. 设置Webpack配置

注意:下面需要传入两个config, 一个是你的webpack配置,一个是depConfig 用来打包依赖的配置。

const depConfig = {
  output: {},
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx'],
  },
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              '@babel/preset-env',
              '@babel/preset-react',
              '@babel/preset-typescript',
            ],
          },
        },
      },
    ],
  },
  plugins: [],
};

const getConfig = async () => {
  await mfsu.setWebpackConfig({
    config, // 你的webpack配置
    depConfig
  });
  return config
}
 
module.exports = getConfig()

OK,这样就配置好了,你以为可以运行了?然后各种坑就接踵而来,一个错仿佛连着下一个错。

下面列一下我在接入后踩得各种坑

三、MFSU踩坑

cannot found './xxx' module

提示找不到模块,这是因为入口不能是相对路径,有人也提了issue

Loading Script failed

image.png

这个情况基本无解,你会发现用webpack打包依赖,没有生成 MFSU_CACHE.jsonmf-va_remoteEntry.js,所以必须用esbuild打包依赖。

new MFSU({
  implementor: webpack,
  buildDepWithESBuild: true, // 使用esbuild打包依赖
})

排除external

如果你的项目用到了vue, vuex, vue-router, 并且它们是CDN的方式引入,那肯定会报错的,为什么呢?因为模块联邦远程代码的加载是异步的,而CDN的代码在加载完成后,代码执行是同步的。你的远程模块还没加载过来,就执行过去了。

所以需要用MFSU的配置排除一下:

new MFSU({
    implementor: webpack,
    buildDepWithESBuild: true,
    unMatchLibs: [ // 排除一下 lib
      /vue/,
      /vuex/,
      /vue-router/
    ],
  })

四、MFSU的执行过程

MFSU的执行过程,可以分为两部分:

  • 本地应用构建(其实就是你的项目)
  • 远程应用构建(你的项目所依赖的lib, 会被当做远程应用)

本地应用

1. 初始化阶段

(1)初始化MFSU配置

首先会创建new MFSU实例,在构造函数中会做这些事:

  • 从静态缓存文件MFSU_CACHE.json 中获取模块依赖配置
  • 遍历MFSU_CACHE.json 直接生成模块树,这一步是为了跳过在你项目编译中的依赖收集的过程。

(2)设置babel插件

设置了babel插件,简单来讲,就是用来收集三方模块依赖替换模块资源路径, 这一块我们下面讲。

(3)设置中间件

设置中间件,中间的作用是为了为了响应远程应用的资源,因为我们知道mfsu打包后会生成mf-va_remoteEntry.js

(4)设置webpack配置

这一步就很关键了,主要分为以下几点:

  • 创建虚拟入口模块

    import('src/index.ts')作一个重新导入,这里为什么要把你的入口变成动态导入呢?原因也很简单:模块联邦的加载是异步的,而入口文件的执行是同步的。MFSU相当于创建了一个虚拟入口,然后动态导入了你的入口。

    可以看到它使用WebpackVirtualModules这个插件来实现的。

    image.png

  • 创建本地模块联邦应用

    在这里,musu给你的webpack配置注入了模块联邦插件,并且直接把你的项目启动Server作为了远程应用的Server.

    它的远程路径为 mf@/mf-va_remoteEntry.js, /其实就是代表当前项目启动的Server

  • 监听done事件

    下面mfsu继续注入了BuildDepPlugin, 目的是监听done事件,等你的项目编译完成后,就开始正式打包远程应用。

    image.png

2. 构建阶段

在上面,mfsu做了一系列的初始化操作,过程也非常简单。那么在项目构建过程中,又发生了什么呢?

(1)替换依赖的source

image.png

可以看到,babel在分析Program这个节点时,先获取到body上的ast节点,然后去修改了source

node.source.value = replaceValue;

举个例子:

import Vue from 'vue'

如果匹配到vue是一个三方模块,那么它的路径会被修改为

import Vue from 'mf/vue'

最后访问页面时,实际会去请求远程应用的模块。

(2)babel插件收集依赖

在编译完成后的插件post方法中,将所有的依赖给收集起来。

image.png

远程应用

1. 触发依赖构建

前面说到,在BuildDepPlugin插件中,监听了done事件,项目编译完成后,会触发依赖build

但在构建前,需要判断shouldBuild, 如果依赖和缓存中的依赖对比,没有发生变化,可以直接跳过依赖构建

image.png

没有命中缓存,就重新构建依赖。

可以看到,我们可以选择用webpack还是esbuild打包

if (this.opts.mfsu.opts.buildDepWithESBuild) {
    await this.buildWithESBuild(buildOpts);
} else {
    await this.buildWithWebpack(buildOpts);
}

2. webpack是如何打包依赖的

说到用webpack打包依赖,大家应该想起前面的depConfigs,它是用来打包依赖的webpack配置。

getWebpackConfig方法中,mfsu 对配置做了一些修改。

 getWebpackConfig(opts: { deps: Dep[] }) {
    const mfName = this.opts.mfsu.opts.mfName!;
    const depConfig = lodash.cloneDeep(this.opts.mfsu.depConfig!);

    // 。。。其他
    depConfig.entry = join(this.opts.mfsu.opts.tmpBase!, MF_ENTRY);

    // ... 其他
    const exposes = opts.deps.reduce<Record<string, string>>((memo, dep) => {
      memo[`./${dep.file}`] = join(this.opts.mfsu.opts.tmpBase!, dep.filePath);
      return memo;
    }, {});
    depConfig.plugins.push(
      new this.opts.mfsu.opts.implementor.container.ModuleFederationPlugin({
        library: {
          type: 'global',
          name: mfName,
        },
        name: mfName,
        filename: REMOTE_FILE_FULL,
        exposes,
        shared: this.opts.mfsu.opts.shared || {},
      }),
    );
    return depConfig;
  }

可以看到,核心主要有两块

  • 设置了依赖构建的入口,也就是.mfsu/mf_index.js,这个文件其实没有任何作用,只是为了让webpack不报错。

  • 注入了ModuleFederationPlugin, 并把依赖通过exposes暴露出来

    可以看到,下面的代码中,遍历了所有三方依赖,并且生成了最终的exposes

    const exposes = opts.deps.reduce<Record<string, string>>((memo, dep) => {
        memo[`./${dep.file}`] = join(this.opts.mfsu.opts.tmpBase!, dep.filePath);
        return memo;
      }, {});
    

打包完成后可以看到下面的文件:

image.png

随便打开一个:

image.png

发现仅仅是把模块导出来了。为什么会这样呢?

你可以把.mfsu目录想象成一个远程应用,如果你想暴露出模块,是不是必须这样写:

new ModuleFederationPlugin({
   exposes: {
      "./button": "./src/button"
   }
})

你得保证这个文件存在呀!那么打包后的代码在哪里呢?

image.png

其实全都在mf-va_remoteEntry.js这个文件里面。

3. esbuild是如何打包依赖的

我们先来思考一个问题,esbuild 我们知道,是没有模块联邦插件的。也不可能实现这些功能,那么有没有可能模块联邦其实就是一堆代码模板。

没错~模块联邦的运行时代码,其实非常固定,我们能直接套过来。

image.png

image.png

image.png 可以看到mfsu模拟实现了webpack打包模块联邦后的代码,esbuild只提供生成依赖模块的代码。

其详细实现,大家可以自行研究。

五、MFSU概览和总结

image2022-11-25_13-59-40.png

我们来总结一下过程

  • 本地应用

    • 首先加载缓存依赖树
    • 注入babel插件
    • 注入中间件
    • 注入模块联邦插件(本地)
    • 分析依赖(替换资源、收集依赖)
    • 生成新的依赖树
  • 远程应用

    • 监听构建完成
    • 判断是否命中缓存,可以跳过依赖构建
    • 注入模块联邦插件(远程),根据依赖生成exposes。
    • 写入缓存MFSU_CACHE.json, 打包生成mf-va_remoteEntry.js

六、结语

在看了mfsu源码后,发现模块联邦竟然还能这么玩。

但是坑感觉比较多。另外对项目的优化,目前没有感觉提升明显,或许是哪里配置有问题 或许使用姿势不正确 ?

欢迎大家讨论和指正~