webpack5新特性

328 阅读20分钟

创建项目

  • 新建项目 webpack5-taste
  • yarn init -y
  • yarn add webpack webpack-cli -D
  • 新建 webpack.config.js

0 配置打包(webpack4也可以0配置,但是远没有5强大)

新建 src/a.js

export const name = 'Jack';
export const age = 18;

src/b.js

import * as a from './a.js';

export { a };

src/index.js

import * as b from './b.js';

console.log(b.a.name);

webpack.config.js

module.exports = {
  mode: 'development'
}

执行打包命令 webpack,发现可以成功打包,并且打包后代码的启动文件等均为箭头函数了!

更小打包体积

在切换 mode 为 production 后,我们发现打包内容只有代码 438 字节。

[webpack-cli] Compilation finished
asset main.js 43 bytes [emitted] [minimized] (name: main)
orphan modules 93 bytes [orphan] 2 modules
./src/index.js + 1 modules 102 bytes [built] [code generated]
webpack 5.11.0 compiled successfully in 176 ms

而 webpack4 同样代码,打包内容为 1.07kb,这么点代码 5 比 4 打包小了接近1kb..

Hash: 32dfad9d9e7e2bb3313a
Version: webpack 4.44.2
Time: 225ms
Built at: 2020-12-19 16:47:38
    Asset      Size  Chunks             Chunk Names
bundle.js  1.05 KiB       0  [emitted]  main
Entrypoint main = bundle.js
[0] ./src/index.js + 2 modules 145 bytes {0} [built]
    | ./src/index.js 52 bytes [built]
    | ./src/b.js 43 bytes [built]
    | ./src/a.js 50 bytes [built]

多进程打包 thread-loader

npm  i thread-loader -D

把这个 loader 放置在其他 loader 之前, 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行

const path = require("path");
module.exports = {
  mode: "development",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.js/,
        include: path.resolve(__dirname, "src"),
        use: [
+          {
+            loader:'thread-loader',
+            options:{
+              workers:3
+            }
+          },
          {
            loader: "babel-loader",
            options: {
              presets: ["@babel/preset-env", "@babel/preset-react"],
            },
          },
        ],
      }
    ]
  }
};

css tree-shaking

npm i  purgecss-webpack-plugin mini-css-extract-plugin css-loader glob -D
  • 可以去除未使用的 css,一般与 glob、glob-all 配合使用
  • 必须和 mini-css-extract-plugin 配合使用
  • paths 路径是绝对路径

webpack.config.js

const path = require("path");
+const glob = require("glob");
+const PurgecssPlugin = require("purgecss-webpack-plugin");
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+ const PATHS = {
+  src: path.join(__dirname, 'src')
+}

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  module: {
    rules: [
+      {
+        test: /\.css$/,
+        include: path.resolve(__dirname, "src"),
+        exclude: /node_modules/,
+        use: [
+          {
+            loader: MiniCssExtractPlugin.loader,
+          },
+          "css-loader",
+        ],
+      },
    ],
  },
  plugins: [
+    new MiniCssExtractPlugin({
+      filename: "[name].css",
+    }),
+    new PurgecssPlugin({
+      paths: glob.sync(`${PATHS.src}/**/*`,  { nodir: true }),
+    })
  ],
};

js tree-shaking

配置 tree-shaking

  • webpack 默认支持,在 .babelrc 里设置 module: false 即可在 production mode 下默认开启
  • 还要注意把 devtool 设置为 null
  • 在 package.json 中配置: "sideEffects": false 所有的代码都没有副作用(都可以进行 tree shaking,默认 import css 文件是不会被摇树优化掉的)
  • 但是我们有些文件是有用的,比如 css 文件和 @babel/polyfill 文件,可以设置 "sideEffects": ["*.css". "@babel/polyfill"]

webpack.config.js

const path = require("path");
module.exports = {
+  mode: "production",
+  devtool:false,
  entry: {
    main: './src/index.js'
  },
  output:{
    path:path.resolve(__dirname,'dist'),
    filename:'[name].[hash].js'
  },
  module: {
    rules: [
      {
        test: /\.js/,
        include: path.resolve(__dirname, "src"),
        use: [
          {
            loader: "babel-loader",
            options: {
+              presets: [["@babel/preset-env",{"modules":false}], "@babel/preset-react"],
            },
          },
        ],
      },
    ],
  }
};

何时 tree-shaking

没有导入和使用

function func1(){
  return 'func1';
}
function func2(){
     return 'func2';
}
export {
  func1,
  func2
}
import { func2 } from './functions';
var result2 = func2();
console.log(result2);

代码不会被执行,不可到达

if (false) {
 console.log('false')
}

代码执行的结果不会被用到

import { func2 } from './functions';
let result = func2();

代码中只写不读的变量

var aabbcc = 'aabbcc';
aabbcc = 'eeffgg';

嵌套的 tree shaking

分析 webpack5 打包后的文件

(()=>{"use strict";console.log("Jack")})();

excuse me?

age = 18 被完全干掉了,在 webpack4 中无法处理包装了一层的模块引用中的 tree shaking,在webpack5 中得以修复,并删除了全部的 runtime 注入代码。

并且也支持 common JS 的 tree shaking 啦。

同样再看一个栗子

import { something } from './something';

function usingSomething() {
  return something;
}

export function test() { 
  return usingSomething();
}

如果 test 方法没有被调用,在 webpack4 中, something 这个模块也会被打包,而在 webpack5 中会被干掉。

代码分割

对于大的 Web 应用来讲,将所有的代码都放在一个文件中显然是不够有效的,特别是当你的某些代码块是在某些特殊的时候才会被用到。 webpack 有一个功能就是将你的代码库分割成 chunks 语块,当代码运行到需要它们的时候再进行加载。

入口点分割

  • Entry Points:入口文件设置的时候可以配置
  • 这种方法的问题
    • 如果入口 chunks 之间包含重复的模块(lodash),那些重复模块都会被引入到各个 bundle 中
    • 不够灵活,并且不能将核心应用程序逻辑进行动态拆分代码
entry: {
  index: "./src/index.js",
  login: "./src/login.js"
}

动态导入和懒加载

  • 用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载 在给单页应用做按需加载优化时
  • 一般采用以下原则:
    • 对网站功能进行划分,每一类一个chunk
    • 对于首次打开页面需要的功能直接加载,尽快展示给用户,某些依赖大量代码的功能点可以按需加载
    • 被分割出去的代码需要一个按需加载的时机

懒加载

hello.js

module.exports = "hello";

index.js

document.querySelector('#clickBtn').addEventListener('click',() => {
    import('./hello').then(result => {
        console.log(result.default);
    });
});

index.html

<button id="clickBtn">点我</button>

按需加载(变量控制组件显示)

如何在 react 项目中实现按需加载?

index.js

import React, { Component, Suspense } from "react";
import ReactDOM from "react-dom";
import Loading from "./components/Loading";
/* function lazy(loadFunction) {
  return class LazyComponent extends React.Component {
    state = { Comp: null };
    componentDidMount() {
      loadFunction().then((result) => {
        this.setState({ Comp: result.default });
      });
    }
    render() {
      let Comp = this.state.Comp;
      return Comp ? <Comp {...this.props} /> : null;
    }
  };
} */
const AppTitle = React.lazy(() =>
  import(/* webpackChunkName: "title" */ "./components/Title")
);

class App extends Component {
  constructor(){
    super();
    this.state = {visible:false};
  }
  show(){
    this.setState({ visible: true });
  };
  render() {
    return (
      <>
        {this.state.visible && (
          <Suspense fallback={<Loading />}>
            <AppTitle />
          </Suspense>
        )}
        <button onClick={this.show.bind(this)}>加载</button>
      </>
    );
  }
}
ReactDOM.render(<App />, document.querySelector("#root"));

Loading.js

import React, { Component, Suspense } from "react";
export default (props) => {
  return <p>Loading</p>;
};

Title.js

import React, { Component, Suspense } from "react";
export default props=>{
  return <p>Title</p>;
}

preload (预先加载)

  • preload 通常用于本页面要用到的关键资源,包括关键 js、字体、css 文件
  • preload 将会把资源得下载顺序权重提高,使得关键数据提前下载好,优化页面打开速度
  • 在资源上添加预先加载的注释,你指明该模块需要立即被使用
  • 一个资源的加载的优先级被分为五个级别,分别是
    • Highest 最高
    • High 高
    • Medium 中等
    • Low 低
    • Lowest 最低
  • 异步/延迟/插入的脚本(无论在什么位置)在网络优先级中是 Low
npm i preload-webpack-plugin -D

webpack.config.js

const path = require("path");
+const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin');

module.exports = {
   mode: "production",
   devtool:false,
  entry: {
    main: './src/index.js'
  },
  output:{
    path:path.resolve(__dirname,'dist'),
    filename:'[name].[hash].js'
  },
  module: {
    rules: [
      {
        test: /\.js/,
        include: path.resolve(__dirname, "src"),
        use: [
          {
            loader: "babel-loader",
            options: {
              presets: [["@babel/preset-env",{"modules":false}], "@babel/preset-react"],
            },
          },
        ],
      },
    ],
  },
  plugins: [
+   new PreloadWebpackPlugin()
  ]
};

src/index.js

// 注意这个魔法注释,import 的脚本本身是异步脚本,优先级是 low,
// 加了 webpackPreload 之后,代表着要按 preload 的优先级去插队处理。
import(
  `./utils.js` 
  /* webpackPreload: true */    
  /* webpackChunkName: "utils" */
)

页面自动插入如下脚本~

<link rel="preload" as="script" href="utils.js">

prefetch(预先拉取)

prefetch 跟 preload 不同,它的作用是告诉浏览器未来可能会使用到的某个资源,浏览器就会在闲时去加载对应的资源,若能预测到用户的行为,比如懒加载,点击到其它页面等则相当于提前预加载了需要的资源。

index.html

<link rel="prefetch" href="utils.js" as="script">

src/index.js

button.addEventListener('click', () => {
  import(
    `./utils.js`
    /* webpackPrefetch: true */
    /* webpackChunkName: "utils" */
  ).then(result => {
    result.default.log('hello');
  })
});

在页面空闲时机会加载 utils.js,当点击按钮时,不会重新发送请求,而是取自 prefetch cache 中的文件资源。

preload vs prefetch

  • preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源,要慎用
  • 而 prefetch 是告诉浏览器页面可能需要的资源,浏览器不一定会加载这些资源
  • 所以建议:对于当前页面很有必要的资源使用 preload, 对于可能在将来的页面中使用的资源使用 prefetch,preload 比页面资源更先加载,prefetch 比页面资源更后加载(空闲时机)。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="prefetch" href="prefetch.js?k=1" as="script">
    <link rel="prefetch" href="prefetch.js?k=2" as="script">
    <link rel="prefetch" href="prefetch.js?k=3" as="script">
    <link rel="prefetch" href="prefetch.js?k=4" as="script">
    <link rel="prefetch" href="prefetch.js?k=5" as="script">
</head>
<body>

</body>
<link rel="preload"  href="preload.js" as="script">
</html>

提取公共代码

为什么需要提取公共代码

  • 大网站有多个页面,每个页面由于采用相同技术栈和样式代码,会包含很多公共代码,如果都包含进来会有问题
  • 相同的资源被重复的加载,浪费用户的流量和服务器的成本;
  • 每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验。
  • 如果能把公共代码抽离成单独文件进行加载能进行优化,可以减少网络传输流量,降低服务器成本

提取思路

  • 基础类库,方便长期缓存
  • 页面之间的公用代码
  • 各个页面单独生成文件

使用 splitChunks 提取公共文件

  • module:就是js的模块化webpack支持commonJS、ES6等模块化规范,简单来说就是你通过import语句引入的代码
  • chunk: chunk是webpack根据功能拆分出来的,包含三种情况
    • 你的项目入口(entry)
    • 通过import()动态引入的代码
    • 通过splitChunks拆分出来的代码
  • bundle:bundle是webpack打包之后的各个文件,一般就是和chunk是一对一的关系,bundle就是对chunk进行编译压缩打包等处理之后的产出

默认配置 webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin');
{
  entry: {
    page1: "./src/page1.js",
    page2: "./src/page2.js",
    page3: "./src/page3.js",
  },
 optimization: {
  splitChunks: {
      // 默认作用于异步chunk,值为all/initial/async
      chunks: "all",
      // 默认值是30kb,代码块的最小尺寸, webpack5 可以根据资源后缀单独设置
      minSize:  {
        javascript: 30000,
        style: 50000  
      }, 
      // 被多少模块共享,在分割之前模块的被引用次数
      minChunks: 1,
      // 限制异步模块内部的并行最大请求数的,说白了你可以理解为是每个import()
      // 里面的最大并行请求数量
      maxAsyncRequests: 3, 
      // 限制入口的拆分数量
      maxInitialRequests: 5,
      // 打包后的名称,默认是chunk的名字通过分隔符(默认是~)分隔开,如vendor~
      name: true, 
      // 默认webpack将会使用入口名和代码块的名称生成命名,比如 'vendors~main.js'
      cacheGroups: {
      automaticNameDelimiter: "~", 
        // 设置缓存组用来抽取满足不同规则的chunk,下面以生成common为例
        vendors: {
          chunks: "all",
          test: /node_modules/,
          // 优先级,一个chunk很可能满足多个缓存组,会被抽取到优先级高的缓存组中,
          // 为了能够让自定义缓存组有更高的优先级(默认0),默认缓存组的priority属性为负值.
          priority: -10, 
        },
        default: {
          chunks: "all",
          minSize: 0, // 最小提取字节数
          minChunks: 2, // 最少被几个chunk引用
          priority: -20,
          reuseExistingChunk: false
        }
      },
      runtimeChunk:true
    },
  plugins:[
    new HtmlWebpackPlugin({
            template:'./src/index.html',
            chunks:["page1"],
            filename:'page1.html'
    }),
    new HtmlWebpackPlugin({
        template:'./src/index.html',
        chunks:["page2"],
        filename:'page2.html'
    }),
    new HtmlWebpackPlugin({
            template:'./src/index.html',
            chunks:["page3"],
            filename:'page3.html'
    })
  ]
}

src/page1.js

import module1 from "./module1";
import module2 from "./module2";
import $ from "jquery";
console.log(module1, module2, $);
import(/* webpackChunkName: "asyncModule1" */ "./asyncModule1");

src/page2.js

import module1 from "./module1";
import module2 from "./module2";
import $ from "jquery";
console.log(module1, module2, $);

src/page3.js

import module1 from "./module1";
import module3 from "./module3";
import $ from "jquery";
console.log(module1, module3, $);

src/module1.js

console.log("module1");

src/module2.js

console.log("module2");

src/module3.js

console.log("module3");

src/asyncModule1.js

import _ from 'lodash';
console.log(_);

关系图如下:

开启 Scope Hoisting

  • Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 "作用域提升",是在 Webpack3 中新推出的功能。
  • scope hoisting 的原理是将所有的模块按照引用顺序放在一个函数作用域里,然后适当地重命名一些变量以防止命名冲突
  • 代码体积更小,因为函数申明语句会产生大量代码
  • 代码在运行时因为创建的函数作用域更少了,内存开销也随之变小
  • 这个功能在 mode 为 production 下默认开启,开发环境要用 webpack.optimize.ModuleConcatenationPlugin 插件
  • 也要使用ES6 Module, CJS 不支持

hello.js

export default 'Hello';

index.js

import str from './hello.js';
console.log(str);

输出的结果main.js

"./src/index.js":
(function(module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
var hello = ('hello');
console.log(hello);
 })

函数由两个变成了一个,hello.js 中定义的内容被直接注入到了 main.js 中

不再为 Node.js 模块 自动引用 Polyfills

  • webpack4带了许多Node.js核心模块的polyfill,一旦模块中使用了任何核心模块(如crypto),这些模块就会被自动启用
  • webpack5不再自动引入这些polyfill
cnpm i crypto-js crypto-browserify stream-browserify buffer -D

src\index.js

import CryptoJS from 'crypto-js';
console.log(CryptoJS.MD5('ys').toString()); // 以前会自动帮引入 crypto,现在不帮引了

webpack.config.js

resolve:{
  /* fallback:{ 
      "crypto": require.resolve("crypto-browserify"), // 需要,需要手动配置
      "buffer": require.resolve("buffer"),
      "stream":require.resolve("stream-browserify")
  }, */
  fallback:{ 
      "crypto": false, // 确定不需要,别给我报错啦
      "buffer": false,
      "stream": false
  }
},

清理已弃用的功能

所有在 webpack 4 标记即将过期的功能,都已在该版移除。因此在迁移到 webpack 5 之前,请确保你在 webpack 4 运行的构建不会有任何的功能过期警告。

不再需要的魔法注释

在开发模式下,默认启用的新命名代码块 ID 算法为模块(和文件名)提供了人类可读的名称。 模块 ID 由其路径决定,相对于 context。代码块 ID 由代码块的内容决定。

所以你不再需要使用import(/* webpackChunkName: "name" */ "module")来调试。但如果你想控制生产环境的文件名,还是有意义的。

可以在生产环境中使用 chunkIds: "named" 在生产环境中使用,但要确保不要不小心暴露模块名的敏感信息。

迁移:如果你不喜欢在开发中改变文件名,你可以通过 chunkIds: "natural" 来使用旧的数字模式。

模块联邦

Webpack 5 增加了一个新的功能 "模块联邦",它允许多个 webpack 构建一起工作。从运行时的角度来看,多个构建的模块将表现得像一个巨大的连接模块图。从开发者的角度来看,模块可以从指定的远程构建中导入,并以最小的限制来使用。

动机

  • Module Federation的动机是为了不同开发小组间共同开发一个或者多个应用
  • 应用将被划分为更小的应用块,一个应用块,可以是比如头部导航或者侧边栏的前端组件,也可以是数据获取逻辑的逻辑组件
  • 每个应用块由不同的组开发
  • 应用或应用块共享其他其他应用块或者库

关键节点概念

  • 使用Module Federation时,每个应用块都是一个独立的构建,这些构建都将编译为容器
  • 容器可以被其他应用或者其他容器应用
  • 一个被引用的容器被称为remote, 引用者被称为host,remote暴露模块给host, host则可以使用这些暴露的模块,这些模块被成为remote模块

实战

字段类型含义
namestring必传值,即输出的模块名,被远程引用时路径为name/{name}/{expose}
libraryobject声明全局变量的方式,name为umd的name
remotesobject远程引用的应用名及其别名的映射,使用时以key值作为name
exposesobject被远程引用时可暴露的资源路径及其别名
sharedobject与其他应用之间可以共享的第三方依赖,使你的代码中不用重复加载同一份依赖

remote\webpack.config.js

let path = require("path");
let webpack = require("webpack");
let HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
    mode: "development",
    entry: "./src/index.js",
    output: {
        publicPath: "http://localhost:3000/",
    },
    devServer: {
        port: 3000
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ["@babel/preset-react"]
                    },
                },
                exclude: /node_modules/,
            },
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template:'./public/index.html'
        }),
        new ModuleFederationPlugin({
            filename: "remoteEntry.js",
            name: "remote",
            exposes: {
                "./NewsList": "./src/NewsList",
            }
          })
    ]
}

remote\src\index.js

import("./bootstrap");

remote\src\bootstrap.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

remote\src\App.js

import React from "react";
import NewsList from './NewsList';
const App = () => (
  <div>
    <h2>本地组件NewsList</h2>
    <NewsList />
  </div>
);

export default App;

remote\src\NewsList.js

import React from "react";
export default ()=>(
    <div>新闻列表</div>
)

----------------------- 分割线 ----------------------- host\webpack.config.js

let path = require("path");
let webpack = require("webpack");
let HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
    mode: "development",
    entry: "./src/index.js",
    output: {
        publicPath: "http://localhost:8000/",
    },
    devServer: {
        port: 8000
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ["@babel/preset-react"]
                    },
                },
                exclude: /node_modules/,
            },
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html'
        }),
        new ModuleFederationPlugin({
            filename: "remoteEntry.js",
            name: "host",
            remotes: {
                remote: "remote@http://localhost:3000/remoteEntry.js"
            }
        })
    ]
}

host\src\index.js

import("./bootstrap");

host\src\bootstrap.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

host\src\App.js

import React from "react";
import Slides from './Slides';
const RemoteNewsList = React.lazy(() => import("remote/NewsList"));

const App = () => (
  <div>
    <h2 >本地组件Slides, 远程组件NewsList</h2>
    <Slides />
    <React.Suspense fallback="Loading NewsList">
      <RemoteNewsList />
    </React.Suspense>
  </div>
);
export default App;

host\src\Slides.js

import React from "react";
export default ()=>(
    <div>轮播图</div>
)

shared 配置公共依赖

remote\webpack.config.js

plugins: [
  new HtmlWebpackPlugin({
      template:'./public/index.html'
  }),
  new ModuleFederationPlugin({
      filename: "remoteEntry.js",
      name: "remote",
      exposes: {
          "./NewsList": "./src/NewsList",
      },
+     shared:{
+       react: { singleton: true },
+       "react-dom": { singleton: true }
+     }
    })
]

host\webpack.config.js

plugins: [
    new HtmlWebpackPlugin({
        template: './public/index.html'
    }),
    new ModuleFederationPlugin({
        filename: "remoteEntry.js",
        name: "host",
        remotes: {
            remote: "remote@http://localhost:3000/remoteEntry.js"
        },
+       shared:{
+         react: { singleton: true },
+         "react-dom": { singleton: true }
+       }
    })
]

双向依赖

Module Federation 的共享可以是双向的

remote\webpack.config.js

plugins: [
  new HtmlWebpackPlugin({
      template:'./public/index.html'
  }),
  new ModuleFederationPlugin({
    filename: "remoteEntry.js",
    name: "remote",
+   remotes: {
+     host: "host@http://localhost:8000/remoteEntry.js"
+   },
    exposes: {
      "./NewsList": "./src/NewsList",
    },
    shared:{
      react: { singleton: true },
      "react-dom": { singleton: true }
    }
  })
]

host\webpack.config.js

plugins: [
  new HtmlWebpackPlugin({
      template: './public/index.html'
  }),
  new ModuleFederationPlugin({
    filename: "remoteEntry.js",
    name: "host",
    remotes: {
        remote: "remote@http://localhost:3000/remoteEntry.js"
    },
+   exposes: {
+     "./Slides": "./src/Slides",
+   },
    shared:{
        react: { singleton: true },
        "react-dom": { singleton: true }
    }
  })
]

remote\src\App.js

import React from "react";
import NewsList from './NewsList';
+const RemoteSlides = React.lazy(() => import("host/Slides"));
const App = () => (
  <div>
+    <h2>本地组件NewsList,远程组件Slides</h2>
    <NewsList />
+    <React.Suspense fallback="Loading Slides">
+      <RemoteSlides />
+    </React.Suspense>
  </div>
);

export default App;

多个remote

all\webpack.config.js

let path = require("path");
let webpack = require("webpack");
let HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
    mode: "development",
    entry: "./src/index.js",
    output: {
        publicPath: "http://localhost:3000/",
    },
    devServer: {
        port: 5000
    },
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ["@babel/preset-react"]
                    },
                },
                exclude: /node_modules/,
            },
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template:'./public/index.html'
        }),
        new ModuleFederationPlugin({
            filename: "remoteEntry.js",
            name: "all",
            remotes: {
                remote: "remote@http://localhost:3000/remoteEntry.js",
                host: "host@http://localhost:8000/remoteEntry.js",
            },
            shared:{
                react: { singleton: true },
                "react-dom": { singleton: true }
              }
          })
    ]
}

remote\src\index.js

import("./bootstrap");

remote\src\bootstrap.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

remote\src\App.js

import React from "react";
const RemoteSlides = React.lazy(() => import("host/Slides"));
const RemoteNewsList = React.lazy(() => import("remote/NewsList"));
const App = () => (
  <div>
    <h2>远程组件Slides,远程组件NewsList</h2>
    <React.Suspense fallback="Loading Slides">
      <RemoteSlides />
    </React.Suspense>
    <React.Suspense fallback="Loading NewsList">
      <RemoteNewsList />
    </React.Suspense>
  </div>
);

export default App;

output 可选的输出 ecma 标准

webpack4 默认只能输出 ES5 代码,webpack5 开始新增一个属性 oupput.ecmaVersion,可以生吃 ES5 和 ES6/ES2015 代码。

module.exports = {
  out: {
    ecmaVersion: 2015
  }
}

[优化] webpack5 设置缓存

打包模块缓存

  • webpack会缓存生成的webpack模块和chunk,来改善构建速度
  • 缓存在webpack5中默认开启,缓存默认是在内存里,但可以对cache进行设置
  • webpack 追踪了每个模块的依赖,并创建了文件系统快照。此快照会与真实文件系统进行比较,当检测到差异时,将触发对应模块的重新构建
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
    mode: 'development',
+   cache: {
+       type: 'filesystem',  //'memory' | 'filesystem'
+       cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
+   },
    devtool: false,
    module:{
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                "@babel/preset-react"
                            ]
                        },

                    }
                ],
                exclude:/node_modules/
            }
        ]
    },
    devServer:{},
    plugins:[
        new HtmlWebpackPlugin({
            template:'./public/index.html'
        })
    ]
}

loader结果 缓存

webpack5 中可以通过 babel-loader 缓存和 cache-loader 缓存结合来实现性能提升。

babel-loader

Babel在转义js文件过程中消耗性能较高,将babel-loader执行的结果缓存起来,当重新打包构建时会尝试读取缓存,从而提高打包构建速度、降低消耗

{
  test: /\.js$/,
  exclude: /node_modules/,
  use: [{
    loader: "babel-loader",
    options: {
      cacheDirectory: true
    }
  }]
}

cache-loader

  • 在一些性能开销较大的 loader 之前添加此 loader, 以将结果缓存到磁盘里
  • 存储和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader
npm i  cache-loader -D
const loaders = ['babel-loader'];
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          'cache-loader',
          ...loaders
        ],
        include: path.resolve('src')
      }
    ]
  }
}

[优化] 设置 oneOf,阻断 loader 全匹配

每个文件对于rules中的所有规则都会遍历一遍,如果使用oneOf就可以解决该问题,只要能匹配一个即可退出。(注意:在oneOf中不能两个配置处理同一种类型文件)

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        //优先执行
        enforce: 'pre',
        loader: 'eslint-loader',
        options: {
          fix: true
        }
      },
      {
        // 以下 loader 只会匹配一个
        oneOf: [
          ...,
          {},
          {}
        ]
      }
    ]
  }
}

更好的监视文件打包机制

webpack4 总是在第一次构建时输出全部文件,但是监视重新构建时会只更新修改的文件。此次更新在第一次构建时会找到输出文件看是否有变化,从而决定要不要输出全部文件。

资源模块

  • 资源模块是一种模块类型,它允许使用资源文件(字体,图标等,而无需配置额外 loader
  • 以前需要 raw-loader 处理 txt 等文件模块 => asset/source 导出资源的源代码
  • 以前需要 file-loader 处理 img 等文件模块 => asset/resource 发送一个单独的文件并导出 URL
  • 以前需要 url-loader 处理图片到 base64 => asset/inline 导出一个资源的 data URI
  • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现,现在只需要设置 dataUrlCondition 即可。
module.exports = {
    module:{
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                "@babel/preset-react"
                            ]
                        },

                    }
                ],
                exclude:/node_modules/
            },
+           {
+               test: /\.png$/,
+               type: 'asset/resource' // 拷贝文件,相当于 file-loader
+           },
+           {
+               test: /\.ico$/,
+               type: 'asset/inline' // 生成一个 base64 字符串,url-loader 文件始终小于阈值的情况
+           },
+           {
+               test: /\.txt$/,
+               type: 'asset/source' // 相当于以前的 raw-loader
+           },
+           {
+               test: /\.jpg$/,
+               type: 'asset', // 这里只设置 asset
+               parser: { // 这里设置阈值
+                   dataUrlCondition: {
+                     maxSize: 4 * 1024 // 4kb
+                   }
+               }
+           }
        ]
    },
+  experiments: { // 启用试验性的支持
+    asset: true
+  },
};

src/index.js

+ import png from './assets/logo.png';
+ import ico from './assets/logo.ico';
+ import jpg from './assets/logo.jpg';
+ import txt from './assets/logo.txt';
+ console.log(png,ico,jpg,txt);

URIs

Webpack 5 支持在请求中处理协议 支持 data 支持 Base64 或原始编码, MimeType 可以在 module. rule 中被映射到加载器和模块类型。

import data from "data:text/javascript,export default 'title'";
console.log(data);

moduleIds & chunkIds的优化

  • module: 每一个文件其实都可以看成一个 module
  • chunk: webpack打包最终生成的代码块,代码块会生成文件,一个文件对应一个chunk
  • 在 webpack5 之前,没有从 entry 打包的 chunk 文件,都会以 1、2、3...的文件命名方式输出,删除某些些文件可能会导致缓存失效(比如 1,2,3,删除 2,3 也会变,会重新打包)
  • webpack5 中在生产模式下,默认启用这些功能 chunkIds: "deterministic", moduleIds: "deterministic",此算法采用确定性的方式将短数字 ID(3 或 4 个字符)短 hash 值分配给 modules 和 chunks,chunkId 设置为 deterministic,则 output 中 chunkFilename 里的 [name] 会被替换成确定性短数字 ID
  • 虽然chunkId不变(不管值是deterministic | natural | named),但更改chunk内容,chunkhash还是会改变的
可选值含义示例
natural按使用顺序的数字ID (webpack5 之前的方式)1
named方便调试的高可读性idsrc_two_js.js
deterministic根据模块名称生成简短的hash值(webpack5 默认设置)915
size根据模块大小生成的数字id0

webpack.config.js

const path = require('path');
module.exports = {
    mode: 'development',
    devtool:false,
+   optimization:{
+       moduleIds:'deterministic', 
+       chunkIds:'deterministic' // 缓存不会因为某文件删除受影响,且混淆了文件名
+   }
}

拓展,optimization 还可配置 usedExports: true,用于打包文件显示哪些模块 unused,便于分析 tree shaking。

更好的算法和默认值来改善长期缓存

contenthash 会以更好的算法来进行计算,从而优化性能。当使用[contenthash]时,Webpack 5 将使用真正的文件内容哈希值。之前它 "只 "使用内部结构的哈希值。当只有注释被修改或变量被重命名时,这对长期缓存会有积极影响。这些变化在压缩后是不可见的。

默认值

  • entry: './src/index.js'
  • output.path: path.resolve(__dirname, 'dist')
  • output.filename: '[name].js'

补充:webpack 工作流

分析 webpack 编译后的结果

debugger.js
const webpack = require('webpack');
const webpackOptions = require('./webpack.config');
// compiler 代表整个编译过程
const compiler = webpack(webpackOptions);
// 调用它的 run 方法可以启动编译
compiler.run((err, stats) => {
  console.log(err);
  let result = stats.toJson({
    file: true,
    assets: true,
    chunk: true,
    module: true,
    entries: true
  });

  console.log(JSON.stringify(result, null, 2));
});


webpack.config.js
const path = require('path');
module.exports = {
  mode: 'development',
  devtool: false,
  context: process.cwd(),
  entry: './src/index.js'
}


执行 debugger.js,简化后的结果如下
{
  "hash": "f85bd1c034f9abe26405", // 本次编译的 hash 值 
  "version": "5.39.1",
  "time": 68, // 花费时间
  "builtAt": 1629782860471, // 构建开始的时间戳
  "publicPath": "auto", // 资源文件的访问路径
  "outputPath": "/Users/ys/study/webpack5-ys/dist", // 输出的目录
  "assetsByChunkName": {  // 哪个代码块产出了哪个文件
    "main": [
      "main.js"
    ]
  },
  "assets": [ // 产出的资源
    {
      "type": "asset",
      "name": "main.js"
    }
  ],
  "chunks": [ // 编译出的代码块
    {
      "names": [
        "main"
      ],
      "files": [
        "main.js"
      ],
      "hash": "af45743264574de85003", // chunk hash
      "id": "main"
    }
  ],
  "modules": [],
  "entrypoints": { // 入口
    "main": {
      "name": "main",
      "chunks": [
        "main"
      ],
      "assets": [
        {
          "name": "main.js",
          "size": 99
        }
      ]
    }
  },
  "namedChunkGroups": {  // splitChunks 用的,我们在这里没有用到
    "main": {
      "name": "main",
      "chunks": [
        "main"
      ],
      "assets": [
        {
          "name": "main.js",
          "size": 99
        }
      ]
    }
  }
}


手写简版 webpack5

webpack5 工作流程

webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的配置对象;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,提供 run 方法,加载所有配置的插件,执行插件的 apply 方法收集插件函数;
  3. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理,在经过 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  4. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  5. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

@1 初始化参数

修改 webpack.config.js,改为多入口方便演示
const path = require('path');
module.exports = {
  mode: 'development',
  devtool: false,
  context: process.cwd(),
- entry: './src/index.js'
+ entry: {
+   entry1: './src/entry1.js',
+   entry2: './src/entry2.js'
+ }
}


修改 debugger.js
- const webpack = require('webpack');
+ const webpack = require('./webpack');
// ...


新增 webpack.js,定义 webpack 函数,进行参数收集。
function webpack(options) {
  // 1. 初始化参数:从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
  let shellConfig = process.argv.slice(2).reduce((shellConfig, item) => {
    // item 就是 --mode=development
    let [key, value] = item.split('=');
    shellConfig[key.slice(2)] = value;
    return shellConfig;
  }, {});

  let finalConfig = { ...options, ...shellConfig }; // 最终结果
  console.log(finalConfig);
}
module.exports = webpack


执行 debugger.js

node debugger.js --mode=development

打印结果~

{
  mode: 'development', // 来自 shell 的参数
  devtool: false,
  context: '/Users/ys/study/webpack5-ys',
  entry: './src/index.js'
}

@2 创建 compiler 类,提供编译函数 run,收集插件回调

修改 webpack.config.js,配置插件
const path = require('path');
+ const RunPlugin = require('./plugins/run-plugin');
+ const DonePlugin = require('./plugins/done-plugin');

module.exports = {
  mode: 'development',
  devtool: false,
  context: process.cwd(),
  entry:{
      entry1:'./src/entry1.js',
      entry2:'./src/entry2.js'
  },
+ plugins: [
+   new RunPlugin(),
+   new DonePlugin()
+ ]
}


修改 debugger.js
const Compiler = require('./Compiler');

function webpack(options) {
  // 1. 初始化参数:从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
  let shellConfig = process.argv.slice(2).reduce((shellConfig, item) => {
    // item 就是 --mode=development
    let [key, value] = item.split('=');
    shellConfig[key.slice(2)] = value;
    return shellConfig;
  }, {});

  let finalConfig = { ...options, ...shellConfig }; // 最终结果

+ // 2. 用上一步得到的参数初始化Compiler对象
+  let compiler = new Compiler(finalConfig);

+ // 加载所有配置的插件,调用插件 apply 方法传入 compiler,进行插件注册
+ let { plugins } = finalConfig;

+ for (let plugin of plugins){
+   plugin.apply(compiler);
+ }

+  return compiler;
}
module.exports = webpack


新增 Compiler.js
let { SyncHook } = require('tapable');

class Compiler {
  constructor(options) {
    this.options = options;
    this.hooks = {
      run: new SyncHook(), // 开始启动编译 刚刚开始
      emit: new SyncHook(['assets']), // 会在将要写入文件的时候触发
      done: new SyncHook() // 将会在完成编译的时候触发 全部完成
    }
  }

  run(callback) {
    console.log('开始编译啦');
    this.hooks.run.call(); // 触发 run 钩子
    callback(null, {
      toJson() {
        return {
          files: [],
          assets: [],
          chunk: [],
          module: [],
          entries: []
        }
      }
    });
  }
}
module.exports = Compiler;


新增 plugins/run-plugin.js
class RunPlugin {
  apply(compiler) {
    // 给 run 钩子收集一个函数,注意 tap 注册的名字 RunPlugin 没有实际作用
    // 只是为了方便阅读
    compiler.hooks.run.tap('RunPlugin', () => {
      console.log('RunPlugin 插件', '开始编译啦');
    });
  }
}

module.exports = RunPlugin;


新增 plugins/done-plugin.js
class DonePlugin {
  apply(compiler) {
    compiler.hooks.done.tap('DonePlugin', () => {
      console.log('DonePlugin 插件', '编译结束了');
    });
  }
}

module.exports = DonePlugin;


执行 debugger.js

 node debugger.js --mode=development

输出结果~

RunPlugin 插件 开始编译啦
DonePlugin 插件 编译结束了
null
{
  "files": [],
  "assets": [],
  "chunk": [],
  "module": [],
  "entries": []
}

@3 开始编译,依次使用 loader 处理入口和依赖文件(ast)

核心流程,比较长,做好心理准备!

新建 loader/logger1-loader.js
function loader(source){
  console.log('logger1-loader.js');
  return source + '//1';
}
module.exports = loader;


新建 loader/logger2-loader.js
function loader(source){
  console.log('logger1-loader.js');
  return source + '//1';
}
module.exports = loader;


修改 webpack.config.js,引入 loader,添加后缀名
const path = require('path');
+ const RunPlugin = require('./plugins/run-plugin');
+ const DonePlugin = require('./plugins/done-plugin');

module.exports = {
  mode: 'development',
  devtool: false,
  context: process.cwd(),
  entry: {
    entry1: './src/entry1.js',
    entry2: './src/entry2.js'
  },
+ resolve: {
+  extensions: ['.js', '.jsx', '.json']
+ },
+ module: {
+   rules: [
+    {
+      test: /\.js$/,
+      use: [
+         path.resolve(__dirname, 'loaders', 'logger1-loader.js'),
+        path.resolve(__dirname, 'loaders', 'logger2-loader.js')
+      ]
+    }
+  ]
+ },
  plugins: [
    new RunPlugin(),
    new DonePlugin()
  ]
}


修改 compiler,run 方法调用创建 Complication 实例,执行 complication.build 开始执行编译
let { SyncHook } = require('tapable');
+ let Complication = require('./Complication');
+ let fs = require('fs');

class Compiler {
  constructor(options) {
    this.options = options;
    this.hooks = {
      run: new SyncHook(), // 开始启动编译 刚刚开始
      emit: new SyncHook(['assets']), // 会在将要写入文件的时候触发
      done: new SyncHook() // 将会在完成编译的时候触发 全部完成
    }
  }
  // @3 执行 Compiler 的 run 方法开始执行编译
  run(callback) {
+    this.hooks.run.call(); // 触发 run 钩子
+    this.compile(callback); // 先编译一次
+    Object.values(this.options.entry).forEach(entry => {
+      // 监听所有入口文件,文件改变重新编译,调试的时候记得先关闭
+      fs.watchFile(entry, () => this,compile(callback));
+    });
+    this.hooks.done.call(); // 触发 done 钩子

    // 模拟编译完执行回调。
    callback(null, {
      toJson() {
        return {
          files: [],
          assets: [],
          chunk: [],
          module: [],
          entries: []
        }
      }
    });
  }
+  // 每次编译都会产生一个新的 Complication 实例
+  // 多个入口一次编译也只产生一个哦~ 
+  compile(callback) {
+    let complication = new Complication(this.options);
+    complication.build(callback);
+  }
}
module.exports = Compiler;


新建核心文件 Complication.js,它提供 build 方法(源码里是 make) 供 compiler.run 调用,里面就是我们的依次使用 loader 处理源码和入口依赖文件的实现
const path = require('path');
const fs = require('fs');
const types = require('babel-types');
const parser = require('@babel/parser'); // js 转 ast
const traverse = require('@babel/traverse').default; // 遍历 ast
const generator = require('@babel/generator').default; // ast 转 js

const baseDir = toUnitPath(process.cwd());
function toUnitPath(filePath) {
  return filePath.replace(/\\/g, '/');
}

function tryExtensions(modulePath, extensions) {
  extensions.unshift('');
  for (let i = 0; i < extensions.length; i++) {
    let filePath = modulePath + extensions[i]; // "./title.js"

    if (fs.existsSync(filePath)) { // 文件存在
      return filePath;
    }
  }
  throw new Error(`Module not found`);
}

class Complication {
  constructor(options) {
    this.options = options;
    // webpack4 数组  webpack5 set,自带去重(比如两个入口引用了同一个模块)
    this.entries = []; // 存放所有的入口
    this.modules = []; // 存放所有的模块
    this.chunks = []; // 存放所的代码块
    this.assets = {}; // 所有产出的资源
    this.files = []; // 所有产出的文件
  }

  build(callback) {
    // 根据配置中的 entry 找出入口文件
    let entry = {};
    if (typeof this.options.entry === 'string') {
      entry.main = this.options.entry;
    } else {
      entry = this.options.entry;
    }

    // 此时 entry={ entry1:'./src/entry1.js', entry2:'./src/entry2.js' }
    for (let entryName in entry) {
      // 获取 entry1 的绝对路径
      let entryFilePath = toUnitPath(path.join(this.options.context, entry[entryName]));
      // 从入口文件出发,调用所有配置的 Loader 对模块进行编译
      let entryModule = this.buildModule(entryName, entryFilePath);
      
      this.modules.push(entryModule);
    }

    console.log(this.modules);
  }
  /**
   * 
   * @param {*} name 当前处理模块的名称(entry1 | entry2 或者其他模块)
   * @param {*} modulePath 该模块的绝对路径
   * @returns 
   */
  buildModule(name, modulePath) {
    // 读取模块文件的内容 
    let sourceCode = fs.readFileSync(modulePath, 'utf8'); // console.log('entry1');
    let rules = this.options.module.rules;
    let loaders = []; // 寻找匹配的 loader
    for (let i = 0; i < rules.length; i++) {
      let { test } = rules[i];
      // 如果此 loader 的正则和模块的路径匹配的话
      if (modulePath.match(test)) {
        loaders = [...loaders, ...rules[i].use];
      }
    }

    for (let i = loaders.length - 1; i >= 0; i--) { // 倒序执行 loader,并把结果传递
      let loader = loaders[i];
      sourceCode = require(loader)(sourceCode);
    }

    // console.log(sourceCode); // console.log('entry1');//2//1 

    // 获得当前模块模块ID ./src/index.js
    let moduleId = './' + path.posix.relative(baseDir, modulePath);
    // 创建当前的模块对象
    let module = { id: moduleId, dependencies: [], name, extraNames: [] };

    // 开始把 loader 处理过的源代码转 ast
    let ast = parser.parse(sourceCode, { sourceType: 'module' });

    // 遍历当前模块的 ast,收集当前模块依赖的模块放到模块对象的依赖数组里
    traverse(ast, {
      CallExpression: ({ node }) => {
        if (node.callee.name === 'require') {
          // 依赖的模块的相对路径
          let moduleName = node.arguments[0].value; //  "./title1"
          // 获取当前模块的所在的目录 path.posix 代表统一 mac 和 window 路径分隔符为 /
          let dirname = path.posix.dirname(modulePath);
          // 拼接绝对路径:/Users/ys/study/webpack5-ys/src/title1,注意没有扩展名
          let depModulePath = path.posix.join(dirname, moduleName);
          let extensions = this.options.resolve.extensions; // 扩展名数组
          // 拿到有效的文件路径(包含扩展名)
          depModulePath = tryExtensions(depModulePath, extensions);
          // depModuleId 就是相对于项目根目录的相对路径 ./src/title1.js
          let depModuleId = './' + path.posix.relative(baseDir, depModulePath);
          // types.stringLiteral 可以创建一个 ast 中字符串的节点
          // 这一步是为了把 require('./title1'); => require('./src/title1.js');
          node.arguments = [types.stringLiteral(depModuleId)];
          // 依赖的模块的绝对路径放到当前的模块的依赖数组里,收集当前模块的依赖模块~
          module.dependencies.push({ depModuleId, depModulePath });
        }
      }
    });

    // AST -> JS
    let { code } = generator(ast);
    module._source = code; // 模块源代码指向语法树转换后的新生成的源代码

    // 再找出该模块依赖的模块,递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
    module.dependencies.forEach(({ depModuleId, depModulePath }) => {
      let depModule = this.modules.find(item => item.id === depModuleId);
      if (depModule) { // 去重,比如两个入口文件引了同一个模块
        depModule.extraNames.push(name);
      } else {
        // 递归,处理依赖的模块,传入模块的 name(比如 "./src/title1.js") 和绝对路径
        let dependencyModule = this.buildModule(name, depModulePath);
        this.modules.push(dependencyModule);
      }
    });

    return module;
  }
}

module.exports = Complication;


src 目录下,新建 entry1.js,entry2.js,title1.js,title2.js
// 入口文件 entry1.js
let title1 = require('./title1');

console.log(title1);

// 入口文件 entry2.js
let title2 = require('./title2');

console.log(title2);

// title1.js
module.exports = 'title1'

// title2.js
module.exports = 'title2'


涉及到的包请自行安装~,执行 debugger.js

 node debugger.js --mode=development

输出结果~

RunPlugin 插件 开始编译啦
// 可以看到 loader2 先执行,从下往上执行~ 而且4个文件执行了 8 次 loader,符合预期
logger2-loader.js 
logger1-loader.js
logger2-loader.js
logger1-loader.js
logger2-loader.js
logger1-loader.js
logger2-loader.js
logger1-loader.js
[
  { 
    id: './src/title1.js', // 依赖的包也递归被 loader 处理了哦
    dependencies: [],
    name: 'entry1',
    extraNames: [],
    _source: "module.exports = 'title1'; //2//1" 
  },
  {
    id: './src/entry1.js',
    dependencies: [ [Object] ],
    name: 'entry1',
    extraNames: [],
    // loader 起作用了,// 2 和 // 1 分别拼接到源码
    _source: 'let title1 = require("./src/title1.js");\n\nconsole.log(title1); //2//1'
  },
  {
    id: './src/title2.js',
    dependencies: [],
    name: 'entry2',
    extraNames: [],
    _source: "module.exports = 'title2'; //2//1"
  },
  {
    id: './src/entry2.js',
    dependencies: [ [Object] ],
    name: 'entry2',
    extraNames: [],
    _source: 'let title2 = require("./src/title2.js");\n\nconsole.log(title2); //2//1'
  }
]
DonePlugin 插件 编译结束了
null
{
  "files": [],
  "assets": [],
  "chunk": [],
  "module": [],
  "entries": []
}

@4 根据依赖关系组装 chunk

修改 Complication.js,根据依赖关系去组装 chunk(通常一个入口文件一个 chunk),并根据组装的 chunk 拼接文件,最后输出到 dist 目录。
const path = require('path');
const fs = require('fs');
const types = require('babel-types');
const parser = require('@babel/parser'); // js 转 ast
const traverse = require('@babel/traverse').default; // 遍历 ast
const generator = require('@babel/generator').default; // ast 转 js

const baseDir = toUnitPath(process.cwd());
function toUnitPath(filePath) {
  return filePath.replace(/\\/g, '/');
}

function tryExtensions(modulePath, extensions) {
  extensions.unshift('');
  for (let i = 0; i < extensions.length; i++) {
    let filePath = modulePath + extensions[i]; // "./title.js"

    if (fs.existsSync(filePath)) { // 文件存在
      return filePath;
    }
  }
  throw new Error(`Module not found`);
}

+ // 拷自 webpack5 输出内容,更改当前入口文件源码和当前入口依赖的 modules 源码
+ function getSource(chunk) {
+   return `
+   (() => {
+       var modules = ({
+           ${chunk.modules.map(module => `
+                   "${module.id}":(module,exports,require)=>{
+                       ${module._source}
+                   }
+               `).join(',')
+     }
+       });
+       var cache = {};
+       function require(moduleId) {
+        var cachedModule = cache[moduleId];
+         if (cachedModule !== undefined) {
+           return cachedModule.exports;
+         }
+         var module = cache[moduleId] = {
+           exports: {}
+         };
+         modules[moduleId](module, module.exports, require);
+         return module.exports;
+       }
+       var exports = {};
+       (() => {
+        ${chunk.entryModule._source}
+       })();
+     })()
+       ;
+   `
+ }

class Complication {
  constructor(options) {
    this.options = options;
    // webpack4 数组  webpack5 set,自带去重(比如两个入口引用了同一个模块)
    this.entries = []; // 存放所有的入口
    this.modules = []; // 存放所有的模块
    this.chunks = []; // 存放所的代码块
    this.assets = {}; // 所有产出的资源
    this.files = []; // 所有产出的文件
  }

  build(callback) {
    // 根据配置中的 entry 找出入口文件
    let entry = {};
    if (typeof this.options.entry === 'string') {
      entry.main = this.options.entry;
    } else {
      entry = this.options.entry;
    }

    // 此时 entry={ entry1:'./src/entry1.js', entry2:'./src/entry2.js' }
    for (let entryName in entry) {
      // 获取 entry1 的绝对路径
      let entryFilePath = toUnitPath(path.join(this.options.context, entry[entryName]));
      // 从入口文件出发,调用所有配置的 Loader 对模块进行编译
      let entryModule = this.buildModule(entryName, entryFilePath);

-      this.modules.push(entryModule);
+      // @4 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk
+      let chunk = {
+        name: entryName,
+        entryModule,
+        modules: this.modules.filter(item => {
+          return item.name === entryName || item.extraNames.includes(entryName);
+        })
+      };
+      this.entries.push(chunk);
+      this.chunks.push(chunk);
+    }

+   // 再把每个 chunk 转换成一个单独的文件加入到输出列表
+    this.chunks.forEach(chunk => {
+      let filename = this.options.output.filename.replace('[name]', chunk.name);
+      // this.assets 就是输出列表,key 输出的文件名 值就是输出的内容
+      this.assets[filename] = getSource(chunk);
+    });

+    // 完事儿,调用 cb,cb 内部会进行文件写入
+    callback(null, {
+      entries: this.entries,
+      chunks: this.chunks,
+      modules: this.modules,
+      files: this.files,
+      assets: this.assets
+    });
  }

  /**
   * 
   * @param {*} name 当前处理模块的名称(entry1 | entry2 或者其他模块)
   * @param {*} modulePath 该模块的绝对路径
   * @returns 
   */
  buildModule(name, modulePath) {
    // 读取模块文件的内容 
    let sourceCode = fs.readFileSync(modulePath, 'utf8'); // console.log('entry1');
    let rules = this.options.module.rules;
    let loaders = []; // 寻找匹配的 loader
    for (let i = 0; i < rules.length; i++) {
      let { test } = rules[i];
      // 如果此 loader 的正则和模块的路径匹配的话
      if (modulePath.match(test)) {
        loaders = [...loaders, ...rules[i].use];
      }
    }

    for (let i = loaders.length - 1; i >= 0; i--) { // 倒序执行 loader,并把结果传递
      let loader = loaders[i];
      sourceCode = require(loader)(sourceCode);
    }
    // console.log(sourceCode); // console.log('entry1');//2//1 

    // 获得当前模块模块ID ./src/index.js
    let moduleId = './' + path.posix.relative(baseDir, modulePath);
    // 创建当前的模块对象
    let module = { id: moduleId, dependencies: [], name, extraNames: [] };

    // 开始把 loader 处理过的源代码转 ast
    let ast = parser.parse(sourceCode, { sourceType: 'module' });

    // 遍历当前模块的 ast,收集当前模块依赖的模块放到模块对象的依赖数组里
    traverse(ast, {
      CallExpression: ({ node }) => {
        if (node.callee.name === 'require') {
          // 依赖的模块的相对路径
          let moduleName = node.arguments[0].value; //  "./title1"
          // 获取当前模块的所在的目录 path.posix 代表统一 mac 和 window 路径分隔符为 /
          let dirname = path.posix.dirname(modulePath);
          // 拼接绝对路径:/Users/ys/study/webpack5-ys/src/title1,注意没有扩展名
          let depModulePath = path.posix.join(dirname, moduleName);
          let extensions = this.options.resolve.extensions; // 扩展名数组
          // 拿到有效的文件路径(包含扩展名)
          depModulePath = tryExtensions(depModulePath, extensions);
          // depModuleId 就是相对于项目根目录的相对路径 ./src/title1.js
          let depModuleId = './' + path.posix.relative(baseDir, depModulePath);
          // types.stringLiteral 可以创建一个 ast 中字符串的节点
          // 这一步是为了把 require('./title1'); => require('./src/title1.js');
          node.arguments = [types.stringLiteral(depModuleId)];
          // 依赖的模块的绝对路径放到当前的模块的依赖数组里,收集当前模块的依赖模块~
          module.dependencies.push({ depModuleId, depModulePath });
        }
      }
    });

    // AST -> JS
    let { code } = generator(ast);
    module._source = code; // 模块源代码指向语法树转换后的新生成的源代码

    // 再找出该模块依赖的模块,递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
    module.dependencies.forEach(({ depModuleId, depModulePath }) => {
      let depModule = this.modules.find(item => item.id === depModuleId);
      if (depModule) { // 去重,比如两个入口文件引了同一个模块
        depModule.extraNames.push(name);
      } else {
        // 递归,处理依赖的模块,传入模块的 name(比如 "./src/title1.js") 和绝对路径
        let dependencyModule = this.buildModule(name, depModulePath);
        this.modules.push(dependencyModule);
      }
    });

    return module;
  }
}

module.exports = Complication;


@5 文件内容写入到文件系统

修改 Compiler.js,重写回调方法,在其内部触发 emit 钩子(传入即将写入的资源列表),然后输出文件到 dist 目录
let { SyncHook } = require('tapable');
let Complication = require('./Complication');
let fs = require('fs');
+ let path = require('path');

class Compiler {
  constructor(options) {
    this.options = options;
    this.hooks = {
      run: new SyncHook(), // 开始启动编译 刚刚开始
      emit: new SyncHook(['assets']), // 会在将要写入文件的时候触发
      done: new SyncHook() // 将会在完成编译的时候触发 全部完成
    }
  }
  // @3 执行 Compiler 的 run 方法开始执行编译
  run(callback) {
    this.hooks.run.call(); // 触发 run 钩子
+   this.compile((err, stats) => {
+     this.hooks.emit.call(stats.assets);
+     // @5 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
+     for (let filename in stats.assets) {
+       let filePath = path.join(this.options.output.path, filename);
+       fs.writeFileSync(filePath, stats.assets[filename], 'utf8');
+     }
+     callback(null, {
+       toJson: () => stats
+     });
    }); // 先编译一次

    Object.values(this.options.entry).forEach(entry => {
      // 监听所有入口文件,文件改变重新编译
      // fs.watchFile(entry, () => this,compile(callback));
    });

    this.hooks.done.call(); // 触发 done 钩子
  }
  // 每次编译都会产生一个新的 Complication 实例
  // 多个入口一次编译也只产生一个哦~ 
  compile(callback) {
    let complication = new Complication(this.options);
    complication.build(callback);
  }
}
module.exports = Compiler;


执行 debugger.js

node debugger.js --mode=development

输出文件如下

# dist 目录新增如下文件
- dist
  - entry1.js
  - entry2.js

这里示例 entry1.js


  (() => {
      var modules = ({
          
                  "./src/title1.js":(module,exports,require)=>{
                      module.exports = 'title1'; //2//1
                  }
              
      });
      var cache = {};
      function require(moduleId) {
        var cachedModule = cache[moduleId];
        if (cachedModule !== undefined) {
          return cachedModule.exports;
        }
        var module = cache[moduleId] = {
          exports: {}
        };
        modules[moduleId](module, module.exports, require);
        return module.exports;
      }
      var exports = {};
      (() => {
       let title1 = require("./src/title1.js");

console.log(title1); //2//1
      })();
    })()
      ;

新建文件 dist/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="./entry1.js"></script>
  <script src="./entry2.js"></script>
</body>
</html>

控制台输出 title1 title2,至此简版 webpack 完结。

拓展:编写修改输出文件的插件

新建 plugins/assets-plugin.js

class AssetPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('AssetPlugin', assets => {
      // emit 产生文件前,修改即将要输出的文件列表,增加 assets.md 文件
      assets['assets.md'] = Object.keys(assets).join('\n');
    });
  }
}

module.exports = AssetPlugin;

修改 webpack.config.js

const path = require('path');
const RunPlugin = require('./plugins/run-plugin');
const DonePlugin = require('./plugins/done-plugin');
+ const AssetPlugin = require('./plugins/assets-plugin');

module.exports = {
  mode: 'development',
  devtool: false,
  context: process.cwd(),
  entry: {
    entry1: './src/entry1.js',
    entry2: './src/entry2.js'
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  resolve: {
    extensions: ['.js', '.jsx', '.json']
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          path.resolve(__dirname, 'loaders', 'logger1-loader.js'),
          path.resolve(__dirname, 'loaders', 'logger2-loader.js')
        ]
      }
    ]
  },
  plugins: [
    new RunPlugin(),
    new DonePlugin(),
+    new AssetPlugin()
  ]
}

执行 debugger.js

node debugger.js --mode=development

输出文件如下

- dist
  - entry1.js
  - entry2.js
  - assets.md

打开 assets.md

entry1.js
entry2.js