普通视图
Anthropic与Snowflake达成2亿美元协议
天溯计量拟于近期在深市发行新股并上市
英国能源监管机构拨款280亿英镑用于升级能源网络
博思软件:公司暂无涉及芯片业务的子公司
基本半导体向港交所提交上市申请书
从零实现一个“类微信”表情输入组件
📋 背景
最近接到一个需求:在系统中添加表情输入功能。由于需要与腾讯某平台保持数据一致,表情包的数量和取值都要完全相同。
翻阅文档后发现并没有提供现成组件,只能自己实现。
先观察了实现方式:打开控制台面板,点击表情后会瞬间请求大量图片。
输入逻辑上,用户选择 😊 后值自动变成 [微笑],用户直接输入 [微笑] 也能映射成对应表情图片。
🚩 最终目标
- ✅ 支持点击表情面板插入表情
- ✅ 支持输入
[微笑]自动转换为表情图片 - ✅ 完成双向绑定,取值时图片转回
[微笑]文本 - ✅ 字符长度计算:中文 1 个字符,英文 0.5 个,表情 1 个
- ✅ 光标定位准确,体验流畅
- ✅ 输入法友好,不在拼音输入阶段转换
🧩 实现步骤
1、获取表情包数据
最初尝试在网上找表情包资源,但数量总是对不上。近百个表情包如果手动逐个校对太过折磨,于是尝试从页面爬取数据。 在浏览器控制台执行以下脚本:
// 获取所有表情元素
const emojiItems = document.querySelectorAll('.emoji-list li');
// 提取关键信息:图片地址、文本代码、文件名
const emojiArray = Array.from(emojiItems).map(li => {
const img = li.querySelector('img');
return img ? {
src: img.src,
alt: img.alt,
dataImage: img.getAttribute('data-image')
} : null;
}). filter(Boolean);
console.log(emojiArray);
执行后直接在控制台复制数组,保存为 JSON 文件。
src 只做下载使用,alt 需要用来做映射,dataImage 用于拼接读取表情图片路径。
[ { "alt": "[微笑]", "src": "https://xxx.qq.com/xxx/emojis/smiley_0.png" "dataImage": "smiley_0" }]
2、批量下载图片
使用 Node. js 脚本批量下载表情图片:
const fs = require('fs');
const path = require('path');
const outputDir = path. resolve(__dirname, 'emojis');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
async function downloadImage(item) {
const fileName = `${item.dataImage || item.alt || 'emoji'}. png`;
const filePath = path.join(outputDir, fileName);
try {
const res = await fetch(item.src);
if (!res.ok) throw new Error(`Failed to fetch ${item.src}`);
const buffer = await res.arrayBuffer();
fs.writeFileSync(filePath, Buffer.from(buffer));
console.log(`Downloaded: ${fileName}`);
} catch (err) {
console.error(`Error downloading ${item.src}:`, err.message);
}
}
async function downloadAll() {
for (const item of emojiArray) {
await downloadImage(item);
}
console.log('All downloads completed!');
}
downloadAll();
表情下载完,后面就好办了——交给 AI 🤖。
3、组件实现
大概思路是有的,本质就是一个 contenteditable 的 div,组件实现完全让 AI 完成,但需要考虑一些特殊情况,要给出明确期望:
1️⃣ 光标位置管理
- 长度达到限制时裁剪字符,统一将光标设置到末尾,防止位置异常(element-plus输入框效果如此)
- 点击空白区域自动定位到末尾
2️⃣ 文本与图片双向转换
文本 → 图片
- 使用防抖(200ms)避免频繁触发
- 仅转换光标前的内容,光标后的内容保持不变
- 通过正则匹配
[xxx]格式进行转换 - 关键:不在用户输入过程中转换,避免干扰输入体验
图片 → 文本
- 文本节点直接提取
textContent - 图片节点提取
data-alt属性转为文本 - 过滤零宽字符
\u200B
3️⃣ 字符长度计算
精确计算混合内容长度:中文 1 个字符,英文/数字 0.5 个,表情 1 个。
4️⃣ 输入法兼容
处理中文输入法的组合事件:compositionstart → compositionupdate → compositionend,避免在拼音输入阶段触发转换。
5️⃣ 删除表情
确保删除时完整移除表情图片节点,不留残留。
最终效果:
![]()
💡总结
借助 AI 辅助开发,核心在于:告知 AI 需要处理的特殊情况,以及不断测试和调优。
只要明确需求规范和边界情况,就能高效实现功能。怎么有种从开发转测试的感觉。
A股三大指数收盘涨跌不一,大消费板块领跌
100¥ 实现的React项目 Keep-Alive 缓存控件
前言
目前在做自己的后台管理系统,采用React构建,利用Antd加了Tabs可关闭页签,结合dnd-kit实现了可拖拽Tabs,页面渲染之前参考网上采用Map缓存,结果实际到手发现不是缓存失效,就是刷新、清除影响缓存,反正问题挺多。
Vue是有自带的Keep-Alive控件,咱小胳膊小腿的,也不敢比,也没那时间精力去研究🧐,但React百度搜了是没有自带的Keep-Alive的,网上的教程也仅止于静态实例(可能我没搜到,万望大佬勿喷),但自己又很想要学这个功能。
最近AI确实很🔥很👍,之前使用过字节的Trae,当时效果还不错,刚好赶上Clude的末班车,自从Clude不让用后,Trae的体验一言难尽。于是抱着体验的态度,花了20$买了Cursor,于是就拿缓存Keep-Alive开🔪,从昨晚开始搞到现在,在我不断测试不断调教下,终于有了成果,但代价也不一般,直接上图
一天过去,直接烧掉100大洋,欢迎品尝
线上地址:www.liyq666.top/
git仓库:gitee.com/lyqjob/proj…
项目实例图
![]()
附上AI生成的使用文档,以下内容AI生成
概述
什么是页面缓存?
想象一下,你在浏览器中打开了多个标签页(比如:用户管理、角色管理、菜单管理)。当你从一个标签页切换到另一个标签页时,页面缓存系统会帮你:
- 保存页面状态:切换标签页时,页面不会重新加载,之前填写的数据、滚动位置等都会保留
- 避免重复请求:切换回之前的标签页时,不会重新请求接口,直接显示之前的数据
- 提升用户体验:页面切换更流畅,没有闪烁和重新加载的感觉
为什么需要缓存?
- 性能优化:减少不必要的 API 请求,提升页面响应速度
- 用户体验:保持页面状态,用户不会丢失已填写的数据
- 资源节约:避免重复渲染组件,节省浏览器资源
核心概念
1. 缓存存储(Cache Store)
缓存系统使用 Map 数据结构来存储页面组件:
// 三个核心存储结构
const cacheStore = new Map() // 存储:key -> ReactElement(页面组件)
const locationStore = new Map() // 存储:key -> Location(路由信息)
const accessOrder = [] // 存储:访问顺序数组,用于 LRU 算法
简单理解:
-
cacheStore:就像一个大仓库,每个页面都有一个编号(key),对应一个页面组件 -
locationStore:记录每个页面的路由信息(路径、参数等) -
accessOrder:记录页面的访问顺序,最近访问的排在最后
2. 缓存 Key(Cache Key)
每个页面都有一个唯一的标识符,由 路径 + 查询参数 组成:
// 例如:
'/setting/user' // 用户管理页面
'/setting/user?page=2' // 用户管理页面,第2页(不同的 key!)
'/setting/role' // 角色管理页面
重要:即使路径相同,查询参数不同,也会被视为不同的页面,需要分别缓存。
3. 白名单机制(Whitelist)
有些页面不需要缓存,每次访问都重新渲染。这些页面在白名单中:
const CACHE_WHITELIST = [
'/', // 首页
'/dashboard', // 数据看板
'/setting/cache', // 缓存管理页面
'/setting/log/loginlog', // 登录日志
'/setting/log/operlog', // 操作日志
'/monitor/online', // 在线用户
'/setting/role/info' // 角色详情
]
为什么需要白名单?
- 首页、看板等页面需要实时数据,不应该缓存
- 日志类页面需要显示最新数据,缓存会导致数据不准确
4. LRU 算法(Least Recently Used)
LRU = 最近最少使用
当缓存数量超过限制(默认 8 个)时,系统会自动删除最久未使用的页面缓存。
工作原理:
- 每次访问页面时,将该页面移到访问顺序数组的最后
- 当缓存超过 8 个时,删除访问顺序数组第一个(最久未使用的)
- 这样保证最常用的页面始终在缓存中
示例:
访问顺序:['/page1', '/page2', '/page3', '/page4', '/page5', '/page6', '/page7', '/page8']
访问 /page1 → ['/page2', '/page3', '/page4', '/page5', '/page6', '/page7', '/page8', '/page1']
访问 /page9 → 缓存已满,删除 /page2(最久未使用)
系统架构
整体架构图
┌─────────────────────────────────────────────────────────────┐
│ BasicLayout(布局组件) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ TabsContainer(标签页容器) │ │
│ │ - 显示标签页 │ │
│ │ - 右键菜单(刷新、关闭等) │ │
│ │ - 拖拽排序 │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ KeepAliveOutlet(缓存核心组件) │ │
│ │ - 管理页面缓存 │ │
│ │ - LRU 算法 │ │
│ │ - 渲染缓存的页面 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 订阅/发布消息
▼
┌─────────────────────────────────────────────────────────────┐
│ useGlobalMessage(全局消息系统) │
│ - 发布 keep:alive:drop(删除单个缓存) │
│ - 发布 keep:alive:clear(清除所有缓存) │
└─────────────────────────────────────────────────────────────┘
│
│ 管理标签页状态
▼
┌─────────────────────────────────────────────────────────────┐
│ useTabsManager(标签页管理) │
│ - 添加/关闭标签页 │
│ - 切换标签页 │
│ - 发送清除缓存消息 │
└─────────────────────────────────────────────────────────────┘
│
│ 确保数据只加载一次
▼
┌─────────────────────────────────────────────────────────────┐
│ useCacheableEffect(可缓存的 Effect) │
│ - 跟踪已初始化的组件 │
│ - 防止重复加载数据 │
└─────────────────────────────────────────────────────────────┘
组件关系
- BasicLayout:最外层布局,包含所有组件
- TabsContainer:显示和管理标签页
- KeepAliveOutlet:核心缓存组件,管理页面缓存
- useTabsManager:管理标签页状态,发送清除缓存指令
- useGlobalMessage:全局消息系统,用于组件间通信
- useCacheableEffect:确保页面数据只加载一次
核心组件详解
1. KeepAliveOutlet(缓存核心组件)
位置:src/components/KeepAlive/index.jsx
职责:
- 管理页面缓存的生命周期
- 实现 LRU 算法
- 渲染缓存的页面组件
核心数据结构
// 缓存存储(模块级变量,所有实例共享)
const cacheStore = new Map() // key -> { element: ReactElement, location: Location }
const accessOrder = [] // 访问顺序数组,用于 LRU 算法
// 暴露到全局的工具函数
window.__checkCache = (key) => cacheStore.has(key) // 检查是否有缓存
window.__isWhitelisted = (pathname) => isWhitelisted(pathname) // 检查是否在白名单
注意:locationStore 已被移除,位置信息直接存储在 cacheStore 的 value 中。
关键函数
1. getCacheKey(pathname, search)
// 生成缓存 key
const getCacheKey = (pathname, search) => {
return pathname + search // 例如:'/setting/user?page=2'
}
2. isWhitelisted(pathname)
// 检查路径是否在白名单中
const isWhitelisted = (pathname) => {
return CACHE_WHITELIST.some(route => {
if (pathname === route) return true
if (pathname.startsWith(route + '/')) return true
return false
})
}
3. moveToRecent(key)
// 将 key 移到访问顺序数组的最后(标记为最近使用)
const moveToRecent = (key) => {
const index = accessOrder.indexOf(key)
if (index >= 0) {
accessOrder.splice(index, 1) // 从原位置删除
}
accessOrder.push(key) // 添加到末尾
}
4. evictLRU(excludeKey)
// LRU 清理:删除最久未使用的缓存(排除当前正在访问的)
const evictLRU = (excludeKey) => {
while (cacheStore.size >= CACHE_LIMIT) { // 默认 8 个
// 找到第一个不是 excludeKey 的 key(最久未使用的)
const keyToRemove = accessOrder.find(k => k !== excludeKey)
if (keyToRemove) {
removeCache(keyToRemove) // 删除缓存
} else {
break
}
}
}
5. removeCache(key)
// 移除指定 key 的缓存
const removeCache = (key) => {
if (cacheStore.has(key)) {
cacheStore.delete(key) // 删除组件缓存
const index = accessOrder.indexOf(key)
if (index >= 0) {
accessOrder.splice(index, 1) // 从访问顺序中删除
}
return true
}
return false
}
缓存管理流程
步骤 1:检查是否需要缓存
const shouldNotCache = useMemo(() => isWhitelisted(location.pathname), [location.pathname])
步骤 2:生成缓存 key
const cacheKey = getCacheKey(location.pathname, location.search)
步骤 3:处理缓存逻辑
useEffect(() => {
// 1. 白名单路由:不缓存,直接返回
if (shouldNotCache) {
if (removeCache(cacheKey)) {
setCacheVersion(v => v + 1) // 触发重新渲染
}
return
}
// 2. 如果 key 没变化,只更新访问顺序
if (prevKeyRef.current === cacheKey) {
if (cacheStore.has(cacheKey)) {
moveToRecent(cacheKey) // 标记为最近使用
}
return
}
// 3. key 变化了,处理新页面
prevKeyRef.current = cacheKey
// 如果还没有缓存,添加缓存
if (!cacheStore.has(cacheKey)) {
// 使用 setTimeout 确保 outlet 已经准备好
const timer = setTimeout(() => {
const currentOutlet = outletRef.current
if (currentOutlet) {
cacheStore.set(cacheKey, {
element: currentOutlet,
location: {
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state,
key: location.key
}
})
if (!accessOrder.includes(cacheKey)) {
accessOrder.push(cacheKey)
} else {
moveToRecent(cacheKey)
}
evictLRU(cacheKey) // 如果超过限制,删除最久未使用的
setCacheVersion(v => v + 1)
}
}, 0)
return () => {
clearTimeout(timer)
}
} else {
// 已缓存,只更新访问顺序
moveToRecent(cacheKey)
}
}, [cacheKey, shouldNotCache, outlet, location.pathname, location.search])
渲染逻辑
const nodes = useMemo(() => {
const list = []
// 1. 白名单路由:直接渲染,不缓存
if (shouldNotCache) {
if (outlet) {
list.push(<div key={cacheKey}>{outlet}</div>)
}
return list
}
// 2. 如果还没有缓存,但 outlet 存在,临时渲染(首次加载)
if (!cacheStore.has(cacheKey) && outlet) {
list.push(<div key={cacheKey}>{outlet}</div>)
}
// 3. 渲染所有缓存的组件(通过 display 控制显示/隐藏)
for (const [key, cache] of cacheStore.entries()) {
const isActive = key === cacheKey
list.push(
<div
key={key}
style={{
display: isActive ? 'block' : 'none', // 只有当前页面显示
height: '100%',
width: '100%'
}}
>
<CachedComponent cacheKey={key}>
{cache.element}
</CachedComponent>
</div>
)
}
return list
}, [cacheKey, cacheVersion, shouldNotCache, outlet])
消息订阅(接收清除缓存指令)
useEffect(() => {
// 订阅 'keep:alive:drop' 事件(删除单个缓存)
const onDrop = (detail) => {
const key = detail?.key
if (!key) return
// 🌟 只有在明确要求清除缓存时才清除(比如刷新标签页)
// 关闭标签页时不应该清除缓存,这样重新打开时可以快速恢复
const shouldRemove = detail?.remove === true
if (shouldRemove) {
if (removeCache(key)) {
setCacheVersion(v => v + 1)
}
// 清除组件初始化状态(刷新时才清除)
if (window.__clearComponentInit) {
window.__clearComponentInit(key)
}
}
// 关闭标签页时:不清除缓存,也不清除初始化状态
}
// 订阅 'keep:alive:clear' 事件(清除所有缓存)
const onClear = () => {
cacheStore.clear()
accessOrder.splice(0, accessOrder.length)
// 清除所有组件初始化状态
if (window.__clearAllInit) {
window.__clearAllInit()
}
setCacheVersion(v => v + 1)
}
const unsubscribeDrop = subscribe('keep:alive:drop', onDrop)
const unsubscribeClear = subscribe('keep:alive:clear', onClear)
return () => {
unsubscribeDrop()
unsubscribeClear()
}
}, [subscribe, error])
重要变化:
- 关闭标签页时:不清除缓存(
remove: false),保留页面状态,重新打开时可以快速恢复 - 刷新标签页时:清除缓存(
remove: true),强制重新加载数据 - 使用
window.__clearComponentInit和window.__clearAllInit清除初始化状态
2. useCacheableEffect(可缓存的 Effect Hook)
位置:src/hooks/useCacheableEffect.js
职责:
- 确保
useEffect只在首次挂载时执行 - 防止切换标签页时重复加载数据
核心数据结构
// 全局存储已初始化的组件(模块级变量)
const initializedComponents = new Set()
// 存储格式:'pathname+search::depsStr'
// 例如:'/setting/user::[]' 或 '/setting/role::[null,null]'
工作原理
export const useCacheableEffect = (effect, deps = [], options = {}) => {
const { cacheable = true, cacheKey } = options
const location = useLocation()
// 生成组件唯一标识
const componentKey = cacheKey || (location.pathname + location.search)
const depsStr = JSON.stringify(deps) // 依赖项的 JSON 字符串
const initKey = `${componentKey}::${depsStr}`
// 使用 ref 存储 effect 和是否已执行
const effectRef = useRef(effect)
const hasExecutedRef = useRef(false)
// 更新 effect 引用
useEffect(() => {
effectRef.current = effect
}, [effect])
useEffect(() => {
// 如果不可缓存,每次都执行
if (!cacheable) {
const cleanup = effectRef.current()
return cleanup
}
// 检查是否已初始化(全局检查)
if (initializedComponents.has(initKey)) {
window.process.done() // 关闭进度条
return // 已初始化,跳过执行
}
// 检查是否已执行(组件级别检查,防止重复执行)
if (hasExecutedRef.current) {
// 确保已标记为已初始化
if (!initializedComponents.has(initKey)) {
initializedComponents.add(initKey)
}
return
}
// 首次执行
hasExecutedRef.current = true
initializedComponents.add(initKey)
const cleanup = effectRef.current()
return () => {
if (typeof cleanup === 'function') {
cleanup()
}
}
}, [componentKey, cacheable, depsStr]) // 不包含 effect,使用 effectRef 存储最新引用
}
关键改进:
- 使用
Set而不是Map存储初始化状态 - 使用
effectRef存储最新的 effect 函数,避免依赖项变化导致的问题 - 双重检查机制:全局检查(
initializedComponents)和组件级别检查(hasExecutedRef) - 自动关闭进度条:当检测到已初始化时,自动调用
window.process.done()
使用示例
非白名单页面(需要缓存):
// 在页面组件中使用
const UserManagement = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
// ✅ 首次加载数据 - 使用 useCacheableEffect,必须添加 cacheable: true
useCacheableEffect(() => {
getList(current, pageSize)
getDepartmentList()
}, [], { cacheable: true }) // 空依赖数组,确保只在首次挂载时执行
// ✅ 分页变化时单独处理(使用普通 useEffect)
useEffect(() => {
getList(current, pageSize)
}, [current, pageSize])
// ✅ 数据恢复机制:当检测到缓存但数据为空时,自动重新加载
const location = useLocation();
useEffect(() => {
const cacheKey = location.pathname + location.search;
const isUsingCache = window.__checkCache && window.__checkCache(cacheKey);
if (loading && isUsingCache && !hasTriedRestoreRef.current) {
const timer = setTimeout(() => {
if (data.length === 0 && total === 0) {
// 数据为空,重新加载
getList(current, pageSize);
} else {
// 数据存在,重置 loading
setLoading(false);
}
hasTriedRestoreRef.current = true;
}, 100);
return () => clearTimeout(timer);
}
}, [loading, data.length, total, location.pathname, location.search]);
// ...
}
白名单页面(不需要缓存):
// 白名单页面使用普通 useEffect,不使用 useCacheableEffect
const LoginLog = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
// ✅ 白名单页面,使用普通 useEffect
useEffect(() => {
getList(current, pageSize)
}, [])
// ...
}
为什么需要 useCacheableEffect?
当页面被 KeepAlive 缓存后,组件不会重新挂载,但 useEffect 仍然会在某些情况下执行。使用 useCacheableEffect 可以确保数据只在首次加载时请求一次,避免重复请求。
数据恢复机制:
当从白名单页面切换回缓存页面时,可能会出现组件状态丢失的情况(数据为空)。系统会自动检测并重新加载数据,确保页面正常显示。
3. useTabsManager(标签页管理 Hook)
位置:src/hooks/useTabsManager.js
职责:
- 管理标签页的状态(添加、关闭、切换等)
- 发送清除缓存的指令
核心功能
1. 添加标签页
const addTab = useCallback((pathname, search) => {
const fullPathKey = pathname + (search || '')
setTabs(prevTabs => {
// 检查是否已存在
const existingTab = prevTabs.find(tab => tab.key === fullPathKey)
if (existingTab) {
return prevTabs // 已存在,不重复添加
}
// 创建新标签页
const newTab = getRouteInfo(pathname, search)
return [...prevTabs, newTab]
})
setActiveKey(fullPathKey)
}, [getRouteInfo])
2. 关闭标签页
const closeTab = useCallback((key) => {
setTabs(prevTabs => {
const targetTab = prevTabs.find(tab => tab.key === key)
if (!targetTab || targetTab.isPinned) return prevTabs
const newTabs = prevTabs.filter(tab => tab.key !== key)
// 如果关闭的是当前激活标签页,切换到其他标签页
if (activeKey === key) {
const nextTab = newTabs[0] || newTabs[newTabs.length - 1]
if (nextTab) {
setActiveKey(nextTab.key)
navigate(nextTab.key)
}
}
return newTabs
})
// 🌟 关闭标签页时不清除缓存,只清除初始化状态
// 这样重新打开时可以快速恢复,但会重新加载数据
globalMessageUtils.keepAlive('drop', { key, remove: false })
}, [activeKey, navigate])
注意:关闭标签页时,缓存不会被清除(remove: false),这样重新打开时可以快速恢复页面。只有刷新标签页时才会清除缓存。
3. 关闭所有标签页
const closeAllTabs = useCallback(() => {
// 发送全局清除消息
globalMessageUtils.keepAlive('clear')
// 重置状态
setTabs(DEFAULT_PINNED_TABS)
setActiveKey('/')
navigate('/')
}, [navigate, success])
4. 刷新标签页
const refreshTab = useCallback((key) => {
// 🌟 刷新标签页时清除缓存和初始化状态,强制重新加载
globalMessageUtils.keepAlive('drop', { key, remove: true })
}, [success])
5. 关闭其他标签页
const closeOtherTabs = useCallback((keepKey) => {
setTabs(prevTabs => {
// 找出即将被关闭的标签页的 key
const keysToDrop = prevTabs
.filter(tab => !tab.isPinned && tab.key !== keepKey)
.map(tab => tab.key);
// 清除其他标签页的缓存
keysToDrop.forEach(key => {
globalMessageUtils.keepAlive('drop', { key });
});
// 🌟 如果保留的标签页不是当前激活的,清除其初始化状态
// 这样会重新加载数据,确保页面状态正确
if (activeKey !== keepKey) {
if (window.__clearComponentInit) {
window.__clearComponentInit(keepKey);
}
}
// 返回保留的标签页列表
return prevTabs.filter(tab =>
tab.isPinned || tab.key === keepKey
);
});
// 激活目标 Key 并导航
setActiveKey(keepKey);
navigate(keepKey);
}, [success, navigate, activeKey]);
4. useGlobalMessage(全局消息系统)
位置:src/hooks/useGlobalMessage.js
职责:
- 实现发布-订阅模式
- 处理组件间通信
核心机制
发布消息
const publish = useCallback((eventType, payload = {}) => {
// 1. 通知订阅者
if (subscribersRef.current.has(eventType)) {
const subscribers = subscribersRef.current.get(eventType)
subscribers.forEach(callback => {
callback({ detail: payload })
})
}
// 2. 发送浏览器原生事件
const event = new CustomEvent(eventType, { detail: payload })
window.dispatchEvent(event)
}, [])
订阅消息
const subscribe = useCallback((eventType, callback, options = {}) => {
const { once = false } = options
if (!subscribersRef.current.has(eventType)) {
subscribersRef.current.set(eventType, new Set())
}
const subscribers = subscribersRef.current.get(eventType)
const wrappedCallback = (event) => {
try {
callback(event.detail)
if (once) unsubscribe(eventType, wrappedCallback)
} catch (error) {
console.error(`Error in subscriber for ${eventType}:`, error)
}
}
subscribers.add(wrappedCallback)
return () => unsubscribe(eventType, wrappedCallback)
}, [])
处理 keepAlive 事件
const handleKeepAlive = useCallback((detail) => {
const action = detail?.action || detail?.message || 'drop'
const options = detail?.options || {}
let eventType = EVENT_TYPES.KEEP_ALIVE + ':' + action // 'keep:alive:drop' 或 'keep:alive:clear'
publish(eventType, options)
}, [publish])
工具函数
export const globalMessageUtils = {
// 发送 keepAlive 消息
keepAlive(message = 'keepAlive', options = {}) {
window.dispatchEvent(new CustomEvent(EVENT_TYPES.KEEP_ALIVE, {
detail: { message, options }
}))
}
}
数据流转过程
场景 1:首次访问页面
1. 用户点击菜单 → 路由变化 → location.pathname = '/setting/user'
2. KeepAliveOutlet 检测到路由变化
3. 生成 cacheKey = '/setting/user'
4. 检查白名单 → 不在白名单中,需要缓存
5. 检查 cacheStore → 没有缓存
6. 等待 outlet(页面组件)加载完成
7. 保存到 cacheStore:cacheStore.set('/setting/user', outlet)
8. 保存到 locationStore:locationStore.set('/setting/user', location)
9. 添加到 accessOrder:accessOrder.push('/setting/user')
10. 执行 LRU 清理(如果超过 8 个)
11. 触发重新渲染,显示页面
12. 页面组件使用 useCacheableEffect 加载数据
13. 数据加载完成,标记为已初始化
场景 2:切换标签页
1. 用户点击其他标签页 → 路由变化 → location.pathname = '/setting/role'
2. KeepAliveOutlet 检测到路由变化
3. 生成 cacheKey = '/setting/role'
4. 检查 cacheStore → 已有缓存
5. 更新访问顺序:moveToRecent('/setting/role')
6. 触发重新渲染
7. 渲染逻辑:
- 显示 '/setting/role'(display: 'block')
- 隐藏 '/setting/user'(display: 'none')
8. 页面组件不会重新挂载,useCacheableEffect 不会执行
9. 直接显示缓存的数据,无需重新请求接口
场景 3:关闭标签页
1. 用户点击关闭按钮 → closeTab('/setting/user')
2. useTabsManager 更新 tabs 状态(移除该标签页)
3. 发送消息:globalMessageUtils.keepAlive('drop', { key: '/setting/user', remove: false })
4. useGlobalMessage 处理消息 → publish('keep:alive:drop', { key: '/setting/user', remove: false })
5. KeepAliveOutlet 订阅到消息 → onDrop({ key: '/setting/user', remove: false })
6. 检查 shouldRemove = false
7. 🌟 不清除缓存,保留页面状态(这样重新打开时可以快速恢复)
8. 触发重新渲染
注意:关闭标签页时,缓存会被保留,这样重新打开时可以快速恢复页面状态。
场景 4:关闭所有标签页
1. 用户点击"关闭所有" → closeAllTabs()
2. 发送全局清除消息:globalMessageUtils.keepAlive('clear')
3. useGlobalMessage 处理消息 → publish('keep:alive:clear', {})
4. KeepAliveOutlet 订阅到消息 → onClear()
5. 执行清除操作:
- cacheStore.clear()
- locationStore.clear()
- accessOrder.splice(0, accessOrder.length)
- window.clearAllInitialized()
6. 重置标签页状态:setTabs(DEFAULT_PINNED_TABS)
7. 导航到首页:navigate('/')
场景 5:刷新标签页
1. 用户右键点击标签页 → 选择"刷新" → refreshTab('/setting/user')
2. 发送删除缓存消息:globalMessageUtils.keepAlive('drop', { key: '/setting/user', remove: true })
3. KeepAliveOutlet 检查 shouldRemove = true
4. 执行 removeCache('/setting/user')
- cacheStore.delete('/setting/user')
- accessOrder 中删除 '/setting/user'
5. 清除组件初始化状态:window.__clearComponentInit('/setting/user')
6. 触发重新渲染
7. 由于缓存已删除,会重新渲染 outlet
8. 页面组件重新挂载,useCacheableEffect 重新执行
9. 重新加载数据
场景 6:关闭其他标签页
1. 用户右键点击标签页 → 选择"关闭其他" → closeOtherTabs('/setting/user')
2. 找出其他标签页的 key:['/setting/role', '/setting/menu']
3. 清除其他标签页的缓存:globalMessageUtils.keepAlive('drop', { key: '/setting/role' })
4. 如果保留的标签页不是当前激活的:
- 清除初始化状态:window.__clearComponentInit('/setting/user')
- 这样会重新加载数据,确保页面状态正确
5. 更新 tabs 状态,只保留目标标签页
6. 导航到目标标签页
7. 目标标签页重新加载数据(因为初始化状态被清除)
场景 7:数据恢复机制
1. 用户从白名单页面(如 /setting/cache)切换回缓存页面(如 /setting/user)
2. KeepAliveOutlet 恢复缓存的组件
3. 页面组件检测到使用缓存:window.__checkCache('/setting/user') === true
4. 检查数据状态:
- 如果数据为空且 total === 0,可能是状态丢失
- 延迟 100ms 后重新检查
5. 如果数据仍然为空,自动重新加载数据
6. 如果数据存在,重置 loading 状态
数据恢复机制的作用:
- 解决从白名单页面切换回缓存页面时,可能出现的数据丢失问题
- 自动检测并恢复数据,确保页面正常显示
LRU 缓存策略
算法原理
LRU(Least Recently Used):最近最少使用算法
核心思想:当缓存空间不足时,删除最久未使用的缓存。
实现细节
1. 访问顺序数组
const accessOrder = [] // 存储访问顺序,数组第一个是最久未使用的
2. 访问页面时
// 将页面移到数组末尾(标记为最近使用)
const moveToRecent = (key) => {
const index = accessOrder.indexOf(key)
if (index >= 0) {
accessOrder.splice(index, 1) // 从原位置删除
}
accessOrder.push(key) // 添加到末尾
}
3. 缓存满时清理
const evictLRU = (excludeKey) => {
while (cacheStore.size >= CACHE_LIMIT) { // 默认 8 个
// 找到第一个不是 excludeKey 的 key(最久未使用的)
const keyToRemove = accessOrder.find(k => k !== excludeKey)
if (keyToRemove) {
removeCache(keyToRemove) // 删除缓存
} else {
break
}
}
}
示例演示
假设 CACHE_LIMIT = 3(为了演示方便,实际是 8):
初始状态:
cacheStore: {}
accessOrder: []
访问 /page1:
cacheStore: { '/page1': <Component1> }
accessOrder: ['/page1']
访问 /page2:
cacheStore: { '/page1': <Component1>, '/page2': <Component2> }
accessOrder: ['/page1', '/page2']
访问 /page3:
cacheStore: { '/page1': <Component1>, '/page2': <Component2>, '/page3': <Component3> }
accessOrder: ['/page1', '/page2', '/page3']
访问 /page4(缓存已满):
1. 添加 /page4
2. 执行 evictLRU('/page4')
3. 删除 accessOrder[0] = '/page1'(最久未使用)
cacheStore: { '/page2': <Component2>, '/page3': <Component3>, '/page4': <Component4> }
accessOrder: ['/page2', '/page3', '/page4']
再次访问 /page2:
1. moveToRecent('/page2') → 移到末尾
cacheStore: { '/page2': <Component2>, '/page3': <Component3>, '/page4': <Component4> }
accessOrder: ['/page3', '/page4', '/page2']
访问 /page5(缓存已满):
1. 添加 /page5
2. 执行 evictLRU('/page5')
3. 删除 accessOrder[0] = '/page3'(最久未使用)
cacheStore: { '/page4': <Component4>, '/page2': <Component2>, '/page5': <Component5> }
accessOrder: ['/page4', '/page2', '/page5']
为什么使用 LRU?
- 符合用户习惯:用户经常访问的页面会保留在缓存中
- 自动管理:无需手动清理,系统自动管理缓存大小
- 性能优化:最常用的页面始终在缓存中,切换速度快
使用指南
1. 在页面组件中使用 useCacheableEffect
非白名单页面(需要缓存):
import { useCacheableEffect } from '@/hooks/useCacheableEffect'
import { useLocation } from 'react-router-dom'
import { useRef } from 'react'
const UserManagement = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const hasTriedRestoreRef = useRef(false)
const prevCacheKeyRef = useRef('')
const location = useLocation()
// ✅ 首次加载数据 - 使用 useCacheableEffect,必须添加 cacheable: true
useCacheableEffect(() => {
getList(current, pageSize)
getDepartmentList()
}, [], { cacheable: true }) // 空依赖数组,确保只在首次挂载时执行
// ✅ 数据恢复机制:当检测到缓存但数据为空时,自动重新加载
useEffect(() => {
const cacheKey = location.pathname + location.search
const isUsingCache = window.__checkCache && window.__checkCache(cacheKey)
if (prevCacheKeyRef.current !== cacheKey) {
hasTriedRestoreRef.current = false
prevCacheKeyRef.current = cacheKey
}
if (loading && isUsingCache && !hasTriedRestoreRef.current) {
const timer = setTimeout(() => {
if (data.length === 0 && total === 0) {
console.log('[User] 检测到缓存但数据为空,重新加载数据')
hasTriedRestoreRef.current = true
getList(current, pageSize)
} else {
setLoading(false)
hasTriedRestoreRef.current = true
}
}, 100)
return () => clearTimeout(timer)
}
if (loading && data.length > 0) {
setLoading(false)
}
}, [loading, data.length, total, location.pathname, location.search])
// ✅ 分页变化时单独处理(使用普通 useEffect)
useEffect(() => {
getList(current, pageSize)
}, [current, pageSize])
// ❌ 不要这样做(会导致每次切换标签页都重新加载)
useEffect(() => {
getList()
}, [])
}
白名单页面(不需要缓存):
// 白名单页面使用普通 useEffect,不使用 useCacheableEffect
const LoginLog = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
// ✅ 白名单页面,使用普通 useEffect
useEffect(() => {
getList(current, pageSize)
}, [])
// ...
}
2. 添加页面到白名单
如果某个页面不需要缓存,添加到白名单:
// src/components/KeepAlive/index.jsx
const CACHE_WHITELIST = [
'/',
'/dashboard',
'/your-new-page' // 添加新页面
]
3. 手动清除缓存
import { globalMessageUtils } from '@/hooks/useGlobalMessage'
// 清除单个页面缓存
globalMessageUtils.keepAlive('drop', { key: '/setting/user' })
// 清除所有缓存
globalMessageUtils.keepAlive('clear')
4. 调整缓存数量限制
// src/components/KeepAlive/index.jsx
const CACHE_LIMIT = 8 // 修改为你需要的数量
常见问题
Q1: 为什么切换标签页后,页面数据没有更新?
A: 这是因为页面被缓存了,组件不会重新挂载。如果需要实时数据,应该:
- 将页面添加到白名单(不缓存)
- 使用刷新功能(右键菜单 → 刷新)
- 在数据变化时手动刷新(例如:保存成功后刷新列表)
Q2: 为什么有些页面切换时会重新加载?
A: 可能的原因:
- 页面在白名单中:这些页面每次访问都会重新加载
- 缓存已满:LRU 算法删除了该页面的缓存
- 页面刷新:浏览器刷新会清空所有缓存
Q3: 如何调试缓存问题?
A: 在开发环境下,KeepAlive 组件会输出调试日志:
// 查看控制台输出
[KeepAlive] 新增缓存: { cacheKey: '/setting/user', cacheSize: 1, ... }
[KeepAlive] 使用缓存: '/setting/user'
[KeepAlive] 清除所有缓存,清除前: { cacheSize: 3, cachedKeys: [...] }
Q4: 为什么 useCacheableEffect 不执行?
A: 检查以下几点:
-
是否设置了
cacheable: true(非白名单页面必须设置) -
组件是否已被标记为已初始化(检查
initializedComponentsSet) - 缓存 key 是否正确(路径 + 查询参数)
-
是否是白名单页面(白名单页面应该使用普通
useEffect,不使用useCacheableEffect)
调试方法:
// 在浏览器控制台查看初始化状态
console.log(window.__clearComponentInit) // 应该是一个函数
console.log(window.__clearAllInit) // 应该是一个函数
// 查看缓存状态
console.log(window.__checkCache('/setting/user')) // 检查是否有缓存
Q5: 如何强制刷新页面数据?
A: 有几种方式:
-
右键菜单 → 刷新:清除缓存并重新加载(
remove: true) - 关闭标签页后重新打开:会重新加载数据(因为初始化状态被清除)
-
在代码中手动清除缓存:
// 清除缓存和初始化状态(强制刷新) globalMessageUtils.keepAlive('drop', { key: location.pathname + location.search, remove: true }) // 只清除初始化状态(保留缓存,但会重新加载数据) if (window.__clearComponentInit) { window.__clearComponentInit(location.pathname + location.search) }
Q8: 为什么切换回缓存页面时数据是空的?
A: 这可能是组件状态丢失导致的。系统已经实现了数据恢复机制:
- 自动检测:当检测到使用缓存但数据为空时,会自动重新加载数据
- 延迟检查:延迟 100ms 检查,确保组件状态已恢复
- 避免重复:使用 ref 跟踪,避免重复加载
如果仍然出现问题,检查:
- 数据恢复机制的
useEffect是否正确实现 -
hasTriedRestoreRef和prevCacheKeyRef是否正确设置 - 数据判断条件是否正确(
data.length === 0 && total === 0)
Q6: 缓存会占用多少内存?
A: 缓存的是 React 组件实例,内存占用取决于:
- 组件复杂度:组件越复杂,占用内存越多
- 数据量:页面数据越多,占用内存越多
- 缓存数量:默认最多 8 个页面
如果内存紧张,可以:
- 减少
CACHE_LIMIT(默认 8) - 将不需要缓存的页面添加到白名单
Q7: 页面刷新后缓存会丢失吗?
A: 是的。缓存存储在内存中(Map 对象),页面刷新后会清空。这是正常行为,因为:
- 页面刷新意味着用户想要重新加载应用
- 缓存数据可能已过期,需要重新获取
- 避免内存泄漏
总结
核心要点
-
缓存存储:使用
Map存储页面组件,accessOrder数组记录访问顺序 - LRU 算法:自动删除最久未使用的缓存,保持缓存数量在限制内
-
白名单机制:某些页面不缓存,每次访问都重新加载(使用普通
useEffect) - 消息系统:通过发布-订阅模式实现组件间通信
-
useCacheableEffect:确保页面数据只加载一次(非白名单页面必须使用,并添加
cacheable: true) - 数据恢复机制:自动检测并恢复丢失的数据,确保页面正常显示
- 智能缓存管理:关闭标签页时保留缓存,刷新时才清除缓存
最佳实践
- ✅ 非白名单页面使用 useCacheableEffect 加载初始数据,并添加
cacheable: true - ✅ 白名单页面使用普通 useEffect,不使用
useCacheableEffect - ✅ 使用普通 useEffect 处理依赖变化(如分页、搜索等)
- ✅ 实现数据恢复机制,确保从白名单页面切换回缓存页面时数据正常
- ✅ 将实时数据页面添加到白名单(如日志、看板等)
- ✅ 合理设置缓存数量限制(默认 8 个)
- ✅ 关闭标签页时保留缓存,刷新时才清除缓存
- ❌ 不要在 useCacheableEffect 中处理依赖变化(应该使用普通
useEffect) - ❌ 不要在白名单页面使用 useCacheableEffect(应该使用普通
useEffect) - ❌ 不要在 useCacheableEffect 的依赖数组中包含函数引用(应该使用空数组
[])
相关文件
-
src/components/KeepAlive/index.jsx- 缓存核心组件 -
src/hooks/useCacheableEffect.js- 可缓存的 Effect Hook -
src/hooks/useTabsManager.js- 标签页管理 -
src/hooks/useGlobalMessage.js- 全局消息系统 -
src/components/TabsContainer/index.jsx- 标签页容器 -
src/layouts/BasicLayout.jsx- 基础布局
总结
不知道这次尝试是否值得,如果不是沉没成本太大,我可能已经中断尝试了,还好有了成果,也可能我测试不够全面,导致有遗留Bug,欢迎告知我,也欢迎后来者继续前进,技术实现的代码总在一步步往前走不是嘛
或许前端终将被AI替代,但路在脚下,你我共勉
好了,我也要去学习研究这个缓存了,看到这里,如果对你有帮助,欢迎git仓库给个⭐️标,谢谢了🙏
沪深两市成交额突破1.5万亿
h5中弹框出现后禁止页面滚动
项目场景:
在写带有遮罩层的弹窗时,弹窗出现时,弹框后面的页面依然会保持滚动状态,其实这种情况并么什么影响,但是有很多时候想禁止滚动。无论在移动端还是PC端都会遇到这种情况。
在写带有遮罩层的弹窗时,弹窗出现时,页面会保持滚动状态,不符合我们的预期
看了些解决方案,大都是改变body的overflow,但是由于滚动条出现和消失,页面也会出现跳动
思路分析:
查看了很多方案,大多都是采用当弹框出现时,设置body的overflow为hidden,但是由于滚动条的出现和消失,会带动页面跟着跳动,这是不愿看到的结果。
深追下去,我们会发现,默认样式下,页面滚动条的父元素是html,而fixed的父元素是body。
第一种解决方法:在线运行
html {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
width: 100%;
height: 100%;
overflow: auto;
}
完整代码:
<template>
<div class="no-scroll">
<div class="bg-container">
<img
src="https://img-blog.csdnimg.cn/5a87670618fe4cc59d938f77d41cb816.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/2de8a9c47e054f7ba7bd959ea5041130.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/1f4a4d8d488c46f8acad53892fed08e6.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/7b6c25d2f32645f986a26648ef0b0001.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/fbc54f7c6e2a41889d3221e1d3223127.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/d6316c5661344816bbd664a1510f9978.jpeg"
alt=""
/>
</div>
<el-button class="open-btn" type="primary" round @click="open">
打开弹框
</el-button>
<div class="mask-container" v-show="showMask">
<div class="container">
<el-button type="primary" round @click="close"> 关闭弹框 </el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "NoScroll",
data() {
return {
showMask: false,
};
},
methods: {
open() {
this.showMask = true;
},
close() {
this.showMask = false;
},
},
};
</script>
<style>
* {
margin: 0;
padding: 0;
}
html {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
width: 100%;
height: 100%;
overflow: auto;
}
img {
width: 100%;
}
.open-btn {
position: fixed;
right: 100px;
bottom: 100px;
}
.mask-container {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
}
.container {
display: flex;
align-items: center;
justify-content: center;
width: 300px;
height: 200px;
background: #fff;
border-radius: 10px;
}
</style>
如果整个架构都已经固定,担心改html、body会影响原始页面,还有另一种方案。只需要使后面滚动的容器添加一个高度就行,不让body出现滚动,通常设置为屏幕高度。
第二种解决方法:在线运行
.bg-container {
width: 100%;
height: 100vh;
overflow: auto;
}
完整代码:
<template>
<div class="no-scroll">
<div class="bg-container">
<img
src="https://img-blog.csdnimg.cn/5a87670618fe4cc59d938f77d41cb816.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/2de8a9c47e054f7ba7bd959ea5041130.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/1f4a4d8d488c46f8acad53892fed08e6.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/7b6c25d2f32645f986a26648ef0b0001.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/fbc54f7c6e2a41889d3221e1d3223127.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/d6316c5661344816bbd664a1510f9978.jpeg"
alt=""
/>
</div>
<el-button class="open-btn" type="primary" round @click="open">
打开弹框
</el-button>
<div class="mask-container" v-show="showMask">
<div class="container">
<el-button type="primary" round @click="close"> 关闭弹框 </el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "NoScroll",
data() {
return {
showMask: false,
};
},
methods: {
open() {
this.showMask = true;
},
close() {
this.showMask = false;
},
},
};
</script>
<style>
* {
margin: 0;
padding: 0;
}
.bg-container {
width: 100%;
height: 100vh;
overflow: auto;
}
img {
width: 100%;
}
.open-btn {
position: fixed;
right: 100px;
bottom: 100px;
}
.mask-container {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
}
.container {
display: flex;
align-items: center;
justify-content: center;
width: 300px;
height: 200px;
background: #fff;
border-radius: 10px;
}
</style>
js、node.js获取指定文件下的内容
最近做一个语言切换功能,所有的的语言翻译都在同一个文件中,导致文件过大,难以维护于是想到了对文件按模块进行拆分,绞尽脑汁,查了各种资料,果然功夫不负有心人,现在方法总结出来,希望能帮助更多有需要的人。
一、js获取指定文件下的内容
首先jieshao
require.context
可以给这个函数传入三个参数:一个要搜索的目录,一个标记表示是否还搜索其子目录, 以及一个匹配文件的正则表达式。
Webpack 会在构建中解析代码中的
require.context()。用法
require.context(directory, useSubdirectories = false, regExp = /^.//)
示例
读取template文件夹下面的所有js文件,并获取值;
项目环境:Vue2项目
目录结构
template
dom.js
tem.js
getmodules.js
// dom.js
module.exports = {
dom: "我是dom"
};
// tem.js
module.exports = {
tem: "我是tem"
};
const modulesFilesen = require.context("./template", true, /.js$/);
const modulesen = modulesFilesen.keys().reduce((modules, modulePath) => {
const moduleName = modulePath.replace(/^./(.*).js/, "$1");
const value = modulesFilesen(modulePath);
modules[moduleName] = value;
return modules;
}, {});
// 结果
modulesen = {
dom: {
dom: "我是dom",
},
tem: {
tem: "我是tem",
},
};
二、node.js获取指定文件下的内容
node.js需要借助模块,主要处理文件的读写、复制、s删除、重命名等操作。
建议使用同步读取文件模块fs.readFileSync(cur, "utf8") ,防止出现文件读取不到的情况。
reduce()的用法可以参考另一篇博文。
// 第一步:引入 fs 文件系统模块
let fs = require("fs");
const dirName = "./template"; // 读取目标文件夹名称
const reg = /(?<=[.])[a-z]+/; // 文件后缀匹配规则
// 第二步:读文件夹,获取文件名列表
// 同步 readdir().返回文件数组列表
let fileList = fs.readdirSync(dirName);
// 第三步:过滤出想要的文件类型
let result = fileList.reduce((pev, cur) => {
const curPicType = cur.match(reg)[0];
if (["js"].includes(curPicType)) {
// 同步读取
let res = fs.readFileSync(cur, "utf8");
pev[cur] = res;
}
return pev;
}, {});
// result = {
// dom: {
// dom: "我是dom",
// },
// tem: {
// tem: "我是tem",
// },
// };
苹果造芯五年,Mac 怎么成了另一种电脑?|明日后视镜
![]()
20 年前,对于 Mac 团队「调教」别人的芯片这件事,Tim Millet 记忆犹新。
那时候 Mac 还活在英特尔的路线图上:macOS 团队常常像在黑箱外工作,性能优化做了一轮又一轮,却不知道产品最终会用哪颗 GPU,直到发布前的最后几个月才揭晓答案。
图形团队不得不写一套「谁都能用」但很难真正压榨硬件潜力的通用方案。
![]()
芯片在前,系统在后,Mac 像住进别人搭好的房子,只能在墙上挂挂画、挪挪家具。
今天的 Tim 坐在同一个园区,身份却完全反过来了——作为 Apple 平台架构副总裁,他负责造那颗芯片本身。
Apple Silicon 走过的这五年,就是 Mac 从「住别人家」到「自己盖房子」的故事:不用再等供应商定好菜单,而是从晶体管开始,为 Mac 这台机器量体裁衣。
现在图形软件团队可以和 Apple Silicon 团队提前几年一起工作。
在接受爱范儿的独家专访时,Tim 说,「芯片还在纸面上的时候,未来要跑的图形、游戏、内容就已经摆在桌上一起讨论了。」
五年来,从无风扇的 MacBook Air、到插电与电池性能完全一致的 MacBook Pro,再到重新被赋予存在感的 Mac mini,背后不仅仅来自苹果一贯的「软硬协同」,更建立在同一个前提上:
Mac 终于用上了为它而生的芯片。
Apple Silicon 如何重新发明电脑?
电脑的形态,已经很多年没有真正改变了——一块屏幕、一块键盘、一台主机,各司其职,彼此之间的关系像被时代写死的答案。
Apple Silicon 上线这五年,某种程度上改变了这个法则。
在过去,轻薄本几乎等同于妥协。当年那台搭载酷睿 M 的 12 英寸 MacBook 就是典型:极致轻薄、外观惊艳,但一遇到高负载就缴械投降。
![]()
换上 Apple 芯片之后,无风扇 MacBook Air 的出现,第一次打破了这个等式。它依旧轻薄安静,却可以稳定应对 4K 剪辑、多轨音频、批量 RAW 修图这类过去需要「有风扇的电脑」才能安心交付的任务。
![]()
2024 年发布的 M4 Mac mini 则用另一种方式重写了桌面电脑的定义。它看起来像个电视盒子,骨子里却更像是一个缩小版工作站,挂上显示器、外接阵列和采集卡,就能撑起一整条内容生产线。
而在 MacBook Pro 这条产品线上,Apple Silicon 解决的是另一个长期痛点:过去的高性能笔记本像被电源线拴着,插电是「战斗模式」,拔电就得省着用。现在,插电和用电池时的性能曲线几乎重合,真正的移动生产力才得以实现。
Mac 与 iPad 产品营销副总裁 Tom Boger 认为,这便是 only Apple Silicon can do 的事情。如果没有 Apple 芯片,以前很难出现的产品形态得以成立。
我们不会先做出一块芯片,再让产品团队去想这块芯片能拿来干嘛。我们是为了产品,去设计这块芯片。
苹果芯片更大的意义,是架构上的统一,Mac 和 iPhone、iPad 终于共享了一套底层体系。
![]()
同一代 Apple Silicon 横跨手机、平板和电脑,系统团队在设计新功能时,可以从一开始就想好三块屏幕上的呈现方式,并且用同一套底层能力支撑。用户看到的,则是设备之间像魔法一样的联动。
苹果在设计 M 芯片时,首要考虑的是什么?
芯片设计,向来被称为「取舍的艺术」——晶体管预算有限,算力、能耗、特性与成本之间,永远在博弈。
但在 Tim 看来,Apple Silicon 的第一道取舍,甚至不在某一颗芯片本身,而在于这套架构能不能撑起整个 Mac 家族——scalability(可扩展性):
Mac 的产品线跨度非常大:入门机、轻薄本、mini、iMac,到另一端的 Pro 笔电和 Studio 级台式机,全都得涵盖。在这种前提下,我们最重要的决定,其实是让架构本身足够可扩展。
换句话说,要先把「一棵树」长出来,再去决定每一根枝、每一片叶子具体长到哪里。
有了可扩展的架构之后,每一代新增的晶体管,就有了更明确的去向:
往上延伸,可以堆给 Mac Studio、MacBook Pro 这类高端机型——更多 GPU 核心、更高内存带宽、更大统一内存,去抬高专业工作流的上限;
往下扩展,则可以用在能效、集成度和图形能力的平衡上,让 MacBook Air、入门 Mac mini 在它们各自的价位段里「看起来轻巧,用起来够狠」。
Tim 也提到,除了纯粹的算力分配,每一档产品还会被预留「个性位」:需要更强显示能力的机型,就会把资源倾斜给显示控制器和外接接口上;强调摄像头和音频体验的机型,就会为图像信号处理、媒体引擎单独开一笔支出。
「统一内存」的前瞻性
![]()
如果说可扩展架构是 Apple Silicon 的骨架,那统一内存就是流动其中的血液。
当年构思这些芯片的时候,我完全没想过,会有一天在自己面前这台机器上本地跑几十亿参数的模型。
但把时间拨回 M1 发布的 2020 年,会发现很多伏笔早已埋下:神经引擎在芯片中占据重要一席,统一内存架构也已就位。那时 AI 还远没有今天这么火热,但团队脑海里已经有了未来几年可能出现的工作负载。
在 Apple Silicon 之前,Mac 的内存世界是割裂的:
Mac 有一套容量很大的内存,但只有 CPU 能直接用;还有一套带宽很高的显存,归 GPU 使用,可容量就小得多。
Tim 说,「统一内存把高带宽和大容量绑在一起,让 GPU 第一次拥有了这种组合。之后在 AI 方向能走多远,很大程度上就是建立在这一步之上。」
统一内存的本质,是把「够大」和「够快」熔铸成一池活水,让 CPU、GPU、神经引擎在同一片水域里协作。
刚推出时,它更多被感知在视频剪辑、3D 渲染、跨设备协同这些场景里。而到了今天,当大家开始认真在本地跑模型,这条「水路」的真正意义才完全显现出来。
模型参数可以直接驻留在统一内存中,省去了数据在不同存储区域间的往返搬运。Mac Studio,尤其是 Ultra 配置,俨然成了一台桌面 AI 工作站。
Tom 用了一个经典的冰球比喻:
我们要滑向冰球将要去的地方,而不是它现在所在的位置。
在苹果的语境里,「过头」更像是一张提前写好的支票,只是兑现的场合和时间,要等用户和开发者一起发现。
如今,这张支票正在被花出去:在 Mac 上本地运行更大的生成式模型;在 Mac Studio 上处理超高分辨率的视频、批量生成图片,用 AI 帮忙写代码、审代码;在配备 Ultra 芯片的 Mac 上,将 AI 推理深度嵌入创作流程,让机器从单纯的工具进化为协作伙伴。
到了 M5,GPU 的身份变了
![]()
如果说 M1 是为 AI 打地基,M5 则是第一次认真重构了 GPU 在整个系统中的角色。
从这一代开始,苹果在 GPU 的每一个核心里,都植入了独立的神经加速单元。打破了传统 CPU/GPU/ 神经引擎分离的 AI 计算模式,犹如为图形单元装了专属 AI 引擎。
Omdia 研究经理 Kieren Jessop 认为,这是一种非常聪明的策略,既有专门的 Neural Engine,还在每个 GPU 核心还加入了神经加速器。这意味着企业和专业人士可以在本地运行大模型——数据不出设备,不担心隐私、云端成本和延迟问题。
Tim 认为,当前端侧 AI 存在三个瓶颈:算力、内存容量和内存带宽。而 Apple Silicon,几乎就是围绕这三点设计出来的。
在 M5 之前,像 Metal FX 这样的 AI 超分方案,是 GPU 和神经引擎合作完成的:游戏以较低分辨率渲染,再交给 AI 放大成高质量画面,于是帧率和画质都能讨到便宜。
现在,很多这类计算可以直接在 GPU 内部走完流程,数据不用来回折返,神经引擎则可以空出手来,去处理其他并行任务——比如你一边玩游戏,一边开着 Center Stage,摄像头用机器学习实时追着你动。
这一切的底层支撑,依然是那池统一内存:高带宽、大容量,加上 CPU、GPU、神经引擎和 GPU 内部 AI 单元的共同访问通路,让数据可以「就地处理」,避免了芯片间无谓的搬运损耗。
把视角再拉远一点:在 M5 这样的架构之上,Mac Studio、MacBook Pro Ultra 等高端型号,就自然而然变成了端侧 AI 的「重型设备」。模型实验、开发调试、推理部署,许多过去只能在云端或服务器上完成的流程,第一次有了落在用户桌面的可能。
AIGC 时代的价值选择
对话的最后,我们把问题抛向了一个更形而上的层面。
AIGC 带来的争议日益尖锐:一边是效率和规模的指数级增长,另一边是对人类创作尊严的忧虑。作为一家在骨子里看重审美和表达的公司,苹果会站在天平的哪一端?
Tom 的第一反应,是苹果那块标志性的路牌——科技和人文的十字路口。
「我们的角色,是尽力发明最强大的技术,然后交到人手里,让他们去做原本做不到的事。」在他的叙述中,Mac Studio、Ultra 级别的 Mac 当然会是 AI 工作流的理想载体,但故事不会在此终结——这些设备的使命始终围绕一个核心:帮助人把脑海中模糊的构想,转化为具体的作品。
但回顾科技史,会发现不止一次出现这样的时刻。现在确实又到了这样一个节点——大家觉得机器要来取代人了。
他接着说:
但每一次,人类的创造力最后都会把这些新技术收编进来,变成扩展自己能力的工具。它们不会把人挤走,反而会放大人的创造力。
在他看来,Mac 的角色其实没有变过:Mac 还会是它一直以来的样子——创作者离不开的那一件工具。苹果关心的是,在这个新工具箱里,人能否保持主动,而不是在算力的洪流里失去话语权。
![]()
▲ 音乐人苏诗丁借助 Mac Studio 搭建了一个极致纯净的家庭录音室
五年 Apple Silicon,把 Mac 从别人路线图上的一行变成了自己地图上的完整版图:
统一内存,让各个计算单元不再各自为政;可扩展的架构,让一整条产品线共享同一套思路;M1 埋下的 AI 伏笔,在 M5 身上得到更激进的演化;Mac Ultra、Mac Studio 则在 AI 时代,扮演起桌面端那台实力过剩的创作与推理机器。
但沿着技术曲线一路往下看,会发现苹果始终在护另一条看不见的线:算力提升、带宽翻倍、架构整合,最后都要落到一个很朴素的问题上——
用这台机器的人,能不能做得更多,能不能更心无旁骛。在这个前提下,芯片可以野心勃勃,语言可以安静克制,计算可以变得越来越像空气……
但创作这件事,还是应该牢牢握在人的手里。
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。
光源资本创始人郑烜乐:从硅基智能到硅碳融合,拥抱AI全要素的新时代|WISE 2025 商业之王
光源资本创始人郑烜乐:从硅基智能到硅碳融合,拥抱AI全要素的新时代|WISE 2025 商业之王
2025年的商业世界正站在新旧转换的十字路口。在商业叙事重构、科技浪潮席卷的当下,WISE2025商业之王大会以“风景这边独好”为基调,试图在不确定中锚定中国商业的确定性的未来。我们在此记录这场思想盛宴的开篇,捕捉那些在变局中依然坚定前行的声音。
11月27-28日,被誉为“年度科技与商业风向标”的36氪WISE2025商业之王大会,在北京798艺术区传导空间落地。
今年的WISE不再是一场传统意义上的行业峰会,而是一次以“科技爽文短剧”为载体的沉浸式体验。从AI重塑硬件边界,到具身智能叩响真实世界的大门;从出海浪潮中的品牌全球化,到传统行业装上“赛博义肢”——我们还原的不仅是趋势,更是在捕捉在无数次商业实践中磨炼出的真知。
我们将在接下来的内容中,逐帧拆解这些“爽剧”背后的真实逻辑,一起看尽2025年商业的“风景独好”。
![]()
光源资本创始人&CEO郑烜乐
以下为光源资本创始人&CEO郑烜乐的演讲,经36氪整理——
大家好,我是光源资本创始人郑烜乐。很高兴再次来到36氪WISE大会,在这个熟悉的舞台上与老朋友相聚,探讨新话题。今年我想分享的主题是“从硅基智能到硅碳融合,拥抱AI全要素的新时代”。
作为一级市场的投行、投资及孵化平台,我们密切关注以AI为主导的一级市场投资热度变化。今年市场热度显著提升,融资金额同比去年增长了54.3%,融资事件数增长更为明显。这并非昙花一现的短期现象,而是AI产业浪潮从早期探索阶段稳步迈入中早期成长期的重要标志。
从赛道分布来看,整体格局保持持平,但具身智能领域的投资数量大幅上升;同时,AI与工业、产业场景的深度融合,To B 与 To C 的各类应用落地,以及AI与生物医药的结合,共同构成了今年的热门方向。从投资阶段来看,行业仍处于早期发展阶段,A轮、天使轮及种子轮投资占比最高,成长期项目虽有所增加,但尚未形成规模性拐点。我们同样密切关注美国 AI 投资的动态,希望从中美两个人工智能大国的具体实践中获得启发。目前来看,即便剔除 OpenAI 单笔数百亿美元的融资,中美一级市场在资金总量上仍存在数量级差距。这反映出中国 AI 领域的资本供给依然不足,但也意味着优质企业的估值普遍被低估,未来具备显著的上行空间。
在投资行业结构上,中美分野日益明显:中国有64.1%的资金投向具身智能,这是当前泛AI领域最热的赛道,并涌现出大量独角兽,另有31.3%资金涌向泛应用层;而美国有43%的资金投向模型层,28%投向应用层,且AI基础设施(Infra)投资极为活跃。在过去三年里,美国的AI应用层已诞生超过50家独角兽,而中国独角兽目前主要集中在模型层与具身智能领域,独角兽企业尚不到10家。这一对比清晰表明,中国 AI 投资,尤其是在应用层的深度孵化与价值释放上,仍有巨大的成长潜力。
这一差异背后,是两国优势的迥异。中国的核心优势在于充沛的硬软件工程师红利、全球领先的产业链完整性以及完备的工业场景。这使得中国在AI+产业、AI+工业、硬件及具身智能等落地执行要素方面具备领先空间。美国则在模型和基础设施领域持续领先,未来两三年内这一格局仍难改变。
从“十四五”到“十五五”,国家提出将AI定位为关键生产要素的整体方针。与移动互联网作为生产关系连接要素不同,AI首次将数据、算力、能源、场景和供应链连接形成链式联动,最终通过“数据积累 - 技术成熟 - 场景改造 - 新数据产生” 的闭环,构建起AI要素链循环飞轮,推动产业价值跃迁。可以说,AI真正将这些要素孤岛连接成新要素,开启了“全要素时代”,进入各行各业推动产业升级。
展望未来,AI全要素时代将走向“硅碳融合”。这一过程伴随着场景或范式的PMF(产品市场匹配)演进:PMF1.0是AI重构信息,语言大模型以文本理解世界;PMF2.0是AI重构表达,包括文生图、文生视频及世界模型的探索;PMF3.0是AI重构流程和组织,通过Agent和智能体员工形成新一代企业组织形态并直接交付生产力成果;PMF4.0是AI渗透物理世界,通过硬件、工业及具身智能走进生产生活场景;PMF5.0则是AI基于AI for Science,在能源、算力、材料、生物医药等方面渗透要素链。
这一演进最终将打造硅碳融合的AI全要素时代,如同工业革命一样,AI将进入各行各业、各个场景、各个生活空间,提供生产、消费、情绪、组织及社会的全方位价值。未来30年,人类将迈入硅碳融合智能体文明,从个体增强、人机共生,到产业链AI化升级、城市级硅碳融合,最终构建人类与AI共生的赋能社会。
回到当下,创业与投资必须正视AI时代面临的四大结构性矛盾:首先是技术迭代快与产业落地慢的矛盾,模型泛化能力虽强,但大量产业尚未找到大规模使用AI或Token的有效场景;其次是算力需求与算力产业链(密度、成本)及能源供给(稳定性、成本)之间的矛盾;再次是平台资源集中与创新分散的矛盾;最后是全球化刚需与地缘政治博弈的矛盾,出海企业面临地缘政治波动。
基于这些矛盾,我们观察到几种创业和投资范式:一是关注大赛道中的边缘侧创新,比如移动互联网时代的字节跳动和快手,从边缘产品切入,逐渐成长为主流;二是利用AI完成手工业向工业的跃迁,例如视频制作、3D生成及AI探矿等领域,从手工作业转变为可规模化工业生产;三是寻找做智能海洋上的“船”而非“礁石”的机会,成为算力成本下降和智能化水平上升的受益者,避免被水位淹没;四是抓住AI产业链要素错配带来的机会,在场景、算力、电力、数据等环节的不匹配中寻找商机,产业链要素将长期处于动态不平衡,要在波动中寻找机会。
分行业来看,AI To B的核心机会在于价值链条的压缩。AI有机会实现从需求到交付的端到端即时响应,甚至直接交付业务结果,替代传统劳务外包或解决方案。同时,AI推动企业组织形态变革,智能体员工参与业务,推动企业扁平化,未来可能出现“一人独角兽”。此外,企业级能力正加速外溢,将大企业能力封装压缩,让大企业能力流向中小企业,带动社会生产力密度的提升。
AI To C领域带来了新一波机会,本质是从工具进化为贴身智能体,从内容连接器变为智能服务工厂。关键竞争维度包括:离人更近的贴身入口、积累大量语境资产(Context)以及场景闭环。在单一或高价值场景实现L3以上“决策-执行-反馈”闭环将产生巨大价值。AI To C的创业机会在于原先供给的AI化带来的成本结构改变和交互方式革命,以及解决移动互联网无法解决的高风险、长周期、强托付问题。
AI+产业通过三个范式将手工业转化为工业:一是将分散、易损耗的人类经验转化为可复制的先验智慧;二是解决传统技术无法解决的规模化复制与个性化矛盾;三是改变产业组织形态,从依赖组织协同转向依赖模型执行。在工业设计、能源、医疗等领域,AI将大幅提高效率。
AI+硬件是今年的投资热点,机会源于交互革命(去屏幕化+实体化)、数据飞轮带来的个性化体验,以及商业模式的升级(从单纯卖硬件转向“硬件+订阅服务”)。而大厂既无法覆盖海量垂直场景,又缺乏硬件基因,叠加中国强大的供应链和人才积累,这为创业公司留下广阔空间。
AI+具身智能虽然热度高,但仍处于早期阶段,数据扩展、模型收敛及本体收敛尚需时日。我们认为该赛道的第一性在于泛化能力(单位泛化能力所需数据成本)和场景规模化落地密度。预计2026年,部分龙头企业将实现突破,同时会涌现一批具有场景禀赋的机器人公司。凭借中国的工程师密度、供应链强度和场景广度,我们有望在全球竞争中引领这一领域。
AI算力方面,生成式AI爆发带来了算力密度提升和成本下行的需求。算力密度提升依赖架构演进、先进封装及专用加速体系(如ASIC);成本下行则关注芯片、液冷及算力调度运营。前沿算力如量子计算(特别是中性原子路线)有望在未来两三年内实现产业化突破,形成“AI×量子”协同算力加速引擎。
AI+能源方面,算力扩张使能源成为新瓶颈与增长点。。清洁能源(如钙钛矿)、核聚变(工程化机遇)以及AI智能能源(虚拟电厂、智能电网)是主要增量方向。AI既受益于能源,也反哺能源系统。
关于创业机会,我认为核心逻辑是“大厂做基建,创业找差异化”。大厂在重资产、高人才密度、高数据壁垒的基础设施层面优势明显;创业公司则应致力于“做垂”(垂直场景供应链)、“做深”(场景闭环与飞轮)以及“做错配”(寻找产业链要素动态错配)。
目前泛AI领域创业公司面临的主要挑战包括:技术周期与资本周期的错配;成长期资金供给不足;商业化压力前置;大厂边界模糊下的生态位选择;以及全球化需求提升背景下的地缘政治不确定性。这导致创业者对资本市场的服务需求发生变化,更加需要穿越周期的产业叙事能力、要素链视角、资本路径规划与调度能力、产业资源导入能力以及战略与治理的陪伴能力。
为此,光源资本基于AI生产力时代打造了全新的产业投行体系,包括财务顾问、3i产业创新孵化器、L2F光源创业者基金、成长期基金、政府落地、兼并收购等工具条线,提供投资、孵化、融资一体化服务。过去三年,我们累计帮助企业完成融资交易额超过1300亿人民币,历经11年发展,在整体市场及人工智能、具身智能、半导体等多个前沿赛道连续三年排名第一;2024年成立的3i产业创新孵化器已参与爱诗科技、银河通用机器人等行业头部企业的孵化;今年我们新发起的L2F光源创业者基金汇聚了多位头部产业LP和成功企业家,旨在以产业资源和创业经验为AI早期创业者精准赋能,做对 AI 创业者最有帮助的超早期投资基金和早期投资人最佳的共同投资伙伴。
我们始终希望将光源打造为连接创新要素与产业发展的桥梁。我们致力于整合自身的资金网络与产业资源网络,共同赋能两类核心伙伴:一方面,助力早期创业者高效实现技术成果的产业化落地与资本化运作;另一方面,为产业集团提供资本支持,以资本为驱动引擎,加速其产业创新升级的进程。
在此,我也想和大家分享我们的使命 —— 我相信,这同样是许多一级市场同行与创业者的共同追求:在当下硅碳深度融合的时代背景下,我们将持续推动创新要素、产业发展与资本力量的同频共振,携手所有伙伴,共同拥抱人工智能全要素赋能的新时代。
我相信,只要我们坚持做“正确的事”——用技术创造真实价值,世界就会因我们的努力而变得更好。
以上就是我的分享,感谢大家!
如何应对铝价上涨?宏鑫科技回应
大唐发电子公司等在重庆投资成立清洁能源公司
node全栈系列(七)-增加验证码登录
这是一个非常实用的功能,能有效防止暴力破解密码。
我们将采用 svg-captcha 库来生成验证码。这是一种不需要安装复杂图形库(如 Python 的 pillow 或 C++ 的 gd)的轻量级方案,生成的 SVG 图片在前端渲染非常清晰且速度快。
实现逻辑如下:
- 生成:前端请求验证码 -> 后端生成一个随机字符 + 一个唯一ID (UUID) -> 后端把 {UUID: 验证码} 存到内存中 -> 把 UUID 和 SVG 图片返给前端。
- 验证:前端登录时,把 账号 + 密码 + 验证码 + UUID 一起发给后端 -> 后端根据 UUID 找内存里的验证码 -> 对比是否一致。
第一步:后端安装依赖
你需要安装两个库:
- svg-captcha: 生成验证码图片。
- uuid: 生成唯一标识符(用来标记这张验证码属于谁)。
npm install svg-captcha uuid
第二步:后端代码实现 (routes/auth.js)
我们需要修改 routes/auth.js,增加获取验证码接口,并修改登录接口。
注意:为了简单起见,我们将验证码存在全局变量 Map 中(内存)。如果是生产环境集群部署,通常存在 Redis 里,但作为学习项目,用内存 Map 足够了。
routes/auth.js
import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import svgCaptcha from 'svg-captcha' // 引入验证码库
import { v4 as uuidv4 } from 'uuid' // 引入UUID
import { pool } from '../db/mysql.js'
import config from '../config.js'
import HttpError from '../utils/HttpError.js'
const router = express.Router()
// --- 全局变量:存储验证码 ---
// Key: uuid, Value: 验证码文字
const captchaStore = new Map()
// 1. 获取图形验证码接口
// GET /auth/captcha
router.get('/captcha', (req, res, next) => {
try {
// 生成验证码
const captcha = svgCaptcha.create({
size: 4, // 4个字符
ignoreChars: '0o1i', // 排除容易混淆的字符
noise: 2, // 干扰线条数量
color: true, // 文字有颜色
background: '#fff' // 背景色
})
// 生成一个唯一标识符
const uuid = uuidv4()
// 存入内存 (转成小写方便比对)
captchaStore.set(uuid, captcha.text.toLowerCase())
// 设置过期时间:5分钟后自动删除,防止内存泄露
setTimeout(() => {
captchaStore.delete(uuid)
}, 5 * 60 * 1000)
// 返回 SVG 图片代码和 UUID
res.json({
code: 200,
message: '获取成功',
data: {
uuid: uuid,
img: captcha.data // 这是 SVG 的 XML 字符串,前端可以直接渲染
}
})
} catch (err) {
next(err)
}
})
// 2. 登录接口 (增加验证码校验)
router.post(
'/login',
[
body('username').notEmpty().withMessage('账号不能为空'),
body('password').notEmpty().withMessage('密码不能为空'),
body('code').notEmpty().withMessage('验证码不能为空'), // 新增校验
body('uuid').notEmpty().withMessage('验证码已失效,请刷新重试') // 新增校验
],
async (req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) throw new HttpError(400, errors.array()[0].msg)
const { username, password, code, uuid } = req.body
// --- 核心修改:校验验证码 ---
const correctCode = captchaStore.get(uuid) // 从内存拿正确的码
if (!correctCode) {
throw new HttpError(400, '验证码已过期,请点击图片刷新')
}
if (correctCode !== code.toLowerCase()) {
throw new HttpError(400, '验证码错误')
}
// 校验通过后,立马删除该验证码(防止重复使用)
captchaStore.delete(uuid)
// ------------------------
// 下面是原有的登录逻辑
const [users] = await pool.query('SELECT * FROM sys_users WHERE username = ?', [username])
if (users.length === 0) throw new HttpError(400, '账号或密码错误')
const user = users[0]
if (user.status === 0) throw new HttpError(403, '账号已被停用')
const isMatch = await bcrypt.compare(password, user.password)
if (!isMatch) throw new HttpError(400, '账号或密码错误')
const payload = { userId: user.id }
const token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRES_IN })
res.json({
code: 200,
message: '登录成功',
data: { token }
})
} catch (err) { next(err) }
}
)
// ... info 接口保持不变 ...
export default router
第三步:前端 API 封装 (api/auth.ts)
import request from '@/utils/request'
// 登录 (现在需要多传 code 和 uuid)
export function login(data: any) {
return request({
url: '/auth/login',
method: 'post',
data
})
}
// 获取验证码
export function getCaptcha() {
return request({
url: '/auth/captcha',
method: 'get'
})
}
// 获取用户信息 (不变)
export function getUserInfo() {
return request({
url: '/auth/info',
method: 'get'
})
}
第四步:前端页面修改 (views/login/login.vue)
我们需要修改 Store 和 页面 UI。
1. 修改 Store (store/user.ts)
不需要修改 Store 的核心逻辑,因为 login action 只是透传参数。只要调用的时候传进去 {username, password, code, uuid} 即可。
2. 修改页面 UI (views/login/login.vue)
在密码框下面增加验证码输入框和图片。
<template>
<div class="login-container">
<div class="login-box">
<h2 class="title">后台管理系统</h2>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入账号"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
placeholder="请输入密码"
prefix-icon="Lock"
type="password"
show-password
/>
</el-form-item>
<!-- 新增:验证码区域 -->
<el-form-item prop="code">
<div class="flex w-full gap-2">
<el-input
v-model="loginForm.code"
placeholder="验证码"
prefix-icon="Key"
class="flex-1"
@keyup.enter="handleLogin"
/>
<!-- 验证码图片容器 -->
<div
class="captcha-box cursor-pointer"
v-html="captchaSvg"
@click="refreshCaptcha"
title="点击刷新"
></div>
</div>
</el-form-item>
<el-button
type="primary"
class="w-full mt-4"
:loading="loading"
@click="handleLogin"
>
登 陆
</el-button>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { ElMessage } from 'element-plus'
import { getCaptcha } from '@/api/auth' // 引入API
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loginFormRef = ref()
const loading = ref(false)
const captchaSvg = ref('') // 存储 SVG 图片代码
const loginForm = reactive({
username: '',
password: '',
code: '', // 验证码输入值
uuid: '' // 验证码唯一ID
})
const loginRules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
}
// 获取/刷新验证码
const refreshCaptcha = async () => {
try {
const res: any = await getCaptcha()
// res.data 包含 { uuid, img }
loginForm.uuid = res.data.uuid
captchaSvg.value = res.data.img
loginForm.code = '' // 刷新后清空输入框
} catch (error) {
console.error(error)
}
}
const handleLogin = () => {
loginFormRef.value.validate(async (valid: boolean) => {
if (valid) {
loading.value = true
try {
await userStore.login(loginForm)
ElMessage.success('登录成功')
const redirect = route.query.redirect as string
router.push(redirect || '/')
} catch (error) {
// 登录失败(如验证码错误),自动刷新验证码
refreshCaptcha()
} finally {
loading.value = false
}
}
})
}
// 初始化时获取验证码
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: white;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.title {
text-align: center;
margin-bottom: 30px;
font-size: 24px;
font-weight: bold;
color: #333;
}
/* 验证码图片样式 */
.captcha-box {
width: 120px;
height: 40px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
}
/* 深度选择器:控制 SVG 尺寸适应容器 */
:deep(.captcha-box svg) {
width: 100%;
height: 100%;
}
</style>
总结变化点
-
后端:
- 引入 svg-captcha 库。
- 新增 /captcha 接口,生成 SVG 并在内存(captchaStore Map)中记录 uuid -> code 的映射。
- 修改 /login 接口,在验证账号前,先拿 uuid 去内存查,对比前端传来的 code。
-
前端:
- UI 上增加了输入框和 v-html 来显示 SVG。
- 进入页面或点击图片时,调用 /captcha 接口,保存 uuid,渲染 img。
- 登录时把 code 和 uuid 一起发给后端。
这样你就拥有了一个安全、轻量且体验流畅的图形验证码功能了!
Cdiscount API 调用合规指南:频率限制、数据用途边界与封号风险规避
Cdiscount API 调用合规指南:频率限制、数据用途边界与封号风险规避
Cdiscount 作为法国头部电商平台,对 API 调用的合规性管控严格,违规操作(如超限请求、数据滥用)会触发从临时封禁到永久封号的处罚。本文从「频率限制合规、数据用途边界、风险规避实操」三大维度,给出可落地的合规指南,适配开发者 / 企业的 API 使用场景。
一、核心合规前提
所有 API 调用需基于 Cdiscount 官方开发者平台([Cdiscount Developer Portal])的合法授权:
- 完成企业 / 个人账号认证,创建应用并获取
Client ID/Client Secret; - 仅使用平台开放的正式 API 端点(禁止抓包、逆向非公开接口);
- 所有请求携带有效 Access Token(OAuth 2.0 授权),禁止共享 Token。
二、频率限制合规:精准控制请求节奏(核心红线)
Cdiscount 对 API 调用频率的限制是最易触发的违规点,不同 API 的限流规则统一且严格,违规后果随次数递增:
1. 官方限流规则(2025 最新)
| API 类型 | 限流阈值 | 违规梯度处罚 |
|---|---|---|
| 商品 / 订单 / 库存 API | 100 次 / 分钟(≈0.6 秒 / 次) | 1 次超限:429 错误 + 临时封禁 10 分钟3 次超限:封禁 1 小时5 次以上:永久封号 |
| Token 获取 API | 10 次 / 小时 | 超限直接封禁 Token,需人工申诉重置 |
| 类目 / 评论 API | 50 次 / 分钟 | 处罚规则同商品 API |
2. 合规限流实现方案(Python)
方案 1:基础固定延迟(适合小流量)
python
运行
import time
import requests
def safe_api_request(url, payload, headers):
"""添加固定延迟,确保≤100次/分钟"""
time.sleep(0.6) # 100次/分钟 = 0.6秒/次,预留冗余
try:
response = requests.post(url, json=payload, headers=headers, timeout=15)
return response.json()
except Exception as e:
print(f"请求异常:{e}")
return None
方案 2:精准限流(推荐生产环境)
使用ratelimit库实现「请求数 / 时间窗口」精准控制,避免人工计算误差:
bash
运行
pip install ratelimit
python
运行
from ratelimit import limits, sleep_and_retry
# 100次/分钟(核心限流规则)
@sleep_and_retry # 触发限流时自动休眠重试
@limits(calls=100, period=60)
def limited_api_request(url, payload, headers):
try:
response = requests.post(url, json=payload, headers=headers, timeout=15)
# 捕获429错误,主动延长休眠
if response.status_code == 429:
time.sleep(60) # 休眠1分钟后重试
return limited_api_request(url, payload, headers)
return response.json()
except Exception as e:
print(f"限流请求异常:{e}")
return None
3. 批量采集限流优化(避免超限)
- 分批请求:单请求最多传入 20 个商品 ID(平台上限),减少总请求数;
- 峰值规避:避开欧洲高峰时段(北京时间 15:00-23:00,对应法国早 9 点 - 晚 5 点),该时段平台服务器负载高,限流判定更严格;
- 分布式限流:多账号 / 多应用调用时,按账号拆分请求量(如 2 个账号各 50 次 / 分钟),避免单账号超限;
- 重试策略:触发 429 错误后,采用「指数退避重试」(10s→20s→40s),禁止高频重试加剧违规。
三、数据用途边界:明确可做 / 不可做(合规核心)
Cdiscount API 数据的使用权限严格限定在「授权业务场景」,超出边界即触发违规,平台会通过数据审计追溯用途:
1. 合法用途(平台允许)
| 用途场景 | 具体说明 |
|---|---|
| 店铺运营管理 | 自有店铺的商品上下架、库存同步、订单处理、定价调整 |
| 合规市场分析 | 基于公开数据的行业趋势分析、竞品价格监控(仅用于自身业务优化,不对外传播) |
| 客户服务优化 | 基于订单 / 评论数据优化售后、物流服务(需脱敏用户信息) |
| 合规数据展示 | 自有平台 / APP 展示 Cdiscount 商品信息(需标注数据来源为 Cdiscount) |
2. 禁止用途(高风险,100% 封号)
| 禁止行为 | 风险后果 |
|---|---|
| 数据转售 / 商业化 | 永久封号 + 平台追责(民事赔偿) |
| 恶意竞争使用 | 如批量采集竞品价格恶意低价倾销 |
| 用户隐私数据滥用 | 如解析 / 传播买家信息、评论者昵称 |
| 非授权数据展示 | 如第三方平台聚合展示 Cdiscount 商品 |
| 伪造 API 请求数据 | 如篡改商品 ID、价格字段 |
| 爬虫 / 逆向非公开接口 | 即使未超限,也会直接永久封号 |
3. 数据使用合规实操
- 数据脱敏:存储 / 展示数据时,隐藏卖家 ID、买家昵称、SKU 参考码等敏感字段(仅保留商品 ID、公开价格等基础信息);
- 数据留存:仅保留业务必需的数据,且留存时间不超过 6 个月(平台要求),定期清理过期数据;
- 来源标注:对外展示 Cdiscount 数据时,必须标注「数据来源:Cdiscount Open API」,禁止宣称自有数据;
- 审计日志:记录所有 API 调用的用途、时间、数据量,平台审计时可提供合规证明。
四、封号风险规避:全链路防控策略
1. 事前防控:从源头降低风险
- 账号分级使用:测试环境用测试账号(低权限),生产环境用正式账号(高权限),避免测试操作污染正式账号;
- API 权限最小化:仅申请业务必需的 API 权限(如仅需商品查询则不申请订单修改权限),权限越多,违规风险越高;
- 监控预警配置:提前设置限流预警(如达到 90 次 / 分钟时触发告警),避免超限;
- 文档备案:留存 API 调用的业务用途说明、授权协议,便于平台核查时举证。
2. 事中监控:实时规避违规行为
| 监控维度 | 监控指标 | 预警 / 处理措施 |
|---|---|---|
| 频率监控 | 分钟级请求数、429 错误次数 | 达到 90 次 / 分钟时暂停请求;429 错误触发自动休眠 |
| 响应码监控 | 401(Token 失效)、403(权限不足) | 401 自动刷新 Token;403 立即停止调用并核查权限 |
| 数据量监控 | 单批次采集商品数、日采集总量 | 超过业务必需量时自动限流 |
| 异常请求监控 | 非 200 响应占比、空数据返回占比 | 占比>10% 时暂停调用,排查原因 |
3. 事后补救:封号后的应对措施
| 封号类型 | 补救措施 | 注意事项 |
|---|---|---|
| 临时封禁(10 分钟 - 1 小时) | 立即停止所有 API 调用,等待封禁结束后降低请求频率(如降至 80 次 / 分钟) | 禁止封禁期间继续请求,否则加重处罚 |
| Token 封禁 | 登录开发者平台申诉,说明违规原因 + 整改方案,申请重置 Token | 申诉需提供 API 调用日志、业务用途证明 |
| 永久封号 | 提交企业资质 + 合规承诺书,人工申诉(成功率<10%) | 优先用备用账号承接业务,避免业务中断 |
4. 核心规避实操技巧
- 避免高频重复请求:对同一商品 / 订单的查询,添加本地缓存(如 Redis 缓存 5 分钟),避免重复调用 API;
- 禁用并发请求:禁止多线程 / 多进程无节制并发调用,即使总频率未超限,短时间峰值也会触发风控;
- 规范请求参数:禁止传入无效参数(如不存在的商品 ID、负数库存),平台会判定为恶意请求;
- 备用账号预案:提前申请 2-3 个备用授权账号,主账号封停时可无缝切换,避免业务中断;
- 关注平台公告:定期查看 Cdiscount 开发者平台的规则更新,限流阈值、数据政策调整需及时适配。
五、合规自查清单(落地必看)
| 自查项 | 合规标准 | 检查方式 |
|---|---|---|
| 频率控制 | 所有 API 请求≤100 次 / 分钟 | 查看日志中分钟级请求数,验证限流代码生效 |
| Token 使用 | 仅自身使用,不共享、不泄露 | 检查代码中 Token 存储方式(禁止硬编码) |
| 数据用途 | 仅用于授权业务,无转售 / 对外传播 | 核查数据输出渠道(如是否对外展示) |
| 敏感数据处理 | 买家 / 卖家隐私字段已脱敏 | 抽查存储的数据集,确认无完整隐私信息 |
| 异常处理 | 429/401 错误有自动处理逻辑 | 模拟超限 / Token 失效,验证程序行为 |
| 日志留存 | 保留近 3 个月的 API 调用日志 | 检查日志文件 / 数据库,确认留存完整 |
六、总结
Cdiscount API 调用合规的核心是「控频率、守边界、防风险」:
- 频率合规:用精准限流工具控制请求节奏,避免 429 错误和封禁;
- 用途合规:仅在授权范围内使用数据,禁止商业化、滥用隐私数据;
- 风险规避:事前防控、事中监控、事后补救,全链路降低封号概率。
合规的本质是「匹配业务需求的最小化调用」—— 不超额请求、不滥用数据、不触碰平台规则红线,既能避免封号风险,也能保障业务长期稳定运行。对于企业用户,建议建立专门的 API 合规小组,定期审计调用行为,确保全流程符合平台规则。
从零打造专业级前端 SDK (一):架构与工程化
从零打造专业级前端 SDK (一):架构与工程化
前言:在前端开发中,我们经常需要接入各种第三方 SDK(如统计埋点、即时通讯、地图服务)。但你是否想过,如果让你从零开发一个 SDK,该如何设计?本文将带你一步步打造一个生产可用的前端埋点 SDK。
1. 为什么我们需要自研 SDK?
市面上已有 Google Analytics、Mixpanel 等成熟方案,为什么还要造轮子?
- 数据安全:敏感数据必须私有化部署。
- 定制需求:需要采集特定的业务数据(如停车场车位状态、设备温度)。
- 极致轻量:第三方 SDK 往往功能臃肿,我们只需要核心功能。
2. 工程化基石:TypeScript + Vite
工欲善其事,必先利其器。对于 SDK 开发,我的技术选型是:
- 语言: TypeScript (类型安全是 SDK 的生命线)。
- 构建: Vite (Library Mode) (基于 Rollup,打包体积小,配置简单)。
2.1 初始化项目
npm create vite@latest parking-tracker-sdk -- --template vanilla-ts
npm install uuid @types/uuid
npm install -D vite-plugin-dts # 用于生成 .d.ts 类型文件
2.2 Vite 配置 (Library Mode)
我们需要 SDK 能同时支持 import (ESM) 和 <script> (UMD) 引入。
vite.config.ts:
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [dts({ include: ['src'] })], // 自动生成类型声明
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'ParkingTracker', // UMD 全局变量名
fileName: (format) => `index.${format}.js`
}
}
});
3. 核心设计模式
SDK 的代码质量直接决定了接入者的体验。这里我们使用了两个经典的设计模式。
3.1 单例模式 (Singleton) —— 唯一的指挥官
场景:无论用户调用多少次 init(),或者在多少个组件里引入 SDK,我们都希望全局只有一个 Tracker 实例,保证配置和状态的唯一性。
实现 (src/core/Tracker.ts):
export class Tracker {
private static instance: Tracker; // 静态属性存储实例
// 1. 私有构造函数:禁止外部直接 new Tracker()
private constructor() {
this.config = { ... };
}
// 2. 静态方法获取实例
public static getInstance(): Tracker {
if (!Tracker.instance) {
Tracker.instance = new Tracker();
}
return Tracker.instance;
}
}
3.2 外观模式 (Facade) —— 极简的门面
场景:Tracker.getInstance().track(...) 这种写法太啰嗦了。用户只想要 Tracker.track(...)。
实现 (src/index.ts):
import { Tracker } from './core/Tracker';
// 导出一个对象,代理核心方法
export default {
init: (config: TrackerConfig) => Tracker.getInstance().init(config),
track: (eventName: string, props?: any) => Tracker.getInstance().track(eventName, props)
};
这样用户就可以愉快地使用了:
import Tracker from 'parking-tracker-sdk';
Tracker.init({ appId: '123' });
Tracker.track('login');
4. 阶段总结
到目前为止,我们已经完成了一个 SDK 的骨架:
- 类型安全:完整的 TS 支持。
- 构建产物:支持 ESM/CJS/UMD。
- 架构设计:单例保证状态唯一,外观提供极致体验。
但现在的 SDK 还是个"哑巴",只能在控制台打印日志。 下一篇,我们将为它注入灵魂——实现网络发送能力,并探讨如何使用策略模式来应对复杂的浏览器环境。
本文代码已开源,欢迎 Star ⭐️