Webpack 打包

190 阅读6分钟

历史问题

  • ES Modules 存在环境兼容问题
  • 模块文件过多,网络请求频繁
  • 所有的前端资源都需要模块化

毋庸置疑,模块化是必要的

提出设想

c3g5WR.png c32EkQ.png

目标

  • 新特性代码编译
  • 模块化 javascript 打包
  • 支持不同类型的资源模块

打包工具

打包工具解决的是前端 整体的模块化,并不单指 javascript 模块化

  • Webpack 模块打包器 (Module bundler)
    • 根据模块加载器(Loader)-- 有环境兼容性问题的代码就可以在打包过程中通过 Loader 进行编译转换

    • 代码拆分(Code Splitting)-- 它能够将应用当中所有的代码都按照我们的需要去打包,就能避免打包到一起,产生的文件就会很大的问题

    • 资源模块(Asset Module)-- 以模块化的方式去载入什么问题类型的资源文件

Webpack

1. Webpack 快速上手

注意 使用 webpack ^4.40.2, webpack-cli ^3.3.9

  • yarn add webpack webpack-cli
  • package.json 中增加 "build": "webpack"

2. Webpack 配置文件

const path = require('path')
module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.join(__dirname, 'output')
    }
}

3. 工作模式

yarn webpack --mode none

4. Webpack 打包结果运行原理

c3vVNd.png c3xma4.png

5. 资源模块加载

  • LoaderWebpack 的核心特性,借助于 Loader 就可以加载任何类型的资源 c8Fn8e.png

  • 配置 Loader

// webpack.config.js

module: {
    // 针对其它资源模块的加载规则的配置
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ] // 当是数组时,优先执行后面的
      }
    ]
  }

6. 导入资源模块

  • JavaScript 驱动整个前端应用的业务 c8FLxH.png
// 在main.js 当中 
import './main.css'
  • Webpack 建议我们根据代码的需要动态导入资源

7. 文件资源加载器

  • file-loader

注意配置网站的根目录

原理 c8EvL9.png

8. URL 加载器

  • Data URLs 是一种当前 url 就可以直接去表示文件内容的方式,这种 url 当中的文本就已经包含了文件的内容,那我们在使用这种 url 的时候就不会发送任何的 http 请求

c8ecPx.png

  • 加载器 url-loader
    • 小文件使用 Data URLs,减少请求次数
    • 大文件单独提取存放,提高加载速度
{
    test:/\.png$/,
    use: {
        loader: 'url-laoder',
        options: {
            limit: 10 * 1024 // 10KB  只会对10KB 以下的文件进行转换
        }
    }
}

9. 常用加载器

  • 编译转换类
    • eg: css-loader
  • 文件操作类
    • eg: file-loader
  • 代码检查类
    • eg: eslint-loader

10. Webpack 与 ES 2015

  • 安装好babel相关 yarn add babel-=loader @babel/core @babel/preset-env -D
// rules
{
    test: /\.js$/,
    use: {
        loader: 'babel-loader',
        options: [
            presets: ["@babel/preset-env"]
        ]
    }
}

Webpack 只是打包工具 加载器可以用来编译转换代码

11. Webpack 加载资源的方式

  • 遵循 ES Modules 标准的 import 声明
  • 遵循 CommonJS 标准的 require 函数
  • 遵循 AMD 标准的 define 函数和 require 函数
  • 样式代码中的 @import 指令和 url 函数
  • HTML 代码中图片标签的 src 属性

注意

  • html中的 src 属性会触发打包,若其它属性引用文件也想触发,则需要如下配置
// rules
{
    test: /\.html$/,
    use: {
        loader: 'html-loader',
        options: {
            attrs: ['img:src', 'a:href']
        }
    }
}

12. Webpack 核心工作原理

  • Loader 机制是 Webpack 的核心

c8lzct.png

13. 开发一个 Loader

c81y8A.png

// my-markdown-loader.js
const marked = require('marked')
module.exports = source => {
    const html = marked(source)
    // 以下两种都可以
    // A
    return `module.exports = ${JSON.stringify(html)}`
    return `module.exports = ${JSON.stringify(html)}`
    
    // B 若使用以下情况,则还需要一个 html-loader 来继续处理结果
    return html
}
// B的情况
// rules
{
    test: /\.md$/,
    use: [
        'html-loader',
        './my-markdown-loader'
    ]
}

14. 插件机制

增强 Webpack 自动化能力

Loader 专注实现资源模块加载

Plugin 解决其他自动化工作

  • eg: 清除 dist 目录
  • eg: 拷贝静态文件至输出目录
  • eg: 压缩输出代码

14.2 自动清除输出目录插件

  • 使用 clean-webpack-plugin 插件
yarn add clean-webpack-plugin -D

使用:

const { CleanWebpackPlugin } = require('clean-webpack-plugin');

// webpack.config.js
plugins: [
    new CleanWebpackPlugin()
]

14.3 自动生成HTML插件

  • html-webpack-plugin
const HtmlWebpackPlugin = require('html-webpack-plugin')

plugins: [
    // HtmlWebpackPlugin可以配置参数 
    // https://www.npmjs.com/package/html-webpack-plugin
    new HtmlWebpackPlugin({
        title: 'webpacl plugin sample',
        meta: {
            viewport: 'width-device-width'
        },
        template: './src/index.html'
    })
]
// 会自动生成html到dist

  • 最好的方式是在源代码当中配置模板
<!-- HtmlWebpackPlugin要配置template的路径 -->
<h1><%= htmlWebpackPlugin.options.title %></h1>
  • 同时输出多个页面文件
plugins: [
    new HtmlWebpackPlugin({
        title: 'webpacl plugin sample',
        meta: {
            viewport: 'width-device-width'
        },
        template: './src/index.html'
    }),
    // 用于生成 about.html
    new HtmlWebpackPlugin({
        filename: 'about.html'
    })
]

14.4 静态文件使用复制

  • copy-webpack-plugin
const CopyWebpackPlugin = require('copy-webpack-plugin')
plugins: [
    new CopyWebpackPlugin([
        'public'
    ])
]

15. 开发一个插件

class MyPlugin {
  apply(compiler) {
    // 找到 emit 钩子(生成资源到 output 目录之前
    compiler.hooks.emit.tap('MyPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (let name in compilation.assets) {
        const contents = compilation.assets[name].source()
        const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
        compilation.assets[name] = {
          source: () => withoutComments,
          size: () => withoutComments.length
        }

      }
    })
  }
}

Webpack 处理开发场景

设想:理想的开发环境

  • idea 1. 以 HTTP Server 运行
  • idea 2. 自动编译 + 自动刷新
  • idea 3. 提供 Source Map 支持

1. 自动编译

  • watch 工作模式,监听文件变化 ,自动重新打包
    • yarn webpack --watch

2. 自动刷新浏览器

  • 希望编译过后自动刷新浏览器
    • BrowserSync

操作上麻烦了,而且效率也变低了

3. Webpack Dev Server

  • 提供用于开发的 HTTP Server
    • 集成 自动编译自动刷新浏览器 等功能
yarn add webpack-dev-server -D

# 打包结果并不会写入到磁盘当中, 存在内存当中
yarn webpack-dev-server

3.2 Dev Server 默认只会 serve 打包输出文件

  • 只要是 Webpack 输出的文件,都可以直接被访问,如果其它静态资源就你说的也需要 serve
// webpack.config.js
{
    devServer: {
        // 额外为开发服务器指定查找资源目录
        contentBase: './public'
    }
}

3.3 Dev Server 代理 API

同源部署没必要开启 CORS,则不允许跨域

问题:开发阶段接口跨域问题

cJTDF1.png

module.exports = {
  devServer: {
    proxy: {
      '/api': {
        // http://localhost: 8080/api/users -> https://api.github.com/api/users
        target: 'https://api.github.com',
        // http://localhost: 8080/api/users -> https://api.github.com/users
          pathRewrite: {
            '^/api': ''
          },
        // 不能使用 localhost: 8080 作为请求 Github 的主机名
        changeOrigin: true
      }
    }
  }
}

4. Source Map (源代码地图)

运行代码与源代码之间完全不同,如果需要调试应用,错误信息无法定位,调试和报错都是基于运行代码

cJO9L8.png

  • 举例 jquery-3.4.1.min.js
  • 如果想使用 Source Map 需要在文件末尾添加
//# sourceMappingURL-jquery-3.4.1.min.map

4.2 Webpac 配置 Source Map

  • WebpackSource Map 支持很多种,每种方式的效率和效果各不相同
// webpack.config.js
module.exports = {
  devtool: 'source-map',
}

支持表 (也可以参考官网 webpack.js.org/configurati… )

cJzfiD.png

eval 模式

eval('console.log(123) //# sourceURL=./foo/bar.js')
// 意味着我们可以通过 sourceURL改变我们通过 eval 执行的这段代码

devtool 模式对比

  • eval - 是否使用 eval 执行模块代码
  • cheap - Source Map 是否包含行信息
  • module - 是否能够得到 Loader 处理之前的源代码
  • 如何选择(建议)
    • 开发模式 - cheap-module-eval-source-map
      • 代码每行不会超过80个字符
      • 代码经过 Loader 转换过后的差异较大
      • 首次打包速度慢无所谓,重新打包相对较快
    • 生产模式 - none
      • Source Map 会暴露源代码
      • 调试是开发阶段的事情
      • 如果对代码没信心 - 建议 nosources-source-map 理解不同模式的差异,适配不同的环境和场景
const HtmlWebpackPlugin = require('html-webpack-plugin')

const allModes = [
  'eval',
  'cheap-eval-source-map',
  'cheap-module-eval-source-map',
  'eval-source-map',
  'cheap-source-map',
  'cheap-module-source-map',
  'inline-cheap-source-map',
  'inline-cheap-module-source-map',
  'source-map',
  'inline-source-map',
  'hidden-source-map',
  'nosources-source-map'
]

module.exports = allModes.map(item => {
  return {
    devtool: item,
    mode: 'none',
    entry: './src/main.js',
    output: {
      filename: `js/${item}.js`
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env']
            }
          }
        }
      ]
    },
    plugins: [
      new HtmlWebpackPlugin({
        filename: `${item}.html`
      })
    ]
  }
})

5. 自动刷新的问题

页面不刷新的前提下,模块也可以及时更新

5.2 HMR - 模块热替换 (Hot Module Replacement)

  • 热拔插 - 在一个正在运行的机器上随时插拔设备
  • Webpack 中的热替换是在应用运行过程中实时替换某个模块,应用运行状态不受影响
  • HMRWebpack 中最强大的功能之一 - 极大程度的提高了开发者的工作效率

5.3 开启 HMR

  • HMR 集成在 webpack-dev-server
    • 运行 webpack-dev-server --hot
    • 也可以通过配置文件开启
    const webpack = require('webpack')
    
    plugins: [
        new webpack.HotModuleReplacementPlugin()
    ]
    // 直接运行 
    // yarn webpack-dev-server
    
  • Webpack 中的 HMR 并不可以开箱即用
  • 需要手动处理模块热替换逻辑

为什么样式文件的热更新可以开箱即用?

因为样式文件是有 Loader 处理的 在 style-loader 当中就已经处理样式文件的热更新

样式文件简单,修改之后只需要替换到浏览器当中就行

通过脚手架创建的项目内部都集成了 HMR 方案

总结:我们需要手动处理 JS 模块更新后的热替换

5.4 HMR APIs

手动处理模块更新后的热替换

  • 处理 JS 模块热替换 和 处理 图片 模块热替换
// main.js  
import createEditor from './editor'
import background from './better.png'
import './global.css'

const editor = createEditor()
document.body.appendChild(editor)

const img = new Image()
img.src = background
document.body.appendChild(img)

// ============ 以下用于处理 HMR,与业务代码无关 ============

// console.log(createEditor)

if (module.hot) {
  let lastEditor = editor
  module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理热替换逻辑')
    // console.log(createEditor)

    const value = lastEditor.innerHTML
    document.body.removeChild(lastEditor)
    const newEditor = createEditor()
    newEditor.innerHTML = value
    document.body.appendChild(newEditor)
    lastEditor = newEditor
  })

  module.hot.accept('./better.png', () => {
    img.src = background
    console.log(background)
  })
}

5.5 HMR 注意事项

  • 处理 HMR 的代码报错会导致自动刷新
  • 没启用 HMR 的情况下,HMR API 报错
    • 在devServer: { hot: true, hotOnly: true } 如果这两个没有开启,则 module.hot 不存在

Webpack 生产环境优化

  • 开发环境注重开发效率
  • 生产环境注重运行效率

为不同的工作环境创建不同的配置

1. 不同环境下的配置

  • 配置文件根据环境不同导出不同的配置
  • 一个环境对应一个配置文件

1.2 Webpack 的配置文件支持导出一个函数

module.exports = (env, argv) => {
    if (env == 'production') {
        // ...
    }
}

2. 不同环境使用配置文件

  • webpack.common.js
  • webpack.dev.js
  • webpack.prod.js

配置的时候需要与 common 里面的信息做merge,社区提供了更好的merge工具 webpack-merge

yarn webpack --config webpack.prod.js

3. 优化配置

3.1 DefinePlugin

为代码注入全局成员

  • 在 production 环境下,默认会启动起来并注入一个 process.env.NODE_ENV 常量
const webpack = require('webpack')
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.DefinePlugin({
      // 值要求的是一个代码片段
      // 如果是 'https://api.example.com'
      // 在使用的地方 会这样 console.log(API_BASE_URL) ==> console.log(https://api.example.com)
      API_BASE_URL: JSON.stringify('https://api.example.com')
    })
  ]
}

3.2 Tree Snaking (摇树)

"摇掉"代码中未引用部分(未引用代码 - dead-code)

  • 会在生产模式下自动开启

  • Tree Shaking 不是指某个配置选项, 是一组功能搭配使用后的优化效果

  • optimization 集中配置 Webpack 内部的优化功能的

    {
      optimization: {
        // 模块只导出被使用的成员
        usedExports: true,
        // 压缩输出结果
        // minimize: true
        }
    }
    // 使用 usedExports: true 和 minimize: true 就类似于 Tree Shaking的功能
    // usedExports 负责标记 "枯树叶 "
    // minimize 负责 "摇掉" 它们
    
    • 合并模块函数 Scope Hoisting
      • 尽可能的将所有模块合并输出到一个函数中 - 既提升了运行效率,又减少了代码的体积 (这个特性又被称之为 `Scope Hoisting - 作用域提升)
    optimization: {
        // 模块只导出被使用的成员
        usedExports: true,
        // 尽可能合并每一个模块到一个函数中
        concatenateModules: true,
    }
    

3.3 Tree Shaking 与 Babel

前景:很多资料查阅出来显示当我们使用了 babel Tree Shaking 就会失效,这里我们统一来说明一下。

  • Tree Shaking 使用前提必须 ES Modules
    • Webpack 打包的代码必须使用 ESM
    • Webpack 是打包模块之前根据配置将模块交给不同的 Loader 处理,最后再将处理结果打包到一起
      • 那么为了转换代码中的 ESMAScript 特性,我们使用 babel-loader 来处理
      • babel-loader 在处理代码时就有可能将 ES Modules 转换成 CommonJS
  • 探索过程
module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              // 如果 Babel 加载模块时已经转换了 ESM,则会导致 Tree Shaking 失效
              // ['@babel/preset-env', { modules: 'commonjs' }]
              // ['@babel/preset-env', { modules: false }]
              // 也可以使用默认配置,也就是 auto,这样 babel-loader 会自动关闭 ESM 转换
              ['@babel/preset-env', { modules: 'auto' }]
            ]
          }
        }
      }
    ]
  },
  optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    // concatenateModules: true,
    // 压缩输出结果
    // minimize: true
  }
}

    1. babel-loader 当中明确显示支持 ESModules cNj1XR.png
    1. 找到我们使用的 preset-env cNvaV0.png

4. sideEffects 副作用

其实 sideEffects 和 Tree Shaking 没啥关系

  • 副作用:模块执行时除了导出成员之外所作的事情
  • sideEffects 一般用于 npm 包标记是否有副作用
// webpack.config.js

module.exports = {
  mode: 'none',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  optimization: {
    sideEffects: true
    // 开启后 webpack 在打包时会检查当前代码所属的这个package.json当中有没有 
    // sideEffects 标识,以此来判断这个是否有副作用,
    // 那如果说没有 副作用,那这些没有用到的模块就不会再打包
  }
}

// package.json
{
  "sideEffects": false  // 我们当前这个package.json所影响的这个项目它当中所有的代码都没有副作用 - 没有副作用,就会被移除掉
}

4.2 sideEffects 注意

确保你的代码真的没有副作用

  • 举例
// 为 Number 的原型添加一个扩展方法 
// extend.js
Number.prototype.pad = function(size) {
  // 将数字转为字符串 => '8'
  let result = this + ''
  // 在数字前补指定个数的0 => '008'
  while(result.length < size) {
      result = '0' + result
  }
  return result
}
// 以上就是一段副作用代码 

// 在 main.js当中
import 'extend.js'

console.log((8).pad(3))

如果使用方式如上:在将副作用配置当中设置为没有副作用,则在打包后 extend.js 当中的代码不会被打包进去

比如:全局的css模块,就属于副作用,若设置为没有副作用,则打包也会忽略它

所以,在配置时可以告诉webpack哪些是有副作用的

// package.json
{
  // "sideEffects": false
  "sideEffects": [
    "./src/extend.js",
    "./src/global.css"
  ]
}

5. Code Splitting (代码分包 / 代码分割)

背景:因为所有代码最终都被打包到一起, bundle体积过大

  • 并不是每个模块在启动时都是必要的
  • 分包,按需加载

现在主流的HTTP1.1的版本本身就有很多的缺陷 - eg: 同域并行请求限制

不打包问题,资源文件太多:

  • 每次请求都会有一定的延迟
  • 请求的 Header 浪费带宽流量

所以模块打包肯定是有必要的

webpack 实现分包的方式主要有2种
  • 多入口打包
  • 动态导入

5.2 多入口打包 (Multi Entry)

  • 多页应用程序 - 一个页面对应一个打包入口
// webpack.config.js
entry: {
  index: "./src/index.js",
  album: "./src/album.js"
},
output: {
    filename: "[name].bundle.js"
},
plugins: [
  new HtmlWebpackPlugin({
    title: 'Multi Entry',
    template: './src/index.html',
    filename: 'index.html',
    // 配置输出的html引用的bundle, 可以使用 chunks
    chunks: ['index']
  }),
  new HtmlWebpackPlugin({
    title: 'Multi Entry',
    template: './src/album.html',
    filename: 'album.html',
    chunks: ['album']
  })
]

5.3提取公共模块

{
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
}

5.4 动态导入

  • 按需加载 - 指需要用到某个模块时,再加载这个模块
  • 动态导入的模块会被自动分包
// import posts from './posts/posts'
// import album from './album/album'

const render = () => {
  const hash = window.location.hash || '#posts'

  const mainElement = document.querySelector('.main')

  mainElement.innerHTML = ''

  if (hash === '#posts') {
    // mainElement.appendChild(posts())
    import(/* webpackChunkName: 'posts' */'./posts/posts').then(({ default: posts }) => {
      mainElement.appendChild(posts())
    })
  } else if (hash === '#album') {
    // mainElement.appendChild(album())
    import(/* webpackChunkName: 'album' */'./album/album').then(({ default: album }) => {
      mainElement.appendChild(album())
    })
  }
}

render()

window.addEventListener('hashchange', render)

5.5 魔法注释 (Magic Comments)

  • /* webpackChunkName: '(名称)' */
import(/* webpackChunkName: 'components' */'./posts/posts').then(({ default: posts }) => {
  mainElement.appendChild(posts())
})

6. MiniCssExtractPlugin

提取 css 到单个文件,通过这个插件可以实现CSS模块的按需加载

  • 如果样式体积不是很大,提成单个文件可能会适得其反
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', // 将样式通过 style 标签注入
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      }
    ]
  },
plugins: [
  new MiniCssExtractPlugin()
]

7. OptimizeCssAssetsWebpackPlugin

压缩输出的 CSS 文件

  • Webpack 内置的压缩是针对 js 文件来说
  • 对于 CSS 使用 optimize-css-assets-webpack-plugin
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
plugins: [
    new OptimizeCssAssetsWebpackPlugin()
  ]

注意: Webpack 建议像这种压缩类的插件应该配置 在 数组当中,以便于miner 这个选项去统一控制

optimization: {
    minimizer: [ // webpack 认为,只要配置了这个数组,就认为我们要自定义配置压缩插件
        new OptimizeCssAssetsWebpackPlugin() // 只配置这个 原本正常压缩的 js 文件又不压缩了
    ]
}
// 需要将内置的js插件设置回来
// yarn add terser-webpack-plugin -D

const TerserWebpackPlugin = require('terser-webpack-plugin')
optimization: {
    minimizer: [
        new OptimizeCssAssetsWebpackPlugin(),
        new TerserWebpackPlugin()
    ]
}

8. 输出文件名 Hash (substitutions)

生产模式下,文件名使用 Hash

  • 使用方式: filename: '[name].[hash].bundle.js'
  • hash: 每次构建会生成一个 hash。和整个项目有关,只要有项目文件更改,就会改变 hash
  • chunkhash:和 webpack 打包生成的 chunk 相关。每一个 entry,都会有不同的 hash
  • contenthash:和单个文件的内容相关。指定文件的内容发生改变,就会改变 hash

hash可以指定长度 '[contenthash:8]'

如果是控制缓存的话,个人认为 contenthash:8 是最好的选择了