普通视图
高盛:美国数据中心用电需求或在两年内翻倍
温州宏丰:白银价格受宏观经济等多重因素共同影响价格波动幅度较大,未来可能给业绩带来一定的不确定性
热门中概股美股盘前多数上涨,百度涨超5%
美股大型科技股盘前多数上涨,英伟达涨超1%
2连板北投科技:主营业务收入构成不涉及人工智能等相关热门概念
美国4月失业率为4.3%
海光DCU完成与混元Hy3 preview大模型的深度适配
元道通信:公司可能被实施重大违法强制退市
面试手写 KeepAlive:React 组件缓存的实现原理
面试手写 KeepAlive:React 组件缓存的实现原理
面试官:"用过 Vue 的
<keep-alive>吗?如果让你在 React 中手写一个,你会怎么实现?"
这看似是一道框架 API 题,实际上考察的是你对 React 组件渲染机制 和 DOM 复用策略 的理解深度。本文将带你从零手写一个 KeepAlive 组件,把每一步的设计决策讲透彻。
先搞懂本质:KeepAlive 解决什么问题?
看一个具体场景。我们的 App 有两个 Tab:
// App.jsx
const App = () => {
const [activeTab, setActiveTab] = useState('A')
return (
<div>
<button onClick={() => setActiveTab('A')}>显示A组件</button>
<button onClick={() => setActiveTab('B')}>显示B组件</button>
<KeepAlive activeId={activeTab}>
{activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
</div>
)
}
Counter 组件内部有一个 count 状态:
const Counter = ({ name }) => {
const [count, setCount] = useState(0)
// 挂载/卸载的生命周期日志
useEffect(() => {
console.log('挂载', name)
return () => console.log('卸载', name)
}, [])
return (
<div>
<h3>{name}视图</h3>
<p>当前计数:{count}</p>
<button onClick={() => setCount(count + 1)}>加1</button>
</div>
)
}
没有 KeepAlive 时,用户在 A 组件把 count 点到 5,切换到 B,再切回 A:
切换 B → A 组件卸载(state 销毁,count 归零,DOM 移除)
切回 A → A 组件重新挂载(count 重新从 0 开始,useEffect 再次执行)
用户体验:辛辛苦苦点的数全白费了。
核心思路:把 JSX 元素存进一个对象里
React 的组件渲染本质上就是把 JSX 转成 Virtual DOM,再映射到真实 DOM。那如果我们不销毁这个 JSX 对应的 DOM,而是把它"藏起来"呢?
关键认知:JSX 本质上就是一个 JavaScript 对象引用。 只要引用不被 GC 回收,React 内部维护的 Fiber 节点和对应的真实 DOM 就不会被销毁。
设计数据结构:
// cache 对象的结构
{
'A': <Counter name="A" />, // JSX 对象引用
'B': <OtherCounter name="B" />,
}
-
key:用
activeId作为缓存键,唯一标识每个需要缓存的视图 - value:存储该视图对应的 JSX 元素(注意:是首次渲染时的那个 JSX 对象,不是每次都创建新的)
一步步写出来
第一版:能跑就行的朴素实现
import { useState, useEffect } from 'react'
const KeepAlive = ({ activeId, children }) => {
const [cache, setCache] = useState({})
useEffect(() => {
if (!cache[activeId]) {
setCache(prev => ({
...prev,
[activeId]: children
}))
}
}, [activeId, children, cache])
return (
<>
{Object.entries(cache).map(([id, component]) => (
<div
key={id}
style={{ display: id === activeId ? 'block' : 'none' }}
>
{component}
</div>
))
}
</>
)
}
export default KeepAlive
逐行解析:
1. 缓存状态:const [cache, setCache] = useState({})
用一个对象存储所有被缓存过的视图。为什么用 useState 而不是 useRef?因为我们需要在状态更新时触发重新渲染——新的 children 被存入缓存后,必须让 React 重新执行 render 才能把新 DOM 渲染出来。
2. 缓存时机:if (!cache[activeId])
useEffect(() => {
if (!cache[activeId]) {
setCache(prev => ({
...prev,
[activeId]: children
}))
}
}, [activeId, children, cache])
这是整个组件的灵魂。判断逻辑是:
| 场景 |
cache[activeId] 是否存在 |
行为 |
|---|---|---|
| 首次切换到某个 Tab | 不存在 | 保存 children 到缓存 |
| 再次切换回已缓存的 Tab | 已存在 | 什么都不做,复用旧缓存 |
注意:这里保存的是第一次渲染时的 children 引用。一旦保存,后续即使 children 变化(其他 Tab 的 JSX),已缓存的引用不会被覆盖。这就是状态得以保留的根源——React 始终渲染的是最初那个 Fiber 节点。
3. 显示策略:display: block / none
{Object.entries(cache).map(([id, component]) => (
<div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
{component}
</div>
))}
所有被缓存过的组件全部渲染在 DOM 树中,但只把当前激活的那个设为可见:
- 激活的 Tab:
display: block(正常显示) - 隐藏的 Tab:
display: none(DOM 存在但不可见)
这是整个方案最巧妙的地方:React 看到 {component} 引用没变,不会重新执行函数组件,不会触发 Hooks 重新计算,不会触发 useEffect。Fiber 节点一直挂在树上,状态完好无损。
当你从 B 切回 A 时,控制台不会打印"挂载 A",因为 A 组件的 Fiber 从未被卸载过。这就是 KeepAlive 的本质——DOM 存在但不显示,而非销毁后重建。
运行效果:对比控制台日志
// 初始加载
挂载 A ← useEffect 触发
// 切换到 B
挂载 B ← B 首次进入缓存,执行挂载
// 注意:没有 "卸载 A"!
// 切回 A
// 没有 "挂载 A"! ← A 从未卸载,缓存命中
// 再次切到 B
// 没有 "挂载 B"! ← B 也从未卸载
A 组件切走时,控制台没有打印"卸载 A",因为 display: none 只是隐藏,React 的 cleanup 函数不会执行。切回来时也没有"挂载 A",计数仍然保持离开时的数字。
面试进阶:面试官可能会追问什么
Q1:为什么用 children 而不是让 KeepAlive 自己去渲染?
// ❌ 不好的设计:KeepAlive 内部 import 组件
<KeepAlive activeId={activeTab} components={{ A: Counter, B: OtherCounter }} />
// ✅ 好的设计:通过 children 让父组件控制渲染
<KeepAlive activeId={activeTab}>
{activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
原因:children 模式遵循 React 的组合优于继承原则。父组件完全控制子组件的 props、条件渲染逻辑,KeepAlive 只负责缓存,职责单一。
Q2:所有缓存组件都在 DOM 中,性能会不会有问题?
会有。每个隐藏的组件虽然不可见,但它的 DOM 节点和 Fiber 节点全部真实存在于内存中。如果你的 Tab 内容包含 1000 个列表项,那缓存 10 个 Tab 就是 10000 个 DOM 节点——对内存和首屏渲染性能都是负担。
生产级方案(如 react-activation)会做更精细的优化:通过 React Portal 把隐藏组件的 DOM 移到一个独立的、脱离文档流的容器中挂起。
Q3:useEffect 的依赖数组里有 cache,会不会导致无限循环?
cache[activeId] 不存在时才调用 setCache,更新后的 cache 中 activeId 已存在,下次 useEffect 执行时 if (!cache[activeId]) 为 false,不会再调用 setCache。所以不会无限循环。
但这里有一个可优化的点:依赖 cache 对象意味着每次缓存更新后 useEffect 都会对整个 cache 重新求值。更好的写法是用函数式 setState + 单独的 useEffect 监听:
useEffect(() => {
setCache(prev => {
if (prev[activeId]) return prev // 已缓存,不更新
return { ...prev, [activeId]: children }
})
}, [activeId, children])
这样去掉了对 cache 的依赖,效果一样但更简洁。
Q4:display: none 和条件渲染有什么区别?
display: none |
条件渲染 {visible && <Comp />}
|
|
|---|---|---|
| DOM 存在 | ✅ 存在 | ❌ 移除 |
| state 保留 | ✅ 保留 | ❌ 销毁 |
| useEffect cleanup | ❌ 不触发 | ✅ 触发 |
| 组件函数是否重新执行 | ❌ 不执行 | ✅ 重新执行 |
条件渲染的本质是移除 DOM → 销毁 Fiber → 清除 state → 执行 cleanup。display: none 的本质是 DOM 还在 → Fiber 还在 → state 还在 → cleanup 不执行。前者是"删了重建",后者是"藏起来再拿出来"。
从面试代码到生产级方案
这个 25 行的实现抓住了 KeepAlive 的核心思想,但它缺少几个关键能力:
| 缺失能力 | 生产级方案(react-activation) |
|---|---|
| 滚动位置恢复 | 内置 saveScrollPosition 属性 |
| 缓存淘汰策略 | 支持 LRU,限制最大缓存数量 |
| 多实例管理 |
AliveScope 全局缓存池统一调度 |
| 生命周期钩子 |
useActivate / useUnactivate 替代 useEffect
|
| SSR 兼容 | 提供 SSRKeepAlive 降级方案 |
| 动画过渡 | 切换时可配合 CSS Transition |
但面试官要看的不是你会不会用库——而是你是否理解状态保留的本质是保留 JSX 引用,保留引用的本质是不让 Fiber 卸载,不让 Fiber 卸载的本质是 DOM 不离树。
总结
手写 KeepAlive 是一个优质的面试题,它串起了 React 的多个核心概念:
JSX 对象引用 → useState 缓存 → display:none 保活
↘ Fiber 持久化 ↙
状态与 DOM 永不销毁
记住这一条线,你就能在任何面试中把 KeepAlive 的原理讲得明明白白。
一句话版本:KeepAlive =
useState存 JSX 引用 +display: none隐藏非激活 DOM,让 React 的 Fiber 节点不被卸载,从而保住所有组件内部状态。
深交所本周共对160起证券异常交易行为采取了自律监管措施
蓝思科技:股东拟向永州市慈善总会无偿捐赠390.02万股
ST清越:公司股票可能被实施重大违法强制退市
DeepSeek拟募资最高500亿元
TEngine 入门系列(一):TEngine 是什么 & 为什么选它
一、做游戏为什么需要框架
1.1 没有框架的开发是什么样的
想象你在盖一栋房子。如果没有图纸、没有脚手架,你能盖吗?能。但你会遇到这些问题:
- 墙歪了才发现地基没打好
- 水管和电线混在一起
- 改个窗户位置,整面墙得拆了重来
游戏开发不用框架,几乎一模一样:
| 开发阶段 | 没有框架的典型问题 |
|---|---|
| 初期 | 感觉很快,随便写写就能跑 |
| 中期 | 脚本之间互相引用,牵一发而动全身 |
| 后期 | 加新功能要改 10 个文件,改完原来的功能又坏了 |
| 上线后 | 想热更一个 Bug,发现根本没法热更 |
| 多人协作 | 每个人写法不一样,合并代码就是灾难 |
1.2 框架解决什么问题
一个好的游戏框架,本质上是帮你制定了一套规则和工具:
- 资源管理:资源怎么加载、怎么卸载、怎么打包——有标准流程
- UI 管理:界面怎么打开、怎么关闭、怎么分层——有统一入口
- 事件系统:模块之间怎么通信——不需要互相引用
- 流程控制:游戏启动、登录、主界面、战斗——有清晰的状态切换
- 热更新:代码和资源都能在不重新发版的情况下更新
一句话总结:框架让你把精力花在游戏玩法上,而不是重复造轮子。
二、TEngine 是什么
2.1 一句话定位
TEngine 是一个基于 Unity 的开箱即用游戏开发框架,集成了资源管理、UI 系统、事件系统、网络模块、热更新等完整的游戏开发基础设施。
它的目标是:让独立开发者和小团队不需要从零搭建底层架构,直接开始写游戏逻辑。
2.2 核心特性
- 开箱即用:导入就能跑,不需要复杂配置
- 模块化设计:每个功能是独立模块,用什么导什么
- YooAsset 资源管理:业界成熟的资源打包和加载方案
- HybridCLR 热更新:支持代码和资源双热更
- 完整的 UI 框架:分层管理、生命周期、堆栈式导航
- 事件驱动:模块间松耦合通信
- 持续维护:GitHub 活跃更新,社区支持
2.3 TEngine 的模块全景
┌─────────────────────────────────────────────────────┐
│ TEngine 框架 │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ 资源管理 │ UI 框架 │ 事件系统 │ 流程控制 │ 网络模块 │
│(YooAsset)│(UIModule)│(EventMgr)│(Procedure│(Network)│
│ │ │ │ + FSM) │ │
├──────────┼──────────┼──────────┼──────────┼─────────┤
│ 对象池 │ 音频管理 │ 计时器 │ 配置表 │ 调试器 │
│(ObjPool) │(AudioMgr)│(TimerMgr)│(DataTable│(Debugger│
│ │ │ │) │) │
├──────────┴──────────┴──────────┴──────────┴─────────┤
│ 热更新(HybridCLR + YooAsset) │
└─────────────────────────────────────────────────────┘
每个模块一句话说明:
| 模块 | 做什么 |
|---|---|
| 资源管理 | 加载/卸载/打包游戏资源 |
| UI 框架 | 管理所有界面的打开、关闭、层级 |
| 事件系统 | 模块间发消息,不需要互相认识 |
| 流程控制 | 管理游戏整体阶段切换 |
| 网络模块 | 与服务器通信 |
| 对象池 | 复用频繁创建/销毁的物体 |
| 音频管理 | 播放背景音乐、音效、语音 |
| 计时器 | 延时执行、倒计时、循环计时 |
| 配置表 | 从 Excel 读取游戏数值 |
| 调试器 | 运行时查看日志、性能、内存 |
| 热更新 | 不重新发版就能更新游戏内容 |
三、为什么选 TEngine
3.1 选几个主流框架对比
| 对比项 | TEngine | GameFramework | QFramework | 不用框架(裸写) |
|---|---|---|---|---|
| 上手难度 | 中等 | 较高 | 低 | 最低(初期) |
| 开箱即用 | 是 | 否(需大量配置) | 部分 | 否 |
| 资源管理 | YooAsset(成熟) | 自带(较老) | 需自己接 | Resources.Load |
| 热更新 | HybridCLR + YooAsset | 需自己接 | 需自己接 | 不支持 |
| UI 框架 | 完整(分层+堆栈) | 完整但复杂 | 简洁 | 自己写 |
| 文档质量 | 中文文档 + 示例 | 中文文档丰富 | 中文教程多 | 无 |
| 适合项目规模 | 中小型商业项目 | 中大型项目 | 小型/原型 | 极小型 Demo |
| 学习曲线 | 前期稍陡,后期省力 | 前期很陡 | 平缓 | 前期平缓,后期灾难 |
| 社区活跃度 | 活跃 | 活跃 | 活跃 | - |
- 除这些之外还有很多有名的框架,比如,ET,MyFramework等等,感兴趣可以自己查看
3.2 什么情况选 TEngine
TEngine 最适合以下场景:
- 独立开发者或 2~5 人小团队:不想花几周搭底层架构
- 需要热更新的手游项目:TEngine 原生集成 HybridCLR + YooAsset
- 有一定 Unity 基础,想进阶到工程化开发:TEngine 的模块设计是很好的学习样本
- 希望用中文文档和社区获得支持:国内开发者维护,交流无障碍
3.3 TEngine vs 裸写:一个真实场景对比
假设你要实现一个常见功能:玩家完成关卡后,弹出结算面板,显示得分和奖励。
裸写方式:
// GameManager.cs 里
public class GameManager : MonoBehaviour
{
public GameObject resultPanel; // 在 Inspector 里拖引用
public Text scoreText;
public Text rewardText;
public void OnLevelComplete(int score, int reward)
{
resultPanel.SetActive(true);
scoreText.text = "得分: " + score;
rewardText.text = "奖励: " + reward;
// 问题:如果 resultPanel 被销毁了呢?
// 问题:如果要加动画呢?
// 问题:如果有多个面板要管理呢?
// 问题:如果其他脚本也要知道关卡完成了呢?
}
}
TEngine 方式:
// 1. 定义事件
public static class GameEvents
{
public static readonly int LevelComplete = "LevelComplete".GetHashCode();
}
// 2. 关卡逻辑完成时,广播事件(不需要知道谁在听)
GameEvent.Send(GameEvents.LevelComplete, new LevelResult { Score = 100, Reward = 50 });
// 3. 结算面板自己监听事件(不需要知道谁发的)
public class UIResultPanel : UIWindow
{
protected override void OnCreate()
{
GameEvent.AddEventListener<LevelResult>(GameEvents.LevelComplete, OnLevelComplete);
}
private void OnLevelComplete(LevelResult result)
{
// UI 框架自动管理面板的打开/关闭/层级/动画
FindChildComponent<Text>("ScoreText").text = $"得分: {result.Score}";
FindChildComponent<Text>("RewardText").text = $"奖励: {result.Reward}";
}
protected override void OnDestroy()
{
GameEvent.RemoveEventListener<LevelResult>(GameEvents.LevelComplete, OnLevelComplete);
}
}
关键区别:
- 关卡逻辑和 UI 面板完全解耦——改一个不影响另一个
- UI 的生命周期由框架管理——不会出现空引用
- 想加新面板监听同一个事件?加就行,不用改关卡逻辑的一行代码
江波龙:ePOP4x产品已经批量应用于北美智能穿戴科技巨头的智能穿戴设备中
TypeScript 数组去重的 20 种实现方式,哪一种你还不知道?
TypeScript 数组去重的 20 种实现方式,用不同思路解决问题
数组去重是最常见的编程算法,非常简单,但也可以有很多的实现方案。TypeScript 在 JavaScript 的基础上加了静态类型,让通用工具函数可以用泛型写一次、对所有可比较类型可用。本文整理 TS 数组去重的 20 种写法,按 5 个策略分类。AI时代,可以不手写代码了,但需要知道代码背后的原理,这样才能更好地指导AI编程。
为什么性能差异这么大?
最简单的写法,新建一个数组,把不在结果里的添加进去。
function unique<T>(arr: T[]): T[] {
const result: T[] = []
for (const item of arr) {
// includes 是 O(n) 线性扫描,整体则是 O(n²)
if (!result.includes(item)) {
result.push(item)
}
}
return result
}
本文源码:github.com/microwind/a…*
问题在于每次 includes 都要全量扫一遍 result,复杂度是 O(n²)。
优化思路:换一种判重方式
-
Set / Map O(1) 查询:
new Set(arr) - 排序 O(n log n):相同元素相邻后扫一遍
- filter + 闭包:在函数式管道里携带"已见"状态
- JSON 序列化:处理对象、嵌套数组等不可哈希元素
- 递归:换种表达方式,本质仍是上面的思路
TS 相比 JS 的优势
-
泛型
<T>:写一次、对所有类型类型安全可用 -
类型约束:
T extends string | number限定基本类型,避免对象误用 Object 字面量 - 编译期校验:传入错误类型立即报错,不会在运行时才崩
推荐方案
| 需求 | 代码 | 性能 | 保序 |
|---|---|---|---|
| 一行最简 | [...new Set<T>(arr)] |
O(n) | ✓ |
| 函数式 + Set | arr.filter(x => !seen.has(x) && seen.add(x)) |
O(n) | ✓ |
| 按字段去重 | [...new Map(arr.map(x => [x.id, x])).values()] |
O(n) | ✓ |
| 对象数组 |
JSON.stringify 作为 Set 的键 |
O(n×m) | ✓ |
第1类:基础循环(方法1-6)
策略原理:不用任何内置数组方法,纯靠下标、嵌套循环、indexOf 这种"原始"手段完成去重。每一步判重都是 O(n),整体 O(n²)。
适用场景:教学、面试手撕。生产代码不建议使用。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原数组]) --> B[取下一个元素]
B --> C{遍历结果数组<br/>是否已存在?}
C -->|否| D[push 追加]
C -->|是| E[跳过]
D --> F([继续])
E --> F
F --> B
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#3A86FF,color:#fff,stroke:#2b63c4,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,F start
class B,D,E step
class C check
// 方法1:双循环索引比较——i 与左侧每个 j 比对
static unique1<T>(arr: T[]): T[] {
const result: T[] = []
for (let i = 0, l = arr.length; i < l; i++) {
for (let j = 0; j <= i; j++) {
if (arr[i] === arr[j]) {
// i === j 表示前面没有相同值,是首次出现
if (i === j) result.push(arr[i])
break
}
}
}
return result
}
// 方法2:新建数组 + includes 检查
static unique2<T>(arr: T[]): T[] {
const result: T[] = []
for (const item of arr) {
// includes 是 O(n) 线性扫描,每个元素都要扫描一次,整体是 O(n²)
if (!result.includes(item)) {
result.push(item)
}
}
return result
}
// 方法3:从后往前原地 splice
static unique3<T>(arr: T[]): T[] {
let l = arr.length
while (l-- > 0) {
// 从后往前遍历,避免删除后索引变化导致跳过元素
// 每个元素都要扫描一次,整体是 O(n²)
for (let i = 0; i < l; i++) {
if (arr[l] === arr[i]) {
arr.splice(l, 1)
break
}
}
}
return arr
}
// 方法4:从前往后原地 splice(删后面相同项)
static unique4<T>(arr: T[]): T[] {
let l = arr.length
for (let i = 0; i < l; i++) {
// 从前往后遍历,每个元素都要扫描一次,整体是 O(n²)
for (let j = i + 1; j < l; j++) {
if (arr[i] === arr[j]) {
arr.splice(j, 1)
j--; l--
}
}
}
return arr
}
// 方法5:forEach + indexOf
// indexOf 返回首次出现下标,等于当前下标即首次
static unique5<T>(arr: T[]): T[] {
const result: T[] = []
// forEach 是 O(n) 线性扫描,每个元素都要扫描一次
arr.forEach((item, i) => {
if (arr.indexOf(item) === i) result.push(item)
})
return result
}
// 方法6:双重 while 倒序 splice
static unique6<T>(arr: T[]): T[] {
let l = arr.length
while (l-- > 0) {
let i = l
// 从后往前遍历,每个元素都要扫描一次,整体是 O(n²)
while (i-- > 0) {
if (arr[l] === arr[i]) {
arr.splice(l, 1)
break
}
}
}
return arr
}
所有泛型方法的
T不需要额外约束——===比较对所有 TS 类型都有效(虽然引用类型只比指针)。
第2类:内置数组方法(方法7-11)
策略原理:JavaScript 数组自带 filter、reduce、forEach 等高阶方法,可以把"判重 + 收集"写成函数式风格。注意 indexOf / includes 仍是 O(n),需要用 Set<T> 闭包才能压到 O(n)。
适用场景:现代 TS 工程的常态写法。可读性高,链式组合方便。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原数组]) --> B[filter / reduce 管道]
B --> C{选择策略}
C -->|indexOf 判重| D["O(n²)" 但简洁]
C -->|Set 闭包判重| E["O(n)" 推荐使用]
C -->|对象键| F["O(n)" 但有类型陷阱]
D --> G([新数组])
E --> G
F --> G
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#0F3460,color:#fff,stroke:#0a2647,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,G start
class B,D,E,F step
class C check
// 方法7:filter + indexOf 一行经典
// indexOf 返回首次出现下标,等于当前下标即首次出现,用 filter 过滤出首次出现的元素
static unique7<T>(arr: T[]): T[] {
return arr.filter((item, i) => arr.indexOf(item) === i)
}
// 方法8:filter + Set 闭包——推荐写法
// Set.add 返回 Set 自身(truthy),结合短路 && 实现首次见到才返回 true
static unique8<T>(arr: T[]): T[] {
const seen = new Set<T>()
return arr.filter(item => !seen.has(item) && !!seen.add(item))
}
// 方法9:reduce 累加(用数组)
// 函数式风格,但 includes 仍是 O(n²)
// 注意 reduce 的泛型参数 T[],初始值为 [] as T[]
static unique9<T>(arr: T[]): T[] {
// 用 reduce 累加数组,每次判断是否已存在,不存在则添加
return arr.reduce<T[]>((acc, item) => {
if (!acc.includes(item)) acc.push(item)
return acc
}, [])
}
// 方法10:reduce + Set 闭包——O(n) 函数式
static unique10<T>(arr: T[]): T[] {
const seen = new Set<T>()
// 用 reduce 累加数组,每次判断是否已存在,不存在则添加
return arr.reduce<T[]>((acc, item) => {
if (!seen.has(item)) {
seen.add(item)
acc.push(item)
}
return acc
}, [])
}
// 方法11:Object + typeof 键
// 用 typeof + value 拼成字符串作为对象键,避免 1 与 '1' 冲突
// 类型约束 T extends string | number | boolean 限制为基本类型
static unique11<T extends string | number | boolean>(arr: T[]): T[] {
const obj: Record<string, true> = {}
// 用 filter 过滤出首次出现的元素,用 typeof + value 拼成字符串作为对象键
return arr.filter(item => {
const key = typeof item + String(item)
return Object.prototype.hasOwnProperty.call(obj, key)
? false
: (obj[key] = true)
})
}
TS 加分项:
T extends string | number | boolean限定调用方只能传基本类型数组,对象数组在编译期就会报错——避免运行时陷阱。
第3类:集合容器(方法12-14)
策略原理:ES6 引入的 Set 与 Map 用 SameValueZero 算法判等,键唯一且 O(1),是 JS/TS 里最自然的去重工具。Object 字面量虽然也能当哈希用,但有"键自动字符串化""数字键被引擎重排"等坑。
适用场景:日常项目首选 Set;需要保留 value 选 Map;只在小数据或特殊兼容场景才用 Object。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原数组]) --> B{选择容器}
B -->|Set 'T'| C[键唯一<br/>保持插入顺序]
B -->|Map 'K, V'| D[键唯一<br/>值可携带类型]
B -->|Object 'Record'| E[键自动字符串化<br/>有重排陷阱]
C --> F([转回数组])
D --> F
E --> F
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#8338EC,color:#fff,stroke:#5e27a8,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,F start
class C,D,E step
class B check
// 方法12:new Set 转数组——一行经典
// Set<T> 用 SameValueZero 比较,NaN 也能正确去重
static unique12<T>(arr: T[]): T[] {
return [...new Set(arr)]
}
// 方法13:Map<T, T> + keys
// 适合"按键去重,值携带其他信息"的场景
static unique13<T>(arr: T[]): T[] {
const map = new Map<T, T>()
// 用 Map<T, T> + keys 转数组,保持插入顺序
arr.forEach(item => map.set(item, item))
return [...map.keys()]
}
// 方法14:Object 字面量哈希——T extends string | number 防误用
// 注意:1 与 '1' 会被合并;数字键会被引擎按升序重排
static unique14<T extends string | number>(arr: T[]): T[] {
const obj = {} as Record<string, T>
// 用 Object 字面量哈希,键自动字符串化,数字键会被引擎按升序重排
for (const item of arr) obj[String(item)] = item
return Object.values(obj)
}
TS 类型提醒:
Map<K, V>的两个泛型参数让你显式声明键值类型,比 JS 的new Map()更安全。如果按业务字段去重,可以写new Map<number, User>()表明键是 id(number),值是 User。
第4类:排序后去重(方法15-17)
策略原理:先 sort 让相同元素相邻,再扫一遍删除相邻相同项。复杂度由排序决定,O(n log n)。优点是不需要额外的哈希结构,"相邻判等"是最便宜的判重方式;缺点是会破坏原顺序。
适用场景:输出本就需要排序、不在意原顺序。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([原数组]) --> B[sort<br/>相同元素相邻]
B --> C{相邻是否相同?}
C -->|是| D[splice/skip]
C -->|否| E[保留]
D --> F([结果])
E --> F
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#FF6B6B,color:#fff,stroke:#cc4444,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,F start
class B,D,E step
class C check
// 方法15:sort + splice 升序去重(仅 number[])
// JS sort 不传比较函数会按字符串排序,必须传 (a, b) => a - b
static unique15(arr: number[]): number[] {
arr.sort((a, b) => a - b)
let l = arr.length
// 先排序,从后往前遍历,相邻元素相同则删除当前元素
while (l-- > 1) {
if (arr[l] === arr[l - 1]) arr.splice(l, 1)
}
return arr
}
// 方法16:sort + filter 相邻判重
static unique16(arr: number[]): number[] {
arr.sort((a, b) => a - b)
// 先排序,从后往前遍历,相邻元素相同则删除当前元素
return arr.filter((item, i) => i === 0 || item !== arr[i - 1])
}
// 方法17:经典双指针(LeetCode 26)
// 排序后原地双指针,O(1) 额外空间
static unique17(arr: number[]): number[] {
if (arr.length === 0) return arr
arr.sort((a, b) => a - b)
let slow = 0
// 先排序,从后往前遍历,相邻元素相同则删除当前元素
for (let fast = 1; fast < arr.length; fast++) {
if (arr[fast] !== arr[slow]) {
arr[++slow] = arr[fast]
}
}
return arr.slice(0, slow + 1)
}
泛化排序的难点:要让排序方法也支持任意
T,得让调用方传compareFn: (a: T, b: T) => number——参考Array.prototype.sort的设计。这里为简明起见限定为number[]。
第5类:递归与特殊(方法18-20)
策略原理:递归用自调用替代循环,是函数式思维的体现,主要用于教学。JSON.stringify 把对象映射为字符串,是处理"不可哈希元素"(对象数组、嵌套数组)的常见招数。
适用场景:递归——教学;JSON——对象数组按整体结构去重。
%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
A([数组 length=n]) --> B{length <= 1?}
B -->|是| C([返回])
B -->|否| D[检查末尾元素<br/>是否在前面出现]
D --> E{重复?}
E -->|是| F[丢弃末尾]
E -->|否| G[保留末尾]
F --> H[递归 length-1]
G --> H
H --> A
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
classDef step fill:#118AB2,color:#fff,stroke:#0b5f7a,stroke-width:2px
classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
class A,C start
class D,F,G,H step
class B,E check
// 方法18:递归原地删除
static unique18<T>(arr: T[], length: number): T[] {
if (length <= 1) return arr
const last = length - 1
// 从后往前遍历,检查末尾元素是否在前面出现
for (let i = last - 1; i >= 0; i--) {
if (arr[last] === arr[i]) {
arr.splice(last, 1)
break
}
}
return UniqueArray.unique18(arr, length - 1)
}
// 方法19:递归拼接返回(不修改原数组)
static unique19<T>(arr: T[], length: number): T[] {
if (length <= 1) return arr.slice(0, length)
const last = length - 1
const lastItem = arr[last]
let isRepeat = false
// 从后往前遍历,检查末尾元素是否在前面出现
for (let i = last - 1; i >= 0; i--) {
if (lastItem === arr[i]) {
isRepeat = true
break
}
}
const head = UniqueArray.unique19(arr, length - 1)
return isRepeat ? head : head.concat(lastItem)
}
// 方法20:JSON 字符串判重——处理对象数组
// 把对象序列化成字符串作为 Set 的键,能去重 {id:1} 这类结构
static unique20<T>(arr: T[]): T[] {
const seen = new Set<string>()
const result: T[] = []
// 遍历数组,把每个对象序列化成字符串作为 Set 的键
for (const item of arr) {
const key = JSON.stringify(item)
if (!seen.has(key)) {
seen.add(key)
result.push(item)
}
}
return result
}
// 用法示例:
// UniqueArray.unique20<{id: number}>([{id: 1}, {id: 2}, {id: 1}])
// => [{id: 1}, {id: 2}]
JSON 的两个限制:① 字段顺序不同的对象会被认为不同(
{a:1,b:2}≠{b:2,a:1});②undefined、函数、循环引用会丢失或抛错。
选择指南
%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 15, 'padding': 5}}}%%
graph TD
Start(["数组去重"]) --> Need{"是否需要保序?"}
Need -->|不需要| Fast["看数据特征"]
Need -->|需要| Ordered["保留原顺序"]
Fast --> Q1{"数据形态"}
Q1 -->|顺便要排序| Sort["sort + Set"]
Q1 -->|纯基本类型| Set1["[...new Set(arr)]"]
Ordered --> Q2{"侧重点"}
Q2 -->|代码简洁| Set2["[...new Set(arr)]<br/>一行解决"]
Q2 -->|函数式 O(n)| FilterSet["filter + Set 闭包"]
Q2 -->|按字段去重| MapByKey["Map + keyFn"]
Q2 -->|对象数组| JSON["JSON.stringify + Set"]
classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a
classDef decision fill:#FE8B57,color:#fff,stroke:#141b2d
classDef fast fill:#3A86FF,color:#fff,stroke:#2b63c4
classDef ordered fill:#8338EC,color:#fff,stroke:#5e27a8
classDef method fill:#0f3460,color:#fff,stroke:#0a2647
class Start start
class Need,Q1,Q2 decision
class Fast fast
class Ordered ordered
class Sort,Set1,Set2,FilterSet,MapByKey,JSON method
| 类别 | 时间复杂度 | 是否保序 | 主要场景 |
|---|---|---|---|
| 基础循环 | O(n²) | 是 | 教学、面试手撕 |
| 内置数组方法 | O(n) ~ O(n²) | 是 | 函数式风格 |
| 集合容器 | O(n) | 看具体类 | 日常项目首选 |
| 排序后去重 | O(n log n) | 否 | 顺便要排序 |
| 递归 / JSON | 视实现 | 看实现 | 教学 / 对象数组 |
实际项目里怎么选
绝大多数情况一行就够:
// 保序、O(n)、写法最短,工程首选
const result = [...new Set<T>(arr)]
// 或函数式风格,O(n)
const seen = new Set<T>()
const result = arr.filter(x => !seen.has(x) && !!seen.add(x))
按业务字段去重(最常用):
interface User {
id: number
name: string
}
// 按 id 去重
const result = [...new Map(users.map(u => [u.id, u])).values()]
对象数组按整体结构去重:
const seen = new Set<string>()
const result = arr.filter(x => {
const key = JSON.stringify(x)
return !seen.has(key) && !!seen.add(key)
})
需要排序:
const result = [...new Set(arr)].sort((a, b) => a - b)
带业务逻辑的去重
实际工作里经常遇到这样的情况:遇到重复时不能简单丢弃,要按某个规则做处理。比如:
- 按
id去重,但要保留分数最高的那条记录 - 去重的同时累加重复次数
- 数值在某个区间内才参与去重
这类需求 Set 直接搞不定,需要把"判重"和"处理"两步拆开来写。TS 里通常用泛型 Map<K, V> + 合并函数:
/**
* 带业务规则的去重。
*
* @param data 原数据
* @param keyFn 从元素提取去重键
* @param onDup 遇到重复时如何合并 (旧值, 新值) -> 新代表值
*/
function uniqueBy<T, K>(
data: T[],
keyFn: (item: T) => K,
onDup?: (oldVal: T, newVal: T) => T,
): T[] {
// Map 保证遍历顺序与首次出现顺序一致
const chosen = new Map<K, T>()
for (const item of data) {
const key = keyFn(item)
if (!chosen.has(key)) {
chosen.set(key, item)
} else if (onDup) {
chosen.set(key, onDup(chosen.get(key)!, item))
}
}
return [...chosen.values()]
}
例 1:按 id 去重,保留分数最高的:
interface Student {
id: number
name: string
score: number
}
const students: Student[] = [
{ id: 1, name: '张三', score: 90 },
{ id: 1, name: '张三', score: 95 }, // 同 id,分数更高
{ id: 2, name: '李四', score: 85 },
]
const result = uniqueBy(
students,
s => s.id,
(oldS, newS) => newS.score > oldS.score ? newS : oldS,
)
// [{id:1, score:95, ...}, {id:2, score:85, ...}]
例 2:去重同时统计频次:
const counts = new Map<string, number>()
for (const item of data) {
counts.set(item, (counts.get(item) ?? 0) + 1)
}
// counts.keys() 是保序的去重结果
例 3:区间过滤——只对 [0, 100] 区间内的值去重,区间外原样保留:
const seen = new Set<number>()
const result: number[] = []
for (const x of data) {
if (x >= 0 && x <= 100) {
if (seen.has(x)) continue
seen.add(x)
}
result.push(x)
}
这三个例子是同一种思路:把判重与业务规则分开。判重用 Set/Map 保证 O(n),规则部分留给回调或显式分支处理。
对象数组去重的几种 TS 写法
TS 比 JS 的优势在于——可以为每种去重写法显式标注键类型,编译器会帮你检查:
写法 1:按字段去重(最常见)
interface User {
id: number
name: string
}
// new Map<id类型, 值类型>
const result: User[] = [
...new Map<number, User>(users.map(u => [u.id, u])).values()
]
写法 2:按多字段组合
const result: User[] = [
...new Map<string, User>(
users.map(u => [`${u.id}|${u.name}`, u])
).values()
]
写法 3:按整体结构(用 JSON)
const seen = new Set<string>()
const result = arr.filter(x => {
const key = JSON.stringify(x)
return !seen.has(key) && !!seen.add(key)
})
写法 4:写一个通用的 uniqueBy(推荐)
function uniqueBy<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
const seen = new Set<K>()
return arr.filter(item => {
const key = keyFn(item)
return !seen.has(key) && !!seen.add(key)
})
}
// 使用
const unique = uniqueBy(users, u => u.id)
TS 类型小贴士
Set 的泛型参数:永远显式标注 Set<T>,避免推断成 Set<unknown>:
const seen = new Set<number>() // ✓ 类型明确
const seen = new Set() // ✗ Set<unknown>
reduce 的初始值:泛型推断有时会推成原数组类型,需要显式标注:
// ✗ 类型错误:推断 acc 为 number 而非 number[]
arr.reduce((acc, x) => [...acc, x], [])
// ✓ 用 reduce<T[]> 或 [] as T[]
arr.reduce<number[]>((acc, x) => [...acc, x], [])
Set.add 返回类型:Set<T>.add 返回 Set<T>(truthy),用 && 短路时需要 !! 转布尔:
// JS 里直接 && 即可,TS 严格模式下需 !!
return !seen.has(item) && !!seen.add(item)
总结
工程应用选择:
- 默认用
[...new Set<T>(arr)]:保序、一行、O(n)、类型安全 - 函数式用
arr.filter(x => !seen.has(x) && !!seen.add(x)) - 按字段去重用通用泛型
uniqueBy<T, K>(arr, keyFn) - 对象整体去重用
JSON.stringify作为键 - 顺便排序用
[...new Set(arr)].sort((a, b) => a - b) - 业务规则干预用
Map<K, V>+ 合并函数
核心思路:
- 同一个问题可以从多个角度切入
- 选对数据结构往往比写更聪明的代码更重要
- O(n²) 与 O(n) 在数据变大时是几百倍的实际差距
- 不要过度优化——能用
new Set就别绕弯 - TS 的泛型让通用工具函数写一次、对所有类型可用,比 JS 更值得封装
更多算法
不同语言算法实现:github.com/microwind/a…
AI编程知识库:microwind.github.io
面试官:说一下你现在使用的 AI IDE,什么,JoyCode 是什么?
前言
面试官:同学你好,先自我介绍一下吧
我:好嘞!我叫海石,是一位能工智人🤖
面试官:?
![]()
我:哦不是,是一位在AI Coding上有一定经验的研发...😁
面试官:既然你都这么说了,那你日常在用什么 AI IDE ?
我:JoyCode!
面试官:嗯嗯,Claude Co我也用,确实好……等等,JoyCode?这是什么?
我:点击此处,快速访问官网呢亲
咳咳,节目效果到此为止
我想不少掘友,听到 JoyCode 的第一反应都是"啥?没听过 。"
⬇️从谷歌搜索指数中也可见一斑⬇️
![]()
作为一个已经把 JoyCode 当成"靠谱队友"用了大半年的京东 JDer,我觉得是时候认真 聊一聊:JoyCode 到底是什么?它好用吗"?
![]()
(个人的分享往往是有限的,一些最佳实践随着技术的迭代,模型的升级,往往也会过时,因此本文只是一次抛砖引玉,欢迎掘友们交流)
「观前叠甲」
1、有些案例现在看确实陈旧,比如rules+mcp,但其实是当初25年mcp比较热门时,skill的概念还没推出时,社区里也比较推崇的方式,大家不妨以一种回顾一路走来的AI Coding发展历史的心态阅读
2、从Vibe Coding 到 Context、SDD、到Agentic Coding(Multi-Agent)、再到Harness Engineering,AI时代给人一种“只要我学得晚,我就可以什么都不用学”的感受😂
3、笔者个人目前最常用的(门槛也最低)方式还是SDD 或者 有时候就是安装一些best practice的skills,开个plan模式,基本上就足够日常开发需求了
至于参考OpenAI的实践,用Harness Engineering的思路对当前工程仓库进行改造,我们可以下回一起聊一聊,不仅仅只是停留于概念的解释,而是真的结合业务需求进行实战
看看这样做了,编码质量到底能提高多少
一、JoyCode 到底是什么?
借用官网的一句话:JoyCode,专为应对企业级复杂任务而设计的智能编码工具
适合在以下场景使用:
- 企业复杂任务场景:助力对业务需求精准理解,代码仓库的深度解析
- 需要开箱即用的全流程智能开发体验:JoyCode 提供完整的 AI 辅助开发体验
- 寻求 AI 辅助编程提升效率的开发者:利用 AI 能力加速开发过程
- 需要智能代码补全和实时编程建议的场景:提高编码效率和质量
以具体业务需求开发为例,聊聊我的"编程队友"JoyCode 是怎么为我提效的
二、并行任务
先来还原一个我上周二下午的真实状态:
![]()
1️⃣ xx系统的页面要补监控配置——监控不能等;
2️⃣ 测试同事在群里 @ 我:"那 份埋点数据呢?"——人不能等;
3️⃣ 产品又甩来一个新需求:"这个加一下,今天能上吗 ?"——需求也不能等。
![]()
放在以前,这种时候我只有两个选择:
- A. 加班
- B. 报风险,然后被产品蛐蛐
但现在,我可以把新需求的代码实现交给 JoyCode,我自己专心搞前两件 。
这就是我想说的第一个核心用法:异步协作。
很多人对 AI 写代码的印象是:"写得快,但写得野。"
变量随便起、组件库瞎引、规范全靠猜——交付出来的代码我还得花一个小时给它"擦屁股", 那不如自己写。
那有没有办法让 AI 既写得快、又写得"懂规矩"?
有,Rules + MCP
Step 1:给 JoyCode 配 Rules,把它从"专门"变成"专业"
JoyCode 支持类似 Cursor 的 .mdc 规则文件,配置入口在这里 👇
![]()
![]()
不知道写什么 Rules?给大家推荐一个 star 接近 3k 的开源项目:
![]()
各种语言、各种框架的 Rules 应有尽有,基本是开箱即用。
![]()
创建好的 Rule 会落到这个目录里
![]()
Step2:基于业务实践沉淀Rule
通用的 Rules 解决不了"业务私有"的问题。
我自己负责的项目用的是京东自研的组件库,其中 jd-icon 在 Vue2 兼容写法 和 Vue3 <script setup> 写法下,用法完全不同——这种"内部知识"AI 是不可能自己悟出来的。
于是我手搓了一份 dong-design-icon.mdc
把"踩过的坑"沉淀成 Rule,这一步看着繁琐,但ROI很高——AI 再也不会写出 <jd-icon icon="plus" /> 这种"四不像"了
Step 3:MCP 一开, 直通"京东内网生态"
京东内部各中台沉淀了很多mcp工具提供给上下游
这提高了我们用大模型进行编码的质量,减少了返工
三、新版本体验
(截止本文发布,版本已经更新至2.6.x)
JoyCode 从 v0.5.0 一路更新到 v2.3.6,最新的 v3.0.0 Preview 也已经在路上。它的更新主题是「提效、智能、便捷」
![]()
「便捷」:满分 10 分,我打 8.4分,因为确实有1.6
✅ 拖拽文件、一键添加上下文
![]()
✅ 一键选中代码加上下文 + Cmd + L 快捷键
![]()
✅ Auto 模式默认选中
![]()
减法美学,给减负点赞。
✅ AI 提交行数披露
![]()
从 v2.3.4 升到 v2.3.6,统计准确多了,看着有成就感。😋
✅ Repo Wiki:基于当前工程仓库生成WIKI,类似Zread 和 DeepWiki
四、技术氛围
公司内部的技术氛围还是不错的,不过这个还是“因组而异”
经常有这样一句话流传:同一个公司组和组之间的区别,可能比公司与公司之间的区别还要大
我们组每周会组织AI相关的分享,包括但不限于AI提效范式的探讨、CopilotKit、AGUI协议、A2UI的实践、AI Coding的干货技巧等等
个人觉得组里对新技术和AI前沿还是很看重
五、结语
AI时代大家不可避免的会感到焦虑
前端已死都不知道听了多少回了
之前逛论坛和社区也会经常看到这样一张图:
守旧派如何如何
维新派如何如何
以及觉得AI已经可以替代程序员的人又如何如何
我的想法不多,如图所示,与君共勉
![]()
一次搞懂:在Vue里用Showdown渲染Markdown+KaTeX数学公式
一个前端小白的踩坑日记,帮你避开那些“根号变触手”的诡异场面
事情是这样的
前两天接到一个需求:数据库里存了一堆 Markdown 格式的文本,里面还夹杂着各种数学公式,比如 $E=mc^2$ 这种,更狠的还有 $$\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}$$。要在 Vue 页面里把它们渲染得漂漂亮亮的,公式要有专业排版,不能用图片糊弄。
我心想:Markdown 渲染嘛,网上插件一抓一大把,分分钟搞定。结果……一抓就是一把 bug,尤其是那些数学公式,跟成精了似的,根号能变出分身来。这一折腾就是两天,最后总算整明白了。今天就把这个过程揉碎了写给你,让你少走点弯路。
市面上的 Markdown 解析器很多,什么 marked、markdown-it、showdown……我最后选了 showdown,原因就两条:
- 稳:老牌库,这么多年了,坑基本都被填平了。
-
插件友好:
showdown-katex插件刚好能满足我们 LaTeX 公式的需求,而且配置非常灵活。
装起来也就两行命令的事儿:
npm install showdown
npm install showdown-katex
先上能跑的代码,免得你们着急。这是一个简单的 Vue 组件,用来展示带公式的 Markdown 内容。
<template>
<div class="msg" v-html="transformMsg(msgInfo)"></div>
</template>
<script>
import showdown from "showdown";
import showdownKatex from "showdown-katex";
export default {
data() {
return {
msgInfo: "" // 从后端拿到的 Markdown 字符串
}
},
methods: {
transformMsg(msgInfo = "") {
// 第一步:预处理脏数据(后面解释为什么)
msgInfo = msgInfo.replaceAll("<br />", "\n");
// 第二步:创建 showdown 转换器,配置好插件
let converter = new showdown.Converter({
tables: true, // 支持表格
strikethrough: true, // 支持 ~~删除线~~
underline: true, // 防止下划线被误解析为斜体
extensions: [
showdownKatex({
throwOnError: false, // 公式写错了也别抛异常,页面别崩
displayMode: false, // 默认行内模式(可以改成 true 让 $$ 变块级)
delimiters: [
{ left: "$$", right: "$$", display: false },
{ left: "$", right: "$", display: false },
{ left: "~", right: "~", display: false, asciimath: true },
],
}),
],
});
// 第三步:转成 HTML 并返回
return converter.makeHtml(msgInfo);
}
}
}
</script>
看着挺简单对吧?我当时也是这么想的,直到我遇到了那个“根号怪”。
奇奇怪怪问题一:根号怎么变分身了?
第一个让我破防的 bug:只要公式里有根号(比如 $\sqrt{2}$),页面上就会出现两个根号!第一个是正常的,第二个像个伸着长脖子的怪物,根号线无限拉长,里面的公式还重复显示。那场面,简直像公式在分裂繁殖。
![]()
经过一番搜索(其实就是去 GitHub 翻 issue),才知道这是 KaTeX 渲染机制导致的。KaTeX 为了兼顾屏幕阅读器,会同时生成两个 DOM 结构:一个用于正常显示(.katex),另一个做辅助(.katex-html)。在某些复杂公式(尤其是根号)下,两个结构都会在页面上渲染出来,就造成了重影。
解决方法?简单粗暴,CSS 一刀切:
.katex-html {
display: none;
}
放心,这不会影响正常公式的显示,屏幕阅读器也能从 .katex 里读取内容。加完这句,根号立马老实了,世界清净。
奇奇怪怪问题二:数据库里的 <br /> 不听话
我们的数据是老系统倒过来的,里面换行用的是 <br />(中间俩空格)。结果 showdown 转换后,这些 <br /> 原样输出到了 HTML 里,根本没变成换行。页面上一段话全挤在一起,像没分段的作文。
解决办法是在转换前做替换,把 <br /> 变成 Markdown 认的换行符 \n:
msgInfo = msgInfo.replaceAll("<br />", "\n");
如果你数据库里还有 <br>、<br/> 等各种变体,可以写个正则一把抓:
msgInfo = msgInfo.replace(/<br\s*\/?>/gi, "\n");
奇奇怪怪问题三:下划线被当成斜体,公式乱套
物理老师最爱写 F_gravity,但 Markdown 里下划线默认表示斜体。于是 F_gravity 就变成了 F<em>gravity</em>,显示出来“F gravity”斜了,完全不是那个意思。
showdown 提供了一个 underline 选项,设为 true 后,__双下划线__ 会变成下划线,但单下划线 _ 还是斜体。这不够啊,我们想要的是:在普通文字里保留 Markdown 语法,但公式里的下划线别捣乱。
其实 showdown-katex 插件在解析时会先把公式内容“保护”起来,只要你的公式被 $...$ 或 $$...$$ 包住,里面的下划线就不会被 Markdown 引擎解析。所以关键是:所有公式都必须写定界符。
如果历史数据里确实有裸下划线没加 $,那只能写个脚本批量包一下,或者在转换前用正则把 \b_\w+_\b 之类的模式手动包成公式。这活儿有点糙,但能救急。
奇奇怪怪问题四:行内公式和块级公式不分家
看上面代码的 delimiters 配置,$$ 和 $ 的 display 都是 false,这意味着无论你写 $E=mc^2$ 还是 $$E=mc^2$$,都会被当成行内公式,不换行,不居中。这显然不符合常识。
正确的配置应该是:$ 行内,$$ 块级。改一下:
delimiters: [
{ left: "$$", right: "$$", display: true }, // 块级公式,独占一行
{ left: "$", right: "$", display: false }, // 行内公式,夹在文字中间
{ left: "~", right: "~", display: false, asciimath: true }
]
当然,如果你希望 $$ 也当行内用,那就保持原样。但根据 LaTeX 习惯,还是建议区分开来。
如果直接在模板里写 v-html="transformMsg(msgInfo)",每次组件重新渲染(比如窗口滚动、数据变化)都会重新解析 Markdown 和公式。公式一多,页面就卡得像幻灯片。
改成计算属性(computed)缓存一下结果:
computed: {
renderedHtml() {
return this.transformMsg(this.msgInfo);
}
}
模板里用 v-html="renderedHtml",只有当 msgInfo 真正变化时才会重新转换。
KaTeX 自带样式,但默认块级公式的上下边距可能跟你的页面不搭。可以自己在全局 CSS 里调一下:
.katex-display {
margin: 1.5em 0;
overflow-x: auto; /* 防止超宽公式溢出 */
}
如果行内公式显得太小,可以来个:
.katex {
font-size: 1.05em;
}
throwOnError: false 是个好习惯。公式写错了,页面不会白屏,只会显示一段红色的错误提示。用户至少能看到“这里有个公式没渲染好”,而不是整个页面崩掉。
我把上面的点整合成一个 Vue 单文件组件,你直接拷到项目里就能用:
<template>
<div class="markdown-math" v-html="renderedHtml"></div>
</template>
<script>
import showdown from "showdown";
import showdownKatex from "showdown-katex";
export default {
name: "MarkdownMath",
props: {
content: {
type: String,
default: ""
},
// 可选:自定义定界符
delimiters: {
type: Array,
default: null
}
},
computed: {
renderedHtml() {
return this.transform(this.content);
}
},
methods: {
transform(raw) {
if (!raw) return "";
// 预处理奇怪的换行
let processed = raw.replace(/<br\s*\/?>/gi, "\n");
const customDelimiters = this.delimiters || [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
{ left: "\\[", right: "\\]", display: true },
{ left: "\\(", right: "\\)", display: false }
];
const converter = new showdown.Converter({
tables: true,
strikethrough: true,
underline: false, // 关掉下划线解析,避免干扰公式
simpleLineBreaks: true,
extensions: [
showdownKatex({
throwOnError: false,
delimiters: customDelimiters
})
]
});
return converter.makeHtml(processed);
}
}
};
</script>
<style scoped>
/* 组件内部样式 */
.markdown-math {
line-height: 1.6;
word-wrap: break-word;
}
</style>
<style>
/* 全局样式,解决根号分身 */
.katex-html {
display: none;
}
.katex-display {
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
}
</style>