创建项目
- 新建项目 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模块
实战
| 字段 | 类型 | 含义 |
|---|---|---|
| name | string | 必传值,即输出的模块名,被远程引用时路径为{expose} |
| library | object | 声明全局变量的方式,name为umd的name |
| remotes | object | 远程引用的应用名及其别名的映射,使用时以key值作为name |
| exposes | object | 被远程引用时可暴露的资源路径及其别名 |
| shared | object | 与其他应用之间可以共享的第三方依赖,使你的代码中不用重复加载同一份依赖 |
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 | 方便调试的高可读性id | src_two_js.js |
| deterministic | 根据模块名称生成简短的hash值(webpack5 默认设置) | 915 |
| size | 根据模块大小生成的数字id | 0 |
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 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的配置对象;
- 开始编译:用上一步得到的参数初始化 Compiler 对象,提供 run 方法,加载所有配置的插件,执行插件的 apply 方法收集插件函数;
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理,在经过 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
在以上过程中,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