Skip to content

coding-ice/webpack-advanced

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

82 Commits
 
 
 
 
 
 

Repository files navigation

背景

webpack静态模块化打包工具。无论是前端工程化,还是高级前端岗位都是绕不开的话题之一,面试经常问到构建优化的的方案,自定义loader,自定义plugin等。无论是进阶,还是面试高级岗位,在这里你都可以找到答案。webpack简单来说就是各种令人眼花缭乱的配置,笔者也是感同身受,学了很快就会忘记,所以才有了这篇文章。采用费曼学习法,用简洁的思想和简单的语言向大家表达出来,帮助大家也是帮助自己,本文的学习资料大部分来自于官方文档。本文将持续迭代,收藏===学会~

安装

pnpm init
# 安装依赖
pnpm i webpack webpack-cli -D

结构划分

代码仓库:https://github.com/GetWebHB/webpack-advanced

  • 每一个小的章节,都会存放在对应的文件夹中(例:0.start),文件夹会存放对应的配置文件,源代码产物。打包命令如下所示,npm run build
// src/0.start/package.json
"scripts": {
  "build": "webpack",
},

scripts脚本,本质上就只在.bin文件夹下寻找 webpack, 即npm run build等同于npx webpack,默认会去存在package.json(即根目录查找配置文件webpack.config.js) ,它的filename可以更改,--config filename即可。只要修改了配置文件,就需要重启服务 目录结构

├── README.MD
├── package-lock.json
├── package.json
└── src
    ├── 0.start
    │   ├── build
    │   │   └── bundle.js
    │   ├── package.json
    │   ├── pnpm-lock.yaml
    │   ├── src
    │   │   └── main.js
    │   └── webpack.config.js

src下是每个小节的代码,cd当前的小节,执行pnpm install / npm run build即可

起手式

本节代码见:0.start

// main.js
function sayHi() {
  console.log('hi ice 24')
}
sayHi()
export { sayHi }

// webpack.config.js
const { resolve } = require('path')

module.exports = {
  entry: './src/main.js',
  output: {
    path: resolve(__dirname, 'build'),
    filename: 'bundle.js',
  },
}
  • 简单走读一下,cjs的方式导出了一个对象
  1. entry代表入口,默认情况下相对路径为package.json存在的目录
  2. output代表出口,即产物打包后的位置,同样是绝对路径,打包到build的文件夹下,文件名为bundle.js
  3. npm run build, 即打包产物,就会发现该目录下出现了产物

mode&devtool

本节代码见:1.mode_devtool
上一小节中,我们学习到了entry,output这两项配置,这一章节中,我们学习modedevtool

mode

概述:告诉 webpack 使用相应模式的内置优化
当我们执行npm run build的时候,webpack会有这一串警告,说我们没有设置mode image.png 那什么是模式(即mode),从提示看说我们可以设置为developmentorproduction
传送门https://webpack.js.org/configuration/mode/

image.png mode 也是最重要的优化,webpack都会帮我们做好。接下来我们来一一了解对应模式展示的不同行为

  • mode = 'none' | 'development' | 'production'(default)
// main.js
const mes = 'hi ice 24'
function sayHi() {
  console.log(mes)
}
sayHi()

export { sayHi }

none

产物分析

image.png

  • 我们大致扫一眼即可

development

我们平常使用的cli, vue/cli(维护阶段),create-react-app等,反正所有底层使用webpack的,npm run serve / npm run start这种在本地开启服务的,采用的策略都是使用development,主打的就是一个快,不需要通过一些plugin,例如terser(后面会讲)丑化压缩代码
产物分析 image.png 从注释中我们可以得知,它使用eval函数可以在浏览器的开发工具中创建一个单独的源文件,在或者说devtool:false的时候就会移除(source map),那么创建一个单独的文件可以干嘛呢?可以映射到代码报错的位置,这也是devtool配置项的作用,我们后面会详细探讨。

production

image.png 就是那么朴实无华,甚至连函数都帮你执行了,直接打印出结果

devtool

概述:是否生成,控制如何生成 source map(源码映射),不同的值会明显影响到构建(build)和重新构建(rebuild)的速度。
默认值

image.png 这幅图比较重要,介绍了该配置项的性能怎样,是否使用于production中,以及构建的速度如何

  • mode模式为development中,我们看见了eval函数,可以映射出代码的错误位置信息,即配置为mode: "eval",现在让我们来深入探讨一下devtool

就拿mode:prod来说,我们发现代码是已经被丑化过,编译后的产物,如果在测试阶段,代码发生了错误压根不知道代码出错在哪里,那我们如何debug呢?这正是source map的作用,编译后的产物 ->(映射)源代码的位置

false

// main.js
const mes = 'hi ice 24'
function sayHi() {
  console.log(mes)
}
console.log(age) // age is not defined
sayHi()

export { sayHi }

// bundle.js
;(() => {
  'use strict'
  console.log(age), console.log('hi ice 24')
})()

跑到浏览器上(测试阶段),我们可以看到错误信息,但是却看不到代码详细出错在第几行,这在一个庞大的项目中,是非常致命的(即devtool: false),不开启source map

image.png

image.png

eval

使用eval函数可以在浏览器的开发工具中创建一个单独的source map

source-map

当我们配置改为它,我们先看下产物

image.png 会多出来一个.map 的文件,即源码映射文件,最后一行代表着引用哪个.map 文件,接下来我们在到浏览器下看下行为。

image.png 竟然神奇的映射出来了源代码的位置(第几行,甚至第几个字符),非常的神奇是吧

image.png
接下来,让我们继续深入探究,简单看下map文件

image.png

  • version:3,从之前 1,2 的版本构建出来的 map 文件有点大,随着不断的迭代构建出来的.map文件也越来小
  • file:映射的源文件(转换后的源文件)
  • mappings:记录位置信息的字符串(VLQ 编码)
  • sources:源的路径
  • sourcesContent:源代码的内容
  • names:转换前的所有变量名和属性名
  • sourceRoot:映射目录的位置,为根目录 image.png

最佳实践

  • prod:none(默认) | false
  • test:source-map
  • development: source-map

babel

本节代码见:2.babel
babel 你可能不太了解(因为cli全部帮我们做好了,配置presets),但是它现在是前端工程化必不缺少的一部分,它的本质就是一个 编译器,把 A 源代码转换为 B 源代码。更通俗的说:把ES6+的代码转换为ES5的代码,可以适配版本更低的浏览器

graph LR
ES6+-- babel --->ES5
Loading

image.png 过程大致分为 3 个阶段

  1. 解析阶段
    • 词法分析,语法分析,生成 ast 树(抽象语法树)
  2. 转换阶段
    • 转换为新的 ast 树
  3. 生成阶段

基础使用

基础使用,我们不再讲解,直接看官网即可,还是比较简单的,它可以在终端直接使用,因为提供了对应的CLI工具

webpack 中使用

// main.js ES6代码
let mes = 'hi ice'
console.log(mes.toUpperCase())
console.log(mes.slice(0, 2))

const double = [1, 2, 3].map((num) => num * 2)
console.log(double) // [2,4,6]

安装开发时依赖,插件作用分别是

  1. 转换箭头函数
  2. 转换块级作用域
  3. 以及 babel-loader
  4. @babel/core不需要安装,因为loader中存在关联会被下载下来
pnpm i @babel/plugin-transform-arrow-functions @babel/plugin-transform-block-scoping babel-loader -D

走读一下配置,modulerules,匹配js文件,采用loader进行转换, options里可以写babel的配置,也可以单独抽成一个独立的文件(babel.config.js

// webpack.config.js
const { resolve } = require('path')

module.exports = {
  mode: 'production',
  entry: './src/main.js',
  output: {
    path: resolve(__dirname, 'build'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: {
          // ...一些配置
        },
      },
    ],
  },
}

使用plugins进行源代码代码转换

// babel.config.js
module.exports = {
  plugins: ['@babel/plugin-transform-block-scoping', '@babel/plugin-transform-arrow-functions'],
}

执行npm run build查看打包的产物就会发现已经转换好了
那么当我们高级语法有很多很多,比如promise async await等,我们难道一个一个去安装插件吗,这样显得太繁琐了,所以这个时候preset(预设)就登场了,提前把那些高级特性全部帮我们设置好

pnpm i @babel/preset-env -D
// babel.config.js
module.exports = {
  presets: ['@babel/preset-env'],
}

接下来,在执行npm run build,效果一样,但是已经帮我们设置好了预设,高级语法都会被打包成低版本的代码,那提前帮我们安装了哪些插件呢? 详见:https://babel.docschina.org/docs/babel-preset-env
@babel/preset-env 其中的env即是根据环境查询兼容性(browserslist)后面我们会讲解到

polyfill

polyfill(补丁),前面我们说到preset-env,可以把我们使用的高级语法,打包成更多浏览器适配的语法,但是对于某种API,不存在的情况它是无能为力的。比如replaceAll ES2021提出的,而polyfill就会帮我注入对应的API

未使用 polyfill

image.png 我们能非常直观的观察到,该replaceAll,直接被构建出来了,但是在低版本的浏览器上肯定是没有这个API的,就会出现类似的错误Uncaught TypeError: mes.replaceAll is not a function,笔者在实际生产中(微信浏览器上),遇到了类似的bug,我们应该如何解决呢?
前置知识

pnpm i core-js regenerator-runtime

注意不是开发时依赖,因为它实际要被我们注入到代码当中,其中这两个包代表用于模拟完整的 ES2015+ 环境

1. 直接引入 API

我们可以根据实际需要直接引入对应的API即可,好比上方的replaceAll,我们知道它是较新的语法,直接从core-js引入即可

// main.js
import 'core-js/es/string/replace-all'

进行打包,然后我们再次在分析下产物,对应的API就被注入到我们的产物当中,就实现了polyfill

image.png

2.useBuiltIns

// babel.config.js
const path = require('path')
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        corejs: 3,
        useBuiltIns: 'usage',
        modules: 'cjs',
      },
    ],
  ],
}

useBuiltIns: usage | entry | false(default)
usage就是你有使用哪些,entry包含node_modules的第三方库使用的,根据实际情况选择即可,但是一般使用usage,因为这些polyfill都会被实际注入到代码中会影响构建产物的大小
corejs:3 代表第三个版本
modules默认是auto,但实际在我的Mac上,会报错不支持cjs的问题,所以我改了下,有可能在windows不需要配置,这点还没测试过。如果你使用了高级特性,但是代码没有polyfill,请看下个章节browserslist,在那里你会找到答案(把市场占有率调低一些比如 > 0.1%)

browserslist

本节代码见:2.babel
在谈起浏览器的兼容性,browserslist一定是前端必不可少的工具,早期无论是处理css(添加浏览器前缀),还是ES6+ -> ES5。好比,我们针对的用户都是一些大学生,普遍这些用户电脑上的浏览器都是较新的,那这些浏览器本身就支持ES6+的语法,我们就没有必要去转换为ES5的代码,我们来简单介绍一下它

pnpm i browserslist -D

我们在根目录下新建文件.browserslistrc

// .browserslistrc
> 1% //市场占有率 > 1%
last 2 versions // 最后两个版本
not dead // 还在维护的

此时,我们执行npx run browserslist image.png 在控制台打印出来了,适配的浏览器版本。这里的版本代表的是区间chrome109 - chrome 120,那么疑问就来了,它是怎么知道要适配哪些浏览器呢?其实是上方的配置文件在影响
postcss / babel它们兼容性的都是通过browserslist工具,然后browserslist是通过can i use网站,查询适配的

graph LR
postcss/babel-- browserslist --->caniuse
Loading

打包 React 代码

本节代码见:3.build_react
前面我们讲过可以通过babel打包js语法, 而react使用的是jsx代码。jsx其实是js代码的扩展,加了一些特定的语法而已

  • 以前:jsx通过babel打包成react.createElement,所以说jsx其实是react.createElement的语法糖
  • v18后,本质是从react/jsx-runtime引入的jsx

image.png 我们了解到这里即可,因为如果没有jsx语法,我们写嵌套的结构简直是噩梦。
直观感受下Count组件,所以才需要jsx语法,更接近html让我们更容易上手 image.png

编写 React 应用

pnpm i react react-dom
//Counter.jsx
import React, { memo, useState } from 'react'

const Counter = memo(() => {
  const [count, setCount] = useState(0)
  return (
    <div className="count">
      <button onClick={() => setCount(count + 1)}>+1</button>
      <span style={{ padding: '0 8px' }}>{count}</span>
      <button onClick={() => setCount(count - 1)}>-1</button>
    </div>
  )
})

export default Counter

// App.js
import React from 'react'
import { createRoot } from '../node_modules/react-dom/client.js'
import Counter from './components/Counter.jsx'

const root = createRoot(document.getElementById('root'))
root.render(<Counter />)

就是一个简单的计数器代码,但是笔者遇到一个比较奇怪的问题,就是默认引入react-dom/client.js的时候,没有向上查找(node_modules),所以这里写了相对路径,效果如下

image.png 暂时没定位出来哪里的问题,如果知道的同学可以指点一下, 接下来我们应用就就好了,我们执行build

使用 babel-loader

错误:不支持 jsx

image.png 报错信息:告诉我们需要一个合适的loader来处理jsx类型的文件,因为我们之前使用loader,rules规则中并没有匹配它,webpack不知道如何去解析它
解决办法

// webpack.config.js
module: {
  rules: [
    {
      test: /\.jsx?$/,
      loader: 'babel-loader',
    },
  ],
},

正则?代表 1 个或者 0 个,告诉它使用babel进行处理,然而它也需要许多插件对jsx代码进行支持,因为plugin过于繁琐,我能直接使用预设即可

pnpm i @babel/preset-react -D
module.exports = {
  presets: ['@babel/preset-env', '@babel/preset-react'],
}

再次执行npm run build我们就发现代码已经打包成功,,但是这里还差最后一步,我们在编写jsx代码的时候,是要把react应用挂载在#root上的,但是我们现在并没有html文件。当然我们也可以手动创建,但是这样太过于繁琐。我们可以使用另外一个插件,直接把产物挂载到html上即可

html-webpack-plugin

pnpm i html-webpack-plugin -D
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      template: './index.html',
    }),
  ],
}

build以后就发现已经构建完毕,我们利用live server开启一个本地服务预览,后面我们还会讲解开启一个本地服务,热更新等,构建一个小型的开发环境
image.png
一个简单的计数器React应用,我们就构建好啦~ image.png

devServer

本小节见4.webpack_dev_server
上一小节中,我们说到打包react应用,但是需要我们手动进行build,但是在实际开发中,肯定不可能这样,每次修改一次代码,就build一下,所以我们要像一些常规的cli一样,开启一个本地服务进行开发
安装

pnpm i webpack-dev-server -D
// package.json
"scripts": {
  "start": "webpack server",
},

启动

npm run start这样就开启了一个服务了,端口在 8080,我们直接打开这个地址即可,同时也会进行热更新

image.png

static

演练场

我们启动的这个服务,不像build,它是实实在在的文件,而我们开启的本地服务内容都是在内存当中的,其中打包的静态资源,一般都需要存放到public文件夹中。 错误演练

image.png

image.png

image.png

image.png 从上面内容,我们可以得知,avatar.jpg请求的地址,正是我们开启服务的地址,但是这个地址的内容都是存在于内存当中,即使我们在目录下存放了图片文件,该服务不知道从哪里去寻找
解决办法

image.png

  • 我们修改一下avatar的存在目录即可,把它修正到public文件夹中即可
const Profile = memo(() => {
  return (
    <div>
      avatar1: <img src="../../avatar.jpg" /> <br />
      avatar2: <img src="/avatar.jpg" />
    </div>
  )
})

avatar1是走的相对路径,avatar2/avatar代表的从根目录进行查找,最后它两都是从8080端口下访问资源,那为什么是public文件夹呢,不是abc

配置详解

  • default(public)
    • 它的默认值为public所以在这个文件夹下的资源,可以被访问到,如果想要改成其他文件夹,直接修改配置即可
      devServer: {
        static: ['public', 'abc'],
      },
    如果是一个数组,那么它们两个文件夹都会起作用

open

  • 自动打开浏览器窗口

port

  • 修改端口号

compress

  • 开启gzip压缩,效果如下 image.png

proxy

在我们日常开发中,经常会遇到跨域的问题,解决方案有很多种

  • 生产阶段
    • 通过nginx进行反向代理来解决跨域问题
    • 后端开启 CORS
      • Access-Control-Allow-Origin: *
  • 开发阶段
    • 后端开启 CORS
    • 利用devServer开启Proxy,本文重点讲解,其他大家自行了解

后端服务

本节代码见:koa
我们采用Koa编写

pnpm i koa koa-router
const Koa = require('koa')
const Router = require('koa-router')

const app = new Koa()
const userRouter = new Router({ prefix: '/users' })

userRouter.get('/', (ctx, next) => {
  const users = [
    {
      name: 'ice',
      age: 24,
    },
    {
      name: 'panda',
      age: 23,
    },
  ]

  ctx.body = users
})

app.use(userRouter.routes())
app.listen(3000, () => {
  console.log('服务启动成功')
})

前端代码

import React, { memo, useEffect, useState } from 'react'
import axios from 'axios'

const Users = memo(() => {
  const [list, setList] = useState([])
  useEffect(() => {
    axios.get('http://localhost:3000/users').then((res) => {
      setList(res.data)
    })
  }, [])

  return (
    <div>
      <h4>Users:</h4>
      <ul>
        {list.map(({ name, age }) => {
          return (
            <li key={name}>
              {name}-{age}
            </li>
          )
        })}
      </ul>
    </div>
  )
})

export default Users

当我们访问浏览器就会看到这样的错误

image.png 这正是跨域的错误,因为我们的前端服务开启在8080端口上,而后端的api服务开启在3000上,我们不能进行访问,这个是浏览器的限制,违背了同源策略,那么我们如何解决这个问题呢?

解决办法

  1. 后端解决开启CORS
ctx.set('Access-Control-Allow-Origin', '*')
  1. 利用proxy开启代理
// webpack.config.js
proxy: {
  '/api': {
    target: 'http://localhost:3000',
    pathRewrite: {
      '^/api': '',
    },
  },
},
axios.get('/api/users').then((res) => {
  setList(res.data)
})

当我们以/api开头的,就会被代理到proxy中,而它是使用的http-proxy-middleware,是它开启的服务去我们3000端口请求数据,拿到数据以后在通过它返回给客户端这样就完成了代理,而pathRewrite是正则匹配,把以/api开头的,我们给它替换为空地址,最后拼接而成的就是后端api的地址

// before
// /api/users

// after
// http://localhost:3000/users

proxy 原理:中间人模式

flowchart LR
前端 --> proxy --> 后端api
后端api --> proxy --> 前端
Loading

changeOrigin

改变host的来源,有些服务器会根据host来源来判断,如果不是同一个 host 访问就给他屏蔽掉,防止爬虫。
koa服务器中,我们打印出它的header,就会发现请求的origin地址,也就是我们的前端地址8080。如果想要改变它的源,配置如下就可以解决

image.png

proxy: {
  '/api': {
    // ...
    changeOrigin: true,
  },
},

因为正常情况下,我服务器在3000端口下,那么请求的host也应该在3000,这个最主要看后端是否有限制

historyApiFallback

前端路由,都是一个url -> components,我们会在url上增加前端路由,然后渲染对应的组件,但是在浏览器中则会代表请求对应的资源,一般为index.html文件,刷新网页就会报一个404找不到文件的错误

image.png
那么我们如何解决呢?我们刷新网页的时候在给它重定下index.html文件即可

devServer: {
  historyApiFallback: true,
},

多入口起点

本节代码见:5.entry_points
我们前面就配置了entry,指向的main.js,当执行build,webpack就会从它出发,把有关联的代码形成一个依赖图(图结构),打包构建,而之前的mode中,我们只配置了单入口,其实是可以设置多入口起点的,不过日常使用的较少(vite/create react app都是单入口),我们了解即可

创建文件

// src/stu.js
console.log('stu')

// src/teacher.js
console.log('teacher')

此时,我们的src文件中,有两个文件,它们彼此并没有依赖,我们设置给他们两个都设置为入口文件

// webpack.config.js
module.exports = {
  // ...
  entry: {
    stu: './src/stu.js',
    teacher: './src/teacher.js',
  },
  output: {
    filename: 'bundle.js',
    path: resolve(__dirname, 'build'),
  },
}

文件打包

entry为对象的写法,我们可以配置多个入口,此时我们执行build,会发现如下错误,有多个chunk名字相同产生了冲突,它不知道如何打包了,其实学到这里,大家肯定知道应该要设置output(即出口配置) image.png

output: {
  filename: '[name]_bundle.js',
  path: resolve(__dirname, 'build'),
},

现在执行build就可以发现打包成功了,其中[name]写法为placeholder(占位符语法)

image.png

依赖共享

如下图所示,我们在日常开发中,会有一些包会在很多文件中使用,比如dayjsstu teacher文件中均有用到

// src/stu.js
import dayjs from 'dayjs'

console.log('stu')
console.log(dayjs().format('YYYY-MM-DD HH:mm:ss'))

// src/teacher.js
import dayjs from 'dayjs'

console.log('teacher')
console.log(dayjs().format('YYYY-MM-DD HH:mm:ss'))

然后我们进行build观察如下产物,会发现dayjs竟然会被打包两次(即第10行

image.png 那么我们针对如上的场景如何优化呢?能不能把文件单独打包到一个文件里面,然后让这两个文件应用这两个包呢?

entry: {
  stu: {
    import: './src/stu.js',
    dependOn: 'shared',
  },
  teacher: {
    import: './src/teacher.js',
    dependOn: 'shared',
  },
  shared: ['dayjs'],
},

我们在另外增加一个入口,让stu & teacher去引入这个需要共享的第三方包,这样就可以实现,我们再次观察产物,就可以看见实现了对shared包进行了引入 image.png

动态导入

本节代码见:6.dynamic_import
其实动态导入大家应该都用过,就是路由懒加载。Vue 中利用import函数导入组件或者React中的React.lazy的函数引入的组件。何为路由懒加载?最主要的就是映射关系, path: component,有history/hash两种模式,监听路由的变化,动态加载component
那么当浏览器首次渲染的时候,只需要下载对应js文件即可(不需要全部下载),有利于提高首屏渲染速度。当浏览器闲置的时候下载,或者使用到的时候在下载即可(路由变化)

基本用法

// main.js
const btnEl1 = document.createElement('button')
const btnEl2 = document.createElement('button')

btnEl1.textContent = '加载stu'
btnEl1.addEventListener('click', () => {
  import('./stu').then((res) => {
    console.log(res)
  })
})

btnEl2.textContent = '加载tea'
btnEl2.addEventListener('click', () => {
  import('./teacher').then((res) => {
    console.log(res)
  })
})

document.body.append(btnEl1, btnEl2)

当我们执行build的时候,就会发现stutea是打成单独的包的,通过某种行为,动态的加载

image.png
当我们开启一个本地服务,首次加载的只有bundle.js

image.png
当我点击按钮,触发了某种行为的时候,才会加载动态导入的文件

image.png

魔法注释

我们前面进行了动态导入,但是仔细看它的名字src_stu_js.bundle,是根据它的目录+filename 生成的,但是实际cli中,都会是对应的页面名称,此时我们就需要用到魔法注释了

btnEl2.addEventListener('click', () => {
  import(/* webpackChunkName: 'teacher' */ './teacher').then((res) => {
    console.log(res)
  })
})

此时,我们就可以发现名称已经改掉了 image.png

prefetch/preload

  • 预获取(prefetch) :将来某些导航下可能需要的资源
  • 预加载(preload):当前导航下可能需要的资源

差别

  • 预加载的chunk会在父chunk加载时并行开始加载,预获取chunk则会在父chunk加载结束后开始加载
  • 预加载chunk具有中等优先级,而预获取chunk则在浏览器闲置时下载

实践

增加:/* webpackPrefetch: true */ image.png 我们可以从浏览器看到,teacherchunk会在父chunk加载完,就立即获取,随后当我们触发动态导入操作的时候,它会从cache中获取文件

image.png preload不正确地使用  webpackPreload  会有损性能,请谨慎使用,笔者就不再演示

optimization

本节代码见:7.optimization

splitChunks

在我们实际构建的产物,往往第三方包(即vendors)和我们自己编写的代码都是分开打包的,一般会构建为两个文件

// main.js
import dayjs from 'dayjs'

console.log(dayjs('YYYY-MM-DD HH:mm:ss'))
console.log('ice')

这是我们不处理构建的产物,无论是第三方库,还是我们编写的代码都在bundle.js文件,现在看起来无伤大雅,但是依赖多了,随着bundle文件越来越大,那么意味着下载的也慢,首屏渲染速度也会下降。所以,我们才要把他们分开存放

image.png

我们走读一下配置,其中all代表,可在同步与异步(异步:动态导入)之间共享chunk,minSize代表生成chunk的最小体积,如果比这个1kb还小的话,就直接不分开打包, defaultVendors设置产物的名称

//webpack.config.js
const { resolve } = require('path')
const htmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 1 * 1024,
      // 自定义分包
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          filename: 'vendors.bundle.js',
        },
      },
    },
  },
}

接下来我们来查看下产物,我们编写的代码,在bundle.js中,而第三方的在vendors

image.png

chunkIds

当我们不设置output输出的文件名且在development模式下,那么它将使用绝对路径进行命名,如下图所示 image.png 但是它的文件名生成算法大有来头,配置即chunkIds,dev环境采用的是named,而prod的阶段采用的是deterministic(在不同的编译中不变的短数id。有益于长期缓存。在生产模式中会默认开启。)
编译结果如下图所示:
image.png

CDN

本节代码见:8.cdn
我们构建的产物,最后是要被部署到服务器上的,但是对于这种物理设备会存在地域的限制,往往服务器离用户近的越近的,相对来说访问的速度越快,所以才有了 CDN,把静态资源部署到 CDN 服务器上,也是性能优化的策略之一
一般把index.html直接部署到服务器上,而那些资源js/css等,应该存放在 CDN 上,选择也还是存在于服务器上,那么怎么修改呢? image.png

publicPath

// webpack.config.js
module.exports = {
  output: {
    publicPath: 'https://cdn.com', // 示例地址
  },
}

构建打包效果如下:

image.png

打包第三方库

对于CDN服务器来说,往往比普通的服务器更贵,小型公司选择性的可能性不大,而对于那种第三包库而说都会有开源的CDN平台,bootcdn,众多知名的库都会托管在这里。 比如,我们想要dayjs使用 CDN 进行加速,而不是打包在我们的源代码中
首先进行排除,externals 中key:value代表不同的意义,key是引入包的名字(from x),而valuecdn库的链接导出的名字

// webpack.config.js
module.exports = {
  externals: {
    dayjs: 'dayjs',
  },
}

在 html 模版中引入即可 image.png

CSS 单独打包

我们知道,从浏览器的渲染原理可以得知css是可以并行加载的,不会阻塞后面代码的执行,dom+css解析完成形成一个render tree在经过layout paint最后显示到浏览器上,而在不配置css单独打包,会打包到js代码里,会增加js包的大小,影响首屏渲染的速度

默认

// webpack.config.js
module: {
  rules: [{ test: /\.css$/, use: ['style-loader', 'css-loader'] }],
},
/* style.css */
body {
  background: skyblue;
}
// main.js
import './style.css'
console.log('ice')

我们执行build,会发现css代码是打包到js代码当中的,通过style-loadercss插入到head当中 image.png

image.png

单独打包

pnpm i mini-css-extract-plugin -D
// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  module: {
    rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name]_[hash:6].css',
    }),
  ],
}

使用mini-css-extract-plugin提取css,把css提取成单个文件,我们执行build在观察产物,资源就被我们提取出来了

image.png

image.png

丑化 Css

不使用丑化,对于一些换行符,空格之类的它实际会占代码体积的,我们可以把它进行丑化,删除一些无用的空格和换行 image.png 使用之后

// webpack.config.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

module.exports = {
  mode: 'development',
  optimization: {
    minimize: true,
    minimizer: [new CssMinimizerPlugin()],
  },
}

我们就可以清晰的看见css被丑化了,minimize代表启动minimizer,而在prod模式下,默认为true image.png

Terser

本节代码见:10.terser
terser 工具,在modeprod环境的时候,会自动应用。那它是什么呢?它用于丑化js代码,什么是丑化呢?我们平常使用的第三方库都会有以min.js结尾的就是丑化之后的代码。而它由mangler and compressor两部分组成
比如:dayjs.min.js,如下图所示 image.png 那为什么需要把它丑化呢?对于我们开发的时候,我们都需要做到“见名知意”,但是对于用户来说,它无需关系变量名,存不存在换行等,因为这样可以大大减少代码的体积,更快的在http中进行传输,更快的传输给用户,加载首评渲染速度等一系列好处。

CLI 中使用

本节代码见:1.CLI

pnpm i terser -D
// main.js
const sum = (n1, n2) => n1 + n2

function bar() {
  console.log('bar')
}
// package.json
"scripts": {
  "build": "terser main.js -o mini.min.js -m --toplevel",
}

我们简单走读一下配置

  1. terser main.js 编译该文件
  2. -o 输出为mini.min.js
  3. -m mangle(乱砍)把变量名砍断,变量名简写即getUserName ->缩短为一个字符 x
  4. --toplevel 顶层作用域的名称进行 压缩/砍断
    ...更多配置

我们执行npm run build,观察一下产物信息,发现代码就被我丑化了 image.png

webpack 中使用

本节代码见:2.webpack
前面我们有说到过,当我们模式为prod的时候,会自动使用terser-webpack-plugin这个插件,而我们可以进行自定义,一般我们使用默认即可,我们首先来看下prod模式下,打包后的产物,它是正常进行压缩的 image.png 接下来,我们来设置一些配置,对它了解的更加深刻

// webpack.config.js
  optimization: {
    minimize: true,
    minimizer: [
      new terserWebpackPlugin({
        extractComments: false, // 提取注释
        terserOptions: {
          mangle: true, // 字母缩写
          compress: {
            // 未使用的不打包
            unused: false,
          },
        },
      }),
    ],
  },
  1. minimize 使用minimizer自定义的terser或者默认的TerserWebpackPlugin
  2. extractComments 是否把注释提取为单独的文件
  3. mangle 字母缩写
  4. unused 未使用的包,不打包

webpack 的配置抽取

本节代码见:11.webpack_merge_config
至今为止,我们已经学了很多的配置项了,但是我们的模式是混乱的,有些应该存在于prod环境下,而有的应该在dev模式下,现在我们就进行抽取

前置知识

我们要区分mode,首先我们要让webpack.config.js中,知道我们执行build处于prod模式,而start处于dev模式下

// package.json
{
  "scripts": {
    "start": "webpack server --config ./config/index.config.js --env development",
    "build": "webpack --config ./config/index.config.js --env production"
  },
}

我们执行对应的命令,--env传入现处的模式,那我们在哪里接受呢?其实我们写的webpack.config.js不仅可以接受一个对象,还可以接受一个函数,webpack就会执行这个函数,并把env的环境传递给我们

module.exports = function (env) {
  // const isProd = env.production
}

核心思路

  1. 执行start/build区分对应的环境
  2. 把文件分离出来,把对应的配置写到对应的mode
    • index.config.js
    • dev.config.js
    • prod.config.js
  3. 利用webpack-merge进行组合

源代码

// index.config.js
const { resolve } = require('path')
const { merge } = require('webpack-merge')
const htmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const prodWebpackConfig = require('./prod.config')
const devWebpackConfig = require('./dev.config')

const getCommonConfig = (isProd) => {
  return {
    entry: './src/main.js',
    output: {
      filename: 'js/[hash:6]_bundle.js',
      path: resolve(__dirname, '../build'),
      clean: true,
    },
    module: {
      rules: [{ test: /\.css$/, use: [isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'] }],
    },
    plugins: [
      new htmlWebpackPlugin({
        template: './index.html',
      }),
    ],
  }
}

module.exports = function (env) {
  const isProd = env.production
  const config = isProd ? prodWebpackConfig : devWebpackConfig

  return merge(getCommonConfig(isProd), config)
}

// dev.config.js
module.exports = {
  mode: 'development',
  devServer: {
    open: true,
    port: 3000,
    compress: true,
    static: ['public', 'abc'],
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        pathRewrite: {
          '^/api': '',
        },
        changeOrigin: true,
      },
    },
    historyApiFallback: true,
  },
}

// prod.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')

module.exports = {
  mode: 'production',
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name]_[hash:6].css',
    }),
  ],
  optimization: {
    minimizer: [new CssMinimizerPlugin(), new TerserWebpackPlugin()],
  },
}

Tree Shaking

本节代码见:12.tree_shaking
“摇树”,把代码中的dead code不打包在产物中,我们前面说到的terser可以实现部分tree shaking的效果,即顶层作用域中没使用到的"函数",变量等。为了实现演示效果,我们把prod.config.jsmode改为dev模式,避免应用了一些插件影响我们。tree shaking依赖于ES Module的静态语法分析,要使用ES Module进行导出

usedExports

如下图所示,我们其中的sum&info都是没有使用过的 image.png 当我们没有使用terser,就会发现,我们构建的产物中,还会存在dead code这些死的代码,这样会增加代码的体积 image.png 当我们使用terser就会把没使用过的代码(即顶层作用域)去除,为什么说是顶层呢?模块就不可以吗?

// service/getUserName.js
function getUserName() {
  return 'ice'
}
export default getUserName

// main.js
import './style.css'
import { getUserName } from './service/getUserName'

const sum = (a, b) => a + b
const info = { name: 'ice', age: 24 }

我们执行build观察产物,会发现并没有被删除掉,是因为terser并不知道这个模块有没有被使用,需要结合usedExports image.png usedExports 做了什么事情?
首先我们为了查看效果,先不要使用terser,避免干扰到我们,build观察一下产物 image.png

optimization: {
  usedExports: true,
},

应用usedExports后,我们在进行build,就会多了一行魔法注释,在结合terser,terser就可以大胆的将这个模块移除了 image.png 我们放开terser再次build导出却没有使用的代码就被移除了 image.png

sideEffects

它会跳过整个模块/文件,直接查看该文件是否有副作用

// package.json
"sideEffects": false,
import './style.css'
import { getUserName } from './service/getUserName'

const sum = (a, b) => a + b
const info = { name: 'ice', age: 24 }

console.log(sum, info)
function getUserName() {
  return 'ice'
}
// 增加副作用代码
window.info = {
  name: getUserName(),
  age: 20,
}

export default getUserName

如果为sideEffects为不保留副作用代码,那么没有使用到的代码模块(文件)将直接被删除,但是这些副作用代码有可能会被用到,那么代码就会报错
style.css也不会被打包

// package.json
  "sideEffects": ["*.css", "getUserName.js"],

把需要保留的副作用模块,写入到数组中即可

Scope Hoisting

本节代码见:13.scope_hoisting
概念
早期 webpack 中模块都会被包裹在单独的函数闭包中,这些包裹的函数使js在浏览器中执行速度变慢,相比像rollupjs就会把所有的模块作用域“提升”合并到一个闭包中,这就称之为“作用域提升”
前面我们有提到,当mode为生产环境的时候默认,会给我们默认使用许多“优化手段”,提升作用域也是应用的一个plugin,我们改为dev来测试一下这个 plugin(即ModuleConcatenationPlugin

不使用

// sub.js
const sub = (a, b) => a - b
export default sub

// sum.js
const sum = (a, b) => a + b
export default sum
import sub from './utils/sub'
import sum from './utils/sum'

console.log(sub(10, 5))
console.log(sum(10, 5))

我们观察一下构建的产物,可以非常的清晰看见他们是存在一个单独的作用域中,这样浏览器执行的速度变慢

image.png

使用

// webpack.config.js
plugins: [
  new webpack.optimize.ModuleConcatenationPlugin(),
],

我们进行build再次观察产物,就会发现他们的作用域被“提升”了,或者说被合并了,处于同一个作用域(即 IIFE,立即执行函数)

image.png

CompressionPlugin

本节代码见:14.gzip
前面我们说到了terser,它可以把源代码进行丑化,但是这种“丑化”已经达到了压缩的最大程度。基本上不能再从源代码上优化,但是gzip格式可以很大程度的减少源文件的大小,可以更快的在网络上传输,加快首屏渲染速度,而在webpackmodeprod的模式下并没有应用这个插件,这是性能优化的手段之一
客户端和服务端达成了共识,当遇到gzip文件,就自动进行解压

// webpack.config.js
plugins: [
  new CompressionPlugin({
    test: /\.(css|js)$/,
    minRatio: 0.8,
    algorithm: 'gzip',
  }),
]

简单走读一下配置

  • test 匹配以.css .js结尾的
  • 最小压缩比例要达到 0.8这个也是默认值

我们进行build,观察一下结果,就会发现打包之后的产物多了.gz文件

image.png

SpeedMeasureWebpackPlugin

本节代码见:15.speed_measure
这个插件主要作用于测量构建速度的,可以根据测量,来优化我们的构建速度

// before
const webpackConfig = {
  plugins: [new MyPlugin(), new MyOtherPlugin()],
}
// after
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')

const smp = new SpeedMeasurePlugin()

const webpackConfig = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
})

效果如下:

image.png 比如我们看babel-loader的消耗时间较长,就可以使用一些优化手段,exclude排除一些第三方的库,库一般都会进行向下兼容,不需要使用babel

{ test: /\.js$/, use: 'babel-loader', exclude: /node_modules/ }

BundleAnalyzer

本代码详见:16.bundle_analyzer
对我们构建的产物进行分析,可以分析包的大小,比如dayjs包,如果占用项目大小较大,我们就可以考虑自己封装一个,从而减少包的代码体积

// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

plugins: [
  new BundleAnalyzerPlugin(),
],

image.png

自定义 loader

本代码详见:17.custom_loader
前面我们有使用过style-loader babel-loader等,那么现在我们就在自定义loader,让我们对loader了解的更加透彻

起手式

// loaders
// ice-loader1.js
module.exports = function (content) {
  console.log('loader1:', content)

  return content
}
// ice-loader2.js
module.exports = function (content) {
  console.log('loader2:', content)

  return content
}

// webpack.config.js
module.exports = {
  resolveLoader: {
    modules: ['node_modules', 'loaders'],
  },
  module: {
    rules: [{ test: /.js$/, use: ['ice-loader1.js', 'ice-loader2.js'] }],
  },
}

简单走读代码:定义了两个loader文件,即(loader1/loader2),build遇到.js结尾匹配loader文件,而loader的应用是从前往后,即loader2先执行,然后loader1执行

同步 loader

我们在匹配规则中,有匹配两个loader,现在要实现一个场景,loader2中会传递一些数据给loader1进行处理

// sync_loaders
// loader2.js
module.exports = function (content) {
  console.log('loader2:', content)

  return content + 'ice'
}
// loader1.js
module.exports = function (content) {
  console.log('loader1:', content)

  return content
}

当我们进行build,先匹配loader2,然后把匹配的结果加上ice,传递给下一个loader,即loader1

异步 loader

module.exports = function (content) {
  console.log('loader2:', content)
  const callback = this.async()

  setTimeout(() => {
    callback(null, content + 'ice')
  }, 3000)
}

在同步的基础上this.async(),把参数通过回调函数的形式传递给下一个loader,其中第一个实参为error,第二个为返回值

接受参数/校验参数

接受参数

// params_loaders
module.exports = function (content) {
  const params = this.getOptions()
  console.log(params)

  return content
}

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: 'params-loader',
          options: {
            name: 'ice',
            age: 20,
          },
        },
      },
    ],
  },
}

校验参数

// validate-loader.js
const { validate } = require('schema-utils')
const schema = require('./schema.json')

module.exports = function (content) {
  const options = this.getOptions()
  validate(schema, options)

  return content
}

// schema.json
{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "必须是字符串类型"
    },
    "age": {
      "type": "number",
      "description": "必须是数字类型"
    }
  }
}

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published