【自我拯救计划】菜狗一篇文章学完webpack

175 阅读23分钟

webpack学习

基本概念

webpack是一个现代Javascript应用程序的静态模块打包工具。当webpack处理应用程序时,它会在内部构建一个依赖图,此依赖图会映射项目所需的每个模块,并生成一个或多个bundle

webpack 配置是标准的 Node.js CommonJS 模块

  • 入口(entry):入口起点指示webpack应该使用哪个模块,来作为构建其内部依赖图的开始。

    默认值是./src/index.js,可以配置entry属性,来指定一个或多个入口起点

  • 输出(output):告诉webpack在哪里输出它所创建的bundle,以及如何命名这些文件,主要输出文件的默认值是./dist/main.js,其他生成文件放在./dist文件夹中

  • loader:因为webpack只能理解json和JavaScript文件。loader让webpack能够去处理其他类型的文件,并将它转换为有效模块,以供应用程序使用,以及被添加到依赖图中

loader有两个属性: test —— 用于表示出应该被对应的loader进行转换的某个或某些文件 use —— 表示进行转换时,应该使用哪个loader

  • plugin:插件可以用于执行范围更广的任务,包括:打包优化,资源管理,注入环境变量

    只需要require它,然后把它添加到plugins数组,多数插件可以通过option自定义;也可以在一个配置文件中因为不同目的而多次使用同一个插件,这时需要new创建实例 plugin是一个具有apply方法的JavaScript对象。apply方法会被webpack compiler调用,并且compiler对象可在整个编译生命周期访问

  • mode:提供mode配置选项,告知webpack使用相应环境的内置优化(none,production,development)

  • 浏览器兼容性:webpack支持所有符合ES5标准的浏览器(支持IE8以下版本),如果想要支持旧版本浏览器,需要提前加载polyfill

模块打包

1. CommonJS

CommonJS 最初只是为了服务端设计,直到有了 Browserify (一个运行在Node.js环境下的模块打包工具)意味着客户端代码也可以遵循 CommonJS 标准来编写了

1. CommonJS 规定一个模块就是一个文件

所有的变量即函数只能自己访问,对外不可见

建立两个js文件:index.jstest.js

index.js

var name = 'hello index'
require('./test.js')
console.log(name)

test.js

var name = 'hello test'

控制台结果如下:

image.png

说明作用域互不干扰,每个模块有自己的作用域

2. CommonsJS 导出是一个模块向外暴露自身的唯一方式

注意module.exports或exports的区别

修改刚刚的两个js文件

index.js

var test = require('./test')
console.log(test)

test.js

module.exports = {
    name: 'hello test',
    calc: function (a, b){
        return a+b
    }
}

执行结果:

image.png

exports.name = 'hello test';
exports.calc = function (a, b){
            return a+b
    }

这样写和module.exports一个效果

但是不能给exports直接赋值一个对象,这样会造成虽然exports进行了赋值,却指向了一个新对象,而module.exports还是原来的空对象

test.js

image.png

执行结果:

image.png

同时,导出语句不代表模块的末尾,在module.exports或exports后代码会照常执行

test.js的末尾加上一句console做个测试:

image.png

执行结果:

image.png

3. commonJS使用require进行模块导入。

如果require模块是第一次被加载,这时会首先执行该模块,然后导出;如果require模块被加载过,这时模块代码不会再次执行,而是直接导出上次执行后的结果

test.js

console.log('start run test.js')

module.exports = {
    name: 'hello test',
    calc: function (a, b){
        return a+b
    }
}
console.log('end')

index.js

const test1 = require('./test')

const sum1 = test1.calc(2,6)

console.log(sum1)

const test2 = require('./test')

const sum2 = test2.calc(2, 6) // 参数另换也是一个意思,之前被加载过,就不会再加载了

console.log(sum2)

两个地方require了test.js,但是其实内部代码只执行了一次

image.png

模块中有一个module对象用来存放信息,这个对象中有一个属性loaded用于记录该模块是否被加载过。默认值为false,当模块第一次被加载后会置为true,后面再次加载检查到mmodule.loaded为true就不会再次执行模块代码

require可以接受表达式,借助这个特性可以动态地指定模块加载路径;而且如果只想通过执行它而产生某种作用,而不需要获取其导出的内容,直接require

2. ES6 Modlue

1. ES6 Module 也是一个文件作为一个模块,每个模块有自己的作用域

ES6 Module会自动采用严格模式,this指向undefined; 而common JS会指向当前模块

image.png

2. 导出模式和导入模式

导出有两种形式: 命名导出和默认导出;默认导出只能有一个;可以使用as对变量重命名

导入可以整体导入也可以直接只导入对应的变量;同时也可以使用as重命名

test.js

// 命名导出写法有如下几种

export const age = 15

export const sayHi = function(a){
    return 'hi'+a
}

const level = 'Level A'

const sayLevel = function(level){
    return level
}
export {level, sayLevel as getLevel}

// 默认导出
export default{
    name: 'test',
    test: this,
    calc: function (a, b){
        return a+b
    }
}

index.js

// 默认导入
// import test from "./test.js";

import {age, sayHi, level, getLevel} from "./test.js"

console.log(age, sayHi, level, getLevel)

测试代码:

image.png

3. common JS 和 ES6 Module 区别

1. 动态和静态

common JSES6 Module最本质的区别在于对模块依赖的解决。common JS发生在代码运行阶段,ES6 Module是发生在代码编译阶段

common JS模块被执行前,并没有办法确定明确的依赖关系,模块的导入和导出发生在代码的运行阶段

ES6 Module不支持导入的路径是一个表达式,并且导入、导出必须位于模块的顶级作用域

所以说,ES6 Module相对与common JS具有以下优势:

  1. 死代码检测和排除:可以使用静态分析工具检测出哪些模块没有被调用,然后在打包时去掉这些没有使用过的模块,可以减少打包资源体积
  2. 模块变量类型检查:js属于动态类型语言,不会再代码执行前检查类型错误;ES6的静态模块有助于确保模块间传递的值或接口类型是正确的
  3. 编译器优化:在common JS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而ES6 Module支持直接导入变量,减少引用层级,程序效率更高

2. 值拷贝与动态映射

在导出一个模块时,common JS获取的是一份导出值的拷贝;而在ES6 Module中则是值的动态映射,并且这个映射是只读的

  • common JS

test.js

console.log('start run test.js')

var count = 0
module.exports = {
    name: 'hello test',
    test: this,
    count: 0,
    calc: function (a, b){
        count++
        return a+b
    }
}
console.log('end')

index.js

var count = require('./test').count

var sum = require('./test').calc

console.log(count)
sum(2,3)
console.log(count)
count++
console.log(count)

测试代码:

image.png

index.js中的count是test.js中的一份值拷贝,因此在调用了函数后,虽然改了原本test.js的count值,但是不会对index.js导入时创建的副本造成影响

而且common JS允许对导入的值进行修改;同样的由于是值拷贝,不会影响test.js其本身

  • ES6 Module

将上方改写

test.js

let count = 0

const calc = function(a, b){
    count++
    return a+b
}
export {count, calc}

index.js

import {count, calc} from "./test.js";

console.log(count)
calc(1, 9)
console.log(count)
count++

可以看出是对原有值的动态映射,当函数调用后,count值发生了改变,而且ES6 Module不能对变量进行更改

测试代码:

image.png

3. 循环依赖

  • common JS

index.js

require('./a')

a.js

const b = require('./b')

console.log('a', b)

module.exports = 'hello i am a'

b.js

const a = require('./a')

console.log('b', a)

module.exports = 'hello i am b'

测试结果:

image.png

这是由于index.js导入a.js,此时开始执行a.js的代码,a.js开始对b.js进行了require,就进入了b.js内部,在b.js内部又对a.js进行了require,产生了循环依赖,此时执行权不会交回到a.js;所以直接取导出值,就是一个空的module.exposrts。b执行完毕,执行权交回给a.js,往下执行,b的打印没有问题

  • ES6 Module

将上方代码改写成ES6 模式

index.js

import a from './a.js'

a.js

import b from './b.js'

console.log('in a', b)

export default 'hello i am a'

b.js

import a from './a.js'

console.log('in b', a)

export default 'hello i am b'

测试代码:

in b undefined

in a hello i am b

ES6 会返回undefined,可以在b.js内console那里使用setTimeout 来延迟

image.png

更好的控制循环依赖:

index.js

import a from './a.js'
a('index.js')

a.js

import b from './b.js'
function a(name){
    console.log(name + '—— a.js')
    b('a.js')
}

export default a

b.js

import a from './a.js'
let invokes = false
function b(name){
    if (!invokes){
        invokes = true
        console.log(name + '—— b.js')
        a('b.js')
    }
}
export default b

测试代码:

image.png

  1. 这是由于index.js作为入口,导入了a.js,开始执行a中代码
  2. a.js中导入b.js,执行权交给了b.js
  3. b.js一直执行到结束,完成b的函数定义;此时a.js还没执行完毕,a仍然还是undefined
  4. 执行权回到a.js中,完成a的函数定义,此时由于ES6动态映射,b.js中a的值由undefined变成了定义的函数
  5. 执行权回到index.js并调用a(),此时会执行a-b-a,打出正确的值

4. 加载其他模块

1. 非模块化文件

比如引入jquery,直接引入:

import './jquery.min.js'

但是,如果引入的非模块化文件是以隐式全局变量方式暴露其接口,就会发生问题;因为webpack在打包时会为每个文件包装一层函数作用域来避免全局污染

2. AMD(Asynchronous Module Definition-异步模块定义)

它与commonJS和ES6 Module最大的区别就是它加载模块是异步的

  • 定义模块
define('geSum',['a'], function(){

})

表示getSum模块需要引入a模块,最后一个参数用来描述模块的导出,可以是函数或对象;如果是函数则导出函数的返回值,如果是对象直接导出对象本身

  • 加载模块
require(['geSum'], function(){

})

require第一个参数指定加载的模块,第二个参数是当加载完成后执行回调函数

优点:

  1. 通过AMD方式定义模块的好处是其模块加载是非阻塞性的,当执行到require函数时并不会停下来去执行被加载模块,而是继续执行require后面的代码,使得模块加载不会阻塞浏览器

缺点:

  1. AMD语法相对冗长
  2. 异步加载方式不如同步清晰,容易造成回调地狱

3. UMD(Universal Module Definition-通用模块标准)

UMD并不能说是一种模块标准,不如说时一组模块形式的集合

它的目标是使一个模块能运行在各种环境下,无论是CommonJS、AMD还是非模块化环境(当时ES6 Module还未被提出)

image.png

UMD其实就是根据全局对象的值来判断目前处于哪种模块环境。如果当前环境是AMD,就以AMD的形式导出;当前是CommonJS,就以CommonJS的形式导出

需要注意:UMD最先判断AMD环境,而通过AMD定义的模块是无法使用CommonJS或ES6 Module的形式正确引入的。在webpack中,它支持AMD和CommonJS,可能全部都是CommonJS引入,UMD却发现是AMD,以AMD方式导出,就会出错。所以这时候,可以更改UMD判断顺序

4. 加载npm模块

  • 加载一个模块
import _ from 'lodash'

加载一个模块只需要写明名字,因为每个npm模块都有一个入口,加载一个模块相当于加载该模块的入口文件

  • 加载单个js文件
import all from 'lodash/fp/all.js' 

通过<package_name>/<path>方式单独加载,可以减少打包资源的体积

5. 模块打包原理

代码的大体结构如下:

image.png

  • 最外层立即执行匿名函数:包裹整个bundle,并构成自身的作用域

  • installedModules对象:每个模块只在第一次加载时执行,之后其导出值就被存储到这个对象里面,当再次加载时直接从这里取值,而不会重新执行

  • __webpack_require__函数:对模块加载的实现,在浏览器中可以通过调用__webpack_require__(module_id)完成模块导入

  • modules对象:工程中所有产生了依赖关系的模块都会以key-value的形式放在这里。key-模块id,由数字或一个很短的hash字符串构成;value-由一个匿名函数包裹的模块实体,匿名函数的参数则赋予每个模块导出和导入的能力

一个bundle执行流程:

  1. 在最外层的匿名函数中会初始化浏览器执行环境,包括定义installedModules对象、__webpack_require__函数等,为模块的加载和执行做一些准备工作

  2. 加载入口模块:每个bundle都有一个入口模块,上方的index,js 就是入口模块

  3. 执行模块代码:如果执行到module.exports则记录下模块的导出值;如果中间遇到require(__webpack_require__)函数,则会暂时交出执行权,进入__webpack_require__函数体内部进行加载其他模块的逻辑

  4. __webpack_require__中会判断即将加载的模块是否存在与installedModules中,如果存在则直接取值,否则到第三步执行该模块的代码来获取导出值

  5. 所有依赖的模块都已执行完毕,最后执行权又回到入口模块。当入口模块的代码执行到结尾,也就是整个bundle运行结束

资源处理

资源入口

webpack通过context和entry这两个配置项来共同决定入口文件的路径。

webpack配置入口做了啥:

  1. 确定入口模块位置,告诉webpack从哪里开始打包
  2. 定义chunk name

1. context

context:资源人口的路径前缀(绝对路径),可以省略,默认值为当前工程的根目录

// <工程根路径>/src/scripts
module.exports = {
    context: path.join(__dirname, './src/scripts')
}

作用:使entry更简洁,尤其是多入口的情况下

2. entry

  • 字符串写法
module.exports = {
    entry: './src/index.js'
}
  • 数组写法
module.exports = {
    entry: ['babel-polyfill', './src/index.js']
}

以上两种都是单入口模式,chunk 会被命名为 main。传入一个数组的作用时将多个资源预先合并,在打包时webpack会将数组的最后一个元素作为实际的入口路径

  • 对象写法

定义多入口,就必须使用对象形式;

key —— chunk name value —— 入口路径

module.exports = {
    entry: {
        index: ['babel-polyfill', './src/index.js'],
        other: 'src/lib.js'
    }
}
  • 函数写法
module.exports = {

    // 返回一个字符串
    entry: () => './src/index.js'
    
    // 返回一个对象
    entry: () => ({
        index: ['babel-polyfill', './src/index.js'],
        other: 'src/lib.js'
    })
    
    // 返回一个Promise对象来进行异步操作
    entry: () => new Promise((resolve) => resolve(['./demo', './demo2'])),
}

3. 应用

module.exports = {
    entry: {
        app: './src/index.js',
        vender: ['react', 'react-dom']
    }
}

可使用optimization.splitChunks,将vendor和app这两个chunk的各个模块提取出来。这样由于vendor不会经常变动,可以有效利用客户端缓存,在用户后续请求页面时会加快整体的渲染速度

同样多入口也是可以:

module.exports = {
    entry: {
        pageA: './src/pageA.js',
        pageB: './src/pageB.js',
        vender: ['react', 'react-dom']
    }
}

资源出口

module.exports = {
    entry: './src/index.js',
    output: {
        // 控制输出资源的文件名(字符串)
        // 可以是bundle名字,也可以是相对路径(没有该目录会创建该目录)
        // 支持模板语法动态生成文件名 【name -> chunk name】【hash -> webpack此次打包所有资源生成的hash】【chunkhash -> 当前chunk内容的hash】【id -> 当前chunk的id】【query -> filename配置项中的query】
        // 如果要控制客户端缓存,最好加上chunkhash,可以更加精确命中缓存
        filename: 'bundle.js',
        // filename: './js/dist.js',
        // filename: '[chunkhash].js',
        
        
        // 指定资源输出的位置,要求值为绝对路径
        // webpack 4之后默认为dist目录
        path: path.join(__dirname, 'dist'),
        
        
        // 指定资源的请求位置(由js或css所请求的间接资源路径)
        // 1. html相关 —— 指定为HTML的相对路径,会构成实际请求的URL
        // 假设请求xxx,实际路径:https://example.com/assets/xxx
        publicPath: '../assets',
        
        // 2. host相关 —— 如果以'/'开头,则代表此时publicPath以当前页面的hostname为基础路径
        // 假设请求xxx,实际路径:https://example.com/xxx
        publicPath: '/',
        
        // 3. CDN —— 需要绝对路径
        // 假设请求xxx,实际路径:http://cdn.com/xxx
        publicPath: 'http://cdn.com/'
    }
}

需要注意webpack-dev-server中也有一个publicPath,这个publicPath是指定webpack-dev-server的静态资源服务路径。

为了避免开发环境和生产环境不一致造成的困惑,可以将二者保持一致,保证任何环境下资源输出的目录都相同

image.png

预处理器(loader)

  1. 每一个loader本质就是一个函数

  2. webpack 4之后loader 支持字符串、source map、AST对象

  3. loader是链式的,从后往前执行处理,把最后生效的放在前面

image.png

1. 配置loader

  • test 可接受一个正则表达式或者一个元素作为正则表达式的数组,只有正则匹配上的模块才能使用这条规则

  • use 可接收一个数组,数组包含该规则所使用的loader

  • exclude 排除 (exlude和include同时存在,exclude优先级更高)

  • include 只包含

  • resource 被加载模块(前面test,exclude,include本质事对resource的配置)

  • issuer 加载者

  • enforce 指定一个loader的种类,只接收prepost两种字符串的值,pre表示在所有正常loader之前执行,post相反

2. 常见loader

1. babel-loader

用来处理ES6+并将其编译为ES5,它使我们能够在工程中使用最新的语言特性,同时处理兼容性问题

  • babel-loader —— 使babel和webpack协同工作的模块

  • @Babel-core —— babel编译器的核心模块

  • @Babel/preset-env —— babel推荐的预置器,可根据用户设置的目标环境自动添加所需插件和补丁来编译ES6+代码

rules: [
    {
        test: /\.js$/,
        // 由于babel-loader对所有js生效,使用exclude避免编译node-modules
        exclude: /node-modules/,
        use: {
            loader: 'babel-loader',
            options: {
            // 会启用缓存机制,防止未改编过的模块二次编译;可接收一个字符串路径也可以是true,默认指向node_modules/.cache/babel-loader
                cacheDirectory: true,
                // @babel/preset-env会将ES6 module转化未CommonJS的形式,会导致webpack的tree-shaking失效,设为false会禁用模块语句的转化;跟.babelrc读取babel配置一样的效果
                presets: [['env', {modules: false}]]
            }
        }
    }
]

2. ts-loader

用于连接webpack与typescript的模块

typescript本身的配置必须放在tsconfig.json中

{
    "compilerOptions": {
        "target": "es5",
        "sourceMap": true
    }
}

3. html-loader

将html文件转化为字符串并进行格式化

4. handlebars-loader

handlebars —— 使用模板和输入对象来生成 HTML 或其他文本格式

用于处理handlebars模板,在安装时要额外安装handlebars

5. file-loader

用于打包文件类型,并返回其publicPath

file-loader支持配置文件名及pubicPath(会覆盖原有的output。publicPath),通过loader的options传入

6. url-loader

与file-loader类似,唯一的不同在于用户可以设置一个文件大小的阈值,当大于该阈值时与file-loader一样返回publicPath,而小于该阈值时则返回文件base64形式编码

7. vue-loader

处理vue组件

安装的时候,需要vue-loader + vue-template-compiler + css-loader

处理样式

1. 分离样式文件,更利于客户端缓存

mini-css-extract-plugin(webpack 4以上使用)—— 用于提取样式到CSS文件

它支持按需加载CSS

2. 样式预处理,比如scss转css

3. PostCSS,接收样式源代码并交由编译插件处理,最后输出css

PostCSS可以结合css-loader使用,也可以单独使用(不配置css-loader也可以达到同样效果);唯一不同时,单独使用postcss-loader时不建议使用CSS中的@import语句,所以还是建议把postcss-loader放在css-loader后面

postcss需要一个单独的配置文件,postcss.config.js

  • 自动前缀

和Autoprefixer结合,为css自动添加厂商前缀

postcss.config.js

const autoprefixer = require('autoprefixer')
module.exports = {
    plugins: [
        autoprefixer({
            grid: true,
            browsers: ['>1%', 'last 3 versions', 'android 4.2', 'ie 8']
        })
    ]
}
  • stylelint

stylelint是一个CSS的质量检测工具,就像eslint一样

比如: postcss.config.js

module.exports = {
    plugins: [
        stylelint({
            config: {
                rules: {
                    // 使用了!important就会给警告
                    'declaration-no-important': true
                }
            }
        })
    ]
}
  • CSSNext

与CSSNext结合可以使用最新的CSS语法特性 postcss.config.js

const postcssCssnext = require('postcss-cssnext')
module.exports = {
    plugins: [
        postcssCssnext({
            browsers: ['>1%', 'last 2 versions']
        })
    ]
}

CSS Modules

让CSS模块化

每个css文件由单独的作用域,不会与外界发生命名冲突

对css进行依赖管理,可以通过相对路径引入css文件

可以通过composes轻松复用其他css模块

只需要开启css-loader中的modules配置项即可

module:{
    rules: [
        {
            test: /\.css/,
            use: [
                'style-loader',
                {
                    loader: 'css-loader',
                    options: {
                        modules: true,
                        // [name]-模块名 [local]-原本选择器标识 [hash: base64:5]-5位hash值
                        localIdentName: '[name]_[local]_[hash:base64:5]'
                    }
                }
            ]
        }
    ]
}

此时js引入css的方式改变,用CSS Module方式CSS文件会导出一个对象,需要将对象属性添加到HTML标签上

代码分片

1. 通过入口划分代码

2. CommonsChunkPlugin

webpack4 之前时内部自带插件,4之后替换为SplitChunks,可以将多个chunk中公共部分提取

公共模块提取好处:

  • 开发过程中减少重复模块打包,可以提升开发速度
  • 减少整体资源体积
  • 合理分片后的代码可以更有效地利用客户端缓存

提取vendor

主要用于多入口之间的各个模块,单入口应用可以用它来提取第三方类库及业务中不常更新的模块,只需要单独为它创建一个入口

提取第三类库及业务中不常更新的模块

设置提取范围

CommonsChunkPlugin中的chunks配置项可以规定从哪些入口中提取公共模块

设置提取规则

minChunks配置:

  1. minChunks可以接受一个数字,当设置minChunks为n时,只有该模块被n个入口同时引用才会进行提取

  2. 设置为Infinity,意味着所有模块都不会被提取。作用一:提取哪些模块是完全可控的;作用二:为了生成一个没有任何模块而仅仅包含你webpack初始化环境的文件,称为manifest

  3. 使用函数可以更细粒度控制公共模块

hash与长缓存

使用该插件提取公共模块时,提取后的资源内部不仅仅是模块代码,往往还包含webpack的运行时(指的是初始化环境的代码,如插件模块缓存对象,声明模块加载函数等)。

解决方案:

将运行时的代码单独提取出来,此时的这个js就应该就先被引入

不足

  1. 一个CommonsChunkPlugin只能提取一个vendor
  2. manifest实际上会使浏览器多加载一个资源,对于页面渲染速度不友好
  3. 在提取公共模块的时候会破坏掉原有的Chunks中模块的依赖关系,导致难以进行更多的优化

optimization.SplitChunks

webpack4为了改进CommonsChunkPlugin而重新设计的

module.exports = {
    mode: 'development',
    optimization: {
        splitChunks: {
        // 意义是将会对所有chunks生效(默认情况下,splitChunks只对异步chunks生效)
            chunks: 'all'
        }
    }
}

    splitChunks: {
    
        // 配置工作模式:async-只提取异步chunk initial-只对入口chunk生效 all-两种模式同时开启
        chunks: 'async',
        
        minSize: {
            javascript: 30000,
            style: 50000
        },
        maxSize: 0,
        minChunks: 1,
        maxAsyncRequests: 5,
        maxInitialRequests: 3,
         // 分隔符
        automaticNameDelimiter: '~',
        // 意味着splitChunks可以根据cacheGroups和作用范围自动为新生成的chunk命名,并以automaticNameDelimiter分隔
        name: true,
        cacheGroups: {
            vendors: {
                test: /[\\/]node_modules[\\/]/,
                priority: -10
            },
            default: {
                minChunks: 2,
                // 优先级
                priority: -20,
                reuseExistingChunk: true
            }
        }
    }

资源异步加载

1. import

跟ES6的import不同,通过import函数加载的模块及其依赖会被异步地进行加载,并返回一个Promise对象

首屏加载的js资源地址时通过页面中的script标签来指定的,而间接资源(通过首屏JS再进一步加载的JS)的位置需要通过output.publicPath来指定

实现原理就是在head标签中动态插入一个script标签

2. 异步chunk配置

chunkFilename —— 指定异步chunk的文件名

生产环境配置

1. 环境配置的封装

不同环境采用不同的配置:

  1. 使用相同的配置文件

image.png

  1. 为不同环境创建各自的配置文件。比如创建webpack.production..config.js 和 webpack.development.config.js 然后修改package.json

image.png

这种方式两个文件会有重复部分,可以抽一个公共的配置,比如单独创建一个webpack.common.config.js

module.exports = {
    entry: './src/index.js'
    // development 和 production共有配置
}

2. 开启production模式

开启production模式,会自动添加许多适用于生产环境的配置项,减少人为手动的工作

3. 环境变量

module.exports = {

    plugins: [
        new webpack.DefinePlugin({
            ENV: JSON.stringify('production')
        })
    ]
}

使用JSON.stringify,是因为DefinePlugin在替换环境变量时对于字符串类型的值进行的是完全替换,如果不加这个,替换后会成为变量名,而非字符串值

process.env.NODE_ENV——区别开发环境和生产环境的变量

process.env——NOde.js用于存放当前进程环境变量的对象;NODE_ENV则可以让开发者指定当前的运行时环境

4. source map

指的是将编译、打包、压缩后的代码映射回源代码的过程。

原理

webpack对于工程源代码的每一步处理都由可能改变便代码的位置、结构、甚至是所处文件,因此每一步都需要生产对应的source map。如果启用了devtool配置项,source map就会随着源代码一步步传递,直到生成最后的map文件(bundle.js.map)

在生成mapping文件的同时,bundle文件中会被追加上一句注释来表示map文件的位置

// bundle.js
(function(){}( // bundle内容 ))()
sourceMappingURL = bundle.js.map

source map配置

module.exports = {
    devtool: 'source-tool',
    module: {
        rules: [
            {
                test: /\.scss$/,
                use: [
                    'style-loader',
                    {
                        loader: 'scss-loader',
                        options: {
                            sourceMap: true
                        }
                    }, {
                        loader: 'scss-loader',
                        options: {
                            sourceMap: true
                        }
                    }
                ]
            }
        ]
    }
}

安全

source map不仅可以帮助开发者调试源码,还有助于查看调用栈信息,但是安全由极大隐患

所以有了hidden-source-map和nosources-source-map两种策略来提升

hidden-source-map:意味着webpack仍然会产出完整的map文件,只不过不会在bundle文件中添加对于map文件的引用。这样打开开发者工具就看不到map文件,此时需要第三方服务比如Sentry,接入后可以进行错误的收集和聚类,以更好地发现和解决线上问题

nosources-source-map:对于安全性的保护没那么强,但是使用当时相对简单。打包部署后,我们可以在浏览器开发者工具的Sources选项卡中看到源码的目录结构,但是文件的具体内容会被隐藏起来。

还有一种方式,我们正常打包出source mao,然后通过服务器nginx设置将.map文件只对固定的白名单开放,而在一般用户的浏览器中就无法获取了

5. 资源压缩

压缩javascript

一般使用两个工具:Uglify(webpack 3已集成)和 terser(webpack 4已集成),后者支持ES6+代码压缩,webpack 4默认使用了terser的插件terser-webpack-plugin

webpack 3:

module.exports = {
    plugins: [new webpack.optimize.UglifyJsPlugin()]
}

webpack 4:

module.exports = {
    optimization: {
        minimize: true
    }
}

terser-webpack-plugin插件支持自定义配置:

  • test —— terser的作用范围
  • include —— 使terser额外对某些文件或目录生效
  • exclude —— 排除某些文件或目录
  • cache —— 是否开启缓存,默认目录为node_modules/.cache/terser-webpack-plugin
  • parallel —— 强烈建议开启,允许使用多个进程进行压缩
  • sourceMap —— 是否生成source map
  • terserOptions —— terser压缩配置,如是否可对变量重命名,是否兼容IE8

压缩CSS

压缩的前提是使用extrac-text-webpack-pluginmini-css-extract-plugin将样式提取出来,接着使用optimize-css-assets-webpack-plugin来进行压缩,该插件本质是使用压缩器cssnano

module.exports = {
    optimization: {
        minimizer: [new OptimizeCSSAssetsPlugin({
            // 生效范围,只压缩匹配到的资源
            assetNameRegExp: /\.optimize\.css$/g,
            // 压缩处理器
            cssProcessor: require('cssnano'),
            // 压缩处理器的配置
            cssProcessorOptions: {discardComments: {removeAll: true}},
            // 是否展示log
            canPrint: true
        })]
    }

}

6. 缓存

合理地使用缓存是提升客户端性能的一个关键因素

  • 资源hash

每次打包的过程中对资源计算一次hash,并作为版本号存放在文件名中

  • 输出动态THTML

html-webpack-plugin会自动地将我们打爆出来的资源名放入生成的index.html中,这样就不必手动更新URL了

  • 使chunk id更稳定

webpack 3使用CommonsChunkPlugin时注意vendor chunk hash变动的问题,它可能影响缓存的正常使用(新插入模块时,会使不应该变化的模块id也发生变化,因为webpack为每个模块指定的id是按数字递增的)

解决方案: 更改模块id的生成方式,使用webpack 3自带的HashedModuleldsPlugin,它可以为每个模块按照其所在路径生成一个字符串类型的hash id

7. bundle体积监控和分析

VS Code有一个插件Import Cost可以帮助我们对引入模块的大小进行实时检测

webpack-bundle-analyzer:帮助我们分析一个bundle的构成

module.exports = {
    plugins: [
        new Analyzer()
    ]
}

生成bundle的模块组成结构图,每个模块所占的体积一目了然

bundlesize:这个工具包可以自动化地对资源体积进行监控

package.json

{
    "bundlesize": [
        "path": "./bundle.js",
        "maxSize": "50 KB"
    ],
    "scripts": {
        "test:size": "bundlesize"
    }
}

通过npm脚本可以执行bundlesize命令,它会根据我们配置的资源路径和最大体积验证最终的bundle是否超限。也可以将其作为自动化测试一部分,来保证输出的资源如果超限了不会在不知情的情况下就被发布出去

打包优化

1. HappyPack

通过多线程来提升webpack打包速度的工具

工作原理:

webpack是单线程的,假设一个模块依赖几个其他模块,webpack必须对这些模块逐个进行转译,串行执行。

HappyPack可以开启多个线程,并行对不同模块进行转译,这样就可以充分利用本地的计算资源来提升打包速度

单个loader的优化:

const HappyPack = require('happypack')

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'happpack/loader'
            }
        ]
    },
    plugins: [
        new HappyPack({
            loaders: [
                {
                    loader: 'babel-loader',
                    options: {
                        presets: ['react']
                    }
                }
            ]
        })
    ]

}

使用happypack/loader替换了原有的babel-loader,并在plugins中添加了HappyPack的插件,将原有的babel-loader连同配置插入

多个loader的优化:

需要id作为区分

const HappyPack = require('happypack')

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'happpack/loader?id=js'
            },
            {
                test: /\.ts$/,
                exclude: /node_modules/,
                loader: 'happpack/loader?id=ts'
            }
        ]
    },
    plugins: [
        new HappyPack({
            id: 'js',
            loaders: [{
                loader: 'babel-loader',
                options: {}
            }]
        }),
       new HappyPack({
            id: 'ts',
            loaders: [{
                loader: 'ts-loader',
                options: {}
            }]
        }),
    ]

}

意味着插入多个HappyPack的插件

2. 缩小打包作用域

从宏观角度,提升性能的方法有两种:增加资源或缩小范围;

增加资源——使用更多cpu和内存,用更多的计算能力来缩短执行任务的时间

缩小范围——针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等

exclude和include

exclude优先级更高

noParse

比如有些库我们不想要webpack去进行解析,而且库的内部也不会有对其他模块的依赖,此时可以进行忽略

module.exports = {
    module: {
        // 此时会忽略所有文件名中包含lodash的模块,这些模块会被打包进资源文件,但是webpack不会对其进行任何解析
        noParse: /lodash/
        
        // webpack 3之后支持完整的路径匹配
        noParse: function(fpath){
        // fpath是绝对路径,此时会忽略lib目录下的资源解析
        return /lib/.test(fpath)
        }
    }
}

IgnorePlugin

它可以做到完全排除一些模块,被排除的模块即便被引用也不会被打包进资源文件中

比如moment.js为了做本地化会加载很多语言包,如果用不到其他地区的语言包,就可以这样去掉

plugins: [
    new webpack.IgnorePlugin({
        // 匹配资源文件
        resourceRegExp: /^\.\/locale$/,
        // 匹配检索目录
        contextRegExp:  /moment$/
    })
]

Cache

有些loader会有一个cache配置项,用来编译代码后同时保存一份缓存,在执行下一次编译前会检查源码文件是否改变,没有就直接采用缓存

webpack 5 有一个新都配置项cache: {type: "filesystem"} 会在全局启用一个文件缓存

3. 动态链接库和DllPlugin

动态链接库是早期受限于计算机内存空间较小的问题而出现的一种内存优化方法。当一段相同的子程序被多个程序调用时,为了减少内存消耗,可以将这段子程序存储为一个可执行文件,当被多个程序调用时只在内存中生成和使用同一个实例

DllPlugin借鉴了这种思路,对于第三方模块或者一些补偿变化的模块,可以预先编译和打包,然后在项目实际构建过程中直接取用即可。Dll取这个名字时由于方法类似而已。打包vendor的时候还会附加生成一份vendoe的模块清单,起到链接和索引的作用

DllPlugin和Code Splitting区别:

二者有点类似,都是用来提取公共模块。Code Splitting设置一些规则并在打包的过程中根据规则提取模块;DllPlugin将vendor完全拆出来,有自己的一整套webpack配置并独立打包,在实际工程构建时就不用对它进行任何处理,直接取用即可

理论上说,DllPlugin会比Code Splitting打包速度更快一点,但是相应地增加了配置,以及资源管理的复杂度

vendor配置

需要单独创建一个Webpack配置文件

webpack.vendor.config.js

const path = require('path')
const webpack = require('webpack')
const dllAssertPath = path.join(__dirname, 'dll')
const dllLibrary = 'dllName'

module.exports = {
    // 配置哪些模块打包为vendor
    entry: ['react'],
    output: {
        path: dllAssertPath,
        filename: 'vendor.js',
        library: dllLibrary
    },
    plugins: [
     new webpack.DllPlugin({
         // 导出的dll library的名字,需要与output.library对应
         name: dllLibrary,
         // 资源清单的绝对路径,业务代码打包时将会使用这个清单进行模块索引
         path: path.join(dllAssertPath, 'manifest.json')
     })
    ]
}

vendor打包

package.json

"scripts": {
    "dll": "webpack __config webpack.vendor.config.js"

}

运行npm run dll会生成一个dll目录,里面有两个文件vendor.jsmanifest.json,前者包含库的代码。后者是资源清单

链接到业务代码

使用DllPlugin配套的插件DllReferencePlugin,起到一个索引和链接的作用

通过DllReferencePlugin来获取刚刚打包好的资源清单,然后再页面中添加vendor.js的引用就行

// webpack.config.js

module.exports = {
    plugins: [
        new webpack.DllReferencePlugin({
            manifest: require(path.join(__dirname, 'dll/manifest.json'))
        })
    ]

}

// index.html

<body>
    <script src="dll/vendor.js"></script>
</body>

当页面执行到vendor.js时,会声明dllName全局变量,而manifest相当于注入app.js的资源地图,app.js会通过name字段找到名为dllName的library,再进一步获取内部模块

如果页面报“变量dllExample不存在”的错误,可能就是没有指定正确的output.library或者忘记在业务代码前加载vendor.js

潜在问题

就是之前类似的问题,不应该受到影响的模块却改变了她们的id

解决方案: 打包vendor的时候,加上HashedModuleldsPlugin。将id的生成算法改为模块引用路径生成一个字符串hash

4. Tree shaking

如果遇到永远无法被执行到的死代码,就会进行标记。并在资源压缩时将它们从最终的bundle中去掉(开发模式存在,生产环境压缩那步被移掉)

实现tree-shaking前提

  1. ES6 Module

  2. 使用webpack进行依赖关系构建

如果使用了babel-loader,就一定要通过配置禁用它的模块化依赖解析

因为使用babel-loader来做依赖解析,webpack收到的就都是CommonJS形式的模块,无法tree-shaking

禁用babel-loader配置如下:

module.exports = {
    rules: [{
        test: /\.js$/,
        exclude: /node_modules/,
        use: [{
            loader: 'babel-loader',
            options: {
                presets: [
                // 一定加上modules:false
                    [@babel/preset-env, {modules: false}]
                ]
            }
        }]
    }]
}
  1. 使用压缩工具去除死代码

tree shaking只是给死代码做标记,真正去除死代码使用terser-webpack-plugin 或者webpack 4 将mode设置为production也可以

开发环境调优

webpack 开发效率插件

1. webpack-dashboard

可以更加直观展示打包相关信息

首先npm install webpack-dashboard

const DashboardPlugin = require('webpack-dashboard/plugin')
module.exports = {
    plugins: [
    new DashboardPlugin()
    ]
}

还需要更改webpack的启动方式

//package.json

"scripts": {
    "dev": "webpack-dashboard -- webpack-dev-server"
}

2. webpack-merge

如果需要对某一个环境配置进行规则修改,需要使用Object.assign,但是却没法准确找到CSS规则并进行替换,所以需要替换掉整个module的配置

使用webpack.smart替换了Object.assign,看在合并module.rules的过程中以test属性作为表示符,当发现有相同项出现的时候会以后面的规则覆盖前面的规则,就不用写冗余代码了

const merge = require('webpack-merge')
module.exports = merge.smart(common, {
    mode: 'production',
    module: {
        rules: [
            {
                test: /\.css$/,
                use: // 省略
            }
        ]
    }
})

3. speed-measure-webpack-plugin

SMP可以分析webpack整个打包过程中在各个loader和plugin说耗费的时间,有助于找出构建过程中的性能瓶颈

首先安装插件,使用wrap方法包裹在webpack的配置对象外面

const SMP = require('speed-measure-webpack-plugin')
const smp = new SMP()
module.exports = smp.smart({
    entry: './app.js'
})

4. size-plugin

可以帮助监控资源体积的变化,尽早发现问题

配置直接在plugins里面引入

模块热替换

保留页面当前状态的前提下呈现最新的改动,可以节省开发者的时间成本

1. 开启HMR

开启HMR需要一些必要条件:

1, 确保项目基于webpack-dev-server或者webpack-dev-middle进行开发的

const webpack = require('webpack')
module.exports = {
    plugins: [
        new webpack.HotModuleReplacementPlugin()
    ],
    devServer: {
        hot: true
    }
}

以上配置webpack会为每个模块绑定一个module.hot对象,这个对象包含HMR的API;借助API可以实现对特定模块开启或关闭HMR,也可以添加热更新之外的逻辑

调用HMR API方式:

  1. 手动添加这部分代码
  2. 借助现成工具,比如react-hot-loader,vue-loader

image.png

2. HMR原理

在开启HMR的状态下进行开发,资源的体积会比之前大很多,是因为webpack为实现HMR注入了相关代码

HMR的核心是客户端从服务端拉取更新后的资源(HMR拉取的不是整个资源文件,而是chunk diff,即chunk需要更新的部分)

  1. 获取拉取资源的时机 —— webpack-dev-server(WDS)与浏览器之间维护了一个websocket,当本地资源发生变化时WDS会向浏览器推送更新时间,并带上此次构建的hash,让客户端与上一次资源进行对比(通过hash对比可以防止冗余更新的出现)也解释了为什么打开多个本地页面,代码一改所有页面都会更新,live reload也是依赖这个实现的

  2. 要知道拉取什么 —— 现在客户端知道有了差别,就会像WDS发起一个请求来获取更改文件的列表。通常这个请求名为[hash].hot-update.json。客户端在借助这些信息继续向WDS获取该chunk的增量更新

  3. 客户端获取到更新之后,这之后不属于webpack的工,但是可以使用它的相关API

3. HMR API

module.hot.decline() —— 禁止使用HMR更新 module.hot.accept() ——启用更新

更多的打包工具

Rollup

webpack —— 优势在于更全面,基于“一切皆模块”的思想

Rollup —— 专注于js的打包

rollup有一项webpack不具备的特性,即通过配置output.format开发者可以选择输出资源的模块形式

Parcel

打包速度比webpack快,主要做了以下几件事:

  • 利用worker来并行执行任务
  • 文件系统缓存
  • 资源编译处理流程优化

另一特性:零配置

利用html作为项目入口,从html再进一步寻找其依赖的资源,并且自动为其生成hash版本及source map,而且内容也是压缩过的

WebAssembly

性能可以媲美原生

————来自巨人的肩膀:《Webpack实战:入门、进阶与调优》