0
点赞
收藏
分享

微信扫一扫

【webpack学习】

暮晨夜雪 2024-11-06 阅读 25

一.webpack基础

1.模块化

2.构建的作用及常见功能

3.常见的构建工具及其优缺点

4.webpack

5.loader

6.plugin

7.DevServer

8.webpack的核心概念

二.webpack配置

1.Entry

2.Output

3.Module

4.Resolve

5.Plugin

6.DevServer

7.其他配置

8.如何整体配置结构

const path = require('path');

module.exports = {
  // entry 表示 入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  // 类型可以是 string | object | array   
  entry: './app/entry', // 只有1个入口,入口只有1个文件
  entry: ['./app/entry1', './app/entry2'], // 只有1个入口,入口有2个文件
  entry: { // 有2个入口
    a: './app/entry-a',
    b: ['./app/entry-b1', './app/entry-b2']
  },

  // 如何输出结果:在 Webpack 经过一系列处理后,如何输出最终想要的代码。
  output: {
    // 输出文件存放的目录,必须是 string 类型的绝对路径。
    path: path.resolve(__dirname, 'dist'),

    // 输出文件的名称
    filename: 'bundle.js', // 完整的名称
    filename: '[name].js', // 当配置了多个 entry 时,通过名称模版为不同的 entry 生成不同的文件名称
    filename: '[chunkhash].js', // 根据文件内容 hash 值生成文件名称,用于浏览器长时间缓存文件

    // 发布到线上的所有资源的 URL 前缀,string 类型
    publicPath: '/assets/', // 放到指定目录下
    publicPath: '', // 放到根目录下
    publicPath: 'https://cdn.example.com/', // 放到 CDN 上去

    // 导出库的名称,string 类型
    // 不填它时,默认输出格式是匿名的立即执行函数
    library: 'MyLibrary',

    // 导出库的类型,枚举类型,默认是 var
    // 可以是 umd | umd2 | commonjs2 | commonjs | amd | this | var | assign | window | global | jsonp ,
    libraryTarget: 'umd', 

    // 是否包含有用的文件路径信息到生成的代码里去,boolean 类型
    pathinfo: true, 

    // 附加 Chunk 的文件名称
    chunkFilename: '[id].js',
    chunkFilename: '[chunkhash].js',

    // JSONP 异步加载资源时的回调函数名称,需要和服务端搭配使用
    jsonpFunction: 'myWebpackJsonp',

    // 生成的 Source Map 文件名称
    sourceMapFilename: '[file].map',

    // 浏览器开发者工具里显示的源码模块名称
    devtoolModuleFilenameTemplate: 'webpack:///[resource-path]',

    // 异步加载跨域的资源时使用的方式
    crossOriginLoading: 'use-credentials',
    crossOriginLoading: 'anonymous',
    crossOriginLoading: false,
  },

  // 配置模块相关
  module: {
    rules: [ // 配置 Loader
      {  
        test: /\.jsx?$/, // 正则匹配命中要使用 Loader 的文件
        include: [ // 只会命中这里面的文件
          path.resolve(__dirname, 'app')
        ],
        exclude: [ // 忽略这里面的文件
          path.resolve(__dirname, 'app/demo-files')
        ],
        use: [ // 使用那些 Loader,有先后次序,从后往前执行
          'style-loader', // 直接使用 Loader 的名称
          {
            loader: 'css-loader',      
            options: { // 给 html-loader 传一些参数
            }
          }
        ]
      },
    ],
    noParse: [ // 不用解析和处理的模块
      /special-library\.js$/  // 用正则匹配
    ],
  },

  // 配置插件
  plugins: [
  ],

  // 配置寻找模块的规则
  resolve: { 
    modules: [ // 寻找模块的根目录,array 类型,默认以 node_modules 为根目录
      'node_modules',
      path.resolve(__dirname, 'app')
    ],
    extensions: ['.js', '.json', '.jsx', '.css'], // 模块的后缀名
    alias: { // 模块别名配置,用于映射模块
       // 把 'module' 映射 'new-module',同样的 'module/path/file' 也会被映射成 'new-module/path/file'
      'module': 'new-module',
      // 使用结尾符号 $ 后,把 'only-module' 映射成 'new-module',
      // 但是不像上面的,'module/path/file' 不会被映射成 'new-module/path/file'
      'only-module$': 'new-module', 
    },
    alias: [ // alias 还支持使用数组来更详细的配置
      {
        name: 'module', // 老的模块
        alias: 'new-module', // 新的模块
        // 是否是只映射模块,如果是 true 只有 'module' 会被映射,如果是 false 'module/inner/path' 也会被映射
        onlyModule: true, 
      }
    ],
    symlinks: true, // 是否跟随文件软链接去搜寻模块的路径
    descriptionFiles: ['package.json'], // 模块的描述文件
    mainFields: ['main'], // 模块的描述文件里的描述入口的文件的字段名称
    enforceExtension: false, // 是否强制导入语句必须要写明文件后缀
  },

  // 输出文件性能检查配置
  performance: { 
    hints: 'warning', // 有性能问题时输出警告
    hints: 'error', // 有性能问题时输出错误
    hints: false, // 关闭性能检查
    maxAssetSize: 200000, // 最大文件大小 (单位 bytes)
    maxEntrypointSize: 400000, // 最大入口文件大小 (单位 bytes)
    assetFilter: function(assetFilename) { // 过滤要检查的文件
      return assetFilename.endsWith('.css') || assetFilename.endsWith('.js');
    }
  },

  devtool: 'source-map', // 配置 source-map 类型

  context: __dirname, // Webpack 使用的根目录,string 类型必须是绝对路径

  // 配置输出代码的运行环境
  target: 'web', // 浏览器,默认
  target: 'webworker', // WebWorker
  target: 'node', // Node.js,使用 `require` 语句加载 Chunk 代码
  target: 'async-node', // Node.js,异步加载 Chunk 代码
  target: 'node-webkit', // nw.js
  target: 'electron-main', // electron, 主线程
  target: 'electron-renderer', // electron, 渲染线程

  externals: { // 使用来自 JavaScript 运行环境提供的全局变量
    jquery: 'jQuery'
  },

  stats: { // 控制台输出日志控制
    assets: true,
    colors: true,
    errors: true,
    errorDetails: true,
    hash: true,
  },

  devServer: { // DevServer 相关的配置
    proxy: { // 代理到后端服务接口
      '/api': 'http://localhost:3000'
    },
    contentBase: path.join(__dirname, 'public'), // 配置 DevServer HTTP 服务器的文件根目录
    compress: true, // 是否开启 gzip 压缩
    historyApiFallback: true, // 是否开发 HTML5 History API 网页
    hot: true, // 是否开启模块热替换功能
    https: false, // 是否开启 HTTPS 模式
    },

    profile: true, // 是否捕捉 Webpack 构建的性能信息,用于分析什么原因导致构建性能不佳

    cache: false, // 是否启用缓存提升构建速度

    watch: true, // 是否开始
    watchOptions: { // 监听模式选项
    // 不监听的文件或文件夹,支持正则匹配。默认为空
    ignored: /node_modules/,
    // 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
    // 默认为300ms 
    aggregateTimeout: 300,
    // 判断文件是否发生变化是不停的去询问系统指定文件有没有变化,默认每隔1000毫秒询问一次
    poll: 1000
  },
}

9.判断如何配置webpack

三.webpack实战

1.如何接入es6

2.如何接入scss

3.React的使用

4.vue的使用

5.使用angular2框架

6.为单页面应用生成html

7.离线应用

离线应用是指通过离线缓存技术,让资源在第一次被加载后缓存在本地,下次访问它时就直接返回本地的文件,就算没有网络连接。

  • 在没有网络的情况下也能打开网页。
  • 由于部分被缓存的资源直接从本地加载,对用户来说可以加速网页加载速度,对网站运营者来说可以减少服务器压力以及传输流量费用。

离线应用的核心是离线缓存技术,历史上曾先后出现2种离线离线缓存技术,它们分别是:

  • AppCache又叫 Application Cache,目前已经从 Web 标准中删除,请尽量不要使用它。
  • Service Workers是目前最新的离线缓存技术,是Web Worker的一部分。 它通过拦截网络请求实现离线缓存,比 AppCache 更加灵活。它也是构建PWA应用的关键技术之一。

Service Workers是一个在浏览器后台运行的脚本,它生命周期完全独立于网页。它无法直接访问 DOM,但可以通过 postMessage 接口发送消息来和 UI 进程通信。 拦截网络请求是 Service Workers 的一个重要功能,通过它能完成离线缓存、编辑响应、过滤响应等功能。

目前 Chrome、Firefox、Opera 都已经全面支持 Service Workers,但对于移动端浏览器就不太乐观了,只有高版本的 Android 支持。 由于 Service Workers 无法通过注入 polyfill 去实现兼容,所以在你打算使用它前请先调查清楚你的网页的运行场景。

// 如果 navigator 对象上存在 serviceWorker 对象,就表示支持
if (navigator.serviceWorker) {
  // 通过 navigator.serviceWorker 使用
}

要给网页接入 Service Workers,需要在网页加载后注册一个描述 Service Workers 逻辑的脚本。 代码如下:

if (navigator.serviceWorker) {
  window.addEventListener('DOMContentLoaded',function() {
    // 调用 serviceWorker.register 注册,参数 /sw.js 为脚本文件所在的 URL 路径
      navigator.serviceWorker.register('/sw.js');
  });
}


一旦这个脚本文件被加载,Service Workers 的安装就开始了。这个脚本被安装到浏览器中后,就算用户关闭了当前网页,它仍会存在。 也就是说第一次打开该网页时 Service Workers 的逻辑不会生效,因为脚本还没有被加载和注册,但是以后再次打开该网页时脚本里的逻辑将会生效。

在 Chrome 中可以通过打开网址chrome://inspect/#service-workers来查看当前浏览器中所有注册了的 Service Workers。

8.检查代码

检查代码主要检查以下几项:

  • 代码风格:让项目成员强制遵守统一的代码风格,例如如何缩进、如何写注释等,保障代码可读性,不把时间浪费在争论如何写代码更好看上;
  • 潜在问题:分析出代码在运行过程中可能出现的潜在 Bug。

在做代码风格检查时需要按照不同的文件类型来检查,下面来分别介绍。

目前最常用的 JavaScript 检查工具是ESlint,它不仅内置了大量常用的检查规则,还可以通过插件机制做到灵活扩展。

结合Webpack

eslint-loader可以方便的把 ESLint 整合到 Webpack 中,使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        // node_modules 目录的下的代码不用检查
        exclude: /node_modules/,
        loader: 'eslint-loader',
        // 把 eslint-loader 的执行顺序放到最前面,防止其它 Loader 把处理后的代码交给 eslint-loader 去检查
        enforce: 'pre',
      },
    ],
  },
}


接入 eslint-loader 后就能在控制台中看到 ESLint 输出的错误日志了。

TSLint是一个和 ESlint 相似的 TypeScript 代码检查工具,区别在于 TSLint 只专注于检查 TypeScript 代码

结合Webpack

tslint-loader是一个和 eslint-loader 相似的 Webpack Loader, 能方便的把 TSLint 整合到 Webpack,其使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.ts$/,
        // node_modules 目录的下的代码不用检查
        exclude: /node_modules/,
        loader: 'tslint-loader',
        // 把 tslint-loader 的执行顺序放到最前面,防止其它 Loader 把处理后的代码交给 tslint-loader 去检查
        enforce: 'pre',
      },
    ],
  },
}

stylelint是目前最成熟的 CSS 检查工具,内置了大量检查规则的同时也提供插件机制让用户自定义扩展。 stylelint 基于 PostCSS,能检查任何 PostCSS 能解析的代码,诸如 SCSS、Less 等。

结合Webpack

StyleLintPlugin能把 stylelint 整合到 Webpack,其使用方法很简单,如下:

const StyleLintPlugin = require('stylelint-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new StyleLintPlugin(),
  ],
}

把代码检查功能整合到 Webpack 中会导致以下问题:

  • 由于执行检查步骤计算量大,整合到 Webpack 中会导致构建变慢;
  • 在整合代码检查到 Webpack 后,输出的错误信息是通过行号来定位错误的,没有编辑器集成显示错误直观;

为了避免以上问题,还可以这样做:

  • 使用集成了代码检查功能的编辑器,让编辑器实时直观地显示错误;
  • 把代码检查步骤放到代码提交时,也就是说在代码提交前去调用以上检查工具去检查代码,只有在检查都通过时才提交代码,这样就能保证提交到仓库的代码都是通过了检查的。

如果你的项目是使用 Git 管理,Git 提供了 Hook 功能能做到在提交代码前触发执行脚本。

四.webpack优化(高频面试部分)

1.缩小文件搜索范围

  • 优化 loader 配置
  • 优化 resolve.modules 配置
  • 优化 resolve.mainFields 配置
  • 优化 resolve.alias 配置
  • 优化 resolve.extensions 配置
  • 优化 module.noParse 配置

1.1优化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'),
      },
    ]
  },
};

1.2优化resolve.modules

1.3优化resolve.mainFields

以isomorphic-fetch为例,它是fetch API的一个实现,但可同时用于浏览器和 Node.js 环境。 它的package.json中就有2个入口文件描述字段:

{
  "browser": "fetch-npm-browserify.js",
  "main": "fetch-npm-node.js"
}


resolve.mainFields的默认值和当前的target配置有关系,对应关系如下:

  • targetweb或者webworker时,值是["browser", "module", "main"]
  • target为其它情况时,值是["module", "main"]

target等于web为例,Webpack 会先采用第三方模块中的browser字段去寻找模块的入口文件,如果不存在就采用module字段,以此类推。

为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用main字段去描述入口文件的位置,可以这样配置 Webpack:

module.exports = {
  resolve: {
    // 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
    mainFields: ['main'],
  },
};


1.4优化resolve.alias

在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,安装到node_modules目录下的 React 库的目录结构如下:

├── dist
│   ├── react.js
│   └── react.min.js
├── lib
│   ... 还有几十个文件被忽略
│   ├── LinkedStateMixin.js
│   ├── createClass.js
│   └── React.js
├── package.json
└── react.js


可以看到发布出去的 React 库中包含两套代码:

  • 一套是采用 CommonJS 规范的模块化代码,这些文件都放在lib目录下,以package.json中指定的入口文件react.js为模块的入口。
  • 一套是把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中dist/react.js是用于开发环境,里面包含检查和警告的代码。dist/react.min.js是用于线上环境,被最小化了。

默认情况下 Webpack 会从入口文件./node_modules/react/react.js开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置resolve.alias可以让 Webpack 在处理 React 库时,直接使用单独完整的react.min.js文件,从而跳过耗时的递归解析操作。

相关 Webpack 配置如下:

module.exports = {
  resolve: {
    // 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.min.js 文件,
    // 减少耗时的递归解析操作
    alias: {
      'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // react15
      // 'react': path.resolve(__dirname, './node_modules/react/umd/react.production.min.js'), // react16
    }
  },
};


1.5优化resolve.extensions

1.6优化module.noParse

2.构建动态链接库

2.1如何接入webpack

Webpack 已经内置了对动态链接库的支持,需要通过2个内置的插件接入,它们分别是:

  • DllPlugin 插件:用于打包出一个个单独的动态链接库文件。
  • DllReferencePlugin 插件:用于在主要配置文件中去引入 DllPlugin 插件打包好的动态链接库文件。

下面以基本的 React 项目为例,为其接入 DllPlugin,在开始前先来看下最终构建出的目录结构:

├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json


其中包含两个动态链接库文件,分别是:

  • polyfill.dll.js里面包含项目所有依赖的 polyfill,例如 Promise、fetch 等 API。
  • react.dll.js里面包含 React 的基础运行环境,也就是 react 和 react-dom 模块。

react.dll.js文件为例,其文件内容大致如下:

var _dll_react = (function(modules) {
  // ... 此处省略 webpackBootstrap 函数代码
}([
  function(module, exports, __webpack_require__) {
    // 模块 ID 为 0 的模块对应的代码
  },
  function(module, exports, __webpack_require__) {
    // 模块 ID 为 1 的模块对应的代码
  },
  // ... 此处省略剩下的模块对应的代码 
]));


可见一个动态链接库文件中包含了大量模块的代码,这些模块存放在一个数组里,用数组的索引号作为 ID。 并且还通过_dll_react变量把自己暴露在了全局中,也就是可以通过window._dll_react可以访问到它里面包含的模块。

其中polyfill.manifest.jsonreact.manifest.json文件也是由 DllPlugin 生成出,用于描述动态链接库文件中包含哪些模块, 以react.manifest.json文件为例,其文件内容大致如下:

{
  // 描述该动态链接库文件暴露在全局的变量名称
  "name": "_dll_react",
  "content": {
    "./node_modules/process/browser.js": {
      "id": 0,
      "meta": {}
    },
    // ... 此处省略部分模块
    "./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
      "id": 42,
      "meta": {}
    },
    "./node_modules/react/lib/lowPriorityWarning.js": {
      "id": 47,
      "meta": {}
    },
    // ... 此处省略部分模块
    "./node_modules/react-dom/lib/SyntheticTouchEvent.js": {
      "id": 210,
      "meta": {}
    },
    "./node_modules/react-dom/lib/SyntheticTransitionEvent.js": {
      "id": 211,
      "meta": {}
    },
  }
}


可见manifest.json文件清楚地描述了与其对应的dll.js文件中包含了哪些模块,以及每个模块的路径和 ID。

main.js文件是编译出来的执行入口文件,当遇到其依赖的模块在dll.js文件中时,会直接通过dll.js文件暴露出的全局变量去获取打包在dll.js文件的模块。 所以在index.html文件中需要把依赖的两个dll.js文件给加载进去,index.html内容如下:

<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--导入依赖的动态链接库文件-->
<script src="./dist/polyfill.dll.js"></script>
<script src="./dist/react.dll.js"></script>
<!--导入执行入口文件-->
<script src="./dist/main.js"></script>
</body>
</html>

2.2如何实现构建动态链接库

构建输出的以下这四个文件

├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json


和以下这一个文件

├── main.js


是由两份不同的构建分别输出的。

动态链接库文件相关的文件需要由一份独立的构建输出,用于给主构建使用。新建一个 Webpack 配置文件webpack_dll.config.js专门用于构建它们,文件内容如下:

const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');

module.exports = {
  // JS 执行入口文件
  entry: {
    // 把 React 相关模块的放到一个单独的动态链接库
    react: ['react', 'react-dom'],
    // 把项目需要所有的 polyfill 放到一个单独的动态链接库
    polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
  },
  output: {
    // 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
    // 也就是 entry 中配置的 react 和 polyfill
    filename: '[name].dll.js',
    // 输出的文件都放到 dist 目录下
    path: path.resolve(__dirname, 'dist'),
    // 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
    // 之所以在前面加上 _dll_ 是为了防止全局变量冲突
    library: '_dll_[name]',
  },
  plugins: [
    // 接入 DllPlugin
    new DllPlugin({
      // 动态链接库的全局变量名称,需要和 output.library 中保持一致
      // 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
      // 例如 react.manifest.json 中就有 "name": "_dll_react"
      name: '_dll_[name]',
      // 描述动态链接库的 manifest.json 文件输出时的文件名称
      path: path.join(__dirname, 'dist', '[name].manifest.json'),
    }),
  ],
};

构建出的动态链接库文件用于给其它地方使用,在这里也就是给执行入口使用。

用于输出main.js的主 Webpack 配置文件内容如下:

const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');

module.exports = {
  entry: {
    // 定义入口 Chunk
    main: './main.js'
  },
  output: {
    // 输出文件的名称
    filename: '[name].js',
    // 输出文件都放到 dist 目录下
    path: path.resolve(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        // 项目源码使用了 ES6 和 JSX 语法,需要使用 babel-loader 转换
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules'),
      },
    ]
  },
  plugins: [
    // 告诉 Webpack 使用了哪些动态链接库
    new DllReferencePlugin({
      // 描述 react 动态链接库的文件内容
      manifest: require('./dist/react.manifest.json'),
    }),
    new DllReferencePlugin({
      // 描述 polyfill 动态链接库的文件内容
      manifest: require('./dist/polyfill.manifest.json'),
    }),
  ],
  devtool: 'source-map'
};


在修改好以上两个 Webpack 配置文件后,需要重新执行构建。 重新执行构建时要注意的是需要先把动态链接库相关的文件编译出来,因为主 Webpack 配置文件中定义的 DllReferencePlugin 依赖这些文件。

执行构建时流程如下:

  1. 如果动态链接库相关的文件还没有编译出来,就需要先把它们编译出来。方法是执行webpack --config webpack_dll.config.js命令。
  2. 在确保动态链接库存在时,才能正常的编译出入口执行文件。方法是执行webpack命令。这时你会发现构建速度有了非常大的提升。

3.如何把任务分解给多个子进程去并发的执行

它把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程。

在整个 Webpack 构建流程中,最耗时的流程可能就是 Loader 对文件的转换操作了,因为要转换的文件数据巨多,而且这些转换操作都只能一个个挨着处理。 HappyPack 的核心原理就是把这部分任务分解到多个进程去并行处理,从而减少了总的构建时间。

从前面的使用中可以看出所有需要通过 Loader 处理的文件都先交给了happypack/loader去处理,收集到了这些文件的处理权后 HappyPack 就好统一分配了。

每通过new HappyPack()实例化一个 HappyPack 其实就是告诉 HappyPack 核心调度器如何通过一系列 Loader 去转换一类文件,并且可以指定如何给这类转换操作分配子进程。

核心调度器的逻辑代码在主进程中,也就是运行着 Webpack 的进程中,核心调度器会把一个个任务分配给当前空闲的子进程,子进程处理完毕后把结果发送给核心调度器,它们之间的数据交换是通过进程间通信 API 实现的。

核心调度器收到来自子进程处理完毕的结果后会通知 Webpack 该文件处理完毕。

const HappyPack = require('happypack');
 
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'happypack/loader?id=js'
      }
    ]
  },
  plugins: [
    new HappyPack({
      id: 'js',
      loaders: ['babel-loader?cacheDirectory']
    })
  ]
};

4.多进程压缩代码

ParallelUglifyPlugin 会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过 UglifyJS 去压缩代码,但是变成了并行执行。 所以 ParallelUglifyPlugin 能更快的完成对多个文件的压缩工作。

使用 ParallelUglifyPlugin 也非常简单,把原来 Webpack 配置文件中内置的 UglifyJsPlugin 去掉后,再替换成 ParallelUglifyPlugin,相关代码如下:

const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');

module.exports = {
  plugins: [
    // 使用 ParallelUglifyPlugin 并行压缩输出的 JS 代码
    new ParallelUglifyPlugin({
      // 传递给 UglifyJS 的参数
      uglifyJS: {
        output: {
          // 最紧凑的输出
          beautify: false,
          // 删除所有的注释
          comments: false,
        },
        compress: {
          // 在UglifyJs删除没有用到的代码时不输出警告
          warnings: false,
          // 删除所有的 `console` 语句,可以兼容ie浏览器
          drop_console: true,
          // 内嵌定义了但是只用到一次的变量
          collapse_vars: true,
          // 提取出出现多次但是没有定义成变量去引用的静态值
          reduce_vars: true,
        }
      },
    }),
  ],
};

在通过new ParallelUglifyPlugin()实例化时,支持以下参数:

  • test:使用正则去匹配哪些文件需要被 ParallelUglifyPlugin 压缩,默认是/.js$/,也就是默认压缩所有的 .js 文件。
  • include:使用正则去命中需要被 ParallelUglifyPlugin 压缩的文件。默认为[]
  • exclude:使用正则去命中不需要被 ParallelUglifyPlugin 压缩的文件。默认为[]
  • cacheDir:缓存压缩后的结果,下次遇到一样的输入时直接从缓存中获取压缩后的结果并返回。cacheDir 用于配置缓存存放的目录路径。默认不会缓存,想开启缓存请设置一个目录路径。
  • workerCount:开启几个子进程去并发的执行压缩。默认是当前运行电脑的 CPU 核数减去1。
  • sourceMap:是否输出 Source Map,这会导致压缩过程变慢。
  • uglifyJS:用于压缩 ES5 代码时的配置,Object 类型,直接透传给 UglifyJS 的参数。
  • uglifyES:用于压缩 ES6 代码时的配置,Object 类型,直接透传给 UglifyES 的参数。

接入 ParallelUglifyPlugin 后,项目需要安装新的依赖:

npm i -D webpack-parallel-uglify-plugin

  • UglifyES是 UglifyJS 的变种,专门用于压缩 ES6 代码,它们两都出自于同一个项目,并且它们两不能同时使用。

  • UglifyES 一般用于给比较新的 JavaScript 运行环境压缩代码,例如用于 ReactNative 的代码运行在兼容性较好的 JavaScriptCore 引擎中,为了得到更好的性能和尺寸,采用 UglifyES 压缩效果会更好。

  • ParallelUglifyPlugin 同时内置了 UglifyJS 和 UglifyES,也就是说 ParallelUglifyPlugin 支持并行压缩 ES6 代码。

5.使用自动刷新

Webpack 支持文件监听相关的配置项如下:

module.export = {
  // 只有在开启监听模式时,watchOptions 才有意义
  // 默认为 false,也就是不开启
  watch: true,
  // 监听模式运行时的参数
  // 在开启监听模式时,才有意义
  watchOptions: {
    // 不监听的文件或文件夹,支持正则匹配
    // 默认为空
    ignored: /node_modules/,
    // 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
    // 默认为 300ms
    aggregateTimeout: 300,
    // 判断文件是否发生变化是通过不停的去询问系统指定文件有没有变化实现的
    // 默认每隔1000毫秒询问一次
    poll: 1000
  }
}


两种

  • 1.在配置文件webpack.config.js中设置watch: true
  • 2.在执行启动 Webpack 命令时,带上--watch参数,完整命令是webpack --watch
  1. 定时的去获取这个文件的最后编辑时间,每次都存下最新的最后编辑时间,如果发现当前获取的和最后一次保存的最后编辑时间不一致,就认为该文件发生了变化。(配置项中的watchOptions.poll就是用于控制定时检查的周期,具体含义是每隔多少毫秒检查一次。)
  2. 当发现某个文件发生了变化时,并不会立刻告诉监听者,而是先缓存起来,收集一段时间的变化后,再一次性告诉监听者。(配置项中的watchOptions.aggregateTimeout就是用于配置这个等待时间。 这样做的目的是因为我们在编辑代码的过程中可能会高频的输入文字导致文件变化的事件高频的发生,如果每次都重新执行构建就会让构建卡死。)

对于多个文件来说,原理相似,只不过会对列表中的每一个文件都定时的执行检查。 但是这个需要监听的文件列表是怎么确定的呢? 默认情况下 Webpack 会从配置的 Entry 文件出发,递归解析出 Entry 文件所依赖的文件,把这些依赖的文件都加入到监听列表中去。 可见 Webpack 这一点还是做的很智能的,不是粗暴的直接监听项目目录下的所有文件。

由于保存文件的路径和最后编辑时间需要占用内存,定时检查周期检查需要占用 CPU 以及文件 I/O,所以最好减少需要监听的文件数量和降低检查频率。

6.热模块替换

原理是当一个源码发生变化时,只重新编译发生变化的模块,再用新输出的模块替换掉浏览器中对应的老模块。

  • 实时预览反应更快,等待时间更短。
  • 不刷新浏览器能保留当前网页的运行状态,例如在使用 Redux 来管理数据的应用中搭配模块热替换能做到代码更新时 Redux 中的数据还保持不变。

都需要往要开发的网页中注入一个代理客户端用于连接 DevServer 和网页, 不同在于模块热替换独特的模块替换机制。

DevServer 默认不会开启模块热替换模式,要开启该模式,只需在启动时带上参数--hot,完整命令是webpack-dev-server --hot

还可以通过接入 Plugin 实现,相关代码如下:

const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');

module.exports = {
  entry:{
    // 为每个入口都注入代理客户端
    main:['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server','./src/main.js'],
  },
  plugins: [
    // 该插件的作用就是实现模块热替换,实际上当启动时带上 `--hot` 参数,会注入该插件,生成 .hot-update.json 文件。
    new HotModuleReplacementPlugin(),
  ],
  devServer:{
    // 告诉 DevServer 要开启模块热替换模式
    hot: true,      
  }  
};

原因在于style-loader会注入用于接受 CSS 的代码。

6.1优化模块热替换

Updated modules: 68是指 ID 为68的模块被替换了,这对开发者来说很不友好,因为开发者不知道 ID 和模块之间的对应关系,最好是把替换了的模块的名称输出出来。 Webpack 内置的 NamedModulesPlugin 插件可以解决该问题,修改 Webpack 配置文件接入该插件:

const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin');

module.exports = {
  plugins: [
    // 显示出被替换模块的名称
    new NamedModulesPlugin(),
  ],
};


重启构建后你会发现浏览器中的日志更加友好了:

除此之外,模块热替换还面临着和自动刷新一样的性能问题,因为它们都需要监听文件变化和注入客户端。 要优化模块热替换的构建性能,思路:监听更少的文件,忽略掉node_modules目录下的文件。 但是其中提到的关闭默认的 inline 模式手动注入代理客户端的优化方法不能用于在使用模块热替换的情况下, 原因在于模块热替换的运行依赖在每个 Chunk 中都包含代理客户端的代码。

7.区分环境

在开发网页的时候,一般都会有多套运行环境,例如:

  1. 在开发过程中方便开发调试的环境。
  2. 发布到线上给用户使用的运行环境。

这两套不同的环境虽然都是由同一套源代码编译而来,但是代码内容却不一样,差异包括:

  • 线上代码被特定的方法压缩过。
  • 开发用的代码包含一些用于提示开发者的提示日志,这些日志普通用户不可能去看它。
  • 开发用的代码所连接的后端数据接口地址也可能和线上环境不同,因为要避免开发过程中造成对线上数据的影响。

具体区分方法很简单,在源码中通过如下方式:

if (process.env.NODE_ENV === 'production') {
  console.log('你正在线上环境');
} else {
  console.log('你正在使用开发环境');
}

7.1实现原理

原理是借助于环境变量的值去判断执行哪个分支。

当你的代码中出现了使用process模块的语句时,Webpack 就自动打包进 process 模块的代码以支持非 Node.js 的运行环境。 当你的代码中没有使用 process 时就不会打包进 process 模块的代码。这个注入的 process 模块作用是为了模拟 Node.js 中的 process,以支持上面使用的process.env.NODE_ENV === 'production'语句。
在构建线上环境代码时,需要给当前运行环境设置环境变量NODE_ENV = 'production',Webpack 相关配置如下:

const DefinePlugin = require('webpack/lib/DefinePlugin');

module.exports = {
  plugins: [
    new DefinePlugin({
      // 定义 NODE_ENV 环境变量为 production
      'process.env': {
        NODE_ENV: JSON.stringify('production')
      }
    }),
  ],
};


执行构建后,你会在输出的文件中发现如下代码:

if (true) {
  console.log('你正在使用线上环境');
} else {
  console.log('你正在使用开发环境');
}


定义的环境变量的值被代入到了源码中,process.env.NODE_ENV === 'production'被直接替换成了true。 并且由于此时访问 process 的语句被替换了而没有了,Webpack 也不会打包进 process 模块了。

DefinePlugin 定义的环境变量只对 Webpack 需要处理的代码有效,而不会影响 Node.js 运行时的环境变量的值。

通过 Shell 脚本的方式去定义的环境变量,例如NODE_ENV=production webpack,Webpack 是不认识的,对 Webpack 需要处理的代码中的环境区分语句是没有作用的。

也就是说只需要通过 DefinePlugin 定义环境变量就能使上面介绍的环境区分语句正常工作,没必要又通过 Shell 脚本的方式去定义一遍。

如果你想让 Webpack 使用通过 Shell 脚本的方式去定义的环境变量,你可以使用 EnvironmentPlugin,代码如下:

new webpack.EnvironmentPlugin(['NODE_ENV'])


以上这句代码实际上等价于:

new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
})

8.CDN加速

8.1 用webpack实现cdn的接入(面试问过)

总结上面所说的,构建需要实现以下几点:

  • 静态资源的导入 URL 需要变成指向 CDN 服务的绝对路径的 URL 而不是相对于 HTML 文件的 URL。
  • 静态资源的文件名称需要带上有文件内容算出来的 Hash 值,以防止被缓存。
  • 不同类型的资源放到不同域名的 CDN 服务上去,以防止资源的并行加载被阻塞。

先来看下要实现以上要求的最终 Webpack 配置:

const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const {WebPlugin} = require('web-webpack-plugin');

module.exports = {
  // 省略 entry 配置...
  output: {
    // 给输出的 JavaScript 文件名称加上 Hash 值
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, './dist'),
    // 指定存放 JavaScript 文件的 CDN 目录 URL
    publicPath: '//js.cdn.com/id/',
  },
  module: {
    rules: [
      {
        // 增加对 CSS 文件的支持
        test: /\.css$/,
        // 提取出 Chunk 中的 CSS 代码到单独的文件中
        use: ExtractTextPlugin.extract({
          // 压缩 CSS 代码
          use: ['css-loader?minimize'],
          // 指定存放 CSS 中导入的资源(例如图片)的 CDN 目录 URL
          publicPath: '//img.cdn.com/id/'
        }),
      },
      {
        // 增加对 PNG 文件的支持
        test: /\.png$/,
        // 给输出的 PNG 文件名称加上 Hash 值
        use: ['file-loader?name=[name]_[hash:8].[ext]'],
      },
      // 省略其它 Loader 配置...
    ]
  },
  plugins: [
    // 使用 WebPlugin 自动生成 HTML
    new WebPlugin({
      // HTML 模版文件所在的文件路径
      template: './template.html',
      // 输出的 HTML 的文件名称
      filename: 'index.html',
      // 指定存放 CSS 文件的 CDN 目录 URL
      stylePublicPath: '//css.cdn.com/id/',
    }),
    new ExtractTextPlugin({
      // 给输出的 CSS 文件名称加上 Hash 值
      filename: `[name]_[contenthash:8].css`,
    }),
    // 省略代码压缩插件配置...
  ],
};


以上代码中最核心的部分是通过publicPath参数设置存放静态资源的 CDN 目录 URL, 为了让不同类型的资源输出到不同的 CDN,需要分别在:

  • output.publicPath中设置 JavaScript 的地址。
  • css-loader.publicPath中设置被 CSS 导入的资源的的地址。
  • WebPlugin.stylePublicPath中设置 CSS 文件的地址。

设置好publicPath后,WebPlugin在生成 HTML 文件和css-loader转换 CSS 代码时,会考虑到配置中的publicPath,用对应的线上地址替换原来的相对地址。

比如webpack.prod.js文件配置:

const path = require('path')
const webpack = require('webpack')
const { smart } = require('webpack-merge')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserJSPlugin = require('terser-webpack-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const webpackCommonConf = require('./webpack.common.js')
const { distPath } = require('./paths.js')

module.exports = smart(webpackCommonConf, {
    mode: 'production',
    output: {
        // filename: 'bundle.[contentHash:8].js',  // 打包代码时,加上 hash 戳
        filename: '[name].[contentHash:8].js', // name 即多入口时 entry 的 key
        path: distPath,
        publicPath: '//js.cdn.com/id/',
        // publicPath: 'http://cdn.abc.com'  // 修改所有静态文件 url 的前缀(如 cdn 域名),这里暂时用不到
    },
    module: {
        rules: [
            // 图片 - 考虑 base64 编码的情况
            {
                test: /\.(png|jpg|jpeg|gif|svg)$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        // 小于 5kb 的图片用 base64 格式产出
                        // 否则,依然延用 file-loader 的形式,产出 url 格式
                        limit: 5 * 1024,

                        // 打包到 img 目录下
                        outputPath: '/img1/',

                        // 设置图片的 cdn 地址(也可以统一在外面的 output 中设置,那将作用于所有静态资源)
                        // publicPath: 'http://cdn.abc.com'
                    }
                }
            },
            // 抽离 css
            {
                test: /\.css$/,
                loader: [
                    MiniCssExtractPlugin.loader,  // 注意,这里不再用 style-loader
                    'css-loader',
                    'postcss-loader'
                ]
            },
            // 抽离 less
            {
                test: /\.less$/,
                loader: [
                    MiniCssExtractPlugin.loader,  // 注意,这里不再用 style-loader
                    'css-loader',
                    {
                        loader: 'less-loader',
                        options: {
                            javascriptEnabled: true
                        }
                    },
                    'postcss-loader'
                ]
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(), // 会默认清空 output.path 文件夹
        new webpack.DefinePlugin({
            'process.env': {
                NODE_ENV: JSON.stringify(process.env.NODE_ENV)
            }
        }),

        // 抽离 css 文件
        new MiniCssExtractPlugin({
            filename: 'css/main.[contentHash:8].css'
        })
    ],

    optimization: {
        // 压缩 css
        minimizer: [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugin({})],

        // 分割代码块
        splitChunks: {
            chunks: 'all',
            /**
             * initial 入口 chunk,对于异步导入的文件不处理
                async 异步 chunk,只对异步导入的文件处理
                all 全部 chunk
             */

            // 缓存分组
            cacheGroups: {
                // 第三方模块
                vendor: {
                    name: 'vendor', // chunk 名称
                    priority: 1, // 权限更高,优先抽离,重要!!!
                    test: /node_modules/,
                    minSize: 0,  // 大小限制
                    minChunks: 1  // 最少复用过几次
                },

                // 公共的模块
                common: {
                    name: 'common', // chunk 名称
                    priority: 0, // 优先级
                    minSize: 0,  // 公共模块的大小限制
                    minChunks: 2  // 公共模块最少复用过几次
                }
            }
        }
    }
})

npm run build之后的index.html文件:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>webpack demo</title>
<link href="//js.cdn.com/id/css/1.main.80a40609.css" rel="stylesheet"><link href="//js.cdn.com/id/css/0.main.7dcb8999.css" rel="stylesheet"></head>
<body>
    <p>webpack demo</p>
    <div id="root"></div>
<script type="text/javascript" src="//js.cdn.com/id/vendor.d96b0c2d.js"></script><script type="text/javascript" src="//js.cdn.com/id/common.c3f8a639.js"></script><script type="text/javascript" src="//js.cdn.com/id/index.2552777e.js"></script></body>
</html>

9.Tree Shaking优化

Tree Shaking 可以用来剔除 JavaScript 中用不上的死代码。它依赖静态的 ES6 模块化语法,例如通过importexport导入导出。

假如有一个文件util.js里存放了很多工具函数和常量,在main.js中会导入和使用util.js,代码如下:

util.js源码:

export function funcA() {
}

export function funB() {
}

export const a = 'a';


main.js源码:

import {funcA} from './util.js';
funcA();


Tree Shaking 后的util.js

export function funcA() {
}


由于只用到了util.js中的funcA,所以剩下的都被 Tree Shaking 当作死代码给剔除了。

Tree Shaking 正常工作的前提是交给 Webpack 的 JavaScript 代码必须是采用 ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让 Webpack 可以简单的分析出哪些export的被import过了。 如果你采用 ES5 中的模块化,例如module.export={...}require(x+y)if(x){require('./util')},Webpack 无法分析出哪些代码可以剔除。

  1. 不会对entry入口文件做 Tree Shaking。
  2. 不会对异步分割出去的代码做 Tree Shaking。

9.1接入Tree Shaking

10.提取公共代码

大型网站通常会由多个页面组成,每个页面都是一个独立的单页应用。 但由于所有页面都采用同样的技术栈,以及使用同一套样式代码,这导致这些页面之间有很多相同的代码。

如果每个页面的代码都把这些公共的部分包含进去,会造成以下问题:

  • 相同的资源被重复的加载,浪费用户的流量和服务器的成本;
  • 每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验。

如果把多个页面公共的代码抽离成单独的文件,就能优化以上问题。 原因是假如用户访问了网站的其中一个网页,那么访问这个网站下的其它网页的概率将非常大。 在用户第一次访问后,这些页面公共代码的文件已经被浏览器缓存起来,在用户切换到其它页面时,存放公共代码的文件就不会再重新加载,而是直接从缓存中获取。 这样做后有如下好处:

  • 减少网络传输流量,降低服务器成本;
  • 虽然用户第一次打开网站的速度得不到优化,但之后访问其它页面的速度将大大提升。
  • 根据你网站所使用的技术栈,找出网站所有页面都需要用到的基础库,以采用 React 技术栈的网站为例,所有页面都会依赖 react、react-dom 等库,把它们提取到一个单独的文件。 一般把这个文件叫做base.js,因为它包含所有网页的基础运行环境;
  • 在剔除了各个页面中被base.js包含的部分代码外,再找出所有页面都依赖的公共部分的代码提取出来放到common.js中去。
  • 再为每个网页都生成一个单独的文件,这个文件中不再包含base.jscommon.js中包含的部分,而只包含各个页面单独需要的部分代码。

原因是为了长期的缓存base.js这个文件。

发布到线上的文件都会采用在CDN加速的方法,对静态文件的文件名都附加根据文件内容计算出 Hash 值,也就是最终base.js的文件名会变成base_3b1682ac.js,以长期缓存文件。 网站通常会不断的更新发布,每次发布都会导致common.js和各个网页的 JavaScript 文件都会因为文件内容发生变化而导致其 Hash 值被更新,也就是缓存被更新。

把所有页面都需要用到的基础库提取到base.js的好处在于只要不升级基础库的版本,base.js的文件内容就不会变化,Hash 值不会被更新,缓存就不会被更新。 每次发布浏览器都会使用被缓存的base.js文件,而不用去重新下载base.js文件。 由于base.js通常会很大,这对提升网页加速速度能起到很大的效果。

10.1如何通过webpack提取公共代码?

Webpack 内置了专门用于提取多个 Chunk 中公共部分的插件 CommonsChunkPlugin,CommonsChunkPlugin 大致使用方法如下:

const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

new CommonsChunkPlugin({
  // 从哪些 Chunk 中提取
  chunks: ['a', 'b'],
  // 提取出的公共部分形成一个新的 Chunk,这个新 Chunk 的名称
  name: 'common'
})

以上配置就能从网页 A 和网页 B 中抽离出公共部分,放到 common 中。

每个 CommonsChunkPlugin 实例都会生成一个新的 Chunk,这个新 Chunk 中包含了被提取出的代码,在使用过程中必须指定name属性,以告诉插件新生成的 Chunk 的名称。 其中chunks属性指明从哪些已有的 Chunk 中提取,如果不填该属性,则默认会从所有已知的 Chunk 中提取。

首先需要先配置一个 Chunk,这个 Chunk 中只依赖所有页面都依赖的基础库以及所有页面都使用的样式,为此需要在项目中写一个文件base.js来描述 base Chunk 所依赖的模块,文件内容如下:

// 所有页面都依赖的基础库
import 'react';
import 'react-dom';
// 所有页面都使用的样式
import './base.css';


接着再修改 Webpack 配置,在 entry 中加入 base,相关修改如下:

module.exports = {
  entry: {
    base: './base.js'
  },
};


以上就完成了对新 Chunk base 的配置。

为了从 common 中提取出 base 也包含的部分,还需要配置一个 CommonsChunkPlugin,相关代码如下:

new CommonsChunkPlugin({
  // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
  chunks: ['common', 'base'],
  // 把公共的部分放到 base 中
  name: 'base'
})


由于 common 和 base 公共的部分就是 base 目前已经包含的部分,所以这样配置后 common 将会变小,而 base 将保持不变。

以上都配置好后重新执行构建,你将会得到四个文件,它们分别是:

  • base.js:所有网页都依赖的基础库组成的代码;
  • common.js:网页A、B都需要的,但又不在base.js文件中出现过的代码;
  • a.js:网页 A 单独需要的代码;
  • b.js:网页 B 单独需要的代码。

为了让网页正常运行,以网页 A 为例,你需要在其 HTML 中按照以下顺序引入以下文件才能让网页正常运行:

<script src="base.js"></script>
<script src="common.js"></script>
<script src="a.js"></script>


以上就完成了提取公共代码需要的所有步骤。

注意:针对 CSS 资源,以上理论和方法同样有效,也就是说你也可以对 CSS 文件做同样的优化。

原因是去掉基础运行库外很难再找到所有页面都会用上的模块。

  • CommonsChunkPlugin 提供一个选项minChunks,表示文件要被提取出来时需要在指定的 Chunks 中最小出现最小次数。 假如minChunks=2chunks=['a','b','c','d'],任何一个文件只要在['a','b','c','d']中任意两个以上的 Chunk 中都出现过,这个文件就会被提取出来。 你可以根据自己的需求去调整 minChunks 的值,minChunks 越小越多的文件会被提取到common.js中去,但这也会导致部分页面加载的不相关的资源越多; minChunks 越大越少的文件会被提取到common.js中去,但这会导致common.js变小、效果变弱。
  • 根据各个页面之间的相关性选取其中的部分页面用 CommonsChunkPlugin 去提取这部分被选出的页面的公共部分,而不是提取所有页面的公共部分,而且这样的操作可以叠加多次。 这样做的效果会很好,但缺点是配置复杂,你需要根据页面之间的关系去思考如何配置,该方法不通用。

11.按需加载

对于采用单页应用作为前端架构的网站来说,会面临着一个网页需要加载的代码量很大的问题,因为许多功能都集中的做到了一个 HTML 里。 这会导致网页加载缓慢、交互卡顿,用户体验将非常糟糕。

导致这个问题的根本原因在于一次性的加载所有功能对应的代码,但其实用户每一阶段只可能使用其中一部分功能。 所以解决以上问题的方法就是用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载。

在给单页应用做按需加载优化时,一般采用以下原则:

  • 把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类。
  • 把每一类合并为一个 Chunk,按需加载对应的 Chunk。
  • 对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的 Chunk 中,以降低用户能感知的网页加载时间。
  • 对于个别依赖大量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进行按需加载。

被分割出去的代码的加载需要一定的时机去触发,也就是当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。

由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,你可以预言用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。

11.1用webpack实现按需加载

Webpack 内置了强大的分割代码的功能去实现按需加载,实现起来非常简单。

现在需要做这样一个进行了按需加载优化的网页:

  • 网页首次加载时只加载main.js文件,网页会展示一个按钮,main.js文件中只包含监听按钮事件和加载按需加载的代码。
  • 当按钮被点击时才去加载被分割出去的show.js文件,加载成功后再执行show.js里的函数。

其中main.js文件内容如下:

window.document.getElementById('btn').addEventListener('click', function () {
  // 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
  import(/* webpackChunkName: "show" */ './show').then((show) => {
    show('Webpack');
  })
});


show.js文件内容如下:

module.exports = function (content) {
  window.alert('Hello ' + content);
};


代码中最关键的一句是import(/* webpackChunkName: "show" */ './show'),Webpack 内置了对import(*)语句的支持,当 Webpack 遇到了类似的语句时会这样处理:

  • ./show.js为入口新生成一个 Chunk;
  • 当代码执行到import所在语句时才会去加载由 Chunk 对应生成的文件。
  • import返回一个 Promise,当文件加载成功时可以在 Promise 的then方法中获取到show.js导出的内容。

为了正确的输出在/* webpackChunkName: "show" */中配置的 ChunkName,还需要配置下 Webpack,配置如下:

module.exports = {
  // JS 执行入口文件
  entry: {
    main: './main.js',
  },
  output: {
    // 为从 entry 中配置生成的 Chunk 配置输出文件的名称
    filename: '[name].js',
    // 为动态加载的 Chunk 配置输出文件的名称
    chunkFilename: '[name].js',
  }
};


其中最关键的一行是chunkFilename: '[name].js',,它专门指定动态生成的 Chunk 在输出时的文件名称。 如果没有这行,分割出的代码的文件名称将会是[id].js

11.2实例强化(按需加载与ReactRouter)

实战中的例子:对采用了ReactRouter的应用进行按需加载优化。 这个例子由一个单页应用构成,这个单页应用由两个子页面构成,通过 ReactRouter 在两个子页面之间切换和管理路由。

这个单页应用的入口文件main.js如下:

import React, {PureComponent, createElement} from 'react';
import {render} from 'react-dom';
import {HashRouter, Route, Link} from 'react-router-dom';
import PageHome from './pages/home';

/**
 * 异步加载组件
 * @param load 组件加载函数,load 函数会返回一个 Promise,在文件加载完成时 resolve
 * @returns {AsyncComponent} 返回一个高阶组件用于封装需要异步加载的组件
 */
function getAsyncComponent(load) {
  return class AsyncComponent extends PureComponent {

    componentDidMount() {
      // 在高阶组件 DidMount 时才去执行网络加载步骤
      load().then(({default: component}) => {
        // 代码加载成功,获取到了代码导出的值,调用 setState 通知高阶组件重新渲染子组件
        this.setState({
          component,
        })
      });
    }

    render() {
      const {component} = this.state || {};
      // component 是 React.Component 类型,需要通过 React.createElement 生产一个组件实例
      return component ? createElement(component) : null;
    }
  }
}

// 根组件
function App() {
  return (
    <HashRouter>
      <div>
        <nav>
          <Link to='/'>Home</Link> | <Link to='/about'>About</Link> | <Link to='/login'>Login</Link>
        </nav>
        <hr/>
        <Route exact path='/' component={PageHome}/>
        <Route path='/about' component={getAsyncComponent(
          // 异步加载函数,异步地加载 PageAbout 组件
          () => import(/* webpackChunkName: 'page-about' */'./pages/about')
        )}
        />
        <Route path='/login' component={getAsyncComponent(
          // 异步加载函数,异步地加载 PageAbout 组件
          () => import(/* webpackChunkName: 'page-login' */'./pages/login')
        )}
        />
      </div>
    </HashRouter>
  )
}

// 渲染根组件
render(<App/>, window.document.getElementById('app'));


以上代码中最关键的部分是getAsyncComponent函数,它的作用是配合 ReactRouter 去按需加载组件,具体含义请看代码中的注释。

由于以上源码需要通过 Babel 去转换后才能在浏览器中正常运行,需要在 Webpack 中配置好对应的 babel-loader,源码先交给 babel-loader 处理后再交给 Webpack 去处理其中的import(*)语句。 但这样做后你很快会发现一个问题:Babel 报出错误说不认识import(*)语法。 导致这个问题的原因是import(*)语法还没有被加入到在ECMAScript 标准中去, 为此我们需要安装一个 Babel 插件babel-plugin-syntax-dynamic-import,并且将其加入到.babelrc中去:

{
  "presets": [
    "env",
    "react"
  ],
  "plugins": [
    "syntax-dynamic-import"
  ]
}


执行 Webpack 构建后,你会发现输出了三个文件:

  • main.js:执行入口所在的代码块,同时还包括 PageHome 所需的代码,因为用户首次打开网页时就需要看到 PageHome 的内容,所以不对其进行按需加载,以降低用户能感知到的加载时间;
  • page-about.js:当用户访问/about时才会加载的代码块;
  • page-login.js:当用户访问/login时才会加载的代码块。

同时你还会发现page-about.jspage-login.js这两个文件在首页是不会加载的,而是会当你切换到了对应的子页面后文件才会开始加载。

12.优化代码在运行时的效率

Prepack 由 Facebook 开源,它采用较为激进的方法:在保持运行结果一致的情况下,改变源代码的运行逻辑,输出性能更高的 JavaScript 代码。 实际上 Prepack 就是一个部分求值器,编译代码时提前将计算结果放到编译后的代码中,而不是在代码运行时才去求值。

  1. 通过 Babel 把 JavaScript 源码解析成抽象语法树(AST),以方便更细粒度地分析源码;
  2. Prepack 实现了一个 JavaScript 解释器,用于执行源码。借助这个解释器 Prepack 才能掌握源码具体是如何执行的,并把执行过程中的结果返回到输出中。
  • 不能识别 DOM API 和 部分 Node.js API,如果源码中有调用依赖运行环境的 API 就会导致 Prepack 报错;
  • 存在优化后的代码性能反而更低的情况;
  • 存在优化后的代码文件尺寸大大增加的情况。

12.1接入webpack

13.开启Scope Hoisting

Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 "作用域提升",是在 Webpack3 中新推出的功能。

分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。

**注意:**由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。

13.1接入webpack

14.输出分析

在启动 Webpack 时,支持两个参数,分别是:

  • --profile:记录下构建过程中的耗时信息;
  • --json:以 JSON 的格式输出构建结果,最后只输出一个.json文件,这个文件中包括所有构建相关的信息。

在启动 Webpack 时带上以上两个参数,启动命令如下webpack --profile --json > stats.json,你会发现项目中多出了一个stats.json文件。 这个stats.json文件是给后面介绍的可视化分析工具使用的。

Webpack 官方提供了一个可视化分析工具Webpack Analyse,它是一个在线 Web 应用。

打开 Webpack Analyse 链接的网页后,你就会看到一个弹窗提示你上传 JSON 文件,也就是需要上传上面讲到的stats.json文件

Webpack Analyse 不会把你选择的stats.json文件发达到服务器,而是在浏览器本地解析,你不用担心自己的代码为此而泄露。

它分为了六大板块,分别是:

  • Modules:展示所有的模块,每个模块对应一个文件。并且还包含所有模块之间的依赖关系图、模块路径、模块ID、模块所属 Chunk、模块大小;
  • Chunks:展示所有的代码块,一个代码块中包含多个模块。并且还包含代码块的ID、名称、大小、每个代码块包含的模块数量,以及代码块之间的依赖关系图;
  • Assets:展示所有输出的文件资源,包括.js.css、图片等。并且还包括文件名称、大小、该文件来自哪个代码块;
  • Warnings:展示构建过程中出现的所有警告信息;
  • Errors:展示构建过程中出现的所有错误信息;
  • Hints:展示处理每个模块的过程中的耗时。

webpack-bundle-analyzer是另一个可视化分析工具, 它虽然没有官方那样有那么多功能,但比官方的要更加直观。


它能方便的让你知道:

  • 打包出的文件中都包含了什么;
  • 每个文件的尺寸在总体中的占比,一眼看出哪些文件尺寸大;
  • 模块之间的包含关系;
  • 每个文件的 Gzip 后的大小。

接入 webpack-bundle-analyzer 的方法很简单,步骤如下:

  1. 安装 webpack-bundle-analyzer 到全局,执行命令npm i -g webpack-bundle-analyzer
  2. 按照上面提到的方法生成stats.json文件;
  3. 在项目根目录中执行webpack-bundle-analyzer后,浏览器会打开对应网页看到以上效果。

五.webpack原理

1.流程概括

2.流程细节

事件名解释
初始化参数从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句new Plugin()
实例化 Compiler用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
加载插件依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API。
environment开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。
after-plugins调用完所有内置的和配置的插件的 apply 方法。
after-resolvers根据配置初始化完 resolver,resolver 负责在文件系统中寻找指定路径的文件。
事件名解释
run启动一次新的编译。
watch-run和 run 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
compile该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
compilation当 Webpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
after-compile一次 Compilation 执行完成。
invalid当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。

在编译阶段中,最重要的要数 compilation 事件了,因为在 compilation 阶段调用了 Loader 完成了每个模块的转换操作,在 compilation 阶段又包括很多小的事件,它们分别是:

事件名解释
build-module使用对应的 Loader 去转换一个模块。
normal-module-loader在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。
program从配置的入口模块开始,分析其 AST,当遇到require等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系。
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk。
事件名解释
should-emit所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。
emit确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
after-emit文件输出完毕。
done成功完成一次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

在输出阶段已经得到了各个模块经过转换后的结果和其依赖关系,并且把相关模块组合在一起形成一个个 Chunk。 在输出阶段会根据 Chunk 的类型,使用对应的模版生成最终要要输出的文件内容。

举报

相关推荐

webpack学习笔记

webpack学习记录

webpack的学习

Webpack学习笔记

Webpack学习笔记(5)

Webpack学习笔记(4)

webpack学习笔记03

0 条评论