面试专项训练——webpack_未完待续

开始之前

几天没见面试题,很焦虑。今天找找 webpack 的面试题看看。

问题记录

webpack treeShakiing 机制的原理是什么

Tree shaking 是一种通过清除多余代码方式来优化项目打包体积的技术。

Tree shaking 如何工作

ES6 前,我们可以使用 CommonJS 引入模块:require(),这种引入是动态的,也意味着我们可以基于条件来导入需要的代码:

let dynamicModule;
// 动态导入
if (condition) {
  myDynamicModule = require('foo');
} else {
  myDynamicModule = require('bar');
}

但是 CommonJS 规范无法确定在实际运行前需要或者不需要某些模块,所以 CommonJS 不适合 tree-shaking 机制。

在 ES6 中,引入了完全静态的导入语法:import。这也意味着下面的导入是不可行的:

// 不可行,ES6 的import是完全静态的
if (condition) {
  myDynamicModule = require('foo');
} else {
  myDynamicModule = require('bar');
}

我们只能通过导入所有的包后再进行条件获取。如下:

import foo from 'foo';
import bar from 'bar';

if (condition) {
  // foo.xxxx
} else {
  // bar.xxx
}

我们只能通过导入所有的包后再进行条件获取。如下:

import foo from 'foo';
import bar from 'bar';

if (condition) {
  // foo.xxxx
} else {
  // bar.xxx
}

ES6 的 import 语法可以完美使用 tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

因为 tree shaking 只能在静态 modules 下工作。ECMAScript 6 模块加载是静态的,因此整个依赖树可以被静态地推导出解析语法树。所以在 ES6 中使用 tree shaking 是非常容易的。

Tree Shaking 原理总结

  • ES6 Module 引入进行静态分析,故而编译的时候正确判断到底加载了那些模块
  • 静态分析程序流,判断那些模块和变量未被使用或者引用,进而删除对应代码

CommonJS 是一种模块规范,最初被应用于 Nodejs,成为 Nodejs 的模块规范。运行在浏览器端的 JavaScript 由于也缺少类似的规范,在 ES6 出来之前,前端也实现了一套相同的模块规范 (例如: AMD),用来对前端模块进行管理。自 ES6 起,引入了一套新的 ES6 Module 规范,在语言标准的层面上实现了模块功能,而且实现得相当简单,有望成为浏览器和服务器通用的模块解决方案。但目前浏览器对 ES6 Module 兼容还不太好,我们平时在 Webpack 中使用的 export 和 import,会经过 Babel 转换为 CommonJS 规范。在使用上的差别主要有:

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

  3. CommonJs 是单个值导出,ES6 Module 可以导出多个

4。 CommonJs 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层

5。 CommonJs 的 this 是当前模块,ES6 Module 的 this 是 undefined

webpack 类似的工具还有哪些,它们之间有什么区别

模块化工具

模块化是一种处理复杂系统分解为更好的可管理模块的方式,可以用来分割,组织和打包应用。

每个模块完成一个特定的子功能,所有模块按某种方法组装起来,成为一个整体(bundle)

模块打包工具除了 webpack 外,还有:

  • Rollup
  • Parcel
  • Snowpack
  • Vite

gulp 和 grunt 只是定义为构建工具,不参与类比

Rollup

rollup 是一款 ES Modules 打包器,作用和 webpack 相似。不过比 webpack 要小巧一些,Vue、React 和 three.js 都在使用。

// ./src/messages.js
export default {
  hi: 'Hey Guys, I am zce~',
};

// ./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);

rollup ./src/index.js –file ./dist/bundle.js

打包结果如下:

//bundle.js
const log = (msg) => {
  console.log('---------- INFO ----------');
  console.log(msg);
  console.log('--------------------------');
};

var messages = {
  hi: 'Hey Guys, I am zce~',
};

// 导入模块成员

// 使用模块成员
log(messages.hi);

可以看到代码很简洁,没有像 webpack 那样存在引导代码和模块。

error 方法没有被调用,输出结果中就没有 error 方法,这是 rollup 默认开启 Tree-shaking 优化了输出结果。

  • 输出代码简洁、效率更高
  • 默认支持 Tree-shaking

但是缺点在于

  • 加载其他类型资源文件或者支持导入 CommonJS 模块,又或是编译 ES 新特性等这些额外的需求,需要使用插件去完成。

不适合开发应用使用。因为需要使用第三方模块,而且第三方模块大都使用 CommonJS 方式导出成员,并且 rollup 不支持 HMR,开发效率不如 webpack。

Parcel

傻瓜式前端打包器,只需要简单的命令即可构建前端 app。

和 webpack 一样支持任意类型文件作为打包入口

但建议使用 HTML 文件为入口,该 HTML 文件像正常开发一样编写代码,引用资源。如下:

<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Parcel Tutorials</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('---------- INFO ----------');
  console.log(msg);
};

使用打包命令:

parcel src/index.html

执行命令后,parcel 不仅会打包应用,同时也会开启一个开发服务器,和 webpack Dev Server 一样

支持 HMR,且用法简单

自动安装依赖。

webpack 开发阶段突然使用安装某个第三方依赖,必然会终止 dev server 然后安装再启动。而 Parcel 则免了这繁琐的工作流程。

Parcel 能够零配置加载其他类型的资源文件,无须像 webpack 那样配置对应的 loader

打包是多进程的,构建速度比 webpack 快。输出文件也会被默认压缩,css 会被单独提取到单个文件中。

Snowpack

较复杂的打包工具(如 Webpack 或 Parcel)的替代方案,利用 JavaScript 的本机模块系统,避免不必要的工作并保持流畅的开发体验。

开发阶段,每次保存单个文件时,Webpack 和 parcel 都需要重新构建和打包应用程序的整个 bundle。而 snowpack 为每个文件构建一次,就可以永久缓存,文件更改时,snowpack 会重新构建该单个文件。

下图为 webpack 与 snowpack 打包区别:

snowpack

可以 snowpack 在重新构建每次变更时,几乎没有是将浪费,只需要在浏览器中进行 HMR 更新。

Vite

是一种新型前端构建工具,能够显著提升前端开发体验。

它主要由两部分组成:

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

其作用类似于 webpack+webpack dev server,其特点如下:

  • 快速的冷启动
  • 即时的 HMR
  • 真正的按需编译

Vite 会直接启动开发服务器,不需要进行打包操作。所以它不需要分析依赖、不需要编译,因此启动速度非常快。

利用浏览器支持 ES Module 的特性,当浏览器请求到某个模块的时候,再根据需要对模块的内容进行编译,这种方式可以缩短编译时间。

Vite

HMR 时,当修改一个模块的时候,仅需让浏览器重新请求该模块即可,无需像 webpack 那样把该模块的相关依赖全部编译一次。

webpack

相比于上述的模块化工具,webpack 大而全,很多常用的功能开箱即用。

最大的特点是一切皆模块和按需加载

相比于其他构建工具,具有以下优势:

  • 高兼容性:对 CommonJS、AMD、ES6 的语法做了兼容
  • 万物皆模块:对 JS、CSS、图片等资源文件都支持打包
  • 开箱即用:提供 HMR、Tree-Shaking 等功能
  • 代码分割:可以将代码切割成不同的 chunk,实现按需加载,降低了初始化时间。
  • 插件系统:具有强大的 plugin 接口,具有更好的灵活性和扩展性
  • 易于调试:支持 sourceUrls 和 SourceMaps
  • 快速运行:wenpack 使用异步 IO 并具有多级缓存,这使得 webpack 很快,在增量编译上更加快
  • 生态环境:社区内容丰富,出现问题容易解决。

如何提高 webpack 的构建速度

如果项目涉及到的页面越多,功能和业务代码不断增长,webpack 的构建时间也会加长,会影响开发效率。

所以有了以下常见的构建优化方法:

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

优化 loader 配置

在使用 loader 时,可以通过配置 inclued、exclued、test 属性来匹配文件,通过 includ、exclude 规定哪些匹配应用 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'),
      },
    ],
  },
};

合理使用 resolve.extensions

resolve 可以帮助 webpack 从每个 require/import 语句中,找到需要引入的合适的模块代码。

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

module.exports = {
  // ...
  resolve: {
    extensions: ['.warm', '.mjs', '.js', '.json'],
  },
};

当我们引入文件时,若没有文件后缀名,则会根据数组内的值依次查找

当我们配置时,则不要随便把所有后缀都写在里面,这会调用多次文件的查找,减慢打包速度。

优化 resolve.modules

resolve.modules 用于配置 webpack 去哪些目录下寻找第三方模块。默认为[“node_modules”],所以默认会从 node_modules 中查找

当安装的第三方模块都放在项目根目录下的./node_modules 目录下时,可以指明第三方模块存放的绝对路径,减少寻找:

module.exports = {
  resolve: {
    // 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前工作目录,也就是项目根目录
    modules: [path.resolve(__dirname, 'node_modules')],
  },
};

这在 webpack5 里已经是默认配置了吧…

resolve.alias

alias 给一些常用的路径起了一个别名,特别时当项目目录层级深时时,某个文件的路径可能是./../../../……的形式

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

使用 DLLPlugin 插件

DLL 动态链接库,是为软件在 windows 中实现共享函数库的一种实现方式,webpack 内置了 DLL 功能,可将共享的,不常改动的代码,抽离为一个共享的库。这个库在之后的编译中,会被引入到其他项目的代码中。

分两步

  • 打包
module.exports = {
    ...
    plugins:[
        new webpack.DllPlugin({
            name:'dll_[name]',
            path:path.resolve(__dirname,"./dll/[name].mainfest.json")
        })
    ]
}
  • 引入
module.exports = {
    ...
    // 对mainfest.json映射文件进行分析,获取要使用的DLL库
    new webpack.DllReferencePlugin({
        context:path.resolve(__dirname,"./dll/dll_react.js"),
        mainfest:path.resolve(__dirname,"./dll/react.mainfest.json")
    }),

    // 将DLL引入HTML中
    new AddAssetHtmlPlugin({
        outputPath:"./auto",
        filepath:path.resolve(__dirname,"./dll/dll_react.js")
    })
}

使用 cache-loader

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

保存和读取缓存文件会有一些时间开销,所以只适用于性能开销较大的 loader

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

terser 启动多线程

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        parallel: true,
      }),
    ],
  },
};

合理使用 sourceMap

打包生成 sourceMap 的时候,如果信息越详细,打包速度就会越慢。

sourceMap

如何借助 webpack 来优化前端性能

JS 代码压缩

terser 是一个用于 JS 压缩和混淆的 plugin。可以使得打包后输出的 bundle 更小。

production 模式下,webpack 默认使用的就是 TerserPlugin 来处理 JS

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

    默认值 true,表示会将注释抽取到一个单独的文件中,开发阶段可以设置为 false,不保留注释。

  • parallel

    使用多进程并发进行构建,提高构建速度,默认为 true,线程数为 os.cup().length - 1.

  • terserOptions 其他配置

    • compress:设置压缩选项
    • mangle:设置混淆选项
    • toplevel:设置变量是否进行转换
    • keep_classname:保留类名称
    • keep_fnames:保留函数名称
    • …

CSS 代码压缩

CSS 压缩通常都是去除一些无用的空格

在 webpack 里可以用 css-minimizer-webpack-plugin 插件来压缩 CSS 代码

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

Html 文件代码压缩

同样的,可以用 HtmlWebpackPlugin 来对 HTML 问价进行压缩

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

除了设置 minify,还会使用到另一个插件 html-minifier-terser

文件大小压缩

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

图片压缩

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,
            },
          },
        },
      ],
    },
  ];
}

Tree Shaking

说白了就是删除掉冗余代码

依赖于 ES modules 的静态语法分析

有两种方案

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

usedExports
module.exports = {
  //...
  optimization: {
    usedExports,
  },
};

使用之后,没被用上的代码在 webpack 打包中会加入 unused harmony export mul 注释,用来告知 Terser 在优化时,可以删除掉这段代码

sideEffects

配置方法是在 package.json 中设置 sideEffects 属性

如果 sideEffects 设置为 false,就是告知 webpack 可以安全的删除未用到的 exports

如果有些文件需要保留,可以设置为数组的形式

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

还可以对 CSS 进行 tree shaking

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const PurgeCssPlugin = require('purgecss-webpack-plugin');

module.exports = {
  //...
  modules: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]'.css,
    }),
    new PurgecssPlugin({
      // src里面的所有文件
      paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
      satelist: function () {
        return { standard: ['html'] };
      },
    }),
  ],
};

到这里我发现我目前来总结和回答 webpack 问题是不合理的,因为没有基础…

总结一定是在系统学习的基础上来进行的,这篇文章难产就是因为 webpack 其实并不了解

mark 一下,目前是 22 年 3 月 22 日 21 点 44 分,我决定先去学习 webpack 基础和原理 完成基础学习后再回头来看这些面试问题 PS: 今天收到了一家公司的入职邀请,可能有点小激动,博客要继续写下去,总结要继续做下去

今天是 2022 年 3 月 28 号,经过几天对 webpack 的研究,我又觉得自己站起来了,来,我们继续!

代码分离

把代码分离到不同的 bundle 中

使用 splitChunksPlugin

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
};

splitChunks 有几个属性:

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

内联 chunk

可以通过 InlineChunkHtmlPlugin 插件将一些 chunk 的模块内联到 html

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

引申问题:说说常规的前端性能优化手段

content 方面

  • 减少 HTTP 请求:合并文件、CSS 精灵、inline Image
  • 减少 DNS 查询:DNS 查询完成之前浏览器不能从这个主机下载任何任何文件。方法:DNS 缓存、将资源分布到恰当数量的主机名,平衡并行下载和 DNS 查询
  • 避免重定向:多余的中间访问
  • 使 Ajax 可缓存
  • 非必须组件延迟加载
  • 未来所需组件预加载
  • 减少 DOM 元素数量
  • 将资源放到不同的域下:浏览器同时从一个域下载资源的数目有限,增加域可以提高并行下载量
  • 减少 iframe 数量
  • 不要 404

Server 方面

  • 使用 CDN
  • 添加 Expires 或者 Cache-Control 响应头
  • 对组件使用 Gzip 压缩
  • 配置 ETag
  • Flush Buffer Early
  • Ajax 使用 GET 进行请求
  • 避免空 src 的 img 标签
  • 减小 cookie 大小
  • 引入资源的域名不要包含 cookie
  • css 方面
  • 将样式表放到页面顶部
  • 不使用 CSS 表达式
  • 不使用 IE 的 Filter

Javascript 方面

  • 将脚本放到页面底部
  • 将 javascript 和 css 从外部引入
  • 压缩 javascript 和 css
  • 删除不需要的脚本
  • 减少 DOM 访问
  • 合理设计事件监听器

图片方面

  • 优化图片:根据实际颜色需要选择色深、压缩
  • 优化 css 精灵
  • 不要在 HTML 中拉伸图片
  • 保证 favicon.ico 小并且可缓存

webpack 的热更新是如何做到的?原理是什么?

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

原理

HMR

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

在编写未经过 webpack 打包的源代码后,webpack compiler 将源代码和 HMR Runtime 一起编译成 bundle 文件,传输给 bundle server 静态资源服务器

当一个文件或资源发生变化时,webpack 监听到文件变化,对文件重新编译并打包,编译生成唯一的 hash 值,这个 hash 值用于作为下次 HMR 的标识。

根据变化的内容生成两个补丁文件:manifest(包含了 hash 和 chundId ,用来说明变化的内容)和 chunk.js 模块

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

在浏览器接受到这条消息之前,浏览器已经在上一次 socket 消息中已经记住了此时的 hash 标识,这时候我们会创建一个 ajax 去服务端请求获取到变化内容的 manifest 文件

mainfest 文件包含重新 build 生成的 hash 值,以及变化的模块。

浏览器根据 manifest 文件获取模块变化的内容,从而触发 render 流程,实现局部模块更新。

总结

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

webpack proxy 的工作原理,为什么能解决跨域

webpack proxy,即 webpack 提供的代理服务。

webpack 中提供服务器的工具为 webpack-dev-server。它将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起,目的是为了提高开发者日常的开发效率,只适用在开发阶段。

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

工作原理实质上是利用 http-proxy-middleware 这个 http 代理中间件,实现请求转发给其他服务器.

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

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

const app = express();

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

// http://localhost:3000/api/foo/bar -> http://www.example.org/api/foo/bar

webpack 中有哪些常见的 plugin,解决了什么问题

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

本质是一个具有 apply 方法的 js 对象

apply 会被 webpack compiler 调用,并且在整个编译生命周期都可以访问 compiler 对象。

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

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

module.exports = ConsoleLogOnBuildWebpackPlugin;

编译生命周期钩子函数:

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

常见 plugin:

  • HtmlWebpackPlugin 在打包结束后,⾃动生成⼀个 html ⽂文件,并把打包生成的 js 模块引⼊到该 html 中
  • mini-css-extract-plugin 提取 CSS 到一个单独的文件中
  • DefinePlugin 允许在编译时创建配置的全局对象,是一个 webpack 内置的插件,不需要安装
  • copy-webpack-plugin 复制文件或目录到执行区域,如 vue 的打包过程中,如果我们将一些文件放到 public 的目录下,那么这个目录会被复制到 dist 文件夹中
  • uglifyjs-webpack-plugin 通过 UglifyES 压缩 ES6 代码
  • webpack-bundle-analyzer 可视化 webpack 输出文件的体积

webpack 中有哪些常见的 loader,解决了什么问题

loader 用于对模块的“源代码”进行转换,在 import 或“加载”模块时预处理文件

webpack 做的事情,仅仅是分析出各种模块的依赖关系,然后形成资源列表,最终打包生成到指定的文件中。

在 webpack 内部中,任何文件都是模块,不仅仅只是 js 文件。默认情况下,在遇到 import 或者 load 加载模块的时候,webpack 只支持对 js 文件打包,像 css、sass、png 等这些类型的文件的时候,webpack 则无能为力,这时候就需要配置对应的 loader 进行文件内容的解析

当 webpack 碰到不识别的模块的时候,webpack 会在配置的中查找该文件解析规则

关于配置 loader 的方式有三种:

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

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

  • rules 是一个数组的形式,因此我们可以配置很多个 loader

  • 每一个 loader 对应一个对象的形式,对象属性 test 为匹配的规则,一般情况为正则表达式

  • 属性 use 针对匹配到文件类型,调用对应的 loader 进行处理

loader 支持链式调用,链中的每个 loader 会处理之前已处理过的资源,最终变为 js 代码。

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

常见的 loader 如下:

  • style-loader: 将 css 添加到 DOM 的内联样式标签 style 里
  • css-loader :允许将 css 文件通过 require 的方式引入,并返回 css 代码
  • less-loader: 处理 less
  • sass-loader: 处理 sass
  • postcss-loader: 用 postcss 来处理 CSS
  • file-loader: 分发文件到 output 目录并返回相对路径
  • url-loader: 和 file-loader 类似,但是当文件小于设定的 limit 时可以返回一个 Data Url
  • html-minify-loader: 压缩 HTML
  • babel-loader :用 babel 来转换 ES6 文件到 ES

webpack 的构建流程

大体分为三个步骤

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

初始化

从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。

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;
}
// ...

编译构建

根据配置中的 entry 找出所有的入口文件

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

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

执行了 run 方法后,首先会触发 compile,主要是构建一个 Compilation 对象

该对象是编译阶段的主要执行者,主要会依次下述流程:执行模块创建、依赖收集、分块、打包等主要任务的对象

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 内容的过程

2022 年 3 月 28 日,刚刚觉得自己站起来了又跪在了这里…可恶啊!我去手撕一遍 webpack 实现,等回来继续写

手撕了一个简单版本,上传了 github 2022 年 3 月 19 日

build module 完成模块编译

这里主要调用配置的 loaders,将我们的模块转成标准的 JS 模块

在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析

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

输出流程

seal 输出资源

seal 方法主要是要生成 chunks,对 chunks 进行一系列的优化操作,并生成要输出的代码

webpack 中的 chunk ,可以理解为配置在 entry 中的模块,或者是动态引入的模块

根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表

emit 输出完成

在确定好输出内容后,根据配置确定输出的路径和文件名

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

在 Compiler 开始生成文件前,钩子 emit 会被执行,这是我们修改最终文件的最后一个机会

webpack

对 webpack 的理解,它解决了哪些问题?

在开发过程中会遇到以下问题

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

webpack 解决了浏览器对于 ES6 的兼容问题,开发中的各种资源(JS、Html、CSS、图片,字体,文本等)模块化问题,对各种资源的管理问题,极大的提升了开发效率,软件兼容性和可维护性。

loader 和 plugin 的区别,编写 loader 和 plugin 的思路

loader 是文件加载器,负责加载资源文件,并对文件进行一些处理,如编译、压缩等,最终一起打包到指定文件中。

plugin 赋予了 webpack 各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是解决 loader 无法实现的其他事

loader 运行在打包文件之前 plugins 在整个编译周期都起作用

在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果

对于 loader,实质是一个转换器,将 A 文件进行编译形成 B 文件,操作的是文件,比如将 A.scss 或 A.less 转变为 B.css,单纯的文件转换过程

其本质为函数,函数中的 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; // 同步
};

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

webpack 编译会创建两个核心对象:

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

如果自己要实现 plugin,也需要遵循一定的规范:

  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问 compiler 实例
  • 传给每个插件的 compiler 和 compilation 对象都是同一个引用,因此不建议修改
  • 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住
class MyPlugin {
  // Webpack 会调用 MyPlugin 实例的 apply 方法给插件实例传入 compiler 对象
  apply(compiler) {
    // 找到合适的事件钩子,实现自己的插件功能
    compiler.hooks.emit.tap('MyPlugin', (compilation) => {
      // compilation: 当前打包构建流程的上下文
      console.log(compilation);

      // do something...
    });
  }
}

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

babel 的原理是什么

  • 解析 Parse: 将代码解析生成抽象语法树( 即 AST ),即词法分析与语法分析的过程
  • 转换 Transform: 对于 AST 进行变换一系列的操作,babel 接收得到 AST 并通过 babel-traverse 对其进行遍历,在此过程中进行添加、更新及移除等操作
  • 生成 Generate: 将变换后的 AST 再转换为 JS 代码, 使用到的模块是 babel-generator

谈谈对 babel-polyfill 的了解

babel polyfill 有三种:

  • babel-polyfill
  • babel-runtime
  • babel-plugin-transform-runtime

babel-polyfill 通过向全局对象和内置对象的 prototype 上添加方法来实现的。所以这会造成全局空间污染。

babel-polyfill 使用的两种方式:

webpack.config.js 中: 配置 webpack.config.js 里的 entry 设置为

entry: ['babel-polyfill', path.join(__dirname, 'index.js')];

业务 js 中: 在 webpack.config.js 配置的主入口 index.js 文件的最顶层键入

import 'babel-polyfill';

简单说 babel-runtime 更像是一种按需加载的实现

import Promise from 'babel-runtime/core-js/promise';

babel-plugin-transform-runtime 装了就不需要装 babel-runtime 了,因为前者依赖后者。 总的来说,babel-plugin-transform-runtime 就是可以在我们使用新 API 时 自动 import babel-runtime 里面的 polyfill,具体插件做了以下三件事情:

  • 当我们使用 async/await 时,自动引入 babel-runtime/regenerator
  • 当我们使用 ES6 的静态事件或内置对象时,自动引入 babel-runtime/core-js
  • 移除内联 babel helpers 并替换使用 babel-runtime/helpers 来替换

babel-plugin-transform-runtime 优点:

  • 不会污染全局变量
  • 多次使用只会打包一次
  • 依赖统一按需引入,无重复引入,无多余引入
  • 避免 babel 编译的工具函数在每个模块里重复出现,减小库和工具包的体积

使用方式:

在 .babelrc 中配置:

plugins: [“tranform-runtime”]

如何提高 webpack 的打包速度

  • happypack: 利用进程并行编译 loader,利用缓存来使得 rebuild 更快,遗憾的是作者表示已经不会继续开发此项目,类似的替代者是 thread-loader
  • 外部扩展(externals): 将不怎么需要更新的第三方库脱离 webpack 打包,不被打入 bundle 中,从而减少打包时间,比如 jQuery 用 script 标签引入
  • dll: 采用 webpack 的 DllPlugin 和 DllReferencePlugin 引入 dll,让一些基本不会改动的代码先打包成静态资源,避免反复编译浪费时间
  • 利用缓存: webpack.cache、babel-loader.cacheDirectory、HappyPack.cache 都可以利用缓存提高 rebuild 效率
  • 缩小文件搜索范围: 比如 babel-loader 插件,如果你的文件仅存在于 src 中,那么可以 include: path.resolve(__dirname, ‘src’),当然绝大多数情况下这种操作的提升有限,除非不小心 build 了 node_modules 文件

谈谈你对 webpack 的认识

WebPack 是一个模块打包工具,可以使用 WebPack 管理模块依赖,并编译输岀模块所需的静态文件。它能够很好地管理与打包 Web 开发中所用到的 HTML、 JavaScript 、CSS 以及各种静态文件(图片、字体等),让开发过程更加高效。对于不同类型的资源, WebPack 有对应的模块加载器。Web Pack 模块打包器会分析模块间的依赖关系,最后生成优化且合并后的静态资源。

体会

勉强过了 webpack 进阶,就是 webpack 实践和源码分析上差点意思了。

评论0

  • 还没有评论,来说点什么吧。