Webpack常见面试题总结(二)

目录
文章目录隐藏
  1. 一、谈谈你对 Webpack 的理解
  2. 二、说说 webpack 的构建流程
  3. 三、Webpack 中常见的 Loader
  4. 四、Webpack 中常见的 Plugin
  5. 五、Loader 和 Plugin 的区别,以及如何自定义 Loader 和 Plugin
  6. 六、Webpack 热更新
  7. 七、Webpack Proxy 工作原理
  8. 八、如何借助 Webpack 来优化性能
  9. 九、提高 Webpack 的构建速度
  10. 十、 除了 Webpack 外,你还了解哪些模块管理工具

一、谈谈你对 Webpack 的理解

1.1 背景

Webpack 的目标是实现前端项目的模块化,从而更高效地管理和维护项目中的每一个资源。在早期的前端项目中,我们通过文件划分的形式来实现模块化,也就是将每个功能及其相关状态数据各自单独放到不同的 JS 文件中。约定每个文件是一个独立的模块,然后再将这些 js 文件引入到页面,一个 script 标签对应一个模块,然后再调用模块化的成员。比如:

<script src="module-a.js"></script>
<script src="module-b.js"></script>

但这种模块化开发的弊端也十分明显,模块都是在全局中工作,大量模块成员污染了环境,模块与模块之间并没有依赖关系、维护困难、没有私有空间等问题。随后,就出现了命名空间方式,规定每个模块只暴露一个全局对象,然后模块的内容都挂载到这个对象中。

window.moduleA = {
  method1: function () {
    console.log('moduleA#method1')
  }
}

不过,这种方式也没有解决第一种方式的依赖等问题。接着,又出现了使用立即执行函数为模块提供私有空间,通过参数的形式作为依赖声明。

(function ($) {
  var name = 'module-a'

  function method1 () {
    console.log(name + '#method1')
    $('body').animate({ margin: '200px' })
  }
    
  window.moduleA = {
    method1: method1
  }
})(jQuery)

上述的方式早期解决模块的方式,但是仍然存在一些没有解决的问题。例如,我们是用过 script 标签在页面引入这些模块的,这些模块的加载并不受代码的控制,时间一久维护起来也十分的麻烦。

除了模块加载的问题以外,还需要规定模块化的规范,如今流行的则是 CommonJS 、ES Modules。

特别是随着前端项目的越来越大,前端开发也变得十分的复杂,我们经常在开发过程中会遇到如下的问题:

  • 需要通过模块化的方式来开发
  • 使用一些高级的特性来加快我们的开发效率或者安全性,比如通过 ES6+、TypeScript 开发脚本逻辑,通过 sass、less 等方式来编写 css 样式代码
  • 监听文件的变化来并且反映到浏览器上,提高开发的效率
  • JavaScript 代码需要模块化,HTML 和 CSS 这些资源文件也会面临需要被模块化的问题
  • 开发完成后我们还需要将代码进行压缩、合并以及其他相关的优化

而 Webpack 的出现,就是为了解决以上问题的。总的来说,Webpack 是一个模块打包工具,开发者可以很方面使用 Webpack 来管理模块依赖,并编译输出模块们所需要的静态文件。

1.2 Webpack

Webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具,可以很方便的管理模块的恶依赖。

1.2.1 静态模块

此处的静态模块指的是开发阶段,可以被 Webpack 直接引用的资源(可以直接被获取打包进 bundle.js 的资源)。当 Webpack 处理应用程序时,它会在内部构建一个依赖图,此依赖图对应映射到项目所需的每个模块(不再局限 js 文件),并生成一个或多个 bundle,如下图。

webpack 静态模块

1.2.2 Webpack 作用

编译代码能力,提高效率,解决浏览器兼容问题。

Webpack 作用

模块整合能力,提高性能,可维护性,解决浏览器频繁请求文件的问题。

解决浏览器频繁请求文件的问题

万物皆可模块能力,项目维护性增强,支持不同种类的前端模块类型,统一的模块化方案,所有资源文件的加载都可以通过代码控制。

支持不同种类的前端模块类型

二、说说 webpack 的构建流程

webpack 的运行流程是一个串行的过程,它的工作流程就是将各个插件串联起来。在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条 webpack 机制中,去改变 Webpack 的运作。

从启动到结束会依次经历三大流程:

  • 初始化阶段:从配置文件和 Shell 语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数。
  • 编译构建阶段:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  • 输出阶段:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

2.1 初始化阶段

初始化阶段主要是从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。配置文件默认下为 webpack.config.js,也或者通过命令的形式指定配置文件,主要作用是用于激活 webpack 的加载项和插件。下面是 webpack.config.js 文件配置,内容分析如下注释:

var path = require('path');
var node_modules = path.resolve(__dirname, 'node_modules');
var pathToReact = path.resolve(node_modules, 'react/dist/react.min.js');

module.exports = {
  // 入口文件,是模块构建的起点,同时每一个入口文件对应最后生成的一个 chunk。
  entry: './path/to/my/entry/file.js',
  // 文件路径指向(可加快打包过程)。
  resolve: {
    alias: {
      'react': pathToReact
    }
  },
  // 生成文件,是模块构建的终点,包括输出文件与输出路径。
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
  },
  // 这里配置了处理各模块的 loader ,包括 css 预处理 loader ,es6 编译 loader,图片处理 loader。
  module: {
    loaders: [
      {
        test: /\.js$/,
        loader: 'babel',
        query: {
          presets: ['es2015', 'react']
        }
      }
    ],
    noParse: [pathToReact]
  },
  // webpack 各插件对象,在 webpack 的事件流中执行对应的方法。
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

webpack 将 webpack.config.js 中的各个配置项拷贝到 options 对象中,并加载用户配置的 plugins。完成上述步骤之后,则开始初始化 Compiler 编译对象,该对象掌控者 webpack 声明周期,不执行具体的任务,只是进行一些调度工作。

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            beforeCompile: new AsyncSeriesHook(["params"]),
            compile: new SyncHook(["params"]),
            afterCompile: new AsyncSeriesHook(["compilation"]),
            make: new AsyncParallelHook(["compilation"]),
            entryOption: new SyncBailHook(["context", "entry"])
            // 定义了很多不同类型的钩子
        };
        // ...
    }
}

function webpack(options) {
  var compiler = new Compiler();
  ...// 检查 options,若 watch 字段为 true,则开启 watch 线程
  return compiler;
}
...

在上面的代码中,Compiler 对象继承自 Tapable,初始化时定义了很多钩子函数。

2.2 编译构建

用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。然后,根据配置中的 entry 找出所有的入口文件,如下:

module.exports = {
  entry: './src/file.js'
}

初始化完成后会调用 Compiler 的 run 来真正启动 webpack 编译构建流程,主要流程如下:

  • compile:开始编译;
  • make:从入口点分析模块及其依赖的模块,创建这些模块对象;
  • build-module:构建模块;
  • seal:封装构建结果;
  • emit:把各个 chunk 输出到结果文件。

1. compile 编译

执行了 run 方法后,首先会触发 compile,主要是构建一个 Compilation 对象。该对象是编译阶段的主要执行者,主要会依次下述流程:执行模块创建、依赖收集、分块、打包等主要任务的对象。

2. make 编译模块

当完成了上述的 compilation 对象后,就开始从 Entry 入口文件开始读取,主要执行 _addModuleChain()函数,源码如下:

_addModuleChain(context, dependency, onModule, callback) {
   ...
   // 根据依赖查找对应的工厂函数
   const Dep = /** @type {DepConstructor} */ (dependency.constructor);
   const moduleFactory = this.dependencyFactories.get(Dep);
   
   // 调用工厂函数 NormalModuleFactory 的 create 来生成一个空的 NormalModule 对象
   moduleFactory.create({
       dependencies: [dependency]
       ...
   }, (err, module) => {
       ...
       const afterBuild = () => {
        this.processModuleDependencies(module, err => {
         if (err) return callback(err);
         callback(null, module);
           });
    };
       
       this.buildModule(module, false, null, null, err => {
           ...
           afterBuild();
       })
   })
}

_addModuleChain中接收参数dependency传入的入口依赖,使用对应的工厂函数NormalModuleFactory.create方法生成一个空的 module 对象。回调中会把此 module 存入compilation.modules对象和dependencies.module对象中,由于是入口文件,也会存入compilation.entries中。随后,执行 buildModule 进入真正的构建模块 module 内容的过程。

3. build module 完成模块编译

这个过程的主要调用配置的 loaders,将我们的模块转成标准的 JS 模块。在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。

从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。

2.3 输出阶段

seal方法主要是要生成 chunks,对 chunks 进行一系列的优化操作,并生成要输出的代码。Webpack 中的 chunk ,可以理解为配置在 entry 中的模块,或者是动态引入的模块。

根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表。在确定好输出内容后,根据配置确定输出的路径和文件名即可。

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

在 Compiler 开始生成文件前,钩子 emit 会被执行,这是我们修改最终文件的最后一个机会。整个过程如下图所示:

输出阶段

三、Webpack 中常见的 Loader

3.1 Loader 是什么?

Loader 本质就是一个函数,在该函数中对接收到的内容进行转换,返回转换后的结果。因为 Webpack 只认识 JavaScript,所以 Loader 就成了翻译官,对其他类型的资源进行转译的预处理工作。

默认情况下,在遇到 import 或者 load 加载模块的时候,webpack 只支持对 js 文件打包。像 css、sass、png 等这些类型的文件的时候,webpack 则无能为力,这时候就需要配置对应的 loader 进行文件内容的解析。在加载模块的时候,执行顺序如,如下图所示:

Loader 是什么

关于配置 Loader 的方式,有常见的三种方式:

  • 配置方式(推荐):在 webpack.config.js 文件中指定 loader
  • 内联方式:在每个 import 语句中显式指定 loader
  • Cli 方式:在 shell 命令中指定它们

3.1 配置方式

关于 Loader 的配置,我们通常是写在module.rules属性中,属性介绍如下:

  • rules是一个数组的形式,因此我们可以配置很多个loader
  • 每一个 loader 对应一个对象的形式,对象属性 test 为匹配的规则,一般情况为正则表达式。
  • 属性use针对匹配到文件类型,调用对应的 loader 进行处理。

下面是一个 module.rules 的示例代码:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          },
          { loader: 'sass-loader' }
        ]
      }
    ]
  }
};

3.2 Loader 特性

从上述代码可以看到,在处理 css 模块的时候,use 属性中配置了三个 loader 分别处理 css 文件。因为 loader 支持链式调用,链中的每个 loader 会处理之前已处理过的资源,最终变为 js 代码。顺序为相反的顺序执行,即上述执行方式为 sass-loader、css-loader、style-loader。

除此之外,loader 的特性还有如下:

  • Loader 可以是同步的,也可以是异步的
  • Loader 运行在 Node.js 中,并且能够执行任何操作
  • 除了常见的通过 package.json 的 main 来将一个 npm 模块导出为 loader,还可以在 module.rules 中使用 loader 字段直接引用一个模块
  • 插件(plugin)可以为 loader 带来更多特性
  • Loader 能够产生额外的任意文件

可以通过 loader 的预处理函数,为 JavaScript 生态系统提供更多能力。用户现在可以更加灵活地引入细粒度逻辑,例如:压缩、打包、语言翻译和更多其他特性。

3.3 常用 Loader

在页面开发过程中,除了需要导入一些场景 js 文件外,还需要配置响应的 loader 进行加载。WebPack 常见的 Loader 如下:

  • style-loader:将 css 添加到 DOM 的内联样式标签 style 里,然后通过 dom 操作去加载 css。
  • css-loader :允许将 css 文件通过 require 的方式引入,并返回 css 代码。
  • less-loader: 处理 less,将 less 代码转换成 css。
  • sass-loader: 处理 sass,将 scss/sass 代码转换成 css。
  • postcss-loader:用 postcss 来处理 css。
  • autoprefixer-loader: 处理 css3 属性前缀,已被弃用,建议直接使用 postcss。
  • file-loader: 分发文件到 output 目录并返回相对路径。
  • url-loader: 和 file-loader 类似,但是当文件小于设定的 limit 时可以返回一个 Data Url。
  • html-minify-loader: 压缩 HTML
  • babel-loader :用 babel 来转换 ES6 文件到 ES。
  • awesome-typescript-loader:将 TypeScript 转换成 JavaScript,性能优于 ts-loader。
  • eslint-loader:通过 ESLint 检查 JavaScript 代码。
  • tslint-loader:通过 TSLint 检查 TypeScript 代码。
  • cache-loader: 可以在一些性能开销较大的 Loader 之前添加,目的是将结果缓存到磁盘里

下面以 css-loader 为例子,来说明 Loader 的使用过程。首先,我们在项目中安装 css-loader 插件。

npm install --save-dev css-loader

然后将规则配置到 module.rules 中,比如:

rules: [
  ...,
 {
  test: /\.css$/,
    use: {
      loader: "css-loader",
      options: {
     // 启用/禁用 url() 处理
     url: true,
     // 启用/禁用 @import 处理
     import: true,
        // 启用/禁用 Sourcemap
        sourceMap: false
      }
    }
 }
]

四、Webpack 中常见的 Plugin

4.1 基础

Plugin 就是插件,基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

Webpack 中的 Plugin 也是如此,Plugin 赋予其各种灵活的功能,例如打包优化、资源管理、环境变量注入等,它们会运行在 Webpack 的不同阶段(钩子 / 生命周期),贯穿了 Webpack 整个编译周期。

Webpack 中常见的 Plugin

使用的时候,通过配置文件导出对象中 plugins 属性传入 new 实例对象即可。

const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 访问内置的插件
module.exports = {
  ...
  plugins: [
    new webpack.ProgressPlugin(),
    new HtmlWebpackPlugin({ template: './src/index.html' }),
  ],
};

4.2 特性

Plugin 从本质上来说,就是一个具有 apply 方法 Javascript 对象。apply 方法会被 webpack compiler 调用,并且在整个编译生命周期都可以访问 compiler 对象。

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('webpack 构建过程开始!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

我们可以使用上面的方式,来获取 Plugin 在编译生命周期钩子。如下:

  • entry-option :初始化 option
  • compile: 真正开始的编译,在创建 compilation 对象之前
  • compilation :生成好了 compilation 对象
  • make:从 entry 开始递归分析依赖,准备对每个模块进行 build
  • after-compile: 编译 build 过程结束
  • emit :在将内存中 assets 内容写到磁盘文件夹之前
  • after-emit :在将内存中 assets 内容写到磁盘文件夹之后
  • done: 完成所有的编译过程
  • failed: 编译失败的时候

4.3 常见的 Plugin

Weebpack 中,常见的 plugin 有如下一些:

  • define-plugin:定义环境变量 (Webpack4 之后指定 mode 会自动配置)
  • ignore-plugin:忽略部分文件
  • html-webpack-plugin:简化 HTML 文件创建 (依赖于 html-loader)
  • web-webpack-plugin:可方便地为单页应用输出 HTML,比 html-webpack-plugin 好用
  • uglifyjs-webpack-plugin:不支持 ES6 压缩 (Webpack4 以前)
  • terser-webpack-plugin: 支持压缩 ES6 (Webpack4)
  • webpack-parallel-uglify-plugin: 多进程执行代码压缩,提升构建速度
  • mini-css-extract-plugin: 分离样式文件,CSS 提取为独立文件,支持按需加载 (替代 extract-text-webpack-plugin)
  • serviceworker-webpack-plugin:为网页应用增加离线缓存功能
  • clean-webpack-plugin: 目录清理
  • ModuleConcatenationPlugin: 开启 Scope Hoisting
  • speed-measure-webpack-plugin: 可以看到每个 Loader 和 Plugin 执行耗时 (整个打包耗时、每个 Plugin 和 Loader 耗时)
  • webpack-bundle-analyzer: 可视化 Webpack 输出文件的体积 (业务组件、依赖第三方模块)

下面通过 clean-webpack-plugin 来看一下插件的使用方法。首先,需要安装 clean-webpack-plugin 插件。

npm install --save-dev clean-webpack-plugin

然后,引入插件即可使用。

const {CleanWebpackPlugin} = require('clean-webpack-plugin');
module.exports = {
 ...
  plugins: [
    ...,
    new CleanWebpackPlugin(),
    ...
  ]
}

五、Loader 和 Plugin 的区别,以及如何自定义 Loader 和 Plugin

5.1 区别

Loader 本质就是一个函数,在该函数中对接收到的内容进行转换,返回转换后的结果。因为 Webpack 只认识 JavaScript,所以 Loader 就成了翻译官,对其他类型的资源进行转译的预处理工作。

Plugin 就是插件,基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

  • Loader 运行在打包文件之前,Loader 在 module.rules 中配置,作为模块的解析规则,类型为数组。每一项都是一个 Object,内部包含了 test(类型文件)、loader、options (参数)等属性。
  • Plugins 在整个编译周期都起作用,Plugin 在 plugins 中单独配置,类型为数组,每一项是一个 Plugin 的实例,参数都通过构造函数传入。

5.2 自定义 Loader

我们知道,Loader 本质上来说就是一个函数,函数中的 this 作为上下文会被 webpack 填充,因此我们不能将 loader 设为一个箭头函数。该函数接受一个参数,为 webpack 传递给 loader 的文件源内容。

同时,函数中 this 是由 webpack 提供的对象,能够获取当前 loader 所需要的各种信息。函数中有异步操作或同步操作,异步操作通过 this.callback 返回,返回值要求为 string 或者 Buffer,如下。

// 导出一个函数,source 为 webpack 传递给 loader 的文件源内容
module.exports = function(source) {
    const content = doSomeThing2JsString(source);
    
    // 如果 loader 配置了 options 对象,那么 this.query 将指向 options
    const options = this.query;
    
    // 可以用作解析其他模块路径的上下文
    console.log('this.context');
    
    /*
     * this.callback 参数:
     * error:Error | null,当 loader 出错时向外抛出一个 error
     * content:String | Buffer,经过 loader 编译后需要导出的内容
     * sourceMap:为方便调试生成的编译后内容的 source map
     * ast:本次编译生成的 AST 静态语法树,之后执行的 loader 可以直接使用这个 AST,进而省去重复生成 AST 的过程
     */
    this.callback(null, content); // 异步
    return content; // 同步
}

5.3 自定义 Plugin

Webpack 的 Plugin 是基于事件流框架 Tapable,由于 webpack 基于发布订阅模式,在运行的生命周期中会广播出许多事件,插件通过监听这些事件,就可以在特定的阶段执行自己的插件任务。

同时,Webpack 编译会创建两个核心对象:compiler 和 compilation。

  • compiler:包含了 Webpack 环境的所有的配置信息,包括 options,loader 和 plugin,和 webpack 整个生命周期相关的钩子.
  • compilation:作为 Plugin 内置事件回调函数的参数,包含了当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当检测到一个文件变化,一次新的 Compilation 将被创建。

如果需要自定义 Plugin,也需要遵循一定的规范:

  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问 compiler 实例
  • 传给每个插件的 compilercompilation 对象都是同一个引用,因此不建议修改
  • 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住

下面是一个自定 Plugin 的模板:

class MyPlugin {
    // Webpack 会调用 MyPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply (compiler) {
    // 找到合适的事件钩子,实现自己的插件功能
    compiler.hooks.emit.tap('MyPlugin', compilation => {
        // compilation: 当前打包构建流程的上下文
        console.log(compilation);
        
        // do something...
    })
  }
}

在 emit 事件被触发后,代表源文件的转换和组装已经完成,可以读取到最终将输出的资源、代码块、模块及其依赖,并且可以修改输出资源的内容。

六、Webpack 热更新

6.1 热更新

Webpack 的热更新又称热替换(Hot Module Replacement),缩写为 HMR。这个机制可以做到不用刷新浏览器而将新变更的模块替换掉旧的模块。

HMR 的核心就是客户端从服务端拉去更新后的文件,准确的说是 chunk diff (chunk 需要更新的部分),实际上 WDS 与浏览器之间维护了一个 Websocket,当本地资源发生变化时,WDS 会向浏览器推送更新,并带上构建时的 hash,让客户端与上一次资源进行对比。客户端对比出差异后会向 WDS 发起 Ajax 请求来获取更改内容(文件列表、hash),这样客户端就可以再借助这些信息继续向 WDS 发起 jsonp 请求获取该 chunk 的增量更新。

在 Webpack 中配置开启热模块也非常的简单,只需要添加如下代码即可:

const webpack = require('webpack')
module.exports = {
  // ...
  devServer: {
    // 开启 HMR 特性
    hot: true
    // hotOnly: true
  }
}

需要说明的是,实现热更新还需要去指定哪些模块发生更新时进行 HRM,因为默认情况下,HRM 只对 css 文件有效。

if(module.hot){
    module.hot.accept('./util.js',()=>{
        console.log("util.js 更新了")
    })
}

6.2 实现原理

首先,我们来看一张图:

实现原理

上面图中涉及了很多不同的概念,如下:

  • Webpack Compile:将 JS 源代码编译成 bundle.js
  • HMR Server:用来将热更新的文件输出给 HMR Runtime
  • Bundle Server:静态资源文件服务器,提供文件访问路径
  • HMR Runtime:socket 服务器,会被注入到浏览器,更新文件的变化
  • bundle.js:构建输出的文件
  • 在 HMR Runtime 和 HMR Server 之间建立 websocket,即图上 4 号线,用于实时更新文件变化

整个流程,我们可以将它分为两个阶段:启动阶段和更新阶段。

启动阶段的主要工作是,Webpack Compile 将源代码和 HMR Runtime 一起编译成 bundle 文件,传输给 Bundle Server 静态资源服务器。接下来,我们重点关注下更新阶段:

当某一个文件或者模块发生变化时,webpack 监听到文件变化对文件重新编译打包,编译生成唯一的 hash 值,这个 hash 值用来作为下一次热更新的标识。然后,根据变化的内容生成两个补丁文件:manifest(包含了 hash 和 chundId ,用来说明变化的内容)和 chunk.js 模块。

由于 socket 服务器在 HMR Runtime 和 HMR Server 之间建立 websocket 链接,当文件发生改动的时候,服务端会向浏览器推送一条消息,消息包含文件改动后生成的 hash 值,如下图的 h 属性,作为下一次热更细的标识。

h 属性

在浏览器接受到这条消息之前,浏览器已经在上一次 socket 消息中已经记住了此时的 hash 标识,这时候我们会创建一个 ajax 去服务端请求获取到变化内容的 manifest 文件。mainfest 文件包含重新 build 生成的 hash 值,以及变化的模块,对应上图的 c 属性。浏览器根据 manifest 文件获取模块变化的内容,从而触发 render 流程,实现局部模块更新。

局部模块更新

6.3 总结

通过前面的分析,总结 Webpack 热模块的步骤如下:

  • 通过webpack-dev-server创建两个服务器:提供静态资源的服务(express)和 Socket 服务
  • express server 负责直接提供静态资源的服务(打包后的资源直接被浏览器请求和解析)
  • socket server 是一个 websocket 的长连接,双方可以通信
  • socket server 监听到对应的模块发生变化时,会生成两个文件.json(manifest 文件)和.js 文件(update chunk)
  • 通过长连接,socket server 可以直接将这两个文件主动发送给客户端(浏览器)
  • 浏览器拿到两个新的文件后,通过 HMR runtime 机制,加载这两个文件,并且针对修改的模块进行更新

七、Webpack Proxy 工作原理

7.1 代理

在项目开发中不可避免会遇到跨越问题,Webpack 中的 Proxy 就是解决前端跨域的方法之一。所谓代理,指的是在接收客户端发送的请求后转发给其他服务器的行为,webpack 中提供服务器的工具为 webpack-dev-server。

7.1.1 webpack-dev-server

webpack-dev-server 是 webpack 官方推出的一款开发工具,将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起。同时,为了提高开发者日常的开发效率,只适用在开发阶段。在 webpack 配置对象属性中配置代理的代码如下:

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

module.exports = {
    // ...
    devServer: {
        contentBase: path.join(__dirname, 'dist'),
        compress: true,
        port: 9000,
        proxy: {
            '/api': {
                target: 'https://api.github.com'
            }
        }
        // ...
    }
}

其中,devServetr 里面 proxy 则是关于代理的配置,该属性为对象的形式,对象中每一个属性就是一个代理的规则匹配。

属性的名称是需要被代理的请求路径前缀,一般为了辨别都会设置前缀为 /api,值为对应的代理匹配规则,对应如下:

  • target:表示的是代理到的目标地址。
  • pathRewrite:默认情况下,我们的 /api-hy 也会被写入到 URL 中,如果希望删除,可以使用 pathRewrite。
  • secure:默认情况下不接收转发到 https 的服务器上,如果希望支持,可以设置为 false。
  • changeOrigin:它表示是否更新代理后请求的 headers 中 host 地址。

7.2 原理

proxy 工作原理实质上是利用 http-proxy-middleware 这个 http 代理中间件,实现请求转发给其他服务器。比如下面的例子:

const express = require('express');
const proxy = require('http-proxy-middleware');

const app = express();

app.use('/api', proxy({target: 'http://www.xxx.com', changeOrigin: true}));
app.listen(3000);

// http://localhost:3000/api/foo/bar -> http://www.xxx.com/api/foo/bar

在上面的例子中,本地地址为http://localhost:3000,该浏览器发送一个前缀带有/api 标识的请求到服务端获取数据,但响应这个请求的服务器只是将请求转发到另一台服务器中。

7.3 跨域

在开发阶段, webpack-dev-server 会启动一个本地开发服务器,所以我们的应用在开发阶段是独立运行在 localhost 的一个端口上,而后端服务又是运行在另外一个地址上。所以在开发阶段中,由于浏览器同源策略的原因,当本地访问后端就会出现跨域请求的问题。

解决这种问题时,只需要设置 webpack proxy 代理即可。当本地发送请求的时候,代理服务器响应该请求,并将请求转发到目标服务器,目标服务器响应数据后再将数据返回给代理服务器,最终再由代理服务器将数据响应给本地,原理图如下:

跨域请求的问题

在代理服务器传递数据给本地浏览器的过程中,两者同源,并不存在跨域行为,这时候浏览器就能正常接收数据。

注意:服务器与服务器之间请求数据并不会存在跨域行为,跨域行为是浏览器安全策略限制。

八、如何借助 Webpack 来优化性能

作为一个项目的打包构建工具,在完成项目开发后经常需要利用 Webpack 对前端项目进行性能优化,常见的优化手段有如下几个方面:

  • JS 代码压缩
  • CSS 代码压缩
  • Html 文件代码压缩
  • 文件大小压缩
  • 图片压缩
  • Tree Shaking
  • 代码分离
  • 内联 chunk

8.1 JS 代码压缩

terser 是一个 JavaScript 的解释、绞肉机、压缩机的工具集,可以帮助我们压缩、丑化我们的代码,让 bundle 更小。在 production 模式下,webpack 默认就是使用 TerserPlugin 来处理我们的代码的。如果想要自定义配置它,配置方法如下。

const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
    ...
    optimization: {
        minimize: true,
        minimizer: [
            new TerserPlugin({
                parallel: true  // 电脑 cpu 核数-1
            })
        ]
    }
}

TerserPlugin 常用的属性如下:

  1. extractComments:默认值为 true,表示会将注释抽取到一个单独的文件中,开发阶段,我们可设置为 false ,不保留注释
  2. parallel:使用多进程并发运行提高构建的速度,默认值是 true,并发运行的默认数量: os.cpus().length – 1
  3. terserOptions:设置我们的 terser 相关的配置:
    • compress:设置压缩相关的选项;
    • mangle:设置丑化相关的选项,可以直接设置为 true;
    • toplevel:底层变量是否进行转换;
    • keep_classnames:保留类的名称;
    • keep_fnames:保留函数的名称。

8.2 CSS 代码压缩

CSS 压缩通常用于去除无用的空格等,不过因为很难去修改选择器、属性的名称、值等,所以我们可以使用另外一个插件:css-minimizer-webpack-plugin。配置如下:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
module.exports = {
    // ...
    optimization: {
        minimize: true,
        minimizer: [
            new CssMinimizerPlugin({
                parallel: true
            })
        ]
    }
}

8.3 Html 文件代码压缩

使用 HtmlWebpackPlugin 插件来生成 HTML 的模板时候,可以通过配置属性 minify 进行 html 优化,配置如下。

module.exports = {
    ...
    plugin:[
        new HtmlwebpackPlugin({
            ...
            minify:{
                minifyCSS:false, // 是否压缩 css
                collapseWhitespace:false, // 是否折叠空格
                removeComments:true // 是否移除注释
            }
        })
    ]
}

8.4 文件大小压缩

对文件的大小进行压缩,可以有效减少 http 传输过程中宽带的损耗,文件压缩需要用到 compression-webpack-plugin 插件,配置如下:

new ComepressionPlugin({
    test:/\.(css|js)$/,  // 哪些文件需要压缩
    threshold:500, // 设置文件多大开始压缩
    minRatio:0.7, // 至少压缩的比例
    algorithm:"gzip", // 采用的压缩算法
})

8.5 图片压缩

如果我们对 bundle 包进行分析,会发现图片等多媒体文件的大小是远远要比 js、css 文件要大的,所以图片压缩在打包方面也是很重要的。配置可以参考如下的方式:

module: {
  rules: [
    {
      test: /\.(png|jpg|gif)$/,
      use: [
        {
          loader: 'file-loader',
          options: {
            name: '[name]_[hash].[ext]',
            outputPath: 'images/',
          }
        },
        {
          loader: 'image-webpack-loader',
          options: {
            // 压缩 jpeg 的配置
            mozjpeg: {
              progressive: true,
              quality: 65
            },
            // 使用 imagemin**-optipng 压缩 png,enable: false 为关闭
            optipng: {
              enabled: false,
            },
            // 使用 imagemin-pngquant 压缩 png
            pngquant: {
              quality: '65-90',
              speed: 4
            },
            // 压缩 gif 的配置
            gifsicle: {
              interlaced: false,
            },
            // 开启 webp,会把 jpg 和 png 图片压缩为 webp 格式
            webp: {
              quality: 75
            }
          }
        }
      ]
    },
  ]
} 

8.6 Tree Shaking

Tree Shaking 是一个术语,在计算机中表示消除死代码,依赖于 ES Module 的静态语法分析。在 webpack 实现 Trss shaking 有两种不同的方案:

  • usedExports:通过标记某些函数是否被使用,之后通过 Terser 来进行优化的
  • sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用

usedExports的配置方法很简单,只需要将usedExports设为 true 即可,如下:

module.exports = {
    ...
    optimization:{
        usedExports
    }
}

sideEffects则用于告知 webpack compiler在编译时哪些模块有副作用,配置方法是在 package.json 中设置sideEffects属性。如果sideEffects设置为 false,就是告知 webpack 可以安全的删除未用到的exports,如果有些文件需要保留,可以设置为数组的形式。

"sideEffecis":[
    "./src/util/format.js", 
    "*.css" // 所有的 css 文件
]

8.7 代码分离

默认情况下,所有的 JavaScript 代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页的加载速度。如果可以分出出更小的 bundle,以及控制资源加载优先级,从而优化加载性能。

代码分离可以通过 splitChunksPlugin 来实现,该插件 webpack 已经默认安装和集成,只需要配置即可。

module.exports = {   
 ...    
    optimization:{    
        splitChunks:{       
             chunks:"all"     
                }  
        }}

splitChunks有如下几个属性:

  • Chunks:对同步代码还是异步代码进行处理
  • minSize: 拆分包的大小, 至少为 minSize,如何包的大小不超过 minSize,这个包不会拆分
  • maxSize: 将大于 maxSize 的包,拆分为不小于 minSize 的包
  • minChunks:被引入的次数,默认是 1

8.8 内联 chunk

可以通过 InlineChunkHtmlPlugin 插件将一些 chunk 的模块内联到 html,如 runtime 的代码(对模块进行解析、加载、模块信息相关的代码),代码量并不大但是必须加载的,比如:

const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
     module.exports = {  
           ...    plugin:[      
              new InlineChunkHtmlPlugin(HtmlWebpackPlugin,[/runtime.+\.js/]}

总结一下,Webpack 对前端性能的优化,主要是通过文件体积大小入手,主要的措施有分包、减少 Http 请求次数等。

九、提高 Webpack 的构建速度

随着功能和业务代码越来越多,相应的 Webpack 的构建时间也会越来越久,构建的效率也会越来越低,那如何提升 Webpack 构建速度,是前端工程化的重要一环。常用的手段有如下一些:

  • 优化 loader 配置;
  • 合理使用 resolve.extensions
  • 优化 resolve.modules
  • 优化 resolve.alias
  • 使用 DLLPlugin 插件;
  • 使用 cache-loader
  • terser 启动多线程;
  • 合理使用 sourceMap

9.1 优化 Loader 配置

在使用 Loader 时,可以通过配置includeexcludetest属性来匹配文件,通过includeexclude来规定匹配应用的loader。例如,下面是 ES6 项目中配置 babel-loader 的例子:

module.exports = {
  module: {
    rules: [
      {
        // 如果项目源码中只有 js 文件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
        use: ['babel-loader?cacheDirectory'],
        // 只对项目根目录下的 src 目录中的文件采用 babel-loader
        include: path.resolve(__dirname, 'src'),
      },
    ]
  },
};

9.2 合理 resolve.extensions

在开发中,我们会有各种各样的模块依赖,这些模块可能来自第三方库,也可能是自己编写的, resolve可以帮助 Webpack 从每个 require/import 语句中,找到需要引入到合适的模块代码。

具体来说,通过resolve.extensions是解析到文件时自动添加拓展名,默认情况如下:

module.exports = {
    ...
    extensions:[".warm",".mjs",".js",".json"]
}

当我们引入文件的时候,若没有文件后缀名,则会根据数组内的值依次查找。所以,处理配置的时候,不要随便把所有后缀都写在里面。

9.3 优化 resolve.modules

resolve.modules 用于配置 webpack 去哪些目录下寻找第三方模块,默认值为['node_modules']。所以,在项目构建时,可以通过指明存放第三方模块的绝对路径来减少寻找的时间。

module.exports = {
  resolve: {
    modules: [path.resolve(__dirname, 'node_modules')]   // __dirname 表示当前工作目录
  },
};

9.4 优化 resolve.alias

alias给一些常用的路径起一个别名,特别当我们的项目目录结构比较深的时候,一个文件的路径可能是./../../的形式,通过配置alias以减少查找过程。

module.exports = {
    ...
    resolve:{
        alias:{
            "@":path.resolve(__dirname,'./src')
        }
    }
}

9.5 使用 DLL Plugin 插件

DLL 全称是动态链接库,是为软件在 winodw 种实现共享函数库的一种实现方式,而 Webpack 也内置了 DLL 的功能,为的就是可以共享,不经常改变的代码,抽成一个共享的库。使用步骤分成两部分:

  • 打包一个 DLL 库
  • 引入 DLL 库

打包一个 DLL 库

Webpack 内置了一个 DllPlugin 可以帮助我们打包一个 DLL 的库文件,如下。

module.exports = {
    ...
    plugins:[
        new webpack.DllPlugin({
            name:'dll_[name]',
            path:path.resolve(__dirname,"./dll/[name].mainfest.json")
        })
    ]
}

引入 DLL 库

首先,使用 webpack 自带的 DllReferencePlugin 插件对 mainfest.json 映射文件进行分析,获取要使用的 DLL 库。然后,再通过 AddAssetHtmlPlugin 插件,将我们打包的 DLL 库引入到 Html 模块中。

module.exports = {
    ...
    new webpack.DllReferencePlugin({
        context:path.resolve(__dirname,"./dll/dll_react.js"),
        mainfest:path.resolve(__dirname,"./dll/react.mainfest.json")
    }),
    new AddAssetHtmlPlugin({
        outputPath:"./auto",
        filepath:path.resolve(__dirname,"./dll/dll_react.js")
    })
}

9.6 合理使用使用 cache-loader

在一些性能开销较大的 loader 之前添加 cache-loader,以将结果缓存到磁盘里,显著提升二次构建速度。比如:

module.exports = {
    module: {
        rules: [
            {
                test: /\.ext$/,
                use: ['cache-loader', ...loaders],
                include: path.resolve('src'),
            },
        ],
    },
};

需要说明的是,保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader。

9.7 开启多线程

开启多进程并行运行可以提高构建速度,配置如下:

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: true, //开启多线程
      }),
    ],
  },
};

十、 除了 Webpack 外,你还了解哪些模块管理工具

模块化是一种处理复杂系统分解为更好的可管理模块的方式。可以用来分割、组织和打包应用。每个模块完成一个特定的子功能,所有的模块按某种方法组装起来,成为一个整体。

在前端领域中,除了 Webpack 外,比较流行的模块打包工具还包括 Rollup、Parcel、snowpack 和最近风靡的 Vite。

10.1 Rollup

Rollup 是一款 ES Modules 打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。从作用上来看,Rollup 与 Webpack 非常类似。不过相比于 Webpack,Rollup 要小巧的多。现在很多苦都使用它进行打包,比如:Vue、React 和 three.js 等。

使用之前,可以使用npm install --global rollup 命令进行安装。Rollup 可以通过命令行接口(command line interface)配合可选配置文件(optional configuration file)来调用,或者可以通过 JavaScript API 来调用。运行 rollup --help 可以查看可用的选项和参数。

下面通过一个简单的例子,说明如何使用 Rollup 进行打包。首先,新建如下几个文件:

// ./src/messages.js
export default {
  hi: 'Hello World'
}

// ./src/logger.js
export const log = msg => {
  console.log('---------- INFO ----------')
  console.log(msg)
  console.log('--------------------------')
}

export const error = msg => {
  console.error('---------- ERROR ----------')
  console.error(msg)
  console.error('---------------------------')
}

// ./src/index.js
import { log } from './logger'
import messages from './messages'
log(messages.hi)

最后,再使用下面的命令打包即可。

npx rollup ./src/index.js --file ./dist/bundle.js

参考:Rollup.js

10.2 Parcel

Parcel ,是一款完全零配置的前端打包器,它提供了 “傻瓜式” 的使用体验,只需了解简单的命令,就能构建前端应用程序。

使用 Parcel 的流程如下:

  1. 创建目录并使用npm init -y初始化package.json
  2. 安装模块npm i parcel-bundler --save-dev
  3. 创建 src/index.html 文件作为入口文件,虽然 Parcel 支持任意文件为打包入口,但是还是推荐我们使用 HTML 文件作为打包入口,官方理由是 HTML 是浏览器运行的入口,故应该使用 HTML 作为打包入口。

使用之前,需要在 package.json 中配置脚本,如下:

"scripts": {
    "parcel": "parcel"
},

Parcel 跟 Webpack 一样都支持以任意类型文件作为打包入口,但建议使用 HTML 文件作为入口,如下。

<!--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>
</head>
<body>
  <script src="main.js"></script>
</body>
</html>

然后,在 main.js 文件通过 ES Moudle 方法导入其他模块成员。

// ./src/main.js
import { log } from './logger'
log('hello parcel')

// ./src/logger.js
export const log = msg => {
  console.log('---------- msg ----------')
  console.log(msg)
}

然后,使用如下的命令即可打包:

npx parcel src/index.html

执行命令后,Parcel 不仅打包了应用,同时也启动了一个开发服务器,跟 webpack Dev Server 的效果是一样的。

10.3 Vite

Vite 是 Vue 的作者尤雨溪开发的 Web 开发构建工具,它是一个基于浏览器原生 ES 模块导入的开发服务器,在开发环境下,利用浏览器去解析 import,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随启随用。同时不仅对 Vue 文件提供了支持,还支持热更新,而且热更新的速度不会随着模块增多而变慢。

Vite 具有以下特点:

  • 快速的冷启动;
  • 即时热模块更新(HMR,Hot Module Replacement);
  • 真正按需编译。

Vite 由两部分组成:

  • 一个开发服务器,它基于 原生 ES 模块 提供了丰富的内建功能,如速度快到惊人的 [模块热更新 HMR;
  • 一套构建指令,它使用 Rollup 打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源。

Vite 在开发阶段可以直接启动开发服务器,不需要进行打包操作,也就意味着不需要分析模块的依赖、不需要编译,因此启动速度非常快。当浏览器请求某个模块的时候,根据需要对模块的内容进行编译,大大缩短了编译时间。

工作原理如下图所示:

vite 工作原理

在热模块 HMR 方面,当修改一个模块的时候,仅需让浏览器重新请求该模块即可,无须像 Webpack 那样需要把该模块的相关依赖模块全部编译一次,因此效率也更高。

相关阅读推荐:关于 Webpack 面试题及答案的整理

「点点赞赏,手留余香」

1

给作者打赏,鼓励TA抓紧创作!

微信微信 支付宝支付宝

还没有人赞赏,快来当第一个赞赏的人吧!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。
码云笔记 » Webpack常见面试题总结(二)

发表回复