普通视图
韩国拟投入1万亿韩元研发设备上AI芯片
AWOL Vision Aetherion系列投影仪海外众筹首日破千万美金
深入理解 INP:从原理到实战的前端交互性能优化
背景
最近有做一些INP优化相关,简单记录一下。
INP高的重灾区就是手机端, 手机 CPU 弱、线程紧张,INP 高基本都出在移动端。
什么是 INP?
INP(Interaction to Next Paint,交互到下一次绘制) 是 Google Core Web Vitals 中评估网页交互响应性的核心指标。自 2024 年 3 月起,INP 正式取代了旧的 FID(First Input Delay),成为衡量用户交互体验的主要标准。
优化INP有什么用?
-
📱 体验层面:解决移动端点击卡顿、无响应,让交互秒反馈,大幅提升流畅度。
-
🔍 SEO 层面:INP 是 Google Core Web Vitals 核心指标,达标有利于搜索排名与流量。
-
💰 业务层面:交互更流畅,用户流失更少,留存、转化率更高。
-
🛠 技术层面:拆分长任务、优化主线程,让项目整体架构更轻、更快、更易维护。
INP vs FID:为什么要换?
| 对比维度 | FID (旧指标) | INP (新指标) |
|---|---|---|
| 测量范围 | 仅第一次交互 | 全生命周期所有交互 |
| 测量内容 | 仅输入延迟 | 输入 + 处理 + 渲染 全链路 |
| 真实性 | 片面 | 更全面、更真实 |
| 优化指导 | 只关注首次交互 | 关注所有交互场景 |
简单来说:FID 只是"开胃菜",INP 才是"全席宴"——它能真正反映用户在使用过程中的完整体验。
INP 测量的是什么?
INP 测量的是:用户进行一次有效交互(点击、点按、键盘输入)后,到浏览器完成下一次视觉更新(paint)所花费的总时间。
时序图解
用户交互(click/tap/keypress)
│
▼
┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐
│ 输入延迟 │ → │ 事件处理 │ → │ 样式计算 + 布局 + 绘制 │ → 下一帧 paint
└─────────────┘ └─────────────┘ └─────────────────────────┘
◀──────────────────── INP 完整耗时 ────────────────────────────▶
📌 关键点:INP 的最终值 = 页面生命周期中所有有效交互里最慢的那一次(取第 75 百分位)
评分标准(2025–2026)
根据 Google 官方标准,INP 以第 75 百分位(P75)进行评估:
| 等级 | 阈值 | 含义 |
|---|---|---|
| 🟢 良好 (Good) | ≤ 200 ms | 响应迅速,用户体验优秀 |
| 🟡 需改进 (Needs Improvement) | 200–500 ms | 有明显延迟感,需要优化 |
| 🔴 较差 (Poor) | > 500 ms | 严重卡顿,体验糟糕 |
优化目标:确保 75% 以上的用户交互响应时间控制在 200 ms 以内。
![]()
为什么 INP 会变差?
核心原因
主线程上存在长任务(Long Task > 50 ms),阻塞了浏览器处理后续交互和渲染。
典型场景
用户点击按钮
→ setLoading(true)
→ 立即执行 200–500 ms 的重计算/网络请求/复杂 DOM 操作
→ 用户迟迟看不到 loading 状态
→ 感觉卡顿、无响应
问题在于:setLoading(true) 虽然调用了,但由于主线程被长任务占用,浏览器根本没机会渲染这个状态变化!
![]()
三个阶段的问题分布
根据实际项目经验,三个阶段的问题分布大致如下:
-
输入延迟:30% - 通常是第三方脚本、初始化任务
-
事件处理:50% - 最常见的问题,业务逻辑复杂
-
呈现延迟:20% - DOM 操作、布局计算
INP 的三个阶段详解
INP 的测量范围涵盖三个关键阶段,每个阶段都可能成为性能瓶颈。深入理解这三个阶段,是优化 INP 的基础。
![]()
阶段一:输入延迟(Input Delay)
定义:从用户触发交互到事件处理器开始执行之间的等待时间。
原因:主线程被其他任务占用(长任务 > 50ms、同步渲染、第三方脚本、垃圾回收),浏览器无法立即响应交互。
案例:比如如下,输入延迟348ms,脚本加载阻塞了交互 导致用户交互卡顿
![]()
优化策略
-
拆分长任务:使用
scheduler.yield()或 scheduler.postTask 让出主线程 -
延迟非关键任务:使用
requestIdleCallback在空闲时执行 -
优化第三方脚本:异步加载、延迟执行
阶段二:事件处理(Processing Time)
定义:事件处理器从开始执行到执行完成所花费的时间。
原因:处理器内部执行耗时操作(复杂计算、大量 DOM 操作、同步网络请求、强制同步布局),阻塞主线程。
实际案例
// ❌ 问题:用户点击更换背景,但画布操作耗时,看不到即时反馈
// 来源:src/core/FTCanvasRenderer.ts - updateBackground
async updateBackground({ bgUrl, bgColor, size, callback }) {
FTBgremoveStore.changeBackgroundLoading = true; // 状态已更新,但浏览器还没渲染
// 同步执行耗时操作,阻塞主线程
const blobUrl = await FTBlobCacheManager.getInstance().addCache(bgUrl); // 100-200ms
bg.setSource(blobUrl, "imageUrl", () => {
this.blurBackground(currentPageData.blurValue || 0); // 50-100ms 模糊处理
fabricCanvas.requestRenderAll(); // 触发重绘
FTBgremoveStore.changeBackgroundLoading = false;
});
}
// ✅ 优化:先让浏览器渲染反馈,再执行耗时操作
import { nextTick } from 'src/utils';
async updateBackground({ bgUrl, bgColor, size, callback }) {
FTBgremoveStore.changeBackgroundLoading = true;
// 让出主线程,确保浏览器先渲染 loading 状态
await nextTick();
// 现在执行耗时操作
const blobUrl = await FTBlobCacheManager.getInstance().addCache(bgUrl);
bg.setSource(blobUrl, "imageUrl", async () => {
await nextTick(); // 再次让出,确保模糊处理不阻塞
this.blurBackground(currentPageData.blurValue || 0);
fabricCanvas.requestRenderAll();
FTBgremoveStore.changeBackgroundLoading = false;
});
}
优化策略
-
先反馈再处理:使用
nextTick()让浏览器先渲染状态变化 -
拆分长任务:将耗时操作拆成多个小任务
-
使用 Web Worker:将计算密集型任务移到 Worker 线程
阶段三:呈现延迟(Presentation Delay)
定义:从事件处理器执行完成,到浏览器完成下一次 paint(绘制)之间的时间。
原因:浏览器渲染管道(样式计算 → 布局 → 绘制 → 合成)耗时过长,常见问题包括强制同步布局、大量重排、复杂 CSS 选择器。
优化策略
-
避免强制同步布局:先批量读取布局属性,再批量写入 DOM
-
减少重排重绘
-
优化 CSS 选择器:避免深层嵌套,使用类选择器
INP优化核心思路
🎯 核心原则:让用户交互后的第一个 paint 尽快发生,把重的、非必须立即执行的工作延迟或拆分。
这样做能让你推迟执行的任务不算在INP时间计算内
❌ 错误示范:阻塞渲染
// 来源:src/store/FTBgremoveStore.tsx - handleLoadSimplifyCanvas
async handleLoadSimplifyCanvas(imgUrl, id, closeLoading = true) {
this.cutoutLoading = true; // 调用了,但...
// 立即执行耗时操作,阻塞主线程
const img = await loadImageByUrl(imgUrl); // 100ms 加载图片
await FTSimpleCanvasRenderInstance.uploadTop(imgUrl); // 80ms 上传到画布
await FTSimpleCanvasRenderInstance.uploadBack(page.backgroundColor); // 50ms 设置背景
this.setCurrentPage({ cutOriginUrl: imgUrl, cropImage: image, ... }); // 同步更新状态
this.cutoutLoading = false;
}
✅ 正确示范:先反馈,再干活
import { nextTick } from 'src/utils';
async handleLoadSimplifyCanvas(imgUrl, id, closeLoading = true) {
// 1. 同步更新状态
this.cutoutLoading = true;
// 2. 关键:让出主线程,给浏览器渲染机会
await nextTick(); // 浏览器有机会渲染 loading 状态
// 3. 现在才开始执行重任务
const img = await loadImageByUrl(imgUrl);
await FTSimpleCanvasRenderInstance.uploadTop(imgUrl);
await FTSimpleCanvasRenderInstance.uploadBack(page.backgroundColor);
// 再次让出,确保状态更新能及时渲染
await nextTick();
this.setCurrentPage({ cutOriginUrl: imgUrl, cropImage: image, ... });
this.cutoutLoading = false;
}
拆分和调度任务、让事件能快速响应。
![]()
工具函数:nextTick 实现
项目中已经实现了优化的 nextTick 函数(src/utils/index.ts),通过让出主线程控制权,确保浏览器有机会完成渲染和响应用户交互:
/**
* nextTick - 将任务推迟执行,优化 INP (Interaction to Next Paint)
*
* 改进点:
* 1. 优先使用 scheduler.postTask (如果可用) 来更好地控制任务优先级
* 2. 使用 MessageChannel 确保任务在下一个事件循环执行,不阻塞渲染
* 3. 双重 requestAnimationFrame 作为降级方案,确保 DOM 更新后执行
*
* @param callBack 要执行的回调函数
* @returns Promise<void>
*/
export function nextTick(callBack?: () => void) {
return new Promise<void>((resolve) => {
// 优先使用 scheduler.postTask (Chrome 94+, 更好的任务调度)
if (typeof (window as any).scheduler !== 'undefined' && (window as any).scheduler.postTask) {
(window as any).scheduler.postTask(() => {
callBack?.();
resolve();
}, { priority: 'user-blocking' });
return;
}
// 使用 MessageChannel 确保在下一个事件循环执行,不阻塞渲染
// 这对于 INP 优化很重要,因为它让浏览器有机会处理用户交互
if (typeof MessageChannel !== 'undefined') {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
callBack?.();
resolve();
};
channel.port2.postMessage(null);
return;
}
// 降级方案:双重 requestAnimationFrame
// 第一个 rAF 确保在浏览器重绘之前,第二个 rAF 确保在重绘之后
// 这样可以确保 DOM 更新已经完成
if (requestAnimationFrame) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
callBack?.();
resolve();
});
});
return;
}
// 最后的降级方案
if (setImmediate) {
setImmediate(() => {
callBack?.();
resolve();
});
} else {
setTimeout(() => {
callBack?.();
resolve();
}, 0);
}
});
}
使用示例
使用 nextTick() 主动让步。
// 使用项目中的 nextTick 函数
import { nextTick } from 'src/utils';
async function processHeavyCanvas() {
doLightWork();
// 让浏览器有机会处理待渲染的帧和用户交互
await nextTick();
// 这样doHeavyWork会被拆分成单独任务延迟执行
doHeavyWork();
}
为什么选择这些 API?
| API | 优点 | 兼容性 |
|---|---|---|
scheduler.postTask |
支持优先级控制,专为任务调度设计 | Chrome 94+ |
MessageChannel |
宏任务,确保在渲染后执行 | 广泛支持 |
双重 rAF
|
确保在下一帧渲染完成后执行 | 广泛支持 |
setTimeout(0) |
最终兜底方案 | 全平台 |
实战检测与调试
使用 Chrome DevTools 分析 INP
cpu 4 - 6倍降速,模拟低性能设备。 对比本地和线上
![]()
点点功能,观察Performace面板实时的INP
![]()
每次交互的实时INP都会记录在这里。 多点点自己的项目,顺着INP变高的那一步操作找问题即可。
使用 Web Vitals 库监控
import { onINP } from 'web-vitals';
onINP((metric) => {
console.log('INP:', metric.value);
console.log('Entries:', metric.entries);
// 上报到分析平台
if (metric.value > 200) {
analytics.track('slow_interaction', {
value: metric.value,
entries: metric.entries,
target: metric.entries[0]?.target,
});
}
});
分析线上用户真实指标
查看线上真实用户指标
Chrome 用户体验报告(crux)
总结
核心要点
-
INP 测量三个阶段:输入延迟、事件处理、呈现延迟
-
优化目标:确保 75% 的交互在 200ms 内完成
-
核心策略:先反馈,再处理;拆分长任务;让出主线程
-
工具支持:使用
nextTick、scheduler.yield()、Web Worker -
真实用户INP观察: crux查看用户真实INP变化,持续优化。
INP 优化的本质是:让用户"感觉"交互是即时响应的,哪怕后台还在默默干活。
参考资料:
腾讯:与特斯拉合作升级座舱体验,上线“微信互联”“目的地服务”功能
1月份我国汽车行业保持平稳运行
React Hooks 在 Table Column Render 回调中的使用陷阱
📋 问题描述
在使用 Ant Design Table 组件时,尝试将包含 useState Hook 的代码从子组件内联到 Column 的 render 回调中,导致页面黑屏。
场景还原
原始实现(正常工作):
// editButton.tsx - 独立子组件
import { Button } from "antd";
import { EditModal } from "./edit";
import { useState } from "react";
export default function EditButton({ data }) {
const [editOpen, setEditOpen] = useState(false);
const onEdit = () => {
setEditOpen(true);
};
return (
<>
<Button type="link" onClick={onEdit}>
编辑
</Button>
<EditModal isModalOpen={editOpen} setIsModalOpen={setEditOpen} />
</>
);
}
// index.tsx - 父组件中使用
<Column
title="操作"
dataIndex="action"
key="action"
render={(_: any, record: any) => {
return <EditButton data={record} />; // ✅ 正常工作
}}
/>
错误实现(导致黑屏):
// index.tsx - 直接在 render 回调中使用 Hook
<Column
title="操作"
dataIndex="action"
key="action"
render={(_: any, record: any) => {
// ❌ 错误:在回调函数中使用 Hook
const [editOpen, setEditOpen] = useState(false);
const onEdit = () => {
setEditOpen(true);
};
return (
<>
<Button type="link" onClick={onEdit}>
编辑
</Button>
<EditModal isModalOpen={editOpen} setIsModalOpen={setEditOpen} />
</>
);
}}
/>
🔍 问题原因分析
根本原因
违反了 React Hooks 的使用规则:Hooks 只能在 React 函数组件的顶层调用,不能在普通函数、回调函数、循环或条件语句中调用。
技术细节
-
render回调的本质-
Table.Column的render属性接收的是一个普通函数,不是 React 组件函数 - 这个函数在每次渲染时被调用,用于生成每一行的渲染内容
- React 无法在这个函数执行上下文中追踪 Hook 的调用顺序
-
-
React Hooks 的工作原理
- Hooks 依赖于 React 的内部机制来维护状态和副作用
- React 通过调用顺序来识别和管理每个 Hook
- 当 Hook 在非组件函数中调用时,React 无法正确建立 Hook 的调用链,导致运行时错误
-
错误表现
- 开发环境:控制台报错
Invalid hook call. Hooks can only be called inside the body of a function component. - 页面表现:白屏/黑屏(React 错误边界捕获错误,导致整个组件树崩溃)
- 开发环境:控制台报错
错误信息示例
Error: Invalid hook call. Hooks can only be called inside the body of a function component.
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
✅ 解决方案
方案一:保持子组件独立(推荐)
优点:
- 符合 React 组件化设计原则
- 每行数据拥有独立的状态管理
- 代码清晰,易于维护和测试
- 性能优化:可以单独对子组件进行 memo 优化
实现:
// editButton.tsx
export default function EditButton({ data }) {
const [editOpen, setEditOpen] = useState(false);
// ... 其他逻辑
return (/* ... */);
}
// index.tsx
<Column
title="操作"
render={(_: any, record: any) => {
return <EditButton data={record} />; // ✅ 正确
}}
/>
方案二:在父组件顶层管理状态
如果需要在父组件层面统一管理弹窗状态(例如:同一时间只允许打开一个弹窗),可以在父组件顶层使用 Hook:
// index.tsx
export function PolicyTextConfig() {
// ✅ 在组件顶层声明状态
const [editingRecord, setEditingRecord] = useState<any>(null);
const [editOpen, setEditOpen] = useState(false);
const handleEdit = (record: any) => {
setEditingRecord(record);
setEditOpen(true);
};
return (
<>
<Table dataSource={tableData}>
<Column
title="操作"
render={(_: any, record: any) => {
// ✅ render 回调中只调用普通函数,不使用 Hook
return (
<Button type="link" onClick={() => handleEdit(record)}>
编辑
</Button>
);
}}
/>
</Table>
{/* ✅ 弹窗放在组件顶层 */}
<EditModal
isModalOpen={editOpen}
setIsModalOpen={setEditOpen}
data={editingRecord}
/>
</>
);
}
📚 知识点总结
React Hooks 规则
-
只在顶层调用 Hook
- ✅ 在函数组件的最顶层
- ✅ 在自定义 Hook 的最顶层
- ❌ 不在循环、条件或嵌套函数中
- ❌ 不在普通回调函数中
-
只在 React 函数中调用 Hook
- ✅ React 函数组件
- ✅ 自定义 Hook
- ❌ 普通 JavaScript 函数
- ❌ 事件处理函数
- ❌ 渲染回调函数(如
render、map回调等)
常见陷阱场景
| 场景 | 是否正确 | 说明 |
|---|---|---|
组件顶层 useState
|
✅ | 正确用法 |
自定义 Hook 中 useState
|
✅ | 正确用法 |
map 回调中 useState
|
❌ | 错误:普通函数 |
onClick 中 useState
|
❌ | 错误:事件处理函数 |
Table.Column render 中 useState
|
❌ | 错误:渲染回调函数 |
条件语句中 useState
|
❌ | 错误:违反调用顺序规则 |
🎯 最佳实践
-
组件化优先
- 需要状态管理的 UI 片段,优先提取为独立组件
- 保持组件的单一职责原则
-
状态提升 vs 状态下沉
- 如果多个组件需要共享状态 → 状态提升到父组件
- 如果状态只属于单个组件 → 保持在组件内部
-
代码审查检查点
- 检查所有 Hook 调用是否在组件顶层
- 检查是否有在回调函数中使用 Hook 的情况
- 使用 ESLint 插件
eslint-plugin-react-hooks自动检测
🔧 调试技巧
-
查看控制台错误
- React 开发模式会给出明确的错误提示
- 关注 "Invalid hook call" 相关错误
-
使用 React DevTools
- 检查组件树结构
- 查看 Hook 调用情况
-
ESLint 配置
{ "plugins": ["react-hooks"], "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } }
📝 总结
这次踩坑的核心教训是:React Hooks 有严格的调用规则,任何违反规则的使用都会导致运行时错误。 在 Ant Design Table 的 render 回调中,应该只返回 JSX 或调用普通函数,而将需要 Hook 的逻辑封装在独立的组件中。
通过这次问题,我们更加理解了:
- React Hooks 的设计原理和限制
- 组件化设计的重要性
- 代码重构时需要保持对 Hook 规则的敏感性
前端向架构突围系列 - 基建与研发效能 [10 - 1]:物料体系的工程化治理
前言
很多公司吹嘘自己有“自研组件库”,点开一看,其实就是把 Ant Design 的按钮改了个颜色,再套个壳。
真正的物料基建,不是为了解决“按钮长什么样”,而是为了解决“为什么我的项目里有 18 个逻辑一模一样的搜索框,且没一个能直接复用”。如果你的基建不能让业务开发在面对 PM 的奇葩需求时少写 50% 的代码,那它就是个摆设。
![]()
一、 认知突围:物料是“业务逻辑”的载体
在架构师眼中,物料的治理不应停留在 UI 视觉层,而应深入到业务语义层。
1.1 从“工具包”到“资产库”
-
UI 组件 (Low Level): 解决的是“样式统一”。(如:Modal, Select)
-
业务物料 (High Level): 解决的是“行为统一”。
- 例子: “用户选择器”不仅是一个下拉框,它背后关联着:接口鉴权、防抖搜索、分页加载、头像渲染。
- 深度治理: 如果每个业务线都自己写一遍这套逻辑,那就是 10 倍的维护成本。
1.2 为什么大部分物料库会走向“腐烂”?
-
过度封装: 为了支持所有场景,给一个组件开了 50 个 Props,最后代码里全是
if/else。 - 文档滞后: 开发者看文档像是在猜灯谜,最后发现“看源码比看文档快”。
- 版本割裂: 核心库升级了,业务线不敢升,最后全公司跑着 5 个版本的组件库。
二、 深度工程化:物料的“生产流水线”
要让物料体系真正流转起来,架构师必须构建一套**“非人治”**的自动化链路。
2.1 基于 AST 的“自动化元数据提取”
别再让开发者手写文档了。利用 TypeScript 的编译器 API(Compiler API),在物料发布时自动扫描源码:
- 自动提取 Props 定义、注释、默认值。
- 自动生成 API 表格。
- 自动识别依赖项。
- 意义: 确保“代码即文档”,从根源上消灭文档与代码不一致的问题。
2.2 视觉回归测试:基建的“保险杠”
在企业级治理中,你最怕的就是:改了 A 组件的一个边距,结果 B 业务线的老页面直接塌陷了。
- 方案: 引入 Visual Regression Testing(如 Playwright + Pixelmatch)。
- 实战: 在 CI 环节,自动化对比组件修改前后的像素差异。哪怕只是偏移了 1px,也要在 PR 阶段被拦截。
三、 治理逻辑:如何让物料“好找且敢用”
3.1 建立“物料索引市场” (Discovery System)
如果一个物料不能在 30 秒内被开发者搜到,那它就不存在。
- 智能搜索: 不止搜名称,更要搜“功能描述”。(搜“上传照片”,能关联出“图片裁剪”和“头像上传”)。
- 在线 Sandbox: 必须提供即时预览和代码试运行。开发者应该在“买”之前,先在浏览器里把玩一下。
3.2 影子测试与灰度策略
核心物料升级时,利用 Babel 插件或 Webpack 插件,在编译阶段分析业务代码的覆盖情况。
- 深度实践: 统计哪些业务方使用了该物料的哪些属性。如果某属性没有任何人用,直接在下一版本废弃(Deprecate),保持物料库的“轻盈”。
四、 架构师的权衡:标准化 vs 灵活性
这是一个经典的架构陷阱:物料封装得越死,复用性越高,但灵活性越差。
4.1 经典陷阱:“千手观音”组件
想象一下,你们团队需要一个“开关 (Switch/Toggle)”组件。
起初(标准化阶段): 基础架构组设计了一个极其标准的 <StandardSwitch />。它只有两个属性:checked 和 onChange。样式是写死的:圆角、蓝色背景。大家用得很开心,规范统一。
后来(灵活性需求爆发):
- 业务线 A:我们的产品主色调是红色,能改颜色吗?
- 业务线 B:我们要搞促销活动,这个开关得是方形的,里面还要加个文字图标。
- 业务线 C:我们需要把开关放在一个极小的空间里,尺寸能自定义吗?
结果(架构腐化): 为了满足这些需求,<StandardSwitch /> 被迫增加了几十个 Props:color, borderRadius, size, showIcon, iconContent...
最终,这个组件变成了一个长着无数只手的“千手观音”,内部充斥着复杂的样式判断逻辑,维护成本极高,且性能堪忧。
4.2 破局思路:Headless (无头) 组件
Headless 的核心思想是:将“逻辑的脑子”与“渲染的皮囊”彻底分离。
- 有头组件 (Traditional): 买电脑送显示器。你想换个 4K 屏?对不起,主机和屏幕焊死在一起了。
- 无头组件 (Headless): 只卖主机。你爱接 4K 屏、带鱼屏还是投影仪,随你便。
架构图解:分离的艺术
我们来看下 Headless 模式下,组件的分层架构:
如上图所示:
-
底层 (Headless 逻辑层): 封装了所有“脏活累活”。比如,开关的状态切换、按空格键触发切换、盲人阅读器的
aria-checked属性支持等。这些逻辑是通用的,与 UI 无关。 -
顶层 (UI 渲染层): 完全由业务方自己决定。他们可以使用
<div>,<span>, CSS-in-JS, Tailwind CSS,想画成圆的就画成圆的,想画成方的就画成方的。
4.3 代码实例:从“千手观音”到“灵活组装”
我们用 React Hooks 来演示一下这个转变(Vue 的 Composition API 同理)。
场景:实现一个 Switch 开关
1. 定义 Headless Hook (只管逻辑):
这个 Hook 包含了开关的所有核心能力,但不涉及任何 DOM 和 CSS。
// useSwitch.ts (物料库提供)
import { useState, useCallback } from 'react';
export function useSwitch(initialState = false) {
// 1. 状态管理
const [isOn, setIsOn] = useState(initialState);
// 2. 交互逻辑
const toggle = useCallback(() => setIsOn(v => !v), []);
// 3. 辅助功能 (A11y) 属性生成器
const getSwitchProps = () => ({
role: 'switch',
'aria-checked': isOn,
tabIndex: 0,
onClick: toggle,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
}
});
// 只返回状态和逻辑方法
return { isOn, toggle, getSwitchProps };
}
2. 业务方 A 的实现(标准圆角蓝风格):
业务方拿到了逻辑,自己决定怎么渲染。
// BusinessA_Switch.jsx (业务方 A 自定义)
import { useSwitch } from '@my-org/hooks';
import styled from 'styled-components';
const StyledButton = styled.button`
background-color: ${props => props.isOn ? 'blue' : 'gray'};
border-radius: 9999px; // 圆角风格
// ... 其他样式
`;
export function StandardSwitch() {
// 使用 Headless 能力
const { isOn, getSwitchProps } = useSwitch();
return (
// 将逻辑属性解构赋值给 UI 元素
<StyledButton isOn={isOn} {...getSwitchProps()}>
<span className="thumb" />
</StyledButton>
);
}
3. 业务方 B 的实现(方形红色促销风格):
业务方 B 可以完全复用逻辑,画出截然不同的 UI。
// BusinessB_PromoSwitch.jsx (业务方 B 自定义)
import { useSwitch } from '@my-org/hooks';
export function PromoSwitch() {
const { isOn, getSwitchProps } = useSwitch();
return (
// 使用 Tailwind 编写完全不同的方形样式
<div
{...getSwitchProps()}
className={`${isOn ? 'bg-red-500' : 'bg-zinc-300'} w-16 h-8 rounded-none flex items-center cursor-pointer`}
>
<div className="bg-white w-6 h-6 mx-1 rounded-none">
{isOn ? '开' : '关'}
</div>
</div>
);
}
4.4 总结
通过 Headless 模式,架构师完成了对权力的完美让渡:
- 架构师守住了底线: 核心交互逻辑、状态流转、可访问性标准被统一封装,不会因为业务方的 UI 定制而产生逻辑 Bug。
- 业务方得到了自由: 他们再也不用为了改个颜色而去求基础架构组加 Props 了。
这就是那句格言的深层含义:
“给业务方留一扇窗(UI 自定义能力),他们就不会想拆掉你的墙(核心逻辑封装)。”
五、 总结:从“重复造轮子”到“按需组装”
5.1 研发复利:架构师的“长期主义”
在一般的团队中,工作量是随项目数量线性增长的;而在拥有顶级物料治理的团队里,工作量曲线应该是对数级的。
- 初期(高投入): 你可能花了 2 周才磨合出一个完美的、Headless 架构的“财务大搜表”物料。
- 后期(高回报): 当公司要开 5 个新的后台管理页面时,开发者只需要花 10 分钟引入物料并配置 Schema。由于物料已经在 100 个场景下跑过,其**健壮性(Robustness)**是任何新写的代码都无法比拟的。
5.2 案例对比:从“冷启动”到“一键飞升”
我们来看一个实际的业务场景:实现一个带“权限控制”和“自动重试”功能的图片上传组件。
| 维度 | 传统“造轮子”模式 (No Infrastructure) | “按需组装”模式 (Material Asset) |
|---|---|---|
| 开发耗时 | 3 - 5 小时(找文档、调 API、写逻辑、调样式) | 5 - 10 分钟(拖拽组件,填写 API Key) |
| 代码量 | 150+ 行(逻辑散落在各个组件中) | 3 行(纯声明式配置) |
| 稳定性 | 极低(不同人写的代码,异常处理逻辑不一) | 极高(物料自带熔断、重试、OSS 分片逻辑) |
| 可维护性 | 噩梦(后端 API 一改,全项目全局搜索替换) | 轻松(物料中心统一升级,全线同步生效) |
- 向上管理: 用数据告诉老板,你搞的基建到底省了多少钱(换算成工时)。
- 向下优化: 如果某个物料的使用率为 0,说明要么是不好用,要么是没推广,架构师应及时止损,将其踢出资产库。
结语:交付的最后一公里
研发效能不是看你写代码有多快,而是看从“代码提交”到“用户可用”的总时长(Lead Time)。
1. 现状对比:手工业 vs. 工业化流水线
如果交付链路不打通,你的基建就像是在泥潭里开跑车。
| 环节 | 人肉运维 (手工时代) | DevOps 交付 (架构时代) |
|---|---|---|
| 构建 | 开发者在本地执行 npm build,环境不一致导致“我本地明明是好的”。 |
云端环境统一构建(Docker 镜像),保证 100% 环境一致。 |
| 部署 | 找运维开权限,手动 FTP 上传或 SSH 到服务器执行 git pull。 |
合入即部署。代码通过测试自动触发部署,开发者无需关注服务器。 |
| 配置 | 手动修改 Nginx 转发规则、配置跨域、刷新 CDN 缓存。 | 配置即代码 (IaC) 。Nginx 和路由配置随代码版本走,一键生效。 |
| 回滚 | “快!把刚才那个备份压缩包覆盖回去!”(手忙脚乱中可能覆盖错版本)。 | 秒级回滚。通过镜像版本切换,一键回到任何稳定时刻。 |
2. 图解:交付链路的“能量损耗”
我们可以用一个“水管模型”来理解交付瓶颈:
警示:
如果中间那段“交付管道”是细窄的、堵塞的,那么你前端基建做得再大(漏斗再宽),最终流向用户的价值流速依然由那根细管子决定。
字节跳动据悉开发AI芯片,并与三星洽谈代工事宜
上海:建立长三角科技政策“互认清单”,组建长三角重大科技基础设施联盟
万字长文之——AI落地规范
AI已经走入普通大众的生活,那么在程序员的世界中呢?我想,随着AI的浪潮滚滚前进,我们的工作中一定或多或少使用到了AI工具。比如一开始的vscode中的代码提示插件,如GitHub Copilot、通义灵码等,到现在的代码生成编辑器,如Cursor、Trae等。
一开始尝试使用Cursor时,会不禁感叹,这个真牛啊!它真的有这么厉害?我不信,让我来挑挑刺:
你看,他生成的代码风格和现有工程不一致;
你看,你看,他需求理解歪了啊,需求你来看看,是不是理解错了;
你看,你看,你看,他每次生成的代码都不一致啊,这怎么维护啊,ababab~ ~ ~
那么,真要放弃AI吗?那肯定不能,毕竟能干活啊;那问题来了,怎么让他更趁手些呢?在努力近一年后,我尝试写一写在现有工程中让 AI 辅助开发能够稳定落地的一些规范与实践。
一、为什么需要「AI 落地规范」
在真实业务项目里引入 AI(如 Cursor、Claude Code)写代码时,常遇到:
- 生成代码风格与现有工程不一致:目录、命名、组件结构各写各的。
- 需求理解偏差:缺少结构化需求与接口说明,AI 只能“猜业务”,更容易出现幻觉。
- 可维护性差:没有统一规则,后人或 AI 难以在既有约定下续写。
要解决这些问题,需要两件事同时到位:
- 输入规范:需求、接口、配置等写成「AI 可读、可执行」的结构化文档。
- 输出规范:用项目规则(如 Cursor Rules)约束 AI 的目录、命名、组件与接口写法。
下面以一个vue项目中的模块「xxxx缴费记录查询」和 xxx-xxx-h5-rules 为例,说说我们团队是如何做的。
二、输入规范:把需求写成 AI 读得懂的语言
AI 落地的前提是:需求与接口被清晰、结构化地写出来,而不是只存在于口头上或零散的聊天里(比如多轮对话,这样只会让代码成为一坨,,,)。
2.1 功能文档结构
xxxx缴费记录查询/
├── xxxx缴费记录查询-需求.md # 功能概述、流程、页面与规则
├── xxxx缴费记录查询接口-需求.md # 接口入参、出参、取值与规则
├── 系统参数配置.md # 提示信息、开关等可配置项
└── 原型文件/ # HTML/CSS/JS 原型
- 需求文档(前端用):功能概述、流程图(如 Mermaid)、页面分区(导航栏、查询区、列表区)、控件表(名称、类型、必录、规则、数据元)、页面规则(如 4.1 页面初始化、4.2 xx数据查询规则)。
- 接口需求文档(后端用):接口概述、入参/出参表(含数据元标识、字段名、类型、是否可空)、接口规则(入参校验、表来源、取值规则)。
- 系统参数配置(共用):按模块/页面列出可配置项(如主页面提示信息),便于前端从配置读取而非写死。
这类结构让 AI 能够:按「页面 + 控件 + 规则」生成页面与交互;按「入参/出参 + 规则」生成 API 调用与 DTO;按「配置项」生成从 store/config 读取的提示或开关。
2.2 AI下的需求文档该怎么写
统一使用对AI更友好的.md文档编写需求,这对需求侧的同学也是一个不小的挑战,但我们认为这是很有必要的!
一、功能概述
用一两句话说明功能面向谁、做什么、范围是什么。例如:
本功能支持xxx
- 对 AI 的用途:确定模块边界、页面标题、路由命名。
二、详细流程
先画流程图(建议用 Mermaid),再配简短流程说明。例如:
sequenceDiagram
actor 用户
participant 本功能 as 本功能
participant 外部系统 as 外部系统
rect rgba(135, 206, 250, 0.4)
Note over 用户,外部系统: 1. 【页面初始化】
用户->>本功能: 1) 访问xxxx缴费记录查询页面
本功能->>外部系统: 引用4.1规则初始化
外部系统-->>本功能: 返回初始化配置及数据
本功能-->>用户: 展示xxxx缴费记录
end
流程说明示例:
- 【页面初始化】访问功能后,系统获取个人信息及缴费列表数据,展示xxxx缴费记录查询主页。引用 4.1 规则初始化。
- 写法要点:流程与后文「四、页面规则说明」中的规则编号对应(如“引用 4.1”),便于实现时按规则写初始化逻辑。
- 对 AI 的用途:生成 mounted/created 中的调用顺序,知道先取配置再取列表。
三、页面需求分析
按页面 → 区域 → 控件分层,用表格把每个区域的控件写清楚。
-
导航栏:操作按钮 + 标题。用表格式列出控件名称、可操作、控件类型、交互事件及规则;标题单独一行说明。
-
页面提示区域:不写死文案,而是引用配置。例如:
提示内容取自「系统参数配置.md」,具体路径:xxxx缴费记录查询 → 主页面提示信息参数配置,若未配置则不显示。
这样 AI 会生成「从配置读取,未配置则不展示」的逻辑,而不是写死一段文字。
-
查询条件选择区域:用一张表统一描述控件,列建议包含:
列名 说明与示例 控件名称 xxx 控件类型 年度选择框、下拉框 可操作/必录/是否可见 Y/N,便于做校验与展示 初始状态/数据 默认当前系统年度、暗文「请选择」 操作规则及取数口径 格式 YYYY、最大不超过当前年度、下拉选项:xxxx;引用“xx数据查询规则” 数据元字段名 与接口对齐 示例(节选):
控件名称 控件类型 可操作 必录 是否可见 初始状态/数据 操作规则及取数口径 年度 年度选择框 Y Y Y 默认显示当前系统年度 格式 YYYY;最大不能超过当前系统年度。引用“xx数据查询规则、xx组件” xx 下拉框 Y N Y 暗文:请选择 下拉选项:xxx。引用“xx数据查询规则” -
列表区域:同样用表列出「展示项 + 取数口径 + 数据元」。例如缴费金额写清单位、格式(¥、保留两位小数)。
这样 AI 能直接生成列表列定义和格式化逻辑。
四、页面规则说明
用编号规则把「页面初始化」「查询规则」等说清楚,并与前文引用一致。
-
4.1 页面初始化:逐条写“根据哪份配置做哪件事”“根据哪条规则初始化数据”。例如:
- 根据《系统参数配置.md》对“页面提示区域”进行初始展示。
- 根据“xx数据查询规则”初始化展示页面数据。
-
4.2 缴费数据查询规则:
- 先写查询条件转换;
- 再写调用哪个接口。
AI 可根据规则生成查询参数组装和接口调用。
2.3 我们为什么规定这样写需求文档?
| 维度 | 写法 | 好处 |
|---|---|---|
| 结构化 | 功能概述 → 流程 → 页面分析 → 规则说明;接口概述 → 入参/出参 → 接口规则 | 层次清晰,AI 和人都能按章节定位;先“做什么”再“怎么做”,减少遗漏。 |
| 表格化 | 控件表、入参表、出参表、代码映射表 | 机器可读性强,AI 可直接用于生成表单字段、请求参数、列表列、常量映射;前后端对齐同一张表,字段名和类型一致。 |
| 引用与编号 | “引用 4.1 规则”“引用xxxz组件”“引用xx数据查询规则”“根据系统参数配置.md” |
需求内可追溯,实现时按编号找到对应规则及组件;提示区、查询逻辑等不写死,便于多地区配置。 |
| 数据元与取数口径 | 数据元标识符、字段名、取数口径、格式备注 | 与标准数据元一致,利于对接;取数口径让前端知道哪些字段是后端计算、如何展示。 |
| 流程 + 规则 | Mermaid 流程图 + 规则编号 | 先有时序再有条文,AI 能生成正确的调用顺序和初始化逻辑;规则可单独实现、单独测试。 |
| 配置与代码分离 | 提示内容取自系统参数配置、若未配置则不显示 | 文案和开关可配置,AI 生成“读配置再展示”的代码,避免硬编码,便于多环境。 |
整体上,需求文档按“概述 → 流程 → 页面/接口表格 → 规则说明”来写,并用表格和编号把控件、字段、映射、规则、组件编号固定下来,既能让人一眼看懂,也能让 AI 按表生成代码、按规则生成逻辑,减少歧义和返工,也方便后续的1—N迭代。
2.4 需求文档中建议包含的要素
| 要素 | 说明 | 对 AI 的用途 |
|---|---|---|
| 功能概述 | 一两句话说清功能做什么、谁用 | 确定模块边界、路由/菜单命名 |
| 流程图 | 用 Mermaid 等画清主流程 | 生成顺序逻辑、调用关系 |
| 页面分区 | 导航栏、提示区、查询区、列表区等 | 对应 template 区块与组件 |
| 控件表 | 控件名称、类型、必录、可见性、初始值、操作规则、数据元 | 生成表单项、校验、字段绑定 |
| 数据元 | 中文名、标识符、字段名、类型、长度 | 与接口字段、DTO 对齐 |
| 页面规则 | 如「4.1 页面初始化」「4.2 xx数据查询规则」 | 生成 mounted/created 逻辑与查询条件转换 |
接口需求文档中则建议包含:
- 接口概述、入参/出参表(含数据元与检索策略);
- 入参校验、涉及表、关键取值规则。
这样 AI 生成的 API 封装、请求参数和 DTO 才能与后端约定一致。
2.5 系统参数配置的用法(可选)
「系统参数配置.md」中按模块列出「主页面提示信息参数配置」等,并说明使用场景、配置项、默认值。
前端应:从配置读取提示语等,而不是在页面里写死。这样 AI 生成的代码会去读 state.configs 或等价配置,便于多地区/多环境差异化。
三、输出规范:用项目规则约束 AI 的代码
有了清晰输入,还需要统一输出:目录、命名、组件结构、API 与状态管理方式都要和现有项目一致。本项目中通过 xxx-xxx-h5-rules 承担这一角色。
3.1 规则体系概览(xxx-xxx-h5-rules)
| 规则文件 | 作用 | 对 AI 的约束 |
|---|---|---|
| project-structure.mdc | 项目结构、入口、路由位置 | 新模块放在哪、如何拆目录 |
| vue-coding-standards.mdc | Vue 组件顺序、JS 规范、路由/Vuex/API 约定 | 组件结构、命名、mapState/mapMutations 用法 |
| component-development.mdc | 通用/业务组件、标准父/子页面结构 | 页面用 xxx + keep-alive、子页面 name 与 class 命名 |
| api-integration.mdc | HTTP 封装、接口组织、错误与加密 | api 模块写法、http 使用方式 |
| business-modules.mdc | 业务模块列表、新增模块步骤 | 路由/store/api 注册顺序与命名 |
| build-deployment.mdc | 构建、环境、部署 | 不随意改构建与公共路径 |
这些规则应被 Cursor 等工具识别(如通过 .cursor/rules 或说明中引用),让 AI 在生成代码时默认遵循。
3.2 部分 mdc 文档说明
规则文件不只有代码,而是「说明 + 列表 + 目录树 + 代码块」组合。下面按文件摘录部分原文,供大家参考。
README.mdc(总览)
---
alwaysApply: true
description: xxx-xxx-h5 项目规则总览
---
# xxx-xxx-h5 项目规则总览
## 规则文件说明
本目录包含 xxx-xxx-h5 项目的所有 Cursor 规则文件,用于指导 AI 助手更好地理解和开发该项目。
### 规则文件列表
1. **project-structure.mdc** - 项目结构指南
- 项目概述和核心目录结构
- 模块化架构特点说明
- 主要入口文件和配置文件位置
2. **vue-coding-standards.mdc** - Vue 2.x 编码规范
- Vue 组件开发规范
- JavaScript 编码标准
- 路由和状态管理规范
...
- 用 frontmatter 声明
alwaysApply: true、description,便于 Cursor 按需加载或常驻。 - 用 编号列表 + 子项 说清每个规则文件负责什么,AI 能快速判断该查哪个文件。
project-structure.mdc(结构说明)
# xxx-xxx-h5 项目结构指南
## 项目概述
xxx-xxx-h5 是一个基于 Vue 2.x 的 H5 应用,采用模块化架构设计。
## 核心目录结构
### 主要入口文件
- main.js - 应用主入口,配置 Vue 实例和全局插件
- App.vue - 根组件
- vue.config.js - Vue CLI 配置文件
### 路由系统
- router/index.js - 主路由配置,包含所有模块路由
- router/ - 各功能模块的路由配置
...
## 模块化架构特点
项目采用功能模块化设计,每个业务功能都有对应的:
- 路由配置 (router/)
- 状态管理 (store/)
- API 接口 (api/)
- 页面组件 (views/)
这种设计便于维护和扩展新功能。
-
先概述再拆目录,
AI 能建立「模块 = router + store + api + views」的心智模型。 - 无代码也可约束「新模块该建哪些目录、放在哪」。
vue-coding-standards.mdc
---
globs: *.vue,*.js
description: Vue 2.x 编码规范和最佳实践
---
### 组件命名
- 组件名使用 PascalCase
- 文件名与组件名保持一致
### 路由命名
- 路由名称使用 camelCase
- 路径使用 kebab-case
- 模块路由统一导入到主路由
- 使用常量导出路由名称,便于维护
-
globs 限定只在
*.vue,*.js下生效,避免在无关文件中误用。 - 条文式约定(命名、路径、常量)无需代码也能约束生成结果。
同一文件内再配「获取系统配置」的说明 + 注意事项 + 代码:规则里先写「在组件中获取系统配置必须使用以下方式」,再贴代码;接着用注意事项列出四条(用户信息走 getters、系统配置走 state、箭头函数、避免在 methods 里访问 store)。示例代码:
// vue-coding-standards.mdc 中规定的方式
export default {
computed: {
sbfConfigs: (vm) => {
return vm.$store.state.configs.xxx
}
}
}
- 先写「必须使用以下方式」再贴代码,AI 会优先采用该写法。
- 注意事项 能减少「能跑但不符合项目习惯」的生成(如把 config 放在 methods 里)。
component-development.mdc
### 组件设计原则
- 单一职责:每个组件只负责一个功能
- 可复用性:组件应该可以在不同场景下复用
- 可配置性:通过 props 提供灵活的配置选项
- 可扩展性:支持插槽和事件扩展
### 标准页面组件结构
- 页面组件放在 `views/` 目录
- 按功能模块组织子目录
- 组件名使用 `moduleName.pageName` 格式
- 父级组件使用 `xxTransition` 和 `keep-alive`
- 设计原则 是纯文字,但能引导 AI 不写「大而全」的单文件页面。
- 标准页面组件结构 四条直接对应「放哪、怎么命名、用什么包裹」,和后面的代码块一致。
api-integration.mdc
### 模块化 API 结构
api/
├── index.js # API 主入口
├── account.js # 账户相关接口
├── apply.js # 申请相关接口
├── basecode.js # 基础代码接口
├── pay.js # 支付相关接口
└── ... # 其他业务模块接口
### 核心 HTTP 配置
- core/http.js - 基础 HTTP 配置
- core/httpEncrypt.js - 加密 HTTP 配置
- core/BusinessError.js - 业务错误处理
- 目录树 让 AI 知道新模块的 api 文件应放在哪、如何与 index 挂载。
-
核心 HTTP 配置 列出文件与职责,生成请求代码时会联想到用
@/core/http、BusinessError。
3.3 这样写 mdc 规则的好处
| 写法 | 好处 |
|---|---|
| frontmatter(alwaysApply / globs / description) | 工具可按路径或全局决定何时注入规则,减少无关上下文;description 便于人和 AI 快速理解文件用途。 |
| 分文件(结构 / Vue / 组件 / API / 业务 / 构建) | 单文件短小、职责清晰,AI 检索时容易命中「路由」「组件」「API」等关键词;后续增删规则不影响其他领域。 |
| 说明 + 列表 + 代码一起写 | 既有「必须」「应」等约束性文字,又有可复制的模板代码,AI 既知道「为什么这样」又知道「长什么样」,生成时更少偏离。 |
| 注意事项 / 命名规范等条文 | 不依赖代码也能约束命名、数据来源(如 config 只从 computed 取)、错误处理方式,避免「风格正确但细节不符合项目习惯」。 |
| 目录树与入口说明 | 明确新模块该建哪些目录、在 index 里如何挂载,AI 生成的目录和 import 关系更一致。 |
| 标准模式 + 复杂模式并存 | 简单业务用标准模式(reset/set、queryList),复杂业务用 actions/getters,AI 能按需求选模板,而不是自己发明一套。 |
整体上,**把规则写成「可执行的说明 + 可粘贴的代码」**,并配合 frontmatter 与分文件组织,能让 AI 在正确时机、正确范围内套用约定,减少反复修改和风格漂移,也便于新人或后续 AI 继承同一套规范。
四、总结
-
新功能先写清需求与接口
至少具备:功能需求(含流程与页面分析)、接口需求(入参/出参/规则)、与前端相关的系统参数配置。 -
把项目规则显式化
像 xxx-xxx-h5-rules 一样,把结构、Vue、组件、API、业务模块、构建拆成独立规则文件,并让 AI 在上下文中能读到这些规则。 -
模块命名与注册统一
业务模块用拼音首字母;新增模块严格按 router → store → api → views 的顺序注册,并在主入口中挂载。 -
配置与数据元不写死
提示语、开关等走系统参数配置;字段名、数据元与接口文档一致,便于前后端联调与 AI 二次修改。 -
用现有模块当模板
让 AI 参考历史模块的 router/store/api/views 实现方式,再结合需求与接口文档生成新模块,可减少风格偏差。
五、结语
AI 要在现有工程里稳定落地,不能只靠「说人话」提需求,而要:
-
输入侧:
把需求、接口、配置写成结构化、可检索的文档,后期可引入向量库编排; -
输出侧:
用项目规则(如 xxx-xxx-h5-rules)约束目录、命名、组件、API 和状态管理。
AI 落地规范是一个系统化工程,本文仅介绍了需求侧和代码生成侧部分,还有很多地方没有提到,谨供大家参考。
上海:完善“沪港通”“债券通”“互换通”等机制安排
Wujie 微前端架构下的跨应用状态管理实践:Props + Bus 双通道方案
在基于 Wujie 的微前端架构中,主应用与子应用之间的状态同步是一个绑不开的难题。本文分享我们在生产项目中的实践:如何用最简洁的方式,实现系统级数据的单一可信源和实时同步,同时避免过度设计的陷阱。
背景
我们的项目是一个典型的企业级 B 端系统,采用 Vue 3 + Pinia + Wujie 的微前端架构:
- 1 个主应用:负责登录、菜单、布局、系统级数据管理
- 6+ 个子应用:各自独立开发部署,通过 Wujie 嵌入主应用运行
技术栈:Vue 3.5 / Pinia 3 / Vite 7 / TypeScript 5 / Wujie 1.0
问题:6 个子应用,6 份重复数据
随着子应用数量增长,我们遇到了一系列状态管理问题:
1. 字典数据 N+1 次请求
主应用启动时请求一次全量字典,但每个子应用启动后又各自请求一次。6 个子应用 = 7 次相同的接口调用。
2. 语言切换不同步
用户在主应用切换语言后,已加载的子应用仍然显示旧语言,必须刷新页面才能生效。
3. Token 刷新断裂
主应用静默刷新 Token 后,子应用持有的旧 Token 继续发请求,触发 401 错误。
4. 用户信息传递不完整
主应用通过 Wujie props 只传了 token 和 userInfo,但子应用还需要 permissions(权限列表)和 roles(角色树),只能自己再请求一次。
5. 数据源不唯一
同一份用户数据,在主应用的 Pinia Store 里有一份,在每个子应用的 Pinia Store 里又各有一份。数据不一致的风险始终存在。
归结为一句话:缺少一个统一的跨应用状态分发机制。
方案选型:我们踩过的坑
尝试一:封装 npm 包(过度设计)
最初我们的方案是创建一个 @cmclink/shared-stores 基础包:
packages/shared-stores/
├── src/
│ ├── auth.ts # useSharedAuth() composable
│ ├── dict.ts # useSharedDict() composable
│ ├── locale.ts # useSharedLocale() composable
│ ├── provider.ts # createSharedStoreProvider()
│ ├── utils.ts # isInWujie() / getWujieBus()
│ ├── types.ts # 12 个类型定义
│ └── constants.ts # 事件常量
看起来很"工程化",但实际落地后发现几个问题:
- 增加了复杂度却没带来多少收益。系统级数据本就由主应用维护,子应用只需要只读消费,多一个包多一层抽象,反而增加了理解成本和维护负担。
-
Provider 的 watch 机制失效。我们设计了
createSharedStoreProvider(bus, source)来监听 Store 变化,但source.auth()每次返回新的普通对象,Vue 的watch根本追踪不到响应式变化——整个广播机制是失效的。 - 子应用被迫依赖这个包。本来子应用只需要读 props 和监听 bus,现在还要安装一个额外的 npm 包。
最终方案:回归简洁
反思后我们确立了核心原则:
系统级数据由主应用独占维护,通过 Wujie 原生机制(props + bus)只读分发给子应用。不引入额外的包,不搞抽象层。
架构设计
Store 三层分类
┌─────────────────────────────────────────────────┐
│ Layer 0 — 系统级(主应用独占维护,子应用只读) │
│ userStore / dictStore / localeStore │
├─────────────────────────────────────────────────┤
│ Layer 1 — 应用级(主应用独有) │
│ tabsStore / messageStore / historyStore │
├─────────────────────────────────────────────────┤
│ Layer 2 — 业务级(各子应用独有) │
│ orderStore / routeStore / blStore ... │
└─────────────────────────────────────────────────┘
关键区分:Layer 0 的数据需要跨应用共享,Layer 1 和 Layer 2 不需要。只对 Layer 0 做状态分发,保持最小化。
双通道通信协议
利用 Wujie 自带的两个通信机制:
| 通道 | 机制 | 用途 | 特点 |
|---|---|---|---|
| Channel 1 | wujie props | 冷启动初始快照 | 同步、可靠、子应用启动即可用 |
| Channel 2 | wujie bus | 运行时增量同步 | 异步、实时、事件驱动 |
props 结构:
{
$shared: {
auth: { token, refreshToken, userId, permissions, roles, userInfo },
dict: { dictMap },
locale: { lang }
},
// 向后兼容旧字段
token: '...',
userInfo: { ... }
}
bus 事件:
| 事件 | 载荷 | 触发时机 |
|---|---|---|
SHARED:AUTH_UPDATED |
{ token, permissions, roles, userInfo } |
权限/角色/用户信息变更 |
SHARED:TOKEN_REFRESHED |
{ token, refreshToken } |
Token 静默刷新后 |
SHARED:DICT_UPDATED |
{ dictMap, version } |
字典数据加载完成 |
SHARED:LOCALE_CHANGED |
{ lang } |
语言切换 |
SHARED:LOGOUT |
void |
用户登出 |
两者互补:props 解决冷启动,bus 解决热更新。
核心实现
整个方案的核心就一个文件:主应用的 shared-provider.ts。
主应用侧:Provider
// apps/main/src/stores/shared-provider.ts
import { watch } from 'vue'
import { bus } from 'wujie'
// 事件常量就地定义,不引入额外包
const SHARED_EVENTS = {
AUTH_UPDATED: 'SHARED:AUTH_UPDATED',
TOKEN_REFRESHED: 'SHARED:TOKEN_REFRESHED',
DICT_UPDATED: 'SHARED:DICT_UPDATED',
LOCALE_CHANGED: 'SHARED:LOCALE_CHANGED',
LOGOUT: 'SHARED:LOGOUT',
} as const
export function setupSharedStoreProvider(): void {
const userStore = useUserStoreWithOut()
const dictStore = useDictStoreWithOut()
const localeStore = useLocaleStoreWithOut()
// 直接 watch Pinia store 的响应式属性
watch(
() => ({
permissions: userStore.permissions,
roles: userStore.roles,
roleId: userStore.roleId,
userInfo: userStore.user,
}),
(newVal) => {
bus.$emit(SHARED_EVENTS.AUTH_UPDATED, {
token: getAccessToken(),
...newVal,
})
},
{ deep: true },
)
// 字典加载完成后广播
watch(
() => dictStore.isSetDict,
(isSet) => {
if (isSet) {
bus.$emit(SHARED_EVENTS.DICT_UPDATED, {
dictMap: dictMapToRecord(dictStore.dictMap),
version: Date.now(),
})
}
},
)
// 语言切换
watch(
() => localeStore.currentLocale.lang,
(lang) => bus.$emit(SHARED_EVENTS.LOCALE_CHANGED, { lang }),
)
// $subscribe 兜底检测登出
userStore.$subscribe(() => {
if (!userStore.isSetUser && userStore.permissions.length === 0) {
bus.$emit(SHARED_EVENTS.LOGOUT)
}
})
}
关键细节:直接 watch Pinia store 的响应式属性,而不是通过 getter 函数间接访问。这是我们踩过的坑——如果 watch(() => source.auth().token, ...) 中的 source.auth() 每次返回新对象,Vue 的响应式追踪会完全失效。
非响应式数据的处理
Token 存储在 sessionStorage(通过 wsCache),不是 Pinia 的响应式状态,无法用 watch 监听。我们的做法是在写入点主动广播:
// apps/main/src/utils/auth.ts
export const setToken = (token: TokenType) => {
wsCache.set(CACHE_KEY.REFRESH_TOKEN, token.refreshToken)
wsCache.set(CACHE_KEY.ACCESS_TOKEN, token.accessToken)
// Token 写入后主动广播(动态 import 避免循环依赖)
import('@/stores/shared-provider').then(({ emitTokenRefreshed }) => {
emitTokenRefreshed()
})
}
Logout 同理,在 user.ts 的 logout() action 中主动调用:
async logout() {
await logout()
removeToken()
emitSharedLogout() // 主动广播,确保子应用收到
this.resetState()
}
初始快照注入
主应用的 App.vue 通过 computed 构建 props,每次 Store 变化自动更新:
<WujieVue
v-for="app in loadedApps"
:key="app.name"
:props="sharedProps"
:alive="true"
/>
<script setup>
const sharedProps = computed(() => ({
$shared: {
auth: { token, permissions, roles, userInfo, ... },
dict: { dictMap },
locale: { lang },
},
// 向后兼容旧子应用
token: getAccessToken(),
userInfo: userStore.user,
}))
</script>
子应用侧:只读消费
子应用不需要安装任何额外依赖,直接用 Wujie 原生 API:
// 冷启动:从 props 获取初始数据
const wujie = (window as any).__WUJIE
const shared = wujie?.props?.$shared
if (shared) {
// 微前端环境:使用主应用的数据
authStore.setToken(shared.auth.token)
authStore.setPermissions(shared.auth.permissions)
dictStore.setDictMap(shared.dict.dictMap)
i18n.global.locale.value = shared.locale.lang
} else {
// 独立运行:走本地 API
await authStore.fetchUserInfo()
await dictStore.fetchDictData()
}
// 热更新:监听 bus 事件
wujie?.bus?.$on('SHARED:TOKEN_REFRESHED', (data) => {
authStore.setToken(data.token)
})
wujie?.bus?.$on('SHARED:LOCALE_CHANGED', (data) => {
i18n.global.locale.value = data.lang
})
wujie?.bus?.$on('SHARED:LOGOUT', () => {
authStore.clearLocal()
router.push('/login')
})
通过 __WUJIE 是否存在来判断运行环境,微前端环境走 props/bus,独立运行走本地 API,子应用始终保持独立可运行。
数据流全景
┌──────────────────────────────────────────────────────────┐
│ 主应用 │
│ │
│ API 请求 → userStore / dictStore / localeStore │
│ │ │
│ shared-provider.ts │
│ (watch 响应式属性 → bus.$emit) │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ wujie props wujie bus tabsStore 等 │
│ ($shared) (SHARED:*) │
│ │ │ │
└────────────┼───────────┼──────────────────────────────────┘
│ │
┌─────────┼───────────┼─────────┐
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ doc │ │ ibs │ │ mkt │ │ ... │
│ │ │ │ │ │ │ │
│ 只读 │ │ 只读 │ │ 只读 │ │ 只读 │
│ 消费 │ │ 消费 │ │ 消费 │ │ 消费 │
└──────┘ └──────┘ └──────┘ └──────┘
踩坑记录
坑 1:watch getter 返回新对象导致响应式失效
// ❌ 错误:每次调用 source.auth() 返回新对象,watch 追踪不到变化
watch(() => source.auth().token, (token) => { ... })
// ✅ 正确:直接 watch Pinia store 的响应式属性
watch(() => userStore.permissions, (permissions) => { ... })
这是 Vue 响应式系统的基本原理,但在抽象层过多时很容易忽略。getter 函数如果每次返回新的普通对象,Vue 无法建立依赖追踪。
坑 2:dictStore.dictMap 是 Map 不是 Object
Pinia store 中字典数据用 Map<string, any> 存储,但跨 iframe context 传输时 Map 无法正确序列化。需要转换为普通 Record:
function dictMapToRecord(dictMap: any): Record<string, any[]> {
const result: Record<string, any[]> = {}
if (dictMap instanceof Map) {
dictMap.forEach((value, key) => { result[key] = value })
} else {
Object.assign(result, dictMap)
}
return result
}
坑 3:Token 存在 sessionStorage 中,不是响应式的
Token 通过 wsCache(封装的 web-storage-cache)存储在 sessionStorage 中,不在 Pinia state 里,watch 监听不到。
解决方案:在写入点主动广播,而不是试图监听存储变化。用动态 import() 避免循环依赖。
坑 4:过度抽象的代价
最初我们设计了完整的 composable 层(useSharedAuth、useSharedDict、useSharedLocale),每个都有 fallback 机制、readonly 包装、onScopeDispose 清理。
看起来很优雅,但实际上:
- 子应用只需要读 props + 监听 bus,10 行代码的事
- 多了一个 npm 包依赖,子应用的 package.json 要加,CI 要装
- composable 内部的 wujie 环境检测逻辑和子应用自己写没区别
- 维护成本远大于收益
最终我们删掉了整个包,回归最简方案。
设计原则总结
| 原则 | 说明 |
|---|---|
| 主应用独占维护 | 系统级数据只在主应用写入,子应用只读 |
| 不过度设计 | 不搞 composable 抽象层、不搞额外 npm 包 |
| 用平台能力 | Wujie 自带 props + bus,够用就不造轮子 |
| 在写入点广播 | 非响应式数据(Token)在写入时主动 emit |
| 向后兼容 | 新增 $shared 字段,保留旧的 token/userInfo
|
| 独立可运行 | 子应用通过 __WUJIE__ 环境检测,非微前端环境走本地 API |
效果
- 接口调用:字典请求从 N+1 次降为 1 次
- 语言切换:实时同步,无需刷新
- Token 刷新:主应用刷新后 50ms 内所有子应用同步
- 代码量:主应用新增 1 个文件(~180 行),子应用各减少 3 个冗余 Store
- 依赖:零新增 npm 包
适用场景
这个方案适用于:
- 基于 Wujie(或类似 iframe 沙箱方案)的微前端架构
- 主应用是唯一的系统级数据管理者
- 子应用数量 > 2,且共享用户/权限/字典/语言等全局数据
- 团队希望保持架构简洁,避免过度工程化
不适用于:
- 子应用之间需要双向通信的场景(本方案是单向只读分发)
- 子应用需要修改系统级数据的场景(应该通过 bus 事件请求主应用修改)
本文基于 Wujie 1.0 + Vue 3.5 + Pinia 3 的生产实践,如有问题欢迎交流。
OpenAI奥特曼:在ChatGPT中更新了GPT-5.2
聊聊编程里的“魔法棒”:取余运算(Modulo)
💡 写在前面: 最近面试被问到一个倒计时相关问题,又一次用到了取余(Modulo)。说实话,刚入行那会儿,总觉得这玩意儿不就是小学数学里的
求余数吗?除了面试题里用来判断奇偶数,平时好像也没啥大用。但随着代码写得越来越多,逐渐发现
%符号背后其实隐藏着一种处理数据的思维模型——它能把无限延伸的线性世界,折叠成有限可控的 周期世界。今天想和大家分享一下我对取余的重新思考,看看它是怎么帮我们优雅地解决那些头疼的边界问题。
重新认识 %
取余的本质,是将任意数值强行限定在一个固定的循环范围内。无论数字跑多远,% N 都能让它回归到 0 至 N-1 的闭环中。
在教科书里,取余的公式是 a % n = r
-
a:被除数 -
n:除数 -
r:余数
但在代码逻辑里,我更愿意把它理解为两个超级好用的思维模型:
🔄 循环
想象一下家里的挂钟。不管时间怎么流逝,时针转了一圈又一圈,它永远只会停在 1 到 12 之间。取余就是这个表盘
,它能让无限增长的数字,乖乖地在一个固定的"圈"里打转。
✂️ 限制
无论你给我的数字有多大,% n 就像一把剪刀,强行把多出来的部分剪掉,只保留 0 到 n-1 这一小段。
就可以理解为:
-
a:被除数(任意数值) -
n:除数(限定的范围大小,也就是"表盘"的大小) -
r:余数(结果永远在0到n-1之间)
特点
构建"周期闭环"
说白了就是让数字一直在一个圈里转,永远跑不出去。比如轮播图或红绿灯,写 if (index >= length) 来防止数组越界,写多了特别烦。
有了取余,这事儿就简单了:
// 不管 index 涨到几万,结果永远锁死在 0 到 length-1 之间
const safeIndex = index % list.length;
降维与坐标映射
这个主要解决"一维变二维"的问题。比如为了省流量,后端扔过来一个长长的一维数组,你需要在界面上画个九宫格。
别傻乎乎地去搞双层循环,直接用数学搞定。假设一行有 col 列:
-
找列号(X轴):看它在当前行走了几步 -> 取余 (
% col) -
找行号(Y轴):看它已经填满了几行 -> 整除 (
Math.floor(i / col))
// 假设数组索引 i=7,一行3个 (col=3)
const x = 7 % 3; // 1 (第2列)
const y = Math.floor(7 / 3); // 2 (第3行)
// 坐标就是 (1, 2)
均匀离散与分流
一大堆随机数据(比如 1000 万个用户 ID),把它们公平地分给 3 台服务器,怎么分最匀称?
别搞什么复杂的随机算法,直接按 ID 取余。这不仅分得匀,还能保证同一个用户每次都能分到同一台机器上(这在分布式里叫 Hash 一致性)。
- 数字 ID:直接取余。
- 字符串 ID:先算 Hash 值(转成数字),再取余。
// 简单又高效的负载均衡
const targetServer = servers[userId % 3];
// 如果是字符串 ID,就先转成数字(Hash)
// const hash = stringToNumber(userId);
// const targetServer = servers[hash % 3];
声明式逻辑
代码是写给人看的。if-else 是告诉机器"怎么做流程控制",而 % 是告诉人"这里是个循环"。
用 % 最大的好处就是——你再也不会把 > 误写成 >= 了。那种差 1 的 Bug(Off-by-one error),写过代码的都懂有多坑。
倍数与规律捕捉
想每隔 10 行打个日志?或者给表格弄个"斑马纹"(奇偶变色)?
这种"每隔 N 次搞点事情"的逻辑,用取余是最直观的。它就像个节拍器,到了那个点就会响。
// 经典的斑马纹逻辑
const color = index % 2 === 0 ? 'white' : 'gray';
常见的面试题(由简到难)
1. 秒转时分秒(倒计时)
问:给你一个总秒数 3661,怎么在页面上显示 01:01:01?
答:这是最基础的"进制转换"题。
- 低位(秒):总秒数对 60 取余 -> 剩下的零头就是秒。
- 中位(分):总秒数先除以 60 得到总分钟数,再对 60 取余 -> 剩下的零头就是分。
- 高位(时):总分钟数除以 60 -> 剩下的就是时。
const totalSeconds = 3661;
const seconds = totalSeconds % 60; // 1
const minutes = Math.floor(totalSeconds / 60) % 60; // 61 % 60 = 1
const hours = Math.floor(totalSeconds / 3600); // 1
const format = time => time.toString().padStart(2, '0');
console.log(`${format(hours)}:${format(minutes)}:${format(seconds)}`); // 01:01:01
2. 判断质数(Prime Number)
问:怎么判断一个数 n 是不是质数?
答:质数就是只能被 1 和它自己整除的数。
所以,拿 2 到 n-1 之间的所有数去试着除它。只要有一个能被整除(n % i === 0),它就不是质数。
优化点:其实只需要试到 Math.sqrt(n) 就够了,后面都是重复的。
为什么? 因子都是成对出现的。比如
36:
2 × 183 × 124 × 96 × 6(根号 n)9 × 4(重复了!)只要在
6(根号 n) 之前没找到因子,后面也绝不会有(除非是它自己)。同理100的根号是10,你只要试到10就行了,不用傻乎乎试到99。
function isPrime(n) {
if (n <= 1) return false;
if (n === 2) return true; // 2 是质数
if (n % 2 === 0) return false; // 偶数直接排除
// 只需要试除奇数,步长为 2
for (let i = 3; i <= Math.sqrt(n); i += 2) {
if (n % i === 0) return false;
}
return true;
}
3. 判断回文数(不转字符串)
问:给你个数字 12321,怎么判断它是回文?不许转成 String。
答:这题考的是数字拆解的基本功。
你需要理解 % 和 / 在十进制里的黄金搭档关系:
-
% 10是"拿":拿到个位数(剥洋葱的第一层)。 -
/ 10是"扔":扔掉个位数(把洋葱缩小一圈)。
一边拆,一边装:
把 x 的屁股(最后一位)拆下来,装到 reversed 的头上。如果装完发现 reversed === x,那就是回文。
let x = 12321, reversed = 0;
// 假设 x=123
// 第一轮:123 % 10 = 3 (拿3), 123 / 10 = 12 (剩12)
// 第二轮:12 % 10 = 2 (拿2), 12 / 10 = 1 (剩1)
// 第三轮:1 % 10 = 1 (拿1), 1 / 10 = 0 (剩0) -> 结束
while (x > 0) {
reversed = reversed * 10 + x % 10; // 拼到新数末尾
x = Math.floor(x / 10); // 原数去掉末尾
}
4. 负数取余的坑(JS vs 其他语言)
问:(-1) % 5 在 JS 里等于多少?在 Python 里呢?
答:这题特容易踩坑。
- 在 JS(C/Java)里,结果是
-1。因为它们看重"商"向 0 取整。 - 在 Python 里,结果是
4。因为 Python 看重"商"向下取整。
实战解法:
如果在 JS 做轮播图(点击上一张),算出 -1 程序就崩了。
记住这个万能公式,不管正负都能转正:
const index = (current + step + length) % length;
为什么加 length?
因为 % 运算在 JS 里会保留符号。假设当前是第 0 张图(current=0),你要退一张(step=-1),总共5张图(length=5)。
-
不加 length:
(0 + (-1)) % 5 = -1❌(不仅不对,还越界了) -
加 length:
(0 + (-1) + 5) % 5 = 4✅(这就对了,回到了最后一个) -
正向移动:
(0 + 1 + 5) % 5 = 1✅(加一圈不影响正数结果,没副作用)
场景举例:
-
轮播图"上一张":
current=0, step=-1。(0 - 1 + 5) % 5 = 4-> 完美跳到最后一张。 -
贪吃蛇穿墙:蛇头钻出左边界
x=-1。(-1 + width) % width-> 瞬间从右边出来。 -
日期计算:今天是周三
3,问 5 天前是周几?(3 - 5 + 7) % 7 = 5-> 周五。不用脑补倒着数数了。
5. 不用临时变量交换两个数
问:给你两个整数 a 和 b,不许用 temp 变量,怎么交换它们?
答:除了烂大街的位运算(异或),取余其实也能干这事儿(虽然不如位运算快,但思路很骚)。
思路是把两个数"压缩"到一个大数里,再拆出来。
let a = 123, b = 456;
// 假设 n 足够大,比 a 和 b 都大
const n = 1000;
// 压缩:把 b 藏在高位,a 藏在低位
a = a + b * n; // 123 + 456 * 1000 = 456123
b = a % n; // 取出低位,也就是原来的 a
a = Math.floor(a / n); // 取出高位,也就是原来的 b
console.log(a, b); // 456, 123
6. 约瑟夫环问题
场景描述:
有 n 个人围成一圈(编号 0 到 n-1)。从第 0 号开始报数,报到 m 的人出局。下一位继续从 1 开始报数,直到只剩最后一个人。问最后这个人的原始编号是多少?
例子:
- n = 5(5个人:0, 1, 2, 3, 4)
- m = 3(报到3出局)
- 出局过程:2号出局 -> 0号出局 -> 4号出局 -> 1号出局 -> 3号幸存。
- 幸存过程:0, 1, 2, 3, 4 -> 0, 1, 3, 4 -> 1, 3, 4 -> 1, 3 -> 3
这道题有点复杂,先上答案,后面咱们掰开揉碎了讲
/**
* @param {number} n 总人数
* @param {number} m 报数号码(报到几出局)
* @return {number} 最后幸存者的编号
*/
function lastRemaining(n, m) {
let pos = 0; // 时光倒流终点:最后只剩1个人时,幸存者索引是0
// 开始倒推:从2个人 -> 3个人 -> ... -> n个人
for (let i = 2; i <= n; i++) {
pos = (pos + m) % i; // 每一轮人数变多(i),位置都要往后挪 m 位
}
return pos;
}
解法思路:时光倒流(坐标偏移)
这个问题如果顺着想(模拟淘汰),数组删元素很麻烦。但如果我们倒着想,利用坐标偏移规律,就非常简单。
1. 正向(淘汰 = 坐标前移):
想象一下,m=3,第 3 个人(索引 2)被淘汰后。
- 按照规则,下一轮报数从被淘汰者的下一个人(索引 3)开始。
- 这就意味着,索引 3 变成了新一轮的 排头兵(新的索引 0)。
- 相当于所有人整体往前挪了 3 位(注意:不仅仅是填补空缺,而是连起点都变了)。
- 即:
旧索引 - 3 = 新索引。
2. 逆向(恢复 = 坐标后移): 我们要找幸存者最初在哪,可以从终局(只剩他 1 人,索引 0)开始,一步步把时光倒流,恢复之前被淘汰的人。
- 恢复就是淘汰的逆操作。
- 既然淘汰是"往前挪 3 位",那恢复就是"往后挪 3 位"(
+3)。 - 公式呼之欲出:
新索引 + 3 = 旧索引。 -
核心补丁:因为是圆圈,往后挪超出了队尾就要绕回队头,所以必须
% 上轮人数。
推导过程演示(N=5, M=3):
我们只关注最后那个幸存者(假设他叫"天选之子"),他在每一轮的索引是多少?
表头说明:
- n:当前轮剩余人数。
- 倒推公式:
(当前索引 + m) % 上轮人数。通过这个公式,我们可以算出幸存者在上一轮(人数更多时)的位置。
| 轮次 | 剩余人数 | 场景描述 | 计算过程 | 幸存者索引 |
|---|---|---|---|---|
| 终局 | 1 | 只剩天选之子 | 0 (固定) | 0 |
| 倒数第2轮 | 2 | 恢复成2人 | (0 + 3) % 2 |
1 |
| 倒数第3轮 | 3 | 恢复成3人 | (1 + 3) % 3 |
1 |
| 倒数第4轮 | 4 | 恢复成4人 | (1 + 3) % 4 |
0 |
| 开局 | 5 | 恢复成5人 | (0 + 3) % 5 |
3 |
结论:一开始索引为 3 的那个人,就是天选之子。
💡 核心疑点 Q&A:
-
为什么要倒推?
- 正推太麻烦:如果正向模拟,你需要不断地删除数组元素、处理索引越界,数组长度一直在变,计算极其复杂。
-
终局是已知的:无论过程多复杂,最后一定只剩 1 个人,且那个人的索引一定是
0。从确定的结果出发找源头,比从源头去猜结果要容易得多。
-
为什么要恢复上一轮的状态?
- 这是一个递归/递推的问题。
5个人的游戏淘汰一个,就变成了4个人的游戏。 - 如果我们知道
4个人里的幸存者是谁,只要把这个幸存者在4个人局里的位置,映射(还原) 回5个人局里的位置,问题就解决了。 - 所谓"恢复",其实就是坐标变换。
- 这是一个递归/递推的问题。
-
为什么要 % i(当前人数),而不是 % n(总人数)?
- 这是很多人的盲点!
- 每一轮淘汰一个人,圈子的大小都在变。
- 倒数第 2 轮时,圈子只有 2 个人,所以是
% 2;倒数第 3 轮时,圈子有 3 个人,所以是% 3。 - 我们是在那一轮的圈子里进行坐标恢复,当然要模那一轮的人数。
-
公式
(当前索引 + m) % 上轮人数怎么来的?- 这就是我们上面提到的坐标偏移:
-
+ m:代表时光倒流,恢复被删掉的
m个位置。 - % 上轮人数:代表在恢复后的圈子里转圈圈,防止索引越界。
💡 小贴士:数学公式版(递归实现)
如果你在算法书上看到这个公式,别慌,它和我们的代码是一回事:
f(n, m) = (f(n-1, m) + m) % n
-
f(n, m):n 个人时幸存者的索引。 -
f(n-1, m):n-1 个人时幸存者的索引(也就是我们代码里的pos)。 - 代码里的
for循环,就是把这个数学递归公式变成了从 2 到 n 的递推。
递归版代码(仅供参考):
虽然代码看着短,但如果 n 很大,会爆栈哦。还是推荐用上面的 for 循环(迭代版)。
function lastRemainingRecursive(n, m) {
if (n === 1) return 0; // 剩下1个人,索引肯定是0
return (lastRemainingRecursive(n - 1, m) + m) % n;
}
动态规划版(标准 DP):
有了推导公式,自然就能写出 DP。
dp[i] 表示 i 个人时的幸存者索引。
function lastRemainingDP(n, m) {
let dp = new Array(n + 1);
dp[1] = 0; // 只有1个人时,索引是0
for (let i = 2; i <= n; i++) {
dp[i] = (dp[i - 1] + m) % i; // 状态转移方程
}
return dp[n];
}
注:我们最开始写的那个 let pos 的版本,其实就是这个 DP 版本的空间优化版(滚动数组思想),把 dp
数组压缩成了一个变量。
总结
说实话,取余(Modulo)这个概念,以前我也觉得它只是个数学符号,顶多用来算算奇偶数。但当你真的深入去理解它,你会发现它其实是一种化直为曲
的思维方式。
无论是处理时间、轮播图,还是解决像约瑟夫环这样复杂的算法题,取余的核心永远只有两点:控制边界和制造循环。
希望这篇文章能帮你打破对 % 的固有印象。下次在代码里遇到"溢出"、"循环"或者"映射"的问题时,试着停下来想一想:这里是不是可以用取余来简化一下?
多思考,多动手,编程不仅是写代码,更是对数据规律的优雅掌控。