起因

为什么想要写这篇呢?

之前写的工作室招新平台,在构建的时候,总是会弹出来 Bundle 大小过大的警告,于是看了一眼 Bundle 里面到底有些啥

image.png

非常的离谱,尤其是 @ant-design/icons, 我只是引用了几个图标,却把整个库都引了进来, 我也不懂为什么 Webpack 的 tree shaking 没有起作用。这个项目我用的是 umi, 在看了文档进行一番操作之后并没有成功,所以我想脱离 umi 来看看,到底是 umi 的问题,还是 @ant-design/icons 的问题,还是我太菜 的问题。

新建项目

当你需要新建一个 React 项目的时候,第一反应是什么? create-react-app, create-next-app 还是 yarn create @umijs/umi-app? 我们似乎已经习惯用这些一键脚手架来配置项目的babelwebpack? 但是这些工具是怎么工作的, babelwebpack 又是怎么配置的,今天让我们从0开始探索一遍。

mkdir myapp && cd myapp
yarn init

添加依赖

# Babel
yarn add -D @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript \
            @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread
# Webpack
yarn add -D webpack webpack-cli webpack-dev-server \ 
            fork-ts-checker-webpack-plugin html-webpack-plugin babel-loader
# Typescript
yarn add -D typescript @types/node
# React
yarn add react react-dom react-router
yarn add -D @types/react @types/react-dom @types/react-router

简单介绍一下这里的依赖都是干啥的

  • babel: JS 语法编译器,这里是将 ts 和 jsx 编译到浏览器能用commonjs (其实不用babel, 只用typescript的编译器也可以,但是限制比较多,而且 babel 的插件多)

  • Webpack: JS 打包器,主要是将各个模块打包到一个文件里,精简体积,减少文件数量,适合前端使用

    • webpack
    • webpack-cli
    • webpack-dev-server : webpack 的开发服务器,可以实现在上面实现 HMR
    • fork-ts-checker-webpack-plugin : webpack 的 ts 类型检查插件
    • html-webpack-plugin : 创建 HTML 文件
    • babel-loader : Webpack 的 Babel Loader, 将 Babel 编译完的文件交给webpack打包

.babelrc

{
  "presets": [
    "@babel/env",
    "@babel/react",
    "@babel/typescript"
  ],
  "plugins": [
    "@babel/proposal-class-properties",
    "@babel/proposal-object-rest-spread"
  ]
}

webpack.config.js

const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
const SRC_DIR = path.resolve(__dirname, 'src')

module.exports = {
    entry: './src/index.tsx',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    resolve: {
        extensions: ['.js', '.jsx', '.ts', '.tsx', '.json']
    },
    module: {
        rules: [
            {
                test: /\.(ts|js)x?$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({inject: true, template: path.join(SRC_DIR, 'index.html')}),
        new ForkTsCheckerWebpackPlugin()
    ]
}

tsconfig.json

tsc --init
# 然后修改
- "jsx": "preserve"
+ "jsx": "react"

src/index.html

HTML模板

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Hello Webpack</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

src/index.tsx

import React from "react";
import ReactDOM from 'react-dom';

ReactDOM.render(
  <h1>Hello, World</h1>,
  document.getElementById('root')
);

编译和查看

webpack
# yarn global add serve
serve -s ./dist
# 然后打开浏览器 http://localhost:5000

配置优化

Dev Server

Dev Server 来自我们前面安装的 webpack-dev-server,正如你所见,前面用的先编译,再通过 serve 提供 http 访问的方式过于繁琐,而 webpack-dev-server 不仅提供了一键编译运行,还提供了热更新等功能。

打开 webpack.config.js,添加

{
...,
    devServer: {
        contentBase: path.join(__dirname, 'dist'),
        compress: true,
        port: 3000
    }
}

然后 webpack serve,打开 devServer

同时,还可以向 package.json 里添加 script

{
...,
    "scripts": {
    "serve": "webpack serve",
    "build": "webpack"
    },
}

yarn serve & yarn build

Source Map

Source Map 能够让我们在浏览器更方便的调试,在 webpack.config.js 中添加

{
...,
    devtool: process.env.NODE_ENV === "development" ? "source-map" : false
}

然后再 NODE_ENV=development yarn serve, 打开页面,按F12, 选择 Source 中的 bundle.js, chrome会提示 Source Map Detect, 然后按Ctrl + P 即可选择自己需要的文件查看。

Bundle Analyzer

当我们使用 webpack serve 的时候, Webpack 会亲切的提示我们

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
  bundle.js (281 KiB)

WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit
 (244 KiB). This can impact web performance.
Entrypoints:
  main (281 KiB)
      bundle.js

但是,我们在上面不过是写了 <h1>Hello, World</h1> , 为什么 Bundle 的大小会大了这么多呢,这个时候我们就需要使用 webpack-bundle-analyzer 来分析 Bundle 里究竟打包了些什么玩意。

yarn add webpack-bundle-analyzer

然后在 webpack.config.js 里加入

...
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

{
...
    plugins: [
        ...,
        new BundleAnalyzerPlugin({
            analyzerMode: process.env.ANALYZER === '1' ? 'server' : 'disabled'
        })
    ]
}

接着 ANALYZER=1 webpack,然后就弹出来 Bundle 大小分析页面

image.png

可以看到绝大部分都来自 react 的包,那么我们如何精简这个 Bundle 呢? 那就要用到

Externals

从 React 的教程上我们可以看到, React 可以直接从 <script></script> 里面引入,那么我们如果直接从 <script></script> 去引入 React, 是不是就可以精简 Bundle 的大小了呢?

这个时候我们就可以用到,它可以让某些包在打包的时候被排除出去,比如我们这里提到的 React

webpack.config.js 里面添加

{
    ...,
    externals: {
        react: 'window.React',
        'react-dom': 'window.ReactDOM'
    }
}

这样就够了吗? 不, 如果你直接编译查看,浏览器会爆

Uncaught TypeError: Cannot read property 'render' of undefined
    at Module../src/index.tsx (index.tsx:4)
    at __webpack_require__ (bootstrap:21)
    at startup:4
    at startup:6

为什么会这样呢,如你所见,上面externals是指,打包时不引入 reactreact-dom, 而是从 window.Reactwindow.ReactDOM 中引入,从编译出来的代码上来看,就是

module.exports = window.Reat;
module.exports = window.ReactDOM;

但是我们并没有引入 <script></script> 来定义这两个全局变量, 那如何引入呢?

最简单的方式当然修改src/index.html,在其上添加,但是我觉得这样并不优雅,我想要在 webpack.config.js 里解决这些问题,那应该怎么做呢,不知道你是否还记得,我们前面提到的一个插件

html-webpack-plugin

这个插件就是用来根据模板修改html页面的,在使用它之前,我们先把 index.ejs copy 下来,并放到 src/index.ejs 中,ejs 是一个嵌入式 JavaScript 模板引擎,某种意义上来说和 jsx 有点像,这里我们主要用它来做模板。

修改 webpack.config.js

{
    ...,
    plugins: [
        ...,
        new HtmlWebpackPlugin({
            inject: true,
            template: path.join(SRC_DIR, 'index.ejs'),
            scripts: [
                process.env.NODE_ENV === 'development' ?
                    'https://cdn.jsdelivr.net/npm/[email protected]/umd/react.development.js' :
                    'https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js',
                process.env.NODE_ENV === 'development' ?
                    'https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.development.js' :
                    'https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js'
            ],
            lang: 'zh_CN',
            appMountIds: ['root']
        }),
    ]
}

经过上面这些配置,现在我们再看看 bundle.js

image.png

我们成功的将 bundle.js 缩小到不到 1kb!

CSS

上面我们演示的是一个非常简单的程序,页面的代码只有一个 <h1>Hello, World</h1> , 在实际应用中我们肯定还会用到css,我们如何打包css呢?

yarn add -D style-loader css-loader
  • style-loader: 读取css并且插入到<head></head>
  • css-loader: 对css中的@import()url()解析

需要注意的是,如果你需要在tsximport xxx.css,你需要创建src/react-app-env.d.ts,并写入

declare module '*.css';

然后我们修改 webpack.config.js

{
    ...,
    module: {
        rules: [
            ...,
            {
                test: /\.css$/,
                use: [
                    {loader: "style-loader"},
                    {
                        loader: "css-loader",
                        options: {importLoaders: 1}
                    }
                ]
            }
        ]
    }
}

index.css

.container {
    display: block;
    margin-top: 100px;
    text-align: center;
    background-color: cadetblue;
    color: black;
    font-size: 48px;
}

index.tsx

import React from "react";
import ReactDOM from 'react-dom';
import './index.css';

ReactDOM.render(
  <div className={'container'}>
    <p>Hello, World</p>
  </div>,
  document.getElementById('root')
);

编译后可以看到css生效了。


上面的配置是将css写进·bundle.js,但是习惯上我们会将cssjs分开来,如何将css单独打包出来呢

yarn add -D mini-css-extract-plugin

然后修改webpack.config.js

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

module.exports = {
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    // 要把上面添加的style-loader删了
                    {loader: MiniCssExtractPlugin.loader},
                    {
                        loader: "css-loader",
                        options: {importLoaders: 1}
                    }
                ]
            }
        ]
    },
    plugins: [
        ...,
        new MiniCssExtractPlugin()
    ]
}

编译运行,可以看到css被打包进了了dist/main.css

Public Folder

我们的网页可能需要一些静态文件,比如favicon,如何让webpack在打包的时候复制静态文件到/dist呢?

yarn add -D copy-webpack-plugin

然后修改webpack.config.js

module.exports = {
    ...,
    plugins: [
        new CopyPlugin({
            patterns: [{from: './public',}]
        }),
        ...
    ]
}

Hash

网页比起App有个好处,它没有版本更新的概念,可以让用户使用到最新的版本。

但是有个问题,大多数网站都会使用CDN,而CDN有缓存,我们的bundle.js很可能会被CDN缓存,让用户无法获取最新的版本,手动清除缓存或者不缓存bundle.js都不是最优解决方案,那怎样解决最好呢?

webpack.config.js

module.exports = {
    output: {
        filename: '[name].[contenthash].js'
    }
}

关于这里的明明规则,请看 Template strings

Tree shaking

本来是想提一下的,但是发现 Webpack 5Three shaking 似乎比 4 要好的多,就先🕊了吧。

Chunks Split

前面我们所配置的webpack,都是将代码打包到一个文件里,但是实际网页中,单文件形式的包往往过大,非常影响体验,所以我们要对生成的文件进行分割,将不在首页加载的包分离出来,提高首屏加载时间。

  optimization: {
    splitChunks: {
      cacheGroups: {
        react: {
          name: 'react',
          chunks: "all",
          test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom|moment|antd|@ant-design)[\\/]/,
          priority: 12
        },
        utils: {
          name: 'utils',
          chunks: "all",
          test: /[\\/]node_modules[\\/](lodash|ramda|refractor|axios)[\\/]/,
          priority: 11
        },
        charts: {
          name: 'charts',
          chunks: "all",
          test: /[\\/]node_modules[\\/]recharts[\\/]/,
          priority: 11
        },
        vendor: {
          name: 'vendor',
          chunks: "all",
          test: /[\\/]node_modules[\\/]/,
          priority: 10
        }
      }
    }
  }

Dynamic Import

import React from 'react';
const ComponentA = React.lazy(() => import("./ComponentA"));
...
import React, {Suspense} from 'react';
import {BrowserRouter, Route, Switch} from 'react-router-dom';
import ComponentA from 'components';

const App = () => {
    return (
        <BrowserRouter>
            <Switch>
                <Suspense fallback={<h1>Loading...</h1>}>
                    <Route path='/a' component={<ComponentA />} />
                </Suspense>
            </Switch>
        </BrowserRouter>
    )
}

Server side render

SSR 我感觉可以单独拎出来再水一篇博客, 就下次再说吧。