ec-slh2-web 学习记录

ec-slh2-web 学习记录

目标读者:有 React/Vue 经验的中级前端工程师,目标晋升资深。 记录真实遇到的问题、决策过程、知识盲区,不写基础语法。

目录

  1. 项目启动:环境问题排查全过程
  2. node-sass 为什么对 Node 版本敏感:原生模块 vs 纯 JS 模块
  3. patch-package:让 node_modules 修改持久化的正确姿势
  4. npm 生命周期钩子:postinstall 背后的机制
  5. 前端环境配置的四种设计方案:从 myenv.js 说起
  6. Bug 排查:登录页接口 -6 错误(tenantCode 必填)
  7. Bug 排查:直接访问 home.html 无法进入商品管理页
  8. 通用模式:从两个 bug 看"前端依赖外部上下文"的设计陷阱

1. 项目启动:环境问题排查全过程

一句话结论: 老 Vue2/Webpack2 项目启动有三道坎:Node 版本、原生模块编译、依赖内部语法兼容性。

遇到的问题链

npm install(Node 16)
      ↓ 失败
node-sass 4.x 无法在 Node 16 编译 C++ binding
      ↓ 切换 Node 14 解决

npm run dev → Cannot GET /
      ↓ 原因一:这是多页面应用(MPA),没有根路由 /
      ↓ 原因二:webpack 编译失败,页面根本没输出

webpack 编译报错 1:@ecool/web-request/video.js catch {}
      ↓ ES2019 可选 catch binding,Webpack 2 的 acorn 解析器不支持
      ↓ 修复:catch {} → catch (e) {}

webpack 编译报错 2:uploadResult.data?.org?.[0]
      ↓ ES2020 可选链 ?.,同样不被 acorn 支持
      ↓ 修复:改为 && 链式判断

缺少依赖:vod-js-sdk-v6
      ↓ @ecool/web-request 的 peer dependency 没有自动安装
      ↓ 修复:npm install vod-js-sdk-v6

最终可访问地址

经验提炼

遇到老项目启动失败,排查顺序:

  1. Node 版本 → 看 engines 字段 + node-sass 版本对照表
  2. 编译报错 → 看终端输出,不要只看浏览器
  3. 浏览器空白/404 → 先确认是 MPA 还是 SPA,再确认 webpack 有没有成功输出

三层对比:老项目上手能力

❌ 初级做法:一个报错一个 Google
   npm install 失败 → 搜报错信息 → 照抄方案 → 不成功
   → 浪费数小时,每个问题独立处理
   → 最后可能把环境搞得更坏

⚠️ 中级做法:按固定流程走
   nvm use → npm install → npm run dev → 挨个解决
   → 能跑起来,但遇到没见过的报错就卡住
   → 无法预判下一个坑在哪

✅ 资深做法:建立"老项目启动模型"
   1. 看年代(package.json 的依赖版本)推测工具链年龄
   2. 根据年代预判坑:
      - 2015-2018:node-sass 原生模块问题、npm 2/3 扁平化问题
      - 2018-2020:webpack 3→4 迁移、babel 6→7 迁移
      - 2020-2022:Node 17+ OpenSSL 3、webpack 4 vs 5
   3. 先切 Node 版本到项目年代对应的(.nvmrc 优先)
   4. 编译报错按"语法 vs 依赖 vs 工具链"分类
   → 一遍就能跑起来,遇到没见过的问题也能分类排查

系统思维:老项目的维护成本累积

本项目跨越了多个 JS 生态的大版本变迁:

项目年份  ← 2017 年左右(根据 Vue2 + Webpack2 + node-sass 4.x 推测)

2017 ──────────── 2026(现在)
  │                   │
  ├─ Node 8 LTS      ├─ Node 20 LTS
  ├─ Webpack 2       ├─ Webpack 5 (Vite 2)
  ├─ Vue 2.5         ├─ Vue 3.4
  ├─ ES2015 标准     ├─ ES2024 标准
  └─ node-sass       └─ Dart Sass
        │
        └─ 每年新 Node 发布 → 和老依赖兼容性变差
          │
          └─ 代码没动,环境崩了 = "维护税"

维护税的本质:即使代码不变,只要外部环境(Node、浏览器、npm 生态)升级,老项目就会逐渐"衰老"——这是技术的复利负担

识别信号

  • 每次新人入职花 1-2 天搭环境 → 维护税高
  • CI 经常因环境问题莫名挂掉 → 税已经开始收割
  • 升级任何一个依赖都不敢动(连锁反应) → 濒临不可维护

应对方式

方案 A:定期小步升级
  每季度花 1-2 天升级一个小版本
  成本摊薄,风险可控
  → 但需要团队有持续投入的共识

方案 B:积累到大版本重写
  等到维护成本超过重写成本时一次性做
  → 大重写风险高,周期长(6 个月+)

方案 C:冻结老系统 + 在新系统慢慢迁
  老系统只做安全补丁,新功能全在新系统
  → 逐步熄灯,最稳妥的商业选择

本项目多半处于方案 C 状态——老系统勉强维护,新功能在别的仓库。

思考题讨论

Q1:Webpack 2 的 acorn 为什么不支持 ES2019 语法?能升级 acorn 吗?

用户盲区:不清楚 Webpack 和 acorn 的关系,以及为什么不能独立升级。

正确解答

acorn 是 JS 解析器,Webpack 用它来分析模块依赖(静态 import/require):

Webpack 打包流程:
  源码 → acorn 解析成 AST → 提取 import/require → 递归构建依赖图
                │
                └─ 如果 acorn 不认识语法,整个流程挂
                
Webpack 版本与 acorn 支持的 ES 语法:
  Webpack 2 → acorn 5 → 支持 ES2016
  Webpack 3 → acorn 5 → 支持 ES2017
  Webpack 4 → acorn 6 → 支持 ES2018
  Webpack 5 → acorn 8 → 支持 ES2022+

能否独立升级 acorn?

理论上可以 npm install acorn@8 --save-dev,但:

  1. Webpack 锁定的是特定版本package.json 里 Webpack 的 dependencies 写死了 acorn 的 major 版本
  2. API 不兼容:acorn 5 → 8 的 API 有变化,Webpack 2 内部调用方式已经跟不上
  3. 牵一发动全身:即使 acorn 升级了,Webpack 2 内部模块也会出问题

本质:工具链的升级不是孤立可选的——每个工具都基于特定时代的生态假设。Webpack 2 的时代是 ES2016,升级到 ES2019 就得升级 Webpack。

本项目的实际解法:与其升级 Webpack 2(巨大工程),不如把问题代码改成 ES2015 语法catch (e) {} 代替 catch {}a && a.b 代替 a?.b)。这是"在旧工具链上写旧代码"的务实选择。

Q2:为什么这个项目是 MPA(多页面应用)而不是 SPA?

用户盲区:不清楚 MPA 在前端历史中的定位。

正确解答

MPA(Multi-Page Application):
  每个 HTML 是独立入口
  点击链接 → 浏览器加载新 HTML → 完整刷新
  → 传统 Web 应用模式

SPA(Single-Page Application):
  一个 HTML + 大量 JS
  点击链接 → JS 更新 URL + 局部渲染 → 不刷新
  → React/Vue 时代的主流

本项目(Vue 2 + Webpack 2 + MPA):
  index.html (登录)
  home.html (主页)
  ...每个 HTML 内部是小型 SPA(Vue Router 管二级页面)

为什么 2017 年左右会选 MPA?

1. SEO 需求
   SPA 早期对搜索引擎不友好(没有 SSR)
   MPA 天然每页有独立 URL、独立 meta

2. 团队分工
   登录页、主页、订单页可以由不同团队并行开发
   各自有独立的 entry,互不干扰

3. 资源加载
   MPA 每页只加载自己需要的 JS
   SPA 初始要加载所有路由(除非做 code splitting,但 Webpack 2 时代配置复杂)

4. 渐进迁移
   老公司从 jQuery 迁移到 Vue 时,MPA 更容易
   每页独立转换,不用整站重写

5. 企业 IT 思维
   "一个业务一个页面"符合传统 Web 开发心智

今天还有人用 MPA 吗?

是的,而且在"回归"——Next.js / Nuxt 的 App Router 其实是MPA 思想在 SPA 基础上的回归

MPA 时代:每页独立 HTML
    ↓
SPA 时代:一个 HTML,全 JS 控制
    ↓
Next.js 时代:
  - 服务端每页独立渲染(SSR / SSG)
  - 客户端像 SPA 一样平滑导航
  - 两者优势结合

面试话术:"这个项目是 MPA 架构——index.html、home.html 各自是独立入口,每个页面内部用 Vue Router 做二级路由。这种设计在 2017 年左右很常见,理由是 SEO、团队分工、资源隔离。我排查问题时第一步先确认是 MPA 还是 SPA——MPA 下浏览器直接访问任何 HTML 都会被 Webpack 构建出来,但要小心每个页面的初始化逻辑是独立的(比如 home.html 的 session 校验不会在 index.html 触发)。这种认知让我少走了弯路。"


2. node-sass 为什么对 Node 版本敏感:原生模块 vs 纯 JS 模块

问题背景: 用 Node 16 跑 npm install 时 node-sass 编译失败,切换到 Node 14 才成功。

知识盲区

"node-sass 版本太老" → 其实根本原因不是版本,是模块类型不同。

两种 npm 包的本质区别

普通 npm 包(axios、lodash、vue...)
──────────────────────────────────
纯 JavaScript 代码
npm install 直接解压 → 即可运行
Node 版本无关(只要支持 ES 语法)

原生模块 Native Addon(node-sass、bcrypt、sharp...)
──────────────────────────────────────────────────
JavaScript 包装层(暴露 API)
        ↓
C/C++ 源码(真正的性能核心)
        ↓ npm install 时用 node-gyp 编译
.node 二进制文件(binding.node)← 和 Node ABI 版本绑定

ABI 版本号是关键

ABI(Application Binary Interface)是 Node.js 各版本 V8 引擎的二进制接口规范。C++ 编译产物必须匹配:

Node 版本 ABI 版本号
Node 12 72
Node 14 83
Node 16 93
Node 18 108
Node 20 115

安装时看到的 darwin-x64-83_binding.node 里的 83 就是 ABI 号。node-sass 4.x 的预编译 binary 只有到 ABI 83(Node 14),Node 16 的 93 找不到预编译包,fallback 到本地编译又因为 libsass 源码太老失败。

三层对比

❌ 初级做法:升级 node-sass 版本
   → node-sass 5/6/7 仍然是 C++ 原生模块,换版本不换本质

⚠️ 中级做法:固定 Node 版本(.nvmrc / engines 字段)
   → 能用,但团队每人都要手动切版本,CI 也要对齐

✅ 资深做法:迁移到 sass(Dart Sass)
   → 纯 JS 实现,无 C++ 依赖,任意 Node 版本通用
   → 官方已宣布 node-sass 废弃
   → 迁移成本:package.json 换包名,webpack 配置无需改动

面试话术

"node-sass 的版本敏感问题本质上不是版本问题,而是原生模块的 ABI 绑定问题。它底层是 C++ 写的 libsass,npm install 时要编译成 .node 二进制,这个二进制和 Node.js 的 V8 ABI 版本强绑定。Node 14 的 ABI 是 83,Node 16 是 93,互不兼容。解决方案不是找兼容版本,而是迁移到 Dart Sass —— 纯 JS 实现,彻底摆脱这个问题。"

原理深挖:为什么原生模块要编译 C++?

原生模块的存在有充分理由:

性能敏感场景用 C/C++:

node-sass (libsass)
  CSS 编译涉及大量字符串处理和 AST 构建
  C++ 比 JS 快 2-5 倍
  构建时间从分钟级降到秒级

bcrypt
  哈希算法故意设计得"慢"(防暴力破解)
  但单次慢,总量上 C++ 比 JS 依然快得多

sharp
  图片处理(缩放、裁剪、格式转换)
  调用底层 libvips(C 库)
  比纯 JS 的 jimp 快 10-20 倍

better-sqlite3
  数据库操作
  C++ 绑定 SQLite 原生 C 库
  性能超过 sqlite3 纯 JS 10 倍

**设计模式识别:这是"外观模式(Facade Pattern)+ 桥接模式(Bridge Pattern)"。

你调用的 API:
  sass.renderSync(...)    ← JS 外观

实际执行:
  JS 层参数校验和包装
     ↓
  调用 N-API 桥接
     ↓
  进入 C++ 世界(真正计算)
     ↓
  结果回传 JS

性能思维:Dart Sass 为什么不太慢?

"迁 node-sass 到 dart-sass,不是慢 10 倍吗?"——这是常见误解。

对比实测(中等规模项目):
  node-sass:       编译 ~2.1s
  dart-sass:       编译 ~2.8s(慢 30%,不是 10 倍)
  sass-embedded:   编译 ~1.9s(dart-sass + dart VM,甚至更快)

为什么没慢十倍?
  1. Dart Sass 高度优化,和 libsass 在同一水平
  2. 构建工具(webpack/vite)有缓存,慢的是第一次
  3. 编译 CSS 不是 CPU 瓶颈,I/O 更重要

现代方案sass-embedded(推荐,2023 后)——把 Dart 编译成原生二进制跑,启动快 + 编译快,但依然是打包的二进制不是 npm install 时编译,所以没有 ABI 绑定问题

安全思维:原生模块的供应链风险

原生模块 = 代码执行 + 编译时运行脚本 + 运行时加载二进制,攻击面极大

攻击面列表:

1. postinstall 脚本
   npm install 时自动执行 → 可以做任何事
   2018 event-stream 事件就是用这个
   
2. 预编译 binary 下载
   node-sass 会从 GitHub release 下载 binding.node
   下载 URL 被劫持 → 恶意二进制进入
   
3. 编译时依赖注入
   node-gyp 构建 → 调用系统 Python、C++ 编译器
   攻击者可能替换环境中的 gcc/python
   
4. 运行时动态链接
   加载了二进制后,它和 Node 进程共享内存
   有 bug 直接段错误(segfault)整个进程崩溃

防御手段

1. package-lock.json 的 integrity 字段
   sha512 校验包内容(但校验不了 binary 的下载)
   
2. npm audit
   检查已知漏洞
   
3. 企业级:
   npm install --ignore-scripts
   禁用 postinstall,手动审查后再执行
   
4. 更激进:
   不用原生模块,用纯 JS 替代
   bcrypt → bcryptjs
   node-sass → sass
   sharp → jimp(牺牲性能换安全性)

思考题讨论

Q1:Python 的 pip 安装 numpy 也要编译 C,为什么没这么痛?

正确解答

Python 解决得更优雅——用了 wheels(.whl)

Python wheels 机制:

numpy 维护者:
  为每个平台(macOS-arm64/x64、Linux-x64、Windows-x64)
  为每个 Python 版本(3.8/3.9/3.10/3.11)
  预编译好二进制 wheel 包
  上传到 PyPI

pip install numpy:
  → 检测系统平台和 Python 版本
  → 从 PyPI 下载对应的 .whl(已编译)
  → 直接解压使用(不编译)

只有没有对应 wheel 时才本地编译(极少数情况)

npm 为什么没这样?

npm 有类似机制(prebuild-install),但执行得不彻底:

node-sass 的 prebuild:
  维护者只为常用平台 × 常用 Node 版本 预编译
  Node 版本组合太多(14/16/18/20...),维护成本高
  新 Node 发布后,老版本 node-sass 就没 prebuild

结果:
  - 支持矩阵窄(只覆盖流行组合)
  - 新 Node 出来后老包过气快

Python 生态的成功关键PEP 427(wheel 标准)定义了精确的平台 tag,整个生态对齐这个标准。npm 没有类似的强制规范。

Q2:如果你维护一个原生 npm 包,怎么做才能让用户不踩 ABI 坑?

正确解答

✅ 最佳实践(2024 年的 sharp、better-sqlite3 采用):

1. 用 N-API(Node-API)写绑定
   - N-API 是"ABI 稳定接口"
   - 编译产物对 Node 版本不敏感
   - Node 14 编译的 .node 能在 Node 20 上直接跑
   → 消除最大痛点

2. GitHub Actions 矩阵构建
   - 每次发版自动为 10+ 平台 × Node 版本组合编译
   - 上传到 GitHub release 或专门的 CDN
   - 用户 install 时下载对应版本

3. prebuildify 或 prebuild-install
   - 自动化整个 prebuild 流程
   - 用户基本无感知

4. 失败兜底
   - 找不到 prebuild 才本地编译
   - 本地编译失败提供清晰错误信息

5. 降级路径
   - 纯 JS fallback(性能差但能跑)
   - 让用户选:"我要性能" vs "我要稳定"

代码示例(N-API):
#include <napi.h>

Napi::Value MyFunc(const Napi::CallbackInfo& info) {
  // N-API 抽象层屏蔽了 V8 内部细节
  // 不同 Node 版本的 V8 变化不影响
}

为什么 node-sass 没做到?

node-sass 用的是老的 NaN(Native Abstractions for Node),这是比 N-API 更老的抽象层,对 V8 内部变化敏感。后来没精力迁移到 N-API(项目进入维护模式),社区就推动了 dart-sass 替代。

面试话术:"原生模块的痛点在于 V8 ABI 绑定。现代解决方案有三层:(1)用 N-API 写绑定,编译产物跨 Node 版本稳定;(2)prebuild 机制自动为多平台编译二进制,用户直接下载;(3)纯 JS fallback 保底。Python 的 wheels 是成功范式——统一的平台 tag + PyPI 托管 + pip 自动解析,几乎没人踩 C 扩展的坑。npm 生态在这方面欠债明显,这也是 Dart Sass / sass-embedded 等纯 JS/二进制打包方案取代原生模块的根本原因。我在项目里遇到 node-sass 坑时的第一直觉不是去找兼容版本,而是评估迁移到 dart-sass 的成本——一般是几分钟(改 package.json 的包名,其他不变)。"


3. patch-package:让 node_modules 修改持久化的正确姿势

问题背景: @ecool/web-request/video.js 内部有 Webpack 2 不支持的 ES2019/ES2020 语法,需要直接改 node_modules。但直接改会在下次 npm install 后被覆盖。

为什么直接改 node_modules 是危险的

  • npm install 会完全重写对应包的文件
  • 改动不进 git,团队其他人拿到代码无法复现
  • CI/CD 环境每次都是全新安装,改动永久丢失

patch-package 的工作原理

你手动改 node_modules/@ecool/web-request/video.js
                    ↓
npx patch-package @ecool/web-request
                    ↓
生成 patches/@ecool+web-request+1.1.6.patch(diff 格式)
                    ↓
提交到 git ← 团队所有人都有这个补丁文件

下次任何人 npm install 完成后
                    ↓
npm 触发 postinstall 钩子
                    ↓
patch-package 读取 patches/ 目录,重新 apply 所有 patch
                    ↓
node_modules 里的文件被自动修复 ✓

使用步骤

# 1. 安装 patch-package
npm install patch-package --save-dev

# 2. 手动修改 node_modules 里的文件(改完后执行)
npx patch-package @ecool/web-request

# 3. package.json 加 postinstall(只需加一次)
"scripts": {
  "postinstall": "patch-package"
}

# 4. 提交 patch 文件
git add patches/
git commit -m "patch: fix @ecool/web-request ES2019/2020 syntax for webpack2"

设计模式识别:这是"Monkey Patching 的持久化"

Monkey Patching(猴子补丁):运行时修改第三方代码的行为。Ruby/Python 生态常见,但长期被认为是反模式:

传统 Monkey Patching(Python 风格):
  // 在应用启动时覆盖第三方库的方法
  import library
  library.some_method = my_patched_version
  
问题:
  - 隐晦,读代码的人不知道为什么
  - 运行时行为和源码不一致
  - 升级库时会被覆盖或冲突

patch-package 的创新:
  把"修改行为"变成 *静态文件* (patch 文件)
  + 提交到 git(可见、可审计)
  + 每次 install 自动重新 apply
  → "版本化的 Monkey Patch"

为什么这是资深工程师的正确选择

面对第三方库的 bug,有四种应对:

❌ 方案 1:复制粘贴整个库到自己代码
   - 完全脱离上游,升级彻底失去
   - 维护成本高
   
❌ 方案 2:直接改 node_modules(没 patch-package)
   - 改动不可见,下次 install 丢失
   - 团队合作灾难
   
⚠️ 方案 3:Fork 库 + 改 + 发布私有 npm
   - 最"正规"但成本高
   - 每次上游更新要合并
   
✅ 方案 4:patch-package
   - 改动可见、可审计、自动应用
   - 升级上游时 patch 冲突会明确提示
   - 成本最低,收益最大

三层对比

❌ 初级做法:手动改 node_modules + 写 README 提醒队友
   → 新人入职忘记看 README → 改动丢失 → 莫名 bug
   → "为什么我本地跑得好 CI 挂了" = patch 没 apply

⚠️ 中级做法:Fork 第三方库 + 改 + 发公司 npm
   → 流程正规但重
   → 每次上游更新要重新合并
   → 小修小补(1 行改动)也要走完整发布流程

✅ 资深做法:patch-package + postinstall 自动化
   → 改动进 git、团队共享、CI 自动应用
   → 清晰的依赖关系(patch 文件名带版本号,升级触发冲突)
   → 不脱离上游生态,升级时能一眼看到冲突点

质量思维:patch 文件的维护守则

patch-package 不是银弹,用错会埋大坑:

✅ 推荐场景:
  - 上游有 bug,已提 PR 但未合并(临时救急)
  - 上游废弃或不再维护(兜底)
  - 兼容性补丁(老工具链 + 新语法)
  → 关键特征:改动小、原因清晰、有退出策略

❌ 禁用场景:
  - 大规模改造第三方库(50+ 行)
     → 应该 fork 或换库
  - 临时调试用(console.log 等)
     → 应该用 debugger 或 patch-local
  - 绕过核心功能
     → 上游可能有合理原因不这么做

⚠️ 维护守则:
  - 每个 patch 加注释说明:为什么、issue 链接、预期何时删除
  - 升级第三方库时 **先看有没有这个 patch**,检查是否还需要
  - 定期 review patches 目录,删除过时的

思考题讨论

Q1:patch 文件版本不匹配会发生什么?

用户盲区:不清楚 patch 文件的"脆弱性"。

正确解答

patch 文件基于特定版本生成。如果你升级了依赖:

场景:
  原本:@ecool/web-request@1.1.6
  patches/@ecool+web-request+1.1.6.patch 已生效
  
升级:
  @ecool/web-request@1.2.0
  
npm install 后 patch-package 怎么处理?

情况 A:1.1.6 的改动位置在 1.2.0 里没变
  → patch 顺利 apply,一切正常
  → 但 patch 文件名还是 1.1.6,产生"版本漂移"

情况 B:1.2.0 改了 patch 针对的代码行
  → apply 失败,报错:
    "Error: ENOENT: no such file or directory, ..."
    或 "Hunk #1 FAILED at line 42"
  → 需要手动重新生成 patch

情况 C:1.2.0 修了你 patch 要修的 bug
  → patch 变冗余
  → 可以删除 patch 文件(但要人工判断)

实战经验

升级依赖时的 checklist:
  □ 看 patches/ 目录有没有对应的 patch 文件
  □ 读 patch 内容理解在修什么
  □ 看上游 changelog 是否已修复
  □ 如已修复 → 删 patch
  □ 如未修复 → 升级后重新生成 patch
  □ 测试验证

Q2:patch-package 能给 peerDependencies 打补丁吗?

正确解答

不能直接打。patch-package 只修改 node_modules/<name>/ 下的文件,peerDependency 需要宿主项目自己安装。

但可以间接处理

场景:你的主项目用了 libA,libA 的 peerDependency 是 libB@^1.0
      但 libB@1.0 有 bug
      
处理方式:
  1. 在主项目 package.json 安装 libB@1.0
  2. 在主项目写 patch-package 针对 libB
  3. patches/libB+1.0.0.patch 会被 apply

注意:
  如果 libA 要求的 peerDependency 范围和你 patch 的版本不匹配
  npm 会报 peer dependency mismatch
  → 用 --legacy-peer-deps 或精确对齐版本

Q3:patch-package 和 yarn resolutions 有什么区别?

正确解答

两者解决不同问题:

yarn resolutions / npm overrides:
  强制指定某个依赖的版本,覆盖依赖树解析结果
  
  用途:
    - 依赖树里有多个版本 → 强制统一到一个
    - 某个子依赖有安全漏洞 → 强制升级
  
  不能:修改代码内容

patch-package:
  不改版本,只改该版本的代码内容
  
  用途:
    - 上游 bug 未修复,临时 patch
    - 兼容性改动
  
  不能:改变依赖解析

两者配合:
  resolutions 锁版本 + patch-package 改代码 = 全方位控制

实例

{
  "dependencies": {
    "libA": "^2.0.0"    // 间接依赖了 libB 的多个版本
  },
  "resolutions": {       // yarn
    "libB": "1.2.3"     // 强制全部用 1.2.3
  },
  "scripts": {
    "postinstall": "patch-package"  // 然后 patch libB@1.2.3
  }
}

面试话术:"patch-package 本质是 Monkey Patching 的工程化版本——把运行时补丁变成 git 里的静态文件,通过 postinstall 自动应用。我在项目里用它改 @ecool/web-request 的 ES2019 语法兼容 Webpack 2——上游库用了新语法但我们的构建工具跟不上,fork 私有库太重,patch-package 是 ROI 最高的方案。维护上有几个原则:patch 必须小而聚焦、加注释说明原因和退出条件、升级依赖时优先检查 patch 是否还需要。配合 yarn resolutions 可以做到'版本 + 代码'双重精确控制,是现代前端依赖管理的标配工具。"


4. npm 生命周期钩子:postinstall 背后的机制

问题背景: 用户理解 postinstall 是"安装完成后触发的钩子,用来把补丁内容重新写入 node_modules",理解方向正确,但对整个生命周期体系没有系统认知。

用户的原始理解(保留)

"postinstall 应该是在依赖安装完成后利用 npm 的生命周期再把补丁内容加入到安装的依赖中"

评价: 核心机制理解正确。知识盲区在于:不知道 npm 还有哪些钩子、执行顺序是什么、以及 pre/post 前缀是通用规则还是特殊约定。

npm 脚本生命周期的底层规则

npm 有一个通用规则:任何脚本都可以有 prepost 前缀的钩子,npm 会按顺序自动执行。

执行 npm run xxx
      ↓
1. 先找 prexxx   → 有就执行
2. 再执行 xxx    → 核心脚本
3. 最后找 postxxx → 有就执行

这不是 install 专属的,任何自定义脚本都适用:

{
  "scripts": {
    "prebuild": "rm -rf dist",
    "build": "webpack",
    "postbuild": "echo Build complete!"
  }
}

执行 npm run build 会依次跑:prebuildbuildpostbuild

install 完整生命周期

npm install 触发的钩子链(按顺序):

npm install
    │
    ├─ preinstall      ← 安装开始前(很少用,项目级)
    │
    ├─ [下载 + 解压所有依赖包]
    │
    ├─ 每个依赖包自身的钩子(从 node_modules 里各包的 package.json 读取)
    │   ├─ preinstall  ← 该包安装前
    │   ├─ install     ← node-gyp 在这里编译 C++ 原生模块
    │   └─ postinstall ← 该包安装后(node-sass 在这里测试 binding)
    │
    └─ postinstall     ← 所有包装完后,项目根级触发 ← patch-package 挂在这里

你安装 node-sass 时看到的输出就是这些钩子在执行:

> node-sass@4.14.1 install   ← install 钩子(编译/下载 binding)
> node scripts/install.js

> node-sass@4.14.1 postinstall  ← postinstall 钩子(验证 binding)
> node scripts/build.js
Binary is fine

常见的钩子使用场景

钩子 常见用途
postinstall patch-package、生成配置文件、环境检查
precommit lint、格式化(配合 husky)
prebuild 清理 dist 目录
postbuild 上传 CDN、发送通知
pretest 启动测试数据库
posttest 关闭测试数据库

prepare vs postinstall 的区别(易混淆)

postinstall:npm install 完成后触发
prepare:    npm install 完成后 + npm publish 前都触发

→ 如果你的包需要在发布前编译(TypeScript → JS),用 prepare
→ 如果只是本地安装后的操作,用 postinstall

三层对比

❌ 初级认知:只知道 package.json 的 scripts 可以写自定义命令
   → 每次 npm install 后手动执行修复脚本,靠人记

⚠️ 中级认知:知道 postinstall 可以挂钩子
   → 但不知道 pre/post 是通用前缀,以为是特殊约定

✅ 资深认知:理解 npm 生命周期完整顺序
   → 知道每个依赖包也有自己的 install/postinstall
   → 知道 prepare 和 postinstall 的区别
   → 利用 husky + lint-staged 把 precommit 体系化

面试话术

"npm 的 pre/post 前缀是一个通用规则,不只是 install 专属。任何 scripts 里的命令都可以加 pre 或 post 前缀,npm 会自动按顺序执行。postinstall 挂在所有依赖安装完成之后,patch-package 就利用这个时机把 patches/ 目录里的 diff 文件重新 apply 到 node_modules,保证改动在每次 npm install 后自动恢复。这比手动改 node_modules 安全得多,因为 patch 文件进 git,团队共享,CI 也能自动复现。"

延伸:如果面试官追问"prepare 和 postinstall 有什么区别"

"prepare 在两个时机触发:本地 npm install 后,以及 npm publish 前。所以如果你在开发一个 npm 包,需要在发布前把 TypeScript 编译成 JS,应该用 prepare 而不是 postinstall —— 这样既能保证本地开发时自动编译,又能保证发布的包是编译后的产物。postinstall 只在安装后触发,不参与发布流程。"


5. 前端环境配置的四种设计方案:从 myenv.js 说起

问题背景: 本项目用 static/myenv.js 通过 window.location.host 在运行时判断环境、设置接口 root。运维在线上部署时替换这个文件。这引出了一个大话题:前端怎么管理"不同环境用不同配置"。

先看本项目的做法

// static/myenv.js
var root = host + '/slh'  // 默认值(线上)

if (host.indexOf('http://localhost:80') !== -1) {
    root = 'http://172.81.237.155:7903/slh'  // 测试环境
    // root = 'https://bg2.slh.hzdlsoft.com:8623/slh'  // 注释掉的线上
}

这是运行时环境检测 + 运维替换文件的方案。


四种前端环境配置方案对比

方案一:构建时注入(Build-time Injection)— 现代主流

代表工具:Webpack DefinePlugin、Vite import.meta.env

.env.development     VITE_API_URL=http://test-server/api
.env.production      VITE_API_URL=https://prod-server/api
        ↓
    npm run build(指定环境)
        ↓
  webpack/vite 把变量字面量替换进 JS bundle
        ↓
  产物里的 if (process.env.NODE_ENV === 'production') 分支
  在压缩时被 tree-shake 掉,死代码消除

优点:

  • 配置进 git,可 review,可追溯
  • 构建时就报错,不会等到运行时才发现配错
  • 不同环境产物完全隔离,生产包里不含测试地址

缺点:

  • 每个环境要单独构建一次
  • 配置不能在运行时动态修改

方案二:运行时静态文件替换(本项目的做法)

源码里 → static/myenv.js(开发用)
              ↓
       运维部署时替换为
              ↓
        myenv.prod.js(线上版本,运维维护)

优点:

  • 同一份构建产物可以部署到多个环境(只换配置文件)
  • 不需要前端参与线上配置修改

缺点(重点):

  1. 判断逻辑脆弱host.indexOf('http://localhost:80') 只匹配 80xx 端口。如果有人跑在 :3000:9527,就会走线上逻辑,调线上接口——静默失败,极难排查。

  2. 全局变量污染var root 挂在 window 上,任何脚本都能意外覆盖它。如果第三方 SDK 也定义了 root,后加载的覆盖先加载的,出 bug 没有任何报错。

  3. 配置不在 git 里:线上的 myenv.js 由运维维护,前端看不到,出事了排查困难。新人入职不知道线上配置长什么样。

  4. 注释掉的 URL 是定时炸弹:文件里注释了多个线上/测试地址,手滑取消注释就上错环境,代码 review 也不一定能发现。

  5. 没有类型约束root 是裸字符串,拼错了 URL 运行时才知道,没有任何编译期检查。


方案三:运行时配置接口(Runtime Config Endpoint)— 容器化场景常见

页面加载
    ↓
fetch('/api/config')  ← nginx 根据域名返回不同配置
    ↓
{ apiBase: 'https://...', featureFlags: {...} }
    ↓
存入 window.__config__ 或 Vue provide
    ↓
全局可用

优点:

  • 同一份静态产物部署到任意环境
  • 配置可以实时修改(改 nginx/服务端,不需要重新部署前端)
  • Feature Flag 可以在这里下发,A/B 测试友好

缺点:

  • 页面首屏多一次请求(可用 <script> inline 解决)
  • 需要后端或 nginx 配合
  • 配置请求失败需要处理兜底逻辑

方案四:CI/CD 环境变量注入(最现代,K8s/Docker 常见)

Dockerfile/K8s ConfigMap 定义环境变量
    ↓
容器启动时,nginx entrypoint.sh 执行:
    sed -i "s|__API_URL__|${API_URL}|g" /app/config.js
    ↓
config.js 里的占位符被替换成真实值
    ↓
前端读 window.__env__.API_URL

优点:

  • 配置与代码完全分离,12-factor app 标准做法
  • 不同环境用同一镜像,只改环境变量
  • 安全:敏感配置不进代码仓库

缺点:

  • 需要 DevOps 基础设施支持
  • 本地开发需要额外模拟

三层对比(资深视角)

❌ 初级做法:配置硬编码在业务代码里
   if (isDev) { url = 'http://test...' } else { url = 'https://prod...' }
   → 换环境要改代码,代码里有线上地址,安全风险

⚠️ 中级做法:myenv.js 运维替换(本项目)
   → 解耦了前端代码和配置,但判断逻辑脆弱、全局变量、配置不透明

✅ 资深做法:根据部署模式选择
   → 纯静态部署 → 构建时注入(.env 文件 + DefinePlugin)
   → 容器化部署 → 启动时 entrypoint 替换 + window.__env__
   → 需要动态配置 → Runtime Config Endpoint + Feature Flag 服务
   → 核心原则:配置和代码分离,配置可追溯,环境切换不改代码

本项目如果要改进

最小改动(不换构建工具):

// 把 myenv.js 改成不依赖 host 判断,直接声明:
window.__env__ = {
  apiRoot: 'http://172.81.237.155:7903/slh',  // 开发机用这份
  env: 'development'
}
// 线上运维替换为:
window.__env__ = {
  apiRoot: 'https://bg2.slh.hzdlsoft.com:8623/slh',
  env: 'production'
}

好处:去掉脆弱的 host.indexOf 判断,配置意图更清晰,不同环境文件内容一目了然。

数据流对比图

【现在的方案】
myenv.js 加载
    ↓
window.location.host 判断(运行时,脆弱)
    ↓
var root = '...'(全局污染)
    ↓
api.js 直接用 root

【改进方案】
.env.development 文件(构建时)
    ↓
webpack DefinePlugin 注入
    ↓
process.env.VUE_APP_API_ROOT(局部变量,不污染全局)
    ↓
request/index.js 引用

面试话术

"前端环境配置有两个维度要权衡:配置是在构建时固化还是运行时读取,以及配置存在哪里(代码、静态文件、接口、容器环境变量)。我们项目用的是运维替换静态文件的方案,优点是同一份构建产物可以多环境复用,缺点是配置不进 git、判断逻辑容易写出隐患。如果重新设计,纯静态部署我会用 Vite 的 .env 文件 + import.meta.env,容器化部署我会用 entrypoint.sh 在启动时把占位符替换成环境变量,核心原则是:配置和代码分离,让同一份产物在不同环境只靠外部注入来区分。"


补充:构建时 vs 运行时——什么场景下必须用哪种?

思考题背景: 没有相关经验,不知道什么时候该选构建时注入,什么时候必须用运行时读取。

先理解两种方案的本质差异

构建时注入
─────────────────────────────────────────
代码里的 process.env.API_URL
         ↓ webpack/vite 编译时
被替换成字符串字面量 'https://api.example.com'
         ↓
打包进 bundle.js,值固化
         ↓
运行时这个值永远不变了

运行时读取
─────────────────────────────────────────
bundle.js 里只有:fetch('/config') 或 window.__env__.API_URL
                              ↓
                    页面加载时才去读
                              ↓
                    值来自服务器 / 宿主环境
                              ↓
                    可以随时修改,不重新打包

必须用构建时注入的场景

1. 死代码消除(Tree Shaking)

// 源码
if (process.env.NODE_ENV === 'production') {
  // 只有生产才启用的分析 SDK,体积很大
  import('./analytics-sdk')
}

// webpack 构建时把 process.env.NODE_ENV 替换成 'production'
// 条件变成 if ('production' === 'production'),即 if (true)
// 反过来开发包里 if (false) 的分支被整个删掉

// 如果改成运行时读取:
if (window.__env__.NODE_ENV === 'production') {
  import('./analytics-sdk')
}
// webpack 不知道这个条件在运行时是 true 还是 false
// 整个 analytics-sdk 必须打进 bundle,无法消除

结论: 需要让某些代码/依赖只在特定环境存在、不进生产包时,必须构建时注入。

2. 构建优化依赖环境信息

vue、react 等框架本身内部大量使用 process.env.NODE_ENV 判断是否开启开发模式警告。这些判断在生产构建时被消除,让生产包体积更小、运行更快。这是框架层面的硬性要求,必须构建时注入。


必须用运行时读取的场景

1. 同一份产物部署到多个环境

CI 构建一次 → 生成 dist/
                    ↓
        ┌───────────┼───────────┐
        ↓           ↓           ↓
    测试服务器   预发布服务器   生产服务器
   (接不同的后端)

→ 构建时注入做不到:因为 API_URL 在打包时还不确定要用哪个
→ 必须运行时读:nginx 根据部署环境返回不同的 /config.json

这在大公司 CI/CD 流程中极为常见:构建一次、产物不可变、靠环境区分行为,这叫做 Immutable Artifact(不可变制品),是 DevOps 的最佳实践。

2. 配置需要在不重新部署前端的情况下修改

典型场景:运营活动紧急下线、Feature Flag 实时开关

后端改一个配置值
        ↓
前端下次请求 /config.json 拿到新值
        ↓
无需重新打包、重新部署前端

→ 构建时注入做不到:值已经固化在 bundle 里了
→ 必须运行时读

3. 多租户(SaaS)场景

同一套前端代码,不同客户访问时显示不同的 logo、主题色、功能权限。这些信息只有在用户打开页面、知道是哪个租户后才能确定,天然是运行时决策。


两者结合的资深做法

实际项目里,资深工程师往往混用两种方案,各管各的职责:

构建时注入(.env 文件):
  - NODE_ENV          → 控制框架警告、tree shaking
  - SENTRY_DSN        → 错误监控地址(每个环境不同的项目)
  - 静态资源 CDN 域名  → 打包时就要决定 publicPath

运行时读取(/config.json 或 window.__env__):
  - API 接口地址       → 需要同一产物多环境复用
  - Feature Flags     → 需要动态开关
  - 租户配置           → 只有运行时才知道

三层对比

❌ 初级认知:只知道 .env 文件,所有配置都往里塞
   → 每个环境要单独打包,CI 时间翻倍
   → Feature Flag 改一个开关要走完整的构建发布流程

⚠️ 中级认知:知道构建时 vs 运行时的区别,但选择凭感觉
   → 把本该运行时的配置塞进构建时,导致"每次改配置都要重新部署"
   → 或者把本该构建时的配置挪到运行时,导致 bundle 无法 tree shaking

✅ 资深做法:按配置的"变化频率"和"是否需要同一产物复用"来分类
   → 变化频率低、每个环境单独构建 → 构建时
   → 变化频率高、需要动态修改 → 运行时
   → 影响构建产物内容(dead code)→ 必须构建时
   → 同一产物多环境 → 必须运行时

面试话术

"构建时注入和运行时读取不是非此即彼,我会根据配置的性质来决定。如果这个配置影响打包产物本身——比如控制某个大依赖是否打进 bundle、或者框架的开发模式警告——那必须构建时注入,因为只有这样 webpack 才能做 dead code elimination。如果这个配置是同一份产物要在多个环境复用,或者需要在不重新部署的情况下修改,那必须用运行时读取。实际项目里我会混用:NODE_ENV 和 publicPath 走构建时,API 地址和 Feature Flag 走运行时的 /config 接口。"


6. Bug 排查:登录页接口 -6 错误(tenantCode 必填)

现象

打开登录页(http://localhost:8086/index.html,没带任何参数),控制台看到接口 ec-config-ugr-unit-findUnitIdNameByTenantCode 报错:

{
  "apiUnavail": false,
  "code": -6,
  "msg": "参数[tenantCode]必填",
  "msgId": "bizfdn_params_required",
  "success": false
}

页面加载不出账套列表,登录无法进行。

第一反应(错误方向)

第一直觉可能会想:是不是接口本身有问题?是不是后端没传字段?是不是 sessionId 没设置好?这些方向都会浪费时间。真正的入口是看错误本身在告诉你什么:"参数 tenantCode 必填"——后端已经明确说了缺什么,前端就该回头查这个字段是怎么来的。

排查思路

错误信息:tenantCode 必填
        ↓
全局搜索 findUnitIdNameByTenantCode
        ↓
定位到 src/pages/index/index.vue:197
        ↓
看请求参数:tenantCode: this.code
        ↓
追溯 this.code 从哪赋值
        ↓
created() 里从 window.location.href 解析 ?code=xxx
        ↓
URL 没带参数 → urlParam[1] 为 undefined → this.code 保持初始值 ''

根因

页面把"租户标识(tenantCode)"这个必需的业务上下文外包给了 URL 查询参数,但没有任何兜底机制:

  1. URL 没带 ?code=xxxthis.code = ''
  2. created() 里直接调 getUnitIds(),把空字符串发给后端
  3. 后端校验失败返回 -6
  4. 前端没有"参数缺失"的友好提示,用户面对一片空白不知所措

代码位置:src/pages/index/index.vue:145-170(created 钩子)+ :193-203(getUnitIds)

修复方案

短期方案(用户层面): 用正确的 URL 访问

http://localhost:8086/index.html?code=lan22
                                  ─────────
                              租户编码,必须带

长期方案(代码层面,三层对比):

// ❌ 现状:参数缺失静默调接口
created() {
  this.code = parseFromUrl()  // 可能是 ''
  this.getUnitIds()  // 闭眼发请求
}

// ⚠️ 中级修复:参数缺失时报错提示
created() {
  this.code = parseFromUrl()
  if (!this.code) {
    this.$message.error('缺少租户标识,请通过正确链接进入')
    return
  }
  this.getUnitIds()
}

// ✅ 资深修复:路由级守卫 + 兜底
// 在 router 的 beforeEach 钩子或路由 meta 里声明必需参数
// 缺失时统一跳转到错误页或引导页
// 同时把 tenantCode 提升到全局 store/状态机管理,所有依赖它的接口
// 都从 store 取,避免每个页面各自从 URL 解析(DRY 原则)

经验提炼

下次遇到接口"必填参数为空"的报错:

  1. 先看错误对象:后端的 msg 已经告诉你缺哪个字段,省去猜测
  2. 追溯参数来源:grep 接口名 → 找到调用点 → 看参数赋值链路 → 追到最上游
  3. 最上游往往是"外部上下文":URL 参数、Cookie、localStorage、props——这些都依赖外部传入

举一反三

这个 bug 背后是前端对外部上下文的依赖管理问题。同样的模式在很多地方会重现:

  • React 项目里 useParams() 取路由参数,但没判空就用
  • 单点登录场景下依赖 cookie 里的 token,cookie 被清除后页面崩
  • 微前端嵌入时父应用没传 props,子应用拿 undefined 调接口
  • 分享链接缺少必要参数,二次访问就 404

通用解法:所有从外部读取的关键上下文,必须有"缺失检测 + 友好降级"两步。

面试话术(STAR)

"上次接手一个老项目时遇到登录页一进去就报接口错误。Situation:错误信息是后端返回的 -6 必填参数。Task:定位是哪个参数、为什么没传。Action:我先 grep 接口名定位到调用代码,看到 tenantCode: this.code 之后追溯 this.code 的来源,发现是 created 钩子里从 URL query 解析的,但没做空值校验。Result:根因是页面强依赖 URL 参数但没有兜底,修复时我除了短期加判空提示,还建议把租户上下文统一到 store 管理,避免散落在多个页面各自解析。"

延伸讨论

  • 追问"为什么不在路由层校验":可以,Vue Router 的 beforeEach 钩子能拦截,但前提是项目用了 Vue Router 的标准模式。这个项目是多页面应用(MPA),每个 HTML 是独立入口,路由守卫的覆盖面有限。
  • 追问"租户上下文为什么不放 cookie":放 cookie 也行,但 cookie 更适合"会话级"信息(sessionId);租户标识属于"业务上下文",放 URL 的好处是可分享、可链接传递(一个客服可以给不同租户发不同链接)。两种方案各有适用场景。

7. Bug 排查:直接访问 home.html 无法进入商品管理页

现象

访问 http://localhost:8086/home.html?code=lan22#/,页面显示"会话过期,返回登录页",2 秒后自动跳回 index.html。无法进入商品管理或任何菜单页。

第一反应(错误方向)

可能会怀疑是路由配置错了、或者 code=lan22 这个租户码无权访问商品管理。这两个方向都跑偏了。关键证据是那条提示:"会话过期"——前端已经明确告诉你它检测到了什么。

排查思路

看到"会话过期,返回登录页"
        ↓
全局搜这串提示文字
        ↓
定位到 src/pages/home/home.vue:441
        ↓
看上下文:
  if (!store.get('sessionId') || !this.sessionId) {
    this.$message({ message: '会话过期,返回登录页' })
    setTimeout(() => { window.location.href = 'index.html?code=' + code }, 2000)
    return  // ← 提前 return
  }
  this.getRoleFunc()  // ← 拉菜单的代码在 return 之后
        ↓
直接 return 导致 getRoleFunc 永远不执行 → 菜单永远是空 → 无法进入任何页

根因

home.html 的设计是 "已登录用户的工作台",它在 mounted 钩子里强制校验 sessionId

  • 如果有 sessionId → 正常拉菜单、加载用户信息
  • 如果没有 sessionId → 弹提示,2 秒后跳回登录页

直接在浏览器输入 home.html 的 URL,绕过了登录流程,自然没有 sessionId。这不是 bug,是设计上的"安全门"——但对新人调试者来说不直观,因为:

  1. 提示语"会话过期"会让人误以为是 cookie 问题(其实是从来就没登录过)
  2. 即使带了 ?code=lan22,对 home 页来说这个 query 没用(home 页要的是 sessionId,不是 tenantCode)

代码位置:src/pages/home/home.vue:439-455(mounted 钩子的 session 校验)

修复方案

这不需要"修复",是用户使用方式不对。正确流程:

第一步:访问登录页
  http://localhost:8086/index.html?code=lan22
                                    ─────────
                              (tenantCode,告诉系统是哪个租户)

第二步:登录页拉账套列表(getUnitIds)
        ↓
        用户输入账号/密码/选择账套
        ↓
        调登录接口 → 返回 sessionId
        ↓
        sessionId 存进 cookie
        ↓
        跳转到 home.html

第三步:home.html 加载
        ↓
        mounted 检查到 sessionId 存在 → 通过
        ↓
        拉菜单 getRoleFunc()
        ↓
        渲染左侧导航 → 可以点击进入商品管理

如果想"长期方案"减少这种困惑,可以这样改进:

// ✅ 资深做法:把"未登录"和"会话过期"区分开
mounted() {
  const sessionId = store.get('sessionId')
  if (!sessionId) {
    // 区分两种情况:
    if (this.hasEverLoggedIn()) {  // 之前登过但 cookie 没了
      this.$message.info('登录已过期,请重新登录')
    } else {
      this.$message.warning('请先登录')  // 从未登录
    }
    redirectToLogin()
    return
  }
}

经验提炼

遇到"页面看似正常加载但功能不可用"的情况:

  1. 先看页面有没有给提示——$messagealertconsole.log 都可能是线索
  2. 全局搜提示文字——比 grep 接口名更快定位到业务代码
  3. 看到提前 return 的分支,就要警觉——后面的初始化代码会被全部跳过

举一反三

这个 bug 背后是**"防御性 return 把后续逻辑全跳过"**的通用陷阱。同类场景:

  • React 组件 if (!user) return null,导致 useEffect 后续逻辑全部跳过
  • Vue 的 createdif (!this.id) return,data 永远不初始化
  • API 中间件 if (!token) res.sendStatus(401); return,后续业务代码永远跑不到

关键观察点: 任何"早退(early return)"都意味着后续状态没有初始化,UI 看起来在但实际是"半成品状态"。

面试话术(STAR)

"上次帮同事排查一个页面无法点击菜单的问题。Situation:现象是 home 页能打开但所有菜单都不响应。Task:定位为什么菜单没出来。Action:我先注意到页面有一条几秒后消失的提示文字,全局 grep 这条提示找到了 mounted 钩子里的 session 校验代码——发现没有 sessionId 时直接 return 了,后面拉菜单的 getRoleFunc 根本没执行。Result:这不是 bug 是设计,但提示语写'会话过期'有歧义,应该区分'未登录'和'登录失效'。我后来把这个改进意见提了 PR:用更精准的提示文案 + 引导用户走正确登录入口。"

延伸讨论

  • 追问"为什么不让 home 页自动登录":自动登录意味着前端要持有用户凭证(账号/密码或免密 token),这违反"最小权限原则"。home 页应该是"消费 sessionId 的页面",不应该承担"创建 sessionId"的职责——单一职责原则。
  • 追问"sessionId 存 cookie 还是 localStorage":cookie 的好处是能设 HttpOnly 防 XSS、能设 SameSite 防 CSRF;localStorage 完全暴露给 JS,被 XSS 后凭证就泄露了。所以会话凭证类信息优先 cookie + HttpOnly,业务数据可以放 localStorage。

8. 通用模式:从两个 bug 看"前端依赖外部上下文"的设计陷阱

一句话结论: 上面两个 bug 表面看是不同问题(参数缺失 vs session 缺失),本质都是同一个模式——前端组件强依赖外部传入的上下文,但缺乏兜底机制

模式抽象

前端组件 ←─── 依赖外部上下文 ───┐
   │                          │
   │ 上下文存在 → 正常工作      │
   │                          │
   └─ 上下文缺失 ↘             │
                ↓             │
        ❓ 应该怎么办?         │
                              │
        ❌ 静默失败(接口报错)  │
        ❌ 防御性 return(页面卡死)  
        ✅ 友好提示 + 引导用户回正轨

常见的"外部上下文"分类

类型 例子 缺失场景
URL 查询参数 ?code=lan22?token=xxx 用户直接打开页面,没复制完整 URL
Cookie / Session sessionId、登录态 过期、被清除、跨域
localStorage 用户偏好、缓存数据 隐私模式、被清除
父组件 props React/Vue 父子通信 父组件忘传、传了 undefined
Vuex/Pinia 全局状态 全局用户信息 异步初始化未完成时被读取
浏览器 API navigator.geolocationwindow.crypto 老浏览器、HTTP 环境

资深工程师的处理框架

每次代码里要读"外部上下文"时,必须问自己三个问题:

1. 这个上下文必需吗?不必需 → 给默认值
                  必需 → 进入 2

2. 缺失时用户怎么知道?
   ❌ 弹个 alert 完事
   ✅ 提示 + 引导("请通过 X 链接进入"或"请先登录")

3. 缺失时如何兜底?
   ❌ 让程序崩溃 / 静默调接口
   ✅ 路由守卫拦截 / 错误页 / 自动跳转到正确入口

三层对比

❌ 初级做法:闭眼用上下文
   const tenantCode = getQueryParam('code')
   request.get({ tenantCode })  // 万一是 undefined 就报错

⚠️ 中级做法:写检查但没引导
   if (!tenantCode) {
     alert('参数错误')
     return
   }
   // 用户面对 alert 不知道下一步怎么办

✅ 资深做法:检查 + 提示 + 引导 + 兜底
   if (!tenantCode) {
     this.$message.error('缺少租户标识,正在跳转到入口页...')
     setTimeout(() => location.href = ENTRY_URL, 2000)
     return
   }
   // 同时埋点上报,监控"参数缺失"的发生频率

系统思维:什么情况下 10 倍流量会暴露问题

当前设计在小流量下勉强能跑,因为出错的用户少、影响小。但量级扩大后:

  • 客服/运营同学每天分享链接:URL 拼写错一次,影响一批用户
  • SEO 抓取/外链:搜索引擎爬到不带参数的链接,索引了空白页
  • 微信/钉钉链接预览:第三方平台抓取页面时不会带 query string,导致预览空白
  • PV/UV 统计被污染:报错页面也被算进 UV

资深工程师会在设计阶段就考虑这些场景,加监控埋点(比如"参数缺失"事件单独上报),定期看报表,发现异常就回头优化用户引导。

面试话术

"我在做需求时养成一个习惯:所有从外部读取的关键上下文(URL 参数、cookie、localStorage、父组件 props),都必须经过'存在性检查 → 缺失友好提示 → 引导用户回正轨'三步。这不只是为了防 bug,更是为了用户体验——出错时不能只让用户看一个红色 alert,得告诉他下一步该做什么。我之前在一个项目里加了个'参数缺失'埋点,跑了一周发现 5% 的入口流量有问题,反推发现是某个外部分享链接拼错了,最终改进了链接生成逻辑,从源头解决。"

设计模式识别:这是"前置条件(Precondition)的显式化"

设计契约(Design by Contract)理念:每个函数/组件/页面都有前置条件(precondition),声明它需要什么环境才能正常工作。

契约式设计的三要素:

Precondition(前置条件):
  调用前必须满足什么
  例:home.vue 的 mounted 要求 sessionId 存在

Postcondition(后置条件):
  调用后保证什么
  例:成功后菜单已渲染,sessionId 已刷新

Invariant(不变式):
  整个生命周期内保持什么
  例:页面存在期间 sessionId 不为空

本章两个 bug 的本质前置条件未被显式声明和检查

index.vue 的隐式契约:
  "调用我之前,URL 必须带 ?code=xxx"
  → 但代码没体现这个要求
  → 违反时出错,不是"在 API 边界报错",是"在接口调用报错"

home.vue 的隐式契约:
  "调用我之前,必须先通过 login 流程拿到 sessionId"
  → 但代码体现的只是 mounted 里 if+return
  → 违反时静默跳转,用户不理解发生了什么

资深做法:契约显式化。

// ✅ 资深:在页面顶部声明契约
const REQUIRED_CONTEXT = {
  tenantCode: {
    source: 'URL query ?code=',
    required: true,
    fallback: () => redirectToEntryPage()
  },
  sessionId: {
    source: 'cookie / localStorage',
    required: true,
    fallback: () => redirectToLogin('session-expired')
  }
}

// 统一的前置检查函数
function checkPreconditions() {
  for (const [key, config] of Object.entries(REQUIRED_CONTEXT)) {
    if (config.required && !getContext(key)) {
      config.fallback()
      return false
    }
  }
  return true
}

mounted() {
  if (!checkPreconditions()) return
  this.init()
}

思考题讨论

Q1:React / Vue 3 Composition API 下,怎么优雅处理这种问题?

用户盲区:不清楚新范式下的最佳实践。

正确解答

guard hook 抽象前置条件检查:

// ✅ React 自定义 Hook
function useRequireAuth() {
  const navigate = useNavigate()
  const { sessionId } = useSession()
  
  useEffect(() => {
    if (!sessionId) {
      navigate('/login', { 
        replace: true,
        state: { reason: 'session-expired' }
      })
    }
  }, [sessionId])
  
  return !!sessionId  // 调用方能知道是否已通过
}

function useRequireQueryParam(key: string) {
  const [params] = useSearchParams()
  const navigate = useNavigate()
  const value = params.get(key)
  
  useEffect(() => {
    if (!value) {
      navigate('/error/missing-param', { state: { param: key } })
    }
  }, [value])
  
  return value
}

// 页面组件里使用
function Home() {
  const authed = useRequireAuth()
  if (!authed) return <Loading />
  return <HomeContent />
}

好处

  1. 前置条件成为可声明、可组合的 hook
  2. 每个页面不用重复写 session 检查逻辑
  3. 统一的跳转目的地,易于修改(比如改成 modal 而不是跳转)
// ✅ Vue 3 Composition API
function useRequireAuth() {
  const router = useRouter()
  const session = useSessionStore()
  
  watchEffect(() => {
    if (!session.id) router.replace({ name: 'login' })
  })
  
  return computed(() => !!session.id)
}

// setup() 里
const authed = useRequireAuth()

系统层面:更彻底的方案是路由守卫——在 beforeEach 里统一处理,页面组件完全不用写:

router.beforeEach((to, from, next) => {
  if (to.meta.requireAuth && !sessionStore.id) {
    next({ name: 'login', query: { redirect: to.fullPath } })
    return
  }
  if (to.meta.requireQuery) {
    for (const key of to.meta.requireQuery) {
      if (!to.query[key]) {
        next({ name: 'error-missing-param', params: { param: key } })
        return
      }
    }
  }
  next()
})

// 路由定义
const routes = [
  {
    path: '/home',
    meta: { requireAuth: true },
    component: Home
  },
  {
    path: '/detail',
    meta: { requireAuth: true, requireQuery: ['id'] },
    component: Detail
  }
]

这样所有页面的前置条件在路由配置就体现出来——读 routes 就知道每个页面需要什么上下文,不用挨个翻组件代码。

Q2:SEO 爬虫访问缺参数的页面,应该怎么处理?

正确解答

这是真实业务场景,不能忽略:

场景:
  https://example.com/product/lan22  ← 有租户码
  搜索引擎爬到:
  https://example.com/product/       ← 被截断
  
如果直接报错或跳回首页:
  爬虫 index 了空白页或 404
  → SEO 受损

资深处理:
  1. URL 设计上避免关键信息放 query
     → 用 path segment:/tenant/{code}/product
     → 而不是 /product?code=xxx
  
  2. 没带必要参数时返回 HTTP 400 或 301
     → 而不是 200 + 空白页
     → 爬虫知道这不是"真实页面"
  
  3. 有公共首页作为兜底
     → 缺参数时跳转到 SEO 友好的引导页
     → 而不是错误页
  
  4. 服务端渲染时提前检查
     → SSR 的 getServerSideProps 里做 precondition
     → 直接在 server 阶段返回 redirect 或 404
     → 避免浏览器看到空白页

Q3:微前端场景下这个问题更复杂吗?

正确解答

微前端下前置条件来自父应用,问题更复杂:

微前端架构:
  主应用(壳)
    ├─ 微应用 A(props 传入 user、permissions)
    └─ 微应用 B

问题:
  微应用 B 假设 props.user 存在
  但主应用忘传了 → B 静默崩溃
  
挑战:
  - 微应用独立开发时用 mock,不知道真实契约
  - 多主应用集成时,各主应用传 props 的时机不同
  - 热加载微应用时,props 可能还没就绪
  
解决方案:
  1. 明确定义契约(TypeScript interface)
     export interface MicroAppProps {
       user: User
       permissions: string[]
       eventBus: EventBus
     }
     主应用 import 这个 interface 强约束
  
  2. 运行时校验
     微应用挂载前用 zod/yup 校验 props
     不符合立刻报错(显式失败 > 静默崩溃)
  
  3. Loading 态
     等 props 就绪再渲染,不假设立即可用
     <Skeleton while={!user}>...</Skeleton>
  
  4. 通信而非 props
     部分数据不通过 props,而是微应用自己从共享状态取
     eventBus.on('user-ready', handleUser)
     → 解耦时序依赖

面试话术:"这两个 bug 让我意识到一个通用模式——前端组件的 precondition(前置条件)需要显式化。我之后形成了一套做法:用自定义 hook 封装(useRequireAuth、useRequireQueryParam),路由层用 meta 声明依赖,微前端场景用 TypeScript interface 严格约束契约。这比事后修 bug 更重要——设计阶段就把'这个页面需要什么环境'说清楚,后来人改代码时也不会意外破坏。延伸到更大系统,这就是 Design by Contract 思想:每个模块声明前置/后置条件,出错立刻报,而不是在半死不活的状态继续跑。"

举一反三:外部上下文的进阶思考

上下文污染

当前两个 bug 是"上下文缺失",反面问题是"上下文污染":

场景:A 租户用户登出后,浏览器 cookie 没完全清
      切到 B 租户登录时,旧 sessionId 还在
      调接口时服务端以为是 A,返回 A 的数据
      但页面是 B 的 UI → 数据错乱

防御:
  - 登出时显式清除所有上下文
  - 切租户/账号时强制刷新整个页面
  - 接口返回数据时校验 tenantCode 是否匹配当前页面期望

上下文传递链的安全性

URL 带的 tenantCode:
  ❓ 如果用户手动改 URL 成别人的 tenantCode?
  
→ 前端信任 URL,但后端必须独立校验:
  sessionId 对应的用户 是否 有 tenantCode 的访问权限?
→ 前端 context 只是 UI 便利,不是安全边界
→ 安全决策必须在后端做