1. 这不是“又一个Webpack+React教程”,而是我踩了三年坑后重写的启动手册

你点开这个标题,大概率正卡在“create-react-app太黑盒”和“自己配Webpack又报错一百行”的中间地带。我见过太多人:花两天搭环境,结果被 webpack.config.js 里一个 resolve.alias 配错搞到凌晨三点;在 babel-loader @babel/preset-react 版本冲突里反复横跳;或者打包完打开页面只看到一行红字—— You need to enable JavaScript to run this app. 。这不是你的问题,是官方文档没告诉你:Webpack + React 的启动,本质不是“配置”,而是“理解构建流水线中每个环节的职责边界”。比如 entry 字段,它根本不是“告诉Webpack从哪开始读代码”,而是定义 模块图的根节点集合 babel-loader 也不是简单地“把JSX转成JS”,它是在AST层面做语法树重写,而 @babel/plugin-transform-react-jsx 才是真正决定 <div> 变成 React.createElement('div') 还是 jsx('div') 的关键。这些细节,决定了你后续调试、优化、甚至排查 Uncaught SyntaxError: Unexpected token '<' 这类经典错误时,是花5分钟定位到 public/index.html 路径问题,还是花两小时怀疑自己装错了React。本文不讲“如何安装”,只讲“为什么这样装”;不贴完整配置,只拆解每一个你必须亲手改、且改错就会崩的参数逻辑。适合刚学完React基础、想真正掌控项目构建链路的开发者,也适合那些用着Vite但面试被问到“Webpack和Vite区别”时只能背答案的中级同学——因为真正的区别,不在打包速度,而在 构建模型的根本哲学差异 :Webpack是“依赖图驱动”,Vite是“原生ESM驱动”。现在,我们从 webpack.config.js 第一行开始,重新认识这个被用烂却极少被真正理解的工具。

2. 核心设计思路:为什么必须手写配置,而不是用CRA或Vite

2.1 CRA的“便利性”背后是能力封印

create-react-app (CRA)确实让新手三分钟跑起React应用,但它用 react-scripts 把Webpack、Babel、ESLint全封装进黑盒。你执行 npm start ,实际触发的是 react-scripts start ,它内部调用 webpack-dev-server ,但你永远看不到 webpack.config.js 。这种封装带来三个硬伤:
第一, 调试断点失效 。当你在Chrome DevTools里想在 src/App.js 打个断点,发现源码映射(source map)指向的是 /static/js/main.chunk.js 里的压缩代码,因为CRA默认开启 cheap-module-source-map ,它只映射到loader处理后的代码,而非原始TSX文件。要修复?得 eject ,但 eject 后整个项目配置爆炸式增长,300多行 webpack.config.js 里混着 ModuleScopePlugin CaseSensitivePathsPlugin 等你根本没见过的插件,新手直接劝退。
第二, 自定义Loader链断裂 。比如你想加一个 svg-react-loader 把SVG文件直接转成React组件,CRA要求你用 craco react-app-rewired 去“绕过”它的配置,本质是用另一个黑盒去修改黑盒,出问题时你得同时查 craco.config.js react-scripts 源码、Webpack文档三份资料。
第三, 生产环境不可控 。CRA的 npm run build 会自动注入 HtmlWebpackPlugin 生成 index.html ,但如果你的CDN需要强制添加 integrity 哈希值,或者要动态注入 <script> 标签加载第三方SDK,CRA默认不开放 html-webpack-plugin templateParameters 配置入口。你得 eject ,或者用 customize-cra ,但后者维护者早已停止更新。

提示:CRA不是不好,而是为“快速验证想法”设计的。一旦项目进入中后期,你需要代码分割策略、自定义热更新逻辑、或集成微前端,CRA就成了天花板。

2.2 Vite的“快”是建立在浏览器原生能力之上的妥协

Vite的启动速度确实惊艳, vite dev 秒开,因为它根本不走Webpack那一套“解析→转换→打包→服务”的流水线。它利用现代浏览器原生支持ES Module(ESM),开发时直接以 /src/main.tsx 为入口,通过 import 语句按需请求 .tsx 文件,由Vite的Dev Server实时编译单个文件并返回ESM格式。这省去了Webpack的整个依赖图构建过程。但这种模式有明确边界:

  • 它无法处理非ESM生态的库 。比如你 npm install some-legacy-lib ,这个库只有 main 字段指向 dist/some-lib.umd.js ,Vite的 @rollup/plugin-commonjs 插件会尝试把它转成ESM,但遇到 require('fs') __dirname 这种Node.js API时直接报错。Webpack则通过 node-loader null-loader 优雅降级。
  • HMR(热模块替换)逻辑更脆弱 。Vite的HMR基于文件系统事件,当 App.tsx 修改时,它通知浏览器重载该模块。但如果你在 App.tsx 里用了 useEffect 依赖 window.addEventListener ,Vite的HMR不会自动清理旧监听器,导致内存泄漏。Webpack的 react-refresh 插件则内置了 useEffect 清理钩子。
  • 生产构建仍需Rollup 。Vite生产构建用Rollup,而Rollup的Tree Shaking对CommonJS模块支持有限。Webpack 5的 ModuleConcatenationPlugin 能将多个模块合并为一个函数作用域,减少闭包开销,Rollup做不到这点。

注意:Vite和Webpack不是“谁替代谁”的关系,而是“场景适配”。Vite适合新项目、纯ESM生态、追求极致启动速度;Webpack适合复杂老项目、混合模块生态、需要精细控制构建产物的场景。

2.3 手写Webpack配置的核心价值:构建流水线的完全主权

手写配置的本质,是把构建过程从“魔法”变成“可调试的代码”。当你自己写 webpack.config.js ,你立刻获得三重主权:
第一重:入口控制权 entry 不再是一个字符串,而是对象。你可以定义:

entry: {
  main: './src/index.tsx', // 主应用入口
  vendor: ['react', 'react-dom'], // 提取公共依赖
  admin: './src/admin/index.tsx' // 后台管理独立入口
}

这样Webpack会生成 main.js vendor.js admin.js 三个chunk,配合 SplitChunksPlugin ,你能精确控制哪些模块打进vendor,避免用户每次更新业务代码都得重新下载React。

第二重:Loader链定义权 module.rules 让你看清每个文件类型经过哪些转换:

{
  test: /\.(js|jsx|ts|tsx)$/,
  exclude: /node_modules/,
  use: [
    { loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } },
    { loader: 'ts-loader', options: { transpileOnly: true } }
  ]
}

这里 babel-loader ts-loader 的顺序不能颠倒——TypeScript编译器( tsc )本身不处理JSX,必须先用Babel把JSX转成 React.createElement ,再交给 tsc 做类型检查。如果顺序反了, tsc 会直接报错 JSX element type does not have any construct or call signatures

第三重:插件干预权 plugins 数组是你插入构建生命周期钩子的地方。比如 DefinePlugin 可以全局注入环境变量:

new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify('production')
})

这比在代码里写 if (process.env.NODE_ENV === 'development') 更高效,因为Webpack会在编译时直接把条件分支删掉,而不是运行时判断。

实操心得:我最初手配Webpack时,总想一步到位写全所有插件。后来发现,应该从最简配置开始:只配 entry output loader ,确保能跑通,再逐个加 HtmlWebpackPlugin MiniCssExtractPlugin 。每加一个插件,就跑一次 npx webpack --mode=development ,看终端输出的chunk列表是否符合预期。这种“增量验证”法,让我三个月内把配置错误率从70%降到5%以下。

3. 核心细节解析: webpack.config.js 每一行背后的战争

3.1 entry :不只是起点,而是模块图的根节点声明

entry 字段常被简化为“告诉Webpack从哪开始打包”,这是严重误解。在Webpack的模块图(Module Graph)模型中, entry 定义的是 图的根节点集合 。每个根节点会触发一次深度优先遍历(DFS),收集所有 import require 依赖的模块,最终形成一棵或多棵树。

考虑这个场景:你有两个入口文件 src/app.tsx src/admin.tsx ,它们都 import { Button } from 'antd' 。如果 entry 写成单个字符串:

entry: './src/app.tsx'

那么 admin.tsx 永远不会被构建,即使它存在。而写成对象:

entry: {
  app: './src/app.tsx',
  admin: './src/admin.tsx'
}

Webpack会分别构建两棵树, Button 组件会被打包进两个chunk,造成代码重复。此时你需要 SplitChunksPlugin 来提取公共模块:

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      antd: {
        name: 'vendors-antd',
        test: /[\\/]node_modules[\\/](antd)[\\/]/,
        priority: 20
      }
    }
  }
}

这里 priority: 20 确保 antd 被优先提取,而不是被 default 缓存组捕获。

关键计算: splitChunks.minSize 默认是20000字节(20KB)。如果你的 vendors-antd chunk小于20KB,它不会被提取。所以当项目初期只用了一个 Button 组件时, antd 可能不会被单独抽离。你需要手动设 minSize: 0 来强制提取,或等项目变大后再调整。

3.2 babel-loader :AST重写的精密手术刀

babel-loader 不是简单的“语法转换器”,它是基于抽象语法树(AST)的代码重写引擎。它的核心工作流是:

  1. Parse :用 @babel/parser 将JSX代码解析成AST(如 <div>Hello</div> 变成 JSXElement 节点);
  2. Transform :用 @babel/traverse 遍历AST,根据预设(preset)或插件(plugin)修改节点(如把 JSXElement 转成 CallExpression 调用 React.createElement );
  3. Generate :用 @babel/generator 把修改后的AST转回JS字符串。

这就是为什么 @babel/preset-react 的配置如此关键。默认情况下,它启用 @babel/plugin-transform-react-jsx ,将JSX转为 React.createElement

<div className="app">Hello</div>
// → 
React.createElement("div", { className: "app" }, "Hello");

但React 18引入了新的JSX转换(React Compiler),需要 @babel/preset-react 开启 runtime: 'automatic'

{
  presets: [
    ['@babel/preset-react', { runtime: 'automatic' }]
  ]
}

此时同一段JSX会转成:

import { jsx as _jsx } from 'react/jsx-runtime';
_jsx("div", { className: "app" }, "Hello");

这个变化影响深远: automatic 模式下,你无需在每个JSX文件顶部写 import React from 'react' ,且 jsx-runtime 包做了性能优化,避免了 React.createElement 的多次函数调用开销。

注意事项: babel-loader 必须配合 @babel/core 使用,且版本必须严格匹配。 @babel/core@7.20.0 babel-loader@8.3.0 兼容,但若升级 @babel/core 到7.22.0, babel-loader 必须同步升到8.4.0,否则会报错 Cannot find module '@babel/core' 。我曾因此卡住一整天,最后发现 yarn why @babel/core 显示项目里有两个版本:一个被 babel-loader 依赖,一个被 @babel/preset-env 依赖,版本冲突导致解析失败。

3.3 output :产物路径的绝对权威

output.path output.publicPath 常被混淆。 path 文件系统绝对路径 ,告诉Webpack把打包文件写到磁盘哪个文件夹; publicPath 运行时URL前缀 ,告诉浏览器从哪里加载chunk。

典型错误配置:

output: {
  path: path.resolve(__dirname, 'dist'),
  publicPath: '/dist/' // ❌ 错误!publicPath应为'/'
}

假设你部署到 https://cdn.example.com/my-app/ ,那么 publicPath 必须设为 '/my-app/' ,否则 main.js 的请求地址会是 https://cdn.example.com/dist/main.js (404),而不是正确的 https://cdn.example.com/my-app/main.js

更隐蔽的问题是 output.filename 的哈希策略。新手常写:

filename: '[name].js' // ❌ 每次构建都覆盖同名文件,CDN缓存失效

正确做法是加入内容哈希(contenthash):

filename: '[name].[contenthash:8].js' // ✅ 哈希值随文件内容变化

contenthash hash 更精准: hash 是整个构建的唯一哈希,哪怕只改了一个CSS文件,所有JS文件的哈希都会变; contenthash 只与当前文件内容相关,改CSS不影响JS哈希。

实操技巧: contenthash 在开发环境无意义(因为文件不写入磁盘),所以你应该用 mode 区分:

filename: isProduction ? '[name].[contenthash:8].js' : '[name].js'

其中 isProduction = process.env.NODE_ENV === 'production' 。这样开发时文件名简洁,生产时带哈希。

3.4 resolve :模块解析的隐形指挥官

resolve.alias resolve.extensions 是提升开发体验的两大利器。

alias 用于缩短长路径引用。比如你的项目结构是:

src/
├── components/
│   ├── ui/
│   │   └── Button.tsx
│   └── layout/
│       └── Header.tsx
└── utils/
    └── api.ts

没有 alias 时, Button.tsx 里引用 Header.tsx 要写:

import Header from '../../../layout/Header'; // 路径深,易出错

配置 alias 后:

resolve: {
  alias: {
    '@components': path.resolve(__dirname, 'src/components'),
    '@utils': path.resolve(__dirname, 'src/utils')
  }
}

就可以写:

import Header from '@components/layout/Header'; // 清晰,且IDE能跳转

extensions 则解决“导入时省略后缀”的问题。默认 resolve.extensions ['.js', '.json'] ,所以 import foo from './foo' 会依次尝试 ./foo.js ./foo.json 。但React项目通常用TSX,必须显式添加:

extensions: ['.tsx', '.ts', '.js', '.json']

否则 import App from './App' 会找不到 App.tsx ,报错 Can't resolve './App'

关键细节: resolve.modules 定义模块搜索路径。默认是 ['node_modules'] ,但如果你有本地 src/lib 目录放通用工具函数,可以加进去:

modules: ['node_modules', path.resolve(__dirname, 'src/lib')]

这样 import { helper } from 'utils' 就能直接找到 src/lib/utils/index.ts ,无需写相对路径。

4. 完整实操流程:从零搭建可商用的Webpack+React项目

4.1 初始化项目与基础依赖安装

我们从一个空文件夹开始,不依赖任何脚手架。创建项目结构:

mkdir my-react-app && cd my-react-app
npm init -y

安装核心依赖:

# Webpack核心
npm install --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin

# React相关
npm install react react-dom
npm install --save-dev @types/react @types/react-dom

# Babel转译
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react babel-loader

# TypeScript支持(推荐)
npm install --save-dev typescript ts-loader @babel/preset-typescript

# CSS处理
npm install --save-dev css-loader style-loader mini-css-extract-plugin

注意: --save-dev 表示开发依赖, --save (或 npm install )表示生产依赖。 react react-dom 是运行时必需的,必须进 dependencies ;而 webpack babel-loader 只是构建工具,进 devDependencies

实操记录:我在安装 ts-loader 时遇到 Cannot find module 'typescript' 错误。查 ts-loader 文档发现,它 不自动安装TypeScript ,必须手动 npm install typescript 。这是很多新手踩的第一个坑——以为loader会自带其依赖的编译器。

4.2 编写 webpack.config.js :最小可行配置

创建 webpack.config.js ,先写最简版本,确保能跑通:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    clean: true // 每次构建前清空dist目录
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js']
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ],
  devServer: {
    port: 3000,
    open: true,
    hot: true // 启用HMR
  }
};

关键点解析:

  • clean: true 是Webpack 5新增选项,替代了旧版的 CleanWebpackPlugin ,避免每次构建前手动删 dist
  • devServer.hot: true 启用热模块替换,修改组件后页面局部刷新,而非整页重载;
  • HtmlWebpackPlugin template 指向 public/index.html ,这是标准约定,Webpack会把 bundle.js 自动注入到HTML的 <body> 底部。

此时创建 src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<h1>Hello Webpack + React!</h1>);

public/index.html

<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

运行 npx webpack serve ,浏览器打开 http://localhost:3000 ,看到 Hello Webpack + React! ,说明基础环境已通。

4.3 增量添加功能:Babel支持与React 18新特性

基础环境跑通后,加入Babel以支持JSX和最新JS语法。修改 module.rules

{
  test: /\.(js|jsx|ts|tsx)$/,
  exclude: /node_modules/,
  use: {
    loader: 'babel-loader',
    options: {
      presets: [
        '@babel/preset-env', // 转译ES6+语法
        ['@babel/preset-react', { runtime: 'automatic' }] // React 18 JSX自动运行时
      ]
    }
  }
}

同时, resolve.extensions 需补充 .jsx

extensions: ['.tsx', '.ts', '.jsx', '.js']

现在你可以写JSX文件了。创建 src/App.tsx

export default function App() {
  return <div className="app">Welcome to React 18!</div>;
}

修改 src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<App />);

运行 npx webpack serve ,页面显示 Welcome to React 18!

注意事项: @babel/preset-react runtime: 'automatic' 必须配合React 18+使用。如果你用React 17,必须设 runtime: 'classic' ,否则 <div> 会转成 jsx('div') ,而React 17没有 jsx 函数,直接报错 ReferenceError: jsx is not defined

4.4 生产环境配置:分离CSS与代码分割

开发环境配置好后,生产环境需优化。创建 webpack.prod.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  entry: './src/index.tsx',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
    assetModuleFilename: 'assets/[name].[contenthash:8][ext]', // 图片等资源
    clean: true
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'] // 生产环境用MiniCssExtractPlugin提取CSS
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
      minify: {
        removeComments: true,
        collapseWhitespace: true
      }
    }),
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash:8].css'
    })
  ],
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          name: 'vendors',
          test: /[\\/]node_modules[\\/]/,
          priority: 10,
          chunks: 'initial'
        }
      }
    }
  }
};

关键升级点:

  • MiniCssExtractPlugin 替代 style-loader ,把CSS从JS中抽离为独立 .css 文件,避免 <style> 标签阻塞渲染;
  • optimization.splitChunks 提取 node_modules 中的依赖到 vendors.js ,减小主包体积;
  • HtmlWebpackPlugin.minify 压缩HTML,删除注释和空白符。

运行 npx webpack --config webpack.prod.js dist 目录生成 main.[hash].js vendors.[hash].js main.[hash].css 等文件,大小比开发版小50%以上。

4.5 高级功能:TypeScript类型检查与Source Map调试

TypeScript项目必须做类型检查,否则 ts-loader 只做编译,不报类型错误。在 webpack.config.js 中添加:

module.exports = {
  // ... 其他配置
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: false, // 关键!设为false启用类型检查
            compilerOptions: {
              noEmit: false // 允许输出JS文件
            }
          }
        }
      }
    ]
  }
};

transpileOnly: false 会让 ts-loader 调用 tsc 进行完整类型检查,错误会直接显示在终端。

Source Map调试是开发体验的生命线。Webpack 5默认 mode: 'development' 启用 eval-source-map ,但它的缺点是:源码映射到 eval() 里的代码,Chrome里无法在原始TSX文件打断点。改为 cheap-module-source-map

devtool: 'cheap-module-source-map'

它生成 .map 文件,映射到原始TSX,且不包含列信息(节省体积),完美平衡调试与性能。

实操心得:我曾因 devtool 配置错误,在Chrome里看到断点打在 bundle.js 第1行,而不是 App.tsx 。查文档发现 eval-source-map 在Webpack 5中已被标记为“不推荐”, cheap-module-source-map 才是开发调试的黄金标准。

5. 常见问题与排查技巧实录:那些让我熬夜的Bug

5.1 经典错误:“Uncaught SyntaxError: Unexpected token '<'”

这个错误90%的原因是: Webpack Dev Server的HTML路由回退配置缺失

现象:你在 http://localhost:3000/ 能正常访问,但刷新 http://localhost:3000/dashboard 时,浏览器控制台报 Unexpected token '<' ,Network面板显示 dashboard 返回的是 index.html 的HTML内容(以 < 开头),而不是JS文件。

原因:React Router等前端路由使用 History API ,URL路径是前端控制的。当用户直接访问 /dashboard 时,浏览器向服务器请求 /dashboard 路径,但Webpack Dev Server默认只对 / 返回 index.html ,对 /dashboard 返回404,然后 index.html 被当作JS执行,自然报错 Unexpected token '<'

解决方案:在 webpack.config.js devServer 中添加 historyApiFallback

devServer: {
  historyApiFallback: {
    rewrites: [
      { from: /^\/$/, to: '/index.html' },
      { from: /^\/.*/, to: '/index.html' }
    ]
  }
}

这告诉Dev Server:所有非静态资源请求(如 /dashboard /api/user ),都返回 index.html ,让React Router接管路由。

排查技巧:遇到此错误,第一步打开Network面板,看报错的请求返回的是HTML还是JS。如果是HTML,立刻检查 historyApiFallback ;如果是404,则检查 publicPath 是否配置正确。

5.2 “Module not found: Can't resolve 'react'” —— Node Modules解析失败

这个错误表面是找不到 react ,根源通常是 resolve.modules resolve.alias 配置错误。

常见场景:

  • 你误在 resolve.alias 里写了 'react': path.resolve(__dirname, 'node_modules/react') ,但 path.resolve 拼错了路径,指向不存在的文件夹;
  • 项目根目录下有 node_modules ,但Webpack配置了 resolve.modules: ['src/node_modules'] ,导致它先去 src/node_modules react ,找不到才去根目录,而 src/node_modules 为空。

排查步骤:

  1. 运行 npm ls react ,确认 react 已正确安装在根 node_modules
  2. 检查 webpack.config.js resolve 配置,临时注释掉 alias modules ,用默认值测试;
  3. 如果恢复默认后正常,说明问题在自定义配置,逐个取消注释定位。

独家技巧:Webpack提供 --display-modules 参数,显示所有被解析的模块路径。运行 npx webpack --display-modules ,在输出中搜索 react ,能看到Webpack实际解析到的路径,一目了然。

5.3 HMR不生效:修改组件后页面不刷新

HMR失效有三大原因:
第一,React Refresh插件未启用 。Webpack 5+需额外安装:

npm install --save-dev @pmmmwh/react-refresh-webpack-plugin react-refresh

并在 webpack.config.js 中添加:

const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

module.exports = {
  // ... 其他配置
  plugins: [
    new ReactRefreshWebpackPlugin()
  ],
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: ['react-refresh/babel'] // 关键!Babel插件
          }
        }
      }
    ]
  }
};

第二,入口文件未包裹 hot.accept webpack-dev-server 的HMR需要入口文件主动接受更新:

import('./App').then(({ default: App }) => {
  const root = ReactDOM.createRoot(document.getElementById('root'));
  root.render(<App />);
});

// 启用HMR
if (module.hot) {
  module.hot.accept('./App', () => {
    const NextApp = require('./App').default;
    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<NextApp />);
  });
}

第三,CSS模块未配置HMR 。如果修改CSS不生效,需在 css-loader 后加 style-loader (开发环境):

{
  test: /\.css$/,
  use: ['style-loader', 'css-loader'] // 开发用style-loader,生产用MiniCssExtractPlugin
}

实操记录:我曾因忘记在Babel配置中加 'react-refresh/babel' 插件,HMR一直不生效。查 @pmmmwh/react-refresh-webpack-plugin 文档才发现,它需要Babel插件配合才能注入HMR代码,否则 module.hot.accept 不会被自动注入。

5.4 构建产物体积过大:如何精准定位“罪魁祸首”

webpack-bundle-analyzer 是分析体积的终极武器。安装:

npm install --save-dev webpack-bundle-analyzer

webpack.prod.js 中添加插件:

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

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态HTML报告
      openAnalyzer: false // 不自动打开浏览器
    })
  ]
};

运行 npx webpack --config webpack.prod.js ,会在 dist 目录生成 report.html 。打开它,看到交互式饼图,点击任意扇形,右侧显示该模块的所有依赖。

典型问题案例:

  • Lodash全量引入 import { debounce } from 'lodash' 会引入整个Lodash库(70KB)。应改为 import debounce from 'lodash/debounce' ,或用 babel-plugin-lodash 自动优化;
  • Moment.js日期库 import moment from 'moment' 引入所有语言包(300KB)。改用 date-fns dayjs ,或配置 moment-locales-webpack-plugin 只打包中文;
  • 图片未压缩 import logo from './logo.png' 直接引入原图。应加 url-loader file-loader ,并配置 limit: 8192 (8KB以下转base64,以上生成独立文件)。

排查技巧:在 report.html 中,按“Size”排序,重点关注 node_modules 下的大模块。右键“Open module in editor”,直接跳转到 node_modules 中该模块的入口文件,看它是否导出了不必要的子模块。

5.5 TypeScript类型错误不提示: ts-loader 配置陷阱

ts-loader 默认 transpileOnly: true ,只编译不检查类型,所以 .tsx 文件里的 const a: number = 'hello' 不会报错。

解决方案:

  1. transpileOnly: false ,但会导致构建变慢(因为 tsc 要做完整类型检查);
  2. 更优方案:用 fork-ts-checker-webpack-plugin 在独立进程检查类型,不阻塞构建:
npm install --save-dev fork-ts-checker-webpack-plugin
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

module.exports = {
  plugins: [
    new ForkTsCheckerWebpackPlugin({
      typescript: {
        configFile: path.resolve(__dirname, 'tsconfig.json')
      }
    })
  ]
};

这样 ts-loader 保持 transpileOnly: true 快速编译,类型检查由独立进程完成,错误实时显示在终端。

注意事项: fork-ts-checker-webpack-plugin 需要 tsconfig.json "noEmit": false ,否则它找不到输出文件。我的 tsconfig.json 关键配置:

{
  "compilerOptions": {
    "target": "ES2015",
    "lib": ["DOM", "ES2015"],
    "module": "ESNext",
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noEmit": false, // 必须为false
    "esModuleInterop": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  }
}

更多推荐