【面试官来了】— 谈谈webpack5吧

3,781 阅读8分钟

我正在参与掘金创作者训练营第6期,点击了解活动详情

前言

这“讨厌”的面试官又来了,挺着个油腻的大肚子,格子衫堪堪裹住他的腰,摸了摸他的地中海发型,张开自认为性感的厚嘴唇,说了句:“年轻人,怎么又是你?上次没把你虐惨吗?不死心的话,这次我们来聊聊 webpack5 吧,嘿嘿~”。

webpack简述

webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。它会根据配置文件的entry属性作为入口,找到各模块的依赖关系,然后将所有这些模块打包成一个或多个 bundle。webpack 只能理解 JavaScript 和 JSON 文件,因此如果想要增强它的能力,那么就需要相应的loader和plugin。另外plugin的串联执行,则用到的是 Tapable 这个库。

Tapable

Tapable 是一个专门用来实现事件订阅或者他自己称为hook(钩子)的工具库,其根本原理还是发布订阅模式,它对外暴露了多个 hook ,以便实现整个应用程序的事件流程。下面列举了主要的几个:

序号钩子名称执行方式概要
1SyncHook同步串行不关心监听函数的返回值
2SyncBailHook同步串行只要监听函数中有一个函数的返回值不为 null,则跳过剩下所有的逻辑
3SyncWaterfallHook同步串行上一个监听函数的返回值可以传给下一个监听函数
4SyncLoopHook同步循环当监听函数被触发的时候,如果该监听函数返回true时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环
5AsyncParallelHook异步并发不关心监听函数的返回值
6AsyncParallelBailHook异步并发只要监听函数的返回值不为 null,就会忽略后面的监听函数执行,直接跳跃到callAsync等触发函数绑定的回调函数,然后执行这个被绑定的回调函数
7AsyncSeriesHook异步串行不关系callback()的参数
8AsyncSeriesBailHook异步串行callback()的参数不为null,就会直接执行callAsync等触发函数绑定的回调函数
9AsyncSeriesWaterfallHook异步串行上一个监听函数的中的callback(err, data)的第二个参数,可以作为下一个监听函数的参数

loader和plugin的区别

  • loader

    上面提到过 webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效模块,以供应用程序使用,以及被添加到依赖图中,大白话就是loader一般是用来处理webpack不认识的文件类型,比如css、image、vue文件。。。所以才有css-loader、image-loader、vue-loader这些东西的存在。

  • plugin

    loader 用于转换某些类型的模块,而plugins(插件)则可以用于增强 webpack,它的执行范围更广。比如:打包优化,资源管理,注入环境变量。简单来说,webpack 在运行时会对外广播事件,插件则监听它所订阅的事件,去改变执行结果。

webpack5 新特性:

启动命令

开发环境:webpack serve

生产环境:webpack

内置清除输出目录

之前的版本我们经常需要一个叫 clean-webpack-plugin 的插件,来帮助我们清除上次构建的dist产物,现在我们只要一个参数的配置就可以搞定:

//webpack.config.js
module.exports = {
    output: {
        clean: true,
    }
}

缓存

会缓存生成的 webpack 模块和 chunk,来改善构建速度。webpack 追踪了每个模块的依赖,并创建了文件快照,与真实的文件系统进行对比,当发生差异时,触发对应的模块重新构建。默认开启缓存,总的来说有两种类型。

  1. cache: { type: 'memory' } 这是默认配置,
  2. cache: { type: 'filesystem' } 缓存到本地文件系统,默认的缓存目录是 node_modules/.cache/webpack,当然也可以自己通过 cacheDirectory 属性配置,生成的目录结构大概是这样的:

image.png

资源模块

原生支持 json、png、jpeg、jpg、txt 等格式文件。也就是说无需配置额外的 loader,比如raw-loader、file-loader、url-loader 等等

// 'javascript/auto' | 'javascript/dynamic' | 'javascript/esm' | 'json' | 'webassembly/sync' | 'webassembly/async' | 'asset' | 'asset/source' | 'asset/resource' | 'asset/inline'
  {
      test: /\.png$/i,
      type: "asset",
      parser: {
        dataUrlCondition: {
          maxSize: 4 * 1024,
        },
      },
   },
   /*
   与之对应的是之前 url-loader 的用法
   use:[{
       loader: 'url-loader',
         options: {
            limit: 8192,
        }
    }]
   */

moduleIds & chunkIds 的优化

在 webpack5 之前,没有从 entry 打包的 chunk 文件,都会以 1、2、3。。。的文件命名方式输出,这样删除某些文件由于顺序变了可能会导致缓存失效; 在 webpack5 中,生产环境下默认使用了 deterministic 的方式生成短 hash 值来分配给 modules 和 chunks 来解决上述问题

  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
  },

更智能的 tree shaking

webpack4 tree-shaking 是通过扫描文件中未引用到的函数实现再将其剔除实现的,作用很小,如果使用场景有嵌套的方法引用,就不管用了;比如,有如下的引用关系:

image.png 在 webpack5 中设置:

optimization: {
  usedExports: true,
},

可以清楚的看到打包结果:

image.png

function2、function4 函数已经被标记为未被使用,在打包生产环境时,将会被剔除。

另外还可以在 package.json 中配置 sideEffects:false 表示整个项目都没有副作用,webpack 在打包时会自动剔除具有副作用代码; 当然也可以指定类型或文件保留副作用,比如配置 sideEffects: ['*.css'] 表示保留 import './index.css' 类似的代码

模块联邦

在介绍这个新特性之前,让我们来先假想一个场景:有两个独立的项目A、B,如果在这两个项目间有公共依赖,我们通常的做法是什么?貌似能想到的最优解就是将公共依赖做成npm包,然后两个项目分别安装,但是在每次对这个npm包升级的时候,两个项目都需要重新更新版本号。项目一旦多了,这样是不是有点繁琐?

基于此,webpack5 推出了模块联邦(Module Federation)这一新特性。用大白话来解释就是,webpack 提供了一种解决方案,将公共依赖打包放在远程地址,各项目间通过 CDN 的方式引用,以达到一种在线 runtime 的效果,它们的关系类似于这样:

image.png

搭建模块联邦

先初始化两个项目 provider、comsumer

pnpm install webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/preset-env @babel/preset-react @babel/core style-loader css-loader -D

pnpm install react react-dom

在熟悉这个功能之前,我们先理清两个重要的角色,webpack 官网上提出了两个概念:remotes 和 host,但为了方便理解,我个人更倾向于叫它们为 provider 和 comsumer,provider作为依赖的提供方,comsumer作为依赖的消费方。

// provider/src/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "provider", // 必须唯一 模块的名称
      filename: "remoteEntry.js", // 必须 生成的模块名称
      exposes: {
        // 很明显,需要对外暴露的模块 注意该对象的key必须这么写
        "./Search": "./src/Search",
        "./utils": "./src/utils",
      },
    }),
  ],
};
// comsumer/src/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "comsumer", // 必须唯一 模块的名称
      // 很明显,需要映射的远程provider
      remotes: {
        /**
         * 这个地方来拆解下这个对象的参数
         * key: 无所谓随意取,但在后续消费的时候有用
         * value: "provider@http://localhost:9000/remoteEntry.js"
         * 这里的provider:依然是上面project-a配置的name
         * http://localhost:9000/: 这个表示provider项目部署后的远程地址
         * remoteEntry.js:指的是上面provider项目中定义的filename
         */
        module1: "provider@http://localhost:9000/remoteEntry.js",
      },
    }),
  ],
};

两个项目的配置定义好了,下面来看下在 comsumer 项目中怎么用吧

import React, { lazy, Suspense, useEffect } from "react";

/**
 * import("module1/Search")
 * 这里的module1 指的是上面comsumer配置中定义remotes时设置的key
 * Search 指的是上面provider配置中定义exposes时设置的key
 */
const ProviderSearch = lazy(() => import("module1/Search"));

const App = () => {
  return (
    <div>
      <h1>这是comsumer项目</h1>
      <Suspense>
        <ProviderSearch />
      </Suspense>
    </div>
  );
};

export default App;

除此之外还有一种全局调用的方法:

在 provider 中加上

// provider
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      ...
      library: { type: "var", name: "provider" },
    }),
  ],
};

在需要使用的地方,注意这里可以不用区别在 provider、comsumer 项目中

function loadComponent(scope, module) {
  return async () => {
    await __webpack_init_sharing__("default");
    const container = window[scope];
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}
// provider 指的是上面在library配置中定义的name属性,utils 指的是provider向外暴露的公共依赖
loadComponent("provider", "utils");

共享模块

如果上面的 provider 项目和 comsumer 项目都引入了 react 那么如何才能共用一个实例呢?我们只需要在原有的配置加上 shared 属性:

// 给两个项目都配置上shared
  shared: {
    react: {
      singleton: true,
    },
  },

可以在 comsumer 项目中看到,引用了 provider 项目中 react 版本 image.png 这里需要注意的是:shared 默认选择的是高版本的共享模块,如果需要指定版本可以添加requiredVersion属性。

最后

还是说回我们这“讨厌的面试官”吧,此刻他心里一阵嘀咕:“这小子一日不见,当刮目相看啊!这次虐不了他了,算了,再找找下一个倒霉蛋吧”,故作镇定的说到:”webpack5 的新特性你没说完吧,算了,看你也不知道,给你份文档回去研究吧,webpack5 changelog,另外,你刚刚说的也有瑕疵哈,先回去等通知吧。”

各位吃瓜群众,我上面说的有瑕疵吗?请一定指正啊,好让我下次暴虐这“面试官”!