從零開始配置 react + typescript(三):webpack

本篇爲 從零開始配置 react + typescript 系列第三篇,將帶你們完成模板項目的 webpack 配置。整個項目的配置我力求達到如下目標:javascript

靈活: 我在配置 eslint 是選擇使用 js 格式而不是 json,就是爲了靈活性,使用 js 文件可讓你使用環境變量動態配置,充分發揮 js 語言的能力。固然了,用 js 做配置文件也是有缺點的,不能使用 json schema 校驗。css

新潮: 我以爲時刻保持對新事物的關注和嘗試去使用它是一個優秀的素質。固然,追新很容易碰到坑,可是,不要緊,我已經幫大家踩過了,踩不過去我也不會寫出來 😂。從我 eslint parserOptions.ecmaVersion 設置爲 2020, 還有常常來一發 yarn upgrade --latest 均可以體現出來。html

嚴格: 就像我平時判斷相等性我大多數狀況都是使用嚴格等 ===,而不是非嚴格等 ==,我以爲越嚴格,分析起來就越清晰,越早能發現問題。例如我麼後面會使用一些 webpack 插件來嚴格檢查模塊大小寫,檢查是否有循環依賴。前端

安逸: 項目中會盡可能集成當前前端生態界實用的和能提升開發愉悅性的(換個詞就是花裏胡哨)工具。vue

生產 ready:配置的時候針對不一樣的打包環境針對性優化,並確保可以投入生產環境使用。html5

若是讀者是初次看到這篇文章,建議先看下前兩篇:java

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

項目地址:react-typescript-boilerplatenode

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 改太累了,一個項目的構建配置有優化點,其它項目都要手動同步一下,效率過低。python

技術選型

TypeScript 做爲靜態類型語言,相對於 js 而言,在類型提示上帶來的提高無疑是巨大的。藉助 IDE 的類型提示和代碼補全,咱們須要知道 webpack 配置對象有哪些字段就不用去查官方文檔了,並且還不會敲錯,很安逸,因此開發語言就選擇 TypeScriptreact

官方文檔上有專門一節 Configuration Languages 介紹怎麼使用 ts 格式的配置文件配置 webpack 命令行工具,我以爲 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 ..
複製代碼

咱們暫時調整成這樣:

// src/tsconfig.json
{
    "compilerOptions": {
        /* Basic Options */
        "jsx": "react",
        "isolatedModules": true,

        /* Strict Type-Checking Options */
        "strict": true,
        "noImplicitAny": true,
        "strictNullChecks": true,
        "strictFunctionTypes": true,
        "strictBindCallApply": true,
        "strictPropertyInitialization": true,
        "noImplicitThis": true,
        "alwaysStrict": true,

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

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

        /* Advanced Options */
        "forceConsistentCasingInFileNames": true,
        "skipLibCheck": true
    }
}
複製代碼

咱們將使用 babel 去編譯 TypeScript,babel 在編譯 TypeScript 代碼是直接去掉 TypeScript 的類型,而後當成普通的 javascript 代碼使用各類插件進行編譯,所以 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": "ES2020",
        "module": "commonjs",

        /* Strict Type-Checking Options */
        "strict": true,
        "noImplicitAny": true,
        "strictNullChecks": true,
        "strictFunctionTypes": true,
        "strictBindCallApply": true,
        "strictPropertyInitialization": true,
        "noImplicitThis": true,
        "alwaysStrict": true,

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

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

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

複製代碼

提幾個須要注意的地方:

  • "target": "ES2020",其實編譯級別你調的很低是沒問題的,你用高級語法 tsc 就轉碼唄,缺點就是轉碼後代碼體積會變大,執行效率也會下降,原生語法通常都是被優化過的。我喜歡調高一點,通常來講只要不用那些在代碼運行平臺還不支持的語法就沒問題。自從 TypeScript3.7 支持了可選鏈,我就開始嘗試在 TypeScript 使用它,可是問題來了,我以前編譯級別一直都是調成最高,也就是 ESNext,由於可選鏈在 ESNext 已是標準了,因此 tsc 對於可選鏈不會轉碼的。而後 node 12 還不支持可選鏈,就會報語法錯誤,因而我就降到 ES2020 了。

  • Strict Type-Checking Options,這部分全開,既然上了 TypeScript 的船,就用最嚴格的類型檢查,拒絕 AnyScript

  • 刪掉 Additional Checks 部份的配置,這部分 eslint 能作的更多,更好

接着咱們新建 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 的編譯事件。 你哪一個 chunck 須要實現熱更新,就要在那個 chunk 中導入這個插件提供的 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.im/post/5e2027096fb9a02fe971f6b8
    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
    process.on('SIGINT', () => {
        // 先關閉 devServer
        httpServer.close();
        // 在 ctrl + c 的時候隨機輸出 'See you again' 和 'Goodbye'
        console.log(
            chalk.greenBright.bold(`\n${Math.random() > 0.5 ? 'See you again' : 'Goodbye'}!`),
        );
    });
}

// 寫過 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 項目下的庫,背靠 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',
    }),
  ],
};
複製代碼

添加版權聲明

這個直接用 webpack 內置的 BannerPlugin 便可:

import { BannerPlugin, Configuration } from 'webpack';

const commonConfig: Configuration = {
  plugins: [
    new BannerPlugin({
      raw: true,
      banner: `/** @preserve Powered by react-typescript-boilerplate (https://github.com/tjx666/react-typescript-boilerplate) */`,
    }),
  ],
};
複製代碼

copyright

須要注意的是咱們在版權聲明的註釋中加了 @preserve 標記,咱們後面會使用 terser 在生產環境構建時壓縮代碼,壓縮代碼時會去掉全部註釋,除了一些包含特殊標記的註釋,例如咱們添加的 @preserve

優化控制檯輸出

咱們使用 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

循環依賴檢查

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

清理上次打包的 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 倍多...

牛逼歸牛逼,可是在個人實際使用時發現它會偶爾會出 bug ,不過幾率不是很高,原本有 bug 倒沒什麼,只要做者還在維護就行,可是這個插件的做者貌似維護不是很積極了,最後一次提交代碼是 18 年 12 月份,也就是說一年多沒維護了。

不過呢,出 bug 通常把緩存刪掉就能解決了,雖然有點小毛病,咱們這個項目仍是配一下,禁不住太香了 😆:

yarn add hard-source-webpack-plugin @types/hard-source-webpack-plugin -D
複製代碼
import HardSourceWebpackPlugin from 'hard-source-webpack-plugin';

const commonConfig: Configuration = {
  plugins: [new HardSourceWebpackPlugin({ info: { mode: 'none', level: 'warn' } })],
};
複製代碼

好了,插件部分介紹完了,接下來開始配置 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(), new NamedModulesPlugin()],
});
複製代碼

NamedModulesPlugin 這個插件能夠在 rebuild 的時候顯示哪些模塊被修改了。

還要添加 '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

生產環境優化

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 等流行庫並對它們的打包進行優化...

本文爲原創內容,首發於我的博客,轉載請註明出處。

相關文章
相關標籤/搜索