wepack 透视——提高工程化(原理篇)

133 阅读12分钟

wepack 透视——提高工程化(原理篇)

webpack 是我们前端工程师必须掌握的一项技能,我们的日常开发已经离不开这个配置。关于这方面的文章已经很多,但还是想把自己的学习过程总结记录下来。 一共两篇文章,分为原理篇和实践篇,从 webpack 构建原理开始,然后基于这个原理之上,明确我们实际工程配置中可以去优化的方向。

  • 构建原理篇,先帮助大家知道整体打包构建的流程,已经了解的可以略过这篇,直接看下一篇实践篇。
  • 构建优化实践篇,主要从 2 个方向去优化
    • 提升构建速度,也就是减少整个打包构建的时间,
    • 优化构建输出,也就是减小我们最终构建输出的文件体积。

1. webpack 究竟解决了什么问题

  • 模块化解决方案

在早前web前端只需要一个简单的 html 页面,插入几条script标签 去引用 js 文件就可以满足需求,随着项目越来越复杂,要实现的功能越来越多,文件也越来越多,全部都这么引入已经不再现实,这时候前端模块化就出现了,从AMD、CMD 到现在的 ES6 模块化写法,我们可以把代码拆成一个个 JS 文件,通过 import 去关联依赖文件,最后再通过某个打包工具把这么多 js 文件按照依赖关系最终打包成一个或多个 js 文件在html 页面去引入。 所以 webpack首要要解决的问题是将多个模块化的 js文件 按照依赖关系打包为一个或多个文件,所以我们通常都会说他是一个模块化解决方案

  • 处理资源转换

随着 ES6,ES7,ES8 的出现,还有 vue、react 等前端框架的出现,我们发现这些文件浏览器是不能直接执行的,需要我们中间编译转换一下为浏览器可执行的文件,所以这时候 webpack 要做的事情又多了一项,按照依赖打包的同时,还要对源文件进行编译转换处理,也就是我们日常配置的 loader 处理。

  • tree-shaking以及代码压缩

现在webpack已经支持了对文件编译转换后再进行打包,满足了我们的基本需求。这时候我们又开始对性能提出了要求,希望打包出的体积越小越好。比如有些文件虽然整个引用了,但其实真正只用了其中部分代码,没用到的部分希望可以被剔除掉。这种是通过剔除无效代码来减小总的打包体积,另外一种方式是通过代码压缩,比如空格、较长的函数名都可以被压缩。因此webpack支持了 tree-shaking和代码压缩。

  • 代码拆分(异步加载 + 抽出第三方公用库)

现在 webpack 打包结果是不是做到了极致了呢?不行,我们还是嫌弃最终打包出的文件体积太大了。这时候懒加载(异步加载)出现了,你只需要把进入首页时所需要的所有资源打包为一个文件输出就行,这样进入首页我只需要加载该文件就行,其他资源文件等我真正执行的时候再去加载就可以。就这样,webpack又支持了异步加载文件的拆包功能,这时候我们最终打包出的主文件只是当前首页需要的资源。

  • 开发辅助工具的提供

我们对于打包的基本需求以及性能需求终于得到了满足,又开始追求开发时的体验了,开发越便捷越好,webpack 就提供了一系列的开发辅助功能,比如 devserver,HMR 等等什么的帮助我们高效的开发。

现在我们回过头总结下看,webpack帮我们做了好多事啊。

  • 作为一个模块化解决方案,帮助我们将繁多的 JS 模块化文件按照依赖关系打包 为一个或多个文件输出
  • 支持针对文件指定文件进行编译转换后再打包
  • 支持针对打包后的内容优化、压缩处理 来减小总的文件体积
  • 支持异步加载以及其他拆包方式
  • 提供一系列开发辅助工具

现在大家有没有好奇webpack究竟是怎么去实现这么多功能的?

2. webpack 构建原理

  • 原理概述

webpack的构建从处理入口文件开始着手,首先解析入口文件,需要 经过 loader转换编译这时候就转换编译,转换完了开始分析是否有依赖文件,有依赖文件就接着处理依赖文件,流程和刚刚一致,需要编译转换就转换,然后接着解析依赖文件是否还有依赖文件,有再接着处理。就这样通过入口文件以及依赖关系,webpack 可以获取并处理所有的依赖文件。然后再基于这些文件做进一步的优化处理,比如 treeshaking 或者 代码压缩,最后生成为我们需要的一个或多个js 文件。先献上总的构建原理图,接下来按照每个模块去阐述。

2.1. 准备工作

首先是开始编译前的准备工作,我们在项目工程里会配置 webpack.config.js文件,里面是我们的一些自定义配置, webpack 首先会将我们的配置文件和它自己的默认配置做一个 merge,生成最终的一个配置文件,其次会将这个最终配置文件里的所有插件plugin在这个时候都注册好,在这里要提一下 webpack 的事件机制,他是基于一个 tapable库做的事件流控制,在整个的编译过程中暴露出各种hook,而我们写的 plugin 也就是去注册监听了某个 hook,在这个 hook 触发时,去执行我们的 plugin。(大家要看 webpack 的源码,一定要先去看下tapable这个库的用法,否则看起来会很累,一头雾水。)。 现在我们配置完成了,plugin也注册了,终于可以开始工作了。

2.2. 处理入口文件

前面说了它会从入口文件开始处理,在我们日常的入口文件配置中,我们有多个配置方式,可能会有单入口、多入口、甚至动态入口等多种形式。

// 入口文件
module.exports = {
  // 单入口
  entry: {
    main: './path/to/my/entry/file.js'
  },

  // 多入口
  entry: {
    app: './src/app.js',
    adminApp: './src/adminApp.js'
  },

  //  动态入口
  entry: () => new Promise((resolve) => resolve(['./demo', './demo2']))
};

在 webpack 的处理中多种入口最后都会转化为同一方法去处理,单入口不用说,多入口我可以先遍历,再去执行该方法,动态入口,我先执行函数再去处理,最终都会进入到 生成入口文件 module 实例阶段。
大家都说 webpack 中一切文件都是 module,那 module 是什么呢,其实他就是一个存了当前文件所有信息的一个对象而已,这个文件包含了以下信息。

module = {
  type,
  request,
  userRequest,
  rawRequest,
  loaders,
  resource,
  matchResource,
  parser,
  generator,
  resolveOptions
}

2.3 递归生成文件module 实例

  • resolve 阶段

我们已经做好了各种入口文件形式的兼容处理了,现在开始真正处理文件生成 module 实例。首先进入 resolve 阶段,它使用了一个 enhanced-resolve 库。它主要做了什么呢?想一想我们要处理文件,首先是不是要先知道文件在哪里?在入口文件的配置中,我们只配了相对路径,所以我们要先拿到该文件的绝对路径位置,拿到位置还不够,如果这个文件是 es6 编写的,我是需要对其转换的。那我怎么知道这个文件是否需要转换呢,需要的话,又是需要通过哪些 loader 进行转换呢?这是我们 resolve 阶段要处理的问题,通过我们的 resolve 配置和 rules 配置去获取到当前文件的绝对路径和需要经过哪些loader 进行处理,然后将这些信息存到我们当前这个文件对应的 module 实例里面。

resolve: {
    // 位于 src 文件夹下常用模块,创建别名,准确匹配
    alias: {
      xyz$: path.resolve(__dirname, 'path/to/file.js')
    },
    modules: ['node_modules'],  // 查找范围的减小
    extensions: ['.js', '.json'],  // import 文件未加文件后缀是,webpack 根据 extensions 定义的文件后缀进行依次查找
    mainFields: ['loader', 'main'],  // 减少入口文件的搜索步骤,设置第三方模块的入口文件位置
  },

  module: {
    rules: [
      {
        test: /\.js$/, // 匹配的文件
        use: 'babel-loader',
        // 在此文件范围内去查找
        include: [],
        // 此文件范围内不去查找
        exclude: file => (
          /node_modules/.test(file) &&
          !/\.vue\.js/.test(file)
        )
      }
    ]
  }

在解析对应要执行的 loaders 过程中,需要注意 loaders 组装的顺序,webpack 会优先处理 inline-loader。

import Styles from 'style-loader!css-loader?modules!./styles.css';

webpack 采用正则匹配的方式解析出要执行的 内联loader。在解析完内联 loader 后,根据配置的 rules,解析剩余的 loaders,组装得到最后的 loaders是一个数组,内容按照[postLoader, inlineLoader, normalLoader, preLoader]先后顺序组合。

注意:这里提到了几种不用类型的 loader,除了 inline-loader 前面介绍了,还有postLoader,preLoader是有 enforce 字段指定的:

 module: {
      rules: [
        {
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: require.resolve('eslint-loader'),
          exclude: /node_modules/
        },
      ]
    }

enforce 可以取值'pre'和'post',分别对应preLoader和postLoader,没有设置此字段的就是normalLoader。

  • 执行 loader 阶段

现在文件基本信息拿到了,发现需要经过 loader 进行处理,好,那我们下一阶段就是去执行他的 loaders,在这里提一点需要注意的地方,loader 的执行是倒序的,为什么他是倒序的呢,是因为loader的执行分为 2 个阶段,

  • pitching 阶段:执行 loader 上的 pitch 方法
  • normal 阶段:执行 loader 常规方法

下面给了一个demo,我们看看执行顺序:

module.exports = {
  //...
  module: {
    rules: [
      {
        //...
        use: [
          'a-loader',
          'b-loader',
          'c-loader'
        ]
      }
    ]
  }
};

我们定义了 3 个 loader,a-loader, b-loader, c-loader,它的执行顺序是 a.pitch-> b.pitch-> c.pitch-> c-loader-> b-loader-> a-loader。

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

之所以有这个设定,是因为中间存在一个逻辑,在 pitch 的执行过程中,一但出现了返回结果,后面的 loader 和 pitch 都不会执行。

比如说 a-loader的 pitch 执行返回的结果,那 b 和 c 的 pitch 和 loader 都不会执行,直接跳到 a-loader 的执行上。

在前面我们提到 loaders 的组装顺序是[postLoader, inlineLoader, normalLoader, preLoader],最终对应的执行顺序如下:

  1. pitching 阶段: postLoader.pitch -> inlineLoader.pitch -> normalLoader.pitch -> preLoader.pitch
  2. normal 阶段: preLoader -> normalLoader -> inlineLoader -> postLoader

执行完 loader 后,也就是对文件做了编译转换,使其变成了最终可以被浏览器执行的代码。

  • parse 阶段

这时候我们要开始处理依赖了,那我怎么知道当前文件有依赖呢?webpack 是采用将loader 执行过后的源文件source转换为AST 去分析依赖。这里使用了acorn 库,将source生成对应的 AST。生成的 AST 划分为 3部分,ImportDeclaration、FunctionDeclaration和VariablesDeclaration,接下来遍历 AST 去收集依赖。 找到 import 等关键字去达到依赖收集的目的。

  • 递归处理依赖阶段

基于我们解析到的依赖文件,我们要开始递归处理依赖了,又回到了我们处理入口文件的整个流程,去生成依赖文件的 module 实例,再执行 对应loader。就这样 webpack 递归处理了所有的依赖文件并完成了所有文件的转换。 前面说了,我们最终是要把这些文件打包为一个或者多个文件输出的,那接下来是不是要对这些文件做一个整合和优化处理?

2.4 生成 chunk

  • 生成 Module-graph

由于这个时候我们是已经拿到了所有文件的 module 实例以及依赖关系,可以先建立基本的一个 module-graph 了,下面我给了一个 demo,a.js作为入口文件,红色的是异步依赖,绿色的是同步依赖。

  • 生成 Basic-chunk-graph

在讲具体的拆包之前,先描述下 module 、chunk和 chunkgroup 之间的关系: 如图所示,chunkGroup 包含多个 chunk,chunk 包含多个 module。webpack 会先划分出 chunkGroup,然后再根据用户自定义的拆包配置,从 chunkGroup 中拆出多个 chunk 最为最终的文件输出。 现在要基于Module-graph进行一个分包操作,分包的依据是异步依赖。首先入口文件会作为一个chunk-group,在分析依赖的过程中解析到异步依赖就回去划分 chunk-group,可以看到最后划分了 4 个chunk-group 。

  • 生成最终的 chunk-graph

我们可以分析下这4 个 chunk-group, 里面有的模块存在多次引用,比如 chunk-group2 只有个 d.js,chunk-group1里面已经包含了 d.js,这时候 chunkgroup2 会被剔除,就这样最后只剩 2 个 chunk-group。这时候集合我们的optimization 配置,来划分最终的输出 chunk 文件。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

2.6 优化

我们已经对所有的 module 实例进行了划分为一个个的 chunk,这时要遍历 chunk做一些优化操作

  • 首先生成对应的 moduleId ,不做任何配置的话,默认采用以自增 id 的方式,这里推荐 hash 的方式,有利于缓存
  • 基于生成的 moduleId进行排序
  • 接着类似于 module 的操作,对应生成 chunkId ,并根据 chunkId进行排序。
  • 分别为 module 和chunk 生成hash
module.exports = {
  //...
  optimization: {
    moduleIds: 'hashed'
  }
};

2.5 生成文件

基于前面已经优化的chunk,现在终于到了最后的生成打包文件环节了。 webpack 把这些文件按照内置的 template 渲染生成最终的打包文件。

3. 总结

总结一下 webpack 的整个构建打包过程,首先通过依赖关系和 loader 配置获取经过编译转换后的所有module 实例,然后再根据配置进行拆分为一个或多个chunk,最后按照内置的template 渲染出最终的文件输出。

作者:吴海元