ec-brand-web 启动排错与技术学习

ec-brand-web 启动排错与技术学习

记录 2026-04-20 首次拉起 ec-brand-web 本地开发环境过程中遇到的全部问题、原理、三层解法对比与面试话术。

项目技术栈:Vue 2.5 + Webpack 2 + Babel 6 + Node 14(node-sass 4.x 约束)+ 多页应用(MPA)。这套栈在 2026 已属老项目,踩坑点集中在"现代语法 × 老工具链"的断层。

目录

  1. Node 版本选型:被 native 模块绑架的现实
  2. Babel 6 的"解析器天花板":插件救不了语法错误
  3. Webpack 2 的 ES module 识别机制与 exports 只读错误
  4. 多页应用(MPA)架构与"根路径空白"的双重误区
  5. 端口冲突排查与进程管理:pkill 的边界
  6. 启动过程关键改动清单

1. Node 版本选型:被 native 模块绑架的现实

现象

package.json 写着 "engines": { "node": ">= 4.0.0" },但直接用系统默认 Node 20 跑 npm installnode-sass 编译失败。

根因:Node ABI 版本 × 预编译二进制

node-sass@4.14.1native 模块,依赖 libsass C++ 代码编译成 .node 二进制。这类模块和 Node 的 V8 ABI(Application Binary Interface) 版本强绑定:

  • Node ABI 版本用 NODE_MODULE_VERSION 表示(Node 14 是 83,Node 16 是 93,Node 20 是 115)
  • 每个 native 模块的发布包会为"主流 ABI 版本"预编译一份二进制
  • 装包时按当前 Node ABI 去匹配,匹配不上就本地编译

node-sass 4.x 的发布物只编译到 Node 13-14 的 ABI(v83)。Node 16+ 需要 node-sass 6+ 或者换 sass(Dart Sass 纯 JS 实现)。强行在新 Node 上装,要么下载二进制失败、要么 node-gyp 本地编译时因 V8 header 变了而失败。

原理类比

类比你熟悉的 iOS/Android 原生组件:React Native 的 .framework.aar 必须匹配 iOS/Android SDK 版本,老 SDK 编译的二进制放新 SDK 里可能因 ABI 变化崩溃。Node native module 同理,只是 ABI 载体是 NODE_MODULE_VERSION 这个整数。

本项目的约束链

node-sass@4.14.1 (项目锁定 ^4.11.0)
     │ 依赖 ABI v83 的预编译二进制
     ▼
Node 14.x(最后一个 v83 的 LTS)
     │ 通过 nvm 管理
     ▼
fsevents@1.2.13(macOS 文件监听)
     │ 也是 native,编译期有警告但不报错
     ▼
✅ 可运行

我选 Node 14.21.3,是"满足 node-sass 4.x ABI 约束的最新 LTS"。Node 16 会失败,Node 12 也能跑但已 EOL 更久。

三层做法对比

❌ 初级:看见 node-sass 报错 → 网上搜 "node-sass installation failed" → 
        复制 `npm rebuild node-sass --force` 碰运气
        (没理解 ABI 约束,换 Node 版本还会再错一次)

⚠️ 中级:意识到是 Node 版本问题 → 用 nvm 切到最新 LTS(Node 22)→ 
        还是失败,再降到 18 → 降到 16 → 最后 14 能跑
        (试出来的,但不知道为什么 14 是对的)

✅ 资深:读 package.json 锁定的 node-sass 版本 → 查 node-sass release notes 
        确认它的 ABI 支持上限 → 直接选 Node 14 → 一次到位
        (还会评估:要不要趁机把 node-sass 迁到 sass,解除 ABI 绑架)

相关代码位置

  • Node 版本选择逻辑:无配置文件,靠 package.jsonnode-sass: ^4.11.0 约束反推
  • 没有 .nvmrc:建议补一个 .nvmrc14.21.3,避免下次新同事又踩坑

面试角度

面试官可能怎么问

  • 初级:"你们项目要求什么 Node 版本?" → 答 14。
  • 资深:"为什么不升 Node 16/18?升级成本在哪?" → 这才是考察点。

面试话术:"我们项目 node-sass 锁在 4.x,它的预编译二进制只到 Node 14 对应的 ABI(v83)。升 Node 要连带把 node-sass 迁到 Dart Sass(sass 包),涉及全项目 scss 语法兼容性回归,所以权衡下先锁 Node 14。.nvmrc 是必要的——没有它新同事进来切不到正确版本,依赖装一半报错比启动失败更难排查。"

延伸讨论

  • "如果我想升呢?" → 分两步:(1) 先把 node-sass 换成 sass 并过全量视觉回归;(2) 再升 Node。单独升 Node 会卡在编译。
  • "线上构建机怎么办?" → CI 镜像固定 Node 14;如果构建机升级失控,加 Docker 把 Node 版本固化。

思考题讨论

Q1:node-sass 迁 sass 会有视觉差异吗?为什么要做"视觉回归"?

用户盲区:以为 CSS 预处理器不同但产出相同。

正确解答

libsass(node-sass 底层)和 Dart Sass 虽然都实现 Sass 规范,但实现细节差异会产生视觉变化

已知的行为差异:

1. 除法语法
   node-sass (libsass): 10px / 2 = 5px(当除法)
   Dart Sass 1.0:       10px / 2 = "10px / 2"(当字符串)
   → 新版本要显式用 math.div(10px, 2)
   → 老代码不迁移会算错尺寸

2. @import 路径解析
   两者对相对路径解析略有差异
   → 偶尔出现"这个变量怎么引不到"

3. 颜色函数精度
   darken()、lighten() 的 HSL 计算精度差异
   → 视觉上细微但像素级敏感的设计会发现

4. 警告/错误严格度
   Dart Sass 对废弃语法警告更严格
   → 老代码大量 warning

5. 编译产物格式
   node-sass 默认保留更多空白,Dart Sass 默认更紧凑
   → 不影响最终渲染,但 git diff 巨大

为什么要"视觉回归"

企业项目里老 CSS 累积了大量 "happened to work" 的魔法:
  - /* FIXME: 不知道为什么这样写,但删了就错位 */
  - 依赖某个 sass 函数的精度特性
  - 用了已废弃的语法但还没报错
  
迁移后要跑:
  1. 人工检查主要页面(视觉 diff 工具)
  2. Percy / Chromatic 做截图对比
  3. 至少一个回归测试周期

这解释了为什么"小小的换库"会变成"几周的工程"——不是技术难,是验证成本高

Q2:除了 node-sass,还有哪些常见的 native 模块坑?

正确解答

前端生态的 native 模块"黑名单":

1. node-sass(本项目踩过)
   → 迁移到 sass (Dart Sass) ✅ 已废弃

2. fibers(Sass 的可选依赖)
   → 现在已废弃,不再需要

3. sharp(图片处理)
   → Node 20+ 有 prebuild,大部分平台无需本地编译
   → Apple Silicon 转换期间经常出问题

4. bcrypt
   → 替代:bcryptjs(纯 JS,慢但稳定)
   → 用于密码哈希,性能差异可以忽略

5. better-sqlite3
   → 必须本地编译
   → CI 镜像要装 python + gcc
   → 替代:sql.js(WASM,性能差但无编译)

6. canvas (node-canvas)
   → 依赖系统的 cairo/pango 库
   → Docker 镜像里装起来极痛
   → 替代:@napi-rs/canvas(Rust + N-API,更现代)

7. puppeteer 的 Chromium
   → 不严格是 native 模块,但下载大二进制
   → CI 环境要特殊处理

8. node-gyp 本身
   → macOS 要装 Xcode CLT
   → 新 Node 不兼容老 node-gyp

资深识别法

# 检查项目依赖里有多少 native 模块
grep -l "node-gyp\|binding.gyp\|prebuild" node_modules/*/package.json

# 或 npm 层面
npm ls --all | grep -E "(node-gyp|binding)"

选型原则

✅ 优先选择:
  - 纯 JS(如 bcryptjs)
  - WASM(如 sql.js、@napi-rs/*)
  - 有 prebuild + N-API(如 sharp、better-sqlite3 新版)

❌ 避免:
  - 必须本地编译的 C++ 扩展
  - 不支持 prebuild 的老 native 模块

Q3:如果硬要升级 Node 但不能换 node-sass,有办法吗?

正确解答

有,但不推荐。三种 hack 方案:

方案 A:用 --force 强制编译
  npm rebuild node-sass --force
  → 需要系统有 Python 2 + 老版本 gcc
  → 现代 macOS 上几乎装不上 Python 2
  → Windows 上更痛
  → 成功率低

方案 B:手动下载兼容的 binding
  找社区维护的 node-sass fork 的 prebuild 二进制
  手动放进 node_modules/node-sass/vendor/
  → 维护性差,下次 install 丢失
  → 需要 patch-package 固化

方案 C:Node 补丁
  用 Node 14 的 V8 header 文件骗过编译
  → 极度脆弱,运行时可能段错误
  → 不值得尝试

❌ 都不推荐
✅ 正确做法:接受迁移成本,迁到 Dart Sass

更根本的教训

Native 模块是"技术债放大器":
  - 项目启动时选了 node-sass(2017 年合理)
  - 时间过去 5 年,Node 升级了几轮
  - 每次升级 = 压力测试迁移意愿
  - 最后变成"没人敢动"的僵化依赖

避免未来踩同样坑:
  - 新项目尽量选纯 JS 依赖
  - 必须用 native 模块时,选有 N-API + prebuild 的
  - 定期审计依赖的技术债(每半年一次)

面试话术:"Node ABI 绑定是个典型的技术债放大器——你在项目启动时选了一个 native 模块很合理,但随着 Node 版本更替,老 native 模块的维护跟不上,慢慢变成'不敢升 Node'的瓶颈。node-sass → sass 是标准演进路径,但不是换个包名这么简单,要做视觉回归。我在项目里会做依赖健康度审计:用 grep binding.gyp 找出所有 native 模块,评估替代方案。现代前端生态的最佳实践是'能纯 JS 不 WASM,能 WASM 不 N-API,能 N-API 不老 node-gyp'——每一层都是把未来的升级痛苦降一个数量级。"


2. Babel 6 的"解析器天花板":插件救不了语法错误

现象

执行 npm run dev 后,@ecool/web-request/video.js 报:

Module parse failed: Unexpected token (66:24)
|                 try { ... }
|                 } catch {
|                         ^

第一反应装 babel-plugin-transform-optional-catch-binding,加入 .babelrcplugins。结果报错一模一样

根因:Babel 6 用的是 babylon,不是 @babel/parser

Babel 有两个阶段:

源码
  │
  ▼
┌──────────────┐
│  Parse       │ ← 把文本变 AST(用 babylon@6 或 @babel/parser@7)
│  (解析器)  │    这一步失败就什么也做不了
└──────┬───────┘
       │ AST
       ▼
┌──────────────┐
│  Transform   │ ← 插件在这里跑:改 AST
│  (插件层)  │    transform-optional-catch-binding 在这里
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  Generate    │ ← AST 再变回代码
└──────────────┘

babel-plugin-transform-optional-catch-binding 做的是把 catch {} 的 AST 节点转成 catch (e) {}前提是 parse 阶段已经产出了 AST 节点

Babel 6 的 parser(babylon 6.x)发布于 2016-2018,不认识 ES2019 的 optional catch binding 和 ES2020 的 optional chaining ?.。Parse 阶段直接抛 Unexpected token,plugins 根本没机会跑。

原理类比

类比 TypeScript:tsconfig.json 里打开 experimentalDecorators 能让 TS 识别装饰器语法。但如果你用的 TS 编译器版本太老不支持某个新语法(比如 using 声明),光加 flag 也没用——要升编译器本身。

Babel parser 就是那个"编译器本身",插件更像"运行时的行为开关"。

本项目的连锁反应

@ecool/web-request@1.1.6 是团队维护的依赖,某次更新引入了 catch {}(ES2019)和 ?.(ES2020),但 ec-brand-web 的 babel 还是 6.x,解析器版本停在 2018 年。版本号 ^1.1.1 被 npm 解析到了 1.1.6 后崩了。

三层做法对比

❌ 初级:加对应 babel 插件 → 还是报错 → 再加一个 preset → 还是报错 → 放弃
        (不知道 parse/transform 分阶段)

⚠️ 中级:升到 babel 7 → 改 preset-env → 重装一堆依赖 → 
        和 webpack 2 + vue-loader 的组合兼容性炸裂 → 回滚
        (方向对但成本估算错)

✅ 资深:权衡「升 babel 的爆炸半径」 vs 「patch 依赖源码」:
        - 依赖里只有 2 处不兼容语法(catch {} 和 ?.)
        - 手动改回旧语法 5 分钟搞定
        - 长期方案:用 patch-package 把 patch 固化,或向上游提 PR
        - 选择直接改 node_modules + 记录 TODO
        (同时意识到:真正的根因是依赖库不该用比宿主更新的语法)

相关代码位置

  • 临时修复:直接改 node_modules/@ecool/web-request/video.js:66 catch {catch (e) {
  • node_modules/@ecool/web-request/video.js:90uploadResult.data?.org?.[0] → 展开为显式 null 检查
  • .babelrc:加了 transform-optional-catch-binding最终没生效,保留作为"未来升级时的便利"

面试角度

面试话术:"Babel 6 和 7 最大的区别之一是解析器换代:babylon 合入 babel 官方仓库变成 @babel/parser,同步升级了对 ES2019/ES2020 新语法的支持。所以老项目里如果依赖包用了 catch {}?.,加插件是无效的——插件在 transform 阶段工作,parse 阶段已经挂了。这种情况要么升整个 babel 工具链(爆炸半径大),要么 patch 依赖源码(用 patch-package 固化)。我们项目选了后者,因为只有 2 处不兼容点,ROI 高得多。"

延伸讨论

  • "为什么不升 babel?" → Webpack 2 + babel 6 + vue-loader@12 是强绑定组合,升 babel 7 要连带升 vue-loader、sass-loader、甚至 webpack,回归面积不可控。
  • "上游依赖的问题怎么反馈?" → 向 @ecool/web-request 提 issue,说明"库里使用了比最低支持的宿主环境更新的语法",或建议发 CJS/dist 产物。

原理深挖:为什么 parser 和 transformer 要分开?

Parse/Transform/Generate 三段式不是偶然,是编译器的经典架构

编译器的标准分层:

Parser(前端)
  - 负责"语法识别"
  - 把文本变成结构化数据(AST)
  - 这一步决定"能不能懂这个代码"

Transformer / Optimizer(中端)
  - 操作 AST
  - 做优化、转换、注入
  - 这一步决定"要怎么改这个代码"

Generator / Code Gen(后端)
  - 把 AST 转回目标形式(可能是 JS、可能是机器码)
  - 决定"产出是什么样"

这种分层的好处:
  - 前端支持多种源语言(JS/TS/JSX/Flow),共用后端
  - 后端支持多种目标(ES5/ES2015/字节码),共用前端
  - 中端优化可以独立演进
  
缺点:
  - 各阶段能力不对齐时,用户不知道问题在哪一阶段
  - 本章的 catch{} 就是典型——用户以为加 plugin 就能解决

对比类似架构

LLVM 的设计:
  C/C++/Rust/Swift → LLVM IR → x86/arm64/wasm
  前端不同,中端共享,后端不同
  
TypeScript 的设计:
  TS 源码 → TS AST → 类型检查 + 降级 → JS
  
Babel 7:
  @babel/parser → AST → plugins/presets → @babel/generator → JS
  
React 18 编译器(React Compiler):
  JSX → AST → 深度分析优化 → 生成 memoization 代码 → JS

设计模式识别:Plugin 模式的典型应用

Babel 是"Plugin 模式"的教科书案例:

// Babel 插件的本质
module.exports = function({ types: t }) {
  return {
    visitor: {
      // 访问每个 Identifier 节点
      Identifier(path) {
        if (path.node.name === 'oldName') {
          path.node.name = 'newName'
        }
      },
      // 访问每个 FunctionDeclaration 节点
      FunctionDeclaration(path) {
        // 改函数
      }
    }
  }
}

Visitor 模式

AST 是树结构
Visitor 是"访问者",遍历树时对每种节点调用对应函数
多个 Visitor 可以组合(一次遍历应用多个转换)

类比:
  React 的 fiber reconciler 也是 visitor 模式
  DOM 的 TreeWalker
  HTML 解析器的 SAX API
  → 这是"遍历树"的通用模式

三层对比:语法兼容性问题的通用应对

❌ 初级:看报错搜插件,插件不行就放弃
   → 不知道 parse/transform 的分层

⚠️ 中级:加 preset-env + targets 配置
   → 有时管用,但不理解为什么
   → 老 babel 配 preset-env 照样挂

✅ 资深:判断问题在哪一层
   - parser 层不支持 → 必须升级 parser 或改源码
   - transformer 层不支持 → 加 plugin 即可
   - generator 层(少见)→ 通常是 bug
   - 同时评估升级 vs patch 的爆炸半径

思考题讨论

Q1:为什么 Babel 7 把 babylon 合并进官方?

用户盲区:觉得"合并"只是组织变化。

正确解答

这是架构演进 + 生态整合的双重需求:

Babel 6 时代(2015-2018):
  babylon 是独立仓库(@babel/parser 之前叫这名)
  parser 和 transformer 版本管理分离
  → 问题:用户可能升级 babel-core 但忘升 babylon
         结果 parser 版本和 plugin 版本不对齐
         引发各种奇怪兼容性问题

Babel 7 合并后(2018+):
  @babel/parser 成为 @babel/core 的嫡系子仓库
  版本严格对齐(都是 7.x)
  用户只需升级 @babel/core 一个版本
  → parser 和 plugin 保证兼容

同时的架构改进:
  1. 统一 @babel/ scope(去 babel- 前缀)
  2. 合并 core/parser/generator/traverse/types 等核心包
  3. 插件用 preset-env + targets 动态决定降级程度
  4. 更现代的配置文件(babel.config.js vs .babelrc)

启示包边界和版本管理息息相关。合并减少了"不同包版本不协调"的概率。这个问题在 TypeScript、Vue、React 生态也反复出现——经验法则是"核心链路的包应该版本锁死"。

Q2:如果 Babel 不存在,前端生态会怎样?

用户盲区:不理解 Babel 的历史地位。

正确解答

Babel 2014 年诞生时的背景:

2014 年的 JS 生态:
  - ES6 (ES2015) 刚出,所有浏览器都还不支持
  - 只有 IE 10/11、Chrome 30 这种老浏览器
  - Webpack 还在襁褓,requirejs 当道
  - React 刚发布

没有 Babel 的世界:
  方案 1:等浏览器普遍支持(要等 3-5 年)
         → 错过 ES6 的生产力红利
  
  方案 2:自己写转译
         → 小公司做不起
  
  方案 3:用 Traceur / CoffeeScript
         → 这些工具当时存在但生态不活

Babel 做对的事:
  1. 开源,社区驱动
  2. 插件化架构(任何人能加新语法支持)
  3. 和 React/JSX 深度整合(JSX 实际是 Babel 插件)
  4. 和 Webpack 完美配合

结果:
  ES6 (2015) → ES2020 的 5 年间,JS 开发者可以
  "写新语法 + 降级到老浏览器"
  → 这是 JavaScript 工具链最大的胜利

Babel 的历史地位让 JS 语言迭代速度跟上生态需求,解决了"浏览器兼容 vs 新语法"的历史矛盾

现在的挑战

2024+ 的变化:
  1. swc (Rust) 和 esbuild (Go) 快 10-100 倍
  2. 浏览器本身跟进 ES 标准的速度变快
  3. 对 legacy IE 的支持需求消失
  
Babel 的未来:
  - 仍然是 AST 操作的标杆(React Compiler、Tailwind JIT 等工具基于 Babel)
  - 但作为"降级工具"的地位在下降
  - 很多项目用 SWC 替代了 Babel(Next.js、Vite 默认用 SWC)
  
启示:
  工具的价值看它解决的历史问题 + 当前需求
  Babel 解决了 2015-2020 的核心痛点
  进入 2025,新痛点(构建速度)让新工具崛起

Q3:这个项目应该升级 Babel 吗?

正确解答

决策框架

升级的收益:
  - 支持新语法(?.、??、class fields 等)
  - 更快的编译速度(Babel 7 比 6 快 3x)
  - 更精准的 polyfill(preset-env + core-js 3)
  - 生态更新(大部分新工具假设 Babel 7+)

升级的成本:
  - vue-loader 必须升(Babel 6 + vue-loader 12 绑死)
  - webpack 2 最好也升(webpack 5 默认配置假设 Babel 7+)
  - sass-loader、file-loader 等周边要对齐
  - 每升一个都要测试,累计 1-2 周工程

本项目的现状:
  - 老项目,维护模式
  - 没有频繁新功能
  - 踩到 2 个语法兼容问题,已用 patch 绕过

ROI 分析:
  投入:1-2 周工程
  收益:解除 patch 维护负担(真实但不紧急)+ 为未来升级铺路

结论:
  - 如果项目还要活 2+ 年 → 值得升(摊销成本)
  - 如果 6 个月内下线 → 不值得,继续 patch
  - 如果在"逐步熄灯"状态 → 坚决不升

资深工程师的判断:不是"好项目都该用最新工具",而是"根据项目生命周期选择投资强度"。

面试话术:"Babel 6 → 7 的 parser 合并是典型的'版本锁死'架构决策,把 babylon/core/traverse 等核心包版本严格对齐,避免生态割裂。这个项目是否要升 Babel,我会用 ROI 框架评估:投入是连带升级 webpack/vue-loader 的 1-2 周工程,收益是解除 patch 维护负担 + 为未来升级 Node 和 node-sass 铺路。关键看项目寿命——还要活 2 年就值得升,6 个月下线就继续 patch。更深层的启示是:工具升级是技术债的复利式还款,越晚越贵,但也不是'越新越好'——SWC/esbuild 这类新一代工具在速度上吊打 Babel,Next.js/Vite 都默认用它们,所以'要不要升级 Babel'可能已经不是正确的问题,更应该问'要不要迁移到 SWC'。"


3. Webpack 2 的 ES module 识别机制与 exports 只读错误

现象

为了让 babel 处理 @ecool/web-request(想绕开上面语法问题),把它加进 webpack.base.conf.js 的 babel-loader include。编译通过了,但浏览器打开页面一片空白,控制台:

Uncaught TypeError: Cannot assign to read only property 'exports' 
of object '#<Object>'
    at eval (65:156:30)
    at Object.<anonymous> (index.js:1118:1)
    ...

第一反应(错误方向):怀疑是某个 .vue 文件混用了 importmodule.exports。搜了半天 src/ 里没有。

根因:Webpack 2 的"harmony 模块"机制 × modules: false

Webpack 2 判定一个文件是 ES Module 还是 CommonJS 的规则:

文件内容有 import / export 关键字吗?
       │
       ├─ 有  → 标记为 ES Module(harmony module)
       │       webpack 会在运行时用 Object.defineProperty 把
       │       exports 设为 **只读的 getter**
       │       之后任何 `module.exports = xxx` 都会抛错
       │
       └─ 没有 → 标记为 CommonJS,exports 可写

.babelrc["env", { "modules": false }] 告诉 babel 不要import/export 转成 require/exports,交给 webpack 处理(这样 webpack 可以做 tree-shaking)。

问题链路

  1. @ecool/web-request 加进 babel-loader include
  2. babel 处理后保留 import/export 语法
  3. @ecool/web-request 内部用 import axios from 'axios' 进而触发 webpack 的 ESM 识别
  4. 依赖图某处有 UMD 模块(vod-js-sdk-v6)用 module.exports = t()
  5. 在 ESM 识别的上下文里,module.exports = 撞到只读 getter,炸

原理类比

类比 React 18 的严格模式(StrictMode):开启后某些操作(第二次 render、setState 时机)行为变得更严格,原本能跑的代码可能出现"这个操作不允许"错误。Webpack 的 harmony 模式也是"更严格的运行时契约"。

关键排错推理

关键一步是意识到:patch node_modules 源码加入 babel includes 不是等价方案。

方案 能解决 catch{}/?. 语法错 是否改变 webpack 模块识别 爆炸半径
patch 源码
加 babel includes ✅(但 babel 解析器本身也不支持,没法单独用) ✅(改变了模块语义)
patch 源码 + babel includes ✅(引入本错误)

已经直接 patch 了 video.js 源码,catch/?. 语法错已经解决。加 babel includes 是多余且引入副作用的。

数据流图

src/pages/home/home.js (entry)
   │ import vue-utils.js
   ▼
src/assets/util/js/vue-utils.js
   │ import { uploadFile } from '@ecool/web-request'
   ▼
node_modules/@ecool/web-request/index.js  [ESM: 有 import/export]
   │ export { captureAndUploadVideoCover } from './video.js'
   ▼
node_modules/@ecool/web-request/video.js  [ESM: 有 import]
   │ import TcVod from 'vod-js-sdk-v6'
   ▼
node_modules/vod-js-sdk-v6/dist/...js  [UMD: module.exports = t()]
                                         ↑
                                 撞上 webpack 只读 getter 时炸

三层做法对比

❌ 初级:看到 Cannot assign to read only exports → 搜关键字 → 
        不知道是 webpack 2 harmony 模式 → 在 src 里到处找 module.exports

⚠️ 中级:定位到依赖图上的 UMD 模块 → 改 UMD 源码(把 module.exports 改成 export default)
        → 治标不治本,改一个跳出一个

✅ 资深:先问"我最近的改动是什么,有没有改变了 webpack 的模块识别假设"
        → 撤掉多余的 babel includes → 保持 ESM/UMD 的自然边界
        → webpack 自己能正确处理 ESM import UMD 的 interop

相关代码位置

  • build/webpack.base.conf.js:48-56 babel-loader 的 include 配置
  • 最终修复:撤回@ecool/web-request 加进 include 的修改(只保留源码 patch)
  • node_modules/vod-js-sdk-v6/dist/vod-js-sdk-v6.js:1 UMD 包装器开头

经验提炼

  • 加 babel includes 是一个"语义变更"操作:不只是"让 babel 处理一下",而是把文件从 webpack 自动处理流转交给 babel,进而改变 webpack 对该文件的模块类型判定。
  • node_modules 里的文件默认不进 babel 是有原因的:大部分 npm 包已经是 ES5/ES2015 产物,加进去没收益只有风险。
  • patch 源码 vs babel processing:如果依赖的问题是"语法不兼容",patch 源码成本更低、副作用更可控。

举一反三:这类"加进 include 引入副作用"的通用模式

- 把目录加进 tsconfig 的 include → 可能触发新类型检查错误
- 把文件加进 ESLint 的处理范围 → 引入一堆历史 warning
- 把 schema 加进 OpenAPI 的生成范围 → 可能改变生成的客户端 API

面试角度

面试话术:"这个错误本质是 webpack 2 的 harmony 模块识别:只要文件里有 import/export,webpack 会把 exports 对象设为只读,之后 module.exports = 就炸。我们项目触发这个错误是因为错误地把一个依赖加进了 babel-loader 的 include——本意是让 babel 转译它的新语法,但同时改变了 webpack 对这个依赖的模块类型判定,让它从'默认 CJS 处理'变成'ESM harmony 上下文',进而牵连到它依赖的 UMD 库(vod-js-sdk-v6)。真正的修复是撤回这个改动,回到'直接 patch 源码'的路线——这个例子说明:对构建工具做配置改动前,要想清楚它改变了什么语义假设,而不只是'让这一步能过'。"

延伸讨论

  • "Webpack 4/5 也会这样吗?" → Webpack 4+ 的 harmony 模式更激进(__esModule 标记、mjs 识别),错误形态类似但报错信息不同。
  • "怎么彻底避免这类问题?" → 用 package.json 的 "type": "module".mjs/.cjs 显式声明模块类型,不依赖启发式推断。

4. 多页应用(MPA)架构与"根路径空白"的双重误区

现象

浏览器打开 http://localhost:8087/ 白屏。控制台可能有 Cannot assign to read only property 'exports' 报错(见第 3 节)。

排错经过的两层认知更新

第一层认知(错误):以为 / 白屏是因为"MPA 没有单一默认 HTML,根路径没有对应文件所以空白"。

实际验证curl -s http://localhost:8087/ 能拿到 200 HTML):build/dev-server.js:78 启用了 connect-history-api-fallback,它把 / 自动 rewrite 到 /index.html。所以 //index.html 返回相同的 HTML,引用同一个 index.js bundle。

第二层认知(正确)/ 白屏和 /index.html 白屏是同一个问题——都是 index.js 这个 bundle 里运行时报错导致 Vue 没挂载成功(第 3 节的 exports readonly)。

根因:MPA 架构本身没毛病,错在先入为主

这个项目确实是 MPA:

src/pages/
  ├─ home/
  │   ├─ home.js    ← webpack entry 1(主应用,内含 vue-router)
  │   └─ home.html  ← html-webpack-plugin template 1
  └─ index/
      ├─ index.js   ← webpack entry 2(登录页)
      └─ index.html ← html-webpack-plugin template 2

build/utils.jsglob.sync(PAGE_PATH + '/*/*.js')pages/ 下每个文件夹视为独立入口,每个入口产出独立的 xxx.html + JS bundle。这是 MPA 的典型结构。

根路径 / 不是 MPA 的问题

  • SPA 的 history-api-fallback:任何未知路径(如 /user/123)都 fallback 到 index.html,前端 router 再看 URL 决定渲染什么
  • MPA 的 history-api-fallback:对已存在的 /home.html 不 fallback,但对 / 仍然 fallback 到 /index.html(默认规则)

所以 / 访问的是登录页 HTML,问题出在登录页 bundle 的运行时错误。

SPA vs MPA 对比

            SPA                              MPA
   ┌────────────────┐               ┌────────────────┐
   │  index.html    │               │  index.html    │ ← 登录
   └───────┬────────┘               │  home.html     │ ← 主应用
           │ JS 里做路由              │  report.html   │ ← 报表(假设)
           ▼                          └────────────────┘
   ┌────────────────┐                    每页独立
   │ vue-router     │                    JS/CSS bundle
   │ /login         │                    不共享内存
   │ /home          │
   │ /report        │
   └────────────────┘
   一个 HTML,内存共享              多个 HTML,完全隔离

本项目为什么用 MPA

推测原因(没有文档说明):

  1. 安全/权限隔离:登录页和主应用 bundle 不打在一起,未登录用户下载不到主应用的 JS
  2. 首屏优化:登录页只带最少代码,加载极快
  3. 遗留:有些老系统从 jQuery + 多 HTML 的架构演进到 Vue,保留了多入口的形态
  4. 后端路由接管:不同 HTML 对应不同后端路径,后端直接返回 HTML,不做前端路由

三层做法对比(遇到白屏怎么排)

❌ 初级:看到 / 空白 → 随便换个路径试试 → 瞎猜

⚠️ 中级:看到 / 空白 → 以为是 MPA 根路径无 HTML → 直接跳去 /home.html 
        → 但 /home.html 可能也白屏(同一个 JS 错误)→ 被误导

✅ 资深:先 curl / 看 HTTP code 和 HTML 内容 → 确认服务端是否给了内容 → 
        200 + HTML 说明服务 OK,问题在浏览器端 → 打开控制台找 JS 运行时错误
        → 错误不在路由层,而在 bundle 本身
        (这个排错顺序:先分清"服务没返回内容"还是"浏览器没渲染内容")

相关代码位置

  • build/utils.js 里的 entries() 函数 — 自动扫描 pages/ 生成入口
  • build/utils.js 里的 htmlPlugin() 函数 — 每个入口生成一个 HtmlWebpackPlugin
  • src/pages/index/index.vue — 登录页
  • src/pages/home/home.vue — 主应用壳,里面有 <router-view>
  • src/router/index.js — 主应用内部的 vue-router(二级路由,独立于 MPA 页面级路由)

面试角度

面试话术:"这个项目是多页应用,每个 pages/xxx/ 文件夹是独立的 webpack 入口,产出独立的 HTML。MPA 的典型场景是登录页和主应用隔离——登录页 bundle 极小,未登录用户拿不到主应用代码,比 SPA 做登录守卫更彻底。代价是页面间切换要走完整 HTML 加载,失去 SPA 的瞬时切换体验,也没法共享 Vuex/Redux 内存状态。这个项目在主应用内部还用了 vue-router 做二级路由,相当于 MPA + SPA 的混合结构。一个排错经验:遇到根路径白屏不要先怀疑 MPA 路由问题,先 curl 看服务端有没有返回内容——如果有内容,100% 是前端 JS 运行时错误,和路由无关。"

延伸讨论

  • "现代怎么做登录隔离?" → Next.js/Nuxt 的 middleware、server actions,可以在同一个 SPA 里做强权限隔离。
  • "MPA 在 2026 还值得用吗?" → 小项目不值;大型后台(权限域泾渭分明)或嵌入式页面(微前端)可能仍然用。

思考题讨论

Q1:MPA 和微前端是什么关系?

用户盲区:以为 MPA 和微前端是同一种东西。

正确解答

两个概念有交集但不相同

MPA(Multi-Page Application):
  定义:一个项目内多个 HTML 入口
  构建:同一个 webpack 构建产出多个 bundle
  边界:代码仓库、团队、构建都是一体的
  隔离:每页独立 runtime,但共享构建工具链
  
  典型场景:
  - 老项目从 jQuery 迁 Vue 时保留多 HTML
  - 强权限隔离(登录 vs 主应用)

微前端(Micro Frontend):
  定义:多个独立应用组合成一个"门户"
  构建:每个子应用独立构建、独立部署
  边界:不同团队、不同仓库、不同技术栈
  隔离:完全隔离(运行时 + 构建时)
  
  典型场景:
  - 大公司内部平台(钉钉、飞书后台)
  - 不同团队独立迭代,集成在一个入口

对比:
               MPA          微前端
  代码仓库     一个          多个
  团队         一个          多个
  技术栈       统一          可不同
  构建         统一          独立
  部署         统一          独立
  运行时隔离   强            极强
  跨子应用通信 JS-level      需要 eventBus/shared store
  学习曲线     低            高

演进路径

单体 SPA
  ↓ (多个页面要隔离运行)
MPA
  ↓ (多个团队要独立部署)
微前端

本项目在 MPA 阶段。如果未来需要不同团队接入子模块,才有必要上微前端。

Q2:为什么现代框架(Next.js)用 MPA 思想?

用户盲区:觉得 MPA 是过时架构。

正确解答

现代 SSR 框架实际上是"MPA + SPA 混合体"

Next.js 的架构(App Router 后):
  
  表面上像 SPA:
    用户点击链接 → 客户端路由更新
    不刷新页面、瞬时切换
  
  实际上是 MPA:
    每个 URL 对应一个独立的服务端渲染单元
    每页有独立的 HTML + 独立的 RSC 数据流
    相同 URL 在不同会话中服务端独立渲染
  
  好处:
    SSR 的 SEO 友好 + SPA 的交互流畅
    按 URL 分片的代码(自动 code splitting)
    服务端组件和客户端组件的天然边界

MPA 思想的回归体现

Next.js 的 pages/(旧)和 app/(新)目录结构:
  pages/index.tsx     → /
  pages/about.tsx     → /about
  pages/blog/[id].tsx → /blog/:id
  
  每个文件 = 一个路由 = 一个独立的打包单元
  → 这就是 MPA 的思想:文件路径即路由

对比纯 SPA

纯 SPA (React 16 + react-router):
  所有路由写在一个 routes.ts 文件里
  构建产物是一个大 bundle
  → 首屏加载体积大
  → 路由和代码分离

现代 MPA 思想 (Next.js):
  路由和代码对齐(约定优于配置)
  每页独立打包
  → 按需加载
  → 更好的缓存

启示技术会螺旋式前进。SPA 革了 MPA 的命,但现在最成功的 SSR 框架又吸收了 MPA 的核心思想。资深工程师要看清架构底层的规律(按需加载、路由即文件、权限隔离),而不是"新 vs 旧"的标签。

Q3:MPA 下怎么共享代码?公共依赖会重复打包吗?

用户盲区:不清楚 webpack 的 CommonsChunkPlugin / splitChunks。

正确解答

默认会重复打包,但可以配置共享

MPA 的基础打包(无优化):
  home.js bundle:
    - home.js 代码
    - vue、axios、lodash(全量)
  
  index.js bundle:
    - index.js 代码
    - vue、axios、lodash(全量,重复!)

  总体积 = 实际代码 + 公共依赖 × N

优化方案 1:webpack 2 的 CommonsChunkPlugin
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: (module) => /node_modules/.test(module.context)
  })
  
  产出:
    vendor.js       ← vue、axios、lodash 等公共依赖
    home.js         ← 只含业务代码
    index.js        ← 只含业务代码
  
  HTML 里:
    <script src="vendor.js"></script>  ← 所有页面共用
    <script src="home.js"></script>

优化方案 2:webpack 4+ 的 splitChunks
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /node_modules/,
          name: 'vendor',
          chunks: 'all'
        }
      }
    }
  }
  → 自动化,无需手动配置
  → 更智能的拆分策略

本项目的情况

Vue 2.5 + Webpack 2 时代:
  需要显式配置 CommonsChunkPlugin
  
如果没配 → 每页都有完整的 Vue runtime(~80KB × N 个页面)
如果配了 → 公共 vendor.js 一次加载,缓存效果好

性能思维HTTP 缓存 + MPA 的协同效应

MPA 的隐形优势:
  用户访问 /home.html
  → 下载 home.js + vendor.js
  → vendor.js 缓存在浏览器
  
  用户点链接到 /report.html(页面刷新)
  → 下载 report.js
  → vendor.js 命中浏览器缓存,不重新下载
  → 感知上和 SPA 的切换差不多(vendor 已缓存)

关键:vendor.js 的 contenthash 稳定
  只要依赖版本不变,文件名不变
  永久缓存(Cache-Control: max-age=31536000)

这是 MPA 架构在 HTTP/1 时代的"天然优化"。到了 HTTP/2 + 细粒度 chunk 时代,SPA 能做得更好,但 MPA 也不落下风

面试话术:"MPA 和微前端是两个层次的隔离——MPA 是项目内的 HTML 多入口,微前端是跨团队的应用组合。现代框架如 Next.js 其实吸收了 MPA 的思想——App Router 的文件路径即路由,每个路由独立打包,本质就是 MPA。这说明架构演进是螺旋式的,不是简单的'新取代旧'。我做项目架构设计时不再纠结'选 SPA 还是 MPA',而是问三个问题:路由划分是否清晰?团队边界在哪?权限隔离要多强?答案自然指向合适的架构。老 MPA 项目的优化关键是 CommonsChunkPlugin / splitChunks 配公共 vendor chunk,配合 HTTP 缓存几乎零增量开销——这是 SPA 难做到的天然优势。"


5. 端口冲突排查与进程管理:pkill 的边界

现象

默认端口 8086 被另一个项目(ec-slh2-web)占用。

修复

config/index.js:26port: 8086 改成 port: 8087

踩过的坑:pkill 误杀

中途用 pkill -f "dev-server" 想杀掉旧进程,差点把同事项目的 dev-server 一起杀了(两个项目命令都是 node build/dev-server.js,差别只在 cwd)。

正确的进程过滤姿势

# ❌ 危险:只按命令名匹配
pkill -f "dev-server"

# ⚠️ 部分正确:按命令 + 项目路径关键词
pkill -f "ec-brand-web.*node build/dev-server"
# (前提:pkill -f 匹配的是完整命令行,含 cwd 则能区分)

# ✅ 最安全:先列出候选,人工确认 PID,再 kill
ps aux | grep "node build/dev-server" | grep -v grep
# 找到明确 PID,kill <PID>

三层做法对比

❌ 初级:pkill -f "node" → 把所有 node 进程(包括 VS Code、Claude Code)全杀了

⚠️ 中级:pkill -f "dev-server" → 误杀同事项目
        (命名相同的进程区分不开)

✅ 资深:用 lsof -i :端口 定位进程 → 验证 PID 的工作目录(lsof -p <pid> -a -d cwd)
        → 确认是本项目的再 kill

原理深挖:端口占用的本质

为什么端口会"被占用"?

TCP 端口的本质:
  操作系统内核维护一个"端口 → 进程"的映射表
  一个端口同一时刻只能绑给一个进程(TCP SO_REUSEADDR 除外)
  
bind(8086) 时:
  内核检查 8086 是否已有进程占用
  有 → 返回 EADDRINUSE 错误
  无 → 登记当前进程,占用成功

常见"占用"原因:
  1. 进程还在跑(最常见)
  2. 进程崩了但端口未释放(TCP TIME_WAIT 状态,~2 分钟)
  3. 同一进程的多个 fork 冲突

完整的排查工具链

macOS / Linux 通用

# 方法 1:lsof(list open files)
lsof -i :8086
# 输出:
# COMMAND  PID USER   FD   TYPE DEVICE NODE NAME
# node   12345 mac   22u   IPv4 ... TCP *:8086 (LISTEN)

# 方法 2:netstat
netstat -anv | grep 8086
# 输出 tcp4  0  0  *.8086  *.*  LISTEN  ...

# 方法 3:ss(Linux,更快)
ss -tulpn | grep 8086

# 拿到 PID 后验证工作目录:
lsof -p 12345 -a -d cwd
# 输出当前工作目录,确认是本项目

# 或看完整命令行:
ps -p 12345 -o command

只看特定端口 + 验证 + kill 的组合

# 一条命令搞定
PID=$(lsof -t -i :8086) && \
  echo "Found PID: $PID" && \
  ps -p $PID -o command && \
  read -p "Kill? [y/N] " confirm && \
  [ "$confirm" = "y" ] && kill $PID

信号(Signal)的正确使用

kill 命令其实是发送信号,不是"杀死"

常见信号:
  SIGTERM (15) - 优雅终止(默认)
    进程有机会清理资源(关闭文件、保存状态)
    kill $PID 或 kill -TERM $PID
    
  SIGINT (2) - 中断(Ctrl+C 发的就是这个)
    通常和 SIGTERM 行为相同
    kill -INT $PID
    
  SIGKILL (9) - 强制终止
    内核直接移除进程,无清理机会
    进程可能留下 orphaned 子进程、未关闭的 socket
    kill -9 $PID   ← 最后手段
    
  SIGHUP (1) - 终端挂起
    很多服务监听这个信号做"reload config"
    nginx、gunicorn 都用这个做热重载
    kill -HUP $PID
    
  SIGSTOP / SIGCONT - 暂停/恢复
    Ctrl+Z 发送 SIGSTOP
    fg 命令发送 SIGCONT

优先级

✅ 先 SIGTERM(kill $PID)→ 等 2-5 秒
❌ 不行再 SIGKILL(kill -9 $PID)
❌ 直接 kill -9 是偷懒 → 数据可能丢失

三层对比:进程管理能力

❌ 初级做法:
  kill -9 <pid>  什么都 -9
  → 服务器可能留下半写数据文件
  → 数据库连接没关,需要等 timeout
  → 同事半天排查"数据库怎么连接数爆了"

⚠️ 中级做法:
  知道 -9 不好,默认 kill
  → 但遇到僵尸进程不知道怎么处理
  → 不会用 SIGHUP 做热重载

✅ 资深做法:
  - 优雅关闭优先(SIGTERM → 等 → SIGKILL)
  - 知道服务支持哪些信号(nginx 的 SIGHUP / SIGUSR1/2)
  - 写服务时注册 signal handler(Node.js 的 process.on('SIGTERM'))
  - 不用 pkill -f 批量操作生产进程

实战:Node.js 服务的优雅关闭

// Node 服务应该这样写
const server = http.createServer(...)
server.listen(8086)

process.on('SIGTERM', () => {
  console.log('Received SIGTERM, shutting down gracefully...')
  
  // 1. 停止接受新连接
  server.close(() => {
    console.log('HTTP server closed')
    
    // 2. 关闭数据库连接
    db.close(() => {
      console.log('DB closed')
      process.exit(0)
    })
  })
  
  // 兜底:如果 30 秒没关完,强制退出
  setTimeout(() => {
    console.error('Forceful shutdown after timeout')
    process.exit(1)
  }, 30000)
})

没这段代码的后果

kill <pid> 发 SIGTERM
→ 进程立刻退出
→ 正在处理的请求被中断
→ 客户端看到"连接被重置"
→ 生产环境可能丢订单、丢支付

有优雅关闭:
kill <pid> 发 SIGTERM
→ 进程标记"不接新请求"
→ 等现有请求处理完
→ 平滑退出
→ 客户端无感知

思考题讨论

Q1:为什么 dev-server 有时候 Ctrl+C 后端口还被占用?

正确解答

可能原因:
  1. 有子进程未退出
     webpack-dev-server 可能 fork 了 workers
     父进程退出但子进程还在 listen 端口
     
  2. TCP TIME_WAIT 状态
     Socket 关闭后进入 TIME_WAIT(~2 分钟)
     期间端口处于"半占用"状态
     macOS 默认开启 SO_REUSEADDR 所以一般没事
     
  3. 程序未处理 SIGINT(Ctrl+C)
     某些 dev-server 不 handle 信号 → 默认行为是立刻退出
     → 子进程可能变成孤儿进程被 launchd / systemd 收养

解决:
  - lsof -i :8086 找 PID,确认是否还活着
  - 找到了 → kill -TERM <pid>
  - 找不到但 bind 失败 → 等 2 分钟或换端口

Q2:lsof、netstat、ss 有什么区别?应该用哪个?

正确解答

lsof (List Open Files):
  设计目的:列出所有打开的"文件"(UNIX 哲学:socket 也是文件)
  功能最广:端口、文件描述符、磁盘文件都能看
  性能:慢(要扫描 /proc/*/fd)
  适用:开发机调试,功能丰富

netstat:
  专门查网络连接
  macOS/Linux 语法略有差异
  Linux 上已 deprecated,替代品是 ss
  性能:中等

ss (Socket Statistics):
  Linux 专用
  性能比 netstat 快 10 倍
  语法简洁
  适用:生产机快速查端口

现代推荐:
  macOS:lsof -i :端口(够用)
  Linux:ss -tulpn | grep 端口
  都不行:fuser 8086/tcp

Q3:pkill vs killall vs kill 有什么区别?

正确解答

kill <pid>:
  按 PID 发信号
  最精确、最安全
  但要先找到 PID

pkill <pattern>:
  按进程名/命令行模式发信号
  支持正则
  危险:模式匹配可能误伤

killall <name>:
  按完整命令名(argv[0])发信号
  比 pkill 更"严格"(精确匹配,不是模式)
  注意:macOS 和 Linux 的 killall 行为不同(macOS 更宽松)

实战:
  pkill -f 'node .*dev-server'    # 匹配完整命令行
  pkill -f '/Users/mac/project-a'  # 匹配 cwd 相关关键字
  
  killall node                     # 所有 node 进程
  killall -9 Chrome                # 强杀所有 Chrome
  
  kill 12345                        # 指定 PID 最安全

资深原则

✅ 生产环境:优先 kill <pid>(可审计)
✅ 开发环境:pkill/killall 方便
❌ 永远不要:pkill -9 -f "node"(容易搞挂整个开发环境)

实用脚本:
  # 列出候选再让我选
  pgrep -l -f 'dev-server'
  # 或者
  ps aux | grep dev-server | grep -v grep

面试话术:"进程管理看似日常工具,但深入下去体现系统思维。kill 不是'杀死'是'发信号'——SIGTERM 优雅、SIGKILL 强杀,两者的区别能决定生产环境有没有数据丢失。Node 服务必须 handle SIGTERM 做优雅关闭(关连接、等请求处理完、再退出),否则 deploy 时会丢请求。日常排查端口占用我用 lsof -i :port 拿 PID + lsof -p pid -a -d cwd 验证工作目录 + kill ——三步保证不误杀。pkill/killall 这种模糊匹配在生产环境要避免,容易搞挂整个环境。这些知识在面试中很少被问到,但是日常踩坑后才会意识到的'系统工程师'视角。"


6. 启动过程关键改动清单

为了让下次能快速复现,完整改动列表:

文件 改动 原因
config/index.js:26 port: 8086 → 8087 和 ec-slh2-web 端口冲突
node_modules/@ecool/web-request/video.js:66 catch {catch (e) { Babel 6 parser 不支持 ES2019
node_modules/@ecool/web-request/video.js:90 uploadResult.data?.org?.[0] → 显式 null 检查 Babel 6 parser 不支持 ES2020 ?.
package.json vod-js-sdk-v6 依赖 @ecool/web-request 的 peer dep
.babelrc transform-optional-catch-binding 插件 无实际作用(parse 阶段就挂了),保留作后续升级准备

未做(但应该做)的事

  • .nvmrc14.21.3,让下个新同事不用再踩 Node 版本坑
  • patch-package 固化 @ecool/web-request/video.js 的 patch,否则下次 npm install 会丢失
  • @ecool/web-request 提 issue:建议库里不使用比项目 babel 更新的 ES 语法,或发布 dist 产物
  • 加 README.md 写清楚本地启动步骤、所需账号去哪拿

附:本次对话中暴露的认知盲点

盲点 现在的理解
"加 babel 插件能处理新语法" 错。插件在 transform 阶段,parser 支持才是前提
"node_modules 的文件 webpack 会自动处理" 对,但"自动处理"的具体策略(CJS vs ESM 识别)依赖文件内容
"多页应用只是文件多一点" 错。MPA 的权限模型、bundle 隔离模型、路由模型都和 SPA 不同
"pkill 按关键字匹配很安全" 错。同名进程在多项目环境下容易误杀,要结合 cwd 或 PID