Webpack5.x使用、原理及思考

webpack 解决了什么问题?

先从前端模块化开始谈起:

最早期,前端项目很简单,一个 html,引入 css 和一些 js 文件即可,随着项目越来越复杂,引入的 js 就增多,这个时候就出现很多问题:

  1. 模块直接在全局工作,污染全局作用域;
  2. 模块增多,容易产生命名冲突;
  3. 没有私有空间,所有模块内的成员都可以在模块外部被访问或修改;
  4. 难以管理模块与模块之间的依赖关系;
  5. 在维护过程中很难分辨每个成员所属的模块;

后来通过命名空间的方式,即每个模块暴露一个对象,模块内的变量、方法全部挂载在该对象上,这种方式解决了命名冲突和作用域污染的问题,但是其他问仍然存在。

再后来通过 IIFE(匿名立即执行函数),解决命名冲突和私有空间的问题,并给 IIFE 传入参数,使得模块之间的依赖变得清晰。但是仍然存在问题,最严重的是模块的加载,因为模块都是通过 script 的方式引入 html 中,比如删除、新增模块,模块之间的依赖关系的维护就变得很难。

以 Node 所遵循的 Common.js 模块化规范出现,一个文件就是一个模块,模块之间通过 module.export 和 require 的方式导出和加载模块,但是这种方式不适用于浏览器。原因是 Common.js 是以同步的方式加载模块,而 Node 的执行机制是启动时加载模块,执行过程只是使用模块。但在浏览器端同步加载模块,就会有大量的同步请求,使得执行效率低下。

于是社区推出了 AMD 模块化标准,对应的库 Require.js,通过 define 定义模块,require 加载模块。同时期淘宝基于 Common.js 推出 sea.js。这些方式仍然存在问题,首先使用相对复杂,其次当项目中模块划分过于细致时,就会出现同一个页面对 js 文件的请求次数过多的情况,导致效率底下。

ES6 推出 ES Module 后,前端模块化实现统一,浏览器遵循 ESM 规范,Node 遵循 Common.js 规范。现在最新的Node版本也可以使用ES6模块,文件扩展名为.mjs而不是.js,或者package.json的type字段设置成 module。

CommonJS 属于内置模块系统,所以在 Node 环境中使用不存在环境支持问题。

ESM 不仅存在浏览器兼容性问题,而且文件划分过多后,又会增加网络请求的次数,并且随着前端项目越来越复杂,html、css、图片等静态资源也面临模块化问题,从宏观来看,这些都属于前端模块。

所以这个时候需要一种方案或工具能同时解决以下几个问题:

  1. 具备编译能力。即将下一代 js 编译成大部分浏览器兼容的代码
  2. 将一个个的模块打包在一起,减少网络请求。因为模块化是为了解决开发阶段的更好的组织管理代码结构,实际运行阶段就没必要
  3. 支持不同类型的模块,将 css、img、字体等全部作为模块管理和维护

Grunt、Gulp 等可以实现第一和第二,但很难解决第三。而 webpack 以“一切皆模块”的思想以及强大的周边成为成为前端项目打包神器之一。

就扯这么多,简单总结下将项目从 webpack3.x 升级 webpack5.x 中踩过的坑和 webpack 的核心特性以,以及学习及实践后的一些思考。

webpack

webpack 就是一个函数,它会将配置对象传入该函数,返回一个编译上下文compiler,然后调用compilerrun方法进行打包构建。

const webpack = require('webpack')
const configuration = require('./webpack.config.js')
const HtmlWebpackPlugin = require('html-webpack-plugin')

const compiler = webpack(configuration)

// 初始化插件
new HtmlWebpackPlugin().apply(compiler) // 该插件就会装载到compiler相应的钩子上

// 执行
compiler.run(function (err, stats) {
  // ...
})

安装

安装 webpack 和 webpack-cli。webpack 是 Webppack 的核心文件,webpack-cli 是 Webpack 的命令行工具。

安装完成后,webpack-cli 所提供的 CLI 程序就会出现在 node_modules/.bin 目录当中,可以通过 npx 快速找到 CLI 并运行它,具体操作如下:

npx webpack

npx

npx 是 npm 5.2 以后新增的一个命令,可以用来更方便的执行远程模块或者项目 node_modules 中的 CLI 程序。

让配置文件支持智能提示

VSCode 对于代码的自动提示是根据成员的类型推断出来的。即便没有使用 TypeScript 这种类型语言,也可以通过类型注释的方式去标注变量的类型。默认 VSCode 并不知道 Webpack 配置对象的类型,可以通过 import 的方式导入 Webpack 模块中的 Configuration 类型,然后根据类型注释的方式将变量标注为这个类型,这样在编写对象的内部结构时就有智能提示了。

import { Configuration } from 'webpack'

/**
 * @type {Configuration}
 */
const config = {
  entry: './src/index.js'
}
module.exports = config

但是打包时一定要注释掉 import,因为 webpack 运行在 Node 环境中,不支持 import 语法。所以直接通过另一种方式:

/** @type {import('webpack').Configuration} */
const config = {
  entry: './src/index.js'
}
module.exports = config

这种导入类型的方式并不是 ES Modules 中的 Dynamic Imports,而是 TypeScript 中提供特性。因为 VSCode 中的类型系统都是基于 TypeScript 的。

基本配置

入口

程序打包的入口文件

module.exports = {
  entry: {
    main: path.resolve(__dirname, './src/index.js')
  }
}

出口

输出文件

module.exports = {
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: '[name].bundle.js',
    chunkFilename: '[id].js'
  }
}

filename 和 chunkFilename 的区别:

filename对应entry中的入口文件,经webpack打包后输出的文件的名称。比如以上main.js 打包出来就是 main.bundle.js

chunkFilename 指未被列在 entry 中,却又需要被打包出来的 chunk 文件的名称。一般来说,这个 chunk 文件指的就是要懒加载的代码或者提取的公共模块。默认采用[id].js 或从 output.filename 中推断出的值([name] 会被预先替换为 [id][id].

对于动态 import 的文件,导入时可以通过 webpack 提供的特殊注释指定文件名。比如:

import(/* webpackChunkName: "mibi-login-money" */ '@/views/login/index.vue')

总之两句话:

filename列在 entry 中,打包后输出的文件的名称。

chunkFilename未列在 entry 中,却又需要被打包出来的文件的名称。

Loader

Loader 是 Webpack 的核心机制之一。

Webpack 默认只能理解 js 和 json。只有通过不同的 Loader,Webpack 才可以实现任何类型资源的加载。Loader 是 Webpack 实现整个前端模块化的核心。

loader 包含两个属性,test 和 use。

test:要被转换的文件后缀的正则

use:所使用的 loader 路径

常用 loader

babel-loader

允许编写下一代 js。需要的依赖:

  • babel-loader 使用 Babel 和 webpack 转译文件
  • @babel/core 将 ES2015+ 转译为向后兼容的 JavaScript
  • @babel/preset-env Babel 支持转译的特性的预设。默认全部

如果是 TypeScript 项目,使用ts-loader代替 babel-loader即可满足所有的 JavaScript 转译需求。

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [['@babel/preset-env']],
            plugins: ['@babel/plugin-proposal-class-properties']
          }
        }
      }
    ]
  }
}

关于 babel 的配置选项可以在 use 配置,也可以在项目根目录下创建.babelrcbabel-config.js/json文件配置,如下:

{
  "presets": ["@babel/preset-env"],
  "plugins": ["@babel/plugin-proposal-class-properties"]
}
样式

如果想在 js 中加载样式,或使用sass等预处理器,或想在任何浏览器中使用所有最新的 CSS 功能,或 px 转 rem 等等,需要相应的 loader 处理。

yarn add sass-loader postcss-loader css-loader style-loader postcss-preset-env node-sass
module.exports = {
  module: {
    rules: [
      {
        test: /\.(scss|css)$/,
        use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
      }
    ]
  }
}

配置 postcss.config.js

module.exports = {
  plugins: {
    'postcss-preset-env': {
      browsers: 'last 2 versions'
    }
  }
}

sass-loader:加载 SCSS 并编译成 CSS

postcss-loader:处理 css,比如添加前缀,px 转 rem 等操作

css-loader:把 CSS 模块加载到 JS 代码中

style-loader:将加载到 js 中的所有样式模块,通过创建 style 标签的方式添加到页面上

生产模式下通常会将 css 单独提取成模块。需要用到mini-css-extract-plugin插件,并替换style-loader

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  module: {
    rules: [
      {
        test: /\.(sass|css)$/i,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'styles/[name].[contenthash:5].css'
    })
  ]
}
图片等静态资源

以前处理图片、字体等文件,使用file-loader,如果要将小图转成 base64 需要用到url-loader。而 webpack5 内置了资源模块,用于处理静态资源。对于图片,使用asset/resource

注意,不再是use,而是type

module.exports = {
  module: {
    rules: [
      {
        test: /\.(?:ico|gif|png|jpg|jpeg)$/i,
        type: 'asset/resource'
      }
    ]
  }
}

如果要设置 limit,将小图转成 base64,则 type 为asset类型,并设置generatorparser选项:

module.exports = {
  module: {
    rules: [
      {
        test: /\.(?:ico|gif|png|jpg|jpeg)$/i,
        type: 'asset',
        generator: {
          // 注意:后缀没【.】
          filename: `/images/[name]-[hash:5][ext]`
        },
        parser: {
          dataUrlCondition: {
            maxSize: 6 * 1024 // 6kb  指定大小
          }
        }
      }
    ]
  }
}

对于 svg 等字体使用asset/inline类型内联一些数据,比如 svg 和字体。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(woff(2)?|eot|ttf|otf|svg|)$/,
        type: 'asset/inline'
      }
    ]
  }
}

思考:为什么要在 JS 中加载其他资源?

Webpack 为什么要在 js 中载入 css 、img 等文件呢?不是应该将样式和行为分离么?

其实 Webpack 不仅建议我们在 js 中引入 css,还建议我们在代码中引入当前业务所需要的任意资源。因为真正需要这个资源的并不是整个应用,而是你此时正在编写的代码。这就是 Webpack 的设计哲学。

做一个假设:

假设在开发页面上的某个局部功能时,需要用到一个样式模块和一个图片。如果将这些资源文件单独引入到 html 中,然后再到 js 中添加对应的逻辑代码。试想,如果后期这个局部功能不用了,就需要同时删除 js 中的代码和 html 中的资源文件,也就是同时需要维护这两条线。而如果遵照 Webpack 的设计,所有资源的加载都是由 js 代码控制,后期也就只需要维护 js 代码这一条线了。

所以,通过 js 代码去引入资源文件,建立 js 和资源文件的依赖关系,具有很明显的优势。因为本身就是 js 负责完成整个应用的业务功能,宏观来说就是驱动了整个前端应用,而 js 在实现业务功能的过程中是需要用到 css、img 等资源文件的。如果建立这种依赖关系,第一逻辑上比较合理,因为 js 确实需要这些资源文件配合才能实现整体功能;第二配合类似 Webpack 这样的模块化打包工具,能确保在上线时,资源不会缺失。

Loader 原理

Loader 就是一个函数,函数的输入是匹配到的文件内容,输出是函数的返回值,必须是字符串类型的 js 代码

// test.md

# hello

这是 markdown 文件
// index.js

import md from './test.md'
console.log(md)

编写 Loader

// md-loader.js

module.exports = source => {
  console.log(source)
  return 'console.log("hello loader~")'
}
module.exports = {
  module: {
    rules: [
      {
        test: /\.md$/,
        use: './md-loader.js'
      }
    ]
  }
}

执行打包可以看到输出了 test.md 文件的内容

image-20210730165520554.png

运行输出的 main.js 可以看到打印出了 laoder 的返回值

image-20210730165811887.png

Webpack 加载资源文件的过程类似于管道,在这个过程中可以依次使用多个 Loader,但是最终这个管道结束过后的结果必须是一段标准的 JS 代码字符串。

Plugin

Plugin 机制的目的是为了增强 Webpack 在自动化构建方面的能力。解决了项目中除了资源模块打包以外的其他自动化工作。

比如:打包之前清除目录、自动生成 html、拷贝不需要打包的文件到输出目录、压缩资源、自动部署打包后的结果等。

常用插件

html-webpack-plugin

以前只在插件选项中定义模板的 title 即可。现在需要在模版中设置插值表达式

<%= htmlWebpackPlugin.options.title %>
clean-webpack-plugin

功能是每次打包之前清除 dist。默认清除全部。可以设置删除指定的目录。

copy-webpack-plugin

项目中一般还有一些不需要参与构建的静态文件,最终也需要发布到线上。一般把这类文件统一放在项目根目录下的 public 或者 static 目录中,Webpack 在打包时将这个目录下所有的文件复制到输出目录。

const CopyWebpackPlugin = require('copy-webpack-plugin')

module.exports = {
  plugins: [new CopyWebpackPlugin({ patterns: ['public'] })]
}

Plugin 原理

相比于 Loader,插件的能力更强大,因为 Loader 只是在模块的加载环节工作,而插件的作用范围几乎可以触及 Webpack 工作的每一个环节。

插件机制其实就是钩子机制。在 Webpack 整个工作过程会有很多环节,为了便于插件的扩展,Webpack 几乎在每一个环节都埋下了一个钩子。这样在开发插件时,通过往这些不同节点上挂载不同的任务,就可以扩展 Webpack 的能力。

插件必须是一个函数或者是一个包含 apply 方法的对象,该方法会在 webpack 编译时被调用,它接收一个 compiler 对象参数,这个对象是 Webpack 工作过程中最核心的对象,里面包含了构建的所有配置信息,通过 compiler 对象的 hooks 属性访问到 run 钩子,再通过 tap 方法注册一个钩子函数,实际上相当于事件的发布订阅,这个方法接收两个参数:第一个是插件的名称;第二个是要挂载到这个钩子上的函数,函数接收一个 compilation 对象参数,这个对象可以理解为此次运行打包的上下文,所有打包过程中产生的结果,都会放到这个对象中。

例:

const eventPlugin = 'ConsoleLogOnBuildWebpackPlugin'

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    // 在该钩子上监听了 ConsoleLogOnBuildWebpackPlugin 事件,当执行到这个阶段时就会触发对应的回调
    compiler.hooks.run.tap(eventPlugin, compilation => {
      console.log('webpack 构建过程开始!')
    })
  }
}

// compiler hook的 tap 方法的第一个参数,应该是驼峰式命名的插件名称。建议为此使用一个常量,以便它可以在所有 hook 中重复使用。
module.exports = ConsoleLogOnBuildWebpackPlugin
const ConsoleLogOnBuildWebpackPlugin = require('./test-plugin')

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

执行打包:

image-20210730173355557.png

项目架构升级后还写了个插件,替换之前就的实现方式。

由于项目是 jsp 的,打包完成后需要的对 js 一对一的引入到 jsp 文件中,之前的方案是所有的 jsp 模版存在一个目录下,通过操作该目录的 jsp,引入 js,然后读取出来 jsp,替换另一个目录下的 jsp。太繁琐且不高效。

通过 webpack 插件,在项目编译完,输入目录之前就在内存中拿到了输出信息,这时候就通过流的方式读取出来,同时读取到 jsp 目录下的内容,通过正则匹配替换 js 的的引入,在写进入。

优势:

  1. 之前的模板就用不上了,这样就少读取一个目录下的几十个文件,提升了性能和缩短了时间
  2. 编译完输出目录之前就开始替换 js 的引入,缩短了打包时间

target

webpack5.x 打包时默认的 js 语法是 es6 的,比如最终的自执行函数是箭头函数,导入声明的变量是const等。导致在 IE 上报错,即便是 IE11 都不支持。
比如对如下代码打包后的结果是:

// 入口文件 源代码
import foo from './foo'

const bar = () => {
  console.log(foo)
}

setTimeout(() => {
  bar()
}, 100)
// 打包后的结果
(()=>{"use strict";const o={age:34};setTimeout((function(){console.log(o)}),100)})();

webpack.5x 对输出选项下提供了细粒度的支持,比如:

module.exports = {
  output: {
    environment: {
      // 环境支持箭头函数
      arrowFunction: true,
      const: true,
      // 支持解构
      destructuring: true,
      // 不支持动态导入
      dynamicImport: false,
      // 支持for...of
      forOf: true,
      // 不支持ESM
      module: false
    }
  }
}

但是如果要把 es 选项一个个改成 false,显然不现实。webpack5.x 提供了target选项,能够为多种环境或 target 构建编译,默认是web。将其设置成 ['web', 'es5'],就可以将 js 转译成 es5 的语法。

// 重新打包
!function(){"use strict";var o={age:34};setTimeout((function(){console.log(o)}),100)}();

devServer

安装webpack-dev-server

yarn add webpack-dev-server

webpack5.x 之前执行命令是webpack-dev-server,webpack5.x 执行webpack server

mode

设置webpack的工作环境,可以在配置对象中设置mode选项,也可在 CLI 中通过参数--mode=xxx设置

mode
node 运行最原始的打包,不做任何额外处理
development 自动优化打包速度,添加一些调试过程中的辅助插件
production 启动内置优化插件,自动优化打包结果,打包速度偏慢

实际项目中会区分开发和生产模式,但是他们又会有公共的配置选项,所以一般是配置三个文件,一个是公共的webpack.base.js,一个开发webpack.dev.js,生产webpack.prod.js。通过webpack-merge进行合并。

容易混淆的的点

hash chunkhash contenthash 区别

通常情况下哈希一般是结合 CDN 缓存来使用的。如果文件内容改变的话,那么对应的文件哈希值也会改变,相应的 HTML 引用的 URL 地址也会改变,触发 CDN 服务器从源服务器上拉取对应数据,进而更新本地缓存。

hash

hash 计算是跟整个项目的构建相关。

image-20210727162857660.png

打包后可以发现,生成文件的 hash 和项目的构建 hash 都是一模一样的。

因为 hash 是项目构建的哈希值,项目中如果有变动,hash 一定会变,比如改动了 foo.js 的代码,index.js 里的代码虽然没有改变,但用的都是同一个 hash。hash 一变,缓存一定失效,这样是没办法实现 CDN 和浏览器缓存的。

chunkhash

chunkhash 是根据不同的入口文件(Entry)进行依赖文件解析、构建对应的 chunk,生成对应的哈希值。

image-20210727163145086.png

修改 foo.js 文件的内容,发现打包后只有 foo.js 的 hash 改变,index 的没有变。

image-20210727163548634.png

contenthash

以上可知,index.js 和 index.css 同为一个 chunk,如果 index.js 内容发生变化, index.css 没有变化,但是打包后 css 的 hash 也都发生变化,显然不利于缓存。

contenthash 将根据资源内容计算出唯一 hash,也就是说文件内容不变,hash 就不变。

image-20210727163922023.png

总结:

hash 计算与整个项目的构建相关;

chunkhash 计算与同一 chunk 内容相关;

contenthash 计算与文件内容相关;

module chunk bundle 的区别

  1. 我们手写的一个个的文件,无论是 ESM 还是 commonJS 亦或 AMD,都是 module
  2. 当 module 源文件传到 webpack 进行打包时,webpack 会根据文件引用关系生成 chunk 文件,webpack 会对这个 chunk 文件进行一些操作
  3. webpack 处理好 chunk 文件后,最终输出 bundle 文件,这个 bundle 文件包含了经过加载和编译的最终源文件,所以它可以直接在浏览器中运行

总结

module chunk bundle 其实就是同一份逻辑代码在不同转换场景下的三个名字:直接写出来的是 module,webpack 处理时是 chunk,最后生成浏览器可以直接运行的是 bundle。

由于项目使用了线上打包,而使用 webpack 打包,需要区分测试环境与生产环境来配置不同的资源路径。 如何在npm run build时传入当前的环境参数?

可以在npm run build后使用 --XXX 实现参数传递。比如 npm run build --envmode=staging 在 webpack 的配置文件中可以通过process.env.npm_config_evnmode获取到参数。

你可能感兴趣的:(Webpack5.x使用、原理及思考)