实践:基于Webpack5搭建React+TS开发环境

1,202 阅读5分钟

💡前言

最近学习了 Webpack 5 之后,想自己搭建个项目练练手,于是就搭建了一个基于 Webpack 5 的 React 的脚手架。

脚手架配置了相关模块,集成了常用功能,便于自己以后 React 新项目的搭建,开箱即用!

仓库地址:「Github」

🔌模块/功能

  • 框架React
  • 路由react-router-dom
  • Typescript
  • 状态管理库redux
  • 样式预处理lesssass
  • 代码检测eslint
  • git commit前规范检测commitlint
  • 时间库dayjs
  • UI库antd,配置了样式按需引入自定义主题
  • react hooksahooks

💾目录结构

项目的整体目录结构如下所示,其中为了测试可用性,添加了一些简单的组件和页面,可自行更改。

│  .babelrc  // Babel配置
│  .commitlintrc.js  // commitlint配置
│  .eslintrc.js  // eslint配置
│  .gitignore  // git忽略文件列表
│  package.json
│  README.md
│  tsconfig.json  // typescript配置
│  yarn.lock
│
├─public  // 全局文件
│  │  index.html  // 模板
│  │
│  └─assets  // 不需要动态导入的资源
│          index.css
│          index.jpg
│          index.js
│
├─scripts  // 脚本
│  │  antd-theme.js  // antd自定义主题配置
│  │  constant.js  // webpack相关的常量
│  │  env.js  // 环境变量
│  │
│  └─config
│          webpack.common.js  // 开发环境+生产环境的公共配置
│          webpack.dev.js  // 开发环境webpack配置
│          webpack.prod.js  // 生产环境webpack配置
│
└─src
    │  App.scss
    │  App.tsx
    │  index.tsx  // 入口文件
    │
    ├─components  // 组件
    │  └─ErrorBoundary  // 错误边界
    │          index.tsx
    │
    ├─pages  // 页面(写了一些页面测试)
    │  ├─Admin
    │  │      index.tsx
    │  │
    │  └─Home
    │          index.tsx
    │
    ├─redux  // redux相关
    │  │  actions.ts
    │  │  constant.ts
    │  │  interface.ts
    │  │  store.ts
    │  │
    │  └─reducers
    │          count.ts
    │          index.ts
    │
    └─types  // 模块声明
            asset.d.ts
            style.d.ts

✂️主要配置文件

package.json

主要看scripts下的内容。还配置了git husky,用于在提交commit前自动检测commit规范性。

{
  "name": "my-react",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "cross-env NODE_ENV=development webpack-dev-server --config ./scripts/config/webpack.dev.js",
    "build": "cross-env NODE_ENV=production webpack --config ./scripts/config/webpack.prod.js"
  },
  "dependencies": {
  	// ...
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "ie >= 9",
    "not op_mini all"
  ],
  "husky": {
    "hooks": {
      "commit-msg": "commitlint --config .commitlintrc.js -e"
    }
  }
}

env.js

导出环境变量。

const isDevelopment = process.env.NODE_ENV === 'development';
const isProduction = process.env.NODE_ENV === 'production';

module.exports = {
  isDevelopment,
  isProduction,
};

constant.js

导出根路径、HOST、POST。

const path = require('path');

const ROOT_PATH = path.resolve(__dirname, '../');

const SERVER_HOST = 'localhost';
const SERVER_PORT = 8080;

module.exports = {
  ROOT_PATH,
  SERVER_HOST,
  SERVER_PORT,
};

webpack.common.js

const path = require('path');
const WebpackBar = require('webpackbar');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin');

const { ROOT_PATH } = require('../constant');
const { isDevelopment, isProduction } = require('../env');
const { myAntd } = require('../antd-theme');

const getCssLoaders = () => {
  const cssLoaders = [
    // 开发模式使用style-loader,生产模式MiniCssExtractPlugin.loader
    isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader,
    {
      loader: 'css-loader',
      options: {
        modules: {
          // 模块化类名,防止重复
          localIdentName: '[local]--[hash:base64:5]',
        },
        sourceMap: isDevelopment,
      },
    },
  ];

  // 加css前缀的loader配置
  const postcssLoader = {
    loader: 'postcss-loader',
    options: {
      postcssOptions: {
        plugins: [
          isProduction && [
            'postcss-preset-env',
            {
              autoprefixer: {
                grid: true,
              },
            },
          ],
        ],
      },
    },
  };

  // 生产模式时,才需要加css前缀
  isProduction && cssLoaders.push(postcssLoader);

  return cssLoaders;
};

const getAntdLessLoaders = () => [
  isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader,
  {
    loader: 'css-loader',
    options: {
      sourceMap: isDevelopment,
    },
  },
  {
    loader: 'less-loader',
    options: {
      sourceMap: isDevelopment,
      lessOptions: {
        // antd 自定义主题
        modifyVars: myAntd,
        javascriptEnabled: true,
      },
    },
  },
];

module.exports = {
  entry: {
    index: path.resolve(ROOT_PATH, './src/index'),
  },

  plugins: [
    // html模板
    new HtmlWebpackPlugin({
      template: path.resolve(ROOT_PATH, './public/index.html'),
      filename: 'index.html',
      inject: 'body',
    }),
    // 打包显示进度条
    new WebpackBar(),
    // webpack打包不会有类型检查,强制ts类型检查
    new ForkTsCheckerWebpackPlugin({
      typescript: {
        configFile: path.resolve(ROOT_PATH, './tsconfig.json'),
      },
    }),
    // 复制不用动态导入的资源
    new CopyWebpackPlugin({
      patterns: [
        {
          context: 'public',
          from: 'assets/*',
          to: path.resolve(ROOT_PATH, './build'),
          toType: 'dir',
          globOptions: {
            dot: true,
            gitignore: true,
            ignore: ['**/index.html'], // **表示任意目录下
          },
        },
      ],
    }),
    // 自动删除上一次打包的产物
    new CleanWebpackPlugin(),
    // 将antd中的moment.js替换为day.js
    new AntdDayjsWebpackPlugin(),
  ],

  module: {
    rules: [
      {
        test: /\.css$/,
        exclude: /node_modules/,
        use: getCssLoaders(),
      },
      {
        test: /\.less$/,
        exclude: /node_modules/,
        use: [
          ...getCssLoaders(),
          {
            loader: 'less-loader',
            options: {
              sourceMap: isDevelopment,
            },
          },
        ],
      },
      {
        test: /\.less$/,
        exclude: /src/,
        use: getAntdLessLoaders(),
      },
      {
        test: /\.scss$/,
        exclude: /node_modules/,
        use: [
          ...getCssLoaders(),
          {
            loader: 'sass-loader',
            options: {
              sourceMap: isDevelopment,
            },
          },
        ],
      },
      {
        test: /\.(tsx?|js)$/, // ts\tsx\js
        loader: 'babel-loader',
        options: { cacheDirectory: true }, // 缓存公共文件
        exclude: /node_modules/,
      },
      {
        test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
        // 自动选择导出为单独文件还是url形式
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024,
          },
        },
      },
      {
        test: /\.(eot|svg|ttf|woff|woff2?)$/,
        // 分割为单独文件,并导出url
        type: 'asset/resource',
      },
    ],
  },

  // 路径配置别名
  resolve: {
    alias: {
      '@': path.resolve(ROOT_PATH, './src'),
    },
    // 若没有写后缀时,依次从数组中查找相应后缀文件是否存在
    extensions: ['.tsx', '.ts', '.js', '.json'],
  },

  // 缓存
  cache: {
    // 基于文件系统的持久化缓存
    type: 'filesystem',
    buildDependencies: {
      // 当配置文件发生变化时,缓存失效
      config: [__filename],
    },
  },
};

webpack.dev.js

const path = require('path');
const { merge } = require('webpack-merge');
const webpack = require('webpack');

const common = require('./webpack.common');
const { ROOT_PATH, SERVER_HOST, SERVER_PORT } = require('../constant');

module.exports = merge(common, {
  target: 'web', // 解决热更新失效
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',
  output: {
    path: path.resolve(ROOT_PATH, './build'),
    filename: 'js/[name].js',
  },
  devServer: {
    host: SERVER_HOST,
    port: SERVER_PORT,
    compress: true, // gzip压缩
    open: true, // 自动打开默认浏览器
    hot: true, // 启用服务热替换配置
    client: {
      logging: 'warn', // warn以上的信息,才会打印
      overlay: true, // 当出现编译错误或警告时,在浏览器中显示全屏覆盖
    },
    // 解决路由跳转404问题
    historyApiFallback: true,
  },
  plugins: [
    // 引入热替换
    new webpack.HotModuleReplacementPlugin(),
  ],

  optimization: {
    minimize: false,
    minimizer: [],
    // 代码分割
    splitChunks: {
      chunks: 'all',
      minSize: 0,
    },
  },
});

webpack.prod.js

const path = require('path');
const { merge } = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

const common = require('./webpack.common');
const { ROOT_PATH } = require('../constant');

module.exports = merge(common, {
  target: 'browserslist',
  mode: 'production',
  devtool: false,
  output: {
    path: path.resolve(ROOT_PATH, './build'),
    filename: 'js/[name].[contenthash:8].js',
    // 资源
    assetModuleFilename: 'assets/[name].[contenthash:8].[ext]',
  },
  plugins: [
    // 生产模式使用了MiniCssExtractPlugin.loader,则需要使用MiniCssExtractPlugin
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].chunk.css',
    }),
    // 查看打包体积大小,启用一个本地服务器
    new BundleAnalyzerPlugin(),
  ],

  // 专门存放优化打包的配置
  optimization: {
    minimize: true,
    minimizer: [
      new CssMinimizerPlugin(),
      // JS压缩
      new TerserPlugin({
        extractComments: false, // 去除所有注释
        terserOptions: {
          compress: { pure_funcs: ['console.log'] }, // 去除所有console.log函数
        },
      }),
    ],
    // 代码分割
    splitChunks: {
      chunks: 'all',
      minSize: 0,
    },
  },
});

📝遇到的问题

BrowserRouter开发环境404问题

webpack.dev.js添加一项,任何请求都会返回index.html文件,解决单页面应用的路由跳转问题。

devServer: {
  // ...
  historyApiFallback: true,
}

安装 node-sass 失败

先全局安装node-gyp

npm install -g node-gyp

再到项目根目录下,yarn继续安装即可。

antd 样式按需加载

安装babel-plugin-import,在.babelrc文件的plugins下,添加一项:

{
  "plugins": [
    ["import", {
      "libraryName": "antd",
      "libraryDirectory": "es",
      "style": true  // `style: true` 会加载 less 文件
    }]
  ]
}

正常使用即可,无需再引入样式:

import React from 'react';
import { Button } from 'antd';
import { useTitle } from 'ahooks';

const Admin: React.FC = () => {
  useTitle('Admin');
  return <Button type='primary'>按钮</Button>;
};

export default Admin;

css-module 与 antd 样式冲突

css-loader配置了模块化引入时,如下所示:

// ...
{
  loader: 'css-loader',
  options: {
    modules: {
      // 模块化类名,防止重复
      localIdentName: '[local]--[hash:base64:5]',
    },
    sourceMap: isDevelopment,
  },
}
// ...

发现 antd 的样式不显示了。原因是模块化也应用于node_modules中的文件,把 antd 中引入的样式也作了模块化,但是引入的组件还是正常的类名,所以显示不出。

解决办法是,将自己写的业务代码第三方库的代码配置分开,因为之前 antd 按需加载配置时,配置了"style": true,加载less,所以要单独配置下less,只在业务代码中开启module

module.exports = {
  // ...
  
  module: {
    rules: [
      {
        test: /\.less$/,
        exclude: /node_modules/, // 排除第三方库代码
        use: [
          ...getCssLoaders(), // 正常配置
          {
            loader: 'less-loader',
            options: {
              sourceMap: isDevelopment,
            },
          },
        ],
      },
      {
        test: /\.less$/,
        exclude: /src/, // 排除业务代码
        use: getAntdLessLoaders(), // 不开启module
      },
      // ...
    ],
  },
  
  // ...
};

antd 自定义主题

处理less。注意排除业务代码,不开启module

// antd自定义主题配置

const myAntd = {
  'primary-color': '#1DA57A',
  'link-color': '#1DA57A',
  'border-radius-base': '8px',
};

module.exports = {
  myAntd,
};
const { myAntd } = require('../antd-theme');

//...
const getAntdLessLoaders = () => [
  isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader,
  {
    loader: 'css-loader',
    options: {
      sourceMap: isDevelopment,
    },
  },
  {
    loader: 'less-loader',
    options: {
      sourceMap: isDevelopment,
      lessOptions: {
        // antd 自定义主题
        modifyVars: myAntd,
        javascriptEnabled: true,
      },
    },
  },
];
//...

{
  test: /\.less$/,
  exclude: /src/,
  use: getAntdLessLoaders(),
}
    
//...

本文记录自己所学,若有不妥,欢迎批评指出~