changelog.local.md - 知识积累与学习记录

changelog.local.md - 知识积累与学习记录

目录

  1. node-sass 编译失败:Xcode 版本、原生模块与跨平台兼容性
  2. 为什么需要本地开发代理:同源策略、CORS 与 pathRewrite
  3. 反向代理、负载均衡、限流、灰度:前端工程师必懂的基础设施

阅读指南

每章采用三段式结构

  • 【核心认知 / 本质】—— 用最少篇幅抓住抽象模式
  • 【思考题讨论】—— 具体案例带入,暴露认知盲区
  • 【详细参考】—— 完整展开,当字典查漏补缺

推荐阅读顺序(从抽象到具体)

  1. 先读 Ch3 的【本质:四个概念的第一性原理】(抽象升级最大,建立通用心智模型)
  2. 再读 Ch2、Ch3 的【思考题讨论】(具体案例,暴露知识盲区)
  3. 最后回查 Ch2、Ch3 的【详细参考】(按需查细节)

1. node-sass 编译失败:Xcode 版本、原生模块与跨平台兼容性

一句话结论

node-sass@4 在新版 Xcode (16/26) + Apple Silicon + Node 16 环境下编译失败,根因是三层不兼容叠加(C++ 标准版本 + V8 头文件 + 编译器严格性),根本解决方案是替换为纯 JS 实现的 sass(Dart Sass)。

现象

/Users/mac/.node-gyp/16.20.2/include/node/v8-internal.h:492:50: error: 'T' does not refer to a value
    !std::is_same<Data, std::remove_cv_t<T>>::value>::Perform(data);

yarn install 在 Building node-sass 阶段失败。

根因

三层不兼容叠加:

node-sass@4 (内置 node-gyp@3.8)
    │ 强制使用 -std=c++11 编译
    ▼
Node 16 的 V8 头文件 (v8-internal.h)
    │ 使用了 C++14 特性 (std::remove_cv_t)
    ▼
Xcode 16/26 的 clang 编译器
    │ 严格遵循 C++ 标准,C++11 模式不暴露 C++14 API
    ▼
  编译失败

为什么老版 Xcode 能编译,新版不行?

老版 clang (Xcode 14 及以下) 对 C++ 标准比较"宽容"——即使你指定 -std=c++11,它也会把部分 C++14/17 的标准库特性(如 std::remove_cv_t)作为编译器扩展暴露出来。新版 clang (Xcode 15+) 收紧了标准合规性,C++11 就是 C++11,不给你"偷渡"高版本的特性。

修复方案

替换 node-sasssass(Dart Sass):

yarn remove node-sass && yarn add -D sass@~1.32.0

为什么选 sass@~1.32.0

  • sass-loader@7(项目当前版本)同时支持 node-sasssass,无需改 webpack 配置
  • sass@1.33+ 废弃了 / 作为除法运算符,会产生大量 deprecation warning(项目中的 .scss 文件可能大量使用 $width / 2 这种写法)
  • ~1.32.0 在 Node 16 下最稳定

相关代码位置

  • package.json:92node-sass 依赖声明
  • package.json:100sass-loader 依赖声明

思考题讨论

思考题 1:xcode-select 切换的本质是什么?

问题xcode-select -s 切换 Xcode 版本的本质是什么?它为什么能影响 Node 原生模块的编译?

用户回答:不知道。

暴露的知识盲区:对 macOS 编译工具链(toolchain)的层级结构不了解。

完整解答

xcode-select -s 改变的是一个全局路径指针:Developer Directory。你可以把它理解为"系统级的快捷方式",指向某个 Xcode 安装包的 Contents/Developer 目录。

xcode-select -s /Applications/Xcode-16.app
                        │
                        ▼ 实际改变的是
         /Applications/Xcode-16.app/Contents/Developer
                        │
          ┌─────────────┼─────────────────┐
          ▼             ▼                 ▼
      Toolchains/     SDKs/          Platforms/
      └── clang       └── MacOSX.sdk  └── iPhoneOS.sdk
          c++             └── usr/include/   (系统头文件)
          ld              └── usr/lib/       (系统库)

node-gyp 编译原生模块时,它实际上会调用:

  1. clang/clang++(C/C++ 编译器)—— 来自 Toolchains/
  2. 系统 SDK 头文件(如 stdio.h, stdlib.h)—— 来自 SDKs/MacOSX.sdk/
  3. 链接器 ld—— 来自 Toolchains/

这三者全部由 Developer Directory 决定。所以 xcode-select -s 本质上是在说:"从现在起,所有需要 C/C++ 编译器和系统 SDK 的程序,都去这个 Xcode 里找。"

类比前端的概念

xcode-select ≈ nvm
Xcode 版本   ≈ Node 版本
clang 版本   ≈ V8 引擎版本
MacOSX.sdk  ≈ Node 的内置模块 (fs, http, etc.)

就像 nvm use 16 让你的 shell 用 Node 16 的 node/npm 一样,xcode-select -s 让你的系统用某个 Xcode 的 clang/ld/SDK

三层对比

❌ 初级理解:"Xcode 就是写 iOS App 的 IDE"
   → 没意识到 Xcode 还包含完整的 C/C++ 编译工具链

⚠️ 中级理解:"切换 Xcode 会换编译器版本"
   → 知道影响但不清楚具体机制

✅ 资深理解:"xcode-select 改变的是 Developer Directory,
   它决定了 clang 版本、SDK 版本、链接器版本的组合。
   不同组合对 C++ 标准的支持程度不同,
   所以会影响所有需要编译原生代码的工具链,包括 node-gyp。"

面试话术:"xcode-select 本质上是切换 macOS 的 Developer Directory 路径,它决定了系统使用哪个版本的 clang 编译器、SDK 头文件和链接器。在前端场景中,这会影响所有通过 node-gyp 编译的原生 Node 模块,因为 node-gyp 底层调用的就是系统的 C++ 编译器。我们项目之前 node-sass 编译失败就是因为新版 Xcode 的 clang 对 C++ 标准合规性更严格,导致用 C++11 模式编译时无法使用 C++14 的 remove_cv_t 特性。最终我们选择替换为纯 JS 实现的 sass 来彻底规避原生编译依赖。"


思考题 2:原生模块 vs 纯 JS 模块的跨平台差异

问题:如果团队里有人用 Intel Mac、有人用 Apple Silicon Mac,node-sass 会不会出现"我这能跑你那跑不了"的情况?纯 JS 的 sass 为什么能避免?

用户回答:不知道。

暴露的知识盲区:对"原生模块"的本质(编译到机器码)和"纯 JS 模块"的本质(解释执行)的区别不清楚。

完整解答

会,而且这是前端团队非常常见的痛点。

原生模块(如 node-sass, sharp, bcrypt)的安装过程:

yarn add node-sass
        │
        ▼
  有预编译二进制吗? ──── 有 ──→ 下载对应平台的 .node 文件 ✓
        │
       没有
        │
        ▼
  本地编译 (node-gyp)
        │
  需要: C++ 编译器 + Python + 正确版本的系统 SDK
        │
        ▼
  编译产物是 .node 文件 (本质是 .dylib / .so / .dll)
  这个文件是 机器码,绑定到特定的:
    - CPU 架构 (x86_64 vs arm64)
    - 操作系统 (macOS vs Linux)
    - Node.js ABI 版本

关键点.node 文件是 编译后的二进制机器码,就像 .exe 一样,它不能跨平台。

Intel Mac 编译出的 .node  →  x86_64 机器码  →  Apple Silicon 上无法加载
Apple Silicon 编译出的 .node → arm64 机器码  →  Intel Mac 上无法加载

所以同一个 node_modules(比如通过共享文件夹或错误地提交到 git),在不同架构的机器上会直接崩溃。

纯 JS 模块为什么没有这个问题?

yarn add sass
        │
        ▼
  下载 .js / .mjs 文件(纯文本)
        │
        ▼
  由 Node.js 的 V8 引擎解释执行
  V8 本身是跨平台编译好的(随 Node 安装包一起发布)
        │
        ▼
  同样的 JS 代码在任何平台上都能跑 ✓

数据流对比

原生模块:
  C++ 源码 ──→ [编译器] ──→ 机器码(.node) ──→ CPU 直接执行
                  ↑
           依赖平台 (OS + CPU架构 + 编译器版本)

纯 JS 模块:
  JS 源码 ──→ [V8 引擎] ──→ JIT 编译 ──→ 执行
                  ↑
           V8 本身已经针对各平台编译好了
           JS 代码层面是平台无关的

性能取舍:原生模块直接跑机器码,理论上比 JS 快(node-sass 比早期的 Dart Sass 快约 10 倍)。但随着 Dart Sass 的优化和 V8 JIT 的进步,这个差距已经缩小到实际项目中感知不到的程度。而原生模块带来的维护成本(编译环境配置、CI/CD 适配、团队成员环境对齐)远大于那点性能差异。

三层对比

❌ 初级做法:`node_modules` 提交到 git / 共享文件夹拷贝
   → 原生模块直接崩溃,不同平台的二进制不兼容

⚠️ 中级做法:每人本地 `yarn install`,遇到编译问题逐个排查
   → 能解决但耗时,新人入职配环境是噩梦

✅ 资深做法:
   1. 能用纯 JS 替代的原生模块一律替换(node-sass → sass, bcrypt → bcryptjs)
   2. 不可替代的原生模块(如 sharp)用 optionalDependencies + 平台特定包
   3. CI/CD 用 Docker 统一构建环境
   4. 团队 README 写清楚系统要求(Node 版本、Xcode 版本、Python 版本)

面试话术:"原生 Node 模块本质上是通过 node-gyp 编译出的平台特定二进制文件(.node),它绑定了 CPU 架构、操作系统和 Node ABI 版本,所以天然不具备跨平台性。我们团队之前 node-sass 就出现过 Intel Mac 和 Apple Silicon Mac 互相跑不通的问题。解决策略是:能用纯 JS 替代的一律替换(比如 node-sasssass),不可替代的用 optionalDependencies 配合平台特定包。这样既消除了本地开发环境差异,也简化了 CI/CD 配置。"


经验提炼

遇到 yarn install / npm install 编译失败时的排查顺序:

  1. 看是哪个包失败 —— 错误日志中找 node_modules/xxx 路径
  2. 确认是不是原生模块 —— 有 binding.gyp / node-gyp rebuild 字样就是
  3. 检查是否有纯 JS 替代品 —— 大部分常用原生模块都有(node-sass→sass, bcrypt→bcryptjs, sharp 目前没有完美替代)
  4. 如果必须用原生模块 —— 检查 Node 版本、Python 版本、Xcode/编译器版本的兼容矩阵

行业视角

  • node-sass 2020 年后官方 deprecated,LibSass 也已停止维护
  • Dart Sass(即 sass 包)是官方推荐的唯一实现
  • 前端工具链整体趋势是去原生化:esbuild/swc 虽然是原生的,但它们用 Go/Rust 编写并提供预编译二进制,不依赖用户本地的编译环境(和 node-gyp 的"用户本地编译"模式有本质区别)

举一反三

以下包也常出现类似的编译问题,了解替代方案:

原生模块 纯 JS 替代 备注
node-sass sass 完美替代
bcrypt bcryptjs 性能差异在 Web 场景可忽略
canvas 无完美替代 考虑用 puppeteer 或服务端渲染
sharp 无完美替代 但 sharp 新版已用预编译方式发布

2. 为什么需要本地开发代理:同源策略、CORS 与 pathRewrite

一句话结论

本地开发代理(webpack-dev-server 的 proxyTable / Vite 的 server.proxy)是浏览器同源策略的绕行方案——把"跨域请求"伪装成"同源请求",让前端在 localhost:8088 也能调用生产/测试环境的后端 API,无需后端配合改 CORS 头,也无需 Chrome 关安全策略。pathRewrite 则是代理的"路径翻译器",用于匹配前端路径和后端真实路径的差异。

本章三段式:【核心认知】→【思考题讨论】→【详细参考】


【核心认知】

根本原因:同源策略(Same-Origin Policy, SOP)

同源 = 协议 + 域名 + 端口 三者全部相同

当前页面 目标接口 是否同源 原因
http://localhost:8088 http://localhost:8088/api 完全相同
http://localhost:8088 http://localhost:9000/api 端口不同
http://localhost:8088 https://localhost:8088/api 协议不同
http://localhost:8088 https://mkt.hzecool.com/api 协议 + 域名 + 端口全不同

为什么浏览器要这么严格?

不是"为难前端",而是防御 CSRF(跨站请求伪造)敏感数据窃取。设想如果没有 SOP:

  • 你登录了银行网站,Cookie 里有 session
  • 你打开恶意网站 evil.com
  • evil.com 的 JS 偷偷发请求 https://bank.com/transfer?to=hacker&amount=10000
  • 浏览器会自动带上你的银行 Cookie → 钱被转走

SOP 的存在让 evil.com 的 JS 读不到 bank.com 的响应(即使请求发出去了),从而让攻击者拿不到敏感数据。

注意一个反直觉点:SOP 是"浏览器行为",不是"网络协议"。用 curl、Postman、Node 发请求完全没有跨域问题——因为同源策略是浏览器 JS 引擎主动加的安全围栏。这是代理能工作的底层前提

解决方案对比(三层做法)

❌ 初级做法:关掉浏览器的跨域检查

# macOS
open -a "Google Chrome" --args --disable-web-security --user-data-dir=/tmp/chrome

问题

  • 团队每个人都要配,新人入职踩坑
  • 线上部署依然会跨域,开发体验和生产行为不一致
  • 安全风险:浏览器此时完全裸奔

⚠️ 中级做法:让后端加 CORS 响应头

Access-Control-Allow-Origin: http://localhost:8088
Access-Control-Allow-Credentials: true

还有什么隐患

  • 需要后端配合改代码/网关配置,前后端联调摩擦大
  • 生产环境的 CORS 白名单可能不包含 localhost,为测试环境单独开口子会把 * 放进白名单,变成永久安全漏洞
  • credentialsAccess-Control-Allow-Origin 不能用 *,必须精确匹配,每个开发者端口都要加

✅ 资深做法:本地 dev server 做反向代理

浏览器 (localhost:8088)
    │ 发请求 /amkt/api.do  ← 浏览器以为是同源
    ▼
webpack-dev-server (localhost:8088)  ← 同源,浏览器放行
    │ 代码里看到 /amkt 前缀,匹配 proxyTable
    │ 重新发起 HTTP 请求(Node 层,无浏览器参与)
    ▼
https://mkt.hzecool.com/amkt/api.do  ← 真实后端
    │ 返回响应
    ▼
webpack-dev-server 转发响应给浏览器
    │ 此时响应来自"同源"的 localhost:8088
    ▼
浏览器接收,业务代码拿到数据 ✅

为什么这是最优解

  • 零侵入:后端不用改任何代码
  • 开发-生产环境一致:生产时前端和后端同源(比如都在 mkt.hzecool.com),不需要代理;代理只在本地生效
  • 安全:不破坏浏览器安全模型
  • 可扩展:不同路径前缀可以代理到不同后端(微服务场景)

【思考题讨论】

思考题 1:生产环境的"代理"在哪里?

问题:生产环境部署时,前端的 /amkt/api.do 请求最终是怎么到达后端的?还需要这个代理吗?如果需要,谁来充当代理角色?

用户回答:还需要代理,比如配置 Nginx 或者其他方式,了解不深。

暴露的知识盲区

  • 对生产环境前后端通信架构不熟
  • 不了解 Nginx 反向代理的具体配置和作用
  • 不清楚"同域部署"和"跨子域部署"的差异
  • 不了解 CDN、API Gateway、BFF 等架构模式

完整解答

核心认知:生产环境不一定需要代理——如果前端和后端同源,浏览器根本不触发 CORS。本项目生产环境就是同源部署https://mkt.hzecool.com/index.html(页面)和 https://mkt.hzecool.com/amkt/api.do(接口)同域,Nginx 在服务器内部分流。

四种生产架构

架构 特点 代理角色 适用场景
同域部署 前后端同域名 Nginx location 分流 中小项目(本项目)
子域分离 www.x.com + api.x.com CORS 或 网关 大公司业务线
CDN + 回源 静态走 CDN,动态回源 CDN + Nginx 双层 大流量项目
BFF 模式 Node 中间层聚合微服务 BFF 充当代理 + 聚合 微服务前端

Nginx 反向代理配置示例(本项目生产环境逻辑类似):

server {
    listen 443 ssl;
    server_name mkt.hzecool.com;
    # 静态资源
    location / {
        root /var/www/market-portal-h5/dist;
        try_files $uri $uri/ /index.html;
    }
    # 后端接口:反向代理到内网应用
    location /amkt/ {
        proxy_pass http://10.0.1.100:8080;
        proxy_set_header Host $host;
    }
}

关键洞察

  • 本地 webpack-dev-server 代理 和 生产 Nginx 反向代理 是同一个东西,只是实现载体不同
  • 这就是为什么本地 config/index.js 的代理前缀必须和生产 Nginx 的 location 前缀保持一致——保证代码无需修改就能在两个环境都跑
  • 反向代理的隐性价值:隐藏后端 IP(安全)、负载均衡、SSL 终止、灰度/限流

为什么不让浏览器直接调后端?

  • 安全:后端 IP 暴露 = 攻击面翻倍
  • 性能:SSL 在 Nginx 层终止,内网走 HTTP
  • 运维:负载均衡、限流、灰度都在网关层做,后端无感

三层对比

层次 生产代理方案
❌ 初级 不知道,以为部署完就自动跨域
⚠️ 中级 知道要配 Nginx,不知道为什么配、配什么
✅ 资深 能画出完整流量路径,知道 location 匹配 → proxy_pass → 内网后端;理解 Nginx 只是反向代理的一种实现,底层思想和 webpack-dev-server、Kong 网关、BFF 层一脉相承

面试话术

"生产环境前端部署时,Nginx(或同等反向代理)承担了本地 webpack-dev-server 的角色。本项目属于同域部署——前端静态资源和后端接口都挂在 mkt.hzecool.com 下,Nginx 用 location 把 / 路由到静态文件,把 /amkt/ 等接口前缀 proxy_pass 到内网应用服务器。这样浏览器看到的所有请求都是同源的,不会触发 CORS。本地代理配置的路径前缀必须和生产 Nginx 保持一致,业务代码才能无修改跨环境运行——这是一个隐性约定。"

延伸讨论

  • Q: 如果把前端改成静态资源走 CDN、接口走 api.hzecool.com,要改什么?
    • A: 所有接口请求会变跨域,需要:①后端加 CORS 响应头,或 ②保持同域(通过 Nginx 把 api.hzecool.com 的请求反向代理到 mkt.hzecool.com/api)。
  • Q: Nginx 挂了怎么办?
    • A: 生产会用 Nginx 集群(keepalived + VIP),或直接上云 SLB。单点 Nginx 在生产是严重隐患。

思考题 2:多后端 + 路径冲突怎么设计

问题:本地开发同时调用两个后端(A 团队用户服务 + B 团队订单服务,完全不同的域名),两边都有一个 /api/list 接口,怎么设计前端代码和代理配置?

用户回答:不确定,需要讲解。

暴露的知识盲区

  • 没有处理过多后端场景
  • 对 axios 实例、请求封装的分层设计不熟
  • 缺少"解耦"思维——遇到问题倾向于 if/else,不是架构层面切分

完整解答

核心思路前缀隔离 + 实例封装 + 配置集中

关键设计

// 1. 代理配置:用不同前缀区分后端
proxyTable: {
  '/user-api': {
    target: 'https://user.team-a.com',
    changeOrigin: true,
    pathRewrite: { '^/user-api': '/api' }  // 还原真实路径
  },
  '/order-api': {
    target: 'https://order.team-b.com',
    changeOrigin: true,
    pathRewrite: { '^/order-api': '/api' }
  }
}

// 2. 请求实例:每个后端独立 axios
const userRequest = axios.create({ baseURL: '/user-api', timeout: 10000 });
const orderRequest = axios.create({ baseURL: '/order-api', timeout: 15000 });

// 3. 业务调用:无需感知多后端
userApi.get('/list');   // → https://user.team-a.com/api/list
orderApi.get('/list');  // → https://order.team-b.com/api/list

为什么每层都要封装?

职责 不封装的坏处
代理配置层 路径前缀 → 目标域名映射 业务代码里散落硬编码域名
axios 实例层 统一超时/鉴权/错误处理 每个请求重复写拦截器
业务调用层 纯业务语义 业务代码耦合后端细节

三层对比

层次 做法 问题
❌ 初级 业务代码里写完整 URL 跨域 + 环境切换噩梦
⚠️ 中级 前缀区分,一个 axios 实例共用 不同后端的超时/鉴权互相干扰
✅ 资深 前缀区分 + 多实例 + 环境变量驱动 + 代理错误日志 解耦 + 可观测 + CI 友好

扩展性(系统思维)

  • 10 个团队的量级时,前缀治理就成了组织问题 → 引入 API Gateway(Kong、APISIX)把路由决策从前端移到网关集中管理
  • 此时前端代理只剩一条:/api → gateway.company.com,具体路由由网关按子路径/Header 分发

面试话术

"多后端场景下,我会用前缀隔离 + 请求实例封装两层解耦。代理配置通过不同前缀(/user-api、/order-api)路由到不同 target,pathRewrite 去掉前缀还原真实路径。业务层为每个后端创建独立的 axios 实例,绑定 baseURL 和团队特定的拦截器(鉴权、超时、错误上报),业务代码完全不感知多后端。规模再大就上 API Gateway,把路由决策从前端移到网关集中管理。"

延伸讨论

  • Q: 如果 A 团队接口需要 JWT,B 团队接口需要 Session Cookie,怎么办?
    • A: 正是分实例的价值——userRequest 拦截器注入 Authorization: Bearer xxxorderRequest 开启 withCredentials: true 带 Cookie。互不干扰。
  • Q: 两个后端有部分接口需要联动(比如获取用户后查订单)怎么写?
    • A: 业务层组合两个实例的调用,或封装聚合服务。千万别在实例层面混用——混用等于把解耦成果抹平。
  • Q: 类型安全怎么做?
    • A: TS 里给每个实例的 get/post 泛型约束该团队的接口定义文件,比如 userRequest.get<UserListResp>('/list')。团队 A 和 B 的类型定义文件分开维护。

思考题 3:本项目 request/index.js 的前缀判断是什么设计?

问题:本项目 src/request/index.js:47-92 里为什么要根据 api 前缀(/amkt/mycrm/slh 等)做不同的 URL 拼接?它和思考题 2 聊的"多后端设计"有什么关系?是资深做法吗?

完整解答

这是思考题 2 里讲的"中级做法"的典型案例——代码识别 API 前缀,硬编码拼接对应的后端域名。

本项目代码逻辑src/request/index.js:42-104):

if (location.host === "mkt.hzecool.com" || env === "prod") {
  // 生产环境
  if (api === "/mycrm/api.do") {
    url = "https://oa.hzdlsoft.com:7480" + api;    // OA 后端
  } else if (api === "/confc/api.do") {
    url = "https://cc.hzecool.com" + api;          // 配置中心
  } else if (api === "/slh/api.do") {
    url = "https://op.hzecool.com" + api;          // 商陆花后端
  } else if (api === "/slb/api.do") {
    url = "https://bao.hzecool.com" + api;         // 宝贝后端
  } // ... 6+ 个分支
} else {
  // 测试环境,另一套域名映射
  if (api === "/mycrm/api.do") {
    url = "https://oatest.hzdlsoft.com:7480" + api;
  } // ... 又是 6+ 个分支
}

为什么这样写?

  • 开发环境:用 webpack 代理(config/index.js),前端代码直接写 /amkt/api.do,浏览器发请求到 localhost:8088/amkt/api.do,dev-server 代理转发到测试后端
  • 生产环境:没有 Nginx 统一反代(或者说前端部署的域名和后端不同),必须在 JS 里手动拼完整 URL,此时请求变成跨域,需要后端配 CORS

这是哪一级做法?

层次 特征
❌ 初级 业务代码里直接写完整 URL
⚠️ 中级(本项目) 在请求层集中判断前缀拼 URL,但硬编码大量 if-else
✅ 资深 配置驱动,一个 map 定义映射关系,环境变量控制

本项目代码的问题

  1. 硬编码:域名散落在 60 行 if-else 里,新增后端要改多处
  2. 环境判断靠 location.host 字符串匹配:测试环境如果换域名(比如加了 mktchk2.hzecool.com)就失效
  3. 无法用环境变量覆盖:CI/CD 想临时切后端得改代码
  4. 没利用代理配置:生产环境其实可以让 Nginx 做反代,前端代码就统一写 /amkt/api.do,和本地开发一致——现在的实现让前端代码在开发/生产行为不一致(开发走代理路径,生产走完整 URL),是"隐性陷阱"

资深的重构方案

// src/config/api-hosts.js
const API_HOSTS = {
  prod: {
    '/amkt': 'https://mkt.hzecool.com',
    '/mycrm': 'https://oa.hzdlsoft.com:7480',
    '/slh': 'https://op.hzecool.com',
    '/slb': 'https://bao.hzecool.com',
    '/confc': 'https://cc.hzecool.com',
    '/wx': 'https://weixin.hzdlsoft.com',
    '/clog': 'https://slh.hzecool.com',
  },
  test: {
    '/amkt': 'https://hzdev.hzdlsoft.com',
    '/mycrm': 'https://oatest.hzdlsoft.com:7480',
    '/slh': 'https://optest.hzecool.com',
    // ...
  },
};

// src/request/resolveUrl.js
function resolveUrl(api, env) {
  const prefix = '/' + api.split('/')[1];  // 取 "/amkt"
  const host = API_HOSTS[env][prefix];
  if (!host) throw new Error(`未配置 ${prefix} 的后端域名`);
  return host + api;
}

重构后的收益

  • 新增后端只改配置表,不改判断逻辑
  • 环境切换靠 env 字符串,不依赖 location.host 模糊匹配
  • 可以通过构建时环境变量注入,CI 友好
  • 数据结构化 → 可测试性大幅提升

为什么老代码还这样写?

不是作者水平不够,是历史遗留的合理选择

  • 项目从 2018 年开始(@Date: 2018-06-13),那时前端工程化不如现在成熟
  • 一开始可能只有 1-2 个后端,if-else 够用
  • 陆续加后端时没人重构,技术债滚雪球
  • 大团队协作中,"能跑就别动"是常态

经验提炼:看到任何 "N 层 if-else 根据字符串分发" 的代码,第一反应要想能不能用数据结构替代(Map / Object / 策略模式)。这是代码坏味道的经典信号

面试话术

"如果让我重构这段代码,我会用配置表替代 if-else——把 '前缀 → 域名' 的映射抽成 API_HOSTS 对象,按环境分组。好处是新增后端零代码改动、环境切换靠变量注入、可测试性大幅提升。更根本的是,我会推动后端同事上 Nginx 反向代理,让生产环境和本地一样统一走 /amkt 前缀,前端代码行为彻底一致——这是架构层面的治理。"


思考题 4:Cookie 在跨域代理场景下为什么经常丢失?

问题:跨域代理场景下 Cookie 经常神秘丢失,cookieDomainRewrite 做了什么?

完整解答

根因:Cookie 有一个强约束——浏览器只给"匹配 Domain 属性"的请求带 Cookie,跨域代理把 Domain 搞乱了。

完整流程演示

1. 浏览器访问 http://localhost:8088
2. 发请求 /amkt/api.do
3. webpack-dev-server 代理到 https://mkt.hzecool.com/amkt/api.do
4. 后端返回响应头:
   Set-Cookie: sessionId=xxx; Domain=mkt.hzecool.com; Path=/; Secure; HttpOnly
5. 浏览器收到响应头 → 检查 Domain 字段:
   - Cookie 要求 Domain=mkt.hzecool.com
   - 当前页面是 localhost
   - ❌ 不匹配!Cookie 被拒绝(silently dropped)
6. 下次请求 → 没有 Cookie → 登录态丢失

为什么浏览器要这么严格?

防止 Cookie 注入攻击。如果允许任意网站给任意域名种 Cookie,evil.com 就能给 bank.com 种一个假 sessionId,用户带着假 Cookie 访问银行直接登录失败/被劫持。

cookieDomainRewrite 做的事

proxyTable: {
  '/amkt': {
    target: 'https://mkt.hzecool.com',
    changeOrigin: true,
    cookieDomainRewrite: 'localhost',  // ← 关键
    // 或 cookieDomainRewrite: '' (清空,表示跟随当前页面)
  }
}

工作原理

后端返回: Set-Cookie: sessionId=xxx; Domain=mkt.hzecool.com
            ↓ 代理拦截响应头
            ↓ cookieDomainRewrite 改写
改写后:    Set-Cookie: sessionId=xxx; Domain=localhost
            ↓ 发给浏览器
浏览器: Domain=localhost 匹配当前页面 ✅ 接受 Cookie

还有 cookiePathRewrite 的坑

类似问题——后端种 Path=/app/,但前端路径是 /,Cookie 同样被丢弃。

cookiePathRewrite: {
  '/app': '/'  // 重写 Path 字段
}

Secure 属性的隐藏陷阱

后端习惯加 Secure(只在 HTTPS 下发送),但本地是 http://localhost,加了 Secure 的 Cookie 根本不会被浏览器种下。

解决方案

  1. 本地用 HTTPS(vue-cli-service serve --https
  2. 或者代理里剥离 Secure:需要更高级的配置,用 onProxyRes 钩子手动改响应头
onProxyRes: (proxyRes, req, res) => {
  const setCookie = proxyRes.headers['set-cookie'];
  if (setCookie) {
    proxyRes.headers['set-cookie'] = setCookie.map(c =>
      c.replace(/; secure/gi, '').replace(/; HttpOnly/gi, '')
    );
  }
}

SameSite 属性的现代坑

2020 年后 Chrome 默认 SameSite=Lax,跨站请求带不上 Cookie。跨域代理场景,后端必须返回 SameSite=None; Secure,而且要 HTTPS——这就是很多老项目升级浏览器后 Cookie 突然失效的原因。

本项目如何规避这些坑?

src/request/index.jssrc/util/apiSvc.js——本项目根本不依赖 Cookie 传递鉴权,而是把 mktTokensessionIdcenterSessionId 放在请求参数里params.mktToken)传递。这是 H5 常见做法,完美绕开所有 Cookie 跨域问题。

// src/request/index.js:32-39
if (!params.mktToken && api === "/amkt/api.do") {
  params.mktToken = store.getters["global/mktToken"];  // 参数里塞 token
}

三种鉴权传递方式对比

方式 跨域友好 安全性 实现复杂度
Cookie ❌ 差(需要各种 rewrite) ✅ HttpOnly 防 XSS 盗取 ⭐⭐⭐
请求参数(本项目) ✅ 好 ⚠️ URL 里可能被日志记录
Authorization Header(Bearer Token) ✅ 好(需 CORS allow-headers) ✅ 不进 URL ⭐⭐

现代项目主流是 Authorization Header + JWT,本项目用请求参数传 token 是历史选择(方便 H5 嵌入 app、分享链接带参),各有权衡。

经验提炼

  • 跨域场景下 Cookie 不丢是个小概率事件,默认都会踩坑
  • Cookie 有三道门闸:Domain、Path、Secure/SameSite——每道都可能让 Cookie 消失
  • 现代架构倾向 Token in Header,从根本上绕开 Cookie 复杂度

面试话术

"跨域代理场景 Cookie 丢失几乎是必然的——浏览器对 Cookie 的 Domain、Path、Secure、SameSite 有严格匹配规则,跨域代理把 Domain 改了就会被拒收。解决方案有三层:①用 cookieDomainRewrite 改写 Set-Cookie 响应头让 Domain 匹配当前页面;②处理 Secure 属性,本地要么上 HTTPS 要么剥离;③根本解法是改用 Authorization Header + JWT,彻底绕开 Cookie 跨域问题,这也是现代 API 的主流选择。"


【详细参考】

现象:没有代理会发生什么?

前端:http://localhost:8088  →  后端:https://mkt.hzecool.com/amkt/api.do

浏览器控制台:
❌ Access to XMLHttpRequest at 'https://mkt.hzecool.com/amkt/api.do'
   from origin 'http://localhost:8088' has been blocked by CORS policy:
   No 'Access-Control-Allow-Origin' header is present on the requested resource.

请求根本没到业务代码,浏览器在"预检 (preflight)"或响应阶段直接拦截。

本项目中的代理配置详解

相关代码位置:config/index.js:26-83

proxyTable: {
  "/amkt": {
    target: "https://mkt.hzecool.com",  // 真实后端域名(由 remoteRequestType 切换)
    secure: true,       // 目标是 https,验证 SSL 证书
    changeOrigin: true  // 关键:重写请求头的 Host 字段
  },
  "/clog": {
    target: "https://log.hzdlsoft.com",  // 日志服务独立域名
    secure: true,
    changeOrigin: true
  }
}

changeOrigin: true 为什么必须加?

不加的话,代理转发时 Host: localhost:8088 会被原样发到后端。很多后端(Nginx、Apache、云服务网关)会按 Host 做虚拟主机路由——看到 Host: localhost:8088 时根本不知道你要访问哪个站点,直接返回 404 或默认页。加上 changeOrigin: true 后,http-proxy-middleware 会把 Host 改成 target 的域名(mkt.hzecool.com),后端才能正确识别。

secure: true 的作用

  • true:验证 target 的 SSL 证书(生产环境 HTTPS 应该 true)
  • false:忽略证书错误(目标是自签名证书的内网测试服时用)

pathRewrite:路径翻译器

截图中的例子:

本地请求: /mop/api.do?cliReqId=xxx
    │
    │ pathRewrite: { '^/mop': '/slh' }
    ▼
实际请求: https://cs1b.slh.hzdlsoft.com:8658/slh/api.do?cliReqId=xxx

什么时候需要 pathRewrite?

  1. 前后端路径约定不一致:前端代码历史原因写了 /mop,后端实际路由是 /slh
  2. 去除代理前缀:前端用 /api/users 匹配代理,但后端路径是 /users,需要 pathRewrite: { '^/api': '' }
  3. 版本迁移过渡:老接口 /v1/* 和新接口 /v2/* 并存,pathRewrite 做无感切换

本项目当前没有使用 pathRewriteconfig/index.js 里全是注释掉的),因为前端路径前缀(/amkt/slh/confc)和后端真实路径完全一致。截图里的 /mop → /slh 是"假设性案例"或历史配置,不是当前实际配置。

面试 / 技术对话角度

面试官可能怎么问

  • 初级追问:"什么是跨域?为什么会跨域?"
  • 中级追问:"开发环境怎么解决?生产环境为什么不用代理?"
  • 资深追问:"代理和 CORS 响应头相比,各自的适用场景?changeOrigin 是干什么的?"
  • 变态追问:"如果后端需要基于 Cookie 鉴权,代理场景下 Cookie 怎么处理?"

面试话术

"本地开发代理本质是绕开浏览器的同源策略——同源策略是浏览器 JS 引擎为防 CSRF 加的围栏,只约束浏览器不约束 Node。webpack-dev-server 作为中间人用 Node 层发真实请求,浏览器视角看到的始终是同源响应。我在项目里配过多后端代理(/amkt 到业务域、/clog 到日志服务),关键是 changeOrigin: true 要加,否则后端按 Host 路由会 404。生产环境一般前后端同源或走网关,不需要这层代理。"

延伸讨论(能和资深开发者聊下去)

Q: 代理场景下 Cookie 怎么处理? A: 默认 http-proxy-middleware 会把 Set-Cookie 响应头原样转发,但 Cookie 里的 Domain=mkt.hzecool.com 在浏览器看来和当前页面 localhost 不匹配,会被丢弃。解决:用 cookieDomainRewrite: 'localhost'cookieDomainRewrite: ''(空字符串表示随当前页面)。本项目用的是 sessionId 放在请求参数里,没踩这个坑。

Q: 代理和 Nginx 反向代理的关系? A: 一模一样的思路。webpack-dev-server 内部就是用 Node 实现的一个迷你版 Nginx,底层是 http-proxy-middleware(本质 fork 自 http-proxy)。生产环境把 webpack-dev-server 换成 Nginx、把 proxyTable 换成 location 块就是生产级方案。

Q: 如果接口是 WebSocket 或 SSE(Server-Sent Events)呢? A: http-proxy-middleware 支持 ws: true 参数代理 WebSocket。SSE 是长连接 HTTP,默认就支持,但要注意 proxyTimeout 不要设太短,否则长连接会被提前切断。

Q: Vite 的 proxy 配置和 webpack-dev-server 有区别吗? A: 几乎一样的 API(Vite 团队直接复用了 http-proxy-middleware 的思想),语法是 server.proxy。Vite 3+ 默认用 rollup 构建 + esbuild 转译,开发体验更快,但代理层逻辑没变。

举一反三:同源策略的"表亲"

机制 解决什么 场景
CORS 跨域 AJAX 生产环境前后端不同源
JSONP 跨域获取数据(历史方案) 已过时,有 XSS 风险
postMessage iframe / window 跨域通信 嵌入第三方页面
Origin / Referer 校验 服务端反 CSRF 关键接口二次校验
CSP (Content-Security-Policy) 限制资源加载来源 XSS 防御

同源策略是一组限制,CORS 是官方开口,代理是开发期权宜之计。三者解决的问题是同一个:如何在"安全"和"开放"之间取平衡。


3. 反向代理、负载均衡、限流、灰度:前端工程师必懂的基础设施

一句话结论

这四个概念是 Nginx / API Gateway / BFF 层的核心能力,看似"后端的事",但资深前端必须懂——因为 Vercel/Netlify 这类前端托管平台都内置了它们,前端的 AB 测试/性能优化/错误兜底全靠这套基础设施。

本章三段式:【本质:第一性原理】→【思考题讨论】→【详细参考】


【本质:四个概念的第一性原理】

学完具体算法容易"见树不见林"。退一步看,这四个概念共享同一个底层模式,理解这个模式就能一眼识别所有类似的基础设施设计。

共同本质:Interceptor Pattern(拦截器模式)

                   拦截点(Middleware)
                        │
  客户端 ────请求────▶  [观测 + 决策 + 改写] ────▶ 服务端
                        │
                   ┌────┴────┐
                   │         │
               限流规则   灰度策略   负载均衡算法

所有这些概念的本质是:在请求链路中间插入一个可编程的拦截点,对流量做三件事:

  1. 观测(Observe):监控流量特征(QPS、IP、Header、用户身份)
  2. 决策(Decide):根据规则决定流量的下一步命运(通过/拒绝/分流)
  3. 改写(Transform):修改流量的属性(重写 Header、路径、Cookie)

这不是 Nginx 独有的——你会发现前端工程里到处是这个模式

场景 拦截点 能力
Nginx / API Gateway 网关 限流/灰度/负载均衡
Express/Koa 中间件 应用层 鉴权/日志/CORS
Redux middleware 状态层 Action 拦截(logger/thunk)
Axios interceptors HTTP 层 请求/响应拦截
React Context/Provider 组件树 数据注入
Service Worker 浏览器层 离线缓存/请求改写
HOC(高阶组件) 组件层 行为增强

识别能力:看到任何"A → [?] → B"的链路,问自己:这个 [?] 能做什么?它就是一个拦截器。理解这个模式后,学新工具不用从头学——只需要搞清楚它的"观测 / 决策 / 改写"三件事怎么配置。

为什么这些概念会存在:分布式系统的根本矛盾

CAP 定理指出:一个分布式系统不可能同时满足 一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance) 三者。实际工程中分区不可避免,所以选择变成 CP 还是 AP

所有流量治理能力本质上都在回答一个问题:"当资源有限、故障必然发生时,如何把有限的能力分配给最重要的请求?"

  • 负载均衡:资源不够 → 多台机器分担 → 横向扩展换容量
  • 限流:流量超载 → 主动拒绝部分请求 → 牺牲可用性换整体稳定
  • 灰度:新版本有风险 → 控制暴露范围 → 牺牲迭代速度换安全性
  • 反向代理:上述都需要一个"中心控制点"来实施

核心思想:悲观工程学(Pessimistic Engineering)——承认系统一定会出问题、资源一定不够、bug 一定会有,用基础设施兜底。

这和前端"防御式编程"(if (data && data.list && data.list.length))是一个哲学:在每个不可信的边界设置检查点

另一个本质:爆炸半径(Blast Radius)思维

所有的治理能力都可以用"爆炸半径"这个概念统一理解:

没有治理:              一个请求打爆 → 全系统崩溃(爆炸半径 = 100%)
                            ▼
  有负载均衡:            一台挂了还有别的(爆炸半径 ≈ 1/N)
  有限流:                恶意流量被拦在门外(爆炸半径 → 0)
  有灰度:                新版本只影响 5% 用户(爆炸半径 = 5%)
  有熔断(这里没讲但同源): 下游挂了我停止调用(爆炸半径不蔓延)

资深工程师做任何改动前,先问:"这个改动如果出问题,爆炸半径多大?如何把它缩小?"

这是价值观层面的升级——从"写能跑的代码"到"写在故障中也能控制损失的系统"。

再一个本质:可观测性是一切治理的前提

不可观测 → 无法治理 → 故障不可控
  │
  ▼
监控(Metrics)+ 日志(Logging)+ 链路追踪(Tracing)
  │
  ▼
数据驱动的治理决策(而非拍脑袋)

限流阈值设多少?灰度放量节奏?负载均衡算法选哪个? 没有数据支持的回答都是瞎猜。所以资深工程师做基础设施的第一步不是加功能,是加监控——先让系统"看得见",再谈治理。

前端类比:你给项目加一个复杂功能前,是不是先接入 Sentry/埋点?一样的思路

最深层:抽象层级的力量

前端工程师常问:"学这些后端概念干嘛?我又不写 Nginx 配置。"

答案:这不是"后端的事",是软件设计的通用模式。当你理解了:

  • 限流 = 客户端防抖/节流的服务端版
  • 灰度 = Feature Flag 的流量层实现
  • 负载均衡 = Redux 分片 / React.lazy 代码分割的流量层类比
  • 反向代理 = Axios interceptor 的协议层实现

你就拥有了跨层级的抽象能力——能在任何层级识别同一个模式的不同实现。这是"中级→资深"最重要的跨越:不是学更多新概念,是在已知概念里看到相同的骨架

真正的资深不是"知道 Nginx 怎么配",是"理解为什么它长这个样、什么场景该用它的变体、它解决不了的问题怎么办"。

四概念之间的关系(可视化总结)

用户请求
    │
    ▼
┌─────────────────────────────────────┐
│   反向代理(Nginx / API Gateway)    │  ← 入口
│                                      │
│   ①  限流(先挡住异常流量)           │
│   ②  灰度(按规则分流到不同版本)     │
│   ③  负载均衡(在目标版本的集群内分发)│
│                                      │
└─────────────────────────────────────┘
    │              │              │
    ▼              ▼              ▼
  后端 A1       后端 A2        后端 B1
  (v1 集群)    (v1 集群)      (v2 金丝雀)

一句话总结:反向代理是舞台,负载均衡、限流、灰度是舞台上的三个能力

面试话术(本质版)

"这些基础设施能力底层共享一个模式:在请求链路中插入可编程的拦截点,做观测、决策、改写。负载均衡、限流、灰度只是这个模式在不同维度的展开——分别对应容量扩展、故障控制、风险管理。它们的存在理由是分布式系统的根本矛盾:资源有限、故障必然、需求多变。资深工程师的核心思维是'爆炸半径控制'——任何变更先问如何缩小故障影响面,这和前端的防御式编程、React 错误边界是同一个哲学。我在前端项目里用 axios interceptor、Service Worker、React HOC 时,本质上就是在应用这套思想的不同变体。"


【思考题讨论】

思考题 1:防抖、节流 vs 漏桶、令牌桶的精确对应

问题:项目里用过防抖和节流,如果用 Nginx 的限流算法类比,防抖 300ms 的搜索框更像漏桶还是令牌桶?

用户回答:更像漏桶,但不知道为啥。

暴露的知识盲区

  • 对"漏桶/令牌桶"的核心规则理解不够精确
  • "蓄水-延迟释放"的画面和"输出速率固定"混淆了

完整解答

直觉对了一半——两个经典算法其实都不完美对应防抖。

核心差异对比

算法 输出时机 输出速率 核心规则
漏桶 持续输出 固定速率 桶满丢弃,速率严格恒定
令牌桶 有令牌就可输出 平均速率固定,允许突发 令牌驱动
防抖 只在静默期后输出一次 无速率概念 边沿检测(检测"输入停止"事件)
节流 窗口内最多一次 固定速率 频率限制

精确对应关系

  • 节流 ≈ 令牌桶(桶容量=1) ✅ —— 每 N ms 生成一个令牌,来请求就消耗
  • 防抖 ≈ "反漏桶"(不是标准算法)—— 漏桶是持续漏,防抖是憋着不漏、静默后一次性出一个
  • 本质差异:防抖是"边沿触发",节流是"频率限制"

为什么你直觉"防抖像漏桶"?

  • 画面上都有"蓄水 → 延迟释放"的感觉
  • 漏桶持续输出所有请求(只是按固定速率),防抖丢弃中间请求只保留最后一个
  • 这是信息保留 vs 信息合并的本质差异

搜索框场景的选择逻辑

// 搜索框:用户输入 "abc"
// 防抖:用户停止输入 300ms 后,发送一次请求(搜 "abc")
// 节流:用户每输入 300ms 发送一次请求(搜 "a" → "ab" → "abc")

// 搜索用防抖更好:
// 1. 节省请求量(从 3 次降到 1 次)
// 2. 用户只关心最终结果,中间状态无意义

按钮点击的场景选择

// 提交按钮:用户狂点 5 次
// 防抖:最后一次点击后 300ms 才提交 → 用户感觉卡顿
// 节流:第一次点击立即提交,后续 300ms 内忽略 → 体验好

三层对比

层次 理解程度
❌ 初级 知道防抖节流有区别,用的时候二选一试
⚠️ 中级 能说清"防抖 = 等一下再执行,节流 = 隔一段时间执行一次"
✅ 资深 能从"信息保留 vs 信息合并"、"边沿触发 vs 频率限制"两个维度精确区分,能根据业务语义选择;能类比到服务端限流算法,理解整个限流家族的统一思想

面试话术

"防抖和节流本质上是客户端的限流手段,但两者的核心规则不同——节流是频率限制,窗口内最多执行一次,对应服务端的令牌桶(桶容量=1);防抖是边沿检测,只在'输入停止'事件触发时执行一次,不完全对应任何经典算法,如果硬对应更像'反漏桶'。选择逻辑看业务语义:关心最终状态用防抖(搜索框、窗口 resize),关心持续反馈用节流(滚动加载、鼠标拖拽、按钮防重复点击)。"

延伸讨论

  • Q: lodash.debounceleadingtrailing 参数分别对应什么?
    • A: leading: true = 第一次立即执行(像节流),trailing: true = 静默期后执行(默认防抖行为)。两个都开 = 首尾各一次。这说明防抖和节流在实现层面是同一套机制的不同配置
  • Q: RxJS 的 debounceTimethrottleTimesampleTimeauditTime 有啥区别?
    • A: 这四个算子对应流处理的四种"时间治理"策略,比 lodash 更丰富——sampleTime 是定期采样最新值,auditTime 是触发后等待一段时间取最后值。本质都是"拦截器模式在流处理上的展开"。

思考题 2:10% 用户试新版,转化率 +5% 全量——灰度还是 AB?

问题:电商首页改版,产品说"先让 10% 用户看新版,如果转化率提升 5% 就全量"——这是灰度发布还是 AB 测试?实现上有什么本质区别?

用户回答:"灰度发布就是先给少量用户试用,AB 测试的概念不清楚。"

暴露的知识盲区

  • 把"少量用户"当成灰度的定义,没抓到目的
  • 对 AB 测试的统计方法论、对照组概念空白

完整解答

你的理解"灰度 = 少量用户试用"对一半——"少量用户"是手段,不是目的。两者的本质区别在目的决策依据

关键对比表

维度 灰度发布 AB 测试
目的 降低技术风险 验证产品假设
决策依据 bug、报错率、延迟 转化率、留存、GMV
放量逻辑 没问题就放(单调递增) 赢家留下(可能回退)
持续时间 几小时~几天 几周~几个月
对照组 不强制 必需
统计要求 肉眼看错误率 p-value、样本量计算

题目的答案:这是 AB 测试,不是灰度。

判断依据:决策标准是业务指标(转化率),不是技术指标(错误率)。灰度的判断是"两天没报错就放量",不是"转化率涨了就放量"。

但现实中常常混用——大公司的流量系统往往同时支持两种:

第一阶段(灰度防崩): 1% 流量跑 1 天 → 看错误率、延迟、监控告警
  │ 通过
  ▼
第二阶段(AB 验证): 10% 新版 vs 10% 旧版 → 对比转化率统计显著性
  │ 赢
  ▼
第三阶段(全量): 100% 新版

AB 测试的经典陷阱(初级常犯)

没有对照组的"AB"不是 AB

❌ 错误: 10% 用户看新版 → 转化率 5% → 和历史数据(旧版 4%)对比 → 上!
✅ 正确: 10% 新版(A组) + 10% 旧版(B组) → 同时段随机分流对比

为什么必须有对照组? 历史数据受季节、促销、市场变化影响,不是干净对照。正确的 AB 必须同时同质人群,用随机分流排除所有混杂变量。

AB 测试的统计学要点(资深必备)

  1. 样本量计算:提升 5% 需要多少样本?需要根据基准转化率、检测的最小效应量、显著性水平(通常 α=0.05)、检验功效(通常 1-β=0.8)计算。一般电商场景至少需要每组几万甚至几十万用户。
  2. 辛普森悖论:新老用户、不同渠道用户的分层结果可能和总体结果相反——得看分组指标,不只是整体。
  3. Novelty Effect:新版本一上线用户觉得新鲜点击率高,一周后回落——AB 周期不能太短。
  4. SRM(Sample Ratio Mismatch):分流本应 50-50,但实际 52-48——分流本身有 bug,实验结果不可信。

三层对比

层次 对灰度/AB 的认知
❌ 初级 "灰度 = 少量用户","AB 测试 = 两个版本对比",混用
⚠️ 中级 能说出目的差异,但不知道 AB 测试的统计陷阱
✅ 资深 能设计完整的实验流程:样本量估算 → 分流随机性验证(SRM) → 辛普森分层分析 → 统计显著性检验 → 业务决策建议

前端工程师为什么要懂这个?

  • 你负责实现 Feature Flag SDK、分流逻辑、埋点采集 —— 直接影响实验正确性
  • 实验分流如果用 Math.random() < 0.5 而不是基于 userId 哈希 → 同一用户可能一会儿 A 组一会儿 B 组 → 数据完全废了
  • 埋点时机错了(新版按钮点击没埋点、旧版埋了) → 转化率统计偏差
  • 这些锅通常会扣在前端头上

面试话术

"灰度和 AB 测试技术实现相似但思维框架完全不同。灰度关注技术稳定性——少量流量试新版,错误率正常就放量,是风险控制。AB 测试关注产品决策——同时期随机分流对比业务指标的统计显著性,是决策验证。常见误区是没有对照组的'AB'其实只是灰度,拿新版数据和历史对比忽略了时间、季节等混杂变量。前端视角要特别注意两点:一是分流函数必须基于 userId 哈希保证同一用户稳定在同一组,二是埋点要在新旧版本对等覆盖,否则会污染实验结果。"

延伸讨论

  • Q: AA 测试是什么?为什么要做?
    • A: AA 测试是"给两组用户同一个版本"的对照实验,用来验证分流系统本身没偏差。如果 AA 测试都能测出"显著差异",说明分流算法或埋点有 bug,不能做 AB。
  • Q: 什么时候 AB 不适用?
    • A: ①样本量极小(小众产品);②决策需要快(天级上线,没时间等显著性);③实验组之间会互相影响(社交产品的网络效应)。此时用"专家判断 + 小范围灰度"替代 AB。
  • Q: Feature Flag 和 AB 的关系?
    • A: Feature Flag 是基础设施,AB 是使用方式之一。同一套 FF 系统可以做灰度(按比例放量)、AB(对比实验)、Kill Switch(紧急关闭功能)、Entitlement(付费功能开关)。这是一套机制多种应用的典型例子。

【详细参考】

概念 1:反向代理 vs 正向代理

最容易混淆的一对。关键看代理帮谁隐藏身份

正向代理(Forward Proxy):隐藏客户端
  用户 → 代理(VPN/科学上网软件)→ Google
  Google 只看到代理的 IP,不知道真实用户

反向代理(Reverse Proxy):隐藏服务端
  用户 → Nginx → 后端集群
  用户只看到 Nginx 的 IP,不知道后端真实结构

前端熟悉的类比

  • 正向代理 ≈ VPN(帮客户端伪装)
  • 反向代理 ≈ 公司前台(帮内部员工过滤访客)

反向代理的核心价值(不仅是跨域):

价值维度 具体做法 例子
安全 隐藏后端真实 IP 后端在内网,只有 Nginx 暴露公网
性能 SSL 终止、静态缓存、Gzip Nginx 比后端业务代码处理 HTTP 快 10 倍
扩展性 一个入口对接多后端 /api/user → 用户服务/api/order → 订单服务
可控性 限流、灰度、A/B 在网关做 业务代码无需改动

常见反向代理实现

  • Nginx:C 语言,最主流,性能高
  • HAProxy:C 语言,更偏 TCP 层负载均衡
  • Envoy:C++,服务网格(Istio)的数据平面
  • Traefik:Go,云原生友好,自动发现服务
  • Kong / APISIX:API Gateway,Nginx 之上加了可编程能力

概念 2:负载均衡(Load Balancing)

为什么需要

单机能力有限(CPU、内存、连接数都有上限),业务规模变大必须横向扩展(scale-out):加机器分担流量。

❌ 单机扛不住:
用户 → [单台后端 10万 QPS 崩溃]

✅ 负载均衡:
用户 → Nginx → ┬─ 后端 A (3万 QPS)
                ├─ 后端 B (3万 QPS)
                └─ 后端 C (3万 QPS)

常见算法(面试高频)

算法 规则 适用场景
轮询(Round Robin) 挨个发 后端机器配置一样
加权轮询(Weighted RR) 按权重分配 新老机器混部,老机器权重低
最少连接(Least Connection) 发给连接数最少的 请求处理时间差异大
IP Hash 同一 IP 固定到同一台 需要会话保持
一致性哈希 按 Key 哈希定位节点 缓存场景,减少节点增减的影响

L4 vs L7 负载均衡

L4(传输层,基于 TCP/UDP):
  只看 IP + 端口 → 性能高,不懂业务
  代表:LVS、F5、云 SLB 的 TCP 模式

L7(应用层,基于 HTTP):
  能看 URL、Header、Cookie → 灵活,性能稍低
  代表:Nginx、HAProxy(HTTP 模式)

大公司典型分层

用户 → LVS (L4,扛大流量) → Nginx 集群 (L7,精细路由) → 业务服务

会话保持的坑(一个真实场景)

后端用内存 Session(不是 Redis)时:

  1. 用户登录,请求路由到机器 1 → Session 存在机器 1 内存
  2. 下一次请求路由到机器 2 → 找不到 Session → 莫名其妙登出

三种解法

  1. IP Hash(简单但粗糙——用户切网 IP 变就失效)
  2. Sticky Cookie(Nginx 种 Cookie 标记目标机器)
  3. Session 外置到 Redis(推荐,真正解耦)

为什么前端要懂这个:排查"偶现登录状态丢失"时,第一反应要想到是不是负载均衡 + 内存 Session 的组合出了问题。


概念 3:限流(Rate Limiting)

为什么需要

  • 防雪崩:单个接口突发流量冲垮整个系统
  • 防爬虫:恶意抓取数据
  • 防刷单:秒杀、优惠券场景
  • 保护下游:第三方 API 有 QPS 限制

四大经典算法

算法 1:固定窗口计数
0秒─────10秒─────20秒─────
  [100次]  [100次]  [100次]

问题:临界点翻倍——第 9.9 秒打 100 次,第 10.1 秒再 100 次,实际 0.2 秒内打了 200 次。

算法 2:滑动窗口
              现在
─────────────────┤
   过去 10 秒的窗口(连续计数,无临界问题)

优点:平滑 缺点:需记录每次请求时间戳,内存开销大

算法 3:令牌桶(Token Bucket)—— 最主流
恒定速率生成令牌 → [桶:容量 100]
                        │
                        ▼
请求到来 → 拿一个令牌 → 放行(拿不到则限流)

精髓

  • 平均限流:令牌生成速率 = 平均 QPS
  • 允许突发:桶里攒满 100 个令牌,瞬间可通过 100 个
  • Nginx limit_req 默认用这个
算法 4:漏桶(Leaky Bucket)
请求 → [桶] → 按恒定速率漏出 → 后端
       (满了就丢)

精髓平滑所有流量,不允许突发 代价:浪费瞬时处理能力,不如令牌桶灵活

对比总结

算法 突发 平滑度 实现复杂度 典型场景
固定窗口 易翻倍 粗粒度限制
滑动窗口 不允许 ⭐⭐⭐ 精确限流
令牌桶 允许 ⭐⭐ 通用首选
漏桶 不允许 最好 ⭐⭐ 调用第三方 API(必须平滑)

前端视角:如何应对 429

服务端限流时返回 HTTP 429 Too Many Requests。前端的正确应对:

// 错误做法:疯狂重试 → 雪上加霜
axios.interceptors.response.use(null, (error) => {
  if (error.response?.status === 429) {
    return axios(error.config);  // ❌ 立刻重试 = 给限流火上浇油
  }
});

// 正确做法:退避重试 + 用户提示
axios.interceptors.response.use(null, (error) => {
  if (error.response?.status === 429) {
    const retryAfter = error.response.headers['retry-after']; // 秒数
    return new Promise((resolve) =>
      setTimeout(() => resolve(axios(error.config)), retryAfter * 1000)
    );
  }
});

前端自身的"限流"

防抖(debounce)和节流(throttle)本质上是客户端的漏桶/令牌桶

  • 防抖(搜索框输入停顿 300ms 后才请求)≈ 漏桶:所有请求排队,按节奏放行
  • 节流(按钮 1 秒只能点 1 次)≈ 令牌桶变种:固定速率释放令牌

这就是客户端限流 + 服务端限流的双保险。


概念 4:灰度发布(Canary Release)

为什么需要

爆炸半径控制。新版本直接切 100% 流量 = 有 bug 就全员崩溃。灰度 = 先 1% 试水,有问题快速回滚,影响面压到最小。

"Canary" 来自"煤矿里的金丝雀"——矿工带金丝雀下井,金丝雀先死说明空气有毒,矿工及时撤离。灰度中的小流量就是金丝雀。

四种常见灰度策略

策略 1:按流量比例(最常用)
旧版 v1 ──95% 流量──▶
新版 v2 ──5% 流量──▶  (逐步放大 5% → 20% → 50% → 100%)

Nginx 实现split_clients 模块):

split_clients "${remote_addr}${http_user_agent}" $backend {
    5%     v2_server;   # 5% 流量到新版
    *      v1_server;
}
策略 2:按用户特征(白名单灰度)
内部员工 (user_id 在 admin 组) → v2
VIP 用户                        → v2
普通用户                         → v1

适合风险高的功能:内部先用,没问题再放开。

策略 3:按请求特征(Header/Cookie 灰度)
Cookie: gray=true  → v2
其余                → v1

开发联调利器:QA 手动切 Cookie 访问新版本,不影响普通用户。

策略 4:按地域灰度
杭州用户 → v2
其他城市 → v1

适合公司所在地内测:出问题方便当面沟通修复。

灰度 ≠ AB 测试 ≠ Feature Flag(最容易混淆)

概念 目的 关注点
灰度发布 降低新版本上线风险 快速回滚,是否有 bug
AB 测试 验证产品假设 转化率、留存指标对比
Feature Flag 运行时开关功能 精细控制,解耦部署和发布

技术实现高度重叠,但思维框架完全不同

  • 灰度:技术稳定性视角,逐步放量
  • AB:产品决策视角,对比实验
  • Feature Flag:研发效率视角,trunk-based 开发

前端灰度的落地姿势(重点)

前端代码灰度比后端复杂——客户端代码已下发到浏览器,无法"悄悄换版本"。三种方案:

方案 A:CDN 层灰度(入口 HTML 分流)
server {
    location = /index.html {
        # 根据 Cookie 返回不同版本的 entry JS
        if ($cookie_gray = "true") {
            rewrite ^ /static/v2/index.html break;
        }
        rewrite ^ /static/v1/index.html break;
    }
}

原理index.html<script src> 指向不同版本的入口 JS,浏览器加载到的 JS 不同,整个 App 就是不同版本。

方案 B:Feature Flag SDK(运行时灰度)

引入 LaunchDarkly / 公司自研 FF SDK:

if (featureFlag.isEnabled('new-checkout-flow', { userId })) {
  return <NewCheckout />;
} else {
  return <OldCheckout />;
}

好处:新旧版本代码同包部署,运维后台秒级切换,无需发版。 坏处:代码里到处是 if 分支,技术债要定期清理。

方案 C:BFF 层灰度

BFF 根据请求特征返回不同 API 响应,前端代码不变但行为不同。 好处:前端无感,逻辑集中 坏处:只能改数据,改不了 UI 组件结构

面试话术(逐概念版)

"反向代理是所有后端流量治理能力的载体。它通过站在客户端和服务端之间的位置,提供四类核心能力:负载均衡把流量分发到多台机器扩展容量;限流用令牌桶/漏桶等算法保护后端不被打垮;灰度发布用流量比例或用户特征控制新版本的爆炸半径;以及 SSL 终止、缓存、安全防护等附加能力。前端视角下,Vercel 的 Edge Network、Netlify 的 CDN、Cloudflare Workers 都是这套思想在前端领域的落地,前端的 AB 测试和 Feature Flag 本质上都是灰度策略的消费方。"

延伸讨论

  • Q: 熔断(Circuit Breaker)和限流是一回事吗?

    • A: 不是。限流是主动防御(你一分钟只能来 100 次),熔断是被动保护(下游挂了,我停止调用避免雪崩)。两者配合使用——Netflix 的 Hystrix、阿里的 Sentinel 都同时提供这两种能力。
  • Q: CDN 算不算反向代理?

    • A: 算,而且是最大规模的反向代理。CDN 边缘节点代表源站响应用户,缓存静态资源就近访问,动态请求回源。前端构建产物放 CDN 本质上是享受了全球反向代理网络。
  • Q: Serverless(Vercel、Netlify)架构下这些概念还有吗?

    • A: 有,只是被平台抽象了。Vercel 的 Edge Middleware 就是 API Gateway 的 Serverless 版本,可以在请求到达函数前做灰度、限流、重写。前端工程师现在能用 JS 写"Nginx 配置"——这是行业趋势。
  • Q: gRPC 场景下这些概念怎么体现?

    • A: gRPC 走 HTTP/2,Envoy 是 gRPC 场景的主流反向代理(Nginx 对 gRPC 支持较弱)。限流/灰度概念不变,但算法层面要处理 HTTP/2 的多路复用特性。

举一反三:行业里的经典工具

能力 传统方案 云原生方案 Serverless 方案
反向代理 Nginx、HAProxy Envoy、Traefik Vercel Edge、Cloudflare Workers
负载均衡 LVS、F5 云 SLB、K8s Service 平台自动负载均衡
限流 Nginx limit_req、Redis + Lua Sentinel、Kong、APISIX Vercel Rate Limiter
灰度 Nginx split_clients Istio VirtualService Vercel Edge Middleware
Feature Flag 自研后台 Unleash(开源) LaunchDarkly、Flagsmith