TypeScript写熟了以后,最憋屈的事是什么?是每次改一行代码都要手动跑一遍编译,然后刷新页面,然后祈祷状态别丢。这期我一次性解决这个问题:用webpack5给TS项目搭上热更新能力,改完代码让浏览器自己跟着变。先说清楚这期适合谁——已经会用tsc编译TS文件,但想进入自动构建流程的人;受够了npm run build然后手动刷新的人;以及想在浏览器里跑TS又不愿意碰脚手架黑盒的人。看完你就能从空白目录起步,搭出一个支持热更新的TS开发环境,每一步我都解释为什么这么配。
1. 项目设计与整体方案拆解:先把需求理清楚
1.1 TS项目为什么需要这层构建
我见过不少朋友写完TS文件,直接扔给tsc编译成JS,然后再手动用浏览器打开。这个方法不是不能用,但项目一旦超过五个文件,痛点就藏不住了:你没法拆模块,因为浏览器原生不支持TS;你没法区分开发和生产,因为编译出来是一坨平铺的JS;你更没法享受到热更新这种开发体验,只能手动刷新,然后看着页面状态清零重来。
webpack要解决的就是这三件事。它把每个TS文件当作模块,通过依赖关系图将所有模块打包成浏览器能理解的形态,同时提供开发服务器和热模块替换机制。这里有个容易误解的地方:webpack本身并不认识TypeScript,它只认JS和JSON。要让TS文件能进webpack的解析流程,必须先用loader把TS转成JS。这个"转换"步骤就是整个配置的核心。
另外要澄清一个概念:热更新不等于自动刷新。自动刷新是页面整体重载,变量、状态全丢。热更新是代码模块的原地替换,你改了某个函数的实现,浏览器里这个函数的旧版本被换掉,但页面不会跳,状态尽量保留。这个区别决定了后面配置时的一个关键选择。
1.2 方案对比:tsc、webpack5、vite各自的位置
在动手配webpack5前,我先横向扫了一遍市面上几个主流方案,不是想说服你非得用webpack,而是让你知道自己选的是什么路径。
| 方案 | 解决什么 | 主要成本 | 适合场景 |
|---|---|---|---|
| tsc --watch | 监听TS文件变化并重新编译 | 不处理模块打包,浏览器还得自己引 | 纯库开发、小工具脚本 |
| webpack5 | 模块打包 + 开发服务器 + 热更新 | 配置项多,概念多,上手有门槛 | 中大型前端项目,需要精细控制 |
| vite | 启动快,基于原生ES模块做预构建 | 生态适配有依赖,TS支持依赖esbuild | 新项目、追求开发体验 |
我的结论是:如果你在做的是一个会持续迭代的浏览器项目,webpack5虽然配置起来比vite啰嗦,但它胜在概念透明、生态成熟、问题容易排查。尤其在TS环境下,webpack的loader机制让"编译、打包、热更新"这件事的每一个环节都可控。你踩过一个配置坑,下一次自己就能修;而用脚手架或vite,出错时面对的是一个抽象层,排查路径长得多。这期系列的目的是"从0开始"把原理吃透,所以我选了webpack5这条更原始、更能看清结构的路线。
在方案层面还有一个前置决定:用webpack5而不是webpack4。webpack5内置了持久化缓存和更精细的模块联邦能力,开发构建速度相比4有明显提升。对于TS项目来说,最容易感知到的变化是配置里不再需要某些为webpack4准备的插件,整体精简不少。
2. 依赖选型:loader、devServer与版本怎么定
2.1 ts-loader与babel-loader之争,我的选择
TS转JS这一步,社区里有两条主流路线,我先把它们的差异摊开。
第一条是用ts-loader。它直接调用TypeScript编译器(tsc),把TS转成JS,并且能做类型检查。优点是"一条龙":tsconfig.json里怎么配,它就怎么编译,类型报错会在webpack的构建输出里显示。代价是慢,因为每个文件都要经过tsc的完整编译流程。
第二条是用babel-loader配合@babel/preset-typescript。babel做语法转换,把TS语法剥离掉,只留下JS,然后走babel的处理流程。它不检查类型,所以速度快,但类型检查必须靠fork-ts-checker-webpack-plugin这类插件在单独进程里补上。
这里插一句babel-loader的一个重要限制:它默认只转语法,不做类型检查,所以你得额外接一个类型检查插件才能拿到完整的TS体验。而ts-loader则原生集成类型检查,配置简单,对新手友好。
我的选择是ts-loader,理由有两条:第一,这个系列的定位是"从0开始理解原理",ts-loader的配置方式和tsc的思想一致,学习路径连贯;第二,中小型项目里ts-loader性能上的差距完全可接受,你应该优先保证开发过程的类型感知能力,而不是一开始就追逐极致构建速度。后面如果你想切换到babel-loader提速,我可以把迁移思路写在最后一部分。
2.2 devServer、HMR与TypeScript的协作关系
热更新不是webpack核心装完就有的,它需要三个角色协同工作。
webpack负责监听文件变化,重新编译模块,并在内存中生成新的模块代码。webpack-dev-server负责起一个本地HTTP服务器,把这个内存里的编译结果提供给浏览器,同时在编译完成时通过WebSocket向浏览器推送更新信号。写在代码里的HotModuleReplacementPlugin(webpack5在开启hot: true时会自动引入)则负责在浏览器端注入热更新运行时,告诉浏览器"我收到了新模块,你去替换".
对TypeScript项目来说,热更新的触发链路是:你修改src/xxx.ts -> ts-loader把它重新转译成JS模块 -> webpack察觉到了这个模块的变化 -> devServer通过WebSocket给浏览器发一个hash和更新列表 -> 浏览器里的运行时决定是原地替换还是触发整页刷新。
如果链路里任何一环断掉,表现都是"我改了文件,浏览器没反应"。后面第五部分我会专门给出排查清单,现在你只需要在脑子里留下这个协作模型。
2.3 依赖清单与安装命令
直接给你一份我实测过的依赖组合。项目目录为空的前提下,运行:
npm init -y npm install webpack webpack-cli webpack-dev-server typescript ts-loader -D这些包里,webpack是核心打包器,webpack-cli提供命令行入口,webpack-dev-server是开发服务器,typescript是TS编译底座,ts-loader是webpack与typescript之间的桥梁。
版本方面,我自己用的是webpack ^5.9x、webpack-dev-server ^4.15.1。如果你Node版本在18以上,也可以装webpack-dev-server的最新v5版本,配置差异不大。不建议把devServer和webpack的版本差得太大,比如webpack5配webpack-dev-server 3就很容易出兼容问题。
装完之后看package.json,如果多出这些依赖,说明第一步走通了。接下来就是配置文件的搭建。
3. 从零配置:webpack5热更新完整接入步骤
3.1 目录结构和tsconfig.json基础配置
开工前先把目录定清楚。习惯上我会建一个src目录放源码,dist目录是webpack的输出目录,但在开发模式下dist甚至可以不落盘,因为webpack-dev-server会直接在内存里提供编译结果。最小结构长这样:
my-ts-project/ ├── package.json ├── tsconfig.json ├── webpack.config.js ├── src/ │ ├── index.ts │ └── math.tstsconfig.json我先给一个能用的版本,然后逐项解释:
{ "compilerOptions": { "target": "ES2017", "module": "ESNext", "moduleResolution": "node", "strict": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true }, "include": ["src"] }这里有个容易踩坑的点:module字段。如果你设成commonjs,ts-loader转出来的模块就是CommonJS格式,webpack5虽然能解析,但会丢失一些ES模块的静态分析优势。所以我会明确设成ESNext,让webpack收到的代码尽量保持ES模块语义,这样tree shaking和热更新的模块边界都更清晰。
target设成ES2017而不是最新,是为了避免ts-loader转出来的代码里出现浏览器不兼容的语法,也是在为后面的兼容性留缓冲。include字段指定了tsc只处理src目录,别去翻node_modules——虽然ts-loader里还会用exclude再兜底一道,但tsconfig这个开关更早生效。
3.2 手写webpack.config.js并逐项解析
接下来是今天的重头戏,webpack.config.js。我先把完整配置贴出来,再逐段拆开讲。
const path = require('path'); module.exports = (env, argv) => { const isProduction = argv.mode === 'production'; return { mode: isProduction ? 'production' : 'development', entry: './src/index.ts', output: { filename: isProduction ? 'bundle.[contenthash].js' : 'bundle.js', path: path.resolve(__dirname, 'dist'), clean: true, environment: { arrowFunction: false, const: false } }, resolve: { extensions: ['.ts', '.js'] }, module: { rules: [ { test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ } ] }, devtool: isProduction ? 'source-map' : 'eval-source-map', devServer: { static: { directory: path.join(__dirname, 'dist') }, compress: true, port: 9000, hot: true, open: true } }; };entry字段指定入口文件,webpack会从这个文件开始,沿着import语句把所有依赖找出来。output里的filename区分了生产和开发:生产环境用contenthash,文件名一换,浏览器缓存就失效;开发环境固定bundle.js,避免反复生成一堆新文件。clean: true是webpack5新增的,每次构建前清空dist目录,省去手工删除的麻烦。
resolve.extensions是新手最容易漏的配置。你写了import './math',webpack得靠这个列表去补后缀:先去查.ts,再查.js。如果不配这个字段,webpack只认带后缀的路径,报错会非常迷惑。
module.rules是loader的入口。test: /\.ts$/意思是匹配所有.ts结尾的文件,交给ts-loader处理。注意exclude: /node_modules/一定要写,否则webpack会试图把node_modules里的第三方TS文件也过一遍loader,构建时间会莫名变长。
devtool这一项决定了报错信息映射到源码的方式。开发阶段用eval-source-map,定位精确、构建速度还行;生产用source-map,生成独立的映射文件,不影响用户侧的包体积。这里我想多说一句:source map在热更新场景下不只是调试才用,它还能帮助浏览器在模块替换时更准确地建立新旧代码的关联关系,降低一些边界情况下的替换失败概率。
devServer是整个热更新配置的心脏。port默认是9000,如果你本机这个端口被占了,构建会直接报错,改成8080或3000都行。hot: true开启热模块替换,这是本期核心中的核心。open: true表示启动后自动打开浏览器,省一次手工操作。
3.3 最小demo验证热更新
配置写完了,得跑起来验货。我先建两个TS文件,一个工具函数,一个入口文件。
// src/math.ts export function add(a: number, b: number): number { return a + b; }// src/index.ts import { add } from './math'; const result = add(1, 2); const el = document.createElement('div'); el.innerHTML = `1 + 2 = ${result}`; document.body.appendChild(el); // 只有引用到了模块的代码能触发HMR if (module.hot) { module.hot.accept(); }package.json里补一下启动脚本:
{ "scripts": { "dev": "webpack serve --mode=development", "build": "webpack --mode=production" } }然后运行npm run dev,终端会显示webpack编译信息和devServer监听地址。打开浏览器,你看到的页面显示"1 + 2 = 3"。现在去修改math.ts,把数字换成2和3,保存后观察浏览器:页面不会刷新,但一秒内文本会自己变成"2 + 3 = 5"。
这就是热更新生效了。注意index.ts里那段if (module.hot),它的作用是在模块替换后主动通知webpack运行时"我接受这个模块的更新",如果你不加,很多情况下webpack会放弃原地替换,直接退化成整页刷新。后面第四部分我会细讲这个accept到底在干什么。
4. 热更新机制与模块热替换边界
4.1 热更新流程拆解:hash、websocket、模块替换
这一节把热更新的内部流程拆开讲,你会发现自己配的那些字段全都是这段流程里的零件。
第一步,你修改文件并保存。webpack的文件监听系统(watchpack)检测到变化,重新执行编译。编译后,webpack会生成两条关键信息:本次构建的hash值,以及一组"更新清单",清单里记录了哪些模块被改动、改动后的模块代码块ID。
第二步,webpack-dev-server通过WebSocket把这条消息推给浏览器。浏览器端的webpack运行时收到消息后,拿新hash和当前页面的hash做对比,确认是增量更新而不是全量重建。这里有个细节:WebSocket建立的是长连接,所以几乎零延迟,这也是为什么热更新感觉"即改即生效"。
第三步,浏览器运行时根据更新清单发起Http请求,拉取新增模块的代码。这个过程走的是JSONP协议,webpack内部专门为HMR设计了一套"脚本注入"机制,浏览器拿到新模块代码后,会在内部模块缓存里找到旧模块,执行新旧替换。
第四步,最关键的决策点:如果被更新的模块和它依赖链上的所有模块都声明了接受热替换,运行时完成原地替换,事件循环不中断。如果任何一个模块声明了不接受,运行时为了安全,会丢弃当前模块树,直接触发整页刷新。这可能让许多人困惑的地方在于——我明明什么都没写,为什么有时候热更新就变成了自动刷新。答案就是accept声明没到位。
4.2 什么时候需要手动调用module.hot.accept
module.hot.accept的作用分两层。没有它,webpack照样能跑热更新,但一旦你修改的模块位于依赖树的中间层,运行时会拿不准"哪些上层模块还依赖旧版本的你",保守选择就是整页刷新。有了它,你明确告诉运行时:这个模块乐意接受自己的更新,请替换我给你注入的新代码。
在纯粹的TS业务代码里,最佳实践是:每个入口模块的顶层都加上if (module.hot) { module.hot.accept(); }。入口模块通常没有别的模块依赖它,所以它接受更新不会有任何问题,反而能让整个过程更顺畅。
有些场景则必须更精细地控制。比如你有一个保存用户表单状态的store模块,修改store的action实现时希望保留当前表单的数据。如果整个页面都接受更新,store模块的实例会被替换,表单数据随之丢失。这时候你可以在store文件里单独管理module.hot.dispose,用来保存旧状态、在新模块里恢复。这个概念跟Vue和React的热更新框架原理是一样的,后者只是在内部帮你封装好了这一层。
强调一个常见的误解:热更新不是不会重跑代码,而是重跑了模块代码但保留了模块实例和应用状态。如果你改的文件是"模块作用域里的一段初始化逻辑",这段逻辑在替换后会重新执行,这是正常的,不需要强行去阻止它。
5. 常见问题与排查技巧实录
5.1 热更新不起作用的排查清单
代码不触发热更新,是这期最常遇到的状况。我把排查点整理成清单,按顺序查一遍,速度最快。
第一项,devServer配置里hot是否真的为true。有时候改着改着配置,文件被IDE格式化成字符串忘了加引号,人眼很难看出来。打开浏览器控制台,如果看到"HMR connected"说明连接正常,看到"App is up to date"说明运行时在正常工作,只是没有检测到更新。
第二项,入口模块里有没有明确接受更新。忘写module.hot.accept()等于告诉运行时"我不接受热替换",它只能退化成整页刷新。页面没刷新,或者刷新了但状态全丢,先检查这里。
第三项,src文件的路径是否正确。很多人改了文件却忘了保存,或者文件放在src外,导致webpack的监听范围根本没覆盖到你正在编辑的文件。改完文件看终端,编译日志有没有变化。
第四项,配置文件里有没有把output.path和devServer.static指向同一个目录,并且目录存在。devServer的static配置只影响静态资源发布,不直接影响HMR,但如果你发现页面加载404,大概率是这里出了问题。
第五项,依赖版本冲突。webpack4的项目装了新版webpack-dev-server会直接报错。这个好排查,报错信息里会提示版本要求,升级或对齐版本即可。
第六项,浏览器缓存。尤其是开发了很长时间之后,浏览器很可能把旧的bundle.js缓存在磁盘里。这时候即使devServer推送了更新,浏览器还在用旧代码。控制台里强制刷新一次,或者开无痕窗口,问题就消失。
第七项,多个webpack实例在跑。我之前遇到过端口默认冲突、两个devServer同时监听同一个项目目录的情况,日志看起来一切正常,但每次保存文件触发了两次编译,最后浏览器收到的更新永远是第二次的,状态错乱。排查方法是看终端有没有两个持续运行的进程占用相同端口。
5.2 真实踩坑记录与解决过程
这次配置过程中我实际踩过一个很有意思的坑,值得单独拿出来讲。
现象:npm run dev启动后,改任何TS文件,终端都显示编译成功,但浏览器页面纹丝不动。控制台里连"HMR connected"都没有。我开始以为是devServer连接有问题,检查了端口、防火墙、静态目录,都没毛病。最后把浏览器控制台往上翻,才发现是在WebSocket握手阶段报了一个错——开发服务器的host配置的是localhost,而我在浏览器里通过IP地址访问页面,跨域了。
解决方法是,在devServer里把host设置成0.0.0.0,或者直接用localhost访问。如果你在局域网环境调试,把host改成0.0.0.0还能顺便解锁手机调试场景,一举两得。
第二个坑是ts-loader的cache问题。项目规模上来以后,ts-loader默认没有缓存,每次重新编译都要走一遍完整语法检查,热更新体验会明显变慢。webpack5可以通过cache: { type: 'filesystem' }开启持久化缓存,这个配置在webpack5里是支持loader缓存的。我实测开启后,第二次启动构建速度能快将近一半。不过要注意,某些修改tsconfig.json的场景下缓存可能滞后,这时需要删掉node_modules/.cache目录强制重建。
第三个坑是样式问题。如果你在TS里import './style.css',ts-loader不处理CSS,webpack会直接报错"模块解析失败"。需要补上style-loader和css-loader两个依赖,在module.rules里新增一条规则。这也是很多TS新手把样式和脚本一起引时遇到的第一个壁。
5.3 开发与生产配置的配置分离建议
上面配置里的isProduction判断你已经看到了,这是一个让同一个配置文件同时服务开发和生产的简单办法。但我建议项目再复杂一点后,考虑做配置切分。
一个常见的分法是分成三份:webpack.common.js放公共逻辑,webpack.dev.js放devServer、source map和热更新相关配置,webpack.prod.js放代码压缩、contenthash、更严格的source map策略。然后用webpack-merge这个库把它们合并。这样做的根本目的是隔离关注点:生产配置里不应该有任何devServer相关代码,开发配置里也不应该因为生产需求引入额外的压缩插件拖慢启动速度。
在我自己项目里的实践是,中小型项目用函数式配置就够,判断一下argv.mode就完事。一旦团队多人协作,或者需要用环境变量区分更多场景(比如测试环境、预发布环境),切分成独立文件能让diff更干净,review也更快。
还有一件容易被忽略的事:devtool在开发和生产的巨大性能差异。开发环境千万别用production级别的source-map策略,那是大而全的生成,速度极慢。eval-source-map在开发里表现足够好,生产用source-map让溯源能力不缺失。
6. 一些只有动手才会明白的结论
最后一节我不做总结,就说三点个人体会。
第一,热更新不是"配一次就完事"的东西,它是可以持续深挖的一块地。等你把HMR摸熟了,会发现它不只对TS有效,CSS、图片、JSON模块全都可以在此基础上做热替换。而这一切的起点,就是你手动搭出来的这一套ts-loader + webpack-dev-server的组合。
第二,我在实际项目中经常看到有人图省事直接用脚手架,出了问题只能删了重来。任何构建工具的问题排查,最终都归结为对"依赖、loader、插件"这三层关系的理解。你亲手配完一遍webpack,就算以后用vite、umia等封装度更高的工具,碰到了奇怪问题,也能一眼判断出是编译阶段、解析阶段还是运行时阶段出的错。
第三,有一个小技巧值得你养成习惯:热更新虽然好用,但不是所有改动能立刻稳定生效。涉及类型定义的大改动,比如调整接口字段,旧代码的缓存可能短暂失效。这种时候别急着debug,缓存目录(node_modules/.cache)删掉重跑一次,通常就正常了。这也是为什么我建议在tsconfig里加上skipLibCheck: true,省去不必要的类型声明文件检查。
从这个系列到现在,从tsc手动编译走过webpack5热更新,你已经把TS项目开发体验里最难啃的一块骨头啃完了。下一步可以想想怎么给这个配置加上React或Vue的框架支持、怎么接ESLint、怎么把公共逻辑抽成公司内部的cli预设。那会是另一个很长的话题,但地基已经在这里了。