性能文章>玩转 webpack5(上)>

玩转 webpack5(上)原创

2年前
530805

前言

本篇长文是学习程柳峰老师开设的《玩转 webpack》专栏的实践笔记,和专栏不一样的是,我的实战源码是基于 webpack5,它的配置和源码实现上与 webpack4 有许多不同的地方,感兴趣的同学可以结合我在上面放出的源码仓库进行学习,相信会有不小的收获。

看完本篇长文,你将收获:

  • 能够根据项目需求灵活的进行 webpack 的配置。
  • 理解 Tree Shaking、Scope Hoisting 等原理。
  • 能够实现打包多页应用、组件库和基础库、SSR 等。
  • 编写可维护的 webpack 配置,结合单元测试、冒烟测试等控制代码质量,使用 Travis 实现持续集成。
  • 知道如何优化构建速度和构建资源体积。
  • 通过源代码掌握 webpack 打包原理
  • 编写 loader 和 plugin。

我将源码放在我的仓库中,可以对照着文档阅读,另外由于掘金字数限制,只能分成两篇文章,想要更好的阅读体验可以移步到我的博客。源码地址:玩转 webpack

初识 webpack

配置文件:webpack.config.js

webpack 配置组成:

安装 webpack:

yarn add webpack webpack-cli -D
复制代码

通过命令行执行 webpack:

./node_modules/.bin/webpack
复制代码

通过 npm script 运行 webpack:

原理:在 node_modules/.bin 目录中创建软连接

yarn run build
复制代码

webpack 基础用法

核心概念之 entry

指定 webpack 打包的入口。

依赖图(构建机制):webpack 会将所有的资源都当成是模块处理,从入口文件开始,递归地解析依赖模块,形成一颗依赖树,递归完成后,输出构建后的资源。

使用方法

单入口:entry 的值是字符串

module.exports = {
  entry: './src/index.js',
};
复制代码

多入口:entry 的值是对象

module.exports = {
  entry: {
    main: './src/index.js',
  },
};
复制代码

核心概念之 output

告诉 webpack 将构建后的资源存放在磁盘的什么地方

使用方法

单入口:

module.exports = {
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, '/dist'),
  },
};
复制代码

多入口:

通过占位符确保文件名称的唯一

module.exports = {
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, '/dist'),
  },
};
复制代码

核心概念之 loader

webpack 默认只支持 js 和 json 两种文件类型,通过 loader 可以配置其它文件类型的解析规则,从而让 webpack 将其它文件的类型加到依赖图中。

本身是一个函数,接收源文件作为参数,返回转换的结果

使用方法

module.exports = {
  module: {
    rules: [{ test: /\.txt/, use: 'raw-loader' }],
  },
};

// 配置项
module.exports = {
  module: {
    rules: [
      {
        test: /\.txt/,
        use: {
          loader: 'raw-loader',
          options: {},
        },
      },
    ],
  },
};
复制代码

核心概念之 plugin

plugin 用于 bundle 文件的优化,资源管理和环境变量注入,作用于整个构建过程。

核心概念之 mode

mode 指定构建环境:production/development/none。

解析 es6 和 jsx

解析 es6

使用 babel-loader,babel 的配置文件是 babel.config.json

{
  "presets": ["@babel/preset-env"],
  "plugins": ["@babel/proposal-class-properties"]
}
复制代码

presets 是一系列 plugin 的集合,表示预设项,plugin 特定某一项功能。

安装依赖

yarn add @babel/core @babel/preset-env babel-loader -D
复制代码

配置 loader

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
        },
      },
    ],
  },
};
复制代码

解析 jsx

安装依赖

yarn add react react-dom @babel/preset-react
复制代码
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}
复制代码

解析 css/less/sass

解析 css

css-loader 用于加载 .css 文件,将其转换成 commonjs 对象

style-loader 将样式通过 <style> 标签注入到 head 中

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};
复制代码

loader 的解析规则是从右往左,也就是先使用 css-loader 解析文件,然后把处理结果交给 style-loader。

解析 less

安装依赖

yarn add less less-loader -D
复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader', 'less-loader'],
      },
    ],
  },
};
复制代码

解析图片和字体资源

使用 file-loader

安装 file-loader

yarn add file-loader
复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
        use: ['file-loader'],
      },
    ],
  },
};
复制代码

使用 url-loader

可以设置小资源自动 base64

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
        use: [{ loader: 'file-loader', options: { limit: 10240 } }],
      },
    ],
  },
};
复制代码

使用 webpack5 的内置 asset

在 webpack5 之前的版本中,常用的 loader 如下:

  • raw-loader 将模块处理成字符串
  • url-loader 可以设置指定的资源大小,如果小于设置的大小则内联进 bundle。
  • file-loader 将文件发送到输出目录

在 webpack5 中,asset modules 替换了上述的 loader,添加了 4 中内置类型:

  • asset/resource 之前由 file-loader 实现
  • asset/inline 之前由 url-loader 实现
  • asset/source 导出资源的源码(字符串类型),之前由 raw-loader 实现
  • asset 可以自动选择导出为 data URI 还是直接发送文件,之前由 url-loader 实现。

解析图片和字体资源时,希望在限制的大小内将资源导出为 data URI,而超过的资源直接将文件发送到输出目录,所以使用 asset

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 10 * 1024, // 10kb
          },
        },
      },
    ],
  },
};
复制代码

监听文件和热更新

watch

监听文件的改动,自动构建

{
  "scripts": {
    "watch": "webpack --watch"
  }
}
复制代码

文件监听原理解析:

  • 轮询判断文件的最后编辑时间是否变化
  • 某个文件发生了变化并不会立刻告诉监听者,而是先缓存起来,等 aggregateTimeout 到期再执行构建任务
module.exports = {
  watch: true, // 开启监听
  watchOptions: {
    // 默认为空,忽略监听的文件夹,可以提升一定性能
    ignored: /node_modules/,
    // 判断文件变化是通过不停地询问系统指定文件有没有变化实现的,每秒询问 1 次
    poll: 1000,
    // 监听到变化后的 300ms 后再去执行
    aggregateTimeout: 300,
  },
};
复制代码

热更新-WDS

webpack-dev-server:

  • 不刷新浏览器
  • 不输出文件,而是放在内存中(构建速度有更大的优势)
  • 使用 HotModuleReplacementPlugin

安装依赖:

yarn add webpack-dev-server -D
复制代码
module.exports = {
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    historyApiFallback: true,
    hot: true,
    open: true,
    quiet: true,
    port: 8082,
  },
};
复制代码
{
  "scripts": {
    "serve": "webpack serve"
  }
}
复制代码

注意:如果有多个入口,但只配置了一个 HtmlWebpackPlugin,多个 chunk 都会**入到生成的 html 中,此时热更新无法正常使用。

热更新-WDM

webpack-dev-middleware:这是一个 express 的中间件,可以让 webpack 把文件交给一个服务器处理,比如接下来要使用的 express,这给了我们更多的控制权。

安装依赖

yarn add express webpack-dev-middleware -D
复制代码
module.exports = {
  output: {
    publicPath: '/',
  },
};
复制代码
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config');
const compiler = webpack(config);

app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  })
);

app.listen(8081, function () {
  console.log('server is running on port 8081');
});
复制代码

启动服务 node server.js

热更新原理

概念:

  • webpack compiler: 将 js 编译成 bundle
  • bundle server: 提供一个服务,使文件在浏览器中访问
  • HMR Server: 将热更新的文件输出给 HMR runtime
  • HMR runtime: 会被注入到 bundle 中,用于更新文件(使用 websocket 和服务端通信)
  • bundle.js 构建产物
  1. 启动阶段:首先源代码通过 webpack compiler 被编译成 bundle.js,然后提交到 bundle server,浏览器就可以在该服务下访问文件。
  2. 变化阶段:WDS 每隔一段时间会去检测文件最后编辑的时间是否发生变化,一旦发现有,就将其加入到缓存列表,等到 aggregateTimeout 到期时,将缓存列表发送给 webpack compiler 编译,编译后的代码交给 HMR server,HRM server 再通知 HMR runtime 变化的文件(以 json 格式传输),HMR runtime 再去更新响应的模块代码。

文件指纹策略:chunkhash/contenthash/hash

文件指纹:打包后输出文件的文件名,通常用作版本管理,只更新修改的文件内容,未更新的文件指纹不会改变,仍然可以使用浏览器的缓存。

常见的文件指纹:

  • hash: 和整个项目的构建相关,只要项目文件有改动,整个项目构建的 hash 值就会变化。
    • 打包阶段有 compile 和 compilation,webpack 启动时会创建一个 compile 对象,只要文件发生变化,compilation 就会发生变化,对应地, hash 值就会发生变化。
    • A 页面发生变化,B 页面未发生变化,但是 hash 也发生了变化。
  • chunkhash:和 webpack 打包的 chunk(入口) 有关,不同的 entry 会生成不同的 chunkhash。
    • js 一般采用 chunkhash。
  • contenthash: 根据内容来定义 hash,文件内容不变,contenthash 则不变。
    • css 一般使用 contenthash。

文件指纹的设置

只能在生产环境下使用

js 文件的文件指纹设置

module.exports = {
  output: {
    filename: '[name]_[chunkhash:8].bundle.js',
  },
};
复制代码

css 的文件指纹设置:使用 mini-css-extract-plugin 将 css 提取成一个文件,然后设置 filename,使用 [contenthash]

如果使用 style-loader,css 会被注入的页面的 head 中,就无法设置文件指纹。

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

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
      {
        test: /\.less$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name][contenthash:8].css',
    }),
  ],
};
复制代码

图片等静态资源文件指纹设置:file-loader 的 name,使用 hash。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'assets/[name][hash:8].[ext]',
            },
          },
        ],
      },
    ],
  },
};
复制代码

分离配置文件

安装 webpack-merge

yarn add webpack-merge -D
复制代码

分离成三个文件,并放在 config 目录下:

  • webpack.common.js
  • webpack.dev.js
  • webpack.prod.js

配置如下:

const path = require('path');

module.exports = {
  entry: {
    main: './src/index.js',
    worker: './src/worker',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, '../', 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
        },
      },
    ],
  },
};
复制代码
const { merge } = require('webpack-merge');
const path = require('path');

const common = require('./webpack.common');

module.exports = merge(common, {
  mode: 'development',
  devServer: {
    contentBase: path.join(__dirname, '../dist'),
    historyApiFallback: true,
    hot: true,
    open: false,
    quiet: true,
    port: 8082,
  },
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'assets/[name].[ext]',
            },
          },
          {
            test: /\.css$/,
            use: ['style-loader', 'css-loader'],
          },
          {
            test: /\.less$/,
            use: ['style-loader', 'css-loader', 'less-loader'],
          },
        ],
      },
    ],
  },
});
复制代码
const { merge } = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');

const common = require('./webpack.common');

module.exports = merge(common, {
  mode: 'production',
  output: {
    filename: '[name]_[chunkhash:8].bundle.js',
    path: path.resolve(__dirname, '../', 'dist'),
    // publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'assets/[name]_[hash:8].[ext]',
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
      {
        test: /\.less$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css',
    }),
  ],
});
复制代码

HTML、CSS 和 Javascript 代码压缩

webpack 默认开启了对 Javascript 代码的压缩。

压缩 CSS

使用 css-minimizer-webpack-plugin, 相比 optimize-css-assets-webpack-plugin,在 source maps 和 assets 中更精确,允许缓存和使用并行模式。

安装依赖:

yarn add css-minimizer-webpack-plugin -D
复制代码

配置:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    // '...' 可以继承默认的压缩配置
    minimizer: [new CssMinimizerPlugin(), '...'],
  },
};
复制代码

压缩 html

安装依赖 html-webpack-plugin,生产环境下会默认开启压缩 html。会自动将构建的产物,如 bundle.js, xx.css 等插入到生成的 html 中;如果有多个入口,只指定了一个 HtmlWebpackPlugin,则都会插入到该 html 中。

yarn add html-webpack-plugin -D
复制代码
module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, '../', 'public/index.html'),
    }),
  ],
};
复制代码

webpack 进阶用法

自动清理构建产物

  1. 使用 npm scripts 清理构建目录:

rm -rf ./dist && webpack

  1. 在 webpack5 中, output 配置提供了 clean 参数,它是一个 boolean 类型,如果为 true,它会在构建前清除上一次的构建产物。

使用 PostCSS 的插件 autoprefixer 自动补齐浏览器厂商前缀

安装依赖:

yarn add postcss-loader autoprefixer -D
复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          'style-loader',
          'css-loader',
          'less-loader',
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [require('autoprefixer')],
              },
            },
          },
        ],
      },
    ],
  },
};
复制代码

package.json 中配置 autoprefixer:

{
  "browserslist": ["> 1%", "last 2 versions", "not ie <= 10"]
}
复制代码

px 自动转换成 rem

使用 px2rem-loader 自动将 px 转换成 rem,配合手淘的 lib-flexible 库,可以在渲染时计算根元素的 font-size,这样就可以实现移动端的自适应。

安装依赖:

yarn add px2rem-loader -D
yarn add lib-flexible -S
复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader',
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [require('autoprefixer')],
              },
            },
          },
          {
            loader: 'px2rem-loader',
            options: {
              remUnit: 75,
              remPrecision: 8,
            },
          },
        ],
      },
    ],
  },
};
复制代码

由于目前的配置还不支持静态资源内联,lib-flexible 的使用在下一小节中介绍。

静态资源内联

资源内联的意义

代码层面:

  • 页面框架的初始化脚本
  • 上报相关打点(css 加载完成、js 加载完成)
  • css 内联可以避免页面的闪动,在首屏加载时体验更好(跟随 html 一起回来)

请求层面:

减少 HTTP 网络请求数

  • 小图片或者字体内联(url-loader | type: "asset"

接下来实现 meta.html 和 lib-flexible 的资源内联,首先将 public/index.html 改为 public.index.ejs,因为使用了 html-webpack-plugin,默认使用的 ejs 模板引擎。

修改 webpack 配置:

module.exports = {
  module: {
    rules: [
      {
        resourceQuery: /raw/,
        type: 'asset/source',
      },
    ],
  },
};
复制代码

将资源内联进 index.ejs:

<!DOCTYPE html>
<html lang="en">
  <head>
    <%= require('./meta.html?raw') %>
    <title>玩转 webpack</title>
    <script>
      <%= require('../node_modules/lib-flexible/flexible?raw') %>
    </script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
复制代码

多页应用打包通用方案

安装依赖:

yarn add glob -D
复制代码
// 设置多页打包,思路是使用 glob 解析出对应的入口文件,然后设置对应的 entry 和 HtmlWebpackPlugin
function setMpa() {
  const entry = {};
  const htmlWebpackPlugins = [];

  const pagePaths = glob.sync(path.join(__dirname, '../src/mpa/**/index.js'));

  pagePaths.forEach((pagePath) => {
    const name = pagePath.match(/src\/mpa\/(.*)\/index\.js/)[1];

    entry[name] = pagePath;
    htmlWebpackPlugins.push(
      new HtmlWebpackPlugin({
        filename: `${name}.html`,
        chunks: [name],
        template: path.join(__dirname, '../', `src/mpa/${name}/index.html`),
      })
    );

    return name;
  });
}
复制代码

使用 source map

关键字:

  • eval: 使用 eval 包裹模块代码
  • source map: 产生 .map 文件(和源代码文件分离)
  • cheap: 不包含列信息
  • inline: 将 .map 作为 DataURI 嵌入,不单独生成 .map 文件(会造成源文件特别大)
  • module: 包含 loader 的 source map

注意点

  • 出于对性能的考虑,在生产环境推荐不使用 source map,这样有最好的打包性能。
  • 开发环境开启,线上环境关闭
    • 如果想使用,可以使用分析不出来业务逻辑的 source map 类型。
    • 线上排查问题的时候可以将 source map 上传到错误监控系统。
    • 生产环境:devtool: source-map; 拥有高质量的 source map
    • 开发环境推荐使用:devtool: eval-cheap-module-source-map

提取页面公共资源

思路:将 react, react-dom 基础包通过 cdn 引入,不打入 bundle。

  • 使用 html-webpack-externals-plugin 分离基础库。
  • 使用 SplitChunkPlugin,webpack4 之后已经内置。

分离 react/react-dom 基础库

安装依赖:

yarn add html-webpack-externals-plugin -D
复制代码
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackExternalsPlugin({
      externals: [
        {
          module: 'react',
          entry: 'https://now8.gtimg.com/now/lib/16.8.6/react.min.js',
          global: 'React',
        },
        {
          module: 'react-dom',
          entry: 'https://now8.gtimg.com/now/lib/16.8.6/react-dom.min.js',
          global: 'ReactDOM',
        },
      ],
    }),
  ],
};
复制代码

entry 使用 cdn 的地址,然后再 index.ejs 中将 react/react-dom 的库引入:

<!DOCTYPE html>
<html lang="en">
  <head>
    <%= require('./meta.html?raw') %>
    <title>玩转 webpack</title>
    <script>
      <%= require('../node_modules/lib-flexible/flexible?raw') %>
    </script>
  </head>
  <body>
    <div id="root"></div>

    <script src="https://now8.gtimg.com/now/lib/16.8.6/react.min.js"></script>
    <script src="https://now8.gtimg.com/now/lib/16.8.6/react-dom.min.js"></script>
  </body>
</html>
复制代码

chunk 参数说明

  • async 对异步引入的库进行分离(默认)
  • initial 对同步引入的库进行分离
  • all 对所有引入的库进行分离(推荐)

例如:

modulex.exports = {
  optimization: {
    splitChunks: {
      chunk: 'async', // 只会分析异步导入的库,如果达到设置的条件,就会将其抽成单独的一个包,即分包
    },
  },
};
复制代码

使用 SplitChunksPlugin 分离基础包

test: 匹配出要分离的包。将 react 和 react-dom 分离为 vendors 包。

minChunks: 设置最小引用次数为 2 次。

minSize: 分离的包体积的大小。

module.exports = {
  optimization: {
    cacheGroups: {
      minSize: 0,
      commons: {
        test: /(react|react-dom)/,
        name: 'vendors',
        chunks: 'all',
        minChunks: 2, // 有两个及以上的页面引用的库,将其抽离
      },
    },
  },
};
复制代码

然后在 HtmlWebpackPlugin 中将 vendors chunk 引入:

modulex.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      chunks: ['vendors'],
      template: path.join(__dirname, '../', 'public/index.ejs'),
    }),
  ],
};
复制代码

Tree Shaking 的使用和原理分析

摇树优化:擦除无用的代码

  • 代码必须是 es6 的写法
  • 如果有副作用,tree shaking 会失效

DCE(Elimination)

mode: production 默认开启 tree shaking

  • 代码不会被执行,不可到达
  • 代码执行的结果不会被用到
  • 代码只影响死变量(只写不读)
if (false) {
  // 不可达
}

function getSex() {
  return 'male';
}

getSex(); // 代码的执行结果不会被用到

var name = 'ywhoo'; // 只写不读
复制代码

原理

利用 es6 模块的特点:

  • 只能在模块顶层出现 import
  • import 的模块名只能是字符串常量
  • import binding 是 immutable 的,即不可变

在编译阶段(静态分析)确定用到的代码,对没用到的代码进行标记,然后在 uglify 阶段删除被标记的代码。

Scope Hoisting 原理分析

现象:构建后的代码存在大量闭包代码

问题:

  • bundle 体积增大
  • 函数作用域变多,内存开销变大

原理:将所有模块的代码按照引用顺序放在一个函数作用域里,然后适当的重命名一些变量以防止变量名冲突。

实现:通过 scope hoisting 可以减少函数声明代码和内存开销

使用: mode 为 production 默认开启

  • 必须是 es6 语法。

代码分割和动态 import

代码分割之前介绍过一点,就是使用 splitChunks 将基础包和公共的函数分离。

代码分割的意义:对于大的 web 应用来讲,将所有的代码都放在一个文件中显然是不够有效的,特别是当某些代码块是在某些特殊的时候才会被使用到。webpack 有一个功能就是将你的代码库分割成 chunks,当需要的时候再进行加载,而不是一次性加载所有的。

使用的场景:

  • 抽离相同的代码块到一个共享块
  • 脚本懒加载(按需加载),使得初始下载的代码更小

懒加载 js 脚本的方式:

  • CommonJS: require.ensure
  • ES6: 动态 import(需要 babel 转换)

原理:在加载的时候使用 jsonp 的方式,创建一个 script 标签,动态地引入脚本。

实现按需导入组件,当点击按钮的时候,会将 getComponent 所包含的代码异步加载进来:

btn.addEventListener('click', function () {
  getComponent().then((comp) => {
    document.body.appendChild(comp);
  });
});

async function getComponent() {
  const { default: _ } = await import('lodash');

  const ele = document.createElement('div');
  ele.innerHTML = _.join(
    ['hello', 'webpack', '我是动态 import 生成的代码'],
    ' '
  );

  return ele;
}
复制代码

遇到的问题

在使用 async 的时候,运行时报错,配置 .babelrc 即可解决:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "e**odules": true
        }
      }
    ]
  ]
}
复制代码

有个坑会导致 HMR 失效,折腾了好久没发现原因,玩玩没想到是在 package.json 中加入了 browserslist 的配置,这在配置 postcss 的 autoprefixer 时用到,配置了该字段 HMR 就不能正常使用:

{
  "browserslist": ["> 1%", "last 2 versions", "not ie <= 10"]
}
复制代码

解决办法是在 webpack 配置中添加 target 配置:

modulex.exports = {
  target: 'web',
};
复制代码

在 webpack 中使用 eslint

行业里面优秀的 eslint 规范实践:

  • Airbnb: eslint-config-airbnb、eslint-config-airbnb-base
  • alloyteam: eslint-config-alloy
  • ivweb: eslint-config-ivweb
  • umijs: fabric

指定团队的 eslint 规范:

  • 不重复造轮子,基于 eslint:recommend 配置并改进
  • 能够帮助发现代码错误的规则,全部开启
  • 帮助保持团队的代码风格统一,而不是限制开发体验(eslint 通常检查可能存在的问题,而代码风格一般交给 prettier 进行统一规范)

eslint 落地

  • 和 CI/CD 系统集成
  • 和 webpack 集成(eslint 不通过构建不成功)

安装 eslint 及 airbnb 的规范实践:

yarn add eslint eslint-plugin-import eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y eslint-config-airbnb -D
复制代码

安装 eslint-loader:

yarn add eslint-loader babel-eslint -D
复制代码

方案一:webpack 和 CI/CD 集成

在 CI 环节中的 build 之前增加 lint,lint 通过后才允许执行后面的流程。

本地开发阶段增加 precommit 钩子

安装 husky

yarn add husky -D
复制代码

增加 npm script,通过 lint-staged 增量检查修改的文件:

{
  "scripts": {
    "precommit": "lint-staged"
  },
  "lint-staged": {
    "*.{js}": ["eslint --fix", "git add"]
  }
}
复制代码

为避免绕过 git precommit 钩子,在 CI 步骤需要增加 lint 步骤。

方案二:webpack 与 eslint 集成

使用 eslint-loader,构建时检查 js 规范,适合新项目,默认会检查所有的 js 文件。

module.exports = {
  rules: [
    {
      test: /\.jsx?$/,
      use: ['babel-loader', 'eslint-loader'],
    },
  ],
};
复制代码
module.exports = {
  parser: 'babel-eslint',
  extends: 'airbnb',
  env: {
    browser: true,
    node: true,
  },
  rules: {
    'comma-dangle': 'off',
    'no-console': 'off',
    'jsx-quotes': 'off',
    'jsx-a11y/click-events-have-key-events': 'off',
    'jsx-a11y/no-static-element-interactions': 'off',
  },
};
复制代码

webpack 打包组件和基础库

rollup 更适合打包组件和库,它更加纯粹。

webpack 除了可以用来打包应用,也可以用来打包 js 库。

实现一个大整数加法库的打包:

  • 需要打包压缩版和非压缩版
  • 支持 AMD/CJS/ESM 模块引入

将库暴露出去:

  • library: 指定库的全局变量
  • libraryTarget: 支持库的引入方式

以下是具体实现,源码我放在了这个仓库lib/big-number 下:

// 大整数加法
/**
 * 从个位开始加,注意进位
 *
 * @export
 * @param {*} a string
 * @param {*} b string
 */
export default function add(a, b) {
  let i = a.length - 1;
  let j = b.length - 1;
  let res = '';
  let carry = 0; // 进位

  while (i >= 0 || j >= 0) {
    let x = 0;
    let y = 0;
    let sum = 0;

    if (i >= 0) {
      x = +a[i];
      i -= 1;
    }

    if (j >= 0) {
      y = +b[j];
      j -= 1;
    }

    sum = x + y + carry;

    if (sum >= 10) {
      carry = 1;
      sum -= 10;
    } else {
      carry = 0;
    }

    res = sum + res;
  }

  if (carry) {
    res = carry + res;
  }

  return res;
}
复制代码

webpack 配置:

const path = require('path');
const TerserWebpackPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  entry: {
    'big-number': path.join(__dirname, './src/index.js'),
    'big-number.min': path.join(__dirname, './src/index.js'),
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, './dist'),
    library: 'bigNumber',
    libraryTarget: 'umd',
    clean: true,
  },
  optimization: {
    //   minimize: false,
    // webpack5 默认使用 terser-webpack-plugin 插件压缩代码,此处使用它自定义
    minimizer: [
      // 只压缩 .min.js 结尾的文件
      new TerserWebpackPlugin({
        test: /\.min\.js$/i,
      }),
    ],
  },
};
复制代码

打包完后,编写组件库的入口文件,在 package.json 中指定,如 main.js,这里根据不同的环境变量指定使用不同的版本:

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./dist/big-number.min.js');
} else {
  module.exports = require('./dist/big-number.js');
}
复制代码

到这里大整数加法库已经开发完成了,接下来将它发布到 npm 仓库,假设已经执行 npm login 登录了 npm 账户,然后执行 npm publish 发布。

注意:如果使用的是淘宝镜像,需要切换回官方镜像。

发布成功后可以安装并使用它:

import bigNumber from 'yw-big-number';

console.log(bigNumber('999', '1')); // 1000
复制代码

webpack 实现 SSR 打包

为什么需要服务端渲染,它有什么优势?

客户端渲染在页面加载时,需要先获取并解析 html,在解析 html 的过程中,如果遇到外部的 js, css,需要等拿到之后再往后解析,当然浏览器会对资源进行预请求,而且非关键性资源不会阻塞 html 的解析,在解析过程中页面处于白屏,解析完成后页面开始展现,此时可能只有 loading,js 脚本正在请求接口数据并等待返回,拿到数据后才开始展示真正的内容,如果有图片资源,此时图片还是不可见的,需要等待加载完成,到这里页面才是可交互的。

可以发现,页面在加载的时候经历了一系列步骤,才真正展现在用户面前,而服务端渲染的优势是,静态资源和数据是随着 html 一起拿到的,浏览器拿到 html 后直接解析,等 js 脚本执行完成后就完全可交互,它主要以下几点优势:

  • 减少白屏时间
    • 所有模板等静态资源都存储在服务端
    • 内网机器拉取数据更快
    • 一个 html 返回所有数据
  • 对 SEO 友好

总结:服务端渲染的核心是减少请求。

代码实现思路

  1. 配置 webpack.ssr.js,将客户端代码以 umd 规范导出
  2. 服务端代码使用 express 实现,将导出的客户端代码引入,通过 ReactDOMServer 的 renderToString 方法将其转换成字符串,放入模板中的 root 节点,然后注册路由,开启监听端口服务。

问题

  1. 执行 node server/index.js 时,报 self is not defined,由于服务端没有 self 全局变量,在执行的最顶部加入如下判断:
if (typeof self === 'undefined') {
  global.self = {};
}
复制代码
  1. 打包出的组件需要兼容写法,如服务端模块使用的是 commonjs 写法,客户端编写组价你的时候也需要遵循 commonjs 规范。

  2. 将 fetch 或 ajax 请求方法改成 isomorphic-fetch 或 axios。

  3. 样式问题(nodejs 无法解析 css)

    1. 服务端打包通过 ignore-loader 忽略掉 css 的解析
    2. 将 style-loader 替换成 isomorphic-style-loader(css module 的写法,不能直接引入)

使用打包出来的浏览器端 html 为模板,设置占位符,动态地插入组件:

const template = fs.readFileSync(
  path.join(__dirname, '../dist/index.html'),
  'utf-8'
);

const useTemplate = (html) => template.replace('<!--HTML_PLACEHOLDER-->', html);

app.get('/app', (req, res) => {
  const html = useTemplate(renderToString(App));

  res.status(200).send(html);
});
复制代码

实现后会有白屏的问题。

  1. 首屏数据如何处理?

服务端获取数据后,替换占位符。

优化构建时命令的显示日志

使用 friendly-errors-webpack-plugin 提供友好的构建信息提示。

构建异常和中断处理

主动捕获并处理构建错误:

  • compiler 在每次构建结束后会触发 done 这个 hook
  • process.exit 主动处理构建报错
module.exports = {
  plugins: [
    function () {
      // this 指向 compiler
      this.hooks.done.tap('done', (stats) => {
        if (
          stats.compilation.errors &&
          stats.compilation.errors.length &&
          process.argv.indexOf('--watch') === -1
        ) {
          process.exit(1); // 抛出异常,终端就知道构建失败了
        }
      });
    },
  ],
};
复制代码

编写可维护的 webpack 构建配置

这一章节会根据之前的配置,编写一个可维护的 webpack 构建配置库,它遵循完整库的编写规范,包含开发规范、冒烟测试、单元测试、持续集成等,最后发布到 npm 社区。

构建配置抽离成 npm 包的意义

  • 通用性
    • 开发人员无需关注构建配置
    • 统一团队构建脚本
  • 可维护性
    • 构建配置合理的拆分
    • README 文档、ChangeLog 文档等
  • 质量
    • 冒烟测试、单元测试、测试覆盖率
    • 持续集成

构建配置管理的可选方案

  • 通过多个配置文件管理不同环境的构建,webpack --config 参数进行控制
    • 基础配置 webpack.common.js
    • 开发环境 webpack.dev.js
    • 生产环境 webpack.prod.js
    • SSR 环境 webpack.ssr.js
  • 将构建配置设计成一个库统一管理
    • 规范:git commit 日志,README,Eslint 规范
    • 质量:冒烟测试、单元测试、测试覆盖率和 CI

使用 webpack-merge 合并配置。

功能模块设计和目录结构

使用 eslint 规范开发

由于是基础库的开发,只需要用到 airbnb 的 eslint-config-airbnb-base 版本。

安装依赖:

yarn add eslint babel-eslint eslint-config-airbnb-base -D
复制代码

配置 .eslintrc.js:

module.exports = {
  parser: 'babel-eslint',
  extends: 'airbnb-base',
  env: {
    browser: true,
    node: true,
  },
  rules: {
    'comma-dangle': 'off',
    'no-console': 'off',
    'jsx-quotes': 'off',
    'global-require': 'off',
    'import/extensions': 'off',
    'jsx-a11y/click-events-have-key-events': 'off',
    'jsx-a11y/no-static-element-interactions': 'off',
    'no-restricted-globals': 'off',
  },
};
复制代码

将 eslint 检查加入 scripts:

{
  "scripts": {
    "eslint": "eslint config --fix"
  }
}
复制代码

冒烟测试介绍和实际运用

冒烟测试时指对提交测试的软件在进行详细深入的测试之前进行的测试,这种预测试的目的是暴露导致软件需重新发布的基本功能失效等严重问题。

冒烟测试执行:

  • 判断构建是否成功
  • 构建产物是否有内容
    • 是否有 js, css 等静态资源文件
    • 是否有 html 文件

安装需要的依赖:

yarn add rimraf webpack mocha assert glob-all
复制代码

编写判断构建是否成功的测试用例:

const rimraf = require('rimraf');
const webpack = require('webpack');
const Mocha = require('mocha');
const path = require('path');

const mocha = new Mocha({
  timeout: '10000',
});

// 删除旧的构建产物
// process.chdir(); // 改变工作目录

rimraf('../../dist', () => {
  const prodConfig = require('../../config/webpack.prod');

  webpack(prodConfig, (err, stats) => {
    if (err) {
      console.error(err);
      process.exit(2);
    }
    console.log(
      stats.toString({
        colors: true,
        modules: false,
        children: false,
      })
    );

    console.log('webpack build succeeded, begin to test.');

    mocha.addFile(path.join(__dirname, './html-test.js'));
    mocha.addFile(path.join(__dirname, './js-css-test.js'));

    mocha.run();
  });
});
复制代码

需要注意的是路径是否正确,html,js,css 的测试用例就不贴代码了,感兴趣的可以到我的仓库代码中查看,最终运行成功的结果如下:

单元测试和测试覆盖率

单纯的测试框架:Mocha/AVA,需要安装额外的断言库:chai/should.js/expect/better-assert 集成框架,开箱即用:Ja**ine/Jest(React)

使用 Mocha + Chai,主要的测试 api:

  • describe 描述需要测试的文件
  • it 一个文件中多个测试用例
  • expect 断言

执行测试命令:

mocha add.test.js
复制代码

单元测试

编写单元测试用例:

mocha 默认会查找 test/index.js。

describe('webpack config test.', () => {
  require('./unit/webpack-base.test');
});
复制代码
const assert = require('assert');

describe('webpack.common.js test case.', () => {
  const baseConfig = require('../../config/webpack.common');

  it('entry', () => {
    // 测试入口的文件路径是否正确
    assert.strictEqual(
      baseConfig.entry.main,
      '/Users/yewei/Project/source-code-realize/play-webpack/lib/yw-build-webpack/src/index.jsx'
    );
  });
});
复制代码

测试通过后如下:

测试覆盖率

安装 istanbul

安装好之后修改 test:unit 命令:

{
  "scripts": {
    "test:unit": "istanbul cover mocha"
  }
}
复制代码

注意:测试的目标代码中不能有 es6+ 语法的代码,否则无法收集到测试覆盖率数据。

执行 yarn test:unit 的结果如下,并且会在根目录下生成 coverage 的目录,用来存放代码覆盖率的结果:

持续集成和 Travis CI

持续集成的作用:

  • 快速发现错误
  • 防止分支大幅偏离主干

核心思路:代码集成到主干前,必须通过自动化测试。只要有一个错误,就不能集成。

Github 最流行的 CI:

接入 Travis CI:

  1. Travis 点击登录
  2. 激活需要持续集成的项目
  3. 项目根目录下新增 .travis.yml

在 github 创建件新项目,然后执行以下步骤将 yw-build-webpack 下的代码上传到该仓库:

# 进入yw-build-webpack,初始化 git
git init
git add .
git commit -m "xxx"
# 将远程仓库添加进来
git remote add origin https://github.com/weiTimes/yw-build-webpack.git
# 推送代码
git push -u origin master
复制代码

添加 .travis.yml:

language: node_js # 语言

sudo: false

node_js:
  - 12.16.1

cache: # 保存缓存
  - npm
  - yarn

before_install: # 安装依赖
  - npm install -g yarn
  - yarn

scripts: # 执行测试
  - yarn test
复制代码

当代码提价时,会走动触发构建任务。

发布构建包到 npm 社区

发布 npm

添加用户:npm adduser

升级版本

  • 升级补丁版本号:npm version patch
  • 升级小版本号:npm version minor
  • 升级大版本号:npm version major

发布版本:npm publish

进入要发布的项目根目录,然后登陆 npm 并执行发布操作:

npm login
npm publish
复制代码

当要发布补丁时,执行以下步骤:

git add .
git commit -m "doc: udpate reamde"
npm version patch
git push -u origin master
npm publish
复制代码

Git commit 规范和 changelog 生成

良好的 git commit 规范优势:

  • 加快 code review 的流程
  • 根据 git commit 的元数据生成 changelog
  • 方便后续维护者维护

angular git commit 规范:

本地开发阶段增加 precommit 钩子

添加依赖:

yarn add conventional-changelog-cli @commitlint/{config-conventional,cli}
复制代码

参考 Git 提交规范

changlog 生成

按照规范 commit 之后,可以很方便地生成 changelog

语义化版本

开源项目版本信息安利

  • 通常由三位组成:x.y.z
  • 版本严格递增:16.2.0 -> 16.3.0 -> 16.3.1
  • 发布重要版本时,可以发布 alpha(内部), beta(外部小范围), rc(公测) 等先行版本 16.2.0-rc.123

遵循 semver 规范:

  • 避免出现循环依赖
  • 减少依赖冲突

规范格式:

  • 主版本号:做了不兼容的 api 修改
  • 次版本号:新增向下兼容的功能
  • 修订版本号:向下兼容的问题修正

总结

走到这里,你应该已经收获了如下知识:

  • 能够根据项目需求灵活的进行 webpack 的配置。
  • 理解 Tree Shaking、Scope Hoisting 等原理。
  • 能够实现打包多页应用、组件库和基础库、SSR 等。
  • 编写可维护的 webpack 配置,结合单元测试、冒烟测试等控制代码质量,使用 Travis 实现持续集成。

下篇将进入第二部分,大致包含 webpack 性能优化、打包原理、编写 loader 和 plugin。

 
点赞收藏
Ywhoo

和风,网名Ywhoo,现任产研公司资深前端工程师,多年前端开发经验,喜欢追寻新技术,同时致力于前端工程化,热衷于编写更优雅的代码,热爱开源与分享,有大型SPA项目的架构及开发经验。

请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
0
Lv2
Ywhoo

徽章

和风,网名Ywhoo,现任产研公司资深前端工程师,多年前端开发经验,喜欢追寻新技术,同时致力于前端工程化,热衷于编写更优雅的代码,热爱开源与分享,有大型SPA项目的架构及开发经验。