webpack5之重新起航

2,006 阅读11分钟

往期文章:

前言

webpack5与webpack4相比有较大的改动,甚至有些颠覆性,目前新版本已经发布,但是配套的loader、plugin还未完全就绪,不建议此时升级项目,但是咱们可以一起学习,了解和掌握其精髓,甚至成为webpack开发者,为之后项目升级做好提前亮~

主要改变

  • 通过持久缓存提高构建性能
  • 使用更好的算法和默认值来改善长期缓存
  • 通过更好的Tree Shaking和代码生成来改善捆绑包大小
  • 改善与Web平台的兼容性
  • 在v4中实施功能时,请清理处于怪异状态的内部结构,而不引入任何重大更改
  • 现在就引入重大更改,为将来的功能做准备,使我们能够尽可能长时间地使用v5

产出物

  • 代码量明显的减少
console.log('Hello World');

  • 生成代码更清爽
    • 如果不引入其他文件,没了 webpack4 那种乱糟的辅助代码(模块管理代码),清爽多了

编译器优化

Compiler大家应该不陌生,在Webpack中充斥着大量的钩子和触发事件

在新的版本中,编译器在使用完毕后应该被关闭,因为它们在进入或退出空闲状态时,拥有这些状态的 hook。 插件可以用这些 hook 来执行不太重要的工作(比如:持久性缓存把缓存慢慢地存储到磁盘上)。同时插件的作者应该预见到某些用户可能会忘记关闭编译器,所以 当编译器关闭所有剩下的工作时应尽快完成。 然后回调将会通知已彻底完成。 当升级到 v5 时,请确保在完成工作后使用 Node.js API 调用 Compiler.close

minSize&maxSize 更好的方式表达

  • webpack4版本
module.exports = {
    optimization: {
        splitChunks: {
            cacheGroups: {
                commons: {
                    chunks: 'all',
                    name: 'commons',
                    minChunks: 1,
                    minSize: '数值',
                    maxSize: '数值'
                }
            }
        }
    }
}
  • webpack5
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          chunks: 'all',
          name: 'commons',
        }
      },
      //最小的文件大小 超过之后将不予打包
      minSize: {
        javascript: 0,
        style: 0,
      },
      //最大的文件 超过之后继续拆分
      maxSize: {
        javascript: 1, //故意写小的效果更明显
        style: 3000,
      }
    }
  }
}

Node.js polyfills 自动被移除

过去,Webpack4版本附带了大多数 Node.js 核心模块的 polyfills,一旦前端使用了任何核心模块,这些模块就会自动应用,但是其实有些是不必要的。 V5中的尝试是自动停止 polyfilling 这些核心模块,并侧重于前端兼容的模块。当迁移到 v5时,最好尽可能使用前端兼容的模块,并尽可能手动添加核心模块的polyfills。

持久化缓存

  • 添加了用于长期缓存的新算法。在生产模式下默认启用这些功能
chunkIds: "deterministic" 
moduleIds: "deterministic" 
mangleExports: "deterministic"

该算法以确定性方式为模块和块分配短(3或5位数字)数字ID,并为导出分配短(2个字符)名称。这是包大小和长期缓存之间的折衷方案

  • moduleIds/chunkIds/mangleExports: false,禁用默认行为,并且可以通过插件提供自定义算法

注意,在moduleIds/chunkIds: false在webpack4生成的版本有效,而在webpack5中,必须提供自定义插件

迁移:最好使用默认值chunkIds,moduleIds和mangleExports。还可以选择使用旧的默认设置chunkIds: "size", moduleIds: "size", mangleExports: "size",这将生成较小的包,但需要使它们频繁地失效以进行缓存。 注意:在webpack 4中,散列的模块ID导致gzip性能降低。这与更改的模块顺序有关,并且已得到修复。

显示地增加缓存选项 cache

  • 开启cache选项,提升webpack打包性能
  • 文件系统缓存。它是可选的,可以通过以下配置启用:
module.exports = {
  // cache:true | false 
  cache: {
    type: 'filesystem',
    // 自定义缓存目录,
    // cacheDirectory: path.resolve(__dirname, '.temp_cache')
    buildDependencies: {
      // 添加项目配置作为构建依赖项,以便配置更改时缓存失效
      config: [__filename],
      // 如果还有构建依赖其他内容,在此处添加
      //注意,配置中引用的webpack、加载器和所有模块都会被自动添加
    }
  }
};

缓存将存储到node_modules/.cache/webpack,当使用Yarn PnP时缓存目录为.yarn/.cache/webpack

许多内部插件也将使用持久性缓存。如 ourceMapDevToolPlugin缓存SourceMap生成、ProgressPlugin缓存模块数量

持久性缓存将根据使用情况自动创建多个缓存文件,以优化对缓存的读写访问。

默认情况下,时间戳将在开发模式下用于快照,而在生产模式下用于文件哈希。

建议:开发开启缓存,生产关闭缓存

真实内容哈希 contenthash

  • webpack4:仅使用文件内部结构的哈希
  • webpack5:文件内容真实哈希,仅修改注释或重命名变量时,webpack5能监测到文件变化,而webpack4监听不到变化(这可能对长期缓存产生积极影响)

友好的命名块 ID

  • webpack4
    • 打包产出物会生成 0.xxx.js (chunk)
    • 简短的数字 ID,在编译之间不会更改。适合长期缓存
  • webpack5
    • development: 默认启用的新命名块 ID 算法,为块(和文件名)提供了易于理解的名称。模块 ID 由其相对于的路径确定 context。块 ID 由块的内容确定,例如:src_demo1_data_js.js
    • production: 算法实现短(3或5位数字)数字ID,例如:125.js

webpack5这么做的好处:防止之前的 0.xxx.js这种文件名串号

开发调试chunks更加友好

  • webpack4: import(/_ webpackChunkName: "name" _/ "module")
  • webpack5: import("module") ,开发环境默认开发 optimization.chunkIds:'named'

模块联合

Webpack5 添加了一个称为“模块联合”的新功能,可以让跨应用间真正做到模块共享。

  • 该功能允许多个 Webpack 构建协同工作.该功能使 Webpack 可以实现线上 Runtime 效果,代码直接在项目间利用 CDN 直接共享,其结果就是:不再需要每次都得本地安装 Npm 包,再构建再发布了
  • Webpack4 可以通过 DLL 或者 Externals 做代码共享时 Common Chunk,但实际上不同同应用和项目间共享就困难了,很难做到项目之间做到按需热插拔。 模块联邦是 Webpack5 新内置的一个重要功能,
  • 从运行时角度: 多个构建的模块的行为将类似于巨大的连接模块图
  • 从开发角度:从指定的远程版本中导入模块,并以最小的限制使用它们

共享模式的发展

NPM 方式共享模块

如下图所示,正常的代码共享需要将依赖作为 Libaray 安装到项目,进行 Webpack 打包构建再上线

对于项目 Home 与 Search,需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中,虽然可以一定程度解决重复安装和修改困难的问题,但依然依赖本地编译

UMD 方式共享模块

模块以 Webpack UMD 模式打包,并输出到其他项目中。目前比较普遍的模块共享方式:

对于项目 Home 与 Search,直接利用 UMD 包复用一个模块。但问题是包体积无法达到本地编译时的优化效果,且库之间很容易发生冲突。

微前端 方式共享模块

  • 微前端一般有两种打包方式:
    • 子应用独立打包,模块解耦,但无法抽取公共依赖等
    • 整体应用一起打包,但打包过程较慢,管理较繁琐

模块联合方式

  • 将应用Home中的一个包应用于Search中,同时具备整体应用一起打包的公共依赖抽取能力

  • 为了让应用具备模块化输出能力,webpack5新增 “中心应用”概念,这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用:

  • 所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,这样,可以更好的集成到主应用中

实现方式

配置

// app_a
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "app_a",
      remotes: {
        app_b: "app_b",
        app_c: "app_c"
      },
      exposes: {
        AppContainer: "./src/App"
      },
      shared: ["react", "react-dom", "react-router-dom"]
    }),
  ]
};

---------------------------------------------------
// app_b
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "app_b",
      library: { type: "var", name: "app_b" },
      filename: "remoteEntry.js",
      exposes: {
        Menu: "./src/Menu"
      },
      shared: ["react", "react-dom"]
    })
  ]
};\
  • name 当前应用名称,需要全局唯一,使用的时通过 name/{name}/{expose} 的方式使用;
  • filename: 打包出来的文件名
  • library 这里的 name 为作为 umd 的 name;
  • remotes 可以将其他项目的 name 映射到当前项目中。
  • exposes 表示导出的模块,只有在此声明的模块才可以作为远程依赖被使用。
  • shared 可以让远程加载的模块对应依赖改为使用本地项目的,优先用 Host 的依赖,如果 Host 没有,本地的

引用

app_a: remotes: { app_b: "app_b", app_c: "app_c" },业务代码就可以直接利用以下方式直接从对方应用调用模块:

  • 方式一:不推荐
<head>
  <script src="http://localhost:3002/app_b.js"></script>
  <script src="http://localhost:3003/app_c.js"></script>
</head>

方式二:

// app_a
// import { Menu } from "app_b/Menu";
// const Menu = await import("app_b/Menu");
const Menu = React.lazy(() => import("app_b/Menu"));

const MenuContainer = () => {
    return (
        <div>
            <React.Suspense fallback="Loading Menu...">
                <Menu />
            </React.Suspense>
        </div>
    );
}

export default MenuContainer;
---------------------------------------------------------
// app_b
const AppContainer = React.lazy(() => import("app__remote/AppContainer"));

const App = () => {
    return (
        <div>
            <React.Suspense fallback="Loading App Container...">
                <AppContainer />
            </React.Suspense>
        </div>
    );
}

开启实验功能

再见file/url/raw-loader-loader

  • 设置 experiments.asset = true,给图片文件设置名字 assetModuleFilename
  • js 名字还是原来的 filename
module.exports = {
  output: {
    assetModuleFilename: 'images/[name].[hash:5][ext]',
  },
  module: {
    rules: [
      {
        test: /\.(png|jpg|svg)$/,
        type: 'asset',
      },
    ],
  },
  experiments: {
    asset: true,
  },
};

异步引入·真香

  • wepback4环境这样异步引入代码
import('./data').then(_ => {
  console.log(_);
});

webpack4 无法处理这种场景

// async.js
let output;
async function main() {
  const dynamic = await import('./data');
  output = dynamic + '';
}
main();
export default output;

// index.js
import output from './async';
console.log(output); // undefined

webpack5 开启topLevelAwait 可以实现,并且无需外面包裹 async

  • webpack配置:experiments.topLevelAwait = true
// async.js
const dynamic = await import('./data');
const output = dynamic + '';
export default output;// hello world
  • 更激进的写法
// async.js
const dynamic = import('./data');
const output = (await dynamic).default + '🍊'; 
export default output; // 输出 hello world🍊]

webpack5 是如何实现 topLevelAwait,保证输出的?

  • 我们先来看一个题目
let a = 0;
async function test() {
  a = a + (await 10);
  console.log(a);
}
test();
console.log(++a);

答案:1 10

分析:当执行到 await 时,会将 10 放到 promise 中,同时将 await 前面的 a 锁死,await本身是es6 Generator 的语法,本质是将一个函数执行暂停,并保存上下文,再次调用时恢复当时的状态,实际管理是协程

    • 进程、线程和协程的比较 进程:变量隔离,自动切换运行上下文 线程:变量不隔离,自动切换运行上下文切换 协程:变量不隔离,不自动切换运行上下文切换

将协程视为轻量级线程是协程的最直观,最高级的隐喻。与实际线程相比,最大的概念差异是缺少调度程序(所有上下文切换必须由程序完成

  • 如果将a放在 await 后面
let a = 0;
async function test() {
  a = (await 10) + a;
  console.log(a);
}
test();
console.log(++a);

答案:1 11

实际应用场景

//demo02/index.js
const connectToDB = async () => {
  const data = await new Promise(r => {
    r('xfz');
  });
  return data;
};
const result = await connectToDB();
let output = `${result} 🍊`;
export { output };

//执行如下代码
import { output } from './demo02';
console.log(output); // xfz 🍊

WebAssembly 使用就像吃了德芙...

  • 整个 wasm 文件
int add (int x, int y) {
  return x + y;
}
//然后我们把它编译后生成 util.wasm
// 这里使用 emscripten 来编译 c
  • webpack4
    • 不能同步地加载,否则会报错,不能把wasm当成主chunk,只能如下面方式引入
import('./util.wasm').then(_ => {
   console.log(_.add(4, 6));
});
  • webpack5,开启 syncWebAssembly和syncWebAssembly后,就可以happy coding 了
// webpack.config.js
module.exports = {
  experiments: {
    asyncWebAssembly: true,
    syncWebAssembly: true,
  },
};

// index.js
import { add } from './util'
console.log(add(4, 6))

分析模式升级

  • 新添加的 percentBy-option 提示 ProgressPlugin 如何计算进度百分比
plugins: [
  new webpack.ProgressPlugin((percentage, message, ...args) => {
    console.info(percentage, message, ...args);
  }),
],
    • percentage:0到1之间的数字,指示编译的完成百分比
    • message:当前正在执行的钩子的简短描述
    • ...args:零个或多个其他字符串描述当前进度
new webpack.ProgressPlugin({
  activeModules: false,// 显示活动模块计数和一个活动模块进行中消息
  entries: true, // 显示正在计数的条目消息
  handler(percentage, message, ...args) {
    // custom logic
  },
  modules: true, // 显示模块计数进行中消息
  modulesCount: 5000, // 最小模块数开始。modules启用属性后生效
  profile: false, // 告诉ProgressPlugin收集配置文件数据以进行进度
  dependencies: true, // 示正在进行的依赖项计数消息
  dependenciesCount: 10000, // 最小依赖项计数开始。dependencies启用属性后生效
  percentBy: 'entries' | 'dependencies' | 'modules' | null, // 说明如何计算进度百分比
});

集成了 prepack

  • prepack 预包装 ,对于繁重的初始化代码,Prepack 在有效解析 JavaScript 后,直接给结果了
(function () {
  function hello() {
    return 'hello';
  }
  function world() {
    return 'world';
  }
  global.s = hello() + ' ' + world();
})();

prepack 觉得你这段代码是废话,直接帮你求值了,经处理后,代码是这样的

s = 'hello world';

webpack-dev-server 变更

  • webpack内置devServer
// package.json
"scripts": {
  "start": "webpack serve --open",
},
// webpack.config.js
devServer: {
  contentBase: './dist',
},
  • 其他选项在会后续文章中详细阐述

html-webpack-plugin

  • webpack会默认生成一个或多个html文件(也就是代码中可以没有template.html)
  • 可以自主选择是否使用webpack默认生成的html
    • 否,指定 template: './index.html'
    • 是,通过 options 取代 template.html 文件中的内容,自动根据options插入生成的html中
plugins: [
  new HtmlWebpackPlugin({
    title: 'Development',
    meta: {
      keywords: 'webpack5的使用',
    },
  }),
],

  • 无论使用那种方式,生成的html都能自动获取到htmlWebpackPlugin的传参
    • 自己指定模版中的内容高于options中的内容 例如:
// config
plugins: [
  new HtmlWebpackPlugin({
    filename: 'index.html',
    template: 'src/index.html',
    title: 'Development',
    meta: {
      keywords: 'webpack5的使用',
    },
  }),
],
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>test</title>
  <style>
    body {
      background-color: pink;
    }
  </style>
</head>
<body>
</body>
</html>

打包后,生成的html

  • 个人更偏向使用默认生成html的方式,因为可以通过webpack轻松控制html里面的内容,而无需像webpack4一样获取html-webpack-plugin的传参数,对于区分开发和生产环境的html更加方便(也可以指定一个基础模版,通过不同环境设置不同的options)
"scripts": {
    "start": "webpack serve --config webpack.dev.js",
    "build": "webpack --config webpack.prod.js"
  },

webpack.dev.js

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './dist',
  },
  plugins: [
    new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
    new HtmlWebpackPlugin({
      title: 'Development',
    }),
  ],
};

webpack.prod.js

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  devtool: false,
  plugins: [
    new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
    new HtmlWebpackPlugin({
      templateContent: `
      <!DOCTYPE html>
      <html>
      <head>
        <meta charset="utf-8">
        <meta http-equiv="x-dns-prefetch-control" contenxit="on">
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <meta name="screen-orientation" content="portrait" />
        <meta content="yes" name="apple-mobile-web-app-capable">
        <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
        <meta name="format-detection" content="telephone=no">
        <meta name="full-screen" content="yes">
        <meta name="x5-fullscreen" content="true">
        <title>生产环境</title>
        <link rel="dns-prefetch" href="//xxx.com">
        <link rel="dns-prefetch" href="//cdn.bootcss.com">
        <link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css">
        <link rel="stylesheet" href="https://cdn.bootcss.com/font-awesome/4.7.0/css/font-awesome.min.css">
      </head>
      <body>
        <div id="app"></div>
        <script src="https://polyfill.io/v3/polyfill.js"></script>
        <script src="https://cdn.bootcss.com/react/16.7.0/umd/react.production.min.js"></script>
        <script src="https://cdn.bootcss.com/react-dom/16.7.0/umd/react-dom.production.min.js"></script>
        <script src="https://cdn.bootcss.com/react-router-dom/4.2.2/react-router-dom.min.js"></script>
        <script src="https://cdn.bootcss.com/mobx/5.1.0/mobx.umd.min.js"></script>
        <script src="https://cdn.bootcss.com/mobx-react/5.2.5/index.min.js"></script>
      </body>
      </html>
      `,
    }),
  ],
};

后续文章

❤️ 加入我们

字节跳动 · 幸福里团队

Nice Leader:高级技术专家、掘金知名专栏作者、Flutter中文网社区创办者、Flutter中文社区开源项目发起人、Github社区知名开发者,是dio、fly、dsBridge等多个知名开源项目作者

期待您的加入,一起用技术改变生活!!!

招聘链接: job.toutiao.com/s/JHjRX8B