从零开始配置 react + typescript(三):webpack

本篇为 从零开始配置 react + typescript 系列第三篇,将带你们完成模板项目的 webpack 配置。整个项目的配置我力求达到如下目标:javascript

灵活: 我在配置 eslint 是选择使用 js 格式而不是 json,就是为了灵活性,使用 js 文件可让你使用导入其它模块,根据开发环境动态配置,充分发挥 js 语言的能力。css

新潮: 我以为时刻保持对新事物的关注和尝试去使用它是一个优秀的素质。固然,追新很容易碰到坑,可是,不要紧,我已经帮大家踩过了,踩不过去我也不会写出来 😂。从我 eslint parserOptions.ecmaVersion 设置为 2020, 还有常常来一发 yarn upgrade --latest 均可以体现出来。html

严格: 就像我平时判断相等性我大多数状况都是使用严格等 ===,而不是非严格等 ==,我以为越严格,分析起来就越清晰,越早能发现问题。例如我么后面会使用一些 webpack 插件来严格检查模块大小写,检查是否有循环依赖。前端

安逸: 项目中会尽可能集成当前前端生态界实用的和能提升开发愉悦性的(换个词就是花里胡哨)工具。vue

生产 ready:配置的时候针对不一样的打包环境针对性优化,并确保可以投入生产环境使用。html5

本篇将分三大部分介绍:java

  1. dev server
  2. 开发环境优化
  3. 生产环境优化

若是读者是初次看到这篇文章,建议先看下前两篇:node

  1. 从零开始配置 react + typescript(一):dotfiles
  2. 从零开始配置 react + typescript(二):linters 和 formatter

项目地址:react-typescript-boilerplatepython

dev server

想当初我刚开始学前端框架的那时候,也是被 webpack 折磨的欲仙欲死,我是先自学的 node 才开始写前端,写 nodejs 很方便,自带的模块化方案 commonjs,写前端项目就要配置打包工具。当时最火的打包工具已是 webpack 了,其次就是 gulp。配置 webpack 老是记不住 webpack 配置有哪些字段,还要扯到一堆相关的工具像 ES6 编译器 babel,CSS 预处理器 sass/less,CSS 后处理器 postcss,以及各类 webpack 的 loader 和 plugin。而后嫌麻烦就有一段时间都是用官方的脚手架,react 就用 cra,也就是 create-react-app,vue 就用 vue-cli。其实也挺好用的,不过说实话,我我的以为,cravue-cli 设计的好,不管是易用性和扩展性都完败,cra 不方便用户修改 webpack 配置,vue-cli 不但易于用户修改 webpack 配置,还能让用户保存模板以及自带插件系统。我感受 react 官方也意识到了这点,因此官方声称近期将会重点优化相关工具链。如今的话,若是我新建一个前端项目,我会选择本身配,不会去采用官方的 cli,由于我以为我本身已经至关熟悉前端各类构建工具了,等我上半年忙完毕业和找工做的事情我应该会将一些经常使用的配置抽成一个 npm 包,如今每次写一个项目都 copy 改太累了,一个项目的构建配置有优化点,其它项目都要手动同步一下,效率过低。react

技术选型

TypeScript 做为静态类型语言,相对于 js 而言,在类型提示上带来的提高无疑是巨大的。借助 IDE 的类型提示和代码补全,咱们须要知道 webpack 配置对象有哪些字段就不用去查官方文档了,并且还不会敲错,很安逸,因此开发语言就选择 TypeScript

官方文档上有专门一节 Configuration Languages 介绍 webpack 命令行工具怎么使用 ts 格式的配置文件 ,我以为 webpack-dev-server 命令行工具应该是同样的。

可是我不打算使用官方文档介绍的方式,我压根不打算使用命令行工具,用 node API 才是最灵活的配置方式。配置 webpack devServer 总结一下有如下方式:

  1. webpack-dev-server,这是最不灵活的方式,固然使用场景简单的状况下仍是很方便的
  2. webpack-dev-server node API,在 node 脚本里面调用 web-dev-server 包提供的 node API 来启动 devServer
  3. express + webpack devServer 相关中间件,实际上 webpack-dev-server 就是使用 express 以及一些 devServer 相关的中间件开发的。在这种方式下, 各类中间件直接暴露出来了,咱们能够灵活配置各个中间件的选项。
  4. koa + webpack devServer 相关中间件,我在 github 上还真的搜到了和 webpack devServer 相关的 webpack 中间件。其实 webpack devServer 就是一个 node server 嘛,用什么框架技术实现不重要,能实现咱们须要的功能就行。

我最终采用 express + webpack devServer 相关中间件的方式,为何不选择用 koa ?由于我以为官方用的就是 express,用 express 确定要比 koa 更成熟稳定,坑要少一些。

实现最基本的打包功能

从简到繁,咱们先来实现最基本的打包功能使其可以打包 tsx 文件,在此基础上一步一步丰富,优化咱们的配置。

配置入口文件

先安装 TypeScript:

# 本地安装开发依赖 typescript
yarn add typescript -D
复制代码

每一个 TypeScript 项目都须要有一个 tsconfig.json 配置文件,使用下面的命令在 src 目录下新建 tsconfig.json 文件:

cd src && npx tsc --init && cd ..
复制代码

咱们暂时调整成这样:

{
    "compilerOptions": {
        /* Basic Options */
        "jsx": "react",
        "isolatedModules": true,

        /* Strict Type-Checking Options */
        "strict": true,

        /* Additional Checks */
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,

        /* Module Resolution Options */
        "moduleResolution": "node",
        "esModuleInterop": true,
        "resolveJsonModule": true,
        "baseUrl": "./",
        "paths": {
            // 配置模块路径映射
            "@/*": ["./*"],
        },

        /* Experimental Options */
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,

        /* Advanced Options */
        "forceConsistentCasingInFileNames": true,
        "skipLibCheck": true,

        // 下面这些选项对 babel 编译 TypeScript 没有做用可是可让 VSCode 等编辑器正确提示错误
        "target": "ES2019",
        "module": "ESNext"
    }
}
复制代码

咱们将使用 babel 去编译 TypeScript,babel 在编译 TypeScript 代码是直接去掉 TypeScript 的类型,而后当成普通的 javascript 代码使用各类插件进行编译,tsc 并无介入编译过程,所以 tsconfig.json 中不少选项例如 targetmodule 是没有用的。

启用 isolatedModules 选项会在 babel 编译代码时提供一些额外的检查,esModuleInterop 这个选项是用来为了让没有 default 属性的模块也可使用默认导入,举个简单的例子,若是这个选项没开启,那你导入 fs 模块只能像下面这样导入:

import * as fs from 'fs';
复制代码

开启了之后,能够直接使用默认导入:

import fs from 'fs';
复制代码

本质上 ESM 默认导入是导入模块的 default 属性:

import fs from 'fs';
// 等同于
import * as __module__ from 'fs';
let fs = __module__.default;
复制代码

可是 node 内建模块 fs 是没有 default 属性的,开启 isolatedModules 选项就会在没有 default 属性的状况下自动转换:

import fs, { resolve } from 'fs';
// 转换成
import * as fs from 'fs';
let { resolve } = fs;
复制代码

咱们添加一个入口文件 src/index.tsx,内容很简单:

import plus from './plus';

console.log(plus(404, 404, 404, 404, 404)); // => 2020
复制代码

src/plus.ts 内容为:

export default function plus(...nums: number[]) {
  return nums.reduce((pre, current) => pre + current, 0);
}
复制代码

编译 TypeScript

咱们知道 webpack 默认的模块化系统只支持 js 文件,对于其它类型的文件如 jsx, ts, tsx, vue 以及图片字体等文件类型,咱们须要安装对应的 loader。对于 ts 文件,目前存在比较流行的方案有三种:

  1. babel + @babel/preset-typescript

  2. ts-loader

  3. awesome-typescript-loader

awesome-typescript-loader 就算了,做者已经放弃维护了。首先 babel 咱们必定要用的,由于 babel 生态有不少实用的插件。虽然 babel 是能够和 ts-loader 一块儿用,ts-loader 官方给了一个例子 react-babel-karma-gulp,可是我以为既然 babel 已经可以编译 TypeScript 咱们就不必再加一个 ts-loader,因此我选择方案一。须要指出的一点就是就是 babel 默认不会检查 TypeScript 的类型,后面 webpack 插件部分咱们会经过配置 fork-ts-checker-webpack-plugin 来解决这个问题。

添加 webpack 配置

咱们将把全部 node 脚本放到项目根目的 scripts 文件夹,由于 src 文件夹是前端项目,而 scripts 文件夹是 node 项目,咱们应该分别配置 tsconfig.json,经过下面的命令在其中生成初始的 tsconfig.json 文件:

cd ./scripts && npx tsc --init && cd ..
复制代码

咱们调整成酱:

// scripts/tsconfig.json
{
    "compilerOptions": {
        /* Basic Options */
        "target": "ES2019",
        "module": "commonjs",

        /* Strict Type-Checking Options */
        "strict": true,

        /* Additional Checks */
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,

        /* Module Resolution Options */
        "moduleResolution": "node",
        "esModuleInterop": true,
        "resolveJsonModule": true,

        /* Experimental Options */
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,

        /* Advanced Options */
        "forceConsistentCasingInFileNames": true,
        "skipLibCheck": true
    }
}
复制代码

提几个须要注意的地方:

  • "target": "ES2019",其实编译级别你调的很低是没问题的,你用高级语法 tsc 就转码呗,缺点就是转码后代码体积通常会变大,执行效率也会下降,原生语法通常都是被优化过的。我喜欢调高一点,通常来讲只要不用那些在代码运行平台还不支持的语法就没问题。自从 TypeScript3.7 支持了可选链,我就开始尝试在 TypeScript 使用它,可是问题来了,我以前编译级别一直都是调成最高,也就是 ESNext,由于可选链在 ES2020 已是标准了,因此 tsc 对于可选链不会转码的。而后 node 12 还不支持可选链,就会报语法错误,因而我就降到 ES2019 了。

  • Strict Type-Checking Options,这部分全开,既然上了 TypeScript 的船,就用最严格的类型检查,拒绝 AnyScript

接着咱们新建 scripts/configs文件夹,里面用来存放包括 webpack 的配置文件。在其中新建三个 webpack 的配置文件 webpack.common.tswebpack.dev.tswebapck.prod.tswebpack.common.ts 保存一些公共的配置文件,webpack.dev.ts 是开发环境用的,会被 devServer 读取,webapck.prod.ts 是咱们在构建生产环境的 bundle 时用的。

咱们接着安装 webpack 和 webpack-merge 以及它们的类型声明文件:

yarn add webpack webpack-merge @types/webpack @types/webpack-merge -D
复制代码

webpack-merge 是一个为 merge webpack 配置设计的 merge 工具,提供了一些高级的 merge 方式。不过我目前并无用到那些高级的 merge 方式,就是当成普通的 merge 工具使用,后续能够探索一下这方面的优化。

为了编译 tsx,咱们须要安装 babel-loader 和相关插件:

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

新建 babel 配置文件 babel.config.js,如今咱们只添加一个 TypeScript preset:

// babel.config.js
module.exports = function (api) {
  api.cache(true);

  const presets = ['@babel/preset-typescript'];
  const plugins = [];

  return {
    presets,
    plugins,
  };
};
复制代码

添加 babel-loader 到 webpack.common.ts

// webpack.common.ts`
import { Configuration } from 'webpack';
import { projectName, projectRoot, resolvePath } from '../env';

const commonConfig: Configuration = {
  context: projectRoot,
  entry: resolvePath(projectRoot, './src/index.tsx'),
  output: {
    publicPath: '/',
    path: resolvePath(projectRoot, './dist'),
    filename: 'js/[name]-[hash].bundle.js',
    // 加盐 hash
    hashSalt: projectName || 'react typescript boilerplate',
  },
  resolve: {
    // 咱们导入ts 等模块通常不写后缀名,webpack 会尝试使用这个数组提供的后缀名去导入
    extensions: ['.ts', '.tsx', '.js', '.json'],
  },
  module: {
    rules: [
      {
        // 导入 jsx 的人少喝点
        test: /\.(tsx?|js)$/,
        loader: 'babel-loader',
        // 开启缓存
        options: { cacheDirectory: true },
        exclude: /node_modules/,
      },
    ],
  },
};
复制代码

我以为这个 react + ts 项目不该该会出现 jsx 文件,若是导入了 jsx 文件 webpack 就会报错找不到对应的 loader,可让咱们及时处理掉这个有问题的文件。

使用 express 开发 devServer

咱们先安装 express 以及和 webpack devServer 相关的一些中间件:

yarn add express webpack-dev-middleware webpack-hot-middleware @types/express @t
ypes/webpack-dev-middleware @types/webpack-hot-middleware -D
复制代码

webpack-dev-middleware 这个 express 中间件的主要做用:

  1. 做为一个静态文件服务器,使用内存文件系统托管 webpack 编译出的 bundle
  2. 若是文件被修改了,会延迟服务器的请求直到编译完成
  3. 配合 webpack-hot-middleware 实现热更新功能

webpack-hot-middleware 这个 express 中间件会将本身注册为一个 webpack 插件,监听 webpack 的编译事件。 你哪一个 entry 须要实现热更新,就要在那个 entry 中导入这个插件提供的 webpack-hot-middleware/client.js 客户端补丁。这个前端代码会获取 devServer 的 Server Sent Events 链接,当有编译事件发生,devServer 会发布通知给这个客户端。客户端接受到通知后,会经过比对 hash 值判断本地代码是否是最新的,若是不是就会向 devServer 拉取更新补丁借助一些其它的工具例如 react-hot-loader 实现热更新。

下面是我另一个还在开发的 electron 项目修改了一行代码后, client 补丁发送的两次请求:

hash

update

第一次请求返回的那个 h 值动动脚趾头就能猜出来就是 hash 值,发现和本地的 hash 值比对不上后,再次请求更新补丁。

咱们新建文件 scripts/start.ts 用来启动咱们的 devServer:

import chalk from 'chalk';
import getPort from 'get-port';
import logSymbols from 'log-symbols';
import open from 'open';
import { argv } from 'yargs';
import express, { Express } from 'express';
import webpack, { Compiler, Stats } from 'webpack';
import historyFallback from 'connect-history-api-fallback';
import cors from 'cors';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';

import proxy from './proxy';
import devConfig from './configs/webpack.dev';
import { hmrPath } from './env';

function openBrowser(compiler: Compiler, address: string) {
    if (argv.open) {
        let hadOpened = false;
        // 编译完成时执行
        compiler.hooks.done.tap('open-browser-plugin', async (stats: Stats) => {
            // 没有打开过浏览器而且没有编译错误就打开浏览器
            if (!hadOpened && !stats.hasErrors()) {
                await open(address);
                hadOpened = true;
            }
        });
    }
}

function setupMiddlewares(compiler: Compiler, server: Express) {
    const publicPath = devConfig.output!.publicPath!;

    // 设置代理
    proxy(server);

    // 使用 browserRouter 须要重定向全部 html 页面到首页
    server.use(historyFallback());

    // 开发 chrome 扩展的时候可能须要开启跨域,参考:https://juejin.cn/post/6844904049276354567
    server.use(cors());

    const devMiddlewareOptions: webpackDevMiddleware.Options = {
        // 保持和 webpack 中配置一致
        publicPath,
        // 只在发生错误或有新的编译时输出
        stats: 'minimal',
        // 须要输出文件到磁盘能够开启
        // writeToDisk: true
    };
    server.use(webpackDevMiddleware(compiler, devMiddlewareOptions));

    const hotMiddlewareOptions: webpackHotMiddleware.Options = {
        // sse 路由
        path: hmrPath,
        // 编译出错会在网页中显示出错信息遮罩
        overlay: true,
        // webpack 卡住自动刷新页面
        reload: true,
    };
    server.use(webpackHotMiddleware(compiler, hotMiddlewareOptions));
}

async function start() {
    const HOST = '127.0.0.1';
    // 4个备选端口,都被占用会使用随机端口
    const PORT = await getPort({ port: [3000, 4000, 8080, 8888] });
    const address = `http://${HOST}:${PORT}`;

    // 加载 webpack 配置
    const compiler = webpack(devConfig);
    openBrowser(compiler, address);

    const devServer = express();
    setupMiddlewares(compiler, devServer);

    const httpServer = devServer.listen(PORT, HOST, err => {
        if (err) {
            console.error(err);
            return;
        }
        // logSymbols.success 在 windows 平台渲染为 √ ,支持的平台会显示 ✔
        console.log(
            `DevServer is running at ${chalk.magenta.underline(address)} ${logSymbols.success}`,
        );
    });

    // 咱们监听了 node 信号,因此使用 cross-env-shell 而不是 cross-env
    // 参考:https://github.com/kentcdodds/cross-env#cross-env-vs-cross-env-shell
    ['SIGINT', 'SIGTERM'].forEach((signal: any) => {
        process.on(signal, () => {
            // 先关闭 devServer
            httpServer.close();
            // 在 ctrl + c 的时候随机输出 'See you again' 和 'Goodbye'
            console.log(
                chalk.greenBright.bold(`\n${Math.random() > 0.5 ? 'See you again' : 'Goodbye'}!`),
            );
            // 退出 node 进程
            process.exit();
        });
    });
}

// 写过 python 的人应该不会陌生这种写法
// require.main === module 判断这个模块是否是被直接运行的
if (require.main === module) {
    start();
}

复制代码

webpackHotMiddlewareoverlay 选项是用因而否开启错误遮罩:

overlay

不少细节我都写到注释里面了,安装其中用到的一些工具库:

yarn add get-port log-symbols open yarg -D
复制代码

前三个都是 sindresorhus 大佬的做品,get-port 用于获取可用端口,log-symbols 提供了下面四个 log 字符,open 用于系统应用打开 uriuri 包括文件和网址你们应该都知道), yargs 用于解析命令行参数。

log-symbols

webpack-dev-middleware 并不支持 webpack-dev-server 中的 historyFallbackproxy 功能,其实无所谓,咱们能够经过 DIY 咱们的 express server 来实现,咱们甚至可使用 express 来集成 mock 功能。安装对应的两个中间件:

yarn add connect-history-api-fallback http-proxy-middleware @types/connect-history-api-fallback @types/http-proxy-middleware -D
复制代码

connect-history-api-fallback 能够直接做为 express 中间件集成到 express server,封装一下 http-proxy-middleware,能够在 proxyTable 中添加本身的代理配置:

import { createProxyMiddleware } from 'http-proxy-middleware';
import chalk from 'chalk';

import { Express } from 'express';
import { Options } from 'http-proxy-middleware/dist/types';

interface ProxyTable {
    [path: string]: Options;
}

const proxyTable: ProxyTable = {
    // 示例配置
    '/path_to_be_proxy': { target: 'http://target.domain.com', changeOrigin: true },
};

// 修饰连接的辅助函数, 修改颜色并添加下划线
function renderLink(str: string) {
    return chalk.magenta.underline(str);
}

function proxy(server: Express) {
    Object.entries(proxyTable).forEach(([path, options]) => {
        const from = path;
        const to = options.target as string;
        console.log(`proxy ${renderLink(from)} ${chalk.green('->')} ${renderLink(to)}`);

        // eslint-disable-next-line no-param-reassign
        if (!options.logLevel) options.logLevel = 'warn';
        server.use(path, createProxyMiddleware(options));

        // 若是须要更灵活的定义方式,请在下面直接使用 server.use(path, proxyMiddleware(options)) 定义
    });
    process.stdout.write('\n');
}

export default proxy;
复制代码

为了启动 devServer,咱们还须要安装两个命令行工具:

yarn add ts-node cross-env -D
复制代码

ts-node 可让咱们直接运行 TypeScript 代码,cross-env 是一个跨操做系统的设置环境变量的工具,添加启动命令到 npm script:

// package.json
{
    "scripts": {
        "start": "cross-env-shell NODE_ENV=development ts-node --files -P ./scripts/tsconfig.json ./scripts/start.ts --open",
    }
}
复制代码

cross-env 官方文档提到若是要在 windows 平台处理 node 信号例如 SIGINT,也就是咱们 ctrl + c 时触发的信号应该使用 cross-env-shell 命令而不是 cross-env

ts-node 为了提升执行速度,默认不会读取 tsconfig.json 中的 files, includeexclude 字段,而是基于模块依赖读取的。这会致使咱们后面写的一些全局的 .d.ts 文件不会被读取,为此,咱们须要指定 --files 参数,详情能够查看 help-my-types-are-missing。咱们的 node 代码并很少,并且又不是常常性重启项目,直接让 ts-node 扫描整个 scripts 文件夹没多大影响。

启动咱们的 dev server,经过 ctrl + c 退出:

npm start
复制代码

dev server

开发环境优化

plugins

每一个 webpack plugin 都是一个包含 apply 方法的 class,在咱们调用 compiler.run 或者 compiler.watch 的时候它就会被调用,而且把 compiler 做为参数传它。compiler 对象提供了各个时期的 hooks,咱们能够经过这些 hooks 挂载回调函数来实现各类功能,例如压缩,优化统计信息,在在编译完弹个编译成功的通知等。

hooks

显示打包进度

webpack-dev-server 在打包时使用 --progress 参数会在控制台实时输出百分比表示当前的打包进度,可是从上面的图中能够看出只是输出了一些统计信息(stats)。想要实时显示打包进度我了解的有三种方式:

  1. webpack 内置的 webpack.ProgressPlugin 插件

  2. progress-bar-webpack-plugin

  3. webpackbar

内置的 ProgressPlugig 很是的原始,你能够在回调函数获取当前进度,而后按照本身喜欢的格式去打印:

const handler = (percentage, message, ...args) => {
  // e.g. Output each progress message directly to the console:
  console.info(percentage, message, ...args);
};
new webpack.ProgressPlugin(handler);
复制代码

progress-bar-webpack-plugin 这个插件不是显示百分比,而是显示一个用字符画出来的进度条:

progress-bar-webpack-plugin

这个插件其实仍是挺简洁实用的,可是有个 bug ,若是在打印进度条的时候输出了其它语句,进度条就会错位,咱们的 devServer 会在启动后会输出地址:

console.log(`DevServer is running at ${chalk.magenta.underline(address)} ${logSymbols.success}`);
复制代码

使用这个进度条插件就会出问题下面的问题,遂放弃。

progress-bar-webpack-plugin

webpackbar 是 nuxt project 下的库,背靠 nuxt,质量绝对有保证。我以前有段时间用的是 progress-bar-webpack-plugin,由于我在 npm 官网搜索 webpack progress,综合看下来就它比较靠谱,webpackbar 都没搜出来。 看了下 webpackbarpackage.json,果真 keywords 都是空的。webpackBar 仍是我在研究 ant design 的 webpack 配置看到它用了这个插件,才发现了这个宝藏:

webpackbar

安装 webpackbar

yarn add webpackbar @types/webpackbar -D
复制代码

添加配置到 webpack.common.ts 的 plugins 数组,颜色咱们使用 react 蓝:

import { Configuration } from 'webpack';

const commonConfig: Configuration = {
  plugins: [
    new WebpackBar({
      name: 'react-typescript-boilerplate',
      // react 蓝
      color: '#61dafb',
    }),
  ],
};
复制代码

优化控制台输出

咱们使用 friendly-errors-webpack-plugin 插件让控制台的输出更加友好,下面使用了以后编译成功时的效果:

build successful

yarn add friendly-errors-webpack-plugin @types/friendly-errors-webpack-plugin -D
复制代码
// webpack.common.ts
import FriendlyErrorsPlugin from 'friendly-errors-webpack-plugin';

const commonConfig: Configuration = {
  plugins: [new FriendlyErrorsPlugin()],
};
复制代码

构建通知

build notification

在我大四实习以前,我就没完整写过 vue 项目的,在上家公司实习的那段时间主要就是写 vue,当时我对 vue-cli 那个频繁的错误通知很反感,我和同事说我想去掉这个通知,没曾想同事都是比较喜欢那个通知,既然有人须要,那咱们这个项目也配一下。

咱们使用 webpack-build-notifier 来支持错误通知,这个插件是 TypeScript 写的,不须要安装 types:

yarn add webpack-build-notifier -D
复制代码
// webpack.common.ts
import WebpackBuildNotifierPlugin from 'webpack-build-notifier';

const commonConfig: Configuration = {
  plugins: [
    // suppressSuccess: true 设置只在第一次编译成功时输出成功的通知, rebuild 成功的时候不通知
    new WebpackBuildNotifierPlugin({ suppressSuccess: true }),
  ],
};
复制代码

由于我不喜欢弹通知,因此模板项目中的我注释掉了这个插件,有须要的本身打开就好了。

严格检查路径大小写

下面的测试代表 webpack 默认对路径的大小写不敏感:

path case

咱们使用 case-sensitive-paths-webpack-plugin 对路径进行严格的大小写检查:

yarn add case-sensitive-paths-webpack-plugin @types/case-sensitive-paths-webpack-plugin -D
复制代码
// webpack.common.ts
import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin';

const commonConfig: Configuration = {
  plugins: [new CaseSensitivePathsPlugin()],
};
复制代码

path-case-check

实际打包测试中发现这个插件很是耗时,而且由于 eslint-import-plugin 默认会对只是大小写不同的模块路径会报错,所以咱们这个项目就不集成了。

case sensitive

循环依赖检查

circle-dependencies

webpack 默认不会对循环依赖报错,经过 circular-dependency-plugin 这个 webpack 插件能够帮咱们及时发现循环依赖的问题:

yarn add circular-dependency-plugin @types/circular-dependency-plugin -D
复制代码
// webpack.common.ts
import CircularDependencyPlugin from 'circular-dependency-plugin';

import { projectRoot, resolvePath } from '../env';

const commonConfig: Configuration = {
  plugins: [
    new CircularDependencyPlugin({
      exclude: /node_modules/,
      failOnError: true,
      allowAsyncCycles: false,
      cwd: projectRoot,
    }),
  ],
};
复制代码

circle dependencies error

这里顺便提一下 cwd 也就是工做路径的问题,官方文档直接用 process.cwd(),这是一种很差的作法,项目路径和工做路径是不一样的两个概念。在 node 中表示项目路径永远不要用 preocess.cwd(),由于总会有些沙雕用户不去项目根目录启动。process.cwd() 也就是工做路径返回的是你运行 node 时所在的路径,假设说项目在 /code/projectRoot,有些用户直接在系统根目录打开 terminal,来一句 node ./code/projectRoot/index.js,这时 index.jsprocess.cwd() 返回的是就是系统根路径 /,不是有些人认为的仍是 /code/projectRoot

获取项目路径应该使用 path.resolve

project root

这个问题 eslint-import-plugin 也会报错,而且在 TypeScript 项目中有时候就是须要循环导入文件,所以也不集成。

清理上次打包的 bundle

前面介绍了一些花里胡哨的插件,也介绍了一些让咱们项目保持健康的插件,如今咱们开始介绍一些打包用的插件。

clean-webpack-plugin 它会在第一次编译的时候删除 dist 目录中全部的文件,不过会保留 dist 文件夹,而且再每次 rebuild 的时候会删除全部再也不被使用的文件。

这个项目也是 TypeScript 写的,总感受 TypeScript 写的项目有种莫名的踏实感:

yarn add clean-webpack-plugin -D
复制代码
// webpack.common.ts
import { CleanWebpackPlugin } from 'clean-webpack-plugin';

const commonConfig: Configuration = {
  plugins: [new CleanWebpackPlugin()],
};
复制代码

自动生成 index.html

众所周知,腾讯的前端面试很喜欢考计算机网络,我曾屡次被问到过如何更新强缓存的问题。解决强缓存当即更新的问题咱们通常就是采起在文件名中插入文件内容的 hash 值,而后首页不使用强缓存。这样只要你更新了某个被强缓存的资源文件,因为更新后内容的 hash 值会变化,生成的文件名也会变化,这样你请求首页的时候因为访问的是一个新的资源路径,就会向服务器请求最新的资源。关于浏览器 HTTP 缓存能够看我另外一篇文章:经过-koa2-服务器实践探究浏览器 HTTP 缓存机制

咱们后续优化生产环境构建的时候会对将 CSS 拆分红单独的文件,若是 index.html 中插入的引入外部样式的 link 标签的 href 是咱们手动设置的,那每次修改样式文件,都会生成一个新的 hash 值,咱们都要手动去修改这个路径,太麻烦了,更不要说在开发环境下文件是保存在内存文件系统的,你都看不到文件名。

build hash

使用 html-webpack-plugin 能够自动生成 index.html,而且插入引用到的 bundle 和被拆分的 CSS 等资源路径。

参考 creat-react-app 的模板,咱们新建 public 文件夹,并在其中加入 index.htmlfavico.icomanifest.json 等文件。public 文件夹用于存放一些将被打包到 dist 文件夹一同发布的文件。

安装并配置 html-webpack-plugin

yarn add html-webpack-plugin @types/html-webpack-plugin -D
复制代码
import HtmlWebpackPlugin from 'html-webpack-plugin';
import { __DEV__, projectName, resolvePath, projectRoot, hmrPath } from '../env';

const htmlMinifyOptions: HtmlMinifierOptions = {
    collapseWhitespace: true,
    collapseBooleanAttributes: true,
    collapseInlineTagWhitespace: true,
    removeComments: true,
    removeRedundantAttributes: true,
    removeScriptTypeAttributes: true,
    removeStyleLinkTypeAttributes: true,
    minifyCSS: true,
    minifyJS: true,
    minifyURLs: true,
    useShortDoctype: true,
};

const commonConfig: Configuration = {
    output: {
        publicPath: '/',
    },
    plugins: [
        new HtmlWebpackPlugin({
            // HtmlWebpackPlugin 会调用 HtmlMinifier 对 HTMl 文件进行压缩
            // 只在生产环境压缩
            minify: __DEV__ ? false : htmlMinifyOptions,
            // 指定 html 模板路径
            template: resolvePath(projectRoot, './public/index.html'),
            // 类型很差定义,any 一时爽...
            // 定义一些能够在模板中访问的模板参数
            templateParameters: (...args: any[]) => {
                const [compilation, assets, assetTags, options] = args;
                const rawPublicPath = commonConfig.output!.publicPath!;
                return {
                    compilation,
                    webpackConfig: compilation.options,
                    htmlWebpackPlugin: {
                        tags: assetTags,
                        files: assets,
                        options,
                    },
        			// 除掉 publicPath 的反斜杠,让用户在模板中拼接路径更天然
                    PUBLIC_PATH: rawPublicPath.endsWith('/')
                        ? rawPublicPath.slice(0, -1)
                        : rawPublicPath,
                };
            },
        }),
    ],
};
复制代码

为了让用户能够像 create-react-app 同样在 index.html 里面经过 PUBLIC_PATH 访问发布路径,须要配置 templateParameters 选项添加 PUBLIC_PATH 变量到模板参数,html-webpack-plugin 默认支持部分 ejs 语法,咱们能够经过下面的方式动态设置 favicon.ico , mainfest.json 等资源路径:

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="icon" href="<%= `${PUBLIC_PATH}/favicon.ico` %>" />
    <link rel="apple-touch-icon" href="<%= `${PUBLIC_PATH}/logo192.png` %>" />
    <link rel="manifest" href="<%= `${PUBLIC_PATH}/manifest.json` %>" />
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>
复制代码

拷贝文件到 dist

public 文件夹中有一些文件例如 favico.iconmainfest.json 须要被拷贝到 dist 文件夹,咱们可使用 copy-webpack-plugin 在使用 devServer 的状况下将文件拷贝到内存文件系统,在生产环境构建的时拷贝到磁盘:

yarn add copy-webpack-plugin @types/copy-webpack-plugin -D
复制代码
// webpack.common.ts
import CopyPlugin from 'copy-webpack-plugin';

const commonConfig: Configuration = {
  plugins: [
    new CopyPlugin(
      [
        {
          // 全部一级文件
          from: '*',
          to: resolvePath(projectRoot, './dist'),
          // 目标类型是文件夹
          toType: 'dir',
          // index.html 会经过 html-webpack-plugin 自动生成,因此须要被忽略掉
          ignore: ['index.html'],
        },
      ],
      { context: resolvePath(projectRoot, './public') }
    ),
  ],
};
复制代码

检查 TypeScript 类型

babel 为了提升编译速度只支持 TypeScript 语法编译而不支持类型检查,为了在 webpack 打包的同时支持 ts 类型检查,咱们会使用 webpack 插件 fork-ts-checker-webpack-plugin,这个 webpack 插件会在一个单独的进程并行的进行 TypeScript 的类型检查,这个项目也是 TypeScript 写的,咱们不须要安装 types。

yarn add fork-ts-checker-webpack-plugin -D
复制代码

添加到 webpack.dev.ts,限制使用的内存为 1G:

import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';

const devConfig = merge(commonConfig, {
  mode: 'development',
  plugins: [
    new ForkTsCheckerWebpackPlugin({
      memoryLimit: 1024,
      // babel 转换的是咱们前端代码,因此是指向前端代码的 tsconfig.json
      tsconfig: resolvePath(projectRoot, './src/tsconfig.json'),
    }),
  ],
});
复制代码

同时修改 webpack.prod.ts,由于咱们生产环境构建并不会长时间的占用内存,因此能够调大点,咱们就默认限制生产环境的构建使用的内存为 2G:

// webpack.prod.ts
const prodConfig = merge(commonConfig, {
  mode: 'production',
  plugins: [
    new ForkTsCheckerWebpackPlugin({
      memoryLimit: 1024 * 2,
      tsconfig: resolvePath(projectRoot, './src/tsconfig.json'),
    }),
  ],
});
复制代码

缓存神器

hard-source-webpack-plugin 是一个给 modules 提供中间缓存步骤的 webpack 插件,为了看到效果咱们可能须要运行两次,第一次就是正常的编译速度,第二次可能会快上不少倍,拿我开发的一个 VSCode 插件 来测试一下:

我先把 node_modules/.cache/hard-source 缓存文件夹删掉,看看没有缓存的时候编译速度:

no cache

耗时 3.075 秒,从新编译:

cache

哇 🚀,直接快了 3.6 倍多...

实测发现这个插件在初次打包会耗时严重,而且即将发布的 webpack5 将内置这个功能,具体能够看这个 issue: [spec: webpack 5] - A module disk cache between build processes 。所以咱们这个项目就不集成这个插件了。

好了,插件部分介绍完了,接下来开始配置 loaders !

loaders

webpack 默认只支持导入 js,处理不了其它文件,须要配置对应的 loader,像 excel-loader 就能够解析 excel 为一个对象,file-loader 能够解析 png 图片为最终的发布路径。loader 是做用于一类文件的,plugin 是做用于 webpack 编译的各个时期。

前面咱们只配置了 babel-loader, 使得 webpack 可以处理 TypeScript 文件,实际的开发中咱们还须要支持导入样式文件,图片文件,字体文件等。

处理样式文件

咱们最终要达到的目标是支持 css/less/sass 三种语法,以及经过 postcssautoprefixer 插件实现自动补齐浏览器头等功能。

CSS

处理 css 文件咱们须要安装 style-loadercss-loader

yarn add css-loader style-loader -D
复制代码

css-loader 做用是处理 CSS 文件中的 @importurl() 返回一个合并后的 CSS 字符串,而 style-loader 负责将返回的 CSS 字符串用 style 标签插到 DOM 中,而且还实现了 webpack 的热更新接口。

style-loader 官方示例配置是这样的:

module.exports = {
  module: {
    rules: [
      {
        // i 后缀忽略大小写
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};
复制代码

能够看到匹配正则用了 i 后缀,我以为这样很差,不该该提升一些无心义的容错率,用.CSS 作后缀就不该该让 webpack 编译经过。咱们知道 webpack 的 loaders 加载顺序是从右到左的,因此须要先执行的 css-loader 应该在后执行的 style-loader 后面:

// webpack.common.ts
const commonConfig: Configuration = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              // CSS modules 比较耗性能,默认就是禁用的
              modules: false,
              // 开启 sourcemap
              sourceMap: true,
              // 指定在 CSS loader 处理前使用的 laoder 数量
              importLoaders: 0,
            },
          },
        ],
      },
    ],
  },
};
复制代码
less

less-loader 依赖 less

yarn add less less-loader -D
复制代码
// webpack.common.ts
const commonConfig: Configuration = {
  module: {
    rules: [
      {
        test: /\.less$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false,
              sourceMap: true,
              // 须要先被 less-loader 处理,因此这里设置为 1
              importLoaders: 1,
            },
          },
          {
            // 先让 less-loader 将 less 文件转换成 css 文件
            // 再交给 css-loader 处理
            loader: 'less-loader',
            options: {
              sourceMap: true,
            },
          },
        ],
      },
    ],
  },
};
复制代码
sass

其实我本人历来不用 lessstylus,我一直用的是 sasssass 有两种语法格式,经过后缀名区分。.sass 后缀名是相似 yml 的缩进写法,.scss 是相似于 CSS 的花括号写法,不过支持嵌套和变量等特性。鉴于我基本上没看过哪一个项目用 yml 格式的写法,用的人太少了,咱们模板就只支持 scss 后缀好了。sass-loader 一样依赖 node-sassnode-sass 真是个碧池,没有代理还安装不了,因此我在系列第一篇就在 .npmrc 就配置了 node-sass 的镜像:

yarn add node-sass sass-loader -D
复制代码
// webpack.common.ts
const commonConfig: Configuration = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false,
              sourceMap: true,
              importLoaders: 1,
            },
          },
          {
            loader: 'sass-loader',
            options: {
              // 中间每一个 loader 都要开启 sourcemap,才能生成正确的 soucemap
              sourceMap: true,
            },
          },
        ],
      },
    ],
  },
};
复制代码
postcss

browser prefix

记得我在大一上网页设计课学到 CSS3 的时候,不少属性都要加浏览器头处理兼容性,当时就对 CSS 兴趣大减,太麻烦了。自从 node 的出现,前端工程化开始飞速发展,之前前端老被叫作切图仔,如今前端工程师也能够用 node 作伪全栈开发了。

postcss 是 CSS 后处理器工具,由于先有 CSS,postcss 后去处理它,因此叫后处理器。

less/sass 被称之为 CSS 预处理器,由于它们须要被 lessnode-sass 预先编译代码到 CSS 嘛。

参考 create-react-app 对 postcss 的配置,安装如下插件:

yarn add postcss-loader postcss-flexbugs-fixes postcss-preset-env autoprefixer postcss-normalize -D
复制代码

添加 postcss.config.js 用于配置 postcss

module.exports = {
  plugins: [
    // 修复一些和 flex 布局相关的 bug
    require('postcss-flexbugs-fixes'),
    // 参考 browserslist 的浏览器兼容表自动对那些还不支持的现代 CSS 特性作转换
    require('postcss-preset-env')({
      // 自动添加浏览器头
      autoprefixer: {
        // will add prefixes only for final and IE versions of specification
        flexbox: 'no-2009',
      },
      stage: 3,
    }),
    // 根据 browserslist 自动导入须要的 normalize.css 内容
    require('postcss-normalize'),
  ],
};
复制代码

咱们还须要添加 browserslist 配置到 package.json

// package.json
{
	"browserslist": [
        "last 2 versions",
        // ESR(Extended Support Release) 长期支持版本
        "Firefox ESR",
        "> 1%",
        "ie >= 11"
    ],
}
复制代码

回顾 CSS, less,sass 的配置能够看到有大量的重复,咱们重构并修改 importLoaders 选项:

function getCssLoaders(importLoaders: number) {
  return [
    'style-loader',
    {
      loader: 'css-loader',
      options: {
        modules: false,
        sourceMap: true,
        importLoaders,
      },
    },
    {
      loader: 'postcss-loader',
      options: { sourceMap: true },
    },
  ];
}

const commonConfig: Configuration = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: getCssLoaders(1),
      },
      {
        test: /\.less$/,
        use: [
          // postcss-loader + less-loader 两个 loader,因此 importLoaders 应该设置为 2
          ...getCssLoaders(2),
          {
            loader: 'less-loader',
            options: {
              sourceMap: true,
            },
          },
        ],
      },
      {
        test: /\.scss$/,
        use: [
          ...getCssLoaders(2),
          {
            loader: 'sass-loader',
            options: { sourceMap: true },
          },
        ],
      },
    ],
  },
};
复制代码

处理图片和字体

通常来讲咱们的项目在开发的时候会使用一些图片来测试效果,正式上线再替换成 CDN 而不是使用 webpack 打包的本地图片。处理文件的经常使用 loader 有俩,file-loaderurl-loaderfile-loader 用于解析导入的文件为发布时的 url, 并将文件输出到指定的位置,然后者是对前者的封装,提供了将低于阈值体积(下面就设置为 8192 个字节)的图片转换成 base64。我突然想起之前腾讯的一个面试官问过这么个问题:使用 base64 有什么坏处吗?其实我以为 base64 好处就是不用二次请求,坏处就是图片转 base64 体积反而会变大,变成原来的三分之四倍。

base64

yarn add url-loader -D
复制代码
const commonConfig: Configuration = {
  module: {
    rules: [
      {
        test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
        use: [
          {
            loader: 'url-loader',
            options: {
              // 图片低于 10k 会被转换成 base64 格式的 dataUrl
              limit: 10 * 1024,
              // [hash] 占位符和 [contenthash] 是相同的含义
              // 都是表示文件内容的 hash 值,默认是使用 md5 hash 算法
              name: '[name].[contenthash].[ext]',
              // 保存到 images 文件夹下面
              outputPath: 'images',
            },
          },
        ],
      },
      {
        test: /\.(ttf|woff|woff2|eot|otf)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: '[name]-[contenthash].[ext]',
              outputPath: 'fonts',
            },
          },
        ],
      },
    ],
  },
};
复制代码

注意到我这里文件名中都插入了文件内容 hash 值,这样就能够解决强缓存须要当即更新的问题。

sourcemap

devtool 构建速度 从新构建速度 生产环境 品质(quality)
(none) +++ +++ yes 打包后的代码
eval +++ +++ no 生成后的代码
cheap-eval-source-map + ++ no 转换过的代码(仅限行)
cheap-module-eval-source-map o ++ no 原始源代码(仅限行)
eval-source-map -- + no 原始源代码
cheap-source-map + o yes 转换过的代码(仅限行)
cheap-module-source-map o - yes 原始源代码(仅限行)
inline-cheap-source-map + o no 转换过的代码(仅限行)
inline-cheap-module-source-map o - no 原始源代码(仅限行)
source-map -- -- yes 原始源代码
inline-source-map -- -- no 原始源代码
hidden-source-map -- -- yes 原始源代码
nosources-source-map -- -- yes 无源代码内容

+++ 很是快速, ++ 快速, + 比较快, o 中等, - 比较慢, --

sourcemap 是如今前端界不少工具必不可缺的一个功能,webpack,TypeScript,babel,powser-assert 等转换代码的工具都要提供 sourcemap 功能,源代码被压缩,混淆,polyfill,没有 sourcemap,根本没办法调试定位问题。

考虑到编译速度,调式友好性,我选择 eval-source-map,若是用户在打包时以为慢,并且可以忍受没有列号,能够考虑调成 cheap-eval-source-map

咱们修改 webpack.dev.ts 的 devtool 为 eval-source-map

// webpack.dev.ts
import commonConfig from './webpack.common';

const devConfig = merge(commonConfig, {
  devtool: 'eval-source-map',
});
复制代码

这里顺便提一下 webpack 插件 error-overlay-webpack-plugin,它提供了和 create-react-app 同样的错误遮罩:

error overlay

可是它有一个限制就是不能使用任何一种基于 eval 的 sourcemap,感兴趣的读者能够尝试如下。

热更新

咱们前面给 devServer 添加了 webpack-hot-middleware 中间件,参考它的文档咱们须要先添加 webapck 插件webpack.HotModuleReplacementPlugin

// webpack.dev.ts
import { HotModuleReplacementPlugin, NamedModulesPlugin } from 'webpack';

const devConfig = merge(commonConfig, {
  plugins: [new HotModuleReplacementPlugin()],
});
复制代码

还要添加 'webpack-hot-middleware/client' 热更新补丁到咱们的 bundle,加入 entry 数组便可:

// webpack.common.ts
import { __DEV__, hmrPath } from '../env';


const commonConfig: Configuration = {
    entry: [resolvePath(projectRoot, './src/index.tsx')],
};

if (__DEV__) {
    (commonConfig.entry as string[]).unshift(`webpack-hot-middleware/client?path=${hmrPath}`);
}
复制代码

经过在 entry 后面加 queryString 的方式可让咱们配置一些选项,它是怎么实现的呢?查看 'webpack-hot-middleware/client' 源码能够看到,webpack 会将 queryString 做为全局变量注入这个文件:

entry query

其实到这咱们也就支持了 CSS 的热更新(style-loader 实现了热更新接口),若是要支持 react 组件的热更新咱们还须要配置 react-hot-loader ,配置它以前咱们先来优化咱们的 babel 配置。

babel 配置优化

前面咱们在前面只配置了一个 @babel/preset-typescript 插件用于编译 TypeScript,其实还有不少能够优化的点。

@babel/preset-env

在 babel 中,preset 表示 plugin 的集合@babel/preset-env 可让 babel 根据咱们配置的 browserslist 只添加须要转换的语法和 polyfill。

安装 @babel/preset-env

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

@babel/plugin-transform-runtime

咱们知道默认状况下, babel 在编译每个模块的时候在须要的时候会插入一些辅助函数例如 _extend,每个须要的模块都会生成这个辅助函数会形成不必的代码膨胀,@babel/plugin-transform-runtime 这个插件会将全部的辅助函数都从 @babel/runtime 导入,来减小代码体积。

yarn add @babel/plugin-transform-runtime -D
复制代码

@babel/preset-react

虽然 @babel/preset-typescript 就能转换 tsx 成 js 代码,可是 @babel/preset-react 还集成了一些针对 react 项目的实用的插件。

@babel/preset-react 默认会开启下面这些插件:

若是设置了 development: true 还会开启:

安装依赖 @babel/preset-react

yarn add @babel/preset-react -D
复制代码

react-hot-loader

为了实现组件的局部刷新,咱们须要安装 react-hot-loader 这个 babel 插件。

yarn add react-hot-loader
复制代码

这个插件不须要安装成 devDependencies,它在生产环境下不会被执行而且会确保它占用的体积最小。其实官方正在开发下一代的 react 热更新插件 React Fast Refresh,不过目前还不支持 webpack。

为了看到测试效果,咱们安装 react 全家桶而且调整一下 src 文件夹下的默认内容:

yarn add react react-dom react-router-dom
yarn add @types/react @types/react-dom @types/react-router-dom -D
复制代码

react 是框架核心接口,react-dom 负责挂载咱们的 react 组件到真实的 DOM 上, react-dom-router 是实现了 react-router 接口的 web 平台的路由库。

react-hot-loader 接管咱们的 react 根组件,其实这个 hot 函数就是一个 hoc 嘛:

// App.ts
import React from 'react';
import { hot } from 'react-hot-loader/root';

import './App.scss';

const App = () => {
  return (
    <div className="app"> <h2 className="title">react typescript boilerplate</h2> </div>
  );
};

export default hot(App);
复制代码

在 webpack entry 加入热更新补丁:

const commonConfig: Configuration = {
  entry: ['react-hot-loader/patch', resolvePath(projectRoot, './src/index.tsx')],
};
复制代码

官方文档提到若是须要支持 react hooks 的热更新,咱们还须要安装 @hot-loader/react-dom,使用它来替换默认的 react-dom 来添加一些额外的热更新特性,为了替换 react-dom 咱们须要配置 webpack alias:

// webpack.common.ts
module.exports = {
  resolve: {
    alias: {
      'react-dom': '@hot-loader/react-dom',
    },
  },
};
复制代码

结合前面提到 babel 插件,最终修改 babel.config.js 成:

const envPreset = [
  '@babel/preset-env',
  {
    // 只导入须要的 polyfill
    useBuiltIns: 'usage',
    // 指定 corjs 版本
    corejs: 3,
    // 禁用模块化方案转换
    modules: false,
  },
];

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['@babel/preset-typescript', envPreset],
    plugins: ['@babel/plugin-transform-runtime'],
    env: {
      // 开发环境配置
      development: {
        presets: [['@babel/preset-react', { development: true }]],
        plugins: ['react-hot-loader/babel'],
      },
      // 生产环境配置
      production: {
        presets: ['@babel/preset-react'],
        plugins: ['@babel/plugin-transform-react-constant-elements', '@babel/plugin-transform-react-inline-elements'],
      },
    },
  };
};
复制代码

注意到咱们生产环境下还安装了两个插件进行生产环境的优化:

yarn add @babel/plugin-transform-react-constant-elements @babel/plugin-transform-react-inline-elements -D
复制代码

@babel/plugin-transform-react-constant-elements 的做用是像下面样将函数组件中的变量提高到函数外来避免每次从新调用函数组件重复声明和不必的垃圾回收:

const Hr = () => {
  return <hr className="hr" />;
};

// 转换成

const _ref = <hr className="hr" />;

const Hr = () => {
  return _ref;
};
复制代码

@babel/plugin-transform-react-inline-elements 的做用读者能够参考 react 的这个 issue:Optimizing Compiler: Inline ReactElements

生产环境优化

添加版权声明

这个直接用 webpack 内置的 BannerPlugin 便可:

import { BannerPlugin } from 'webpack';

const mergedConfig = merge(commonConfig, {
  mode: 'production',
  plugins: [
    new BannerPlugin({
      raw: true,
      banner: `/** @preserve Powered by react-typescript-boilerplate (https://github.com/tjx666/react-typescript-boilerplate) */`,
    }),
  ],
});
复制代码

copyright

须要注意的是咱们在版权声明的注释中加了 @preserve 标记,咱们后面会使用 terser 在生产环境构建时压缩代码,压缩代码时会去掉全部注释,除了一些包含特殊标记的注释,例如咱们添加的 @preserve

CSS 拆分

若是 CSS 是包含在咱们打包的 JS bundle 中那会致使最后体积很大,严重状况下访问首页会形成短暂的白屏。拆分 CSS 咱们直接使用 mini-css-extract-plugin

yarn add mini-css-extract-plugin -D
复制代码

修改生产环境配置:

// webpack.prod.ts
import MiniCssExtractPlugin from 'mini-css-extract-plugin';

const prodConfig = merge(commonConfig, {
  mode: 'production',
  plugins: [
    new MiniCssExtractPlugin({
      // 文件名中插入文件内容的 hash 值
      filename: 'css/[name].[contenthash].css',
      chunkFilename: 'css/[id].[contenthash].css',
      ignoreOrder: false,
    }),
  ],
});
复制代码

mini-css-extract-plugin 还提供了 mini-css-extract-plugin.loader,它不能和 style-loader 共存,因此咱们修改 webpack.common.ts 的配置使得开发环境下使用 style-loader 生产环境下使用 mini-css-extract-plugin.loader

import { loader as MiniCssExtractLoader } from 'mini-css-extract-plugin';
import { __DEV__ } from '../env';

function getCssLoaders(importLoaders: number) {
  return [
    __DEV__ ? 'style-loader' : MiniCssExtractLoader,
    {
      loader: 'css-loader',
      options: {
        modules: false,
        sourceMap: true,
        importLoaders,
      },
    },
    {
      loader: 'postcss-loader',
      options: { sourceMap: true },
    },
  ];
}
复制代码

代码压缩

JavaScript 压缩

网上不少教程在讲 webpack 压缩代码的时候都是使用 uglifyjs-webpack-plugin,其实这个仓库早就放弃维护了,并且它不支持 ES6 语法,webpack 的核心开发者 evilebottnawi 都转向维护 terser-webpack-plugin 了。咱们使用 terser-webpack-plugin 在生产环境对代码进行压缩,而且咱们能够利用 webpack4 新增的 tree-shaking 去除代码中的死代码,进一步减少 bundle 体积:

yarn add terser-webpack-plugin @types/terser-webpack-plugin -D
复制代码

treeshake 须要在 package.json 中配置 sideEffects 字段,详情能够阅读官方文档:Tree Shaking

CSS 压缩

压缩 CSS 使用 optimize-css-assets-webpack-plugin

yarn add optimize-css-assets-webpack-plugin @types/optimize-css-assets-webpack-plugin -D
复制代码

修改 webpack.prod.ts

import TerserPlugin from 'terser-webpack-plugin';
import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin';

const prodConfig = merge(commonConfig, {
  mode: 'production',
  optimization: {
    // 使用 minimizer 而不是默认的 uglifyJS
    minimize: true,
    // 两个 minimizer:TerserPlugin 和 OptimizeCSSAssetsPlugin
    minimizer: [new TerserPlugin({ extractComments: false }), new OptimizeCSSAssetsPlugin()],
  },
});
复制代码

构建分析

咱们添加一些 webpack 插件用来进行构建分析

时间统计

speed measure

咱们使用 speed-measure-webpack-plugin 对打包时间进行统计:

yarn add speed-measure-webpack-plugin -D
复制代码

项目进行到这,咱们终于碰到第一个没有 TypeScript 类型声明文件的库了,新建 scripts/typings/index.d.ts 文件,由于须要编写的类型不多,index.d.ts 就做为一个全局声明文件,在其中添加 speed-measure-webpack-plugin 的外部模块声明:

// scripts/typings/index.d.ts
declare module 'speed-measure-webpack-plugin' {
    import { Configuration, Plugin } from 'webpack';

    // 查看官方文档,须要哪些选项就声明哪些选项就行
  	// 能够看出 TypeScript 是很是灵活的
    interface SpeedMeasurePluginOptions {
        disable: boolean;
        outputFormat: 'json' | 'human' | 'humanVerbose' | ((outputObj: object) => void);
        outputTarget: string | ((outputObj: string) => void);
        pluginNames: object;
        granularLoaderData: boolean;
    }

    // 继承 Plugin 类, Plugin 类都有 apply 方法
    class SpeedMeasurePlugin extends Plugin {
        constructor(options?: Partial<SpeedMeasurePluginOptions>);
        wrap(webpackConfig: Configuration): Configuration;
    }

    export = SpeedMeasurePlugin;
}
复制代码

修改 webpack.prod.ts

import SpeedMeasurePlugin from 'speed-measure-webpack-plugin';

const mergedConfig = merge(commonConfig, {
  // ...
});

const smp = new SpeedMeasurePlugin();
const prodConfig = smp.wrap(mergedConfig);
复制代码

bundle 分析

bundle analyze

yarn add BundleAnalyzerPlugin @types/BundleAnalyzerPlugin -D
复制代码

咱们添加一个 npm script 用于带 bundle 分析的构建,由于有些时候咱们并不想打开一个浏览器去分析各个模块的大小和占比:

"scripts": {
    "build": "cross-env-shell NODE_ENV=production ts-node --files -P scripts/tsconfig.json scripts/build",
    "build-analyze": "cross-env-shell NODE_ENV=production ts-node --files -P scripts/tsconfig.json scripts/build --analyze",
},
复制代码

修改 webpack.prod.ts

// 添加
import { isAnalyze } from '../env';

if (isAnalyze) {
    mergedConfig.plugins!.push(new BundleAnalyzerPlugin());
}
复制代码

这样当咱们想看各个模块在 bundle 中的大小和占比的时候能够运行 npm run build-analyze,将会自动在浏览器中打开上图中的页面。

准备 gzip 压缩版本

咱们使用官方维护的 compression-webpack-plugin 来为打包出来的各个文件准备 gzip 压缩版:

yarn add compression-webpack-plugin @types/compression-webpack-plugin -D
复制代码

跟踪 gzip 后的资源大小

trace size

size-plugin 是谷歌出品的一个显示 webpack 各个 chunk gzip 压缩后的体积大小以及相比于上一次的大小变化,上图中红框中的部分显示了我加了一句 log 以后 gizip 体积增长了 11B。

yarn add size-plugin -D
复制代码

这个库有没有官方的 types 文件,咱们添加 size-plugin 的外部模块声明:

// scripts/typings/index.d.ts
declare module 'size-plugin' {
    import { Plugin } from 'webpack';

    interface SizePluginOptions {
        pattern: string;
        exclude: string;
        filename: string;
        publish: boolean;
        writeFile: boolean;
        stripHash: Function;
    }

    class SizePlugin extends Plugin {
        constructor(options?: Partial<SizePluginOptions>);
    }

    export = SizePlugin;
}
复制代码
// webpack.prod.ts
const mergedConfig = merge(commonConfig, {
  plugins: [
    // 不输出文件大小到磁盘
    new SizePlugin({ writeFile: false }),
  ],
});
复制代码

总结

最近刚学会一个词 TL; DR,其实就是:

Too long; didn't read.

其实我本身也是常常这样,哈哈。到这里已经有 1 万多字了,我估计应该没几我的会看到这。整个流程走下来我以为是仍是很是天然的,从开发环境到生产环境,从基本的配置到优化控制台显示,准备 gzip 压缩版本这些锦上添花的步骤。写这篇文章其实大部分的时间都花费在了查阅资料上,每个插件我都尽可能描述好它们的做用,若是有值得注意的地方我也会在代码注释中或者文字描述中提出来。我知道可能这篇文章对于一些基础比较差或者没怎么手动配置过 webpack 的同窗压力比较大,极可能看不下去,这是正常的,我之前也是这样,不过我以为你若是可以咬咬牙坚持读完,尽管不少地方看不懂,你老是会从中学到一些对你有用的东西,或者你也能够收藏下来当自字典来查。这篇文章不少配置并非和 react+typescript 强耦合的,你加一个 vue-loader 不就能够正常使用 vue 来开发了吗?更重要的是我但愿一些读者能够从中学到探索精神,可怕不表明不可能,实践探索才能掌握真知。

最后咱们加上咱们的构建脚本 build.ts

// scripts/build.ts
import webpack from 'webpack';

import prodConfig from './configs/webpack.prod';
import { isAnalyze } from './env';

const compiler = webpack(prodConfig);

compiler.run((error, stats) => {
  if (error) {
    console.error(error);
    return;
  }

  const prodStatsOpts = {
    preset: 'normal',
    modules: isAnalyze,
    colors: true,
  };

  console.log(stats.toString(prodStatsOpts));
});
复制代码

effect

我最近一直在忙毕业和找工做的事情,下一篇可能要在一个月后左右了。若是读者对文章中有哪些不理解的地方建议先去看下源代码,还有问题的话能够在 github issues 或者发布平台的评论区向我提问,若是以为本文对你有用,不妨赏颗 star 😁。

下一篇应该会讲述如何集成 ant designlodash 等流行库并对它们的打包进行优化...

本文为原创内容,首发于我的博客,转载请注明出处。

相关文章
相关标签/搜索