第一个成功在APP store 上架的APP
XunDoc开发之旅:当AI医生遇上家庭健康管家
当我在生活中目睹家人为管理复杂的健康数据、用药提醒而手忙脚乱时,一个想法冒了出来:我能否打造一个App,像一位贴心的家庭健康管家,把全家人的健康都管起来?它不仅要能记录数据,还要够聪明,能解答健康疑惑,能主动提醒。这就是 XunDoc App。
1. 搭建家庭的健康数据中枢
起初,我转向AI助手寻求架构指导。我的构想很明确:一个以家庭为单位,能管理成员信息、记录多种健康指标(血压、血糖等)的系统。AI很快给出了基于SwiftUI和MVVM模式的代码框架,并建议用UserDefaults来存储数据。
但对于一个完整的应用而言,我马上遇到了第一个问题:数据如何在不同视图间高效、准确地共享? 一开始我简单地使用@State,但随着功能增多,数据流变得一团糟,经常出现视图数据不同步的情况。
接着在Claude解决不了的时候我去询问Deepseek,它一针见血地指出:“你的数据管理太分散了,应该使用EnvironmentObject配合单例模式,建立一个统一的数据源。” 这个建议成了项目的转折点。我创建了FamilyShareManager和HealthDataManager这两个核心管家。当我把家庭成员的增删改查、健康数据的录入与读取都交给它们统一调度后,整个应用的数据就像被接通了任督二脉,立刻流畅稳定了起来。
2. 请来AI医生:集成Moonshot API
基础框架搭好,接下来就是实现核心的“智能”部分了。我想让用户能通过文字和图片,向AI咨询健康问题。我再次找到AI助手,描述了皮肤分析、报告解读等四种咨询场景,它很快帮我写出了调用Moonshot多模态API的代码。
然而,每件事都不能事事如意的。文字咨询很顺利,但一到图片上传就频繁失败。AI给出的代码在处理稍大一点的图片时就会崩溃,日志里满是编码错误。我一度怀疑是网络问题,但反复排查后,我询问Deepseek,他告诉我:“多模态API对图片的Base64编码和大小有严格限制,你需要在前端进行压缩和校验。”
我把他给我的建议给到了Claude。claude帮我编写了一个“图片预处理”函数,自动将图片压缩到4MB以内并确保编码格式正确。当这个“关卡”被设立后,之前桀骜不驯的图片上传功能终于变得温顺听话。看着App里拍张照就能得到专业的皮肤分析建议,那种将前沿AI技术握在手中的感觉,实在令人兴奋。
3. 打造永不遗忘的智能提醒系统
健康管理,贵在坚持,难在记忆。我决心打造一个强大的医疗提醒模块。我的想法是:它不能是普通的闹钟,而要像一位专业的护士,能区分用药、复查、预约等不同类型,并能灵活设置重复。
AI助手根据我的描述,生成了利用UserNotifications框架的初始代码。但很快,我发现了一个新问题:对于“每周一次”的重复提醒,当用户点击“完成”后,系统并不会自动创建下一周的通知。这完全违背了“提醒”的初衷。
“这需要你自己实现一个智能调度的逻辑,在用户完成一个提醒时,计算出下一次触发的时间,并重新提交一个本地通知。” 这是deepseek告诉我的,我把这个需求告诉给了Claude。于是,在MedicalNotificationManager中, claude加入了一个“重新调度”的函数。当您标记一个每周的用药提醒为“已完成”时,App会悄无声息地为您安排好下一周的同一时刻的提醒。这个功能的实现,让XunDoc从一个被动的记录工具,真正蜕变为一个主动的健康守护者。
4. 临门一脚:App Store上架“渡劫”指南
当XunDoc终于在模拟器和我的测试机上稳定运行后,我感觉胜利在望。但很快我就意识到,从“本地能跑”到“商店能下”,中间隔着一道巨大的鸿沟——苹果的审核。证书、描述文件、权限声明、截图尺寸……这些繁琐的流程让我一头雾水。
这次,我直接找到了DeepSeek:“我的App开发完了,现在需要上传到App Store,请给我一个最详细、针对新手的小白教程。”
DeepSeek给出的回复堪称保姆级,它把整个过程拆解成了“配置App ID和证书”、“在App Store Connect中创建应用”、“在Xcode中进行归档打包”三大步。我就像拿着攻略打游戏,一步步跟着操作:
- 创建App ID:在苹果开发者后台,我按照说明创建了唯一的App ID
com.[我的ID].XunDoc。 - 搞定证书:最让我头疼的证书环节,DeepSeek指导我分别创建了“Development”和“Distribution”证书,并耐心解释了二者的区别。
- 设置权限:因为App需要用到相机(拍照诊断)、相册(上传图片)和通知(医疗提醒),我根据指南,在
Info.plist文件中一一添加了对应的权限描述,确保审核员能清楚知道我们为什么需要这些权限。
一切准备就绪,我在Xcode中点击了“Product” -> “Archive”。看着进度条缓缓填满,我的心也提到了嗓子眼。打包成功!随后通过“Distribute App”流程,我将我这两天的汗水上传到了App Store Connect。当然不是一次就通过上传的。

5. 从“能用”到“好用”:三次UI大迭代的觉醒
应用上架最初的兴奋感过去后,我陆续收到了一些早期用户的反馈:“功能很多,但不知道从哪里开始用”、“界面有点拥挤,找东西费劲”。这让我意识到,我的产品在工程师思维里是“功能完备”,但在用户眼里可能却是“复杂难用”。
我决定重新设计UI。第一站,我找到了国产的Mastergo。我将XunDoc的核心界面截图喂给它,并提示:“请为这款家庭健康管理应用生成几套更现代、更友好的UI设计方案。”
Mastergo给出的方案让我大开眼界。它弱化了我之前强调的“卡片”边界,采用了更大的留白和更清晰的视觉层级。它建议将底部的标签栏导航做得更精致,并引入了一个全局的“+”浮动按钮,用于快速记录健康数据。这是我第一套迭代方案的灵感来源:从“功能堆砌”转向“简洁现代” 。

然而,Mastergo的方案虽然美观,但有些交互逻辑不太符合iOS的规范。于是,第二站,我请来了Stitch。我将完整的产品介绍、所有功能模块的说明,以及第一版的设计图都给了它,并下达指令:“请基于这些材料,完全重现XunDoc的完整UI,但要遵循iOS Human Interface Guidelines,并确保信息架构清晰,新用户能快速上手。”等到他设计好了后 我将我的设计图UI截图给Claude,让他尽可能的帮我生成。

(以上是我的Stitch构建出来的页面)
Claude展现出了惊人的理解力。它不仅仅是在画界面,而是在重构产品的信息架构。它建议将“AI咨询”的四种模式(皮肤、症状、报告、用药)从并列排列,改为一个主导航入口,进去后再通过图标和简短说明让用户选择。同时,它将“首页”重新定义为真正的“健康概览”,只显示最关键的数据和今日提醒,其他所有功能都规整地收纳入标签栏。这形成了我的第二套迭代方案:从“简洁现代”深化为“结构清晰” 。

拿着Claude的输出,我结合Mastergo和Stitch的视觉灵感,再让Cluade一步一步的微调。我意识到,颜色不仅是美观,更是传达情绪和功能的重要工具。我将原本统一的蓝色系,根据功能模块进行了区分:健康数据用沉稳的蓝色,AI咨询用代表智慧的紫色,医疗提醒用醒目的橙色。图标也设计得更加线性轻量,减少了视觉负担。(其实这是Deepseek给我的建议)这就是最终的第三套迭代方案:在清晰的结构上,注入温暖与亲和力。

这次从Stitch到Claude的UI重塑之旅,让我深刻意识到,一个成功的产品不仅仅是代码的堆砌。它是一次与用户的对话,而设计,就是这门对话的语言。通过让不同的AI助手在我的引导下“协同创作”,我成功地让XunDoc从一個工程师的作品,蜕变成一个真正为用户着想的产品。
现在这款app已经成功上架到了我的App store上 大家可以直接搜索下来进行使用和体验,我希望大家可以在未来可以一起解决问题!
来源:juejin.cn/post/7559864914883067914
一些经典的3D编辑器开源项目
前言
给大家分享一下个人在探索开发three.js编辑器项目期间发现的一些比较不错的3D编辑器类型的开源项目,如果你也正打算做类似相关的项目,那么这些开源项目会是一个不错的参考借鉴
以下排名不分先后🙏🏻
项目一:Astral3D
描述:基于Vue3 + THREE.JS 免费开源的三维引擎及配套编辑器,包含BIM轻量化、CAD解析预览、粒子系统、插件系统等功能。
特点:强大的3D场景内容元素的编辑和保存功能和丰富多样的3D元素内容,同时支持BIM和CAD等工业建模文件的加载渲染
注意⚠️:项目是Apache-2.0 license 的开源协议,项目作者本人也声明了项目可用于个人学习,如有商用需要向作者申请商用授权
界面:

Github: github.com/mlt131220/A…
项目二:thebrowserlab
描述:一个「运行在浏览器里的 3D 编辑器 + 创意编码 (creative-coding) 环境」
特点:支持加载视频、文本、图片、粒子等内容并提供了丰富的编辑表单参数可视化编辑配置,同时还支持在线代码的脚本内容写入设置3D场景内容。
注意⚠️:项目使用 MIT 授权 (MIT license),意味着你可以自由地 fork、修改、商用 (遵守 MIT 即可)
界面:

在线地址:thebrowserlab.com/
Github: github.com/icurtis1/th…
项目三:threepipe
描述:一个基于 Three.js 构建的现代 3D 框架
特点:项目基于了Three.js进行了二次封装,提供了不少高级功能,使其适合从简单 3D 模型预览到复杂 交互 / 渲染应用,通过简单的API 使用就可以快速创建复杂的3D模型预览器,模型编辑器等内容。
注意⚠️:既然是封装好的框架,在享受使用的便利时,新的学习成本也是不可避免的,项目使用 Apache-2.0-1协议,商用也许需要授权,不过毕竟是歪果仁开发的,即使未授权也难以知晓
界面:
Github: github.com/repalash/th…
项目四:ShadowEditor
描述:基于Three.js、Go语言和MongoDB的跨平台的3D场景编辑器,支持桌面版和Web版。
特点:跨平台的支持 Windows / Linux / Mac,在桌面 (desktop) 和浏览器 (web) 中都能运行,前后端一体的项目
注意⚠️:使用 MIT 许可证的项目,可以自由用于学习、实验或商业用途。从界面不难看出,应该是属于上古时期的项目了,three.js版本也是107的。作者也推出了商业版的,如有需要也可以试用一下商业版的
界面:

在线地址:http://www.hylab.cn/shadowedito…
Github: gitee.com/tengge1/Sha…
项目五:three-editor
描述:一个基于 Three.js 的 可视化 / 低代码 3D 编辑器 / 内核/框架。它的目标是降低使用 Three.js 的门槛,让构建 Web 3D 场景更简单、更迅速
特点:提供了一整套“可视化 + 配置 + 编辑 + 渲染”的能力,使得即使不深入了解 Three.js,也能快速构建 3D 场景 / 项目,:如果你只是想在网页中展示某个 3D 模型、场景或交互,而不想编写大量 Three.js boilerplate,three-editor 能极大降低门槛
注意⚠️:因为场景内容都是封装处理好的,提供的可编辑参数内容配置并不多,如果你的自定义需求很多的话使用这个项目前需要谨慎考虑一下
界面:

在线地址:z2586300277.github.io/threejs-edi…
Github: github.com/z2586300277…
项目六:scene-editor
描述:vis-three/scene-editor 是基于 vis-three 框架构建的 —— vis-three 本身是一个封装自 Three.js 的前端 3D 开发框架,用于简化 Web3D 开发
特点:基于vis-three 衍生开发的一个3D编辑器提供了一套较为完整的 Web 3D 场景编辑功能 — 目标是让你即使对 3D 或 Three.js 不熟,也能比较轻松地 “拖/配/编辑” 出一个 3D 场景
注意⚠️:仓库地址的代码是Vue3项目编译打包后的,作者并没有直接提供Vue3项目的源代码,如果有二次开发需求,无法直接性修改源代码
界面:

在线地址:z2586300277.github.io/threejs-edi…
Github: github.com/Shiotsukika…
Gitee:gitee.com/vis-three/s…
项目七: three.js官方编辑器
描述:Three.js(著名的 WebGL / Web 3D 渲染库)自带 / 官方提供的可视化编辑器,接触过three.js的应该都知道吧
特点:3D编辑器的鼻祖了也是唯一一个能和three.js最新版本保持随时同步的编辑器,很多现有的商业项目和开源项目的功能,或多或少都参考了这个项目去实现的
注意⚠️:使用原生js 去实现的,二次开发和扩展功能成本较大
界面:

在线地址:threejs.org/editor/
Github: github.com/mrdoob/thre…
项目八: threejs-3dmodel-edit
描述:一个基于 Three.js + Vue 3 + TypeScript + Pinia 的前端 3D 模型编辑器 / 可视化编辑平台
特点:是一个比较完整、现代、易用的 Web-based 3D 模型编辑器 — 它把 Three.js 的功能通过 Vue / TS / Pinia 封装起来,让非专业 3D 建模背景的人也能比较容易地加载 /编辑 /导出 /展示 3D 模型。基于企业级项目代码开发的标准规范,如果你正在开发自己的第一个企业级Three.js 项目那么这个项目的代码设计思路将会是一个不错的参考
注意⚠️:作者本人的3D开源项目,毛遂自荐一下,哈哈哈哈
界面:

Github: github.com/zhangbo126/…
Gitee:gitee.com/ZHANG_6666/…
结语
ok以上就是作者本人已知的一些不错的开源3D编辑器合集了,如果你还知道一些好的3D编辑器项目欢迎评论区补充
来源:juejin.cn/post/7576867727719039011
🍭🍭🍭升级 AntD 6:做第一个吃螃蟹的人
AntD 6 发布之后,网上很多人都在观望:
“要不要升级?”
“会不会炸?”
“我的项目能不能扛得住?”
其实 AntD 官方已经在文档里把升级路径写得非常清楚,只是稍显简略。
下面我用更真实、更工程化的方式,把 v5 → v6 的升级步骤 做了一次加强版讲解,
让你升级时不至于踩坑。
① 第一步:升级到 v5 最新版本(必须执行)
在升级到 AntD 6 之前,官方强烈建议你先把项目从 v5 升到 v5 最新版本:5.29.1。
为什么?
✔ v5 的最新版本会给出所有废弃 API 的 warning
✔ 不处理这些 warning,到 v6 会直接报错
✔ v5 → v6 是平滑升级路径,只要你处理掉 v5 的 warning,升级 v6 就不会炸
执行命令:
npm install antd@5
装完以后,启动项目,务必一条一条看控制台 warning。
比如:
- 某个 API 将被移除
- 某个 props 已废弃
- 某个组件 v6 即将删除
所有 warning 都处理完,再继续下一步。
这阶段非常关键,等于是在做“升级前全身检查”。
其实你只要用了5的版本基本没啥大问题

② 第二步:确保项目运行在 React 18(或以上)
AntD 6 不再支持 React 17 及以下版本。
AntD 官方的态度非常明确:
“React 17,我们不救了。”
好消息是:绝大多数前端项目早就 React 18 了。
如果你还停留在 React 17,那建议你别升级 AntD,
你升级 React 本身都要做好打仗的准备。
检查你的 package.json:
"react": "^18.x.x",
"react-dom": "^18.x.x"
如果不是,那你真的得升级个 der(官方术语:赶紧升 😅)。
React 17 升到 React 18 已经是必经之路,
Suspense、Concurrent、SSR 都已经进入新阶段,不升会拖累整个项目生态。
③ 第三步:开干!升级到 AntD 6
前面两步做完,你的项目基本已经“具备上 6 条件”了。
现在就可以正式开刀:
npm install --save antd@6
或者你爱用的包管理器:
yarn add antd@6
# or
pnpm add antd@6
# or
npm install antd@latest
安装完成后,你的项目就是 AntD 6 正式用户。
④ 第四步:启动项目,处理残留 warning
升级完成以后,重启项目。
你可能会看到一些:
- 类型定义变动 warning
- 某些行为变更 warning
- 某些组件结构调整提示
- mask blur 带来的视觉差异
- 你自己写的样式被 DOM 改动影响
这些属于正常“升级后适配”。
根据提示处理即可。
建议你重点检查以下区域:
✔ 自定义覆盖类名(AntD 6 DOM 有变化)
✔ Modal、Drawer 的 mask 是否出现模糊效果
✔ Table、Form 是否有类型冲突
✔ 已废弃 API 是否仍然使用
✔ 第三方依赖是否引用了 AntD 内部 class
通常处理 1-2 小时就可以全部解决 ( 不解决也行,能跑就行😉)。
⑤ 最终:你就正式吃上了 AntD 6 的“螃蟹”
完成以上步骤后,你就完成了整个升级链路:
- 清理 v5 废弃 API
- 升到 React 18(如果你的项目还没)
- 升到 AntD 6
- 修复升级后剩余 warning
从此以后:
- 你可以用 Masonry 瀑布流
- 你可以用更快的 Tooltip
- 你可以享受 ZeroRuntime
- 你可以用语义化 DOM 更好写主题
- 你的项目正式进入 2025 年的前端栈
一句话:
你是第一个吃螃蟹的人,但这次螃蟹真的不难吃,而且还挺香,哥们已经升级,满嘴流油了。

来源:juejin.cn/post/7576571960286822435
基于WASM的纯前端Office解决方案:在线编辑/导入导出/权限切换/多实例/实例缓存(已开源)
效果展示
所有操作均在浏览器进行,先来看看最终效果:
🌐 在线演示: mvp-onlyoffice.vercel.app/
🔗 GitHub仓库: mvp-onlyoffice
基本示例

多实例示例

多tab示例

。。。。。。。。。。
核心功能演示
- ✅ 文档上传:支持本地文件直接上传
- ✅ 实时编辑:流畅的文档编辑体验
- ✅ 格式转换:基于WASM的文档格式转换
- ✅ 导出保存:一键导出编辑后的文档
- ✅ 模式切换:只读/可编辑模式自由切换
- ✅ 多语言支持:中英文界面无缝切换
- ✅ 多实例支持:同时运行多个独立编辑器实例(Word/Excel/PPT)
- ✅ 资源隔离:每个实例独立的图片上传和媒体资源管理
技术架构
核心技术栈
- React 19 + Next.js 15:现代化前端框架
- OnlyOffice SDK:官方JavaScript SDK,提供文档编辑核心能力
- WebAssembly (x2t-wasm):文档格式转换引擎
- TypeScript:类型安全的开发体验
- EventBus:事件驱动的架构设计
- IndexedDB:WASM文件缓存优化
- EditorManagerFactory:多实例管理器工厂模式
tip: 事实上不依赖于 react,你可以拿到 项目中的 src/onlyoffice-comp ,然后接入到任何系统中去,接入层可以参考 src/app/excel/page.tsx等应用层文件
架构流程图
用户上传文档
↓
React组件层
↓
EditorManagerFactory (多实例管理器工厂)
↓
EditorManager (编辑器管理器)
↓
X2T Converter (WASM转换器)
↓
OnlyOffice SDK (文档编辑器)
↓
EventBus (事件总线)
↓
导出/保存文档
WASM文档转换核心流程
转换流程图解
用户选择文件
↓
浏览器读取文件
↓
WASM虚拟文件系统
↓
X2T引擎执行转换
↓
生成二进制数据 + 媒体资源
↓
OnlyOffice编辑器加载
核心代码实现
// src/onlyoffice-comp/lib/x2t.ts
/**
* X2T 工具类 - 负责文档转换功能
*/
class X2TConverter {
private x2tModule: EmscriptenModule | null = null;
// 支持的文件类型映射
private readonly DOCUMENT_TYPE_MAP: Record<string, DocumentType> = {
docx: 'word',
doc: 'word',
odt: 'word',
rtf: 'word',
txt: 'word',
xlsx: 'cell',
xls: 'cell',
ods: 'cell',
csv: 'cell',
pptx: 'slide',
ppt: 'slide',
odp: 'slide',
};
/**
* 转换文档格式
*/
async convertDocument(file: File): Promise<ConversionResult> {
// 初始化WASM模块
await this.ensureReady();
// 写入虚拟文件系统
const data = await file.arrayBuffer();
this.x2tModule!.FS.writeFile('/working/origin', new Uint8Array(data));
// 执行C++编译的转换模块
this.executeConversion('/working/params.xml');
// 提取转换结果和媒体文件
return {
bin: this.x2tModule!.FS.readFile('/working/output.bin'),
media: this.collectMediaFiles() // 提取图片等资源
};
}
}
编辑器管理器:多实例架构设计
项目采用工厂模式管理多个编辑器实例,每个实例都有独立的容器ID和资源管理:
// src/onlyoffice-comp/lib/editor-manager.ts
// EditorManagerFactory - 多实例管理器工厂
class EditorManagerFactory {
private static instance: EditorManagerFactory;
private managers: Map<string, EditorManager> = new Map();
// 创建或获取编辑器管理器实例
create(containerId?: string): EditorManager {
if (containerId) {
// 如果已存在,返回现有实例
if (this.managers.has(containerId)) {
return this.managers.get(containerId)!;
}
// 创建新实例
const manager = new EditorManager(containerId);
this.managers.set(containerId, manager);
return manager;
}
// 创建默认实例
return this.createDefault();
}
// 获取指定容器ID的实例
get(containerId: string): EditorManager | undefined {
return this.managers.get(containerId);
}
// 销毁指定实例
destroy(containerId: string): void {
const manager = this.managers.get(containerId);
if (manager) {
manager.destroy();
this.managers.delete(containerId);
}
}
// 销毁所有实例
destroyAll(): void {
this.managers.forEach(manager => manager.destroy());
this.managers.clear();
}
}
// EditorManager - 单个编辑器管理器
class EditorManager {
private instanceId: string;
private containerId: string;
private editor: DocEditor | null = null;
constructor(containerId?: string) {
this.instanceId = nanoid(); // 生成唯一实例ID
this.containerId = containerId || `onlyoffice-editor-${this.instanceId}`;
}
// 获取实例ID
getInstanceId(): string {
return this.instanceId;
}
// 获取容器ID
getContainerId(): string {
return this.containerId;
}
// 导出文档(事件驱动)
async export(): Promise<SaveDocumentData> {
const editor = this.get();
if (!editor) {
throw new Error('Editor not available');
}
// 触发保存
(editor as any).downloadAs();
// 等待保存事件
const result = await onlyofficeEventbus.waitFor(
ONLYOFFICE_EVENT_KEYS.SAVE_DOCUMENT,
10000
);
return result;
}
// 设置只读模式
async setReadOnly(readOnly: boolean): Promise<void> {
// 实现逻辑...
}
}
事件驱动架构:EventBus解耦设计
项目采用事件总线机制,实现组件间的松耦合通信:
// src/onlyoffice-comp/lib/eventbus.ts
class EventBus {
private listeners: Map<EventKey, Array<(data: any) => void>> = new Map();
// 监听事件
on<K extends EventKey>(key: K, callback: (data: EventDataMap[K]) => void): void {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key)!.push(callback);
}
// 等待事件触发(返回 Promise)
waitFor<K extends EventKey>(key: K, timeout?: number): Promise<EventDataMap[K]> {
return new Promise((resolve, reject) => {
const timeoutId = timeout
? setTimeout(() => {
this.off(key, handleEvent);
reject(new Error(`Event ${key} timeout after ${timeout}ms`));
}, timeout)
: null;
const handleEvent = (data: EventDataMap[K]) => {
if (timeoutId) clearTimeout(timeoutId);
this.off(key, handleEvent);
resolve(data);
};
this.on(key, handleEvent);
});
}
}
支持的事件类型
saveDocument- 文档保存完成事件documentReady- 文档加载就绪事件loadingChange- 加载状态变化事件
核心功能特性
1. 多实例支持
支持同时创建和管理多个独立的编辑器实例,每个实例都有独立的容器ID和资源管理:
import { createEditorView } from '@/onlyoffice-comp/lib/x2t';
import { editorManagerFactory } from '@/onlyoffice-comp/lib/editor-manager';
// 创建第一个编辑器实例
const manager1 = await createEditorView({
isNew: true,
fileName: 'Document1.docx',
containerId: 'editor-1', // 指定容器ID
});
// 创建第二个编辑器实例
const manager2 = await createEditorView({
isNew: true,
fileName: 'Document2.xlsx',
containerId: 'editor-2', // 不同的容器ID
});
// 分别操作不同实例
const result1 = await manager1.export();
const result2 = await manager2.export();
// 销毁指定实例
editorManagerFactory.destroy('editor-1');
// 销毁所有实例
editorManagerFactory.destroyAll();
关键特性:
- 容器隔离:每个实例使用唯一的容器ID,通过
data-onlyoffice-container-id属性精确定位 - 资源隔离:每个实例管理独立的媒体资源映射,图片上传不会相互干扰
- 独立事件处理:每个实例通过
createWriteFileHandler(manager)创建独立的图片上传处理函数
2. 国际化支持
项目内置多语言支持,可自由切换中英文界面。在多实例场景下,切换语言会重新创建所有编辑器实例:
// 切换语言(多实例场景)
const handleLanguageSwitch = async () => {
const newLang = currentLang === 'zh' ? 'en' : 'zh';
setCurrentLang(newLang);
// 保存每个编辑器实例的文档信息
const editorDocuments = {
manager1: { fileName: 'Doc1.docx', file: file1 },
manager2: { fileName: 'Doc2.xlsx', file: file2 },
};
// 重新创建所有编辑器以应用新语言
if (editorDocuments.manager1) {
await createEditorView({
fileName: editorDocuments.manager1.fileName,
file: editorDocuments.manager1.file,
containerId: 'editor-1',
lang: newLang,
});
}
if (editorDocuments.manager2) {
await createEditorView({
fileName: editorDocuments.manager2.fileName,
file: editorDocuments.manager2.file,
containerId: 'editor-2',
lang: newLang,
});
}
};
3. 导入导出功能
完整的文档导入导出能力:
// 导出文档
const result = await editorManager.export();
// result 包含: { fileName, fileType, binData, media }
// 转换并下载
const buffer = await convertBinToDocument(
result.binData,
result.fileName,
FILE_TYPE.XLSX,
result.media
);
const blob = new Blob([buffer.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
// 执行下载操作
4. 只读/可编辑模式切换
灵活的权限控制,支持动态切换编辑模式:
// 设置为只读模式
await editorManager.setReadOnly(true);
// 切换为可编辑模式
await editorManager.setReadOnly(false);
// 查询当前模式
const isReadOnly = editorManager.getReadOnly();
实现原理:
- 从只读切换到可编辑:重新创建编辑器实例
- 从可编辑切换到只读:使用
processRightsChange命令
5. IndexedDB缓存优化
使用IndexedDB缓存WASM文件,大幅提升二次加载速度:
// 拦截 fetch,缓存 WASM 文件到 IndexedDB
private interceptFetch(): void {
const originalFetch = window.fetch;
window.fetch = async function(input: RequestInfo | URL): Promise<Response> {
// 先尝试从缓存读取
const cached = await this.getCachedWasm(url);
if (cached) {
return new Response(cached, {
headers: { 'Content-Type': 'application/wasm' }
});
}
// 缓存未命中,从网络加载并缓存
const response = await originalFetch(input);
const arrayBuffer = await response.arrayBuffer();
await this.cacheWasm(url, arrayBuffer);
return response;
};
}
使用示例
基本使用(单实例)
import { createEditorView } from '@/onlyoffice-comp/lib/x2t';
import { editorManagerFactory } from '@/onlyoffice-comp/lib/editor-manager';
// 创建编辑器视图(使用默认实例)
await createEditorView({
file: fileObject, // File 对象(可选)
fileName: 'document.xlsx', // 文件名
isNew: false, // 是否新建文档
readOnly: false, // 是否只读
lang: 'zh', // 界面语言
});
// 获取默认实例并导出文档
const defaultManager = editorManagerFactory.getDefault();
const result = await defaultManager.export();
console.log('导出成功:', result);
多实例使用
import { createEditorView } from '@/onlyoffice-comp/lib/x2t';
import { editorManagerFactory } from '@/onlyoffice-comp/lib/editor-manager';
// 创建多个编辑器实例
const manager1 = await createEditorView({
isNew: true,
fileName: 'Doc1.docx',
containerId: 'editor-1',
lang: 'zh',
});
const manager2 = await createEditorView({
isNew: true,
fileName: 'Doc2.xlsx',
containerId: 'editor-2',
lang: 'zh',
});
const manager3 = await createEditorView({
isNew: true,
fileName: 'Doc3.pptx',
containerId: 'editor-3',
lang: 'zh',
});
// 分别导出
const result1 = await manager1.export();
const result2 = await manager2.export();
const result3 = await manager3.export();
React组件集成(多实例)
// src/app/multi/page.tsx
function MultiInstancePageContent() {
const [managers, setManagers] = useState({
manager1: null,
manager2: null,
manager3: null,
});
// 保存文档信息,用于语言切换
const [editorDocuments, setEditorDocuments] = useState({
manager1: null,
manager2: null,
manager3: null,
});
// 创建编辑器
const handleView = async (editorKey: string, fileName: string, file?: File) => {
const containerId = `editor-${editorKey.replace('manager', '')}`;
const manager = await createEditorView({
file,
fileName,
isNew: !file,
containerId, // 指定容器ID
lang: getOnlyOfficeLang(),
});
setManagers(prev => ({
...prev,
[editorKey]: manager,
}));
// 保存文档信息
setEditorDocuments(prev => ({
...prev,
[editorKey]: { fileName, file: file || undefined },
}));
};
// 语言切换(重新创建所有编辑器)
const handleLanguageSwitch = async () => {
const newLang = currentLang === 'zh' ? 'en' : 'zh';
// 重新创建所有编辑器
if (editorDocuments.manager1) {
const doc = editorDocuments.manager1;
await handleView('manager1', doc.fileName, doc.file);
}
// ... 其他实例
};
return (
<div className="grid grid-cols-3 gap-4">
{/* 编辑器容器 - 使用 data-onlyoffice-container-id 属性 */}
<div className="onlyoffice-container" data-onlyoffice-container-id="editor-1">
<div id="editor-1" className="absolute inset-0" />
</div>
<div className="onlyoffice-container" data-onlyoffice-container-id="editor-2">
<div id="editor-2" className="absolute inset-0" />
</div>
<div className="onlyoffice-container" data-onlyoffice-container-id="editor-3">
<div id="editor-3" className="absolute inset-0" />
</div>
</div>
);
}
项目结构
mvp-onlyoffice/
├── src/
│ ├── app/ # Next.js 应用页面
│ │ ├── excel/ # Excel 编辑器页面
│ │ ├── docs/ # Word 编辑器页面
│ │ ├── ppt/ # PowerPoint 编辑器页面
│ │ └── multi/ # 多实例演示页面
│ ├── onlyoffice-comp/ # OnlyOffice 组件库
│ │ └── lib/
│ │ ├── editor-manager.ts # 编辑器管理器(支持多实例)
│ │ ├── x2t.ts # 文档转换模块
│ │ ├── eventbus.ts # 事件总线
│ │ └── utils.ts # 工具函数
│ └── components/ # 通用组件
├── public/ # 静态资源
│ ├── web-apps/ # OnlyOffice Web 应用资源
│ ├── sdkjs/ # OnlyOffice SDK 资源
│ └── wasm/ # WebAssembly 转换器
└── onlyoffice-x2t-wasm/ # x2t-wasm 源码
部署方案
Vercel一键部署
项目已配置静态导出,可直接部署到Vercel:
# 安装依赖
npm install
# 构建项目
npm run build
# Vercel 会自动检测并部署
🌐 在线演示: mvp-onlyoffice.vercel.app/
静态文件部署
项目支持静态导出,构建后的文件可部署到任何静态托管服务:
# 构建静态文件
npm run build
# 输出目录: out/
# 可直接部署到 GitHub Pages、Netlify、Nginx 等
技术优势总结
| 特性 | 传统方案 | 本方案 |
|---|---|---|
| 数据安全 | ❌ 需要上传服务器 | ✅ 完全本地处理 |
| 部署成本 | ❌ 需要后端服务 | ✅ 纯静态部署 |
| 格式支持 | ⚠️ 有限格式 | ✅ 30+种格式 |
| 离线使用 | ❌ 需要网络 | ✅ 完全离线 |
| 性能优化 | ⚠️ 依赖网络 | ✅ IndexedDB缓存 |
| 国际化 | ⚠️ 需额外配置 | ✅ 内置支持 |
| 权限控制 | ⚠️ 复杂实现 | ✅ 简单API |
| 多实例支持 | ❌ 不支持 | ✅ 原生支持,资源隔离 |
技术原理
使用x2t-wasm替代OnlyOffice服务
传统OnlyOffice集成需要:
- 搭建OnlyOffice Document Server
- 配置文档转换服务
- 处理文档上传下载
- 管理服务器资源
本方案通过WASM技术:
- 在浏览器中直接运行x2t转换引擎
- 使用虚拟文件系统处理文档
- 完全客户端化,无需服务器
多实例架构设计
- 工厂模式:使用
EditorManagerFactory统一管理多个编辑器实例 - 容器隔离:每个实例使用唯一的容器ID,通过
data-onlyoffice-container-id属性精确定位 - 资源隔离:每个实例管理独立的媒体资源映射,图片上传通过独立的
writeFile处理函数 - 事件隔离:虽然使用全局 EventBus,但每个实例的事件处理函数是独立的
参考项目
- Qihoo360/se-office - se-office扩展,提供基于开放标准的全功能办公生产力套件
- cryptpad/onlyoffice-x2t-wasm - CryptPad WebAssembly文件转换工具
- ranuts/document - 参考静态资源实现
开源地址
🔗 GitHub仓库: mvp-onlyoffice
总结
本项目提供了一个完整的纯前端OnlyOffice集成方案,通过WASM技术实现了文档格式转换的本地化,结合React和OnlyOffice SDK,打造了一个功能完善、性能优秀的文档编辑器。
核心亮点:
- 🚀 纯前端架构,无需后端服务
- 🔒 数据完全本地化,保护隐私安全
- ⚡ 基于WASM的高性能转换
- 🌏 内置国际化支持
- 📦 支持导入导出
- 🔐 灵活的权限控制
- 🎯 多实例支持:同时运行多个独立编辑器,资源完全隔离
欢迎Star和Fork,一起推动前端Office编辑技术的发展!
相关阅读:
来源:juejin.cn/post/7575425466904723519
90% 前端都不知道的 20 个「零依赖」浏览器原生能力!
分享 20 个 2025 年依旧「少人知道、却能立竿见影」的原生 API。
收藏 = 省下一个工具库 + 少写 100 行代码!
1. ResizeObserver
精准监听任意 DOM 宽高变化,图表自适应、虚拟滚动必备。
new ResizeObserver(([e]) => chart.resize(e.contentRect.width))
.observe(chartDom);
2. IntersectionObserver
检测元素进出视口,一次搞定懒加载 + 曝光埋点,性能零损耗。
new IntersectionObserver(entrieList =>
entrieList.forEach(e => e.isIntersecting && loadImg(e.target))
).observe(img);
3. Page Visibility
侦测标签页隐藏,自动暂停视频、停止轮询,移动端省电神器。
document.addEventListener('visibilitychange', () =>
document.hidden ? video.pause() : video.play()
);
4. Web Share
一键唤起系统分享面板,直达微信、微博、Telegram,需 HTTPS。
navigator.share?.({ title: '好文', url: location.href });
5. Wake Lock
锁定屏幕常亮,直播、PPT、阅读器不再自动息屏。
await navigator.wakeLock.request('screen');
6. Broadcast Channel
同域标签实时广播消息,登录态秒同步,告别 localStorage 轮询。
const bc = new BroadcastChannel('auth');
bc.onmessage = () => location.reload();
7. PerformanceObserver
无侵入采集 FCP、LCP、FID,一行代码完成前端性能监控。
new PerformanceObserver(list =>
list.getEntries().forEach(sendMetric)
).observe({ type: 'largest-contentful-paint', buffered: true });
8. requestIdleCallback
把埋点、日志丢进浏览器空闲时间,首帧零阻塞。
requestIdleCallback(() => sendBeacon('/log', data));
9. scheduler.postTask
原生优先级任务队列,低优任务后台跑,主线程丝滑。
scheduler.postTask(() => sendBeacon('/log', data), { priority: 'background' });
10. AbortController
随时取消 fetch,路由切换不再旧请求竞态,兼容 100%。
const ac = new AbortController();
fetch(url, { signal: ac.signal });
ac.abort();
11. ReadableStream
分段读取响应流,边下载边渲染,大文件内存零爆涨。
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
appendChunk(value);
}
12. WritableStream
逐块写入磁盘或网络,实时保存草稿、上传大文件更稳。
const writer = stream.writable.getWriter();
await writer.write(chunk);
13. Background Fetch
PWA 后台静默下载,断网恢复继续,课程视频提前缓存。
await registration.backgroundFetch.fetch('video', ['/course.mp4']);
14. File System Access
读写本地真实文件,需用户授权,Web IDE 即开即用。
const [fh] = await showOpenFilePicker();
editor.value = await (await fh.getFile()).text();
15. Clipboard
异步读写剪贴板,无需第三方库,HTTPS 环境安全复制。
await navigator.clipboard.writeText('邀请码 9527');
16. URLSearchParams
解析、修改、构造 URL 查询串,告别手写正则。
const p = new URLSearchParams(location.search);
p.set('page', 2);
history.replaceState({}, '', `?${p}`);
17. structuredClone
深拷贝对象、数组、Map、Date,循环引用也能完美复制。
const copy = structuredClone(state);
18. Intl.NumberFormat
千分位、货币、百分比一次格式化,国际化零配置。
new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' })
.format(1234567); // ¥1,234,567.00
19. EyeDropper
浏览器级吸管工具,像素级取色,设计系统直接调用。
const { sRGBHex } = await new EyeDropper().open();
20. WebCodecs
原生硬解码音视频,4K 60 帧流畅播放,CPU 占用直降。
const decoder = new VideoDecoder({
output: frame => ctx.drawImage(frame, 0, 0),
error: console.error
});
decoder.configure({ codec: 'vp09.00.10.08' });
来源:juejin.cn/post/7545294766975615015
写给小公司前端的 UI 规范
写给小公司前端的 UI 规范
大部分小公司前端开发是不是都会有个困扰,一天到晚做的都是后台管理系统,而且百分之 80 都是表格的增删改查,导致领导觉得前端简单的很,所以连个 UI 都没有,但是每次写完页面又被老大吐槽没有审美,而且如果整个系统多个人开发,每个的风格都不一样,导致整个系统看起来很乱,想要统一又不知道从何入手,所以今天我给大家分享一下我们团队的针对后台管理系统的 UI 规范,希望对大家有帮助。
叠个甲:这个只是我们遵循的规范,因为交互和设计这种东西每个人的感受不一样,你觉得你这个规范我觉得不合理,没关系,你可以按照自己的想法来也可以,只要遵循一个原则,那就是整个系统风格保持一致性即可,所以这个规范仅供参考。
大家都知道后台管理主要就是几部分:表格、表单、弹窗,所以我们的 UI 规范也是围绕这三部分来的。
表格
自适应形式
- 最小页面宽度+自适应的综合运用,最小自适应页面宽度 1366px(小于此宽度则不再自适应,超出页面的内容使用滚动条查看),单元格字段过长一行展示不下时,不换行并出现省略号,鼠标移入,提示框显示完整字段。

表格内行高
| 场景 | 字号 | 行高 | 适用情况 |
|---|---|---|---|
| 紧凑模式 | 12px | 42px | 数据量大的表格 |
| 标准模式 | 14px | 48px | 默认推荐 |
| 大字号模式 | 16px | 56px | 无障碍/老年版 |

表格内对齐方式
- 表头和文字内容:采用左对齐
- 表头和普通数字:采用左对齐
- 表头和具有比较场景的数字: 采用右对齐
- 表头和操作项: 采用左对齐

分页器
分页器元素:
- 数据总量、单页面展示数量、翻页部分
对齐方式:
- 数据总量左对齐,单页面展示数量&翻页部分右对齐(依次顺序为数据总量、单页面展示数量、翻页部分)
分页器位置:
- 表格为页面全部内容时,表格超出一页分页器固定及底,表格未超出一页分页器跟随表格下方
- 表格仅为页面部分内容时,分页器跟随表格下方

表格操作区
按钮类型:
- 新增数据类按钮 &面向已有数据类的操作按钮(包含可合并类操作按钮)。
对齐方式:
- 操作区居右,主按钮居右。
视觉样式:
- 新增数据类按钮样式-【文字+主色底】或【文字+图标+主色底文字+中性色线框】
- 面向已有数据类的操作按钮样式-【文字+主色底】或【文字+图标+主色底文字+中性色线框】
按钮个数:
- 小于等于 4 个全部展示
- 大于四个时候,最后一个按钮为【更多】按钮,点击【更多】按钮后,下拉展示全部按钮

表格筛选区域
- 一行最多四个筛选条件,不超过四个的时候查询按钮和重置按钮跟在筛选项后
- 超过四个筛选条件时,出现【展示更多】按钮,点击【展示更多】按钮后,下拉展示全部筛选条件,【展示更多】变成【收起更多】按钮
- 如果是时间范围 比如开始时间和结束时间 他独占两个筛选项
- 默认 label 最多四个字 建议两个字或者四个字
- 筛选项的宽度建议 200px

表格滑块
表格为页面全部内容时:
- 内容无超出:滚动不出现
- 横向内容有超出:表格内横向滚动条,且默认固定操作和标题列
- 纵向内容有超出:页面滚动条,表头到达页面顶部,吸顶固定
表格仅为页面部分内容时:
- 表格区最大高度为 10 行数据+分页器,当 10 行数据+分内器超出页面容器时,表格区最大高度将限制为页面容器的 90%
- 内容无超出:滚动不出现
- 横向内容有超出:表格内横向滚动条,且默认固定操作项和标题列
- 纵向内容有超出:表格内滚动条,固定表头&分页器


表单
表单项 Label 与控件的对齐方式&必填标识
- 表单项 Label 和控件对齐方式
- 当表单项 Label 过长(6 个中文字符以上),采用顶对齐方式布局。当表单项 Label 在 6 个中文字符以内,使用右对齐方式布局。
对齐布局; - 当表单项在 15 个以下时,采用单列布局方式,当表单项在 15 个以上时,采用多列顶对齐方式布局。
- 表单项 Label 和必填标识对齐方式
- 表单项 Label 右对齐布局时,必填示识放在标题前;
- 表单项 Label 顶对齐布局时,必填示识放在标题后;

提交/取消操作按钮位置&对齐方式
操作按钮统一采用左对齐的方式,表单域未超出一屏时跟在表单项下方,若超出一屏则固定吸底。
表单页自适应方式&lnput 框长度
- 当只有单列时,表单域左对齐且定宽 、输入框&选择框长度为 480px、文本框长度为 640px,支持表单项 Label 顶对齐和右对齐。
- 当出现双列时,表单域左对齐,表单域宽度为页面容器宽度的 80%,自适应布局,仅支持表单项 Label 顶对齐。
- 当出现三列时,表单域占满整个页面容器,自适应布局,仅支持表单项 Label 顶对齐。

弹窗
弹窗尺寸:
- 在未达到弹窗最大尺寸(弹窗最大宽高<页面宽高的 80%)时,弾窗尺寸由内容决定,弹窗内容距离弹窗边距 20px。
- 滚动条出现在弹窗上,不要出现在整个页面上
弹窗位置:
- 页面居中。
操作按钮位置:
- 固定在弹窗底部,整体居左,主按钮在左侧。


总 结
我之前也在小公司待过,能充分了解小公司的现状,别说 UI 了,有的直接是连前端都不要,管你什么 UI,时间长了就麻木了,想进步却找不到方向,所以希望这个规范能帮助到你,让你在 UI 规范方面有所提升。
因为只是我们总结的一套规范,其实还有很多不足,你完全可以在这个基础上,根据自己的需求,进行修改和优化,比如你觉得表格删除按钮必须是红色,弹窗的按钮放在右下角更合理,不喜欢筛选项有什么展开收缩等等这些你都可以根据自己的需求进行修改,还是那句话,整个系统风格保持统一。
如果大家有自己团队的规范,也可以评论区发出来共享一下大家相互学习一下。

来源:juejin.cn/post/7521013717439315994
图片标签用 img 还是 picture?很多人彻底弄混了!
在网页开发中,图片处理是每个前端开发者都会遇到的基础任务。面对 <img> 和 <picture> 这两个标签,很多人存在误解:要么认为它们是互相替代的关系,要么在不合适的场景下使用了复杂的解决方案。今天,我们来彻底理清这两个标签的真正用途。
<img> 标签
<img> 是 HTML 中最基础且强大的图片标签,但它远比很多人想象的要智能。
基本语法:
<img src="image.jpg" alt="图片描述">
核心属性:
src:图片路径(必需)alt:替代文本(无障碍必需)srcset:提供多分辨率图片源sizes:定义图片显示尺寸loading:懒加载控制
<img> 的响应式能力被低估了
很多人认为 <img> 不具备响应式能力,这是错误的认知:
<img
src="image-800w.jpg"
srcset="image-320w.jpg 320w,
image-480w.jpg 480w,
image-800w.jpg 800w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
33vw"
alt="响应式图片示例"
>
这种写法的优势:
- 浏览器自动选择最适合当前屏幕分辨率的图片
- 根据视口大小动态调整加载的图片尺寸
- 代码简洁,性能优秀
<picture> 标签
<picture> 不是为了替代 <img>,而是为了解决 <img> 无法处理的特定场景。
<picture> 解决的三大核心问题
1. 艺术指导(Art Direction)
在不同设备上显示不同构图或裁剪的图片:
<picture>
<!-- 桌面端:宽屏全景 -->
<source media="(min-width: 1200px)" srcset="hero-desktop.jpg">
<!-- 平板端:适中裁剪 -->
<source media="(min-width: 768px)" srcset="hero-tablet.jpg">
<!-- 移动端:竖版特写 -->
<img src="hero-mobile.jpg" alt="产品展示">
</picture>
2. 现代格式降级
优先使用高效格式,同时兼容老旧浏览器:
<picture>
<source type="image/avif" srcset="image.avif">
<source type="image/webp" srcset="image.webp">
<img src="image.jpg" alt="格式优化示例">
</picture>
3. 复杂条件组合
同时考虑屏幕尺寸和图片格式:
<picture>
<!-- 大屏 + AVIF -->
<source media="(min-width: 1200px)" type="image/avif" srcset="large.avif">
<!-- 大屏 + WebP -->
<source media="(min-width: 1200px)" type="image/webp" srcset="large.webp">
<!-- 大屏降级 -->
<source media="(min-width: 1200px)" srcset="large.jpg">
<!-- 移动端方案 -->
<img src="small.jpg" alt="复杂条件图片">
</picture>
关键区别与选择指南
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 同一图片,不同分辨率 | <img> + srcset + sizes | 代码简洁,浏览器自动优化 |
| 不同构图或裁剪 | <picture> | 艺术指导必需 |
| 现代格式兼容 | <picture> | 格式降级必需 |
| 简单静态图片 | <img> | 无需复杂功能 |
| 兼容老旧浏览器 | <img> | 最广泛支持 |
常见误区纠正
误区一:<picture> 用于响应式图片
- 事实:
<img>配合srcset和sizes已经能处理大多数响应式需求 - 真相:
<picture>主要用于艺术指导和格式降级
误区二:<picture> 更现代,应该优先使用
- 事实: 在不需要艺术指导或格式降级的场景下,
<img>是更好的选择 - 真相: 合适的工具用在合适的场景才是最佳实践
误区三:响应式图片一定要用 <picture>
- 事实: 很多响应式场景用
<img>+srcset更合适 - 真相: 评估需求,选择最简单的解决方案
场景分析
应该使用 <img> 的场景
网站Logo:
<img src="logo.svg" alt="公司Logo" width="120" height="60">
用户头像:
<img
src="avatar.jpg"
srcset="avatar.jpg 1x, avatar@2x.jpg 2x"
alt="用户头像"
width="80"
height="80"
>
文章配图:
<img
src="article-image.jpg"
srcset="article-image-600w.jpg 600w,
article-image-1200w.jpg 1200w"
sizes="(max-width: 768px) 100vw, 600px"
alt="文章插图"
loading="lazy"
>
应该使用 <picture> 的场景
英雄横幅(不同裁剪):
<picture>
<source media="(min-width: 1024px)" srcset="hero-wide.jpg">
<source media="(min-width: 768px)" srcset="hero-square.jpg">
<img src="hero-mobile.jpg" alt="产品横幅" loading="eager">
</picture>
产品展示(格式优化):
<picture>
<source type="image/avif" srcset="product.avif">
<source type="image/webp" srcset="product.webp">
<img src="product.jpg" alt="产品详情" loading="lazy">
</picture>
最佳实践
1. 始终遵循的规则
<!-- 正确:始终提供 alt 属性 -->
<img src="photo.jpg" alt="描述文本">
<!-- 错误:缺少 alt 属性 -->
<img src="photo.jpg">
<!-- 装饰性图片使用空 alt -->
<img src="decoration.jpg" alt="">
2. 性能优化策略
<!-- 优先加载关键图片 -->
<img src="hero.jpg" alt="重要图片" loading="eager" fetchpriority="high">
<!-- 非关键图片延迟加载 -->
<img src="content-image.jpg" alt="内容图片" loading="lazy">
<!-- 指定尺寸避免布局偏移 -->
<img src="product.jpg" alt="商品" width="400" height="300">
3. 现代图片格式策略
<picture>
<!-- 优先使用AVIF,压缩率最高 -->
<source type="image/avif" srcset="image.avif">
<!-- 其次WebP,广泛支持 -->
<source type="image/webp" srcset="image.webp">
<!-- 最终回退到JPEG -->
<img src="image.jpg" alt="现代格式示例">
</picture>
总结
<img> 和 <picture> 不是竞争关系,而是互补的工具:
<img>:处理大多数日常图片需求,特别是分辨率适配<picture>:解决特定复杂场景,如艺术指导和格式降级
核心建议:
- 从最简单的
<img>开始,只在必要时升级到<picture> - 充分利用
<img>的srcset和sizes属性 - 为关键图片使用
<picture>进行格式优化 - 始终考虑性能和用户体验
掌握这两个标签的正确用法,你就能在各种场景下都做出最合适的技术选择,既保证用户体验,又避免过度工程化。
希望这篇指南能帮助你彻底理解这两个重要的HTML标签!
本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《SpringBoot+Vue3 整合 SSE 实现实时消息推送》
《SpringBoot 动态菜单权限系统设计的企业级解决方案》
来源:juejin.cn/post/7577298871005036578
javascript新进展你关注了吗:TC39 东京会议带来五大新特性
上周,JavaScript 语言的掌舵者们齐聚东京。在 Sony Interactive Entertainment 的主持下,Ecma 国际 TC39 委员会召开了第 104 次全体会议,一系列围绕 迭代器(Iterator) 和 Promise 的提案取得了重要进展。这些新特性将让 JavaScript 开发者写出更简洁、更函数式的代码。
让我们一起来看看这些即将改变你编码方式的新特性。
科普:TC39 与提案流程
在深入了解新特性之前,我们先来快速了解一下它们背后的组织和流程。
TC39 是什么?
TC39 (Technical Committee 39) 是 Ecma International 旗下的技术委员会,专门负责 ECMAScript 标准(即 JavaScript 的规范)的制定和维护。其成员由主流浏览器厂商(如 Google, Mozilla, Apple, Microsoft)以及其他对 Web 技术有影响力的科技公司和专家组成。
提案如何成为标准?
一个新特性从提出到最终进入标准,通常需要经历 5 个阶段(The TC39 Process):
- Stage 0 (Strawman - 稻草人): 任何想法或建议,用于开启讨论。
- Stage 1 (Proposal - 提案): 确定问题和解决方案,展示潜在的 API 形式。
- Stage 2 (Draft - 草稿): 具体的语法和语义描述,此时特性已基本定型。
- Stage 3 (Candidate - 候选): 规范文本已完成,需要浏览器厂商的实现反馈和用户测试。
- Stage 4 (Finished - 完成): 至少有两个独立的浏览器实现并通过了测试,准备纳入下一版 ECMAScript 标准。
大概需要多久?
这个过程没有固定的时间表,取决于提案的复杂度和争议程度:
- 简单特性:可能在 1-2 年内走完流程。
- 复杂特性:可能需要在 Stage 2 或 Stage 3 停留数年(例如 Temporal 提案)。
通常来说,一旦提案进入 Stage 3,就意味着它极有可能在不久的将来成为标准的一部分,各大浏览器也会开始逐步支持。
1. Iterator Sequencing(迭代器序列化)— Stage 3
提案地址: proposal-iterator-sequencing
解决什么问题?
你是否曾经需要把多个迭代器"串联"起来,当作一个来用?在以前,你得用生成器函数配合 yield* 来实现:
let lows = Iterator.from([0, 1, 2, 3]);
let highs = Iterator.from([6, 7, 8, 9]);
// 以前的写法,略显繁琐
let combined = function* () {
yield* lows;
yield* highs;
}();
Array.from(combined); // [0, 1, 2, 3, 6, 7, 8, 9]
新写法
现在只需一行:
let combined = Iterator.concat(lows, highs);
Array.from(combined); // [0, 1, 2, 3, 6, 7, 8, 9]
// 还可以在中间插入其他值
let digits = Iterator.concat(lows, [4, 5], highs);
Array.from(digits); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Iterator.concat() 接受多个可迭代对象,惰性地按顺序产出所有值。这不仅代码更简洁,而且因为是惰性求值,处理大数据集时内存效率也更高。
2. Await Dictionary of Promises(Promise 字典等待)— Stage 1
提案地址: proposal-await-dictionary
解决什么问题?
当你需要并发等待多个 Promise 时,Promise.all 返回的是数组,你得靠下标来取值:
const [shape, color, mass] = await Promise.all([
getShape(),
getColor(),
getMass(),
]);
// 顺序错了?很容易出 bug
新写法
使用 Promise.allKeyed(),用对象的键来命名你的结果:
const { shape, color, mass } = await Promise.allKeyed({
shape: getShape(),
color: getColor(),
mass: getMass(),
});
// 再也不用担心顺序问题了!
该提案还支持 Promise.allSettledKeyed(),用于处理可能失败的场景:
const results = await Promise.allSettledKeyed({
shape: getShape(),
color: getColor(),
mass: getMass(),
});
if (results.shape.status === "fulfilled") {
console.log(results.shape.value);
} else {
console.error(results.shape.reason);
}
这个 API 设计有意与 Iterator.zipKeyed() 保持一致,体现了 TC39 在语言设计上的系统性思考。
3. Joint Iteration(联合迭代)— Stage 2.7
提案地址: proposal-joint-iteration
解决什么问题?
Python 开发者对 zip() 函数再熟悉不过了——它能把多个序列"拉链式"地组合在一起。JavaScript 一直缺少这个功能。
新写法
Iterator.zip() 和 Iterator.zipKeyed() 填补了这个空白:
// 位置对应的组合
Array.from(Iterator.zip([
[0, 1, 2],
[3, 4, 5],
]))
// 结果: [[0, 3], [1, 4], [2, 5]]
// 用键名组合
Iterator.zipKeyed({
a: [0, 1, 2],
b: [3, 4, 5, 6],
c: [7, 8, 9],
}).toArray()
// 结果: [
// { a: 0, b: 3, c: 7 },
// { a: 1, b: 4, c: 8 },
// { a: 2, b: 5, c: 9 }
// ]
还支持三种模式来处理长度不等的迭代器:
mode: 'shortest'(默认):最短的迭代器耗尽时停止mode: 'longest':最长的迭代器耗尽时停止,可以指定填充值mode: 'strict':如果长度不等则抛出错误
Iterator.zipKeyed({
a: [0, 1, 2],
b: [3, 4, 5, 6],
c: [7, 8, 9],
}, {
mode: 'longest',
padding: { c: 10 },
}).toArray()
// 结果包含: { a: undefined, b: 6, c: 10 }
4. Iterator Join(迭代器连接)— 新提案
提案地址: proposal-iterator-join
解决什么问题?
Array.prototype.join() 能把数组元素用分隔符连接成字符串。但如果你有一个迭代器呢?现在你得先转成数组:
myIterator.toArray().join(', ') // 先全部加载到内存
新写法
Iterator.prototype.join() 让你直接在迭代器上操作:
Iterator.from(['a', 'b', 'c']).join(', ')
// 结果: "a, b, c"
这对于处理大型或无限序列(只取部分)时特别有用,避免了不必要的内存分配。
5. Typed Array Find Within(类型数组范围查找)
解决什么问题?
现有的 TypedArray.prototype.indexOf() 只能从数组开头或指定位置向后搜索。当你需要在特定范围内查找时,现有 API 不够灵活。
新能力
这个提案为 TypedArray 添加了在指定范围内进行查找的能力,让二进制数据处理更加高效。这对于处理音视频数据、WebGL 缓冲区、以及其他需要精确控制搜索范围的场景非常有用。
迭代器生态的完善
如果你一直关注 TC39 的动态,你会发现这次会议推进的提案有一个共同主题:完善 JavaScript 的迭代器生态。
| 提案 | 功能 | 状态 |
|---|---|---|
| Iterator Helpers | map, filter, take, drop 等 | ✅ Stage 4(已标准化) |
| Iterator Sequencing | concat | Stage 3 |
| Joint Iteration | zip, zipKeyed | Stage 2.7 |
| Iterator Join | join | 新提案 |
| Await Dictionary | Promise.allKeyed | Stage 1 |
参考链接:
来源:juejin.cn/post/7577612585845194761
弃用 html2canvas!快 93 倍的截图神器
在前端开发中,网页截图是个常用功能。从前,html2canvas 是大家的常客,但随着网页越来越复杂,它的性能问题也逐渐暴露,速度慢、占资源,用户体验不尽如人意。
好在,现在有了 SnapDOM,一款性能超棒、还原度超高的截图新秀,能完美替代 html2canvas,让截图不再是麻烦事。

什么是 SnapDOM
SnapDOM 就是一个专门用来给网页元素截图的工具。

它能把 HTML 元素快速又准确地存成各种图片格式,像 SVG、PNG、JPG、WebP 等等,还支持导出为 Canvas 元素。

它最厉害的地方在于,能把网页上的各种复杂元素,比如 CSS 样式、伪元素、Shadow DOM、内嵌字体、背景图片,甚至是动态效果的当前状态,都原原本本地截下来,跟直接看网页没啥两样。
SnapDOM 优势
快得飞起
测试数据显示,在不同场景下,SnapDOM 都把 html2canvas 和 dom-to-image 这俩老前辈远远甩在身后。

尤其在超大元素(4000×2000)截图时,速度是 html2canvas 的 93.31 倍,比 dom-to-image 快了 133.12 倍。这速度,简直就像坐火箭。
还原度超高
SnapDOM 截图出来的效果,跟在网页上看到的一模一样。
各种复杂的 CSS 样式、伪元素、Shadow DOM、内嵌字体、背景图片,还有动态效果的当前状态,都能精准还原。

无论是简单的元素,还是复杂的网页布局,它都能轻松拿捏。
格式任你选
不管你是想要矢量图 SVG,还是常用的 PNG、JPG,或者现代化的 WebP,又或者是需要进一步处理的 Canvas 元素,SnapDOM 都能满足你。

多种格式,任你挑选,适配各种需求。
三、怎么用 SnapDOM
安装
SnapDOM 的安装超简单,有好几种方式:
用 NPM 或 Yarn:在命令行里输
# npm
npm i @zumer/snapdom
# yarn
yarn add @zumer/snapdom
就能装好。
用 CDN 在 HTML 文件里加一行:
<script src="https://unpkg.com/@zumer/snapdom@latest/dist/snapdom.min.js"></script>
直接就能用。
要是项目里用的是 ES Module:
import { snapdom } from '@zumer/snapdom
基础用法示例
一键截图
const card = document.querySelector('.user-card');
const image = await snapdom.toPng(card);
document.body.appendChild(image);
这段代码就是找个元素,然后直接截成 PNG 图片,再把图片加到页面上。简单粗暴,一步到位。
高级配置
const element = document.querySelector('.chart-container');
const capture = await snapdom(element, {
scale: 2,
backgroundColor: '#fff',
embedFonts: true,
compress: true
});
const png = await capture.toPng();
const jpg = await capture.toJpg({ quality: 0.9 });
await capture.download({
format: 'png',
filename: 'chart-report-2024'
});
这儿可以对截图进行各种配置。比如 scale 能调整清晰度,backgroundColor 能设置背景色,embedFonts 可以内嵌字体,compress 能压缩优化。配置好后,还能把截图存成不同格式,或者直接下载到本地。
和其他库比咋样
和 html2canvas、dom-to-image 比起来,SnapDOM 的优势很明显:
| 特性 | SnapDOM | html2canvas | dom-to-image |
|---|---|---|---|
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
| 准确度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 文件大小 | 极小 | 较大 | 中等 |
| 依赖 | 无 | 无 | 无 |
| SVG 支持 | ✅ | ❌ | ✅ |
| Shadow DOM 支持 | ✅ | ❌ | ❌ |
| 维护状态 | 活跃 | 活跃 | 停滞 |
五、用的时候注意点
用 SnapDOM 时,有几点得注意:
跨域资源
要是截图里有外部图片等跨域资源,得确保这些资源支持 CORS,不然截不出来。
iframe 限制
SnapDOM 不能截 iframe 内容,这是浏览器的安全限制,没办法。
Safari 浏览器兼容性
在 Safari 里用 WebP 格式时,会自动变成 PNG。
大型页面截图
截超大页面时,建议分块截,不然可能会内存溢出。
六、SnapDOM 能干啥及代码示例
社交分享
async function shareAchievement() {
const card = document.querySelector('.achievement-card');
const image = await snapdom.toPng(card, { scale: 2 });
navigator.share({
files: [new File([await snapdom.toBlob(card)], 'achievement.png')],
title: '我获得了新成就!'
});
}
报表导出
async function exportReport() {
const reportSection = document.querySelector('.report-section');
await preCache(reportSection);
await snapdom.download(reportSection, {
format: 'png',
scale: 2,
filename: `report-${new Date().toISOString().split('T')[0]}`
});
}
海报导出
async function generatePoster(productData) {
document.querySelector('.poster-title').textContent = productData.name;
document.querySelector('.poster-price').textContent = `¥${productData.price}`;
document.querySelector('.poster-image').src = productData.image;
await new Promise((resolve) => setTimeout(resolve, 100));
const poster = document.querySelector('.poster-container');
const blob = await snapdom.toBlob(poster, { scale: 3 });
return blob;
}
写在最后
SnapDOM 就是这么一款简单、快速、准确,还零依赖的网页截图神器。
无论是社交分享、报表导出、设计保存,还是营销推广,它都能轻松搞定。
而且它是免费开源的,背后还有活跃的社区支持。要是你还在为网页截图的事儿发愁,赶紧试试 SnapDOM 吧。
要是你在用 SnapDOM 的过程中有啥疑问,或者碰上啥问题,可以去下面这些地方找答案:
- 项目地址 :github.com/zumerlab/sn…
- 在线演示 :zumerlab.github.io/snapdom/
- 详细文档 :github.com/zumerlab/sn…
来源:juejin.cn/post/7544287909475090451
弃用 uni-app!Vue3 的原生 App 开发框架来了!
长久以来,"用 Vue 3 写真正的原生 App" 一直是块短板。
uni-app 虽然"一套代码多端运行",但性能瓶颈、厂商锁仓、原生能力羸弱的问题常被开发者诟病。
整个 Vue 生态始终缺少一个能与 React Native 并肩的"真·原生"跨平台方案
直到 NativeScript-Vue 3 的横空出世,并被 尤雨溪 亲自点赞。

为什么是时候说 goodbye 了?
| uni-app 现状 | 开发者痛点 |
|---|---|
| 渲染层基于 WebView 或弱原生混合 | 启动慢、掉帧、长列表卡顿 |
自定义原生 SDK 需写大量 renderjs / plus 桥接 | 维护成本高,升级易断裂 |
| 锁定 DCloud 生态 | 工程化、Vite、Pinia 等新工具跟进慢 |
| Vue 3 支持姗姗来迟,Composition API 兼容碎裂 | 类型推断、生态插件处处踩坑 |
"我们只是想要一个 Vue 语法 + 真原生渲染 + 社区插件开箱即用 的解决方案。"
—— 这,正是 NativeScript-Vue 给出的答案。
尤雨溪推特背书
2025-10-08,Evan You 转发 NativeScript 官方推文:
"Try Vite + NativeScript-Vue today —
HMR,native APIs,live reload."

配图是一段 <script setup> + TypeScript 的实战 Demo,意味着:
- 真正的 Vue 3 语法(
Composition API) - Vite 秒级热重载
- 直接调用 iOS / Android 原生 API
获创始人的公开推荐,无疑给社区打了一剂强心针。
NativeScript-Vue 是什么?
一句话:Vue 的自定义渲染器 + NativeScript 原生引擎

- 运行时 没有 WebView,JS 在
V8 / JavaScriptCore中执行 <template>标签 → 原生UILabel/android.widget.TextView- 支持 NPM、CocoaPods、Maven/Gradle 全部原生依赖
- 与 React Native 同级别的性能,却拥有 Vue 完整开发体验
5 分钟极速上手
1. 环境配置(一次过)
# Node ≥ 18
npm i -g nativescript
ns doctor # 按提示安装 JDK / Android Studio / Xcode
# 全部绿灯即可
2. 创建项目
ns create myApp \
--template @nativescript-vue/template-blank-vue3@latest
cd myApp
模板已集成 Vite + Vue3 + TS + ESLint
3. 运行 & 调试
# 真机 / 模拟器随你选
ns run ios
ns run android
保存文件 → 毫秒级 HMR,console.log 直接输出到终端。
4. 目录速览
myApp/
├─ app/
│ ├─ components/ // 单文件 .vue
│ ├─ app.ts // createApp()
│ └─ stores/ // Pinia 状态库
├─ App_Resources/
└─ vite.config.ts // 已配置 nativescript-vue-vite-plugin
5. 打包上线
ns build android --release # 生成 .aab / .apk
ns build ios --release # 生成 .ipa
签名、渠道、自动版本号——标准原生流程,CI 友好。
Vue 3 生态插件兼容性一览
| 插件 | 是否可用 | 说明 |
|---|---|---|
| Pinia | ✅ | 零改动,app.use(createPinia()) |
| VueUse | ⚠️ | 仅无 DOM 的 Utilities 可用 |
| vue-i18n 9.x | ✅ | 实测正常 |
| Vue Router | ❌ | 官方推荐用 NativeScript 帧导航 → $navigateTo(Page) |
| Vuetify / Element Plus | ❌ | 依赖 CSS & DOM,无法渲染 |
检测小技巧:
npm i xxx
grep -r "document\|window\|HTMLElement" node_modules/xxx || echo "大概率安全"
调试神器:Vue DevTools 支持
NativeScript-Vue 3 已提供 官方 DevTools 插件
组件树、Props、Events、Pinia状态 实时查看- 沿用桌面端调试习惯,无需额外学习成本
👉 配置指南:https://nativescript-vue.org/docs/essentials/vue-devtools
插件生态 & 原生能力
- 700+
NativeScript官方插件
ns plugin add @nativescript/camera | bluetooth | sqlite... - iOS/Android SDK 直接引入
CocoaPods/Maven一行配置即可:
// 调用原生 CoreBluetooth
import { CBCentralManager } from '@nativescript/core'
- 自定义 View & 动画
注册即可在<template>使用,与 React Native 造组件体验一致。
结语:这一次,Vue 开发者不再低人一等
React Native 有 Facebook 撑腰,Flutter 有 Google 背书,
现在 Vue 3 也有了自己的 真·原生跨平台答案 —— NativeScript-Vue。
它让 Vue 语法第一次 完整、无损、高性能 地跑在 iOS & Android 上,
并获得 尤雨溪 公开点赞与 Vite 官方生态加持。
弃用 uni-app,拥抱 NativeScript-Vue,
让 性能、原生能力、工程化 三者兼得,
用你最爱的 .vue 文件,写最硬核的移动应用!
🔖 一键直达资源
来源:juejin.cn/post/7560510073950011435
前端实时推送(券商行业必看) & WebSocket 原理解析
一、历史背景 + 时间轴
网页一旦需要 “实时” ,麻烦就开始了:数据在不断变化,用户却只能等下一次刷新;
- 刷新解决不了的延迟,用短轮询凑数,又被无数空请求反噬;
- 再加长轮询,试图把“有了新数据再说”变成一种伪推送,却仍困在请求—响应的笼子里。
- 开发者于是继续前探:让连接不再频繁重建,尝试分块直输,把事件像水一样持续送达,于是有了更顺滑的 Streaming 与标准化的 SSE。
直到某一刻,我们不再满足于“更聪明的单向”,而是迈向真正的“同时说话与倾听”——WebSocket 把通信从一次次请求,变成一条持久而通透的通道。此后,
- HTTP/2、HTTP/3 与 QUIC 又在底层为效率和时延开了绿灯,甚至提供了可选可靠与无序传输的更多可能。
接下来,我们就沿着这条主线,层层展开:它们各自解决了什么、在哪些场景最合拍、又如何在你的系统里形成清晰的选型边界。

网页一旦需要 “实时” ,麻烦就开始了:数据在不断变化,用户却只能等下一次刷新;
- 刷新解决不了的延迟,用短轮询凑数,又被无数空请求反噬;
- 再加长轮询,试图把“有了新数据再说”变成一种伪推送,却仍困在请求—响应的笼子里。
- 开发者于是继续前探:让连接不再频繁重建,尝试分块直输,把事件像水一样持续送达,于是有了更顺滑的 Streaming 与标准化的 SSE。
直到某一刻,我们不再满足于“更聪明的单向”,而是迈向真正的“同时说话与倾听”——WebSocket 把通信从一次次请求,变成一条持久而通透的通道。此后,
- HTTP/2、HTTP/3 与 QUIC 又在底层为效率和时延开了绿灯,甚至提供了可选可靠与无序传输的更多可能。
接下来,我们就沿着这条主线,层层展开:它们各自解决了什么、在哪些场景最合拍、又如何在你的系统里形成清晰的选型边界。

01|从整页刷新出发:减少浪费的一条链路
这一块是为了解决“整页刷新导致的高延迟与带宽浪费”,逐级细化与优化。
这一块是为了解决“整页刷新导致的高延迟与带宽浪费”,逐级细化与优化。
a. 早期网页:整页刷新
- 背景与问题:每次更新都整页请求,体验割裂、带宽浪费、延迟高。
- 直接影响:促使前端与服务端思考“只取变化”。
- 背景与问题:每次更新都整页请求,体验割裂、带宽浪费、延迟高。
- 直接影响:促使前端与服务端思考“只取变化”。
b. 短轮询(Short Polling)为解决整页刷新的低效
- 解决:改为“隔一段时间拉一次”,显著减少整页重载带来的浪费。
- 局限:高频请求带来大量空响应与服务器开销。
- 承接改进:为减少空转,演进到长轮询;同时催生更流式的思路(Streaming/SSE)。
- 解决:改为“隔一段时间拉一次”,显著减少整页重载带来的浪费。
- 局限:高频请求带来大量空响应与服务器开销。
- 承接改进:为减少空转,演进到长轮询;同时催生更流式的思路(Streaming/SSE)。
c. 长轮询(Comet/挂起请求)为减少短轮询的空转
- 解决:请求挂起,服务器有新数据才返回,接近“伪推送”,显著降低空转。
- 局限:本质仍是请求-响应;连接频繁重建;难做真正双向。
- 承接改进:
- 单向推送更稳:SSE 标准化单向事件流。
- 若要真双向与二进制:交给 WebSocket(见独立块)。
- 解决:请求挂起,服务器有新数据才返回,接近“伪推送”,显著降低空转。
- 局限:本质仍是请求-响应;连接频繁重建;难做真正双向。
- 承接改进:
- 单向推送更稳:SSE 标准化单向事件流。
- 若要真双向与二进制:交给 WebSocket(见独立块)。
d. HTTP Streaming(分块传输/持续输出)为进一步降低重连与延迟
- 解决:保持连接,分块持续输出,适合连续文本/事件流,重连更少、延迟更低。
- 局限:多为单向,受代理/中间件影响,兼容性不一。
- 承接改进:单向事件由 SSE 标准化;双向场景仍需 WebSocket。
- 解决:保持连接,分块持续输出,适合连续文本/事件流,重连更少、延迟更低。
- 局限:多为单向,受代理/中间件影响,兼容性不一。
- 承接改进:单向事件由 SSE 标准化;双向场景仍需 WebSocket。
e. SSE(Server-Sent Events)单向推送的标准化终点
- 解决:以标准事件流语义提供单向推送,浏览器原生支持,资源占用低。
- 适配范围:通知、进度、日志/监控等文本或事件流。
- 位置关系:在“只需单向推送”的场景中,SSE 是这一链条的稳定落点,而非过渡技术。
- 解决:以标准事件流语义提供单向推送,浏览器原生支持,资源占用低。
- 适配范围:通知、进度、日志/监控等文本或事件流。
- 位置关系:在“只需单向推送”的场景中,SSE 是这一链条的稳定落点,而非过渡技术。
02|范式跃迁:WebSocket(独立大块)
这不是前面链条的“又一改良”,而是从请求-响应转向全双工持久连接的范式变化。
这不是前面链条的“又一改良”,而是从请求-响应转向全双工持久连接的范式变化。
WebSocket(全双工、持久、低开销)
- 解决:真正的双向实时通信,降低握手与头部开销,支持文本与二进制,端到端延迟低。
- 典型场景:聊天、协同编辑、在线游戏、行情推送、IoT。
- 与上一链条的关系:
- 在“需要双向实时”的主战场,实质上取代了短轮询/长轮询等过渡方案。
- 与 SSE 并存:若只有单向通知/事件流,SSE 更简单更省资源;若需要双向或二进制,WebSocket 更合适。
- 运维关注:连接状态管理、容量与反压、企业代理/负载均衡兼容。
- 解决:真正的双向实时通信,降低握手与头部开销,支持文本与二进制,端到端延迟低。
- 典型场景:聊天、协同编辑、在线游戏、行情推送、IoT。
- 与上一链条的关系:
- 在“需要双向实时”的主战场,实质上取代了短轮询/长轮询等过渡方案。
- 与 SSE 并存:若只有单向通知/事件流,SSE 更简单更省资源;若需要双向或二进制,WebSocket 更合适。
- 运维关注:连接状态管理、容量与反压、企业代理/负载均衡兼容。
03|底座升级与新选项:HTTP/2·HTTP/3·QUIC 家族
这部分不是替代前两块,而是提供更高效的承载与更灵活的传输语义。
这部分不是替代前两块,而是提供更高效的承载与更灵活的传输语义。
WS over H2/H3
- 价值:与同域请求复用连接、更好穿透与效率、更低握手成本。
- 作用:让 WebSocket 的部署与网络效率更优。
- 价值:与同域请求复用连接、更好穿透与效率、更低握手成本。
- 作用:让 WebSocket 的部署与网络效率更优。
WebTransport(基于 QUIC)
- 价值:可选可靠与有序/无序、更低延迟,适合实时媒体、游戏、定制协议。
- 关系:不是取代 WebSocket/SSE 的通吃方案,而是当你需要“更细粒度的可靠性与顺序控制”时的新工具。
- 价值:可选可靠与有序/无序、更低延迟,适合实时媒体、游戏、定制协议。
- 关系:不是取代 WebSocket/SSE 的通吃方案,而是当你需要“更细粒度的可靠性与顺序控制”时的新工具。
二、速查表
实时推送的目标是“低延迟、双向或单向地把数据从服务端送到客户端”。主流技术选型包括:

实时推送的目标是“低延迟、双向或单向地把数据从服务端送到客户端”。主流技术选型包括:

三、WebSocket 核心定义(重要)
WebSocket 是 HTML5 推出的一种全双工(Full-Duplex)、持久化(Persistent)的网络通信协议, 基于TCP协议构建,允许客户端(浏览器)与服务器之间建立一条长期稳定的连接通道,实现「服务器主动向客户端推送数据」和「客户端实时向服务器发送数据」的双向通信,无需频繁建立/断开连接。
其核心特点可概括为:
- 全双工:通信双方可同时发送/接收数据(区别于HTTP的「请求-响应」单向通信);
- 持久连接:连接建立后长期保持,避免HTTP每次通信都需重新握手的开销;
- 轻量协议:数据帧头部信息简洁(仅2-14字节),传输效率远高于HTTP;
- 协议标识:客户端发起连接时使用ws://(非加密)或wss://(加密,基于TLS,类似HTTPS)作为协议前缀。
WebSocket 是 HTML5 推出的一种全双工(Full-Duplex)、持久化(Persistent)的网络通信协议, 基于TCP协议构建,允许客户端(浏览器)与服务器之间建立一条长期稳定的连接通道,实现「服务器主动向客户端推送数据」和「客户端实时向服务器发送数据」的双向通信,无需频繁建立/断开连接。
其核心特点可概括为:
- 全双工:通信双方可同时发送/接收数据(区别于HTTP的「请求-响应」单向通信);
- 持久连接:连接建立后长期保持,避免HTTP每次通信都需重新握手的开销;
- 轻量协议:数据帧头部信息简洁(仅2-14字节),传输效率远高于HTTP;
- 协议标识:客户端发起连接时使用ws://(非加密)或wss://(加密,基于TLS,类似HTTPS)作为协议前缀。
从零开始的完整流程
下面是一条你在前端真实会走的链路:创建连接 → HTTP 握手与协议切换 → 进入 WebSocket 双向通信 → 启动心跳检测 → 发现异常并重连 → 重连成功后的补偿 → 服务端跨域放行 → 正常/异常关闭。
下面是一条你在前端真实会走的链路:创建连接 → HTTP 握手与协议切换 → 进入 WebSocket 双向通信 → 启动心跳检测 → 发现异常并重连 → 重连成功后的补偿 → 服务端跨域放行 → 正常/异常关闭。
1) 创建连接(入口)
2) HTTP 握手与协议切换(从“请求”到“长连”)
客户端(浏览器) 创建 WebSocket 实例时,会发起一个特殊的 HTTP - GET,核心目的是 「请求将协议从HTTP升级为WebSocket」。服务端验证通过后返回 101,双方切换到 WebSocket 帧通信。
WebSocket 帧是双向通信中的最小传输结构,携带 “ 数据类型 、 是否为消息的最后一段 、 负载长度 、 掩码/密钥 、 实际数据 ” 。消息可以被拆成多帧连续发送,也可以一个帧就送完。

客户端(浏览器) 创建 WebSocket 实例时,会发起一个特殊的 HTTP - GET,核心目的是 「请求将协议从HTTP升级为WebSocket」。服务端验证通过后返回 101,双方切换到 WebSocket 帧通信。
WebSocket 帧是双向通信中的最小传输结构,携带 “ 数据类型 、 是否为消息的最后一段 、 负载长度 、 掩码/密钥 、 实际数据 ” 。消息可以被拆成多帧连续发送,也可以一个帧就送完。

客户端发起「协议升级请求」(HTTP - GET 请求)
请求头中关键字段(面试高频考点):
GET /ws-endpoint HTTP/1.1:请求方法为 GET,路径为服务器的 WebSocket 端点(如/ws);Host:example.com:服务器域名;Upgrade:websocket:核心字段,告知服务器「要升级协议为 WebSocket」;Connection:Upgrade:配合 Upgrade,表示「这是一个协议升级请求」;Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jzQ==:客户端生成的随机字符串(Base64 编码,长度 16 字节),用于服务器验证(防止恶意连接);Sec-WebSocket-Version:13:WebSocket 协议版本(当前主流为 13,需服务器支持);Sec-WebSocket-Origin:https://example.com:客户端所在域名(用于服务器跨域验证)。
请求头中关键字段(面试高频考点):
GET /ws-endpoint HTTP/1.1:请求方法为GET,路径为服务器的 WebSocket 端点(如/ws);Host:example.com:服务器域名;Upgrade:websocket:核心字段,告知服务器「要升级协议为 WebSocket」;Connection:Upgrade:配合 Upgrade,表示「这是一个协议升级请求」;Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jzQ==:客户端生成的随机字符串(Base64 编码,长度 16 字节),用于服务器验证(防止恶意连接);Sec-WebSocket-Version:13:WebSocket 协议版本(当前主流为 13,需服务器支持);Sec-WebSocket-Origin:https://example.com:客户端所在域名(用于服务器跨域验证)。
服务器响应「协议升级成功」(HTTP - 101状态码)
服务器收到请求后,若支持 WebSocket 协议且验证通过(如 Sec-WebSocket-Key 验证、跨域验证) ,会返回HTTP - 101(SwitchingProtocols)状态码,表示「同意协议升级」。
响应头中关键字段 (面试高频考点):
HTTP/1.1 101 Switching Protocols:101 状态码是协议切换的标志;Upgrade: websocket:确认升级为WebSocket 协议;Connection:Upgrade:确认连接用于协议升级;Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=:服务器对Sec-WebSocket-Key 的处理结果(核心验证逻辑):- 服务器将客户端发送的 Sec-WebSocket-Key 与固定字符串
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接; - 对拼接后的字符串进行
SHA-1 哈希计算; - 将哈希结果转为 Base64 编码,即为 Sec-WebSocket-Accept 的值;
- 客户端会验证该值是否正确,若不正确则拒绝建立连接(防止伪造响应)。
服务器收到请求后,若支持 WebSocket 协议且验证通过(如 Sec-WebSocket-Key 验证、跨域验证) ,会返回HTTP - 101(SwitchingProtocols)状态码,表示「同意协议升级」。
响应头中关键字段 (面试高频考点):
HTTP/1.1 101 Switching Protocols:101 状态码是协议切换的标志;Upgrade: websocket:确认升级为WebSocket 协议;Connection:Upgrade:确认连接用于协议升级;Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=:服务器对Sec-WebSocket-Key 的处理结果(核心验证逻辑):- 服务器将客户端发送的 Sec-WebSocket-Key 与固定字符串
258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接; - 对拼接后的字符串进行
SHA-1哈希计算; - 将哈希结果转为 Base64 编码,即为 Sec-WebSocket-Accept 的值;
- 客户端会验证该值是否正确,若不正确则拒绝建立连接(防止伪造响应)。
- 服务器将客户端发送的 Sec-WebSocket-Key 与固定字符串
3) 进入通信阶段(双向数据 + 基础发送)
握手通过,onopen 会触发。此时做两件事:
- 发送初始化数据(如身份、订阅主题)
- 启动心跳(下一步会讲)
通信注意:
onmessage 既可能是字符串,也可能是二进制(Blob/ArrayBuffer)bufferedAmount 可用来做背压控制(积压太大时暂停继续 send)
握手通过,onopen 会触发。此时做两件事:
- 发送初始化数据(如身份、订阅主题)
- 启动心跳(下一步会讲)
通信注意:
onmessage既可能是字符串,也可能是二进制(Blob/ArrayBuffer)bufferedAmount可用来做背压控制(积压太大时暂停继续 send)
4) 启动心跳(让连接“活着”且可感知)
持久连接会遭遇 Wi‑Fi 抖动、防火墙清理等问题。心跳=周期性发 ping,超时未收到 pong 就判死链。
推荐参数(可按业务调优):
HEARTBEAT_INTERVAL ≈ 30sHEARTBEAT_TIMEOUT ≈ 10s

实操要点:
- 启动前先清理旧定时器,避免重复
- 收到 pong 立即清除超时定时器
- onclose/onerror 必须停止心跳
持久连接会遭遇 Wi‑Fi 抖动、防火墙清理等问题。心跳=周期性发 ping,超时未收到 pong 就判死链。
推荐参数(可按业务调优):
HEARTBEAT_INTERVAL ≈ 30sHEARTBEAT_TIMEOUT ≈ 10s

实操要点:
- 启动前先清理旧定时器,避免重复
- 收到 pong 立即清除超时定时器
- onclose/onerror 必须停止心跳
5) 异常→重连(恢复连接但不过载)
一旦 onerror、onclose(code ≠ 1000) 或心跳判定超时,进入重连。
目标是:能恢复、不过载、可被用户停止。
策略三件套:
- 指数退避:1s → 2s → 4s … 最多 30s
- 次数上限:如 10 次(达到即停)
- 可控停止:提供“停止重连”或页面关闭时停

补偿机制:
- 在断开前缓存“待发送”数据(例如未发出的聊天消息)
- 重连成功后按序补发,确保业务连续性
一旦 onerror、onclose(code ≠ 1000) 或心跳判定超时,进入重连。
目标是:能恢复、不过载、可被用户停止。
策略三件套:
- 指数退避:1s → 2s → 4s … 最多 30s
- 次数上限:如 10 次(达到即停)
- 可控停止:提供“停止重连”或页面关闭时停

补偿机制:
- 在断开前缓存“待发送”数据(例如未发出的聊天消息)
- 重连成功后按序补发,确保业务连续性
6) 服务器放行跨域(握手能否过关的关键)
虽然 WebSocket 原生“支持跨域”,但握手是 HTTP,服务端需要对 Sec-WebSocket-Origin 做白名单校验。
否则会 403 或直接关闭。
- Node.js(ws)
- 读取
req.headers['sec-websocket-origin'] - 不在
allowedOrigins 列表:关闭 1008 “Cross-origin access denied”
- Spring Boot
registry.addHandler(...).setAllowedOrigins("https://a.com", "https://b.com")- 需要时
.withSockJS()提供降级

虽然 WebSocket 原生“支持跨域”,但握手是 HTTP,服务端需要对 Sec-WebSocket-Origin 做白名单校验。
否则会 403 或直接关闭。
- Node.js(ws)
- 读取
req.headers['sec-websocket-origin'] - 不在
allowedOrigins列表:关闭1008“Cross-origin access denied”
- 读取
- Spring Boot
registry.addHandler(...).setAllowedOrigins("https://a.com", "https://b.com")- 需要时
.withSockJS()提供降级

7) 正常关闭与资源清理(善始善终)
- 用户离开页面或主动退出:ws.close(1000, '用户主动退出')
- onclose 中停止心跳与重连,清空定时器与队列,避免泄漏
- 记录关闭原因码:1000 正常、1006 常见于异常/心跳超时
- 用户离开页面或主动退出:ws.close(1000, '用户主动退出')
- onclose 中停止心跳与重连,清空定时器与队列,避免泄漏
- 记录关闭原因码:1000 正常、1006 常见于异常/心跳超时
四、面试题
面试关注点通常围绕“协议对比、连接管理、消息语义、可靠性与扩展性、安全与运维成本”。
面试关注点通常围绕“协议对比、连接管理、消息语义、可靠性与扩展性、安全与运维成本”。
1、WebSocket 与 SSE 的差异与使用场景,HTTP轮询呢?
WebSocket(全双工,二进制/文本)
- 适用:即时聊天、协作编辑、游戏状态同步、行情推送、需要客户端→服务端主动上行的实时交互。
- 优点:低延迟、头开销小、全双工、支持二进制、可自定义子协议。
- 注意:需处理心跳、重连、背压、鉴权与扇出扩展;代理/LB 要正确透传 Upgrade 和超时设置。
Server-Sent Events(SSE,单向 server→client)
- 适用:通知流、日志流、监控事件、流式生成文本(如增量输出)、只需服务端下行的实时更新。
- 优点:浏览器原生 EventSource、文本流、自动重连、支持 Last-Event-ID 断点续传;实现简单。
- 注意:单向、仅文本(可 base64 二进制)、连接数限制与代理超时需要关注;移动端网络切换要做容错。
HTTP 轮询/长轮询(兼容兜底)
- 适用:对实时性要求不高的小流量场景、受网络环境或企业防火墙限制无法使用 WS/SSE 时的兜底。
- 优点:最易落地、与缓存/鉴权/监控体系天然兼容;对中间设备最友好。
- 注意:延迟更高、资源利用低;高频轮询会带来成本与限流压力。
✅ 重点
- WebSocket 通过 HTTP/1.1 Upgrade → 101 完成切换,此后是帧协议的全双工通道;Keep-Alive 仅是复用 TCP,不改变 HTTP 的请求-响应语义。
- 选型规则:需要双向实时交互选 WebSocket;单向事件流选 SSE;受限或低实时性场景用轮询作兜底。
WebSocket(全双工,二进制/文本)
- 适用:即时聊天、协作编辑、游戏状态同步、行情推送、需要客户端→服务端主动上行的实时交互。
- 优点:低延迟、头开销小、全双工、支持二进制、可自定义子协议。
- 注意:需处理心跳、重连、背压、鉴权与扇出扩展;代理/LB 要正确透传 Upgrade 和超时设置。
Server-Sent Events(SSE,单向 server→client)
- 适用:通知流、日志流、监控事件、流式生成文本(如增量输出)、只需服务端下行的实时更新。
- 优点:浏览器原生 EventSource、文本流、自动重连、支持 Last-Event-ID 断点续传;实现简单。
- 注意:单向、仅文本(可 base64 二进制)、连接数限制与代理超时需要关注;移动端网络切换要做容错。
HTTP 轮询/长轮询(兼容兜底)
- 适用:对实时性要求不高的小流量场景、受网络环境或企业防火墙限制无法使用 WS/SSE 时的兜底。
- 优点:最易落地、与缓存/鉴权/监控体系天然兼容;对中间设备最友好。
- 注意:延迟更高、资源利用低;高频轮询会带来成本与限流压力。
✅ 重点
- WebSocket 通过 HTTP/1.1 Upgrade → 101 完成切换,此后是帧协议的全双工通道;Keep-Alive 仅是复用 TCP,不改变 HTTP 的请求-响应语义。
- 选型规则:需要双向实时交互选 WebSocket;单向事件流选 SSE;受限或低实时性场景用轮询作兜底。
2、如何设计一个可水平扩展的实时推送系统?
在可水平扩展的实时推送系统中,WebSocket 连接会分布在多台网关节点上。
核心挑战是如何在连接与消息不在同一台机器时,仍能把消息快速路由到正确的连接。可行的范式是
- 网关层负责连接
- 管道层负责路由与发布订阅
- 存储层负责状态与回放
在可水平扩展的实时推送系统中,WebSocket 连接会分布在多台网关节点上。
核心挑战是如何在连接与消息不在同一台机器时,仍能把消息快速路由到正确的连接。可行的范式是
- 网关层负责连接
- 管道层负责路由与发布订阅
- 存储层负责状态与回放
🔌 网关层(负责连接)
- 终止 TLS/WS,维持心跳与速率限制,保持无状态实例。
- 建立本地索引:connectionId → 订阅集合,userId → connectionIds。
- 将连接元数据上报共享存储:connectionId、userId、nodeId、订阅、最近心跳。
- 仅订阅“与自己有关的分片”:按 userId/topic 的哈希分片从管道层拉取,减少无关扇出。
- 写通道背压与优先级:控制帧/关键消息优先,低优先级可丢尾或抽样。
- 终止 TLS/WS,维持心跳与速率限制,保持无状态实例。
- 建立本地索引:connectionId → 订阅集合,userId → connectionIds。
- 将连接元数据上报共享存储:connectionId、userId、nodeId、订阅、最近心跳。
- 仅订阅“与自己有关的分片”:按 userId/topic 的哈希分片从管道层拉取,减少无关扇出。
- 写通道背压与优先级:控制帧/关键消息优先,低优先级可丢尾或抽样。
🚇 管道层(负责路由与发布订阅)
- 选型具备分片与回放能力的总线(Kafka/Pulsar/NATS/Redis Streams)。
- 分片策略:
- 点推:按 userId/connectionId 一致性哈希到分区,保证单用户局部有序。
- 主题推送:按 topic 分区,网关本地再做订阅过滤与扇出。
- 路由方式:
- 生产者只需写对的分片;总线按分区把消息送到订阅该分片的网关。
- 广播/超大房间采用“分层扇出”:先到分片,再由各网关本地扇出,必要时加中间扇出代理。
- 去重与幂等:messageId 或 (topic, partition, offset) 作为幂等键,网关/客户端各自维护短期去重集合。
- 选型具备分片与回放能力的总线(Kafka/Pulsar/NATS/Redis Streams)。
- 分片策略:
- 点推:按 userId/connectionId 一致性哈希到分区,保证单用户局部有序。
- 主题推送:按 topic 分区,网关本地再做订阅过滤与扇出。
- 路由方式:
- 生产者只需写对的分片;总线按分区把消息送到订阅该分片的网关。
- 广播/超大房间采用“分层扇出”:先到分片,再由各网关本地扇出,必要时加中间扇出代理。
- 去重与幂等:messageId 或 (topic, partition, offset) 作为幂等键,网关/客户端各自维护短期去重集合。
🗃️ 存储层(负责状态与回放)
- 会话与订阅状态:使用 Redis Cluster 或 KV 服务存 userId→connectionIds、connectionId→nodeId、订阅清单、心跳时间。
- 游标与回放:在总线层保留 offset;客户端重连携带 resumeToken,网关据此恢复订阅并按 offset 增量补发。
- 一致性与更新:订阅变更写事件流,相关网关消费后刷新本地索引;用版本号/逻辑时钟避免乱序覆盖。
- 会话与订阅状态:使用 Redis Cluster 或 KV 服务存 userId→connectionIds、connectionId→nodeId、订阅清单、心跳时间。
- 游标与回放:在总线层保留 offset;客户端重连携带 resumeToken,网关据此恢复订阅并按 offset 增量补发。
- 一致性与更新:订阅变更写事件流,相关网关消费后刷新本地索引;用版本号/逻辑时钟避免乱序覆盖。
3、如何保证消息不丢、不重、按序?
- 不丢: 消息先落到能持久化、带副本确认的总线里(像“写盘且多副本到位才算成功”),写失败就退避重试;消费侧是“先送到用户手里或进可靠下行队列,再更新位点”,断线后拿着
resumeToken+offset 从保留的历史里把漏掉的补回来。 - 不重: 每条消息都有一个不会撞车的“指纹”(messageId 或 topic-partition-offset);网关用一小块内存做近端去重,只有第一次真正写入才前进位点,重复的一概忽略;客户端也按同一指纹做幂等处理,避免业务状态被二次改动。
- 按序: 把需要有序的对象(userId/roomId)哈希到同一分区,借用分区内天然顺序;同一个键在网关里串行发送、同队列内重试,不跨分区不并行穿插,这样即使重试和补发也不会把顺序打乱。
- 不丢: 消息先落到能持久化、带副本确认的总线里(像“写盘且多副本到位才算成功”),写失败就退避重试;消费侧是“先送到用户手里或进可靠下行队列,再更新位点”,断线后拿着
resumeToken+offset从保留的历史里把漏掉的补回来。 - 不重: 每条消息都有一个不会撞车的“指纹”(messageId 或 topic-partition-offset);网关用一小块内存做近端去重,只有第一次真正写入才前进位点,重复的一概忽略;客户端也按同一指纹做幂等处理,避免业务状态被二次改动。
- 按序: 把需要有序的对象(userId/roomId)哈希到同一分区,借用分区内天然顺序;同一个键在网关里串行发送、同队列内重试,不跨分区不并行穿插,这样即使重试和补发也不会把顺序打乱。
4、心跳如何设计?超时如何判定?
这里的心跳,目标是 “保活、探测、可平滑重连”。
- 不失联: 用应用层
ping/pong,客户端主发、服务端回;- 间隔 20–60s,加±10%抖动
- 未知网络时取 20–30s,确保小于最短 NAT 空闲回收。
- 怎么判死: 别一跳不回就拍板。记录
lastSeen,允许 2–3 次心跳未达或 now-lastSeen 超过 2–3 个周期再判断;进入“Suspect”时降级写入,仍有业务流量即立刻恢复。 - 断了咋办:重连走指数退避并带抖动,携带
resumeToken/offset 补发;移动端切网优先复用会话,失败再重建。监控 RTT/丢包与 Suspect 比例,自动调心跳与阈值。
这里的心跳,目标是 “保活、探测、可平滑重连”。
- 不失联: 用应用层
ping/pong,客户端主发、服务端回;- 间隔 20–60s,加±10%抖动
- 未知网络时取 20–30s,确保小于最短 NAT 空闲回收。
- 怎么判死: 别一跳不回就拍板。记录
lastSeen,允许 2–3 次心跳未达或now-lastSeen超过 2–3 个周期再判断;进入“Suspect”时降级写入,仍有业务流量即立刻恢复。 - 断了咋办:重连走指数退避并带抖动,携带
resumeToken/offset补发;移动端切网优先复用会话,失败再重建。监控 RTT/丢包与 Suspect 比例,自动调心跳与阈值。
5、如何在 Nginx/Envoy 反向代理后稳定运行 WebSocket?
核心思路:让代理“不瞎操心”、连接“常被看见”、后端“可续上”。
- 代理设置:开启 WebSocket 升级;调大超时,禁用缓冲与压缩;保持 TCP keepalive,HTTP/2 用 CONNECT(H2/WebSocket)。
- 心跳与保活:应用层 ping/pong 20–30s(±10%抖动),保证小于代理/NAT空闲回收;大连接数用轻量负载均衡(hash by userId/roomId)避免跨节点迁移。
- 断线与重连:客户端指数退避+抖动,带会话 token/offset 续传;后端幂等去重,重放不重不丢。
- 运维与观测:开代理层指标(升级成功率、idle 关闭数、5xx)、RTT/丢包与重连率,异常时自适应缩短心跳或放宽超时。
6、如何做鉴权与权限隔离?
握手前校验 JWT;Subprotocol 指定租户/版本;频道级 ACL;避免敏感数据从客户端请求非授权频道。
- 握手前校验 JWT:在
HTTP Upgrade前验证 iss/aud/exp/签名并解析 tenant_id/user_id/scopes,避免建立长连后再踢。 - Subprotocol 指定租户/版本:用 Sec-WebSocket-Protocol 携带 tenant 和策略版本做白名单匹配,确保连接上下文一致。
- 频道级 ACL:频道强制以租户前缀命名,每次 subscribe/publish 依据 RBAC+scope 前缀(到资源或前缀)做服务端授权。
- 避免敏感数据越权:仅按服务器维护的“已授权订阅集合”下发数据,忽略客户端自报筛选请求并拒绝未授权频道。
7、如何评估性能与成本?
- 每连接内存占用: 用基线压测量出 MB/1k 连接的实际占用,结合目标并发外推单机上限并监控 GC/碎片。
- 每秒消息数(fanout×频率): 用发布频率×平均扇出得到总吞吐,核算带宽与发送队列容量,识别热点频道放大效应。
- 尾延迟 P95/P99: 持续跟踪端到端延迟长尾并关联队列深度与CPU/GC事件,确保在SLA红线下仍稳定。
- 压测考虑广播峰值与重连风暴: 分别模拟大扇出瞬时广播与大量短时间内握手重连,验证背压、限速和握手路径的韧性。
8、遇到“重连风暴”怎么处理?
- 抖动退避(指数退避 + 随机抖动): 客户端按指数退避间隔重试并加入随机抖动,避免同相位同时重连造成尖峰。
- 分批恢复: 将连接恢复按固定批次/时间片发放(如每 100ms 开放 N 个),把尖峰摊平到更长窗口。
- 服务端限流与排队: 在握手与认证路径设置并发/速率上限与队列,超限直接返回可重试错误或延迟令牌。
- 灰度放量: 按租户/区域/版本逐步提升允许重连比例,结合健康度与错误率自动调节放量速度。
9、前端如何封装一个健壮的 WebSocket 客户端?
- 状态机(
CONNECTING / OPEN / CLOSING / CLOSED ): 用有限状态机驱动所有事件与迁移,单航道控制避免并发重连与回调竞态。 - 心跳/重连策略: 按固定心跳探活与半开检测,重连采用指数退避叠加随机抖动并设上限与冷却期。
- 消息序列化: 统一 envelope(type/id/ts/payload),默认 JSON,性能敏感时切 Protobuf/MessagePack 并保持向后兼容。
- 离线缓存与去重: 未连通时将待发入队、跨刷新用 IndexedDB,按 seq/uuid 去重并用 last-seq 做断点续传。
- 可观测日志: 记录连接尝试/关闭码/重连次数/心跳RTT/队列长度等指标与事件,便于快速定位长尾与故障。
作者:HiStewie
来源:juejin.cn/post/7572539461478907947
CONNECTING / OPEN / CLOSING / CLOSED ): 用有限状态机驱动所有事件与迁移,单航道控制避免并发重连与回调竞态。来源:juejin.cn/post/7572539461478907947
🚣【附源码】牺牲两天摸鱼时间,我做了款大屏
📝项目背景
最近时间比较闲,摸鱼的时间越来越多了,人一闲下来就会想做点什么。说干就干,立马行动。
在刷了半小时pdd之后我买了张ui图,并根据这个ui做了一个大屏。
最终效果如下:

📦项目地址
这里附上项目地址,如果你觉得不错的话,帮我点一个小小的start。
🌐在线预览
这个预览地址是
vercel的地址,如果你没有挂梯子的话,会访问不了。访问不了的话,建议直接本地跑项目。
🛠️ 技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue | 3.5.13 | 前端框架 |
| TypeScript | 5.7.2 | 类型安全 |
| Vite | 6.1.0 | 构建工具 |
| ECharts | 5.6.0 | 数据可视化 |
| Sass | 1.89.2 | CSS预处理器 |
| Vue3-scroll-seamless | 1.0.6 | 无缝滚动 |
| autofit.js | 3.2.8 | 适配不同分辨率的屏幕 |
| vue3-odometer | 0.1.3 | 数字翻牌效果 |
项目主要是vue3+echarts的组合,整个项目主要都是一些图表的应用。下面会介绍一些模块的实现思路。
💻一些模块的实现
🗺️中间地图
第一步先获取地图行政区的geo数据,以我这个项目为例,我需要获取山东省的地图数据。
打开dataV,找到数据可视化学院,在里面找到需要的行政区,把它的geojson下载下来。

下载下来的数据长这样

这就是我们需要的geojson数据了。
拿到数据之后,就需要将其渲染出来。
这里我用的是echarts的地图。因为这个项目的地图,基本没有交互,就纯纯的数据展示。使用echarts来做的 效果会比,cesium那些更好。
注册地图
import * as echarts from 'echarts'
import sdData from '@/assets/data/山东省'
echarts.registerMap('sd', sdData as any)
先将前面下载来的数据geojson数据注册到echarts里面,并配置echarts的geo选项
{
geo: [
// 最外围发光边界
{
map: 'sd',
aspectScale: 0.85,
layoutCenter: ['50%', '50%'], //地图位置
layoutSize: '100%',
z: 12,
emphasis: {
disabled: true
},
itemStyle: {
normal: {
borderColor: 'rgb(180, 137, 81)',
borderWidth: 8,
shadowColor: 'rgba(218, 163, 88, 0.4)',
shadowBlur: 20
}
}
},
],
}

这时候渲染出来的地图是纯色的,什么都没有 也没有立体。
因为这个geo是一个平面的地图,想要立体效果,可以通过堆叠地图,并且设置位移的方式实现
比如我这边就通过这种方式去实现

通过叠加多个图层,并且每个图层的layoutCenter都不同
最终就可以实现这种看起来很立体的二维地图

具体实现代码可以访问我的github仓库看,这里只介绍一下大致思路
🔢底部的数字字体和轮播

可以看到我底部的数字字体很特别,这不是图片,这是一种电子屏风格的数字字体。

我们在网上找一个类似的字体,将其下载下来,并用css的@font-face将其引入。然后在需要的地方用font-family使用即可。

除了这个,这里还有一个数字的轮播效果,我是用vue3-odometer实现的。

为什么用这个库呢,主要是使用方便,不用配置一堆乱七八糟的。
📊其他图表
其它图表就比较常规了,这里就不做过多介绍,具体可以看源码的实现。
🔚 结尾
这个大屏虽然只有一个页面,但是做的时候,相关的图表配置调整还是挺多的。后续打算开发一个mini版的后台管理,用来管理大屏数据,并且这个后台管理的接口用node开发,用来当作node后端的练习。
来源:juejin.cn/post/7521986967103143972
40岁老前端2025年上半年都学了什么?
前端学习记录第5波,每半年一次。对前四次学习内容感兴趣的可以去我的掘金专栏“每周学习记录”进行了解。
第1周 12.30-1.5
本周学习了一个新的CSS媒体查询prefers-reduced-transparency,如果用户在系统层面选择了降低或不使用半透明,这个媒体查询就能够匹配,此特性与用户体验密切相关的。

更多内容参见我撰写的这篇文章:一个新的CSS媒体查询prefers-reduced-transparency —— http://www.zhangxinxu.com/wordpress/?…
第2周 1.6-1.12
这周新学习了一个名为Broadcast Channel的API,可以实现一种全新的广播式的跨页面通信。
过去的postMessage通信适合点对点,但是广播式的就比较麻烦。
而使用BroadcastChannel就会简单很多。
这里有个演示页面:http://www.zhangxinxu.com/study/20250…
左侧点击按钮发送消息,右侧两个内嵌的iframe页面就能接收到。

此API的兼容性还是很不错的:

更多内容可以参阅此文:“Broadcast Channel API简介,可实现Web页面广播通信” —— http://www.zhangxinxu.com/wordpress/?…
第3周 1.13-1.19
这周学习的是SVG半圆弧语法,因为有个需求是实现下图所示的图形效果,其中几段圆弧的长度占比每个人是不一样的,因此,需要手写SVG路径。

圆弧的SVG指令是A,语法如下:
M x1 y1 A rx ry x-axis-rotation large-arc-flag sweep-flag x2 y2
看起来很复杂,其实深究下来还好:

详见这篇文章:“如何手搓SVG半圆弧,手把手教程” - http://www.zhangxinxu.com/wordpress/?…
第4周-第5周 1.20-2.2
春节假期,学什么学,high起来。
第6周 2.3-2.9
本周学习Array数组新增的with等方法,这些方法在数组处理的同时均不会改变原数组内容,这在Vue、React等开发场景中颇为受用。
例如,在过去,想要不改变原数组改变数组项,需要先复制一下数组:

现在有了with方法,一步到位:

类似的方法还有toReversed()、toSorted()和toSpliced()。
更新内容参见这篇文章:“JS Array数组新的with方法,你知道作用吗?” - http://www.zhangxinxu.com/wordpress/?…
第7周 2.10-2.16
本周学习了两个前端新特性,一个JS的,一个是CSS的。
1. Set新增方法
JS Set新支持了intersection, union, difference等方法,可以实现类似交集,合集,差集的数据处理,也支持isDisjointFrom()是否相交,isSubsetOf()是否被包含,isSupersetOf()是否包含的判断。
详见此文:“JS Set新支持了intersection, union, difference等方法” - http://www.zhangxinxu.com/wordpress/?…

2. font-size-adjust属性
CSS font-size-adjust属性,可以基于当前字形的高宽自动调整字号大小,以便各种字体的字形表现一致,其解决的是一个比较细节的应用场景。
例如,16px的苹方和楷体,虽然字号设置一致,但最终的图形表现楷体的字形大小明显小了一圈:

此时,我们可以使用font-size-adjust进行微调,使细节完美。
p { font-size-adjust: 0.545;}
此时的中英文排版效果就会是这样:

更新细节知识参见我的这篇文章:“不要搞混了,不是text而是CSS font-size-adjust属性” - http://www.zhangxinxu.com/wordpress/?…
第8周 2.17-2.23
本周学习的是HTML permission元素和Permissions API。
这两个都是与Web浏览器的权限申请相关的。
在Web开发的时候,我们会经常用到权限申请,比方说摄像头,访问相册,是否允许通知,又或者地理位置信息等。

但是,如果用户不小心点击了“拒绝”,那么用户就永远没法使用这个权限,这其实是有问题的,于是就有了元素,权限按钮直接暴露在网页中,直接让用户点击就好了。

但是,根据我后来的测试,Chrome浏览器放弃了对元素的支持,因此,此特性大家无需关注。
那Permissions API又是干嘛用的呢?
在过去,不同类型的权限申请会使用各自专门的API去进行,这就会导致开始使用的学习和使用成本比较高。
既然都是权限申请,且系统出现的提示UI都近似,何必来个大统一呢?在这种背景下,Permissions API被提出来了。
所有的权限申请全都使用一个统一的API名称入口,使用的方法是Permissions.query()。

完整的介绍可以参见我撰写的这篇文章:“HTML permission元素和Permissions API简介” - http://www.zhangxinxu.com/wordpress/?…
第9周 2.24-3.2
CSS offset-path属性其实在8年前就介绍过了,参见:“使用CSS offset-path让元素沿着不规则路径运动” - http://www.zhangxinxu.com/wordpress/?…
不过那个时候的offset-path属性只支持不规则路径,也就是path()函数,很多CSS关键字,还有基本形状是不支持的。
终于,盼星星盼月亮。
从Safari 18开始,CSS offset-path属性所有现代浏览器全面支持了。

因此,很多各类炫酷的路径动画效果就能轻松实现了。例如下图的蚂蚁转圈圈动画:

详见我撰写的此文:“终于等到了,CSS offset-path全浏览器全支持” - http://www.zhangxinxu.com/wordpress/?…
第10周 3.3-3.9
CSS @supports规则新增两个特性判断,分别是font-tech()和font-format()函数。
1. font-tech()
font-tech()函数可以检查浏览器是否支持用于布局和渲染的指定字体技术。
例如,下面这段CSS代码可以判断浏览器是否支持COLRv1字体(一种彩色字体技术)技术。
@supports font-tech(color-COLRv1) {}
2. font-format()
font-format()这个比较好理解,是检测浏览器是否支持指定的字体格式的。
@supports font-format(woff2) { /* 浏览器支持woff2字体 */ }
不过这两个特性都不实用。
font-tech()对于中文场景就是鸡肋特性,因为中文字体是不会使用这类技术的,成本太高。
font-format()函数的问题在于出现得太晚了。例如woff2字体的检测,这个所有现代浏览器都已经支持了,还有检测的必要吗,没了,没有意义了。
不过基于衍生的特性还是有应用场景的,具体参见此文:“CSS supports规则又新增font-tech,font-format判断” - http://www.zhangxinxu.com/wordpress/?…
第11周 3.10-3.16
本周学习了一种更好的文字隐藏的方法,那就是使用::first-line伪元素,CSS世界这本书有介绍。
::first-line伪元素可以在不改变元素color上下文的情况下变色。
可以让按钮隐藏文字的时候,里面的图标依然保持和原本的文字颜色一致。

详见这篇文章:“一种更好的文字隐藏的方法-::first-line伪元素” - http://www.zhangxinxu.com/wordpress/?…
第12周 3.17-3.23
本周学习了下attachInternals方法,这个方法很有意思,给任意自定义元素使用,可以让普通元素也有原生表单控件元素一样的特性。
比如浏览器自带的验证提示:

比如说提交的时候的FormData或者查询字符串:

有兴趣的同学可以访问“研究下attachInternals方法,可让普通元素有表单特性”这篇文章继续了解 - http://www.zhangxinxu.com/wordpress/?…
第13周 3.24-3.30
本周学习了一个新支持的HTML属性,名为blocking 属性。
它主要用于控制资源加载时对渲染的阻塞行为。
blocking 属性允许开发者对资源加载的优先级和时机进行精细控制,从而影响页面的渲染流程。浏览器在解析 HTML 文档时,会根据 blocking 属性的值来决定是否等待资源加载完成后再继续渲染页面,这对于优化页面性能和提升用户体验至关重要。
blocking 属性目前支持的HTML元素包括
使用示意:

更多内容参见我撰写的这篇文章:“光速了解script style link元素新增的blocking属性” - http://www.zhangxinxu.com/wordpress/?…
第14周 3.31-4.6
本周学习了JS EditContext API。
EditContext API 是 Microsoft Edge 浏览器提供的一个 Web API,它允许开发者在网页中处理文本输入事件,以便在原生输入事件(如 keydown、keypress 和 input)之外,实现更高级的文本编辑功能。

详见我撰写的这篇文章:“JS EditContext API 简介” - http://www.zhangxinxu.com/wordpress/?…
第15周 4.7-4.13
本周学习一个DOM新特性,名为caretPositionFromPoint API。
caretPositionFromPoint可以基于当前的光标位置,返回光标所对应元素的位置信息,在之前,此特性使用的是非标准的caretRangeFromPoint方法实现的。
和elementsFromPoint()方法的区别在于,前者返回节点及其偏移、尺寸等信息,而后者返回元素。
比方说有一段
元素文字描述信息,点击这段描述的某个文字,caretPositionFromPoint()方法可以返回精确的文本节点以及点击位置的字符偏移值,而elementsFromPoint()方法只能返回当前
元素。
不过此方法的应用场景比较小众,例如点击分词断句这种,大家了解下即可。

详见我撰写的这篇文章:“DOM新特性之caretPositionFromPoint API” - http://www.zhangxinxu.com/wordpress/?…
第16周 4.14-4.20
本周学习的是getHTML(), setHTMLUnsafe()和parseHTMLUnsafe()这三个方法,有点类似于可读写的innerHTML属性,区别在于setHTMLUnsafe()似乎对Shadow DOM元素的设置更加友好。
parseHTMLUnsafe则是个document全局方法,用来解析HTML字符串的。
这几个方法几乎是同一时间支持的,如下截图所示:

具体参见我写的这篇文章:介绍两个DOM新方法setHTMLUnsafe和getHTML - http://www.zhangxinxu.com/wordpress/?…
第17周 4.21-4.27
光速了解HTML shadowrootmode属性的作用。
shadowRoot的mode是个只读属性,可以指定其模式——打开或关闭。
这定义了影子根的内部功能是否可以从JavaScript访问。
当影子根的模式为“关闭”时,影子根的实现内部无法从JavaScript访问且不可更改,就像元素的实现内部不能从JavaScript访问或不可更改一样。
属性值是使用传递给Element.attachShadow()的对象的options.mode属性设置的,或者在声明性创建影子根时使用<template>元素的shadowrootmode属性设置的。

类似的属性总共有4个:
- shadowRootClonable 标示可复制状态
- shadowRootDelegatesFocus 标示聚焦委托状态(子元素点击,ShadowRoot获得焦点)
- shadowRootMode 标示开放状态
- shadowRootSerializable 标示序列化状态
这些属性都是与Web Components开发相关的,我看还有人用在SSR中,可以遍历组件元素内部的信息。
详见我整理的这篇文章:“光速了解HTML shadowrootmode等属性的作用” - http://www.zhangxinxu.com/wordpress/?…
第18周 4.28-5.4
最近已经在正式项目中使用scale, rotate, translate属性了(注意,没有skew属性),很赞,毕竟这几个特性已经支持4年多了。

告别transform属性,直接使用scale、rotate和translate属性,是 CSS 发展的一个新趋势。它们不仅语法简洁、易于使用,而且能让我们更方便地对元素的变形效果进行独立控制,提高代码的可维护性和性能。在未来的前端开发中,我们应该积极拥抱这些新特性,让我们的 CSS 代码更加简洁、高效。
详见此文:告别transform,是时候直接使用scale, rotate属性啦 - http://www.zhangxinxu.com/wordpress/?…
第19周 5.5-5.11
本周学习CSS animation-composition属性,该属性可以让动画效果累加。
演示页面地址见这里:不同值混合后的动画效果demo - http://www.zhangxinxu.com/study/20250…
支持replace、add和accumulate这三个值,其中后面两个值很容易混淆,add直接就是属性值累加,accumulate则是属性的计算值累加。
animation-composition特别适合用在transform定位的同时需要transform动画的场景中。

详见我写的这篇文章:“CSS animation-composition可以让动画效果累加” - http://www.zhangxinxu.com/wordpress/?…
第20周 5.12-5.18
这是这周才知道的一个知识,那就是输入框的value值也能直接返回数值类型。
已知输入框元素:
<input id="number" min="1" max="10" type="number" />
平常我们获取输入框的值都是使用 number.value 获取的,但是这个属性的返回值是个字符串。
其实现在浏览器支持直接返回数值类型的,使用numer.valueAsNumber即可。
类似的还有valueAsDate属性,适合时间类型的输入框。
详见此文:你知道吗,输入框的value值也能直接返回数值类型 - http://www.zhangxinxu.com/wordpress/?…
第21周 5.19-5.25
Chrome 133实现了attr()函数所有CSS属性都支持,这个特性可就厉害了。

举个例子,有一个链接地址是图片,那么,无需img元素介入,纯CSS就能让这个地址以图片的方式显示出来。
代码示意:
<a href="example.jpg">图片?</a>
[href]::before {
content: '';
display: block;
width: 150px; height: 200px;
background: image-set(attr(href));
background-size: cover;
}
此时,图片显示的效果就可以实现了。
关于attr()函数更多内容,可以参加此文:“震惊,有生之年居然看到CSS attr()全属性支持” - http://www.zhangxinxu.com/wordpress/?…
第22周 5.26-6.1
本周学习的是JS PageSwapEvent事件,乍一看,以为是页面切换触发的事件。
后来细细一研究,并不是,这个事件发生在,如果页面设置了页面级别的Page Transition过渡效果(URL跳转的页面之间也能平滑过渡,参见下面GIF图),在页面离开的时候,会触发。

主要是方便开发者精确控制页面间的动画细节用的。
这么一看,此事件算是比较小众的,平常开发使用机会并不大,了解下即可。
详见此文:“JS PageSwap PageReveal事件干嘛用的?” -http://www.zhangxinxu.com/wordpress/?…
第23周 6.2-6.8
本周学习的是CSS新的伪元素::scroll-button,其通过特定语法,可以给滚动容器创建自定义的滚动定位按钮,例如:
ul::scroll-button(left) { content: "◄"; }
ul::scroll-button(right){content:"►";}
配合Scroll Snap,可以纯CSS实现slider效果:

更多内容容我本周继续深入学习。
第24周 6.9-6.15
本周学习::scroll-marker伪元素。
上周学习的::scroll-button()伪元素函数可以给Carousel 效果增加左右切换按钮

这周学习的::scroll-marker则可以给Scroll Snap交互的列表元素创建索引切换按钮,以便定位到具体的元素上,效果参见:

::scroll-marker需要配合scroll-marker-group属性和::scroll-marker-group伪元素一起使用才能生效。
另外,同时被浏览器支持的还有::column伪元素,如果Snap效果是使用columns布局实现的时候使用。
更多内容,可以访问这篇文章:“CSS ::scroll-button ::scroll-marker伪元素又是干嘛用的?” - http://www.zhangxinxu.com/wordpress/?…
第25周 6.16-6.22
本周学习了text-wrap的两个子属性和两个新值。
text-wrap:pretty声明和text-wrap:wrap是一样的,区别在于text-wrap:pretty更注重排版,而非性能,也就是wrap的算法速度更快。
text-wrap:stable可以让编辑内容前面的行内容保持稳定,而不会整个文本内容发生排版变化。
两个子属性,一个是text-wrap-mode,还有一个是text-wrap-style。

更多相关内容参见这篇文章:text-wrap进化:支持两子属性和pretty stable新值 - http://www.zhangxinxu.com/wordpress/?…
第26周 6.23-6.29
本周学习clip-path shape()函数。
之前的路径剪裁使用的是path()函数,但是会有尺寸无法自适应的问题。
因为SVG路径里面的数值都是固定的像素px大小,在SVG元素中,这些大小与SVG外部尺寸关联,不会有问题,但是,放在CSS图像中,那就问题大了。
例如,Font Awesome小图标SVG基本尺寸都是512*512,其path坐标值都是好几百的值。
但是,CSS小图标的尺寸是20*20,如果应用几百数值的剪裁路径,小图标肯定就有问题,对不对?
要么path坐标等比例缩小,要么CSS小图标尺寸也设成512像素,然后再zoom缩放,但这样实现就很麻烦。
于是,在这个背景下,clip-path的shape()函数应运而生。
.use-shape {
clip-path: shape(from 50% 0%,curve to 0% 50% with 22.38% 0%/0% 22.38%,smooth by 50% 50% with 22.38% 50%,smooth by 50% -50% with 50% -22.38%,smooth to 50% 0% with 77.62% 0%,close);
}
支持百分比值,和CSS calc等数学函数,自动和元素尺寸相适应,就很厉害!
对此,我还专门开发了一个CSS clip-path path() to shape()函数转换工具 - http://www.zhangxinxu.com/sp/path2sha…

详见我撰写的这篇文章:“CSS小图标剪裁终极解决方案clip-path shape()函数” - http://www.zhangxinxu.com/wordpress/?…
-------------
好,以上就是我这个40岁的老前端上半年学习的内容,下半年我还将继续学习,继续保持对前端的好奇心,欢迎关注,转发,一起进步。
来源:juejin.cn/post/7524548909530005540
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。
老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。
解决方案:三条技术路线,你全踩了一遍
1. 最偷懒:原生 loading="lazy"
一行代码就能跑,浏览器帮你搞定。
<img
src="https://cdn.xxx.com/poster1.jpg"
loading="lazy"
decoding="async"
width="800" height="450"
/>
🔍 关键决策点
loading="lazy"2020 年后现代浏览器全覆盖,IE 全军覆没。- 必须写死
width/height,否则 CLS 会抖成 PPT。
适用场景:内部系统、用户浏览器可控,且图片域名已开启 Accept-Ranges: bytes(支持分段加载)。
2. 最稳妥:scroll 节流 + getBoundingClientRect
老项目里还有 5% 的 IE11 用户,我们只能回到石器时代。
// utils/lazyLoad.js
const lazyImgs = [...document.querySelectorAll('[data-src]')];
let ticking = false;
const loadIfNeeded = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
lazyImgs.forEach((img, idx) => {
const { top } = img.getBoundingClientRect();
if (top < window.innerHeight + 200) { // 提前 200px 预加载
img.src = img.dataset.src;
lazyImgs.splice(idx, 1); // 🔍 及时清理,防止重复计算
}
});
ticking = false;
});
};
window.addEventListener('scroll', loadIfNeeded, { passive: true });
🔍 关键决策点
- 用
requestAnimationFrame把 30 ms 的节流降到 16 ms,肉眼不再掉帧。 - 预加载阈值 200 px,实测 4G 网络滑动不白屏。
缺点:滚动密集时 CPU 占用仍高,列表越长越卡。
3. 最优雅:IntersectionObserver 精准观测
新项目直接上 Vue3 + TypeScript,我们用 IntersectionObserver 做统一调度。
// composables/useLazyLoad.ts
export const useLazyLoad = (selector = '.lazy') => {
onMounted(() => {
const imgs = document.querySelectorAll<HTMLImageElement>(selector);
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
const img = e.target as HTMLImageElement;
img.src = img.dataset.src!;
img.classList.add('fade-in'); // 🔍 加过渡动画
io.unobserve(img); // 观测完即销毁
}
});
},
{ rootMargin: '100px', threshold: 0.01 } // 🔍 提前 100px 触发
);
imgs.forEach((img) => io.observe(img));
});
};
- 浏览器合成线程把「目标元素与视口交叉状态」异步推送到主线程。
- 主线程回调里只做一件事:把
data-src搬到src,然后unobserve。 - 整个滚动期间,零事件监听,CPU 占用 < 1%。
原理剖析:从「事件驱动」到「观测驱动」
| 维度 | scroll + 节流 | IntersectionObserver |
|---|---|---|
| 触发时机 | 高频事件(~30 ms) | 浏览器内部合成帧后回调 |
| 计算量 | 每帧遍历 N 个元素 | 仅通知交叉元素 |
| 线程占用 | 主线程 | 合成线程 → 主线程 |
| 兼容性 | IE9+ | Edge79+(可 polyfill) |
| 代码体积 | 0.5 KB | 0.3 KB(含 polyfill 2 KB) |
一句话总结:把「我每隔 16 ms 问一次」变成「浏览器你告诉我啥时候到」。
应用扩展:把懒加载做成通用指令
在 Vue3 项目里,我们干脆封装成 v-lazy 指令,任何元素都能用。
// directives/lazy.ts
const lazyDirective = {
mounted(el: HTMLImageElement, binding) {
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value; // 🔍 binding.value 就是 data-src
io.disconnect();
}
},
{ rootMargin: '50px 0px' }
);
io.observe(el);
},
};
app.directive('lazy', lazyDirective);
模板里直接写:
<img v-lazy="item.url" :alt="item.title" />
举一反三:三个变体场景思路
- 无限滚动列表
把IntersectionObserver绑在「加载更多」占位节点上,触底即请求下一页,再把新节点继续observe,形成递归观测链。 - 广告曝光统计
广告位 50% 像素可见且持续 1 s 才算一次曝光。设置threshold: 0.5并在回调里用setTimeout延迟 1 s 上报,离开视口时clearTimeout。 - 背景图懒加载
背景图没有src,可以把真实地址塞在style="--bg: url(...)",交叉时把background-image设成var(--bg),同样零回流。
小结
- 浏览器新特性能救命的,就别再卷节流函数了。
- 写死尺寸、加过渡、及时
unobserve,是懒加载不翻车的三件套。 - 把观测器做成指令/组合式函数,后续业务直接零成本接入。
现在你的「企业风采」首屏降到 1.2 s,老板滑得开心,运营继续传 8K 图,世界和平。
来源:juejin.cn/post/7530854092869615635
如果产品经理突然要你做一个像抖音一样流畅的H5
从前端到爆点!抖音级 H5 如何炼成?
在万物互联的时代,H5 页面已成为产品推广的利器。当产品经理丢给你一个“像抖音一样流畅的 H5”任务时,是挑战还是机遇?别慌,今天就带你走进抖音 H5 的前端魔法世界。
一、先看清本质:抖音 H5 为何丝滑?
抖音 H5 之所以让人欲罢不能,核心在于两点:极低的卡顿率和极致的交互反馈。前者靠性能优化,后者靠精心设计的交互逻辑。比如,你刷视频时的流畅下拉、点赞时的爱心飞舞,背后都藏着前端开发的“小心机”。
二、性能优化:让页面飞起来
(一)懒加载与预加载协同作战
懒加载是 H5 性能优化的经典招式,只在用户即将看到某个元素时才加载它。但光靠懒加载还不够,聪明的抖音 H5 还会预加载下一个可能进入视野的元素。以下是一个基于 IntersectionObserver 的懒加载示例:
document.addEventListener('DOMContentLoaded', () => {
const lazyImages = [].slice.call(document.querySelectorAll('img.lazy'));
if ('IntersectionObserver' in window) {
let lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazy Images.forEach((lazyImage) => {
lazyImageObserver.observe(lazyImage);
});
}
});
(二)图片压缩技术大显神威
图片是 H5 的“体重”大户。抖音 H5 常用 WebP 格式,它在保证画质的同时,能将图片体积压缩到 JPEG 的一半。你可以用以下代码轻松实现图片格式转换:
function compressImage(inputImage, quality) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = inputImage.naturalWidth;
canvas.height = inputImage.naturalHeight;
ctx.drawImage(inputImage, 0, 0, canvas.width, canvas.height);
const compressedImage = new Image();
compressedImage.src = canvas.toDataURL('image/webp', quality);
compressedImage.onload = () => {
resolve(compressedImage);
};
});
}
三、交互设计:让用户欲罢不能
(一)微动画营造沉浸感
在点赞、评论等关键操作上,抖音 H5 会加入精巧的微动画。比如点赞时的爱心从手指位置飞出,这其实是一个 CSS 动画加 JavaScript 事件监听的组合拳。以下是一个简易版的点赞动画代码:
@keyframes flyHeart {
0% {
transform: scale(0) translateY(0);
opacity: 0;
}
50% {
transform: scale(1.5) translateY(-10px);
opacity: 1;
}
100% {
transform: scale(1) translateY(-20px);
opacity: 0;
}
}
.heart {
position: fixed;
width: 30px;
height: 30px;
background-image: url('../assets/heart.png');
background-size: contain;
background-repeat: no-repeat;
animation: flyHeart 1s ease-out;
}
document.querySelector('.like-btn').addEventListener('click', function(e) {
const heart = document.createElement('div');
heart.className = 'heart';
heart.style.left = e.clientX + 'px';
heart.style.top = e.clientY + 'px';
document.body.appendChild(heart);
setTimeout(() => {
heart.remove();
}, 1000);
});
(二)触摸事件优化
在移动设备上,触摸事件的响应速度直接影响用户体验。抖音 H5 通过精准控制触摸事件的捕获和冒泡阶段,减少了延迟。以下是一个优化触摸事件的示例:
const touchStartHandler = (e) => {
e.preventDefault(); // 防止页面滚动干扰
// 处理触摸开始逻辑
};
const touchMoveHandler = (e) => {
// 处理触摸移动逻辑
};
const touchEndHandler = (e) => {
// 处理触摸结束逻辑
};
const element = document.querySelector('.scrollable-container');
element.addEventListener('touchstart', touchStartHandler, { passive: false });
element.addEventListener('touchmove', touchMoveHandler, { passive: false });
element.addEventListener('touchend', touchEndHandler);
四、音频处理:让声音为 H5 增色
抖音 H5 的音频体验也很讲究。它会根据用户的操作实时调整音量,甚至在不同视频切换时平滑过渡音频。以下是一个简单的声音控制示例:
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioElement = document.querySelector('audio');
const audioSource = audioContext.createMediaElementSource(audioElement);
const gainNode = audioContext.createGain();
audioSource.connect(gainNode);
gainNode.connect(audioContext.destination);
// 调节音量
function setVolume(level) {
gainNode.gain.value = level;
}
// 音频淡入效果
function fadeInAudio() {
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + 1);
}
// 音频淡出效果
function fadeOutAudio() {
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1);
}
五、跨浏览器兼容:让 H5 无处不在
抖音 H5 能在各种浏览器上保持一致的体验,这离不开前端开发者的兼容性优化。常用的手段包括使用 Autoprefixer 自动生成浏览器前缀、为老浏览器提供 Polyfill 等。以下是一个为 CSS 动画添加前缀的示例:
const autoprefixer = require('autoprefixer');
const postcss = require('postcss');
const css = '.example { animation: slidein 2s; } @keyframes slidein { from { transform: translateX(0); } to { transform: translateX(100px); } }';
postcss([autoprefixer]).process(css).then(result => {
console.log(result.css);
/*
输出:
.example {
animation: slidein 2s;
}
@keyframes slidein {
from {
-webkit-transform: translateX(0);
transform: translateX(0);
}
to {
-webkit-transform: translateX(100px);
transform: translateX(100px);
}
}
*/
});
打造一个像抖音一样的流畅 H5,需要前端开发者在性能优化、交互设计、音频处理和跨浏览器兼容等方面全方位发力。希望这些技术点能为你的 H5 开发之旅提供助力,让你的产品在激烈的市场竞争中脱颖而出!
来源:juejin.cn/post/7522090635908251686
前端部署,又有新花样?
大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。
但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。
常见的选择无非是 Vercel、Netlify 或 GitHub Pages。它们表面上“一键部署”,但细节其实并不轻松:要注册平台账号、要配置域名,还得接受平台的各种限制。国内的一些云服务商(比如阿里云、腾讯云)管控更严格,操作门槛也更高。更让人担心的是,一旦平台宕机,或者因为地区网络问题导致访问不稳定,你的项目可能随时“消失”在用户面前。虽然这种情况不常见,但始终让人心里不踏实。
很多时候,我们只是想快速上线一个小页面,不想被部署流程拖累,有没有更好的方法?
一个更轻的办法
前段时间我发现了一个开源工具 PinMe,主打的就是“极简部署”。

它的使用体验非常直接:
- 不需要服务器
- 不用注册账号
- 在项目目录敲一条命令,就能把项目打包上传到 IPFS 网络
- 很快,你就能拿到一个可访问的地址
实际用起来的感受就是一个字:爽。
整个过程几乎没有繁琐配置,不需要绑定平台账号,也不用担心流量限制或收费。
这让很多场景变得顺手:
- 临时展示一个 demo,不必折腾服务器
- 写了个静态博客,不想搞 CI/CD 流程
- 做了个活动页或 landing page,随时上线就好
以前这些需求可能要纠结“用 GitHub Pages 还是 Vercel”,现在有了 PinMe,直接一键上链就行。
体验一把
接下来看看它在真实场景下的表现:部署流程有多简化?访问速度如何?和传统方案相比有没有优势?
测试项目
为了覆盖不同体量的场景,这次我选了俩类项目来测试:
- 小型项目:fuwari(开源的个人博客项目),打包体积约 4 MB。
- 中大型项目:Soybean Admin(开源的后台管理系统),打包体积约 15 MB。
部署项目
PinMe 提供了两种方式:命令行 和 可视化界面。

这两种方式我们都来试一下。
命令行部署
先全局安装:
npm install -g pinme
然后一条命令上传:
pinme upload <folder/file-path>
比如上传 Soybean Admin,文件大小 15MB:

输入命令之后,等着就可以了:

只用了两分钟,终端返回的 URL 就能直接访问项目的控制页面。还能绑定自己的域名:

点击网站链接就可以看到已经部署好的项目,访问速度还是挺快的:

同样地,上传个人博客也是一样的流程。

部署完成:

可视化部署
不习惯命令行?PinMe 也提供了网页上传,进度条实时显示:

部署完成后会自动进入管理页面:

经过测试,部署速度和命令行几乎一致。
其他功能
历时记录
部署过的网站都能在主页的 History 查看:

历史部署记录:

也可以用命令行:
pinme list
历史部署记录:

删除网站
如果不再需要某个项目,执行以下命令即可:
pinme rm
PinMe 背后的“硬核支撑”
如果只看表层,PinMe 就像一个极简的托管工具。但要理解它为什么能做到“不依赖平台”,还得看看它背后的底层逻辑。
PinMe 的底层依赖 IPFS,这是一个去中心化的分布式文件系统。
要理解它的意义,得先聊聊“去中心化”这个概念。
传统互联网是中心化的:你访问一个网站时,浏览器会通过 DNS 找到某台服务器,然后从这台服务器获取内容。这条链路依赖强烈,一旦 DNS 被劫持、服务器宕机、服务商下线,网站就无法访问。

去中心化的思路完全不同:
- 数据不是放在单一服务器,而是分布在全球节点中
- 访问不依赖“位置”,而是通过内容哈希来检索
- 只要有节点存储这份内容,就能访问到,不怕单点故障
这意味着:
- 更稳定:即使部分节点宕机,内容依然能从其他节点获取。
- 防篡改:文件哪怕改动一个字节,对应的 CID 也会完全不同,从机制上保障了前端资源的完整性和安全性。
- 更自由:不再受制于中心化平台,文件真正由用户自己掌控。
当然,IPFS 地址(哈希)太长,不适合直接记忆和分享。这时候就需要 ENS(Ethereum Name Service)。它和 DNS 类似,但记录存储在以太坊区块链上,不可能被篡改。比如你可以把 myblog.eth 指向某个 IPFS 哈希,别人只要输入 ENS 域名就能访问,不依赖传统 DNS,自然也不会被劫持。

换句话说:
ENS + IPFS = 内容去中心化 + 域名去中心化

前端个人项目瞬间就有了更高的自由度和安全性。
一点初步感受
PinMe 并不是要取代 Vercel 这类成熟平台,但它带来了一种新的选择:更简单、更自由、更去中心化。
如果你只是想快速上线一个小项目,或者对去中心化部署感兴趣,PinMe 值得一试。
- 官网:pinme.eth.limo/
- Github:github.com/glitternetw…
这是一个完全开源的项目,开发团队也会持续更新。如果你在测试过程中有想法或需求,不妨去 GitHub 提个 Issue —— 这不仅能帮助项目成长,也能让它更贴近前端开发的实际使用场景!
来源:juejin.cn/post/7547515500453380136
从“版本号打架”到 30 秒内提醒用户刷新:一个微前端团队的实践
从“版本号打架”到 30 秒内提醒用户刷新:一个微前端团队的实践
1. 背景与痛点
我们团队维护着一个微前端子应用集群,每个子应用都需要同时服务 dev / test / release / online 多套环境。分支策略(master / release / test / dev / hotfix / feature_x.x.x)加上 Jenkins 自动化,让“一天多次发布”成为常态。但真正影响交付效率的并不是发布次数,而是一个顽固的问题:测试同学常年停留在旧版本页面。
我们团队维护着一个微前端子应用集群,每个子应用都需要同时服务 dev / test / release / online 多套环境。分支策略(master / release / test / dev / hotfix / feature_x.x.x)加上 Jenkins 自动化,让“一天多次发布”成为常态。但真正影响交付效率的并不是发布次数,而是一个顽固的问题:测试同学常年停留在旧版本页面。
1.1 真实场景
- 测试在早上打开 dev 页面,下午我们发布了新的组件样式;
- 他们继续在旧页面里回归,反馈的问题我们一眼看出“这是老版本”;
- 群里喊“刷新一下”并不靠谱,于是“无效缺陷 + 反复沟通”成了常态。
更严重的一次事故,是我们在版本检查逻辑里同时使用了 webpack DefinePlugin 与自定义插件,各自调用了一次 getAppVersion()。结果前端控制台打印的是 0.8.3-release-202511210828,而 version.json 里是 0.8.3-release-202511210829。两边只差 1 秒钟,却让线上用户始终被提示刷新,形象地被团队称为“版本号打架”。
- 测试在早上打开 dev 页面,下午我们发布了新的组件样式;
- 他们继续在旧页面里回归,反馈的问题我们一眼看出“这是老版本”;
- 群里喊“刷新一下”并不靠谱,于是“无效缺陷 + 反复沟通”成了常态。
更严重的一次事故,是我们在版本检查逻辑里同时使用了 webpack DefinePlugin 与自定义插件,各自调用了一次 getAppVersion()。结果前端控制台打印的是 0.8.3-release-202511210828,而 version.json 里是 0.8.3-release-202511210829。两边只差 1 秒钟,却让线上用户始终被提示刷新,形象地被团队称为“版本号打架”。
1.2 我们的诉求
- 用户在 30 秒内感知版本更新;
- 弹窗里能看到“当前版本 / 最新版本 / 环境”;
- 支持“立即刷新 / 稍后再说”,不给用户造成中断;
- 方案需兼容现有微前端架构与 CI/CD 流程,不依赖后端改造。
- 用户在 30 秒内感知版本更新;
- 弹窗里能看到“当前版本 / 最新版本 / 环境”;
- 支持“立即刷新 / 稍后再说”,不给用户造成中断;
- 方案需兼容现有微前端架构与 CI/CD 流程,不依赖后端改造。
2. 方案探索与取舍
在动手前,我们列出几种可行方式:
| 方案 | 实现复杂度 | 实时性 | 依赖 | 适配场景 | 关键优缺点 |
|---|---|---|---|---|---|
| 纯前端轮询 version.json | 低 | 中(30s) | 前端 + Nginx | 多环境微前端 | 成本最低;轻微网络开销 |
| Service Worker/PWA | 中 | 较高 | 现代浏览器 | PWA 应用 | 缓存控制好,但改造量大 |
| WebSocket 推送 | 高 | 最高 | 后端服务 | 强实时场景 | 需要额外服务端开发 |
| 后端接口统一管理 | 中 | 中 | 前后端 | 版本集中管理 | 带来跨团队耦合 |
综合团队资源与落地速度,我们选择了 纯前端轮询 + 静态版本文件 的做法,并明确两个原则:
- 版本号唯一,可追溯:
基础版本号-环境-时间戳; - 发布零侵入:Jenkins 仍旧运行
npm run build-xxx,无需新增步骤。
3. 技术方案总览
- 构建阶段生成 version.json:在
vue.config.js 中提前计算版本号,既注入到前端(process.env.APP_VERSION),也写入输出目录的 version.json; - 前端轮询比对:应用启动后每 30 秒请求一次
version.json,禁用缓存并携带时间戳,比较版本号; - 交互提示:复用 Ant Design Vue 的
Modal.confirm,展示当前/最新版本与环境; - 缓存策略:Nginx 对 HTML/
version.json 禁止缓存,对 JS/CSS/图片继续长缓存; - CI/CD 配合:所有环境沿用既有脚本,只是构建产物目录多了一份实时的
version.json。
vue.config.js 中提前计算版本号,既注入到前端(process.env.APP_VERSION),也写入输出目录的 version.json;version.json,禁用缓存并携带时间戳,比较版本号;Modal.confirm,展示当前/最新版本与环境;version.json 禁止缓存,对 JS/CSS/图片继续长缓存;version.json。4. 关键落地细节
4.1 版本号只生成一次(Build-time Deterministic Versioning)
vue.config.js 抽象 buildEnvName、buildVersion,并在 DefinePlugin 与生成 version.json 时复用:
const buildEnvName = getEnvName();
const buildVersion = getAppVersion();
module.exports = {
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
"process.env.APP_VERSION": JSON.stringify(buildVersion),
"process.env.APP_ENV": JSON.stringify(buildEnvName),
}),
],
},
chainWebpack(config) {
config.plugin("generate-version-json").use({
apply(compiler) {
compiler.hooks.done.tap("GenerateVersionJsonPlugin", () => {
fs.writeFileSync(
path.resolve(__dirname, "edu/version.json"),
JSON.stringify(
{
version: buildVersion,
env: buildEnvName,
timestamp: new Date().toISOString(),
publicPath: "/child/edu",
},
null,
2
)
);
});
},
});
},
};
这样即使构建过程持续 5~10 分钟,注入的版本号和静态文件里的版本仍保持一致。这其实是把“构建产物视为不可变工件”的原则落地——保证任何使用该工件的入口看到的元数据都是同一个快照。
4.2 版本检查器(Runtime Polling & Cache Busting)
class VersionChecker {
currentVersion = process.env.APP_VERSION;
publicPath = "/child/edu";
checkInterval = 30 * 1000;
init() {
console.log(`📌 当前前端版本:${this.currentVersion}(${process.env.APP_ENV})`);
this.startChecking();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && !this.hasNotified) {
this.checkForUpdate();
}
});
}
async checkForUpdate() {
const url = `${this.publicPath}/version.json?t=${Date.now()}`;
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return;
const latestInfo = await response.json();
if (latestInfo.version !== this.currentVersion && !this.hasNotified) {
this.hasNotified = true;
this.stopChecking();
this.showUpdateModal(latestInfo.version, latestInfo.env);
}
}
}
class VersionChecker {
currentVersion = process.env.APP_VERSION;
publicPath = "/child/edu";
checkInterval = 30 * 1000;
init() {
console.log(`📌 当前前端版本:${this.currentVersion}(${process.env.APP_ENV})`);
this.startChecking();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && !this.hasNotified) {
this.checkForUpdate();
}
});
}
async checkForUpdate() {
const url = `${this.publicPath}/version.json?t=${Date.now()}`;
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return;
const latestInfo = await response.json();
if (latestInfo.version !== this.currentVersion && !this.hasNotified) {
this.hasNotified = true;
this.stopChecking();
this.showUpdateModal(latestInfo.version, latestInfo.env);
}
}
}
这里有两个容易被忽略的细节:
fetch显式加cache: "no-store",再叠加时间戳参数,防止 CDN / 浏览器任何一层干预;visibilitychange监听,保证窗口重新激活时立即比对,避免用户在后台等了很久才看到弹窗。
入口 main.ts 在应用 mount 之后调用 versionChecker.init(),即可把整个检测链路串起来。
4.3 Nginx 缓存策略(Precise Cache Partition)
location / {
if ($request_filename ~* .html$) {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
location /child/edu {
if ($request_filename ~* .html$) {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
location ~* /child/edu/version.json$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
add_header Surrogate-Control "no-store";
}
location / {
if ($request_filename ~* .html$) {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
location /child/edu {
if ($request_filename ~* .html$) {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
location ~* /child/edu/version.json$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
add_header Surrogate-Control "no-store";
}
这一层的思路是把资源分成两类:需要实时性(HTML、version.json)就 no-store,其余走长缓存。再配合 try_files 兜底 history 路由,微前端子应用的独立部署不会互相影响。
4.4 CI/CD 配置(Zero-touch Pipeline)
| 环境 | 构建命令 | 输出路径 | 说明 |
|---|---|---|---|
| develop | npm run build-develop | /child/edu | 日常开发验证 |
| testing | npm run build-testing | /child/edu | 集成测试 |
| release | npm run build-release | /child/edu | 预发布 |
| production | npm run build-production | /child/edu | 线上 |
所有命令都带 cross-env NODE_OPTIONS=--openssl-legacy-provider,以兼容不同系统的 OpenSSL 版本。更重要的是,这套方案没有“要求运维多做一步”——构建产物天然携带 version.json,任何环境拿到包即可上线。
5. 测试与验证
我们定义了一个完整的回归流程,确保方案不会给测试和上线带来额外负担:
- 首次访问:打开 dev 环境页面,确认控制台打印版本号,Network 里能看到
version.json且响应头无缓存; - 触发新版本:调整任意文案,重新发布,保持旧页面不刷新;
- 轮询验证:30 秒内弹出提示框,展示当前/最新版本和环境;
- 交互路径:
- 点击“立即刷新”:页面强制 reload,新版本生效;
- 点击“稍后刷新”:记录取消动作并重新开启轮询;
- 边界场景:切 tab / 清缓存 / 新设备访问 / 短时间连续发布,均能正确感知最新版本。
6. 注意事项与常见问题
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 没有弹窗 | version.json 404 或版本未变 | 检查部署路径、确认构建是否生成文件 |
| 弹窗后刷新仍旧版本 | 静态资源被缓存 | 核实 Nginx 缓存策略、查看浏览器缓存设置 |
| 构建失败 | cross-env 未安装或权限不足 | 补充依赖、确保 Jenkins 工作目录可写 |
| 持续误报更新 | 构建阶段多次生成版本号 | 在 vue.config.js 顶部缓存 buildVersion 并全局复用 |
7. 落地成效
- 旧页面用户在 30 秒内收到提醒,测试效率显著提升;
- “幽灵弹窗”彻底消失,版本对比逻辑稳定;
- 方案只触碰前端与 Nginx 配置,发布流程无需改造;
- 文档化后,其他子应用无需重复思考,直接复用。
8. 展望
下一步我们计划:
- 封装通用 SDK:抽象版本生成、轮询、弹窗逻辑,支持 Vue CLI / Vite;
- 可视化版本面板:在主应用汇总所有环境的版本和发布时间;
- 差异化策略:针对高优先级版本强制刷新,普通版本允许用户自行选择。
这次实践让我再次意识到:真正的坑往往藏在看似“微不足道”的细节里。当我们把问题和思考写成文档、沉淀成模板,团队就能以更小的代价获得更稳定的交付。如果你也在推进微前端版本同步,欢迎交流、互相借鉴。
来源:juejin.cn/post/7575006095389605897
你还在 for 循环里使用 await?异步循环得这样写
1. 前言
在循环中使用 await,代码看似直观,但运行时要么悄无声息地停止,要么运行速度缓慢,这是为什么呢?
本篇聊聊 JavaScript 中的异步循环问题。
2. 踩坑 1:for 循环里用 await,效率太低
假设要逐个获取用户数据,可能会这样写:
const users = [1, 2, 3];
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
代码虽然能运行,但会顺序执行——必须等 fetchUser(1) 完成,fetchUser(2) 才会开始。若业务要求严格按顺序执行,这样写没问题;但如果请求之间相互独立,这种写法就太浪费时间了。
3. 踩坑 2:map 里直接用 await,拿到的全是 Promise
很多人会在 map() 里用 await,却未处理返回的 Promise,结果踩了坑:
const users = [1, 2, 3];
const results = users.map(async (id) => {
const user = await fetchUser(id);
return user;
});
console.log(results); // 输出 [Promise, Promise, Promise],而非实际用户数据
语法上没问题,但它不会等 Promise resolve。若想让请求并行执行并获取最终结果,需用 Promise.all():
const results = await Promise.all(users.map((id) => fetchUser(id)));
这样所有请求会同时发起,results 中就是真正的用户数据了。
4. 踩坑 3:Promise.all 一错全错
用 Promise.all() 时,只要有一个请求失败,整个操作就会报错:
const results = await Promise.all(
users.map((id) => fetchUser(id)) // 假设 fetchUser(2) 出错
);
如果 fetchUser(2) 返回 404 或网络错误,Promise.all() 会直接 reject,即便其他请求成功,也拿不到任何结果。
5. 更安全的替代方案
5.1. 用 Promise.allSettled(),保留所有结果
使用 Promise.allSettled(),即便部分请求失败,也能拿到所有结果,之后可手动判断成功与否:
const results = await Promise.allSettled(users.map((id) => fetchUser(id)));
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("✅ 用户数据:", result.value);
} else {
console.warn("❌ 错误:", result.reason);
}
});
5.2. 在 map 里加 try/catch,返回兜底值
也可在请求时直接捕获错误,给失败的请求返回默认值:
const results = await Promise.all(
users.map(async (id) => {
try {
return await fetchUser(id);
} catch (err) {
console.error(`获取用户${id}失败`, err);
return { id, name: "未知用户" }; // 兜底数据
}
})
);
这样还能避免 “unhandled promise rejections” 错误——在 Node.js 严格环境下,该错误可能导致程序崩溃。
6. 现代异步循环方案,按需选择
6.1. for...of + await:适合需顺序执行的场景
若下一个请求依赖上一个的结果,或需遵守 API 的频率限制,可采用此方案:
// 在 async 函数内
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
// 不在 async 函数内,用立即执行函数
(async () => {
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
})();
- 优点:保证顺序,支持限流
- 缺点:独立请求场景下速度慢
6.2. Promise.all + map:适合追求速度的场景
请求间相互独立且可同时执行时,此方案效率最高:
const usersData = await Promise.all(users.map((id) => fetchUser(id)));
- 优点:网络请求、CPU 独立任务场景下速度快
- 缺点:一个请求失败会导致整体失败(需手动处理错误)
6.3. 限流并行:用 p-limit 控制并发数
若需兼顾速度与 API 限制,可借助 p-limit 等工具控制同时发起的请求数量:
import pLimit from "p-limit";
const limit = pLimit(2); // 每次同时发起 2 个请求
const limitedFetches = users.map((id) => limit(() => fetchUser(id)));
const results = await Promise.all(limitedFetches);
- 优点:平衡并发和控制,避免压垮外部服务
- 缺点:需额外引入依赖
7. 注意:千万别在 forEach() 里用 await
这是个高频陷阱:
users.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user); // ❌ 不会等待执行完成
});
forEach() 不会等待异步回调,请求会在后台乱序执行,可能导致代码逻辑出错、错误被遗漏。
替代方案:
- 顺序执行:用 for...of + await
- 并行执行:用 Promise.all() + map()
8. 总结:按需选择
JavaScript 异步能力很强,但循环里用 await 要“按需选择”,核心原则如下:
| 需求场景 | 推荐方案 |
|---|---|
| 需保证顺序、逐个执行 | for...of + await |
| 追求速度、独立请求 | Promise.all() + map() |
| 需保留所有结果(含失败) | Promise.allSettled()/try-catch |
| 需控制并发数、遵守限流 | p-limit 等工具 |
9. 参考链接
来源:juejin.cn/post/7569402861802782730
为什么有些人边框不用border属性
1) border 会改变布局(占据空间)
border 会参与盒模型,增加元素尺寸。
例如,一个宽度 200px 的元素加上 border: 1px solid #000,实际宽度会变成:
200 + 1px(left) + 1px(right) = 202px
如果不想影响布局,就很麻烦。
使用 box-shadow: 0 0 0 1px #000不会改变大小,看起来像 border,但不占空间。
2) border 在高 DPI 设备上容易出现“模糊/不齐”
特别是 0.5px border(发丝线),在某些浏览器上有锯齿、断线。
transform: scale(0.5) 或伪元素能做更稳定的发丝线。
3) border 圆角 + 发丝线 常出现不规则效果
border + border-radius 在不同浏览器的渲染不一致,容易出现不均匀、颜色不一致的问题。
用 outline / box-shadow 圆角更稳定。
4) border 不适合做阴影/多层边框
如果你需要两层边框:
双层边框用 border 很难做
而用:
box-shadow: 0 0 0 1px #333, 0 0 0 2px #999;
非常简单。
5) border 和背景裁剪一起用时容易出 bug
比如 background-clip、overflow: hidden 配合 border 会出现背景被挤压、不应该被裁剪却裁剪等问题。
6) hover/active 等状态切换时会“跳动”
因为 border 会改变元素大小。
例子:
.btn { border: 0; }
.btn:hover { border: 1px solid #000; }
鼠标移上去会抖动,因为尺寸变大了。
用 box-shadow 的话就不会跳。
总结
边框可以分别使用border、outline、box-shadow三种方式去实现,其中outline、box-shadow不会像border一样占据空间。而box-shadow可以用来解决两个元素相邻时边框变宽的问题。不使用border并不是因为它不好,而是因为outline和box-shadow的兼容性和灵活性相对border会更好一点。
来源:juejin.cn/post/7575065042158633010
老乡鸡也开源?我用 Trae SOLO 做了个像老乡鸡那样做饭小程序!
大家好,我是不如摸鱼去,欢迎来到我的 AI 编程分享专栏。
去年,「老乡鸡不装了,直接开源」的消息引发了广泛的关注。我也纳闷,老乡鸡不是做菜的吗,开的哪门子源?仔细看了下原来是把他们的菜品、溯源报告这些开源了。然后,GitHub 上这个叫「像老乡鸡那样做饭」的项目火了,如今 star 数量已经达到了 18k,这是它的地址:github.com/Gar-b-age/C… 。
作为一名爱做饭的程序员,面对如此诱人的开源资源,怎能袖手旁观?我选择用 Trae 快速构建了一个像老乡鸡那样做饭小程序! 本文将分享我如何利用 Trae SOLO 的高效开发能力,把这份“开源美味”封装成便捷的小程序。
开源
像老乡鸡那样做饭小程序已开源,参见文章TRAE SOLO 正式发布了?我用它将像老乡鸡那样做饭小程序开源了!
实现效果

技术栈
我们开发前选择好开发的技术栈,这样 AI 可以在我们规划好的路线上进行开发,可以达到事半功倍的效果。
前端技术栈
因为我本身就是一个前端程序员,所以前端技术栈比较熟,直接选择常用的技术栈:
- 小程序的开发框架: uni-app
- 开发模板项目: wot-starter 地址: starter.wot-ui.cn/
- 组件库: wot-ui 地址: wot-ui.cn/
后端技术栈
服务端最好是可以免开发,于是我选择了 TRAE SOLO 集成的 Supabase 作为我们的云端服务。
Supabase 是一个开源的 Firebase 替代品,旨在帮助开发者快速构建后端功能。它基于 PostgreSQL 数据库,并提供实时订阅、身份验证、存储、边缘函数等功能,支持 REST 和 GraphQL API。Supabase 强调开源、可自托管,并提供免费起步的云端服务,适合构建现代 Web 和移动应用。
菜谱数据来源
我们的菜谱数据来自于开源项目「像老乡鸡那样做饭」,这是它的 GitHub 地址:github.com/Gar-b-age/C…
动手
我们选择好技术栈之后就可以开始开发,由于我们使用 Supabase 作为服务,所以后端开发无需操心,只需要让 Trae SOLO 来建表、处理数据就好了。
数据处理
我们将菜谱的 markdown 和相关图片放到了 cook-book 目录下,然后让 Trae SOLO 开始处理吧!

Trae SOLO 开始处理需求,生成 tasks 列表,并执行



不过它也不是一步到位了,我发现导入的数据有的配料字段是空的,有的步骤是空的,于是让它重新检查了下(后面我自己检查了下发现是部分菜谱的 markdown 文件的标题不对,这里我就自己处理了)。

最终,Trae SOLO 帮我将全部的菜谱数据处理完毕,并插入到 Supabase 的数据库中了,接近 200 道菜,足够每天吃一道了。

小结
“干净”的数据能达到事半功倍的效果,从上面纠错的过程可以印证这一点,在让 AI 处理前,花点时间做基础的数据清洗(统一文件命名规范、检查必要字段是否存在、清理异常字符)是非常值得的投入。
小程序开发
我们开发前,有一些准备工作,由于 wot-starter 中包含暗黑模式等相关的配置,我们本次暂不需要,故需要移除,以免干扰 AI 对项目的理解(这里我们要明确一个点,要尽量提供有用的语料给 AI,因为过大的上下文会导致它天马行空)。

我们向 Trae SOLO 提出以下需求,先实现一个简易版:
开发一个像老乡鸡那样做饭小程序,基于现有表结构实现以下核心功能:
1. 分类浏览功能:按照菜品分类展示菜谱列表
2. 首页推荐功能:在首页展示精选推荐的3-5个菜谱
3. 菜谱详情页:点击可查看完整菜谱信息
要求:
- 保持现有表结构不变
- 界面设计简洁直观
- 确保数据加载流畅
- 适配移动端显示
- 使用unocss编写样式,使用rpx做单位
经过一轮开发后,项目结构如下:

不过此时项目还是跑不起来的,控制台报错了,我们直接将控制台报错发送给 Trae SOLO。

解决之后,我们的小程序启动起来,效果就已经差不多达到了文章开头的样子了。

当然还有一些小问题,都可以让 Trae SOLO 来处理

后续完善
初版完成后,我们群里的好朋友 FliPPeDround 提醒我说,「像老乡鸡那样做饭」项目的 Github 上有 PR 提供了做菜的手绘流程图。那太好了,我们这就给加上。
首先还是让 Trae SOLO 将新增的手绘流程图上传到 Supabase 并将其地址插入到对应的菜谱中

然后在菜谱详情中展示手绘流程图

效果如图,配上流程图,清晰又美观。

总结
我们今天几乎零代码 用 Trae SOLO 实现了「像老乡鸡那样做饭」小程序,过程中这三个规则让我们事半功倍:
- 保持数据干净:“干净”的数据能达到事半功倍的效果,上面处理数据时纠错的过程可以印证这一点,在让 AI 处理前,花点时间做基础的数据清洗(统一文件命名规范、检查必要字段是否存在、清理异常字符)是非常值得的投入。
- 纯净上下文:为 AI 提供一个相对“纯净”的、与当前任务高度相关的上下文环境,这样可以让 AI 在我们规划好的路线上进行开发,避免“天马行空”。
- 增量式沟通:尽量做到增量式沟通,不要一次性把所有需求都丢给 AI。先实现核心功能,跑通后再提新需求,比如我们添加手绘流程图的功能。每次交互都基于当前已完成的代码状态,让 AI 能“看到”它之前的成果,更容易理解下一步要做什么。
好了,实现这个小程序后,我再也不愁没菜吃了!后面我想还可以加一些有趣的功能,例如:今天吃什么、每周必吃、大家都在吃,等等功能,当然这些功能也是由 Trae 来开发,大家可以期待下,同时也期待未来 AI 编程能给我们带来更多、更强的能力,让我们能专注于更重要的「业务逻辑」。
参考资料
- 小程序的开发框架: uni-app
- 开发模板项目: wot-starter 地址: starter.wot-ui.cn/
- 组件库: wot-ui 地址: wot-ui.cn/
- 像老乡鸡那样做饭:github.com/Gar-b-age/C…
往期精彩
当年偷偷玩小霸王,现在偷偷用 Trae Solo 复刻坦克大战
告别 HBuilderX,拥抱现代化!这个模板让 uni-app 开发体验起飞
Vue3 uni-app 主包 2 MB 危机?1 个插件 10 分钟瘦身
欢迎评论区沟通、讨论👇👇
来源:juejin.cn/post/7554225547117576243
我为什么在团队里,强制要求大家用pnpm而不是npm?

最近,我在我们前端团队里推行了一个“强制性”的规定:所有新项目,必须使用pnpm作为包管理工具;所有老项目,必须在两个月内,逐步迁移到pnpm。
这个决定,一开始在团队里是有阻力的。
有同事问:“老大,npm用得好好的,为啥非要换啊?我们都习惯了。”
也有同事说:“yarn不也挺快的吗?再换个pnpm,是不是在瞎折腾?”
我理解大家的疑问。但我之所以要用“强制”这个词,是因为在我看来,在2025年的今天,继续使用npm或yarn,就像是明明有高铁可以坐,你却非要坚持坐绿皮火车一样,不是不行,而是没必要。
这篇文章,我就想把我的理由掰开揉碎了,讲给大家听。
npm和yarn的“原罪”:那个又大又慢的node_modules
在聊pnpm的好处之前,我们得先搞明白,npm和yarn(特指yarn v1)到底有什么问题。
它们最大的问题,都源于一个东西——扁平化的node_modules。
你可能觉得奇怪,“扁平化”不是为了解决npm v2时代的“依赖地狱”问题吗?是的,它解决了老问题,但又带来了新问题:
1. “幽灵依赖”(Phantom Dependencies)
这是我最不能忍受的一个问题。
举个例子:你的项目只安装了A包(npm install A)。但是A包自己依赖了B包。因为是扁平化结构,B包也会被提升到node_modules的根目录。
结果就是,你在你的代码里,明明没有在package.json里声明过B,但你却可以import B from 'B',而且代码还能正常运行!
这就是“幽灵依赖”。它像一个幽灵,让你的项目依赖关系变得混乱不堪。万一有一天,A包升级了,不再依赖B了,你的项目就会在某个意想不到的地方突然崩溃,而你甚至都不知道B是从哪来的。
2. 磁盘空间的巨大浪费
如果你电脑上有10个项目,这10个项目都依赖了lodash,那么在npm/yarn的模式下,你的磁盘上就会实实在在地存着10份一模一样的lodash代码。
对于我们这些天天要开好几个项目的前端来说,电脑的存储空间就这么被日积月累地消耗掉了。
3. 安装速度的瓶颈
虽然npm和yarn都有缓存机制,但在安装依赖时,它们仍然需要做大量的I/O操作,去复制、移动那些文件。当项目越来越大,node_modules动辄上G的时候,那个安装速度,真的让人等到心焦。
pnpm是怎么解决这些问题的?——“符号链接”
好了,现在主角pnpm登场。pnpm的全称是“performant npm”,意为“高性能的npm”。它解决上面所有问题的核心武器,就两个字:链接。
pnpm没有采用扁平化的node_modules结构,而是创建了一个嵌套的、有严格依赖关系的结构。
1. 彻底告别“幽灵依赖”
在pnpm的node_modules里,你只会看到你在package.json里明确声明的那些依赖。
你项目里依赖的A包,它自己所依赖的B包,会被存放在node_modules/.pnpm/这个特殊的目录里,然后通过 符号链接(Symbolic Link) 的方式,链接到A包的node_modules里。
这意味着,在你的项目代码里,你根本访问不到B包。你想import B?对不起,直接报错。这就从结构上保证了,你的项目依赖关系是绝对可靠和纯净的。
2. 磁盘空间的“终极节约”
pnpm会在你的电脑上创建一个“全局内容可寻址存储区”(content-addressable store),通常在用户主目录下的.pnpm-store里。
你电脑上所有项目的所有依赖,都只会在这个全局仓库里,实实在在地只存一份。
当你的项目需要lodash时,pnpm不会去复制一份lodash到你的node_modules里,而是通过 硬链接(Hard Link) 的方式,从全局仓库链接一份过来。硬链接几乎不占用磁盘空间。
这意味着,就算你有100个项目都用了lodash,它在你的硬盘上也只占一份的空间。这个特性,对于磁盘空间紧张的同学来说,简直是福音。
3. 极速的安装体验
因为大部分依赖都是通过“链接”的方式实现的,而不是“复制”,所以pnpm在安装依赖时,大大减少了磁盘I/O操作。
它的安装速度,尤其是在有缓存的情况下,或者在安装一个已经存在于全局仓库里的包时,几乎是“秒级”的。这种“飞一般”的感觉,一旦体验过,就再也回不去了。
为什么我要“强制”?
聊完了技术优势,再回到最初的问题:我为什么要“强制”推行?
因为包管理工具的统一,是前端工程化规范里最基础、也最重要的一环。
如果一个团队里,有人用npm,有人用yarn,有人用pnpm,那就会出现各种各样的问题:
- 不一致的
lock文件:package-lock.json,yarn.lock,pnpm-lock.yaml互相冲突,导致不同成员安装的依赖版本可能不完全一致,引发“在我电脑上是好的”这种经典问题。 - 不一致的依赖结构:用npm的同事,可能会不小心写出依赖“幽灵依赖”的代码,而用pnpm的同事拉下来,代码直接就跑不起来了。
在一个团队里,工具的统一,是为了保证环境的一致性和协作的顺畅。而pnpm,在我看来,就是当前这个时代下,包管理工具的“最优解”。
所以,这个“强制”,不是为了搞独裁,而是为了从根本上提升我们整个团队的开发效率和项目的长期稳定性。
最后的经验
从npm到yarn,再到pnpm,前端的包管理工具一直在进化。
pnpm用一种更先进、更合理的机制,解决了过去遗留下的种种问题。它带来的不仅仅是速度的提升,更是一种对“依赖关系纯净性”和“工程化严谨性”的保障。
我知道,改变一个人的习惯很难。但作为团队的负责人,我有责任去选择一条更高效、更正确的路,然后带领大家一起走下去。
如果你还没用过pnpm,我强烈建议你花十分钟,在你的新项目里试一试🙂。
来源:juejin.cn/post/7530180321619656745
我做了套小红书一键发布系统,运营小姐姐说她不想离开我了
运营小姐姐的烦恼
在我们公司有一个运营部门, 平常要负责把内容发布到抖音、快手、小红书等这些平台。账号类型也很多,有品牌号、合作号、还有专门用来做活动的测试号。 日常的工作量其实不小。尤其一到促销期或者活动期,一天要发好几条笔记,而且要同时覆盖不同的账号。
每次到了活动期,运营小姐姐的电脑上都是好几台手机和几十张图 + N 份文案堆在一起,一天下来要发好几条笔记,还得确保不同账号内容不重样、不出错。她经常一边切账号一边自言自语:“这张图是给哪个号的来着?我刚才发的是品牌号还是活动号?”
以前我们运营部门的流程是这样的:
策划把图文和文案打包发给运营,运营打开文件,一条条复制粘贴、上传图片、切换账号、点击发布……
多账号的时候还得来回切换登录,有时候内容顺序搞错了,还得手动撤回重发,前功尽弃。
这种方式不仅效率低下、容易出错,而且让人精神高度紧张,运营小姐姐经常下班后还得回群里道歉:“刚才发错平台了,我重新发一遍……”
技术出手,只为运营小姐姐少加点班
我每天下班的时候都能看到运营小姐姐还坐在工位上加班,发内容、切账号、贴文案、盯发布进度,一天反反复复几十次。到了活动高峰期,还得深夜返工,改图换文案,一不小心还会贴错内容、发错账号,前功尽弃。
面对运营小姐姐每天这样重复性极高的内容工作流程,我还是看不下去了:
我决定要用技术打破这一切机械操作。
于是我打算做一套 “内容统一管理 + 多平台分享集成” 的系统。
所有图文内容、发布素材和话题信息,运营可以提前在后台配置好。确认无误后,只需点击“一键生成分享链接”,把链接分享到对应设备上,打开就能自动跳转各个平台的发布界面,文案和图片都自动填充,彻底告别人工复制粘贴。
从“人工发图”变成“链接跳转”,
从“复制粘贴”变成“一键分发”,
用一行代码,换运营十分钟。
运营小姐姐只需要点一下,就能完成过去十几分钟的手工操作。把效率提上来,错误率降下去,让小姐姐能准点下班。
如何实现一键发布小红书笔记
那本篇文章我们就先单独讲一下如何实现用一条链接一键直接将图文和视频内容分享到小红书 App 笔记笔记里的功能。
我们先来看一下平常在小红书 App 手动发布笔记的流程 通常会先点击首页下方的加号按钮 选择要上传的图片或视频 然后输入标题和正文 再选择相关话题 确认无误后点击发布按钮完成发布 下面我放一张示意图方便大家直观理解整个流程

如果是多账号运营,这个过程就要重复好几次,效率非常低。等我们设计好架构接入技术之后,我们就能把整个流程程序化,把要发布的标题、内容、图片集中整理到一个统一的后台页面。运营先添加预发布的文案,在后台点击预览确认没有问题后,就可以生成一个分享链接,再把链接发给其他负责发布的同学。然后打开链接就能直接预览,并且一键快速发布,这就是我们希望实现的最终效果。
实现一键发布前的后台准备
由于项目真实后台涉及敏感配置和接口调用,这里我们用一个简单后台来说明功能设计流程。以下是一个我自己简单搭建的小红书文案管理后台,主要分为两个页面:文案列表页和发布页,分别用于管理文案和录入新文案。
我们先来看文案列表页,如下图展示:

它的主要功能是展示所有待发布或已发布的文案记录。每条文案包括封面图、标题、正文内容、图文数量,以及一组操作按钮,支持“复制分享链接”、“预览”、“编辑”和“删除”。通过这些操作,我们可以快速查阅、预览和维护已有文案。
右上角是搜索框和「新建文案」按钮,运营可以通过关键词快速查找历史文案,也可以点击新建按钮跳转到发布页。
下面是发布页的示意图,如图所示:

在这个页面中,我们可以填写文案的标题和正文内容,正文支持 Markdown 格式,方便排版。接着我们可以从素材库中选择需要发布的图片或视频内容。填写完成后,点击底部的「发布文案」按钮,即可提交到后台数据库中。
整个后台的核心流程是:运营先在发布页准备好图文内容并保存,之后可以回到列表页复制分享链接,将链接发送给需要发布的同学,对方点击链接即可跳转到预览页,一键发布到小红书 App笔记。这种方式大大简化了手动发布流程,尤其适合多账号分工运营场景,能极大提升效率,减少重复劳动。
后台文案管理功能设计
为了更方便地管理和发布图文内容,我们设计了一个简单的小红书后台系统。后台主要由两个核心页面组成:文案列表页 和 发布页,分别用于查看、维护和录入要发布的图文素材。
后台的数据存储基于一张 x_post 表,结构设计如下:
CREATE TABLE `x_post` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`type` varchar(20) NOT NULL COMMENT '笔记类型 normal | video',
`title` varchar(255) DEFAULT NULL COMMENT '标题',
`content` text COMMENT '正文内容',
`cover_url` varchar(500) DEFAULT NULL COMMENT '封面图',
`image_urls` text COMMENT '图片列表,JSON数组格式',
`video_url` varchar(500) DEFAULT NULL COMMENT '视频地址',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除 0=未删除 1=已删除',
`deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='发布笔记表';
这张表记录了每篇小红书笔记的关键信息,包括标题、内容、封面、图文或视频链接、删除时间和是否删除等字段。其中 image_urls 是一个 JSON 数组格式,便于存储多图信息,type 字段区分图文和视频类型。
内容创建完后,运营小姐姐就可以通过后台的发布页录入一篇新文案,然后在列表页点击「预览」按钮来查看内容展示效果。(其实我们还可以支持一键导入文案素材的功能)
例如下图就是我们第一篇文案的预览效果页面:


这个预览页面完全模拟小红书 App 内的发布效果,顶部是封面图和内容图集,下面是正文,包括 emoji、换行、图文排版、交通路线推荐等内容。
如果是多账号运营,传统做法需要登录多个账号重复上传素材和手动复制粘贴内容,非常低效。而接入我们这个系统后,运营只需提前在后台准备好文案并生成预览链接,分发给不同的发布同事即可。对方打开链接后可以直接查看效果,并一键完成发布操作。
这大大提升了运营效率,尤其是在有多个账号需要同步发布或定时推送内容的场景中,非常实用。
这就是我们后台的整体设计思路。接下来,我们将详细介绍如何与小红书 App 进行联动,实现一键直达发布页的功能。通过技术手段进一步简化发布流程,实现图文内容从准备到落地的一体化闭环。
接入小红书分享平台 API
为了实现我们前面提到的“一键跳转到小红书发布页”的能力,我们需要借助小红书官方提供的 分享开放平台。它主要面向内容型或工具型的合作应用,提供了 SDK 和相关接口,支持 Android、iOS、HarmonyOS 和 JS 等多种平台。
从官方说明来看,当前支持的能力包括:
- 一键分享:通过开放的接口,第三方 App 或网页可以直接将图文、视频内容分享到小红书,免去了手动复制粘贴和跳转操作,极大提高了分享效率。
- 快速发布:在唤起小红书 App 后,系统会自动识别我们传入的内容类型,比如是图文还是视频。它还支持在发布页挂载话题标签,并根据内容自动推荐标题或话题。这项功能目前仅在 Android 和 iOS 平台支持,HarmonyOS 暂不支持。
- 活动运营联动:我们还可以联动小红书站内的活动,比如双十一、出游季等主题活动。在分享内容时,用户可以直接跳转到这些活动页面,提升曝光率和互动率。不过需要注意的是,这项能力目前同样只在 Android 和 iOS 平台可用。
这些功能意味着,我们不需要自行设计复杂的内容编辑器,也不需要从零搭建视频发布模块,只需准备好基础内容,通过官方提供的 SDK 唤起小红书 App,即可完成整个跳转发布流程。
接入流程
下面是我们使用小红书JS SDK整个接入流程的概览图:

从图中可以看出,整个分享流程包括如下关键步骤:
- 第三方开发者在开放平台官网申请注册应用,平台审核后分配 appKey 和 appSecret;
- 开发者服务端保管好这些凭证,并在需要调用时计算签名;
- 签名参数(nonce、sign、timestamp)将下发给前端页面;
- 页面通过 JS SDK 唤起小红书 App,完成内容分享跳转;
- 后台可通过 openAPI 配合 JS-SDK 进行鉴权校验。
鉴权签名流程

整个 JS SDK 鉴权流程主要包括以下三步:
- 第一步:获取 access_token
第一次签名操作,使用 appKey、appSecret 等生成加签参数,向 openAPI 请求access_token。 - 第二步:生成 JS 签名 signature
获取到 access_token 后,使用appKey + nonce + timestamp + access_token进行再次签名,生成 JS SDK 所需的signature。 - 第三步:返回签名参数给前端
服务端将appKey、nonce、timestamp和signature返回给第三方前端,前端可使用 JS SDK 调用分享方法,并唤起小红书 App。
发布分享
在完成了前面的接入流程和鉴权签名后,我们最终要调用的是小红书 JS SDK 提供的分享方法 xhs.share(),这个方法可以让我们唤起小红书 App,并快速填充好要发布的内容。
调用格式大致如下:
xhs.share({
shareInfo: {
type: 'normal', // 笔记类型,图文用 'normal',视频用 'video'
title: '...', // 笔记标题(可选)
content: '...', // 笔记正文内容(可选)
images: ['...'], // 图文笔记必填,必须是公网图片地址
video: '...', // 视频笔记必填,必须是公网视频地址
cover: '...', // 视频封面图,必须是公网地址
},
verifyConfig: {
appKey: '...', // 平台分配的应用唯一标识
nonce: '...', // 服务端生成的随机字符串
timestamp: '...', // 服务端生成的时间戳
signature: '...', // 服务端生成的签名
},
fail: (e) => {
// 调用失败时的处理逻辑
}
})
这里的 shareInfo 是我们希望填入小红书发布页面的内容信息,主要包括笔记类型(图文 or 视频)、标题、正文、图片或视频地址等;而 verifyConfig 是服务端提前生成的签名鉴权参数,必须由服务器返回,不能由前端自己生成。
下面是每个字段的含义说明:
| 字段名 | 是否必填 | 类型 | 说明 |
|---|---|---|---|
| type | 是 | String | 笔记类型,支持 normal(图文)和 video(视频) |
| title | 否 | String | 笔记标题 |
| content | 否 | String | 笔记文字内容 |
| images | 否 | Array | 图文类型必填,图片列表,必须是公网地址,不支持本地路径 |
| video | 否 | String | 视频类型必填,视频文件地址,必须是公网地址 |
| cover | 否 | String | 视频封面图,必须是公网地址 |
| appKey | 是 | String | 应用在小红书开放平台申请到的唯一标识 |
| nonce | 是 | String | 服务器生成的随机字符串,用于签名 |
| timestamp | 是 | String | 服务器生成的时间戳,用于签名 |
| signature | 是 | String | 服务器生成的签名结果 |
| fail | 否 | Function | 分享失败时的回调方法,可用于提示或埋点 |
需要注意的是,小红书的 xhs.share() 方法内置了鉴权流程,我们只需要提供服务端生成的 nonce、timestamp 和 signature,前端不需要自己参与签名过程。
上面我们已经介绍了接入小红书分享平台 JS SDK 的整个流程,包括平台能力、签名机制以及前后端的配合方式。
接下来我将通过一个简化版本的服务端接口与前端 HTML 页面,来实现一个最小闭环的一键发布功能 Demo,帮助大家快速理解整个流程的落地实现。
服务端接口设计:文案预览与一键发布签名生成
在服务端的逻辑里,我们通常只需要实现两个接口:一个是文案预览接口,另一个是一键发布时用到的分享参数生成接口。
文案预览接口的作用是:当我们从后台生成了一个分享链接发给同事后,对方打开这个链接时,前端需要能展示出这条小红书文案的内容,比如标题、正文、图片等信息。这时候页面会携带一个唯一的文案 ID(通常是链接里的参数),服务端需要根据这个 ID 去数据库查一下这条文案是否存在、有没有被下架等状态校验,然后把文案信息返回给前端做展示。
而一键发布接口的作用是:当用户点击“立即发布”按钮后,前端需要去服务端调用一个接口,这个接口的任务是再次校验文案是否有效,并根据我们接入小红书分享平台的要求,生成所需的签名参数(比如 appKey、nonce、timestamp、signature 等),这些参数会被前端 JS-SDK 拿去唤起小红书 App,实现真正的跳转和发布功能。
接口一:文案预览接口
在我们的后台中,每一条小红书文案都支持“点击生成分享链接”的功能。这个分享链接通常会附带一个唯一标识 ID,例如:
https://xxxx.com/share.html?_id=ABY1boZllzPlTiq2
这个 _id 就是我们为每一条文案生成的唯一标识,目的是为了让运营或其他同事拿到链接后可以打开预览页面,并看到这条笔记的内容细节。
我们服务端的预览接口设计也非常简单,主要逻辑如下:
控制器代码
/**
* 预览接口
* 示例:GET /api/post/preview/1
*/
@GetMapping("/preview/{id}")
public ResponseEntity<XPost> preview(@PathVariable Long id) {
return ResponseEntity.ok(postService.previewPost(id));
}
为了方便演示,这里我们使用的是通过 GET 请求访问接口,并直接返回数据库中的文案信息。实际项目中可以根据自己的需求使用更安全或更复杂的方式来处理分享链接的鉴权与数据访问。
Service 层逻辑
public XPost previewPost(Long id) {
return postRepository.findById(id)
.orElseThrow(() -> new RuntimeException("内容不存在"));
}
这个方法会根据文案 ID 去数据库中查询对应的数据。如果找不到,就直接抛出异常提示“内容不存在”。
通过这个接口,我们就可以在分享链接打开时,自动去服务端拉取对应文案的数据,并渲染在页面中,方便运营预览确认。
前端预览页面展示效果
在前面我们已经通过服务端接口拿到了某条文案的详细内容(如图片列表、正文文案等)。这一步我们会基于这些数据,用一个简单的 HTML 页面来完成“预览页面”的渲染展示。
这个页面的作用是:当我们在后台生成分享链接并复制给运营后,运营或用户可以通过这个链接在浏览器中打开文案预览页面,确认发布内容是否正确。如果确认无误,还可以点击“一键发布”按钮,跳转至小红书 App 完成最终发布。
示例展示效果
如下图所示,这是我们通过微信打开渲染出来的预览页面效果:

我这个demo页面使用了原生 HTML + Tailwind CSS + Swiper 轮播图组件来实现这个页面。代码逻辑非常简单:
- 页面加载时调用服务端接口
/api/post/preview/{id}获取文案内容; - 根据接口返回的
imageUrls渲染轮播图; - 根据
content渲染正文文案; - 点击“发布到小红书”按钮,跳转到下一步一键分享页面。
下面是部分部分代码片段:
<!-- 图片轮播区域 -->
<div class="swiper">
<div class="swiper-wrapper" id="preview-images"></div>
<div class="swiper-pagination"></div>
</div>
<!-- 文案内容 -->
<div class="xhs-text" id="preview-text">加载中...</div>
<!-- 一键发布按钮 -->
<button class="btn-publish" onclick="publishPost()">发布到小红书</button>
我们通过简单的轮播图 + 段落渲染的方式,让用户在手机上也可以便捷查看预览效果。整个页面在移动端有良好的显示效果,支持微信、浏览器等环境打开。
接口二:一键发布接口:生成跳转小红书所需参数
在完成了预览页面后,接下来我们要实现的就是 一键发布接口。这个接口的核心目标是:
当用户在预览页面点击“发布到小红书”按钮后,后端生成一组必要的签名参数,并返回给前端,前端再用这组参数跳转到小红书 App 完成笔记发布。
接口定义
我们定义了如下接口来支持这个过程:
/**
* 发布接口
* 示例:GET /api/post/publish/1
*/
@GetMapping("/publish/{id}")
public ResponseEntity<Map<String, Object>> publish(@PathVariable Long id) {
Map<String, Object> result = postService.buildPublishResult(id);
return ResponseEntity.ok(result);
}
这个接口中,id 表示文案在数据库中的主键 ID。我们通过这个 ID 查询文案内容,并为其生成一组带签名的跳转参数。
配置小红书开放平台的 AppKey 和 AppSecret
在 Spring Boot 的 application.yml 配置文件中,我们提前配置好了小红书开放平台的 appKey 和 appSecret,用于生成签名参数:
# 小红书分享开放平台配置
xhs:
app-key: xxxxxxx
app-secret: xxxxxxx
核心方法:构建发布参数
接口底层调用的逻辑是:
public Map<String, Object> buildPublishResult(Long id) {
XPost post = previewPost(id); // 获取笔记内容
// 生成签名部分
Map<String, Object> data = generateSignature(id);
// 构建分享内容
Map<String, Object> shareInfo = new HashMap<>();
shareInfo.put("type", post.getType());
shareInfo.put("title", post.getTitle());
shareInfo.put("content", post.getContent());
shareInfo.put("images", ImageUtils.parseImages(post.getImageUrls()));
shareInfo.put("cover", Optional.ofNullable(post.getCoverUrl())
.orElseGet(() -> ImageUtils.getFirstImage(post.getImageUrls())));
shareInfo.put("time", System.currentTimeMillis());
shareInfo.put("_id", "xhs_" + post.getId());
Map<String, Object> result = new HashMap<>();
result.put("data", data); // 签名字段
result.put("shareInfo", shareInfo); // 文案内容
return result;
}
/**
* 生成小红书发布参数签名
* @param postId 文案ID
* @return appKey + nonce + timestamp + signature
*/
public Map<String, Object> generateSignature(Long postId) {
// 查询是否存在
XPost post = postRepository.findById(postId)
.orElseThrow(() -> new RuntimeException("内容不存在"));
String appKey = xhsProperties.getAppKey();
String appSecret = xhsProperties.getAppSecret();
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
String timestamp = String.valueOf(System.currentTimeMillis());
try {
String signature = SignatureUtil.buildSignature(appKey, nonce, timestamp, appSecret);
Map<String, Object> result = new HashMap<>();
result.put("appKey", appKey);
result.put("nonce", nonce);
result.put("timestamp", Long.parseLong(timestamp));
result.put("signature", signature);
return result;
} catch (Exception e) {
throw new RuntimeException("生成签名失败: " + e.getMessage(), e);
}
}
接口返回的结果分为两部分:
data部分:用于跳转小红书所需的签名参数,包括appKey、nonce(随机字符串)、timestamp(时间戳)、signature(签名值),这些字段将作为xhs.share()方法中的verifyConfig参数传入;shareInfo部分:是我们准备要发布的笔记内容,包括type(图文 or 视频)、title(标题)、content(正文)、images(图片地址列表)、cover(封面图)等信息,用于在前端渲染展示和传递给小红书 SDK。
我们测试时实际返回的结构如下所示:
{
"shareInfo": {
"cover": "http://110.42.233.124/images/dishini.jpg",
"images": [
"http://110.42.233.124/images/dishini.jpg",
"http://110.42.233.124/images/dishini2.jpg",
"http://110.42.233.124/images/dishini3.jpg"
],
"time": 1758188642421,
"_id": "xhs_1",
"type": "normal",
"title": "一张图讲清楚上次迪士尼交通攻略🚇",
"content": "一张图讲清楚上次迪士尼交通攻略🚇\n有多少宝子去迪士尼光是研究交通就费了好大劲。。。"
},
"data": {
"signature": "2486268ecfa3886b7fc4cd1d1fcbda5381b3177f96daf5f1e4dd09b938f94d4f",
"appKey": "red.96juc55xxxx",
"nonce": "10e07cfc400c",
"timestamp": 1758188642420
}
}
前端拿到这组数据后,只需要将 shareInfo 和 data 传入小红书 SDK 的 xhs.share() 方法中,即可完成跳转与笔记预填。
签名生成工具类:SignatureUtil.java
小红书分享 SDK 的调用需要进行签名认证,为此我们封装了一个简单的工具类,用于生成 SHA256 签名,核心思路是:
- 将
appKey、nonce、timestamp等参数排序; - 拼接成
key=value&key2=value2...的格式后追加appSecret; - 使用 SHA-256 进行加密并返回十六进制字符串。
/**
* 小红书签名生成工具类
*/
public class SignatureUtil {
/**
* 构建签名
*
* @param appKey 应用唯一标识
* @param nonce 随机字符串(建议12位)
* @param timestamp 当前时间戳(毫秒)字符串格式
* @param appSecret 应用密钥
* @return 签名字符串(SHA-256 Hex)
*/
public static String buildSignature(String appKey, String nonce, String timestamp, String appSecret) throws Exception {
Map<String, String> params = new HashMap<>();
params.put("appKey", appKey);
params.put("nonce", nonce);
params.put("timestamp", timestamp);
return generateSignature(appSecret, params);
}
/**
* 签名生成逻辑
* @param secretKey 秘钥
* @param params 参数 map
* @return 签名字符串
*/
public static String generateSignature(String secretKey, Map<String, String> params) {
// Step 1: 排序参数
Map<String, String> sortedParams = new TreeMap<>(params);
// Step 2: 拼接参数字符串(key=value&...)+ secretKey
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
if (sb.length() > 0) sb.append("&");
sb.append(entry.getKey()).append("=").append(entry.getValue());
}
sb.append(secretKey); // 拼接密钥
// Step 3: 进行 SHA256 签名
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
// 转十六进制
StringBuilder hex = new StringBuilder();
for (byte b : hashBytes) {
hex.append(String.format("x", b));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256算法不可用", e);
}
}
}
图片处理工具类:ImageUtils.java
因为我们数据库中 image_urls 字段是 JSON 数组格式(如 ["url1", "url2", "url3"]),前端展示和分享时需要将其解析成 List<String>,我们封装了如下工具类:
parseImages():将数据库的 JSON 格式或逗号分隔的图片字符串,解析为图片地址列表。getFirstImage():获取列表中的第一张图,作为默认封面图。
/**
* 图片处理工具类
*/
public class ImageUtils {
private static final ObjectMapper mapper = new ObjectMapper();
/**
* 将 imageUrls 字段解析为图片地址列表
* @param imageUrls 数据库中的 imageUrls 字段
* @return List<String> 图片地址列表
*/
public static List<String> parseImages(String imageUrls) {
if (imageUrls == null || imageUrls.isBlank()) return Collections.emptyList();
try {
// 如果是 JSON 数组格式
if (imageUrls.trim().startsWith("[")) {
return mapper.readValue(imageUrls, new TypeReference<List<String>>() {});
} else {
// 普通逗号分隔格式
return Arrays.stream(imageUrls.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
} catch (Exception e) {
return Collections.emptyList(); // 解析失败返回空列表
}
}
/**
* 获取图片中的第一张图(封面用)
* @param imageUrls 数据库中的 imageUrls 字段
* @return 第一张图片地址或 null
*/
public static String getFirstImage(String imageUrls) {
List<String> images = parseImages(imageUrls);
return images.isEmpty() ? null : images.get(0);
}
}
前端跳转页实现(跳转小红书 App 分享)
在我们前面实现的文案预览页中,点击“一键发布”按钮后,会跳转到一个中间页,这个页面的作用是:
- 检查用户当前的浏览器环境(是否是微信、是否是 Android);
- 请求服务端接口,获取真实的文案内容 + 签名参数;
- 自动调用小红书开放平台提供的
xhs.share()方法,引导用户跳转并打开小红书 App 进行发布。
判断浏览器类型与环境
前端页面在加载时,会首先通过以下代码判断当前浏览器环境:
const u = navigator.userAgent.toLowerCase();
const isAndroid = u.includes('android') || u.includes('adr');
const isWeixin = u.includes('micromessenger');
const params = Object.fromEntries(new URLSearchParams(location.search).entries());
const id = params.id;
const appKey = params.appKey;
const nonce = params.nonce;
const timestamp = params.timestamp;
const signature = params.signature;
// 如果在微信中提示用户跳出
if (isAndroid && isWeixin) {
document.getElementById("tips").style.display = 'block';
} else {
goShare();
}
这一段逻辑我们简单做了几件事:
- 从浏览器的
userAgent中判断用户是否使用的是 安卓设备,以及是否是在 微信浏览器中打开。 - 如果是在微信中打开,则无法直接跳转小红书 App,因此我们会弹出提示,要求用户“点击右上角 -> 浏览器打开”。
- 否则,说明环境已满足跳转条件,我们会直接调用
goShare()方法进入分享流程。
请求服务端接口,校验并返回参数
当满足环境条件后,页面会自动调用我们之前实现的第二个接口 /api/post/publish/{id},用于服务端校验文案真实性,并返回跳转小红书所需的加密参数。代码如下:
const res = await fetch(`http://110.42.233.124:8888/api/post/publish/${id}`);
const json = await res.json();
const { data: verifyConfig, shareInfo } = json;
调用小红书 SDK 的分享能力
一旦我们拿到 verifyConfig(签名参数)和 shareInfo(图文内容),就可以正式调用小红书的 SDK 进行分享:
xhs.share({
shareInfo: {
type,
title,
content,
images,
...(type === 'video' ? { video, cover } : {})
},
verifyConfig: {
appKey: verifyConfig.appKey,
nonce: verifyConfig.nonce,
timestamp: verifyConfig.timestamp,
signature: verifyConfig.signature
},
fail: (e) => {
console.error('小红书分享失败:', e);
alert('小红书分享失败: ' + JSON.stringify(e));
}
});
这个方法会自动拉起小红书 App 并进入发布页,用户可以直接确认发布内容。
最终效果
以下是我们实际测试中跳转页的样式效果示意(图片有点大,耐心等待一下..... )包含提示引导和自动跳转逻辑:

从图中流程我们可以看到,我们打开了后台给到的分享链接后,我们就可以进入第一个文案预览页面。当我们点击预览页面中的「一键发布」按钮后,页面会自动跳转至我们前面实现的中间页(分享跳转页)。整个过程的用户体验如下:
- 提示离开微信(或浏览器)
如果用户是在微信中打开链接,系统会自动弹出提示,提示用户“即将打开第三方 App”,引导用户点击右上角菜单,选择「在浏览器中打开」。 - 自动跳转小红书 App
当用户点击继续,或在普通浏览器中打开时,页面会自动调用小红书开放平台的跳转能力(xhs.share),此时会拉起小红书 App。 - 自动进入发布页,内容已填充
成功跳转后,用户会直接进入小红书的发布页面,不需要再手动粘贴内容:
- 文案标题和正文内容已自动填写;
- 所选图片已经预先加载为图文笔记的图组;
- 下方的「话题」也已经贴上;
- 如果是视频内容,则会自动带上封面图与视频资源。
也就是说,用户只需要点几下,就能一键跳转到小红书 App 发布笔记,几乎不需要做任何额外操作,整个过程简单快速,适合后台批量生成内容后,由运营一键分发。
这一套跳转流程适用于图文与视频类型的内容,不需要接入小红书 App SDK,只需要后台生成好内容和签名,前端发起跳转即可。
进阶设计:多设备自动发布与后台数据统计
到这里,我们已经完成了整套“小红书文案一键发布”的实现流程,用户只需点击跳转,就能将指定文案快速填充到小红书 App 的发布页面。
但如果放在实际业务中,这还只是第一步。
目前这种方式仍然依赖人工操作 —— 也就是说,我们还是需要每台手机手动打开链接、点击发布,才能完成整个笔记的投放过程。
而在真实的项目实践中,我们已经做到了 “机器化发布”:通过技术手段控制多台手机(甚至几十上百台设备)同时打开指定链接、自动触发跳转与发布动作,极大地提升了投放效率,节省了大量人力。
此外,后台系统也支持发布数据的实时统计,例如:
- 哪些账号已成功发布;
- 每条文案的发布状态;
- 相关数据是否回传成功(如小红书笔记的曝光量、点赞量等);
- 发布失败的情况排查与补投管理。
当然,这部分内容就不展开讲了,有兴趣的同学可以进一步了解在分发调度、设备联控、账号管理等方面的实践方案。
用后台赋能一对多教学,打造自己的“小红书孵化器”
这套系统除了用于团队运营,其实对独立开发者、个人博主、内容培训者也非常实用。
以我自己为例,比如我现在在做「教粉丝小红书起号」的副业。但如果每位粉丝都要我手动发图、写文案、传视频,再一个个教他们怎么发笔记,不仅效率低,还特别容易出错。
于是我直接用自己搭的“小红书发布系统”来做教学工具:
- 我在后台准备好文案、话题、图片/视频;
- 系统自动生成发布跳转链接;
- 然后我把这个链接生成限时二维码,发给粉丝;
- 粉丝扫码后,点击“一键发布”;
- 自动跳转到小红书 App,文案和素材都粘贴好了,直接发布即可。
这样我就可以用一套系统同时带多个账号起号,效率极高,不用再反复教每个人怎么贴图、加话题、排版、复制文案。
甚至还可以统计哪篇文案被谁用了,效果怎么样,方便后续优化内容策略。
所以说这套系统已经不只是一个“分享工具”,更是我做小红书教学、孵化账号的内容分发中枢了。
更多平台的扩展实践:从小红书到全平台统一管理
小红书虽然是目前最热门的图文笔记平台之一,但它的 JS SDK 和分享 API 对接起来其实非常简单。我们前端只要把参数拼好,直接调用 xhs.share(),就能一键跳转到 App 发布页面。
也正因为这种接入方式足够“轻量、稳定”,我们很快发现:
同样的模式,可以复制到更多平台。
除了小红书,我们还陆续接入了抖音(抖音开放平台)、快手(快手开放平台)、B站(哔哩哔哩开放平台)、微博(微博开放平台)等多个平台,把“一键发布”做成了一个真正的统一系统。
最后我们搭建出了一个 “内容发布中台” :
- 后台统一配置文案、图片、视频等素材;
- 前端页面自动生成跳转链接;
- 多平台同时分发,减少人工操作;
- 实时回传发布状态,统一统计效果数据。

这样一来,无论是日常运营,还是品牌推广,都能用最少的人力,把内容快速推送到所有目标平台,实现最高效的触达。
技术也能让运营小姐姐更轻松
这一套“小红书一键发布”系统,其实技术上并不复杂,前端甚至就一行 xhs.share(...)。但当我们把流程统一、配置集中、权限收敛、参数自动化后,整个团队的节奏就变了。
以前运营小姐姐每天得在文案、图片、账号之间来回切换,错一个字就要撤回重发,现在她登录后台挑好文案,一键生成链接,分享到手机点一下,直接跳进 App 就能发笔记,图文话题全自动贴好。
从“复制粘贴”到“一键跳转”,从“账号切来切去”到“后台统一分发”,
连她自己都说:“以前像搬砖工人,现在像点菜经理。”
最重要的是:
她终于可以准时下班了。
我们现在还接入了设备控制、状态检测、效果回传,如果后面再接个大模型自动生成文案,她可能连选图都不用了,坐着喝奶茶就能把内容分发搞定。
这也让我明白一件事:
不是运营不努力,而是工具不给力。
技术如果能让人少加点班、多留点发际线,那就值得去做。
这其实就是一个非常典型的例子:技术可以改变运营效率,甚至直接降低人力成本。
未来当我们再结合 AI 大模型(比如自动生成文案、图片审核、内容风格推荐等),这个平台的自动化能力还可以再上一个台阶。
所以哪怕只是一次小小的集成,也值得认真打磨。它的价值,可能比我们想象中更大哦。
来源:juejin.cn/post/7552489208804491316
Electron 发布 39 版本 ,这更新速度也变态了吧❓︎❓︎❓︎
最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777
Electron 39.0.0 于 2025 年 10 月 27 日发布。此版本带来了对 Chromium、Node.js 和 V8 引擎的更新,提升了性能和稳定性,同时也引入了一些新的功能和改进。以下是此版本的详细变化。
栈升级
- Chromium: 更新到 142.0.7444.52,这意味着 Electron 在此版本中升级了其底层浏览器引擎。该版本修复了多个性能和安全漏洞,同时引入了一些新的 Web 标准。
- Node.js: 更新到 22.20.0,这个版本包含了许多重要的 Node.js 修复和改进,包括性能优化和一些新的 API。
- V8: 更新到 14.2,V8 引擎的升级提升了 JavaScript 执行的效率,使得应用程序的响应速度更快,内存占用更低。
破坏性更改
在这一版本中,有几个 API 和行为发生了变化,这可能会导致与以前版本的兼容性问题。
OffscreenSharedTexture:此 API 的签名进行了更新,新的版本提供了一个统一的handle,用于持有原生句柄。这意味着开发者需要调整代码,以便正确使用这个新的接口。window.open:该方法的行为得到修复,确保它创建的弹出窗口始终是可调整大小的。原本可能出现的不一致性问题已经被解决,确保符合标准规范。
新特性
- Offscreen 渲染支持 RGBAF16:Electron 现在支持以
RGBAF16格式输出图像数据。这意味着应用程序可以更好地支持高动态范围(HDR)图像,提供更高质量的图像渲染。 process.getSystemMemoryInfo()增强:在 macOS 上,getSystemMemoryInfo方法新增了fileBacked和purgeable字段,这让开发者能够获得更多关于系统内存的信息,包括哪些内存是文件映射的、哪些可以被清除以释放空间。systemPreferences.getAccentColor:在 Linux 上,Electron 新增了一个方法systemPreferences.getAccentColor,它返回操作系统的强调色。这对于需要与操作系统主题颜色匹配的应用程序很有用。- 托盘图标
guid选项:在 macOS 上,Tray构造函数现在支持一个新的guid选项。这个选项允许托盘图标在应用程序重新启动后保持相同的位置和状态,使得用户体验更加一致。 - WebFrameMain API 增强:Electron 新增了
webFrameMain.fromFrameToken(processId, frameToken)方法,开发者可以通过此方法从帧令牌获取WebFrameMain实例,这对于需要直接操作特定帧的应用程序非常有用。 - 可访问性支持:Electron 引入了更细粒度的可访问性支持,包括为开发者提供更多的 API 来提高对残障人士的支持。这使得应用程序能更好地满足可访问性需求,提供更加友好的用户体验。
app.getRecentDocuments()支持:在 Windows 和 macOS 上,Electron 现在支持app.getRecentDocuments()方法。通过这个方法,开发者可以获取到最近访问的文档列表,方便实现类似于“最近使用文件”的功能。- USB 设备 API 更新:Electron 新增了对
USBDevice.configurations的支持。开发者现在可以获取到连接到设备的 USB 配置信息,这对于需要与 USB 设备交互的应用程序非常有用。 - 文件系统 API 更新:在应用程序中持久化文件系统权限状态变得更加简单。Electron 允许在给定的会话内持久化文件系统授权状态,避免用户每次打开应用时重新授权。
- 动态导入(ESM)支持:在非上下文隔离的预加载脚本中,Electron 现在支持动态导入 ECMAScript 模块(ESM)。这使得开发者能够在 Electron 中更灵活地使用 JavaScript 模块化。
修复
- 系统配色问题修复:修复了
systemPreferences.getAccentColor返回的颜色反转的问题,确保返回的颜色值符合预期。 - 开发者工具:修复了在 Wayland 上调用
webContents.openDevTools({ mode: 'detach' })时可能导致的崩溃问题。Wayland 是 Linux 上的一种显示协议,这个修复对于在该平台上开发的 Electron 应用程序至关重要。 - 会话管理问题:修复了访问
webContents.session时可能导致崩溃的问题。这个修复增强了应用程序在多会话环境下的稳定性。 - 窗口管理:修复了在调用
window.close()后,执行某些操作可能导致崩溃的问题。这个修复提高了窗口管理的可靠性。 - 命令行参数问题修复:修复了通过命令行参数传递特性参数时,可能导致的崩溃问题,确保应用程序能够稳定运行。
- 文件对话框问题修复:修复了在 Windows 上调用
dialog.showOpenDialog时,如果传入的扩展名过滤器数组为空,可能导致的崩溃问题。
其他更改
- 资源定位:内部的资源定位机制发生了变化,现在 Electron 使用
DIR_ASSETS来定位资产和资源。此外,app.getPath方法现在支持返回一个新的 "assets" 键,用于获取应用程序资源路径。 - 文档更新:官方文档得到了更新和补充,相关的 API 和功能进行了详细说明,开发者可以参考最新的文档了解更多实现细节。
总结
Electron 39.0.0 版本主要带来了对 Chromium、Node.js 和 V8 的升级,提升了性能和稳定性。此外,新增了对 Offscreen 渲染、系统主题色、USB 设备支持等的支持,也修复了多个与窗口管理、文件对话框等相关的 bug。对于开发者来说,这些更新和改进能够带来更高效、稳定的开发体验。
来源:juejin.cn/post/7566885041043046434
别 npm i!20亿包刚被投毒!速查!
20 亿次周下载量、18 个“基建级”包、一场持续 2 小时的**“核弹级”**污染——这次,攻击者把枪口对准了每一个前端开发者与 Web3 用户。
凌晨的“钓鱼邮件”,撕开 20 亿次周活的口子
9 月 8 日 17:39 UTC,Aikido Security 的红色警报划破周末宁静:
npm 周下载量超 20 亿的 18 个核心包,被植入浏览器端加密货币劫持代码。

攻击入口简单到令人发指——一封“npm 官方”发来的 2FA 过期提醒。
| 要素 | 攻击者伪造内容 |
|---|---|
| 发件人 | support@npmjs.help(非官方域名) |
| 标题 | 【紧急】您的账户将于 9 月 10 日被锁定 |
| 按钮 | 一键更新 2FA(实则窃取 token) |

维护者 Josh Junon(qix)点下链接 30 秒后,攻击者即获得其 npm 账户完全控制权,随后向 chalk、debug、ansi-styles 等“基建级”包推送了带毒补丁版本。
恶意代码:功能 100% 正常,只是多了一笔“隐形转账”
攻击者没有粗暴地“删库跑路”,而是把恶意逻辑藏进 浏览器环境专属分支:
- 只在
<script>或webpack/browserify打包后生效,Node 服务端无感知; - 监听常见 Web3 钱包(MetaMask、Phantom、OKX)的
sendAsync调用; - 用 Levenshtein 算法 计算目标地址与内置地址列表的相似度 ≥ 0.9 即触发替换;
- 伪造与原交易相同的 txHash 回执,用户以为成功,实则资金已转入黑客地址。
“代码 diff 只看 12 行,格式化后像是一段 polyfill,谁会在意?”—— 事后 Josh 复盘
2 小时核弹扩散:10% 云函数瞬间污染
| 时间线 | 事件 |
|---|---|
| 15:12 UTC | 恶意版 chalk@5.4.0-beta.1 发布 |
| 15:47 UTC | Vercel 自动构建触发,全球 Edge Function 同步拉取 |
| 16:05 UTC | 首例用户反馈“链上转账成功但资金未到账” |
| 17:39 UTC | Aikido 发出警报,npm 官方下架所有带毒版本 |
| 22:19 UTC | 主流云厂商完成构建缓存清零 |
仅 127 分钟,恶意包进入 Cloudflare、Vercel、Netlify、AWS Lambda 的默认缓存链;据 Aikido 抽样,10% 的云函数实例被污染,波及 2.3 万个站点。
冰山之下:18 个“核弹”完整清单
- chalk
- 周下载:3.0 亿
- 传递性依赖:4.7 万个包
- 典型上游:create-react-app、jest、eslint
- debug
- 周下载:3.6 亿
- 传递性依赖:5.9 万个包
- 典型上游:express、morgan、nodemon
- ansi-styles
- 周下载:3.7 亿
- 传递性依赖:3.2 万个包
- 典型上游:chalk、log-symbols、ora
- supports-color
- 周下载:3.5 亿
- 传递性依赖:3.0 万个包
- 典型上游:chalk、debug、webpack-dev-server
- has-flag
- 周下载:3.3 亿
- 传递性依赖:2.8 万个包
- 典型上游:supports-color、meow
- ms
- 周下载:3.1 亿
- 传递性依赖:4.5 万个包
- 典型上游:debug、send、serve-static
- strip-ansi
- 周下载:2.9 亿
- 传递性依赖:3.4 万个包
- 典型上游:chalk、ora、yargs
- is-fullwidth-code-point
- 周下载:2.8 亿
- 传递性依赖:2.6 万个包
- 典型上游:string-width、wide-align
- emoji-regex
- 周下载:2.7 亿
- 传递性依赖:2.4 万个包
- 典型上游:node-emoji、slackify-html
- fs.realpath
- 周下载:2.5 亿
- 传递性依赖:2.9 万个包
- 典型上游:glob、rimraf
- inflight
- 周下载:2.4 亿
- 传递性依赖:2.7 万个包
- 典型上游:glob、npm
- once
- 周下载:2.3 亿
- 传递性依赖:3.1 万个包
- 典型上游:glob、npm、request
- wrappy
- 周下载:2.2 亿
- 传递性依赖:2.5 万个包
- 典型上游:once、glob
- color-convert
- 周下载:2.1 亿
- 传递性依赖:2.2 万个包
- 典型上游:chalk、ansi-styles
- color-name
- 周下载:2.0 亿
- 传递性依赖:2.0 万个包
- 典型上游:color-convert
- balanced-match
- 周下载:1.9 亿
- 传递性依赖:2.3 万个包
- 典型上游:brace-expansion、minimatch
- concat-map
- 周下载:1.8 亿
- 传递性依赖:2.1 万个包
- 典型上游:brace-expansion
- brace-expansion
- 周下载:1.7 亿
- 传递性依赖:2.4 万个包
- 典型上游:minimatch、rimraf
以上 18 个包周下载总量 20.4 亿次,累计被 38 万个开源项目直接或间接依赖,构成现代前端与 Node 工具链的“水电煤”基础设施。
*传递性依赖:指直接/间接引用该包的 npm 项目总量,数据来源 libraries.io
开发者自救手册:3 条命令 1 分钟自检
# 1. 检查是否安装过带毒版本
npm ls chalk debug ansi-styles \
| grep -E '5\.4\.0-beta\.1|4\.3\.5-beta\.1|6\.2\.1-beta\.1'
# 2. 锁定干净版本
npm overrides \
"chalk@>=5.4.0-beta <5.4.1":"5.3.0" \
"debug@>=4.3.5-beta <4.3.6":"4.3.4"
# 3. 清空缓存 & 重装
npm cache clean --force
rm -rf node_modules package-lock.json
npm ci
Web3 用户额外建议:
在浏览器插件设置 → 隐私与安全 → 授权站点白名单,关闭“自动签名”功能,任何转账二次确认。
npm 官方回应与后续动作
- 2FA 强制令:2025 年 10 月 1 日起,所有周下载 >100 万的维护者必须硬件密钥(YubiKey/WebAuthn)+ 2FA,否则暂停发版权限;
- 发布“可验证构建”试点:源码与预编译产物在 GitHub Actions 中可重现哈希,npm registry 自动比对;
- 供应链实时雷达:与 GitHub Advisory DB、Snyk、OSV 打通,恶意版本 ≤15 分钟 全网黑名单。
写在最后:前端“水电煤”真的安全吗?
chalk 只是给终端上个色,debug 只是打印一条日志——但当它们成为 38 万个包的必经之路,就不再是“小工具”而是基础设施。
一次钓鱼邮件,就能让 20 亿次周活的“水电煤”瞬间投毒,这就是现代软件供应链的蝴蝶效应。
npm 生态的暴击提醒我们:
“不要信任、永远验证”不仅属于 Web3,也属于每一个 npm install 的瞬间。
来源:juejin.cn/post/7552725431779835947
为什么游戏公司现在都喜欢用protobuf?
点击上方亿元程序员+关注和★星标

引言
哈喽大家好,不知道小伙伴们最近有没有发现一个现象,无论是大厂还是小团队,越来越多的游戏项目都在使用protobuf作为数据交换格式。
笔者想起,在去年有幸研学过某个砍树游戏(寻道大千)源码的时候,发现他们用的也是protobuf。
曾经流行的JSON、XML似乎在游戏开发领域悄然退居二线,这到底是为什么?
今天我们就来聊聊protobuf为何能成为游戏行业的“新宠”。
什么是protobuf?
先简单科普一下
Protocol Buffers是
它能够将结构化数据序列化,适用于网络传输和数据存储。
与JSON和XML相比,
protobuf生成的二进制数据更小,解析速度更快。
游戏公司为何钟情于protobuf?
1.性能优势
游戏对性能的要求极为苛刻,尤其是网络游戏。
每毫秒的延迟都可能影响玩家的游戏体验。
Protobuf的二进制格式使得数据包体积比JSON小3-10倍,序列化和反序列化速度比JSON快5-100倍。

**说到这里有个小技巧:**小伙伴们可以先用JSON,然后领导要优化的时候,再改成Protobuf,实现质的飞跃,建议大家不要学。
2.跨平台跨语言
Protobuf支持多种编程语言(C++、C#、Java,JavaScript,TypeScript等),只需定义一次数据结构,即可在各个平台上使用。
笔者觉得这也是Protobuf生态好的原因之一。
3.向前向后兼容
Protobuf通过字段编号而非字段名来标识数据。
新增字段不会破坏旧版程序,老版本可以忽略新字段继续运行,这为需要频繁更新的游戏以及多变的游戏修改需求提供了便利。
一起来看个例子
既然Protobuf有这么多优点,深受众多公司青睐,那么我们一起来看看它在Cocos游戏开发中的实际表现。
1.搭建场景
首先简单搭建一下场景,分为三部分。
- 1.信息展示:包括头像和昵称。
- 2.登陆按钮:点击向服务器获取头像和昵称。
- 3.Loading效果:单纯为了让例子更好看。

2.搭建服务器
找AI搭子帮忙简单搭建一个简单的Http服务器。

代码比较简单,生成如下:

3.协议用JSON
用JSON比较容易上手,现在很多语言都自带JSON的编码和解析。
客户端
首先定义一下发送的结构,包括账号和密码,返回的消息包括是否成功、消息和用户信息。

然后请求服务器,JSON编码采用JSON.stringify(requestData),解码采用response.json()。

添加点击事件,加个Loading效果。(这里也有个小技巧,我们先假装Loading等待2秒,留一点优化空间,等领导反馈卡的时候再快点,十分好用,也建议大家不要学)

服务端
服务端做一下简单的处理,解码采用JSON.parse(buffer.toString()),编码采用JSON.stringify(loginResponse)。

启动服务器

效果演示

4.协议用Protobuf
用Protobuf需要先定义好协议文件,我们简单定义一下登录协议、登陆响应和登陆成功的用户数据。

与此同时,需要通过npm install -g pbjs安装一下生成工具。

客户端
首先通过pbjs ./proto/login.proto --ts ./assets/scripts/proto/login.ts生成一下ts代码。

与JSON类似,通过生成的编码接口encodeLoginRequest(loginRequest)和解码接口decodeLoginResponse(response),对数据进行编码和解析。

最后是发送。

服务端
首先通过pbjs ./proto/login.proto --es6 ./server/proto/login.js生成一下js代码。

服务端也是通过生成的解码接口proto.decodeLoginRequest(buffer)和编码接口proto.encodeLoginResponse(loginResponse)进行解码和编码。

效果演示

并非万能钥匙
根据上面的例子来看,protobuf并非在所有场景下都是最佳选择。
对于简单的示例或者小项目,引入protobuf需要安装环境、生成代码,反而增加了不必要的复杂度。
结语
游戏公司的项目一般都有一定规模,并且是团队开发,protobuf凭借其出色的性能,确实成为了游戏公司的优选方案。
但是笔者认为,不管谁强谁弱,只有真正适合自己的,才是最好的。
你们觉得呢?
本文源工程可通过私信发送 protobuf 获取。
我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。
AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。
实不相瞒,想要个赞和爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!
推荐专栏:
点击下方灰色按钮+关注。
来源:juejin.cn/post/7566103962497794086
当上传不再只是 /upload,我们是怎么设计大文件上传的
业务背景
在正式讲之前,先看一个我们做的大文件上传demo。
下面这个视频演示的是上传一个 1GB 的压缩包,整个过程支持分片上传、断点续传、暂停和恢复。
可以看到速度不是特别快,这个是我故意没去优化的。
前端那边计算文件 MD5、以及最后合并文件的时间我都保留了,
主要是想让大家能看到整个流程是怎么跑通的。

平时我们在做一些 SaaS 系统的时候,文件上传这块其实基本上都设计的挺简单的。
前端做个分片上传,后端把分片合并起来,最后存 OSS 或者服务器某个路径上,再返回一个 URL 就完事了。
大多数情况下,这样的方案也确实够用。
但是最近我在做一个私有化项目,场景完全不一样。
项目是给政企客户部署的内部系统,里面有 AI 大模型客服问答的功能。
客户需要把他们内部的文档、手册、规范、图纸、流程等资料打包上传到服务器,用来做后续的向量化、知识检索或者模型训练。
这类场景如果还沿用之前 SaaS 系统那种上传方式,往往就不太适用了。
因为这些文件往往有几个共同点:
- 文件数量多,动辄几百上千份(Word、PDF、PPT、Markdown 都有);
- 文件体积大,打成 zip 动不动就是几个 G,甚至十几二十个 G;
- 上传环境复杂,客户一般在内网或局域网,有的甚至完全断网;
- 有安全要求,文件不能经过云端 OSS,里面可能有保密资料;
- 需要审计,要能知道是谁上传的、什么时候传的、文件现在存哪;
- 上传完之后还要进一步处理,比如自动解压、解析文本、拆页、向量化,然后再存入 Milvus 或 pgvector。
在正式讲之前,先看一个我们做的大文件上传demo。
下面这个视频演示的是上传一个 1GB 的压缩包,整个过程支持分片上传、断点续传、暂停和恢复。
可以看到速度不是特别快,这个是我故意没去优化的。
前端那边计算文件 MD5、以及最后合并文件的时间我都保留了,
主要是想让大家能看到整个流程是怎么跑通的。

平时我们在做一些 SaaS 系统的时候,文件上传这块其实基本上都设计的挺简单的。
前端做个分片上传,后端把分片合并起来,最后存 OSS 或者服务器某个路径上,再返回一个 URL 就完事了。
大多数情况下,这样的方案也确实够用。
但是最近我在做一个私有化项目,场景完全不一样。
项目是给政企客户部署的内部系统,里面有 AI 大模型客服问答的功能。
客户需要把他们内部的文档、手册、规范、图纸、流程等资料打包上传到服务器,用来做后续的向量化、知识检索或者模型训练。
这类场景如果还沿用之前 SaaS 系统那种上传方式,往往就不太适用了。
因为这些文件往往有几个共同点:
- 文件数量多,动辄几百上千份(Word、PDF、PPT、Markdown 都有);
- 文件体积大,打成 zip 动不动就是几个 G,甚至十几二十个 G;
- 上传环境复杂,客户一般在内网或局域网,有的甚至完全断网;
- 有安全要求,文件不能经过云端 OSS,里面可能有保密资料;
- 需要审计,要能知道是谁上传的、什么时候传的、文件现在存哪;
- 上传完之后还要进一步处理,比如自动解压、解析文本、拆页、向量化,然后再存入 Milvus 或 pgvector。
所以这种情况还用 SaaS 系统那种“简单上传+云存储”方案的话,那可能问题就一堆:
- 上传中断后用户一刷新浏览器就得重传整个包;
- 集群部署时分片打到不同机器上根本无法合并;
- 多人同时上传可能会发生文件覆盖或路径冲突;
- 没有任何上传记录,也追踪不到是谁传的;
- 对政企来说,审计、合规、保密全都不达标。
所以,我们需要重新设计文件上传的功能逻辑。
目的是让它不仅能支持大文件、断点续传、集群部署,还能同时适配内网环境、权限管控,以及后续的 AI 文档解析和知识向量化等处理流程。
为什么很多项目只需要一个 upload 接口
如果我们回头看一下自己平常做过的一些常规 Web 项目,尤其是各种 SaaS 系统或者后台管理系统,
其实大多数时候后端只会提供一个 /upload 接口, 前端拿到文件后直接调用这个接口,后端保存文件再返回一个 URL 就结束了。
甚至我们在很多项目里,前端都不会把文件传给业务服务,
而是直接通过前端 SDK(比如阿里云 OSS、腾讯云 COS、七牛云等)上传到云存储,
上传完后拿到文件地址,再把这个地址回传给后端保存。
这种方式在 SaaS 系统或者轻量级的业务里非常普遍,也非常高效。 主要原因有几个:
- 文件都比较小,大多数就是几 MB 的图片、PDF 或 Excel;
- 云存储足够稳定,上传、下载、访问都有完整的 SDK 支撑;
- 系统是公网部署,不需要考虑局域网、内网断网这些问题;
- 对安全和审计的要求不高,文件内容也不是涉密数据;
- 用户体验优先,所以直接把文件上传到云端是最省事的方案。
换句话说,这种“一个 upload 接口”或“前端直传 OSS”模式,其实是面向通用型 SaaS 场景的。
对于绝大多数互联网业务来说,它既够快又够省心。
但一旦项目换成政企、私有化部署或者 AI 训练平台这种环境,
就完全不是一个量级的问题了。
这里的关键不在“能不能上传”,
而在于文件上传之后的可控性、可追溯性和安全性。
前端常见的大文件上传方式
在重新设计后端接口之前,我们先来看看现在前端常见的大文件上传思路。
其实近几年前端这块已经比较成熟了,主流方案大体都是围绕几个核心点展开的:
秒传检测、分片上传、断点续传、并发控制、进度展示。
一般来说,前端拿到文件后,会先计算一个文件哈希值,比如用 MD5。
这样做的目的是为了做秒传检测:
如果服务器上已经存在这个文件,就可以直接跳过上传,节省时间和带宽。
接下来是分片上传。
文件太大时,前端会把文件拆成多个固定大小的小块(比如每块 5MB 或 10MB),
然后一片一片地上传。这样做可以避免一次性传输大文件导致浏览器卡顿或网络中断。
然后就是断点续传。
前端会记录哪些分片已经上传成功,如果上传过程中网络中断或浏览器刷新,
下次只需要从未完成的分片继续上传,不用重新传整包文件。
在性能方面,前端还会做并发控制。
比如同时上传三到五个分片,上传完一个就立刻补下一个,
这样整体速度比单线程串行上传要快很多。
最后是进度展示。
通过监听每个分片的上传状态,前端可以计算整体进度,
给用户展示一个实时的上传百分比或进度条,让体验更可控。
可以看到,前端的大文件上传方案已经形成了一套相对标准的模式。
所以这次我在重新设计后端的时候,就打算基于这种前端逻辑,
去构建一套更贴合企业私有化环境的上传接口控制体系。
目标是让前后端的职责划分更清晰:
前端负责切片、控制与恢复;后端负责存储、校验与合并。
后端接口设计思路
前端的大文件上传流程其实已经相对固定了,我们只要让后端的接口和它配合得上,就能把整个上传链路打通。
所以我这次重新设计时,把上传接口拆成了几个比较独立的阶段:
秒传检查、初始化任务、上传分片、合并文件、暂停任务、取消任务、任务列表。
每个接口都只负责一件事,这样接口的职责会更清晰,也方便后期扩展。
一、/upload/check —— 秒传检查
这个接口是整个流程的第一步,用来判断文件是否已经上传过。
前端在计算完文件的全局 MD5(或其他 hash)后,会先调这个接口。
如果后端发现数据库里已经有相同 hash 的文件,就直接返回“已存在”,前端就不用再上传了。
请求示例:
POST /api/upload/check
{
"fileHash": "md5_abc123def456",
"fileName": "training-docs.zip",
"fileSize": 5342245120
}
返回示例:
{
"success": true,
"data": {
"exists": false
}
}
如果 exists = true,说明服务端已经有这个文件,可以直接走“秒传成功”的逻辑。
伪代码示例:
@PostMapping("/check")
public Result checkFile(@RequestBody Map body) {
// 1. 校验 fileHash 参数是否为空
// 2. 查询 file_info 表是否已有该文件
// 3. 如果文件已存在,直接返回秒传成功(exists = true)
// 4. 如果文件不存在,查询 upload_task 表中是否有未完成任务(支持断点续传)
}
二、/upload/init —— 初始化上传任务
如果文件不存在,就要先初始化一个新的上传任务。
这个接口的作用是创建一条 upload_task 记录,同时返回一个唯一的 uploadId。
前端会用这个 uploadId 来标识整个上传过程。
请求示例:
POST /api/upload/init
{
"fileHash": "md5_abc123def456",
"fileName": "training-docs.zip",
"totalChunks": 320,
"chunkSize": 5242880
}
返回示例:
{
"success": true,
"data": {
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"uploadedChunks": []
}
}
uploadedChunks 用来支持断点续传,如果之前有部分分片上传过,就会在这里返回索引数组。
伪代码示例:
@PostMapping("/init")
public Result initUpload(@RequestBody UploadInitRequest request) {
// 1. 检查是否已有同 fileHash 的任务,若有则返回旧任务信息(支持断点续传)
// 2. 否则创建新的 upload_task 记录,生成 uploadId
// 3. 初始化分片数量、大小、状态等信息
// 4. 返回 uploadId 与已上传分片索引列表
}
三、/upload/chunk —— 上传单个分片
这是整个上传过程里调用次数最多的接口。
每个分片都会单独上传一次,并在服务端保存为临时文件,同时写入 upload_chunk 表。
上传成功后,后端会更新 upload_task 的进度信息。
请求示例(表单上传):
POST /api/upload/chunk
Content-Type: multipart/form-data
formData:
uploadId: b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b
chunkIndex: 0
chunkSize: 5242880
chunkHash: md5_001
file: (二进制分片数据)
返回示例:
{
"success": true,
"data": {
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"chunkIndex": 0,
"chunkSize": 5242880
}
}
伪代码示例:
@PostMapping(value = "/chunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result uploadChunk(@ModelAttribute UploadChunkRequest req) {
// 1. 校验任务状态,禁止上传已取消或已完成的任务
// 2. 检查本地目录(或云端存储桶)是否存在,不存在则创建
// 3. 接收当前分片文件并写入临时路径
// 4. 写入 upload_chunk 表,标记状态为 “已上传”
// 5. 更新 upload_task 的 uploaded_chunks 数量
}
四、/upload/merge —— 合并分片
当前端确认所有分片都上传完后,会调用 /upload/merge。
后端收到这个请求后,去检查所有分片是否完整,然后按照索引顺序依次合并。
合并成功后,会删除临时分片文件,并更新 upload_task 状态为“完成”。
如果启用了云存储,这一步也可以直接把合并后的文件上传到 OSS。
请求示例:
POST /api/upload/merge
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"fileHash": "md5_abc123def456"
}
返回示例:
{
"success": true,
"message": "文件合并成功",
"data": {
"storagePath": "/data/uploads/training-docs.zip"
}
}
伪代码示例:
@PostMapping("/merge")
public Result mergeFile(@RequestBody UploadMergeRequest req) {
// 1. 检查 upload_task 状态是否允许合并
// 2. 校验所有分片是否都上传完成
// 3. 如果是本地存储:按 chunk_index 顺序流式合并文件
// 4. 如果是云存储:调用云端分片合并 API(如 OSS、COS)
// 5. 校验文件 hash 完整性,更新任务状态为 COMPLETED
// 6. 将最终文件信息写入 file_info 表
}
五、/upload/pause —— 暂停任务
这个接口用于在上传过程中手动暂停任务。
前端可能会在网络波动或用户主动点击暂停时调用。
后端会更新任务状态为“已暂停”,并记录当前已上传的分片数。
请求示例:
POST /api/upload/pause
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b"
}
返回示例:
{
"success": true,
"message": "任务已暂停"
}
伪代码示例:
@PostMapping("/pause")
public Result pauseUpload(@RequestBody UploadPauseRequest req) {
// 1. 查找对应的 upload_task
// 2. 更新任务状态为 “已暂停”
// 3. 返回任务状态确认信息
}
六、/upload/cancel —— 取消任务
如果用户想放弃本次上传,可以调用 /cancel。
后端会把任务状态标记为“已取消”,并清理对应的临时分片文件。
这样能避免磁盘上堆积无用数据。
请求示例:
POST /api/upload/cancel
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b"
}
返回示例:
{
"success": true,
"message": "任务已取消"
}
伪代码示例:
@PostMapping("/cancel")
public Result cancelUpload(@RequestBody UploadCancelRequest req) {
// 1. 查找对应的 upload_task
// 2. 更新任务状态为 “已取消”
// 3. 删除或标记已上传的分片文件为待清理
// 4. 返回操作结果
}
七、/upload/list —— 查询任务列表
这个接口我们用于管理后台查看当前上传任务的整体情况。
可以展示每个任务的文件名、大小、进度、状态、上传人等信息,方便追踪和审计。
请求示例:
GET /api/upload/list
返回示例:
{
"success": true,
"data": [
{
"uploadId": "b4f8e3a7-1a0c-4a1d-88af-61e98d91a49b",
"fileName": "training-docs.zip",
"status": "COMPLETED",
"uploadedChunks": 320,
"totalChunks": 320,
"uploader": "admin",
"createdAt": "2025-10-20 14:30:12"
}
]
}
伪代码示例:
@GetMapping("/list")
public Result> listUploadTasks() {
// 1. 查询所有上传任务
// 2. 按创建时间或状态排序
// 3. 返回任务摘要信息(任务名、状态、进度、上传人等)
}
接口调用顺序小结
那我们这整个上传过程的调用顺序就是:
1. /upload/check → 秒传检测
2. /upload/init → 初始化上传任务
3. /upload/chunk → 循环上传所有分片
4. /upload/merge → 所有分片完成后合并
(可选)/upload/pause、/upload/cancel 用于控制任务
(可选)/upload/list 用于任务追踪与审计
接口调用顺序示意图
下面这张时序图展示了前端、后端、数据库在整个上传过程中的交互关系。

这样安排有几个好处:
- 逻辑衔接顺:上面刚讲完每个接口的职责,下面立刻用图总结;
- 视觉节奏平衡:读者读到这里已经看了不少文字,用图能缓解阅读疲劳;
- 承上启下:这张图既总结接口流程,又能自然引出下一节“数据库表设计”。
这套接口设计基本能覆盖大文件上传在企业项目中的常见需求。
接下来,我们再来看看支撑这套接口背后的数据库表设计。
数据库的作用是让上传任务的状态可追踪、可恢复,也能在集群部署时保持一致性。
数据库表设计思路
前面说的那一套接口,要真正稳定地跑起来,
后端必须有一套能记录任务状态、分片信息、文件存储路径的数据库结构。
因为上传这种场景不是“一次请求就结束”的操作,它往往会持续几分钟甚至几个小时,
所以我们需要让任务状态可以追踪、可以恢复,还要能支撑集群部署。
我这次主要设计了三张核心表:upload_task(上传任务表)、upload_chunk(分片表)、file_info(文件信息表)。
它们分别负责记录任务、分片和最终文件三层的数据关系。
一、upload_task —— 上传任务表
这张表是整个上传过程的“总账”,
每一个文件上传任务,不管分成多少片,都会在这里生成一条记录。
它主要用来保存任务的全局信息,比如文件名、大小、上传进度、状态、存储方式等。
CREATE TABLE `upload_task` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`upload_id` varchar(64) NOT NULL COMMENT '任务唯一ID(UUID)',
`file_hash` varchar(64) NOT NULL COMMENT '文件哈希(用于秒传与断点续传)',
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
`file_size` bigint(20) NOT NULL COMMENT '文件总大小(字节)',
`chunk_size` bigint(20) NOT NULL COMMENT '每个分片大小(字节)',
`total_chunks` int(11) NOT NULL COMMENT '分片总数',
`uploaded_chunks` int(11) DEFAULT '0' COMMENT '已上传分片数量',
`status` tinyint(4) DEFAULT '0' COMMENT '任务状态:0-待上传 1-上传中 2-合并中 3-完成 4-取消 5-失败 6-已合并 7-已暂停',
`storage_type` varchar(32) DEFAULT 'local' COMMENT '存储类型:local/oss/cos/minio/s3等',
`storage_url` varchar(512) DEFAULT NULL COMMENT '文件最终存储地址(云端或本地路径)',
`local_path` varchar(512) DEFAULT NULL COMMENT '本地临时文件或合并文件路径',
`remark` varchar(255) DEFAULT NULL COMMENT '备注信息',
`uploader` varchar(64) DEFAULT NULL COMMENT '上传人',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `upload_id` (`upload_id`),
KEY `idx_hash` (`file_hash`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传任务表(支持多种云存储)';
设计要点:
upload_id是前端初始化任务后由后端生成的唯一标识;file_hash用来支持秒传逻辑;status控制任务生命周期(等待、上传中、合并中、完成等);storage_type、storage_url可以兼容多种存储方案(本地、OSS、COS、MinIO);uploaded_chunks字段让任务能随时恢复,适配断点续传。
二、upload_chunk —— 分片表
这张表对应每个上传任务下的所有分片。
每一个分片都会单独在这里占一条记录,用来追踪它的上传状态。
这张表的存在让我们能做断点续传、进度统计、以及合并前的完整性检查。
CREATE TABLE `upload_chunk` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`upload_id` varchar(64) NOT NULL COMMENT '所属上传任务ID',
`chunk_index` int(11) NOT NULL COMMENT '分片索引(从0开始)',
`chunk_size` bigint(20) NOT NULL COMMENT '实际分片大小(字节)',
`chunk_hash` varchar(64) DEFAULT NULL COMMENT '可选:分片hash(用于高级去重)',
`status` tinyint(4) DEFAULT '0' COMMENT '状态:0-待上传 1-已上传 2-已合并',
`local_path` varchar(512) DEFAULT NULL COMMENT '分片本地路径',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_task_chunk` (`upload_id`,`chunk_index`),
KEY `idx_upload_id` (`upload_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传分片表';
设计要点:
upload_id是任务外键,和upload_task一一对应;chunk_index代表分片顺序,合并文件时会按这个排序;chunk_hash可选字段,用来在上传前后做完整性校验;status字段控制上传进度(待上传、已上传、已合并);- 唯一索引 (
upload_id,chunk_index) 避免重复插入分片。
通过这张表,我们可以轻松实现断点续传:
当用户重新开始上传时,后端只返回未完成的分片索引,前端跳过已上传的部分。
三、file_info —— 文件信息表
这张表记录的是上传完成后的“最终文件信息”,
相当于系统的文件索引表。只要文件合并成功并通过校验,
后端就会往这里写入一条记录。
这张表支撑秒传功能,也能被后续的文档解析或向量化任务使用。
CREATE TABLE `file_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`file_hash` varchar(64) NOT NULL COMMENT '文件hash,用于秒传',
`file_name` varchar(255) NOT NULL COMMENT '文件名称',
`file_size` bigint(20) NOT NULL COMMENT '文件大小',
`storage_type` varchar(32) DEFAULT 'local' COMMENT '存储类型:local/oss/cos/minio/s3等',
`storage_url` varchar(512) DEFAULT NULL COMMENT '文件最终存储地址(云端或本地路径)',
`uploader` varchar(64) DEFAULT NULL COMMENT '上传人',
`status` tinyint(4) DEFAULT '1' COMMENT '状态:1-正常,2-删除中,3-已归档',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `file_hash` (`file_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已上传文件信息表(支持多云存储)';
设计要点:
file_hash是全局唯一标识,用于秒传和查重;storage_url记录最终可访问路径;status可扩展为删除、归档等后续操作;- 这张表和业务系统中的“文档解析”、“知识库构建”可以直接关联。
四、三张表之间的关系
这三张表之间的关系我们可以简单理解为:
upload_task (上传任务)
├── upload_chunk (分片详情)
└── file_info (最终文件)
upload_task管理任务生命周期;upload_chunk跟踪每个分片的上传进度;file_info保存最终文件索引,用于秒传与后续 AI 处理。
这样设计的好处是:
- 上传状态可追踪;
- 上传任务可恢复;
- 文件信息可统一管理;
- 多节点部署也能保证一致性。
上传状态流转与任务恢复机制
有了前面的三张核心表,整个上传的过程就能被“状态机化”管理。
简单来说,我们希望每一个上传任务从创建、上传、合并到完成,都能有一个明确的状态,
系统也能在任意阶段中断后恢复,不需要用户重新来一遍。
我们把整个上传任务的生命周期划分成几个关键状态:
WAITING(0, "待上传"),
UPLOADING(1, "上传中"),
MERGING(2, "合并中"),
COMPLETED(3, "已完成"),
CANCELED(4, "已取消"),
FAILED(5, "上传失败"),
CHUNK_MERGED(6, "已合并"),
PAUSED(7, "已暂停");
一、WAITING(待上传)
当用户在前端发起上传、文件切片还没真正传上来之前,
系统会先生成一个上传任务记录(也就是 /upload/init 接口那一步)。
这个时候任务只是“登记”在数据库里,还没开始传数据。
我们可以理解为:
任务刚创建,还没开始跑。
此时前端拿到 uploadId,就可以开始逐片上传了。
在数据库层面,upload_task.status = 0,所有的分片表里还没有数据。
二、UPLOADING(上传中)
当第一个分片开始上传时,系统会把任务状态更新为 上传中。
这时候每上传一块分片,都会往 upload_chunk 表里写入一条记录,
并且更新任务的 uploaded_chunks 字段。
我们会周期性地根据分片上传数量去更新进度条,
比如已上传 35 / 100 块,系统就知道这部分可以恢复。
这个阶段是任务生命周期里最活跃的一段:
用户可能暂停、断网、刷新页面、甚至浏览器崩溃。
但是没关系,因为分片信息都落地到数据库了,
我们能随时通过 upload_chunk 的状态重新恢复上传。
三、PAUSED(已暂停)
如果用户主动点击“暂停上传”,
系统就会把任务状态标记为 PAUSED。
暂停并不会删除分片,只是告诉系统“不要再继续发请求”。
这样当用户重新点击“继续上传”时,
前端只需从后端拿到“哪些分片还没上传”,就能断点续传。
这个状态一般只在用户控制的情况下出现,
比如网络不好、或者中途切换网络时暂停。
四、CANCELED(已取消)
取消和暂停不同,取消意味着用户彻底放弃了这个上传任务。
任务会被标记为 CANCELED,同时系统可以选择:
- 删除已经上传的临时分片文件;
- 或者保留一段时间等待清理任务。
在后台日志中,这个状态主要用于审计:
记录谁取消了任务、在什么时间、上传了多少进度。
五、MERGING(合并中)
当所有分片都上传完成后,
后端会自动或手动触发文件合并逻辑(调用 /upload/merge)。
此时任务状态会切换为 MERGING,表示系统正在进行最后一步。
在这一步里:
- 如果是本地存储,会逐个读取分片文件并拼接为完整文件;
- 如果是云存储(比如 OSS、MinIO),则会触发服务端的分片合并 API。
合并过程通常比较耗时,尤其是几 GB 的文件,
所以单独拿出来作为一个明确状态是必要的。
六、CHUNK_MERGED(已合并)
有些系统会把合并成功但未做后续处理的状态单独标出来,
比如文件已经合并,但还没入库、还没解析。
这个状态可以让我们在合并之后还有机会做文件校验或后处理。
不过在实际项目里,也可以直接跳过这一步,
合并完后立刻进入下一状态——COMPLETED。
七、COMPLETED(已完成)
文件合并完成、验证通过、存储路径落地、写入 file_info 表,
这时候任务就算彻底完成了。
在这个状态下:
- 用户可以正常访问文件;
- 系统可以执行后续的解析任务(比如文档拆页、向量化等);
- 文件具备秒传条件,下次再上传同样的文件会直接跳过。
COMPLETED 是整个生命周期的终点状态。
在数据库中,任务记录会更新最终路径、存储类型、完成时间等字段。
八、FAILED(上传失败)
上传过程中如果出现异常,比如网络中断、磁盘写入异常、OSS 上传失败等,
系统会标记任务为 FAILED。
这一状态不会自动清理,
方便管理员事后追踪错误原因或人工恢复。
失败任务在设计上一般允许“重新启动”,
也就是通过任务 ID 重新触发上传,从未完成的分片继续。
我们可以通过下面这张图可以更直观地看到整个上传任务的生命周期:

九、任务恢复机制
在这套机制下,任务恢复就变得非常自然。
前端每次进入上传页面时,只要传入文件的 hash,
后端就能通过 upload_task 和 upload_chunk 判断:
- 这个文件有没有上传任务;
- 如果有,哪些分片已经上传;
- 任务当前状态是什么(暂停、失败还是上传中)。
然后前端只需补传那些未上传的分片即可。
这就是我们常说的 断点续传(Resumable Upload) 。
在集群环境中,这套逻辑同样成立,
因为任务与分片状态都落在数据库,不依赖单台服务器。
无论请求打到哪一台机器,上传进度都是统一可见的。
十、中断后如何续传
在实际使用中,用户上传中断是很常见的。
比如文件太大上传到一半,浏览器突然关了;
或者公司网络断了,机器重启了;
甚至有人直接换了电脑继续操作。
如果系统没有任务恢复机制,那用户每次都得重新传一遍,
尤其是那种几个 G 的文件,不但浪费时间,还容易出错。
所以我们在设计这套上传中心时,
一开始就考虑了“断点续传”和“任务恢复”的问题。
1. 恢复上传靠的其实是数据库里的状态
断点续传的核心逻辑,其实很简单:
我们让任务和分片的状态都写进数据库。
每当用户重新进入上传页面、选中同一个文件时,
前端会先计算出文件的 hash,然后调用 /upload/check 接口。
后端收到 hash 后,会依次去查三张表:
- 先查
file_info
如果能查到,说明文件之前已经上传并合并成功,
这时候直接返回“文件已存在”,前端就能实现“秒传”,不需要重新上传。 - 查不到
file_info,就去查upload_task
如果找到了对应任务,就说明这个文件上传到一半被中断了。
这时我们会返回这个任务的 uploadId。 - 再查 `upload_chunk``
系统会统计出哪些分片已经上传成功,哪些还没传。
然后返回一个“未完成的分片索引列表”给前端。
前端拿到这些信息后,就能从中断的地方继续往下传,
不用再重复上传已经完成的部分。
2. 前端续传时的流程
前端拿到旧的 uploadId 和未完成分片列表后,
只需要跳过那些已经上传成功的分片,
然后照常调用 /upload/chunk 去上传剩下的部分。
上传过程中,每个分片的状态都会被实时更新到 upload_chunk 表中,upload_task 表的 uploaded_chunks 也会跟着同步增加。
当所有分片都上传完后,任务状态自动进入 MERGING(合并中)阶段。
所以整个续传过程,其实就是**“基于数据库状态的增量上传”**。
用户不需要额外操作,系统自己就能恢复上次的进度。
3. 任务状态和恢复判断
任务是否允许恢复,系统会根据 upload_task.status 来判断。
大致逻辑是这样的:
| 状态 | 是否可恢复 | 说明 |
|---|---|---|
| WAITING | 可以 | 任务刚创建,还没开始传 |
| UPLOADING | 可以 | 正在上传中,可以继续 |
| PAUSED | 可以 | 用户主动暂停,可以恢复 |
| FAILED | 可以 | 上传失败,可以重新尝试 |
| CANCELED | 不可以 | 用户主动取消,不再恢复 |
| COMPLETED | 不需要 | 已经完成,直接秒传 |
| MERGING | 等待中 | 系统正在合并,前端等待即可 |
这套判断逻辑让任务的行为更清晰。
比如用户暂停上传再回来时,可以直接恢复;
如果任务已经取消,那就算用户重启也不会再自动续传。
4. 多机器部署下的恢复问题
有些人会担心:如果我们的系统是集群部署的,
上传时中断后再续传,万一请求打到另一台机器上,
还能恢复吗?
其实没问题。
因为我们所有任务和分片的状态都是写进数据库的,
不依赖内存或本地文件。
也就是说,哪怕用户上次上传在 A 机器,这次续传到了 B 机器,
系统仍然能根据数据库的记录知道:
这个 uploadId 下的哪些分片已经上传完,哪些还没传。
所以集群部署下也能无缝续传,不会出现“不同机器不认任务”的情况。
5. 小结
整个任务恢复机制靠的就是两张表:upload_task 和 upload_chunk。upload_task 负责记录任务总体进度,upload_chunk 负责记录每个分片的上传状态。
当用户重新上传时,我们查表判断进度,
前端从未完成的地方继续传,就能实现真正意义上的“断点续传”。
这套机制有几个显著的好处:
- 上传进度可追踪;
- 中断后可恢复;
- 支持集群部署;
- 不依赖浏览器缓存或 Session。
所以,只要数据库没丢,任务记录还在,
上传进度就能恢复,哪怕换机器、重启系统都没问题。
文件合并与完整性校验
前面的所有步骤,其实都是在为这一刻做准备。
当用户的所有分片都上传完成后,接下来最重要的工作就是:
把这些分片拼成一个完整的文件,并且确保文件内容没有出错。
这一步看似简单,但其实是整个大文件上传流程里最容易出问题的地方。
尤其在集群部署下,如果不同分片分布在不同机器上,
那合并逻辑就不能只靠本地文件路径去拼接,否则根本找不到所有分片。
所以我们先来理一理整个思路。
一、合并的触发时机
前端在检测到所有分片都上传完成后,会调用 /upload/merge 接口。
这个接口的作用就是通知后端:
“这个任务的所有分片都传完了,现在可以开始合并了。”
后端接收到请求后,会先去查数据库确认几个关键信息:
- 这个任务对应的 uploadId 是否存在;
upload_chunk表里所有分片是否都处于 “已上传” 状态;- 当前任务状态是否允许合并(例如不是暂停、取消或失败)。
确认无误后,任务状态会从 UPLOADING 变成 MERGING,
正式进入文件合并阶段。
二、本地合并逻辑
如果系统配置的是本地存储(也就是 cloud.enable = false),
那所有分片文件都保存在服务器的临时目录中。
合并逻辑大致是这样的:
- 后端按分片的
chunk_index顺序,依次读取每个分片文件。 - 逐个写入到一个新的目标文件中,比如
merge.zip。 - 每合并一个分片,就更新数据库中的状态。
- 合并完成后,把任务状态更新为
COMPLETED,并写入最终路径。
整个过程看起来很直观,
但这里有两个要点需要特别注意:
- 写入顺序要严格按照分片索引,否则文件内容会错乱;
- 文件 IO 要用流式写入(Stream) ,避免内存一次性读取所有分片导致溢出。
合并完成后,我们会计算整个文件的 MD5,与原始 fileHash 对比,
如果不一致,就说明合并过程中数据丢失或出错。
这种情况任务会被标记为 FAILED,并在日志中留下异常记录。
三、云端合并逻辑
如果我们配置了云存储(比如 OSS、COS、MinIO 等),
那分片文件就不是存在本地磁盘,而是上传到云端的对象存储桶里。
在这种情况下,合并逻辑就不需要我们自己拼文件了,
因为大部分云存储服务都提供了“分片合并”的 API。
比如以 OSS 为例,上传时我们调用的是 uploadPart 接口,
合并时只需要调用 completeMultipartUpload,
它会根据上传时的分片顺序自动合并为一个完整对象。
整个过程的优点是:
- 不占用本地磁盘;
- 不受单机 IO 限制;
- 云端自动校验每个分片的完整性。
所以在云存储场景下,我们只需要做两件事:
- 通知云服务去执行合并;
- 成功后记录最终的文件地址(
storage_url)到数据库。
这样整个流程就闭环了。
四、集群部署下的合并问题
单机情况下,合并很简单,因为所有分片都在本地。
但如果系统是集群部署的,分片请求可能打到了不同机器,
这时候分片文件就会分散在多个节点上。
我们在设计时考虑了三种解决方案:
方案 1:共享存储(私有化部署下比较推荐)
最常见的做法是把所有机器的上传目录指向同一个共享路径,
比如通过 NFS、NAS、或对象存储挂载到 /data/uploads。
这样无论用户上传的分片打到哪台机器,
最终都会写入同一个物理目录。
当合并请求发起时,任意一台机器都能访问到完整的分片文件。
这是目前在企业部署中最稳定、最通用的方案。
方案 2:云存储中转
如果机器之间没有共享目录,那我们可以让每个分片先上传到云端,
合并时再调用云服务的 API 进行分片合并。
这种方式适合公网可访问的 SaaS 环境。
但对于政企内网部署,就不一定行得通。
方案 3:统一调度节点
还有一种是我们自己维护一个“合并调度节点”,
所有分片上传完后,系统会把合并任务分配到一个指定节点执行,
这个节点会从其他机器拉取分片(比如通过 HTTP 内部传输或 RPC)。
这种方式更复杂,适合大规模分布式存储场景。
在私有化项目中,我们一般采用第一种方式——共享目录 + 本地合并。
既能保证性能,也能兼顾安全性。
五、完整性校验
文件合并完成后,最后一步是完整性校验。
我们会重新计算合并后文件的 MD5,与前端最初上传的 fileHash 对比。
如果一致,就说明文件合并成功,内容没有丢失;
如果不一致,就说明某个分片损坏或顺序错误,
任务会被标记为 FAILED,并自动记录错误日志。
这样可以确保文件数据的安全性,
避免在后续 AI 解析或向量化阶段出现内容异常。
六、异步处理与性能优化
开头的视频里我们也看到了,整个上传和合并过程我们是同步执行的。
从前端开始上传分片,到最后文件合并完成,都在等待同一个流程走完。
这种方式在演示时很直观,但在真实项目中其实问题不少。
最明显的一个问题就是——时间太长。
像我们刚才那个 1GB 的文件,即使网络稳定、服务器性能还可以,
整个流程也要几分钟甚至更久。
如果我们让前端一直等待响应,接口超时、连接断开、前端刷新这些问题就都会冒出来。
所以,在真正的业务系统里,我们一般会把合并、校验、迁移 OSS 或解析入库这些操作改成异步任务来做。
接口只负责接收分片、登记状态,然后立刻返回“任务已创建”或“上传完成,正在处理中”的提示。
后续的合并、校验、清理临时文件这些工作交给后台的异步线程、任务队列或者调度器去跑。
这样做的好处有几个:
- 前端体验更流畅,不用卡在“等待合并”阶段;
- 后端可以批量处理任务,减少高峰期的 IO 压力;
- 如果任务失败或中断,也能通过任务表重试或补偿;
- 对接外部存储或 AI 解析流程时,也能自然衔接后续任务链。
简单来说,上传只是第一步,
而合并、校验、转存这些操作本质上更像是后台任务。
我们在系统设计时只要把这些环节分开,让接口尽量“轻”,
这套上传系统就能在面对更大文件、更复杂场景时依然稳定可靠。
七、小结
整个合并与校验阶段,是把前面所有分片上传工作“收尾”的过程。
我们通过以下机制保证了稳定性:
- 本地存储场景下:顺序读取 + 流式写入 + hash 校验;
- 云存储场景下:依赖云端分片合并 API;
- 集群环境下:通过共享存储或统一调度节点解决文件分散问题;
- 数据库层面:实时记录状态,便于追踪和审计。
最终,当文件合并成功、校验通过后,
系统会将结果写入 file_info 表,
整条上传链路就算是完整闭环。
最后
我们平常做的项目,大多数时候文件上传都挺简单的。
前端传到 OSS,后端接个地址存起来就行。
但等真正做私有化项目的时候,也就会发现很多地方都不一样了。
要求更多,考虑的细节也多得多。
像这次做的大文件上传就是个很典型的例子。
以前那种简单方案,放在这种环境下就完全不够用了。
得考虑断点续传、任务恢复、集群部署、权限、审计这些东西,
一步没想好,后面全是坑。
我们现在这套设计,其实就是在解决这些“现实问题”。
接口虽然多一点,但每个职责都很清晰,
任务状态能追踪,上传中断能恢复,
甚至以后如果我们想单独抽出来做一个文件系统模块也完全没问题。
不管是拿来给知识库用,还是 AI 向量化、文档解析,这套逻辑都能复用。
其实很多以前觉得“简单”的功能,
一旦遇到复杂场景,其实都得重新想。
但好处是,一旦做通了,这套东西就能稳定用很久。
到这里,大文件上传这块我们算是完整走了一遍。
以后再遇到类似需求,我们就有经验了,
不用再从头掉坑里爬出来一次哈。
来源:juejin.cn/post/7571355989133099023
只有 7 KB!前端圈疯传的 Vue3 转场动效神库!效果炸裂!
“只要 7 KB,就能把多页站变成丝滑 SPA!”——这句话在前端圈疯传的神库,就是 Barba.js。
今天,我们就把它和 Vue3 搭配,手把手带你做出**“效果炸裂”**的页面切换动效!

为什么是 Barba.js × Vue3?
- 天生轻量:
gzip后仅7 KB,0 依赖,对Vue3的bundle体积几乎没影响。 - 渐进增强:不改现有路由,只要把需要动效的部分包一层
data-barba="container",老项目也能无痛升级。 - 动画自由:官方只负责“切换生命周期”,真正的视觉冲击可以交给
GSAP、Anime.js或Vue3的<Transition>组件。 - 社区炸裂:GitHub
1.2 w+star,大量 Vue3 样板可直接抄作业。

30 秒极速上手
1.安装
npm i @barba/core @barba/css # 核心 + 零 JS 动画辅助
npm i gsap # 想玩高级动效再装
2.HTML 骨架(Vue3 单页或多页皆可)
<body data-barba="wrapper">
<header>公共头部</header>
<!-- Vue3 挂载点,也是 Barba 需要替换的区域 -->
<main id="app"
data-barba="container"
data-barba-namespace="home">
<!-- 这里放 <RouterView/> 或直接放组件 -->
</main>
<footer>公共底部</footer>
</body>
3.初始化(main.ts)
import barba from '@barba/core'
import barbaCss from '@barba/css'
import gsap from 'gsap'
barba.use(barbaCss) // 先让 Barba 帮你加/删 class
barba.init({
transitions: [{
name: 'cover', // 自定义名字
sync: true, // 进出同时执行,更顺滑
leave({ current }) {
// 当前页面滑出
return gsap.to(current.container, {
y: '-100%',
opacity: 0,
duration: 0.6,
ease: 'power2.inOut'
})
},
enter({ next }) {
// 新页面滑入
gsap.from(next.container, {
y: '100%',
opacity: 0,
duration: 0.6,
ease: 'power2.inOut'
})
}
}]
})
⚠️ 注意:
- 每次切换完成后,手动重新挂载 Vue3 实例(如果使用多页模式),或让组件复用
<keep-alive>。
- Barba 会帮你更新浏览器
history,SEO不受影响。
实战:3 个效果炸裂的技巧
视差 + 蒙版过渡
在 leave 钩子用 GSAP 把旧页面做 clip-path 收缩,新页面做 视差滑动,视觉冲击直接拉满。

路由级差异化动效
利用 @barba/router 给 /home → /about 用 淡入淡出,给 /portfolio/* → /portfolio/* 用 3D 翻转,保持品牌调性一致。

鼠标悬停预加载
打开 @barba/prefetch,用户还没点击就把下一页提前拉回来,真正“秒开”体验。

常见踩坑 & 解决方案
🔴 场景 1:首屏闪白
症状:刷新后能看到瞬间白屏,然后内容才出现。
快速解法:
- 给
<body data-barba="wrapper">先加style="opacity:0"。 - 在
barba.init里写一次性的once过渡:
once({ next }) {
gsap.fromTo(next.container,
{ opacity: 0 },
{ opacity: 1, duration: 0.4, onComplete: () => document.body.style.opacity = 1 }
)
}
🔴 场景 2:Vue3 组件不销毁,内存暴涨
症状:来回切换页面后,控制台出现 [Vue warn]: Component is already mounted。
快速解法:
在 afterEnter 里手动卸载并重新挂载:
afterEnter({ next }) {
app?.unmount()
app = createApp(App)
app.mount('#app')
}
🔴 场景 3:滚动位置错乱
症状:A 页面滚到 800 px,跳转到 B 页面却直接回到顶部。
快速解法:
barba.hooks.beforeLeave(() => {
history.replaceState({ ...history.state, scrollY: window.scrollY }, '')
})
barba.hooks.afterEnter(() => {
const { scrollY = 0 } = history.state || {}
window.scrollTo({ top: scrollY, behavior: 'smooth' })
})
🔴 场景 4:点击浏览器后退,页面样式瞬间全乱
症状:后退时 Barba 把缓存的 DOM 直接塞回,但 Vue3 样式作用域失效。
快速解法:
给 <style scoped> 再补一个全局补丁:
/* barba 会把 container 整个替换,scoped 样式会丢 */
.barba-container[data-namespace="home"] .hero {
/* 重写一次关键样式 */
}
🔴 场景 5:移动端首次滑动卡顿
症状:iOS Safari 第一次滑屏有 300 ms 延迟。
快速解法:
在 barba.init 里关闭预加载的 timeout:
barba.init({
timeout: 0, // 不等待 requestIdleCallback
})
写在最后
Barba.js 并不是一个“Vue3 专用”的库,但正是这种 框架无关 的特性,让它在 Vue3 项目里反而更自由:
- 你可以继续用
Vue Router做SPA; - 也可以把多页站改造成
“伪 SPA”,却保留SSR的SEO优势。
一句话:如果你受够了页面跳转的“闪白”,又不想折腾整站改造成 SPA,Barba.js + Vue3 就是目前性价比最高的动效解!
- Barba.js 官网:
https://barba.js.org/ - Barba.js Github:
https://github.com/barbajs/barba
来源:juejin.cn/post/7532287059374506027
前端何时能出个"秦始皇"一统天下?我是真学不动啦!
前端何时能出个"秦始皇"一统天下?我是真学不动啦!
引言
前端开发的世界,就像历史上的战国时期一样,各种框架、库、工具层出不穷,形成了一个百花齐放但也令人眼花缭乱的局面。
而且就因为百家争鸣,导致各种鄙视链出现
比如 React 和 Vue 互喷
v:你react 这么难用,不如我vue 简单
r:你一点都不灵活,我想咋用咋用
v:你useEffect 心智负担太重,一点都好用
r:啥心智负担,那是你太笨了,我就喜欢这种什么都掌握在自己手里的感觉
v:你内部更是混乱,一个状态管理就那么多种 啥redux、mobx、recoil。。。。不像我们一个pinia 走天下
r:你管我 我想用哪个用哪个,你还说我,你内部对一个 用ref还是用reactive 都吵得不可开交!
......

1. 框架之争
- React: 由Facebook维护的一个用于构建用户界面的JavaScript库。其设计理念是通过组件化的方式简化复杂的UI开发。
- 官网: reactjs.org/
- GitHub: github.com/facebook/re…
- GitHub Stars: 超过235k(截至2025年4月)
- Vue.js: 一种渐进式JavaScript框架,非常适合用来构建单页应用。Vue的核心库只关注视图层,易于上手。
- 官网: vuejs.org/
- GitHub: github.com/vuejs/vue
- GitHub Stars: 约209k(截至2025年4月)
- Angular: Google支持的一个开源Web应用框架,适用于大型企业级项目。它提供了一个全面的解决方案来创建动态Web应用程序。
- 官网: angular.io/
- GitHub: github.com/angular/ang…
- GitHub Stars: 大约97.5k(截至2025年4月)
- Solid.js: 一个专注于性能和简单性的声明式UI库,采用细粒度的响应式系统,提供了极高的运行效率。
- 官网: http://www.solidjs.com/
- GitHub: github.com/solidjs/sol…
- GitHub Stars: 约33.3k(截至2025年4月)
- Svelte: 一种新兴的前端框架,通过在编译时将组件转换为高效的原生代码,从而避免了运行时开销。
- 官网: svelte.dev/
- GitHub: github.com/sveltejs/sv…
- GitHub Stars: 约82.3k(截至2025年4月)
- Ember.js: 一个旨在帮助开发者构建可扩展的Web应用的框架,尤其适合大型团队协作。
- 官网: emberjs.com/
- GitHub: github.com/emberjs/emb…
- GitHub Stars: 约22.6k(截至2025年4月)
2. 样式处理满花齐放
样式处理方面可以进一步细分,包括CSS预处理器、CSS-in-JS、Utility-First CSS框架以及CSS Modules等。
- CSS预处理器
- Sass: 提供变量、嵌套规则等高级功能,极大地提高了CSS代码的可维护性。
- 官网: sass-lang.com/
- GitHub: github.com/sass/sass
- GitHub Stars: 约15.2k(截至2025年4月)
- Less: 另一种流行的CSS预处理器,支持类似的功能但语法稍有不同。
- 官网: lesscss.org/
- GitHub: github.com/less/less.j…
- GitHub Stars: 约17k(截至2025年4月)
- Stylus: 一款灵活且功能强大的CSS预处理器,允许省略括号和分号等符号,使代码更加简洁。
- 官网: stylus-lang.com/
- GitHub: github.com/stylus/styl…
- GitHub Stars: 约11.2k(截至2025年4月)
- Sass: 提供变量、嵌套规则等高级功能,极大地提高了CSS代码的可维护性。
- CSS-in-JS
- styled-components: 允许你通过JavaScript编写CSS,并将样式直接附加到组件上。
- 官网: styled-components.com/
- GitHub: github.com/styled-comp…
- GitHub Stars: 约40.8k(截至2025年4月)
- Emotion: 类似于styled-components,但提供了更多的灵活性和性能优化。
- 官网: emotion.sh/
- GitHub: github.com/emotion-js/…
- GitHub Stars: 约17.7k(截至2025年4月)
- styled-components: 允许你通过JavaScript编写CSS,并将样式直接附加到组件上。
- 原子化css
- Tailwind CSS: 一种实用优先的CSS框架,让你可以通过低级实用程序类构建定制设计。
- 官网: tailwindcss.com/
- GitHub: github.com/tailwindlab…
- GitHub Stars: 约87.2k(截至2025年4月)
- UnoCSS: 新一代的原子化CSS引擎,旨在提供极致的性能和灵活性。
- 官网: uno.antfu.me/
- GitHub: github.com/unocss/unoc…
- GitHub Stars: 约17.5k(截至2025年4月)
- Windi CSS: 一个基于Tailwind CSS的即时按需CSS框架,提供了更快的开发体验。
- 官网: windicss.org/
- GitHub: github.com/windicss/wi…
- GitHub Stars: 约6.5k(截至2025年4月)
- Tailwind CSS: 一种实用优先的CSS框架,让你可以通过低级实用程序类构建定制设计。
3. 构建工具五花八门
构建工具是现代前端开发不可或缺的一部分,它们负责将源代码转换为生产环境可用的形式,并优化性能。
- Webpack: 一个模块打包工具,广泛用于复杂的前端项目中。它支持多种文件类型的处理,并具有强大的插件生态。
- 官网: webpack.js.org/
- GitHub: github.com/webpack/web…
- GitHub Stars: 约65.2k(截至2025年4月)
- Vite: 由Vue.js作者尤雨溪开发的下一代前端构建工具,以其极快的冷启动速度和热更新闻名。
- 官网: vitejs.dev/
- GitHub: github.com/vitejs/vite
- GitHub Stars: 约72.1k(截至2025年4月)
- Rollup: 一个专注于JavaScript库的打包工具,特别适合构建小型库或框架。
- 官网: rollupjs.org/
- GitHub: github.com/rollup/roll…
- GitHub Stars: 约25.7k(截至2025年4月)
- Rspack: 一个基于Rust实现的高性能构建工具,兼容Webpack配置,旨在提供更快的构建速度。
- 官网: rspack.dev/
- GitHub: github.com/web-infra-d…
- GitHub Stars: 约11.3k(截至2025年4月)
- esbuild: 一个用Go语言编写的极速打包工具,专为现代JavaScript项目设计。
- 官网: esbuild.github.io/
- GitHub: github.com/evanw/esbui…
- GitHub Stars: 约38.8k(截至2025年4月)
- Turbopack: 由Next.js团队推出的下一代构建工具,号称比Webpack快700倍。
- 官网: turbo.build/docs
- GitHub: github.com/vercel/turb…
- GitHub Stars: 约27.5k(截至2025年4月)
- Rolldown: 一个基于Rust的Rollup替代方案,旨在提供更快的构建速度和更高的性能。
- 官网: rolldown.dev/
- GitHub: github.com/rolldown/ro…
- GitHub Stars: 约10.7k(截至2025年4月)
对比分析:
- Webpack 是目前最成熟的构建工具,生态系统庞大,但配置复杂度较高。
- Vite 凭借其快速的开发体验迅速崛起,尤其在中小型项目中表现优异。
- Rollup 更适合轻量级项目或库的构建,虽然社区规模较小,但在特定场景下非常高效。
- Rspack 和 esbuild 利用高性能语言(如Rust和Go)实现了极快的构建速度,适合对性能要求较高的项目。
- Turbopack 是新兴工具,主打极速构建,未来可能成为Webpack的有力竞争者。
- Rolldown 提供了另一种基于Rust的高速构建解决方案,特别针对Rollup用户群体。
4. 包管理工具逐步更新
- npm: Node.js默认的包管理器,允许开发者轻松地安装、共享和分发代码。
- 官网: http://www.npmjs.com/
- GitHub: github.com/npm/cli
- cnpm: npm在中国的镜像站,由于网络问题,很多中国开发者更倾向于使用cnpm。
- GitHub: github.com/cnpm/cnpm
- Yarn: Facebook推出的一个快速、可靠、安全的依赖管理工具。
- 官网: yarnpkg.com/
- GitHub: github.com/yarnpkg/yar…
- pnpm: 快速且节省磁盘空间的包管理器。
- 官网: pnpm.io/
- GitHub: github.com/pnpm/pnpm
5. 状态管理百家争鸣
状态管理是前端开发中的重要组成部分,它帮助开发者有效地管理应用的状态变化。
- Redux: 经典的Flux实现,广泛用于React生态系统中,适合管理大型应用的状态。
- 官网: redux.js.org/
- GitHub: github.com/reduxjs/red…
- GitHub Stars: 约61.1k(截至2025年4月)
- MobX: 响应式状态管理库,通过可观察对象实现自动化的状态更新。
- 官网: mobx.js.org/
- GitHub: github.com/mobxjs/mobx
- GitHub Stars: 约27.8k(截至2025年4月)
- Zustand: 轻量级的状态管理解决方案,API简单且易于使用。
- 官网: zustand-demo.pmnd.rs/
- GitHub: github.com/pmndrs/zust…
- GitHub Stars: 约51.7k(截至2025年4月)
- Jotai: 原子化状态管理库,专注于轻量级和灵活性。
- 官网: jotai.org/
- GitHub: github.com/pmndrs/jota…
- GitHub Stars: 约19.8k(截至2025年4月)
- Recoil: Facebook推出的实验性状态管理库,专为React设计。
- 官网: recoiljs.org/
- GitHub: github.com/facebookexp…
- GitHub Stars: 约19.6k(截至2025年4月)
- Pinia: Vue的下一代状态管理库,设计简洁且与Vue 3完美集成。
- 官网: pinia.vuejs.org/
- GitHub: github.com/vuejs/pinia
- GitHub Stars: 约13.8k(截至2025年4月)
6. JavaScript运行时环境都有好几种
JavaScript运行时环境是现代前端和后端开发的核心部分,它决定了代码如何被解析和执行。以下是几种主流的JavaScript运行时环境:
- Node.js:
- Node.js 是一个基于Chrome V8引擎的JavaScript运行时,广泛用于构建服务器端应用、命令行工具以及全栈开发。
- 它拥有庞大的生态系统,npm作为其默认包管理器,已经成为全球最大的软件注册表。
- 官网: nodejs.org/
- GitHub: github.com/nodejs/node
- GitHub Stars: 约111k(截至2025年4月)
- Deno:
- Deno 是由Node.js的原作者Ryan Dahl创建的一个现代化JavaScript/TypeScript运行时,旨在解决Node.js的一些设计缺陷。
- 它内置了对TypeScript的支持,并提供了更安全的权限模型(如文件系统访问需要显式授权)。
- Deno还集成了标准库,无需依赖第三方模块即可完成许多常见任务。
- 官网: deno.land/
- GitHub: github.com/denoland/de…
- GitHub Stars: 约103k(截至2025年4月)
- Bun:
- Bun 是一个新兴的JavaScript运行时,旨在提供更快的性能和更高效的开发体验。
- 它不仅可以用作运行时环境,还可以替代npm、Yarn等包管理工具,同时支持ES Modules和CommonJS。
- Bun的目标是成为Node.js和Deno的强大竞争者,特别适合高性能需求的场景。
- 官网: bun.sh/
- GitHub: github.com/oven-sh/bun
- GitHub Stars: 约77.5k(截至2025年4月)
对比分析:
- Node.js 是目前最成熟且广泛应用的JavaScript运行时,尤其在企业级项目中占据主导地位。
- Deno 提供了更现代化的设计理念,特别是在安全性、TypeScript支持和内置工具方面表现突出。
- Bun 是一个新兴的选手,凭借其极速的性能和多功能性迅速吸引了开发者关注,未来潜力巨大。
7. 跨平台开发
随着移动设备和多终端生态的普及,跨平台开发成为现代应用开发的重要方向。以下是几种主流的跨平台开发工具和技术:
- React Native:
- React Native 是由Facebook推出的一个基于React的跨平台移动应用开发框架,允许开发者使用JavaScript和React构建原生性能的iOS和Android应用。
- 它提供了丰富的社区支持和插件生态,适合需要快速迭代的项目。
- 官网: reactnative.dev/
- GitHub: github.com/facebook/re…
- GitHub Stars: 约122k(截至2025年4月)
- Flutter:
- Flutter 是由Google开发的一个开源UI框架,使用Dart语言构建高性能的跨平台应用。
- 它通过自绘引擎渲染UI,提供了一致的用户体验,并支持Web、iOS、Android以及桌面端开发。
- 官网: flutter.dev/
- GitHub: github.com/flutter/flu…
- GitHub Stars: 约170k(截至2025年4月)
- Electron:
- Electron 是一个用于构建跨平台桌面应用的框架,基于Node.js和Chromium,广泛应用于桌面端应用开发。
- 它允许开发者使用Web技术(HTML、CSS、JavaScript)构建功能强大的桌面应用,但可能会导致较大的应用体积。
- 官网: http://www.electronjs.org/
- GitHub: github.com/electron/el…
- GitHub Stars: 约116k(截至2025年4月)
- Tauri:
- Tauri 是一个轻量级的跨平台桌面应用框架,旨在替代Electron,提供更小的应用体积和更高的安全性。
- 它利用系统的原生Webview来渲染UI,同时支持Rust作为后端语言,从而实现更高的性能。
- 官网: tauri.app/
- GitHub: github.com/tauri-apps/…
- GitHub Stars: 约91.5k(截至2025年4月)
- Capacitor:
- Capacitor 是由Ionic团队推出的一个跨平台工具,允许开发者将Web应用封装为原生应用。
- 它支持iOS、Android和Web,并提供了丰富的插件生态,方便调用原生设备功能。
- 官网: capacitorjs.com/
- GitHub: github.com/ionic-team/…
- GitHub Stars: 约13.1k(截至2025年4月)
- UniApp:
- UniApp 是一个基于 Vue.js 的跨平台开发框架,能够将代码编译到多个平台,包括微信小程序、H5、iOS、Android以及其他小程序(如支付宝小程序、百度小程序等)。
- 它的优势在于一次编写,多端运行,特别适合需要覆盖多个小程序平台的项目。
- 官网: uniapp.dcloud.io/
- GitHub: github.com/dcloudio/un…
- GitHub Stars: 约40.6k(截至2025年4月)
对比分析:
- React Native 和 Flutter 是移动端跨平台开发的两大主流选择,分别适合熟悉JavaScript和Dart的开发者。
- Electron 是桌面端跨平台开发的经典解决方案,虽然体积较大,但易于上手。
- Tauri 提供了更轻量化的桌面端开发方案,适合对性能和安全性有更高要求的项目。
- Capacitor 则是一个灵活的工具,特别适合将现有的Web应用快速迁移到移动端。
- UniApp 非常适合需要覆盖多种小程序平台的项目,尤其在国内的小程序生态中表现出色。
结论
你看我这还是只是列举了一部分,都这么多了,学前端的是真的命苦啊,真心学不动了。

而且最近 尤雨溪宣布成立 VoidZero 说是一代JavaScript工具链,能够统一前端 开发构建工具,如果真能做到,真是一件令人振奋的事情,希望尤雨溪能做到跟 spring 一样统一java 天下 把前端的天下给统一了,大家觉得有可能么?
来源:juejin.cn/post/7493420166878822450
10 个被严重低估的 JS 特性,直接少写 500 行代码
前言
最近逛 Reddit 的时候,看到一个关于最被低估的 JavaScript 特性的讨论,我对此进行了总结,和大家分享一下。
最近逛 Reddit 的时候,看到一个关于最被低估的 JavaScript 特性的讨论,我对此进行了总结,和大家分享一下。
1. Set:数组去重 + 快速查找,比 filter 快 3 倍
提到数组去重,很多人第一反应是 filter + indexOf,但这种写法的时间复杂度是 O (n²),而 Set 天生支持 “唯一值”,查找速度是 O (1),还能直接转数组。
举个例子:
用户 ID 去重:
// 后端返回的重复用户 ID 列表
const duplicateIds = [101, 102, 102, 103, 103, 103];
// 1 行去重
const uniqueIds = [...new Set(duplicateIds)];
console.log(uniqueIds); // [101,102,103]
避免重复绑定事件:
const listenedEvents = new Set();
// 封装事件绑定函数,防止同一事件重复绑定
function safeAddEvent(eventName, handler) {
if (!listenedEvents.has(eventName)) {
window.addEventListener(eventName, handler);
listenedEvents.add(eventName); // 标记已绑定
}
}
// 调用 2 次也只会绑定 1 次 scroll 事件
safeAddEvent("scroll", () => console.log("滚动了"));
safeAddEvent("scroll", () => console.log("滚动了"));
提到数组去重,很多人第一反应是 filter + indexOf,但这种写法的时间复杂度是 O (n²),而 Set 天生支持 “唯一值”,查找速度是 O (1),还能直接转数组。
举个例子:
用户 ID 去重:
// 后端返回的重复用户 ID 列表
const duplicateIds = [101, 102, 102, 103, 103, 103];
// 1 行去重
const uniqueIds = [...new Set(duplicateIds)];
console.log(uniqueIds); // [101,102,103]
避免重复绑定事件:
const listenedEvents = new Set();
// 封装事件绑定函数,防止同一事件重复绑定
function safeAddEvent(eventName, handler) {
if (!listenedEvents.has(eventName)) {
window.addEventListener(eventName, handler);
listenedEvents.add(eventName); // 标记已绑定
}
}
// 调用 2 次也只会绑定 1 次 scroll 事件
safeAddEvent("scroll", () => console.log("滚动了"));
safeAddEvent("scroll", () => console.log("滚动了"));
2. Object.entries () + Object.fromEntries ():对象数组互转神器
以前想遍历对象,要用 for...in 循环,外加判断 hasOwnProperty;如果想把数组转成对象,只能手动写循环。这对组合直接一键搞定。
举个例子:
筛选对象属性,过滤掉空值:
// 后端返回的用户信息,包含空值字段
const userInfo = {
name: "张三",
age: 28,
avatar: "", // 空值,需要过滤
phone: "13800138000",
};
// 1. 转成[key,value]数组,过滤空值;2. 转回对象
const filteredUser = Object.fromEntries(Object.entries(userInfo).filter(([key, value]) => value !== ""));
console.log(filteredUser);
// {name: "张三", age:28, phone: "13800138000"}
URL 参数转对象(不用再写正则了)
// 地址栏的参数:?name=张三&age=28&gender=男
const searchStr = window.location.search.slice(1);
// 直接转成对象,支持中文和特殊字符
const paramObj = Object.fromEntries(new URLSearchParams(searchStr));
console.log(paramObj); // {name: "张三", age: "28", gender: "男"}
以前想遍历对象,要用 for...in 循环,外加判断 hasOwnProperty;如果想把数组转成对象,只能手动写循环。这对组合直接一键搞定。
举个例子:
筛选对象属性,过滤掉空值:
// 后端返回的用户信息,包含空值字段
const userInfo = {
name: "张三",
age: 28,
avatar: "", // 空值,需要过滤
phone: "13800138000",
};
// 1. 转成[key,value]数组,过滤空值;2. 转回对象
const filteredUser = Object.fromEntries(Object.entries(userInfo).filter(([key, value]) => value !== ""));
console.log(filteredUser);
// {name: "张三", age:28, phone: "13800138000"}
URL 参数转对象(不用再写正则了)
// 地址栏的参数:?name=张三&age=28&gender=男
const searchStr = window.location.search.slice(1);
// 直接转成对象,支持中文和特殊字符
const paramObj = Object.fromEntries(new URLSearchParams(searchStr));
console.log(paramObj); // {name: "张三", age: "28", gender: "男"}
3. ?? 与 ??=:比 || 靠谱
用 || 设置默认值时,会把 0、""、false这些 “有效假值” 当成空值。比如用户输入 0(表示数量),count || 10会返回 10,但这里其实应该返回 0。而??只判断 null/undefined。
举个例子:
处理用户输入的 “有效假值”:
// 用户输入的数量( 0 是有效数值,不能替换)
const userInputCount = 0;
// 错误写法:会把 0 当成空值,返回 10
const wrongCount = userInputCount || 10;
// 正确写法:只判断 null/undefined,返回 0
const correctCount = userInputCount ?? 10;
console.log(wrongCount, correctCount); // 10, 0
给对象补默认值(不会覆盖已有值):
// 前端传入的配置,可能缺少 retries 字段
const requestConfig = { timeout: 5000 };
// 只有当 retries 为 null/undefined 时,才赋值 3(不覆盖已有值)
requestConfig.retries ??= 3;
console.log(requestConfig); // {timeout:5000, retries:3}
// 如果已有值,不会被覆盖
const oldConfig = { timeout: 3000, retries: 2 };
oldConfig.retries ??= 3;
console.log(oldConfig); // {timeout:3000, retries:2}
用 || 设置默认值时,会把 0、""、false这些 “有效假值” 当成空值。比如用户输入 0(表示数量),count || 10会返回 10,但这里其实应该返回 0。而??只判断 null/undefined。
举个例子:
处理用户输入的 “有效假值”:
// 用户输入的数量( 0 是有效数值,不能替换)
const userInputCount = 0;
// 错误写法:会把 0 当成空值,返回 10
const wrongCount = userInputCount || 10;
// 正确写法:只判断 null/undefined,返回 0
const correctCount = userInputCount ?? 10;
console.log(wrongCount, correctCount); // 10, 0
给对象补默认值(不会覆盖已有值):
// 前端传入的配置,可能缺少 retries 字段
const requestConfig = { timeout: 5000 };
// 只有当 retries 为 null/undefined 时,才赋值 3(不覆盖已有值)
requestConfig.retries ??= 3;
console.log(requestConfig); // {timeout:5000, retries:3}
// 如果已有值,不会被覆盖
const oldConfig = { timeout: 3000, retries: 2 };
oldConfig.retries ??= 3;
console.log(oldConfig); // {timeout:3000, retries:2}
4. Intl API:原生国际化 API
很多人会用 moment.js 处理日期、货币格式化,但这个库体积特别大(压缩后也有几十 KB);而 Intl 是浏览器原生 API,支持货币、日期、数字的本地化,体积为 0,还能自动适配地区。
举个例子:
多语言货币格式化(适配中英文):
const price = 1234.56;
// 人民币格式(自动加 ¥ 和千分位)
const cnyPrice = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
}).format(price);
// 美元格式(自动加 $ 和千分位)
const usdPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
console.log(cnyPrice, usdPrice); // ¥1,234.56 $1,234.56
日期本地化(不用手动拼接年月日):
const now = new Date();
// 中文日期:2025年11月3日 15:40:22
const cnDate = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now);
// 英文日期:November 3, 2025, 03:40:22 PM
const enDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now);
console.log(cnDate, enDate);
很多人会用 moment.js 处理日期、货币格式化,但这个库体积特别大(压缩后也有几十 KB);而 Intl 是浏览器原生 API,支持货币、日期、数字的本地化,体积为 0,还能自动适配地区。
举个例子:
多语言货币格式化(适配中英文):
const price = 1234.56;
// 人民币格式(自动加 ¥ 和千分位)
const cnyPrice = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
}).format(price);
// 美元格式(自动加 $ 和千分位)
const usdPrice = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
console.log(cnyPrice, usdPrice); // ¥1,234.56 $1,234.56
日期本地化(不用手动拼接年月日):
const now = new Date();
// 中文日期:2025年11月3日 15:40:22
const cnDate = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now);
// 英文日期:November 3, 2025, 03:40:22 PM
const enDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(now);
console.log(cnDate, enDate);
5. Intersection Observer:图片懒加载 + 滚动加载,不卡主线程
传统我们用 scroll事件 + getBoundingClientRect()判断元素是否在视口,会频繁触发重排,导致页面卡顿;Intersection ObserverAPI 是异步监听,不阻塞主线程,性能直接提升一大截。
举个例子:
图片懒加载(可用于优化首屏加载速度):
<img data-src="https://xxx.com/real-img.jpg" src="placeholder.jpg" class="lazy-img" />
// 初始化观察者
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 当图片进入视口
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 加载真实图片
lazyObserver.unobserve(img); // 加载后停止监听
}
});
});
// 给所有懒加载图片添加监听
document.querySelectorAll(".lazy-img").forEach((img) => {
lazyObserver.observe(img);
});
列表滚动加载更多(避免一次性加载过多数据):
<ul id="news-list">ul>
<div id="load-more">加载中...div>
const loadObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
// 当加载提示进入视口,请求下一页数据
fetchNextPageData().then((data) => {
renderNews(data); // 渲染新列表项
});
}
});
// 监听加载提示元素
loadObserver.observe(document.getElementById("load-more"));
传统我们用 scroll事件 + getBoundingClientRect()判断元素是否在视口,会频繁触发重排,导致页面卡顿;Intersection ObserverAPI 是异步监听,不阻塞主线程,性能直接提升一大截。
举个例子:
图片懒加载(可用于优化首屏加载速度):
<img data-src="https://xxx.com/real-img.jpg" src="placeholder.jpg" class="lazy-img" />
// 初始化观察者
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 当图片进入视口
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 加载真实图片
lazyObserver.unobserve(img); // 加载后停止监听
}
});
});
// 给所有懒加载图片添加监听
document.querySelectorAll(".lazy-img").forEach((img) => {
lazyObserver.observe(img);
});
列表滚动加载更多(避免一次性加载过多数据):
<ul id="news-list">ul>
<div id="load-more">加载中...div>
const loadObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
// 当加载提示进入视口,请求下一页数据
fetchNextPageData().then((data) => {
renderNews(data); // 渲染新列表项
});
}
});
// 监听加载提示元素
loadObserver.observe(document.getElementById("load-more"));
6. Promise.allSettled ():批量请求不 “挂掉”,比 Promise.all 更实用
如果使用 Promise.all,当批量请求时,只要有一个请求失败,Promise.all 就会直接 reject,其他成功的请求结果就拿不到了;而 allSettled 会等待所有请求完成,不管成功失败,还能分别处理结果。
举个例子:
批量获取用户信息 + 订单 + 消息(部分接口失败不影响整体):
// 3个并行请求,可能有失败的
const requestList = [
fetch("/api/user/101"), // 成功
fetch("/api/orders/101"), // 失败(比如订单不存在)
fetch("/api/messages/101"), // 成功
];
// 等待所有请求完成,处理成功和失败的结果
Promise.allSettled(requestList).then((results) => {
// 处理成功的请求
const successData = results.filter((res) => res.status === "fulfilled").map((res) => res.value.json());
// 记录失败的请求(方便排查问题)
const failedRequests = results.filter((res) => res.status === "rejected").map((res) => res.reason.url);
console.log("成功数据:", successData);
console.log("失败接口:", failedRequests); // ["/api/orders/101"]
});
如果使用 Promise.all,当批量请求时,只要有一个请求失败,Promise.all 就会直接 reject,其他成功的请求结果就拿不到了;而 allSettled 会等待所有请求完成,不管成功失败,还能分别处理结果。
举个例子:
批量获取用户信息 + 订单 + 消息(部分接口失败不影响整体):
// 3个并行请求,可能有失败的
const requestList = [
fetch("/api/user/101"), // 成功
fetch("/api/orders/101"), // 失败(比如订单不存在)
fetch("/api/messages/101"), // 成功
];
// 等待所有请求完成,处理成功和失败的结果
Promise.allSettled(requestList).then((results) => {
// 处理成功的请求
const successData = results.filter((res) => res.status === "fulfilled").map((res) => res.value.json());
// 记录失败的请求(方便排查问题)
const failedRequests = results.filter((res) => res.status === "rejected").map((res) => res.reason.url);
console.log("成功数据:", successData);
console.log("失败接口:", failedRequests); // ["/api/orders/101"]
});
7. element.closest ():向上找父元素最安全的方式
传统如果想找某个元素的父元素,比如点击列表项找列表,需要使用 element.parentNode.parentNode,但一旦 DOM 结构变了,代码就崩了;closest() 回直接根据 CSS 选择器找最近的祖先元素,不管嵌套多少层。
举个例子:
点击列表项,给列表容器加高亮:
<ul class="user-list">
<li class="user-item">张三li>
<li class="user-item">李四li>
ul>
document.querySelectorAll(".user-item").forEach((item) => {
item.addEventListener("click", (e) => {
// 找到最近的.user-list(不管中间嵌套多少层)
const list = e.target.closest(".user-list");
list.classList.toggle("active"); // 切换高亮
});
});
输入框聚焦,给表单组加样式:
<div class="form-group">
<label>用户名label>
<input type="text" id="username" />
div>
const usernameInput = document.getElementById("username");
usernameInput.addEventListener("focus", (e) => {
// 找到最近的.form-group,加focused样式
const formGr0up = e.target.closest(".form-group");
formGr0up.classList.add("focused");
});
传统如果想找某个元素的父元素,比如点击列表项找列表,需要使用 element.parentNode.parentNode,但一旦 DOM 结构变了,代码就崩了;closest() 回直接根据 CSS 选择器找最近的祖先元素,不管嵌套多少层。
举个例子:
点击列表项,给列表容器加高亮:
<ul class="user-list">
<li class="user-item">张三li>
<li class="user-item">李四li>
ul>
document.querySelectorAll(".user-item").forEach((item) => {
item.addEventListener("click", (e) => {
// 找到最近的.user-list(不管中间嵌套多少层)
const list = e.target.closest(".user-list");
list.classList.toggle("active"); // 切换高亮
});
});
输入框聚焦,给表单组加样式:
<div class="form-group">
<label>用户名label>
<input type="text" id="username" />
div>
const usernameInput = document.getElementById("username");
usernameInput.addEventListener("focus", (e) => {
// 找到最近的.form-group,加focused样式
const formGr0up = e.target.closest(".form-group");
formGr0up.classList.add("focused");
});
8. URL + URLSearchParams:处理 URL 方便多了
传统解析 URL 参数、修改参数,还要写复杂的正则表达式,有时还得处理中文编码问题;当然我们会直接引入三方库来处理,但毕竟还要引入多余的苦,其实 URL API 可以直接解析 URL 结构,URLSearchParams 可用于处理参数,支持增删改查,自动编码,方便多了。
解析 URL 参数(支持中文和特殊字符):
// 当前页面URL:https://xxx.com/user?name=张三&age=28&gender=男
const currentUrl = new URL(window.location.href);
// 获取参数
console.log(currentUrl.searchParams.get("name")); // 张三
console.log(currentUrl.hostname); // xxx.com(域名)
console.log(currentUrl.pathname); // /user(路径)
修改 URL 参数,跳转新页面:
const url = new URL("https://xxx.com/list");
// 添加参数
url.searchParams.append("page", 2);
url.searchParams.append("size", 10);
// 修改参数
url.searchParams.set("page", 3);
// 删除参数
url.searchParams.delete("size");
console.log(url.href); // https://xxx.com/list?page=3
window.location.href = url.href; // 跳转到第3页
传统解析 URL 参数、修改参数,还要写复杂的正则表达式,有时还得处理中文编码问题;当然我们会直接引入三方库来处理,但毕竟还要引入多余的苦,其实 URL API 可以直接解析 URL 结构,URLSearchParams 可用于处理参数,支持增删改查,自动编码,方便多了。
解析 URL 参数(支持中文和特殊字符):
// 当前页面URL:https://xxx.com/user?name=张三&age=28&gender=男
const currentUrl = new URL(window.location.href);
// 获取参数
console.log(currentUrl.searchParams.get("name")); // 张三
console.log(currentUrl.hostname); // xxx.com(域名)
console.log(currentUrl.pathname); // /user(路径)
修改 URL 参数,跳转新页面:
const url = new URL("https://xxx.com/list");
// 添加参数
url.searchParams.append("page", 2);
url.searchParams.append("size", 10);
// 修改参数
url.searchParams.set("page", 3);
// 删除参数
url.searchParams.delete("size");
console.log(url.href); // https://xxx.com/list?page=3
window.location.href = url.href; // 跳转到第3页
9. for...of 循环:比 forEach 灵活,还支持 break 和 continue
我们都知道,forEach 不能用 break中断循环,也不能用 continue跳过当前项。而for...of不仅支持中断,还能遍历数组、Set、Map、字符串,甚至获取索引。
举个例子:
遍历数组,找到目标值后中断:
const productList = [
{ id: 1, name: "手机", price: 5999 },
{ id: 2, name: "电脑", price: 9999 },
{ id: 3, name: "平板", price: 3999 },
];
// 找价格大于8000的产品,找到后中断
for (const product of productList) {
if (product.price > 8000) {
console.log("找到高价产品:", product); // {id:2, name:"电脑", ...}
break; // 中断循环,不用遍历剩下的
}
}
遍历 Set,获取索引:
const uniqueTags = new Set(["前端", "JS", "CSS"]);
// 用 entries() 获取索引和值
for (const [index, tag] of [...uniqueTags].entries()) {
console.log(`索引${index}:${tag}`); // 索引 0:前端,索引 1:JS...
}
我们都知道,forEach 不能用 break中断循环,也不能用 continue跳过当前项。而for...of不仅支持中断,还能遍历数组、Set、Map、字符串,甚至获取索引。
举个例子:
遍历数组,找到目标值后中断:
const productList = [
{ id: 1, name: "手机", price: 5999 },
{ id: 2, name: "电脑", price: 9999 },
{ id: 3, name: "平板", price: 3999 },
];
// 找价格大于8000的产品,找到后中断
for (const product of productList) {
if (product.price > 8000) {
console.log("找到高价产品:", product); // {id:2, name:"电脑", ...}
break; // 中断循环,不用遍历剩下的
}
}
遍历 Set,获取索引:
const uniqueTags = new Set(["前端", "JS", "CSS"]);
// 用 entries() 获取索引和值
for (const [index, tag] of [...uniqueTags].entries()) {
console.log(`索引${index}:${tag}`); // 索引 0:前端,索引 1:JS...
}
10. 顶层 await:模块异步初始化
以前在 ES 模块里想异步加载配置,必须写个 async 函数再调用;现在 top-level await 允许你在模块顶层直接用 await,其他模块导入时会自动等待,不用再手动处理异步。
举个例子:
模块初始化时加载配置:
// config.js
// 顶层直接 await,加载后端配置
const response = await fetch("/api/config");
export const appConfig = await response.json(); // {baseUrl: "https://xxx.com", timeout: 5000}
// api.js(导入 config.js,自动等待配置加载完成)
import { appConfig } from "./config.js";
// 直接用配置,不用关心异步
export const apiClient = {
baseUrl: appConfig.baseUrl,
get(url) {
return fetch(`${this.baseUrl}${url}`, { timeout: appConfig.timeout });
},
};
点击按钮动态加载组件(按需加载,减少首屏体积):
// 点击“图表”按钮,才加载图表组件
document.getElementById("show-chart-btn").addEventListener("click", async () => {
// 动态导入图表模块,await 等待加载完成
const { renderChart } = await import("./chart-module.js");
renderChart("#chart-container"); // 渲染图表
});
以前在 ES 模块里想异步加载配置,必须写个 async 函数再调用;现在 top-level await 允许你在模块顶层直接用 await,其他模块导入时会自动等待,不用再手动处理异步。
举个例子:
模块初始化时加载配置:
// config.js
// 顶层直接 await,加载后端配置
const response = await fetch("/api/config");
export const appConfig = await response.json(); // {baseUrl: "https://xxx.com", timeout: 5000}
// api.js(导入 config.js,自动等待配置加载完成)
import { appConfig } from "./config.js";
// 直接用配置,不用关心异步
export const apiClient = {
baseUrl: appConfig.baseUrl,
get(url) {
return fetch(`${this.baseUrl}${url}`, { timeout: appConfig.timeout });
},
};
点击按钮动态加载组件(按需加载,减少首屏体积):
// 点击“图表”按钮,才加载图表组件
document.getElementById("show-chart-btn").addEventListener("click", async () => {
// 动态导入图表模块,await 等待加载完成
const { renderChart } = await import("./chart-module.js");
renderChart("#chart-container"); // 渲染图表
});
结语
可以看到,以前我们依赖的第三方库,其实原生 API 早就能解决,比如用 Intl 替代 moment.js,用 Set 替代 lodash 的 uniq,用 Intersection Observer 替代懒加载,随着老旧的浏览器被讨论,兼容性越来越好,这些 API 以后会成为基操。
作者:冴羽
来源:juejin.cn/post/7568153532014559267
可以看到,以前我们依赖的第三方库,其实原生 API 早就能解决,比如用 Intl 替代 moment.js,用 Set 替代 lodash 的 uniq,用 Intersection Observer 替代懒加载,随着老旧的浏览器被讨论,兼容性越来越好,这些 API 以后会成为基操。
来源:juejin.cn/post/7568153532014559267
大家觉得,在前端开发中,最难的技术是哪一个?
“你不能把点点滴滴的事情在未来连接起来,你只能在回顾时看到它们的联系。所以你必须相信,未来的某一刻,你做的所有事情都会有意义。” ——乔布斯
Hello,大家好,我是 三千。
大家觉得,在前端开发中,最难的技术是哪一个?
如果你之前完全没有接触过3D 可视化应用开发,那使用Three.js开发应用还是门槛挺高的,比如,加载一个模型,调光,选择模型弹框的功能,就能干出Three.js上百行的代码。同时还有很多复杂的3D概念需要理解。
前言

今天给大家分享一个3D 开发框架:TresJS 。它是一个基于 Vue.js 的声明式 Three.js 框架,将 Vue 的开发便利性与 Three.js 的强大功能完美结合,提供了模板语法和组件化的开发方式,与 Vue 生态无缝结合,无需额外学习复杂的 Three.js API,大大简化了复杂 3D 场景的构建。高扩展性,与 Three.js 的资源和技术完美兼容,并且在内部进行了大量优化,确保在构建复杂 3D 场景时,性能表现依然出色,无论是数据可视化、虚拟现实。还是3D动画效果,TresJS 都能轻松应对。
下面我们通过一个例子,来看看它是怎么使用的。

1、安装
通过npm的方式,我们可以安装 TresJS:
pnpm add three @tresjs/core
- Typescript
TresJS 是用 Typescript 编写的,是完全类型化的。如果您使用的是 Typescript,您就能充分享受类型的好处。 只需要保证你安装了 three 的类型定义。
npm install @types/three -D
2、设置体验画布
在我们创建场景前,我们需要一个什么来展示它。使用原始的 ThreeJS 我们会需要创建一个 canvas HTML 元素来挂载 WebglRenderer 并初始化一个场景。
通过 TresJS 你仅仅需要导入默认组件 并把它添加到你的 Vue 组件的模板部分即可。
<script lang="ts" setup>
import { TresCanvas } from '@tresjs/core'
script>
<template>
<TresCanvas window-size>
TresCanvas>
template>
这个 TresCanvas 组件会在场景幕后做一些设置的工作:
- 它创建一个 WebGLRenderer 用于自动更新每一帧。
- 它根据浏览器刷新率设置要在每一帧上调用的渲染循环。
3、画布尺寸
默认的情况下,TresCanvas 组件会跟随父元素的宽高,如果出现空白页,请确保父元素的大小合适。
<script lang="ts" setup>
import { TresCanvas } from '@tresjs/core'
script>
<template>
<TresCanvas>
TresCanvas>
template>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
#app {
height: 100%;
width: 100%;
}
style>
如果您的场景不是用户界面的一部分,您也可以通过像这样的使用 window-size prop 来强制画布使用整个窗口的宽度和高度:
<script lang="ts" setup>
import { TresCanvas } from '@tresjs/core'
script>
<template>
<TresCanvas window-size>
TresCanvas>
template>
4、创建一个场景
我们只需要 4 个核心元素来创建 3D 体验:
使用 TresJS 时,您只需将 组件添加到 Vue 组件的模板中,它就会自动为您创建Renderer(canvas 作为 DOM 元素)和Scene。
<template>
<TresCanvas window-size>
TresCanvas>
template>
然后,您可以使用 组件来添加一个 透视相机
<template>
<TresCanvas window-size>
<TresPerspectiveCamera />
TresCanvas>
template>
5、添加一个🍩
那个场景看起来有点空,让我们添加一个基本对象。如果我们使用普通的 ThreeJS,我们需要创建一个 网格 对象,并在其上附加一个 材质 和一个 几何体,如下所示:
const geometry = new THREE.TorusGeometry(1, 0.5, 16, 32)
const material = new THREE.MeshBasicMaterial({ color: 'orange' })
const donut = new THREE.Mesh(geometry, material)
scene.add(donut)
网格是 three.js 中的基本场景对象,用于保存在 3D 空间中表示形状所需的几何体和材质。
现在让我们看看如何使用 TresJS 轻松实现相同的事情。为此,我们将使用 组件,在默认插槽之间,我们将传递一个 和一个。
<template>
<TresCanvas window-size>
<TresPerspectiveCamera />
<TresMesh>
<TresTorusGeometry :args="[1, 0.5, 16, 32]" />
<TresMeshBasicMaterial color="orange" />
TresMesh>
TresCanvas>
template>
- 注意,我们不需要导入任何东西,这是因为 TresJS 会为您使用的 PascalCase 的带有 Tres 前缀的 Three 对象自动生成一个 Vue 组件。例如,如果要使用
组件。
<script setup lang="ts">
import { TresCanvas } from '@tresjs/core'
script>
<template>
<TresCanvas
clear-color="#82DBC5"
window-size
>
<TresPerspectiveCamera
:position="[3, 3, 3]"
:look-at="[0, 0, 0]"
/>
<TresMesh>
<TresTorusGeometry :args="[1, 0.5, 16, 32]" />
<TresMeshBasicMaterial color="orange" />
TresMesh>
<TresAmbientLight :intensity="1" />
TresCanvas>
template>
从这里开始,您可以开始向场景中添加更多对象,并调整组件的属性来查看它们如何影响场景。

6、思路总结
最后我们用人话总结一下上面的思路:
- 1、最外层我们定义一个TresCanvas,在里面我们可以添加场景
- 2、然后定义一个透视相机,用于观察3D场景,position里去定义相机x,y,z轴的位置,look-at里定义相机观察的目标点
- 3、相机定义完之后,我们开始渲染3d对象,TresTorusGeometry用来定义环面集合体的半径和环向参数。TresMeshBasicMaterial定义几何体的基本材质和颜色。
- 4、最后用TresAmbientLight设置一下环境光的强度。
结语
以上就是今天与大家分享的全部内容,你的支持是我更新的最大动力,我们下期见!
打工人肝 文章/视频 不易,期待你一键三连的鼓励 !!!
😐 这里是【程序员三千】,💻 一个喜欢捣鼓各种编程工具新鲜玩法的啊婆主。
🏠 已入驻:抖爸爸、b站、小红书(都叫【程序员三千】)
💽 编程/AI领域优质资源推荐 👉 http://www.yuque.com/xiaosanye-o…
来源:juejin.cn/post/7468330256689463348
一天 AI 搓出痛风伴侣 H5 程序,前后端+部署通吃,还接入了大模型接口(万字总结)
自我介绍
大家好,我是志辉,10 年大数据架构,目前专注 AI 编程
大家好,我是志辉,10 年大数据架构,目前专注 AI 编程
1、背景
这个很早我就想写了 App 了,我也是痛风患者,好多年,深知这里面的痛呀,所以我想给大家带来一个好的通风管家的体验,但宏伟目标还是从小点着手,那么就有了今天的主角,痛风伴侣 H5。
这个很早我就想写了 App 了,我也是痛风患者,好多年,深知这里面的痛呀,所以我想给大家带来一个好的通风管家的体验,但宏伟目标还是从小点着手,那么就有了今天的主角,痛风伴侣 H5。
目录大纲
前面都是些开胃小菜,看官们现在我们就正式开始正文,那么整体目前是分的 6 个阶段。
前四个阶段可以分为一个大家的阶段,就完成了你的产品工作
最后就是收尾工作,以及后续的维护。
废话少说,就正式开始吧。
前面都是些开胃小菜,看官们现在我们就正式开始正文,那么整体目前是分的 6 个阶段。
前四个阶段可以分为一个大家的阶段,就完成了你的产品工作
最后就是收尾工作,以及后续的维护。
废话少说,就正式开始吧。
第零阶段:介绍
产品开发流程图

这是一个传统的软件开发流程,从需求的讨论开始到最后的产品上线,总共需要的六大步骤,包括后续的迭代升级维护。

这是一个传统的软件开发流程,从需求的讨论开始到最后的产品上线,总共需要的六大步骤,包括后续的迭代升级维护。
成本测算
这里面其实最大的就是投入的人力成本,还不算使用的电脑、软件这些,还包括最大的就是时间成本。
我们按按照基本公司业务项目的项目来迭代看
- 人力成本
- 产品:1~2 人,有些大项目合作的会更多,跨大部门合作的。
- UI :1 人
- 研发:
- 前端:1~2人
- 后端:2~3人
- 测试:1~2 人
- 合计:这里面最少都是 6 人
- 时间成本
- 这里不用多少,大家如果有经验的,基本公司项目一般的需求都是至少一个月才能上线一个版本,小需求快的也就是半个月上线。
- 沟通成本
- 这个就用说了,大家都是合作项目,产品和 UI,产品和研发,研发和测试,这就是为啥会有那么多会的缘故,不同的工种,面对的是不同

这里面其实最大的就是投入的人力成本,还不算使用的电脑、软件这些,还包括最大的就是时间成本。
我们按按照基本公司业务项目的项目来迭代看
- 人力成本
- 产品:1~2 人,有些大项目合作的会更多,跨大部门合作的。
- UI :1 人
- 研发:
- 前端:1~2人
- 后端:2~3人
- 测试:1~2 人
- 合计:这里面最少都是 6 人
- 时间成本
- 这里不用多少,大家如果有经验的,基本公司项目一般的需求都是至少一个月才能上线一个版本,小需求快的也就是半个月上线。
- 沟通成本
- 这个就用说了,大家都是合作项目,产品和 UI,产品和研发,研发和测试,这就是为啥会有那么多会的缘故,不同的工种,面对的是不同

时间成本感受
个人创业感想
那这里你就可能要较真了,你这个功能简单,哪能跟公司的项目比了。
那我就想起我之前跟我同学一起创业搞 app 的时候,那个时候我不会 app、也不会前端,我是主战大数据的,其实对后端有些框架不也太熟。
那会儿我们四个人,1 个 app、1 个后端+前端、1 个产品,也是足足搞了 1 个多月才勉强上了第一个小版本。
但是我们花的时间很多,虽然一个月,但那一个月我没睡过觉,不会就得学呀,哪像现在不会你找个 AI 帮手帮你搞,你就盯着就行,那会一边学前端,一遍写代码,遇到问题只能搜索引擎查,要么就是硬看源码去找思路解决。
想想就是很痛苦。
那这里你就可能要较真了,你这个功能简单,哪能跟公司的项目比了。
那我就想起我之前跟我同学一起创业搞 app 的时候,那个时候我不会 app、也不会前端,我是主战大数据的,其实对后端有些框架不也太熟。
那会儿我们四个人,1 个 app、1 个后端+前端、1 个产品,也是足足搞了 1 个多月才勉强上了第一个小版本。
但是我们花的时间很多,虽然一个月,但那一个月我没睡过觉,不会就得学呀,哪像现在不会你找个 AI 帮手帮你搞,你就盯着就行,那会一边学前端,一遍写代码,遇到问题只能搜索引擎查,要么就是硬看源码去找思路解决。
想想就是很痛苦。
公司工作感想
本职工作是大数据架构,设计的都是后端复杂的项目通信,整体底层架构设计,但是也需要去做一些产品的事情。
但是大数据产品就不像业务系统配比那么豪华,一整个公司就两三个人,那么有时候就的去做后端服务、前端界面,就为了把我们的产品体验做好。
每天下班疯狂学习前端框架,从最基本的 html、css、js 学起,不然问题解决不了,花了大量的时间,并且做项目还要学习各种框架,不然报错了你都不知道咋去搜索。
这样能做大功能的事情很少,也就是修修补补做些小功能。
本职工作是大数据架构,设计的都是后端复杂的项目通信,整体底层架构设计,但是也需要去做一些产品的事情。
但是大数据产品就不像业务系统配比那么豪华,一整个公司就两三个人,那么有时候就的去做后端服务、前端界面,就为了把我们的产品体验做好。
每天下班疯狂学习前端框架,从最基本的 html、css、js 学起,不然问题解决不了,花了大量的时间,并且做项目还要学习各种框架,不然报错了你都不知道咋去搜索。
这样能做大功能的事情很少,也就是修修补补做些小功能。
产品感想
这也是我最近用了 AI 编程后的感想,最近公司的数据产品项目,我基本都是 AI 编程搞定。
以前复杂的画布拖拉拽,我基本搞不定到上线的质量,现在咔咔的一下午就搞定开发,再结合 AI 的部署模板,一天就基本完成功能。效率太快。
也是这样,我在现在的公司的产出一周顶的上以前的一个月(这真不是吹牛,开会的半个小时,大数据的首页的 landing page 我就做好了🤦♂️) ,但是时间完全不用一周(偷偷的在这了讲,老板知道了,就。。。所以我要多做一些,让老板留下我)。
我现在感想的就是现在更加需要的就是你的创意、你的想法,现在的 AI 能力让更多的人提效的同时,也降低了普通人实现自己产品的可能性。这在以前是无法想象的,毕竟很多门槛是无法跨越,是需要时间磨练的。
这也是我最近用了 AI 编程后的感想,最近公司的数据产品项目,我基本都是 AI 编程搞定。
以前复杂的画布拖拉拽,我基本搞不定到上线的质量,现在咔咔的一下午就搞定开发,再结合 AI 的部署模板,一天就基本完成功能。效率太快。
也是这样,我在现在的公司的产出一周顶的上以前的一个月(这真不是吹牛,开会的半个小时,大数据的首页的 landing page 我就做好了🤦♂️) ,但是时间完全不用一周(偷偷的在这了讲,老板知道了,就。。。所以我要多做一些,让老板留下我)。
我现在感想的就是现在更加需要的就是你的创意、你的想法,现在的 AI 能力让更多的人提效的同时,也降低了普通人实现自己产品的可能性。这在以前是无法想象的,毕竟很多门槛是无法跨越,是需要时间磨练的。
效果展示
然后再多来几张美美的截图(偷偷告诉你,这就是我的背景图片工具做出来的。)

然后再多来几张美美的截图(偷偷告诉你,这就是我的背景图片工具做出来的。)

第一阶段:需求
1、需求思考
做产品最开的就是需求了,如果你是产品经理,那么我理解这一阶段是不需要 AI 来帮你忙的。
虽然大家基本对产品或多或少都有一些理解,那么专业性肯定比不了,那么我们就需要找专业的帮忙了。
所以我这里找的是 ChatGPT,大家找 DeepSeek,或者是 Gemin,或者是 Claude 都可以的。
我目前准备为痛风患者开发一个拍照识别食物嘌呤的h5应用,我的需求如下:
1. 这个h5底部有3个tab: 识别、食物、我的
2. 在【识别】页面,用户可以选择拍照或者选择相册上传,然后AI识别食物,并且给到对应的嘌呤的识别结果和建议。
3. 在【食物】页面,用于展示不同升糖指数的常见食物,顶部有一个筛选,用户可以筛选按嘌呤含量高低的食物,下方显示食物照片/名称/嘌呤/描述
4. 【我的】页面顶部有一个折线图,用户记录用户的尿酸历史;下方显示近3次的尿酸数据:包括平均、最低、最高的数据;还有记录尿酸和历史记录的列表。
在技术上,【我的】页面尿酸历史记录保存在本地localStorage中,【食物】的筛选也是通过本地筛选,拍照识别食物嘌呤的功能,采用通义千问的vl模型。
请你参考我的需求,帮我编写一份对应的需求文档。
发给 ChatGPT

这样就给我们回复了。
做产品最开的就是需求了,如果你是产品经理,那么我理解这一阶段是不需要 AI 来帮你忙的。
虽然大家基本对产品或多或少都有一些理解,那么专业性肯定比不了,那么我们就需要找专业的帮忙了。
所以我这里找的是 ChatGPT,大家找 DeepSeek,或者是 Gemin,或者是 Claude 都可以的。
我目前准备为痛风患者开发一个拍照识别食物嘌呤的h5应用,我的需求如下:
1. 这个h5底部有3个tab: 识别、食物、我的
2. 在【识别】页面,用户可以选择拍照或者选择相册上传,然后AI识别食物,并且给到对应的嘌呤的识别结果和建议。
3. 在【食物】页面,用于展示不同升糖指数的常见食物,顶部有一个筛选,用户可以筛选按嘌呤含量高低的食物,下方显示食物照片/名称/嘌呤/描述
4. 【我的】页面顶部有一个折线图,用户记录用户的尿酸历史;下方显示近3次的尿酸数据:包括平均、最低、最高的数据;还有记录尿酸和历史记录的列表。
在技术上,【我的】页面尿酸历史记录保存在本地localStorage中,【食物】的筛选也是通过本地筛选,拍照识别食物嘌呤的功能,采用通义千问的vl模型。
请你参考我的需求,帮我编写一份对应的需求文档。
发给 ChatGPT

这样就给我们回复了。
2、思考
你说我不会像你写那么多好的提示词,一个我也是借鉴别人的,一个就是继续找 AI 帮你搞定,比如你不知道 localstoreage 是什么,没关系,这个都是可以找 AI 问出来的。

或者是说你只有一个想法,而不知道这个产品要做成什么,也可以问 AI。
GPT 会告诉你每个阶段该做哪些功能,这样看看哪些对你合适,然后通过不断的多轮对话,来让他输出最后的需求文档。

你说我不会像你写那么多好的提示词,一个我也是借鉴别人的,一个就是继续找 AI 帮你搞定,比如你不知道 localstoreage 是什么,没关系,这个都是可以找 AI 问出来的。

或者是说你只有一个想法,而不知道这个产品要做成什么,也可以问 AI。
GPT 会告诉你每个阶段该做哪些功能,这样看看哪些对你合适,然后通过不断的多轮对话,来让他输出最后的需求文档。

3、创建需求工作空间
我们在电脑新建个目录,用来存放暂时的需求文档和一些前置工作的文件
Step 1: 在电脑的某个目录下创建前期我们需要的工作项目的目录,这里我叫 h5-food-piaoling
Step 2: Cursor 打开这个目录
Step 3: 创建 docs 目录
Step 4: docs 目录下创建 prd.md 文件,把刚刚 GPT 生成的需求文档拷贝过来。
我这里是后截图的,所以文件很多,不要受干扰了

我们在电脑新建个目录,用来存放暂时的需求文档和一些前置工作的文件
Step 1: 在电脑的某个目录下创建前期我们需要的工作项目的目录,这里我叫 h5-food-piaoling
Step 2: Cursor 打开这个目录
Step 3: 创建 docs 目录
Step 4: docs 目录下创建 prd.md 文件,把刚刚 GPT 生成的需求文档拷贝过来。
我这里是后截图的,所以文件很多,不要受干扰了

4、重要的一步
到这里需求文档就创建好了,那么我们是不是马上就可以开发了,哦,NO,这里还有很重要的一步。
那么就是需要仔细看这个 GPT 给我们生成的需求文档,还是需要人工审核下的,避免一些小细节的词语、或者影响的需要修改的。
比如这里,我已经恢复不出来了,这里原来有些 “什么一页的文章,适合老年人的这些文字”,这些其实不符合我那会儿想的需求的,所以我就删除了。

比如这里用到的一些技术,如果你懂的话,就可以换成你懂的技术,也是需要考虑到后面迭代升级的一些事情。

总结:其实这里就是需要人工审查下,避免一些很不符合你想的那些,是需要修改/删除的,这个会影响后面生成 UI/交互的逻辑。
不过这个步骤不做问题也不大,这一步也是需要长久锻炼出来,后面等真实的页面出来后,你再去修改也行。
到这里需求文档就创建好了,那么我们是不是马上就可以开发了,哦,NO,这里还有很重要的一步。
那么就是需要仔细看这个 GPT 给我们生成的需求文档,还是需要人工审核下的,避免一些小细节的词语、或者影响的需要修改的。
比如这里,我已经恢复不出来了,这里原来有些 “什么一页的文章,适合老年人的这些文字”,这些其实不符合我那会儿想的需求的,所以我就删除了。

比如这里用到的一些技术,如果你懂的话,就可以换成你懂的技术,也是需要考虑到后面迭代升级的一些事情。

总结:其实这里就是需要人工审查下,避免一些很不符合你想的那些,是需要修改/删除的,这个会影响后面生成 UI/交互的逻辑。
不过这个步骤不做问题也不大,这一步也是需要长久锻炼出来,后面等真实的页面出来后,你再去修改也行。
第二阶段:数据准备
这里的一步也是我认为比较特别的点,这个步骤的点可以借鉴到其他场景里面。
这里的一步也是我认为比较特别的点,这个步骤的点可以借鉴到其他场景里面。
1、哪里找数据
你的产品里的数据的可信度在哪里?特别是关乎于健康的,网上的信息纷繁复杂,大家很难分清哪些是真的,哪些是假的。
我之前查食物的嘌呤的时候,就遇见了,同样一个食物,app 上看到的,网上看到的都不一样,我就黑人问号了???
所以,这里就涉及到数据的权威性、真实性了。那么权威机构发布的可信度会更强。
所以我找到了卫健委颁发的数据。
地址:http://www.nhc.gov.cn/sps/c100088…

另外还可以看到不止痛风的资料有,还有青少年、肥胖、肾病的食养指南。
这些病其实都是慢性病,不是吃药就能马上好起来,需要长期靠饮食、运动来恢复的。
可以把这些数据用起来,后面挖掘更多需求。
你的产品里的数据的可信度在哪里?特别是关乎于健康的,网上的信息纷繁复杂,大家很难分清哪些是真的,哪些是假的。
我之前查食物的嘌呤的时候,就遇见了,同样一个食物,app 上看到的,网上看到的都不一样,我就黑人问号了???
所以,这里就涉及到数据的权威性、真实性了。那么权威机构发布的可信度会更强。
所以我找到了卫健委颁发的数据。
地址:http://www.nhc.gov.cn/sps/c100088…

另外还可以看到不止痛风的资料有,还有青少年、肥胖、肾病的食养指南。
这些病其实都是慢性病,不是吃药就能马上好起来,需要长期靠饮食、运动来恢复的。
可以把这些数据用起来,后面挖掘更多需求。
2、下载数据
这一步周就是把数据下载下来,直接点击上面的

下载来后是个pdf 的文件,那么这一步我们就准备好了。
这里我附带一份,大家可以作为参考
暂时无法在飞书文档外展示此内容
这一步周就是把数据下载下来,直接点击上面的

下载来后是个pdf 的文件,那么这一步我们就准备好了。
这里我附带一份,大家可以作为参考
暂时无法在飞书文档外展示此内容
3、处理数据
这一步是为什么了,是因为目前在所有的 AI 编程工具里面,pdf 是读取不了的,特别是 Cursor 里面。
目前能够读取的是 markdown 格式的数据
markdown 格式的数据很简单,就是纯文本,加上一些符号,就可以做成标题显示
不懂的可以直接问题 AI 工具就行了。
这里就可以看到大模型给我们的解释了。
这一步是为什么了,是因为目前在所有的 AI 编程工具里面,pdf 是读取不了的,特别是 Cursor 里面。
目前能够读取的是 markdown 格式的数据
markdown 格式的数据很简单,就是纯文本,加上一些符号,就可以做成标题显示
不懂的可以直接问题 AI 工具就行了。
这里就可以看到大模型给我们的解释了。
插曲
我不懂 markdown 是什么,帮我解释下,我一点都不懂这个
在 Cursor 里面使用 ask 模式来提问

下面就是一个回答的截图,如果你对里面的文字不清楚的,那么就继续问 AI 就可以了。多轮对话。

我不懂 markdown 是什么,帮我解释下,我一点都不懂这个
在 Cursor 里面使用 ask 模式来提问

下面就是一个回答的截图,如果你对里面的文字不清楚的,那么就继续问 AI 就可以了。多轮对话。

处理数据
这里就是需要把 pdf 转为 markdown 的数据
这里推荐使用:mineru.net/
重点在于免费,直接登录注册进来后,点击上传我们刚下载的 pdf。

等待上传转换完成,下一步就是在文件里面,看到转换的文件了。
点击右侧下载,就是 markdown 格式。

把下载好的 markdown 文件放入到项目里面的 data 目录,待会儿会需要数据处理。

这里就是需要把 pdf 转为 markdown 的数据
这里推荐使用:mineru.net/
重点在于免费,直接登录注册进来后,点击上传我们刚下载的 pdf。

等待上传转换完成,下一步就是在文件里面,看到转换的文件了。
点击右侧下载,就是 markdown 格式。

把下载好的 markdown 文件放入到项目里面的 data 目录,待会儿会需要数据处理。

4、修正需求文档
那么让 Cursor 给我们重新生成需求文档,这样食物的分类,还有统计,会更准确,因为现在是基于权威数据来的。
食物数据库目前是存储在 json 文件里,请根据 @成人高尿酸血症与痛风食养指南 (2024 年版).md 的食物嘌呤数据,再根据 @prd.md 里面的食物数据结构,生成一份数据,并获取对应的 image 图片,保存在 imgs 目录下

那么让 Cursor 给我们重新生成需求文档,这样食物的分类,还有统计,会更准确,因为现在是基于权威数据来的。
食物数据库目前是存储在 json 文件里,请根据 @成人高尿酸血症与痛风食养指南 (2024 年版).md 的食物嘌呤数据,再根据 @prd.md 里面的食物数据结构,生成一份数据,并获取对应的 image 图片,保存在 imgs 目录下

5、生成数据文件
前面我们不是讲到了。食物列表的数据需要存储在本地,也就是客户端,形式我们就采用 json 的形式
同样你不知道 json 是个啥的话,找 AI 问,或者直接 Cursor 里面提问就行了。
左边是提示词,右侧就是创建的 json 文件
食物数据库目前是存储在 json 文件里,请根据 @成人高尿酸血症与痛风食养指南 (2024 年版).md 的食物嘌呤数据,再根据 @prd.md 里面的食物数据结构,生成一份数据,并获取对应的 image 图片,保存在 imgs 目录下


结果:

6、继续调整文件
上一步骤发现,其实只给我们列觉了 53 种食物,并不全
我需要全部的数据,那么继续
总结的有 53 种食物,但是我看 @成人高尿酸血症与痛风食养指南 (2024 年版).md 下的“表1-2 常见食物嘌呤含量表” 应该不止这么多,请再次阅读然后补全数据到 @foods.json 文件里。

最后发现,总文档里总结了 180 种的食物

最后生成的数据文件如下:

前面我们不是讲到了。食物列表的数据需要存储在本地,也就是客户端,形式我们就采用 json 的形式
同样你不知道 json 是个啥的话,找 AI 问,或者直接 Cursor 里面提问就行了。
左边是提示词,右侧就是创建的 json 文件
食物数据库目前是存储在 json 文件里,请根据 @成人高尿酸血症与痛风食养指南 (2024 年版).md 的食物嘌呤数据,再根据 @prd.md 里面的食物数据结构,生成一份数据,并获取对应的 image 图片,保存在 imgs 目录下


结果:

6、继续调整文件
上一步骤发现,其实只给我们列觉了 53 种食物,并不全
我需要全部的数据,那么继续
总结的有 53 种食物,但是我看 @成人高尿酸血症与痛风食养指南 (2024 年版).md 下的“表1-2 常见食物嘌呤含量表” 应该不止这么多,请再次阅读然后补全数据到 @foods.json 文件里。

最后发现,总文档里总结了 180 种的食物

最后生成的数据文件如下:

6、图片问题
不过这里有个问题就是,食物对应的图片是没有办法在这里一次性完成的
我也尝试了在 Cursor 里让他帮我完成,结果些了一大堆的代码,下来的图片还对应不上。
尝试了很多方案,都不太理想。
那你说了,去搜索引擎下载了,我也想到了,不过想起来你要去搜索,然后找图片,下载,有 180 多种了,还要命名好图片名字,最后保存到目录。
想到这里,我就头大,索性干脆自己写一个,其他流程系统都帮我搞定,暂时目前只需要我人工确认图片,保证准确性。
Claude Code + 爬虫碰撞什么样的火花,3 小时搞定我的数据需求
这个小系统也还是有很大的挖掘潜力,后面也还可以做很多事情
到这里基本需求阶段就完成了,数据也准备的差不多了,下面就是进入开发阶段了。
不要看前面的文字多,那都是前戏,下面就是正戏,坐稳扶好,开奔。
不过这里有个问题就是,食物对应的图片是没有办法在这里一次性完成的
我也尝试了在 Cursor 里让他帮我完成,结果些了一大堆的代码,下来的图片还对应不上。
尝试了很多方案,都不太理想。
那你说了,去搜索引擎下载了,我也想到了,不过想起来你要去搜索,然后找图片,下载,有 180 多种了,还要命名好图片名字,最后保存到目录。
想到这里,我就头大,索性干脆自己写一个,其他流程系统都帮我搞定,暂时目前只需要我人工确认图片,保证准确性。
Claude Code + 爬虫碰撞什么样的火花,3 小时搞定我的数据需求
这个小系统也还是有很大的挖掘潜力,后面也还可以做很多事情
到这里基本需求阶段就完成了,数据也准备的差不多了,下面就是进入开发阶段了。
不要看前面的文字多,那都是前戏,下面就是正戏,坐稳扶好,开奔。
第三阶段:开发+联调+测试
这里是主要的开发、联调、测试阶段,也就是在传统开发流程中会占据大部分的时间,基本一个软件/系统的开发大部分的时间都在这个里面,所以我们看看结合 AI 它的速度将会达到什么样。
这里是主要的开发、联调、测试阶段,也就是在传统开发流程中会占据大部分的时间,基本一个软件/系统的开发大部分的时间都在这个里面,所以我们看看结合 AI 它的速度将会达到什么样。
== 1、前端 ==
步骤一:bolt 开发
说下为什么采用 bolt 工具来做第一步工作。
其实线下 v0、bolt、lovable 很多这种前端设计工具,那么他与 Cursor 的区别在哪里了?
1、首先通过简单的提示词,它生成的功能和 UI 基本都是很完善的,UI 很美、交互也很舒服。这种你在 Curosr 里面从零开始些是很难的。
2、这种工具一般都可以选择界面的上的元素(比如 div、button,这个就比较难),然后进行你的提示词修改,很精准,这个你在 Cursor 里面比较难做。
3、还有一个点就是前端开发的界面的定位这些大模型很难听得懂你在说啥的,所以我感觉也是这块的难度采用了上面那么多的类似的工具的诞生。
当然,如果不用这些工具,直接让 Cursor 给你出 ui 设计,然后使用 UI 设计出前端代码也可以的。
这个我看看后面用其他例子来讲解。
把上面的需求步骤的 prd.md 的需求直接粘贴到提示词框里。没问题,就可以直接点击提交了。
小技巧:看左下角有个五角星的图标,是可以美化提示词的,这个目前倒是 bolt 都有的功能。
另外还可以通过 Github 或者 Figma 来生成项目图片。

下面就是嘎嘎开始干活了。

等他写完,就可以在界面的右侧看到写完的H5程序。
界面很简单,左侧就是对话区域,右侧就是产品的展示区域
小细节:在使用移动端展示的时候,还可以选择对应的手机型号

说下为什么采用 bolt 工具来做第一步工作。
其实线下 v0、bolt、lovable 很多这种前端设计工具,那么他与 Cursor 的区别在哪里了?
1、首先通过简单的提示词,它生成的功能和 UI 基本都是很完善的,UI 很美、交互也很舒服。这种你在 Curosr 里面从零开始些是很难的。
2、这种工具一般都可以选择界面的上的元素(比如 div、button,这个就比较难),然后进行你的提示词修改,很精准,这个你在 Cursor 里面比较难做。
3、还有一个点就是前端开发的界面的定位这些大模型很难听得懂你在说啥的,所以我感觉也是这块的难度采用了上面那么多的类似的工具的诞生。
当然,如果不用这些工具,直接让 Cursor 给你出 ui 设计,然后使用 UI 设计出前端代码也可以的。
这个我看看后面用其他例子来讲解。
把上面的需求步骤的 prd.md 的需求直接粘贴到提示词框里。没问题,就可以直接点击提交了。
小技巧:看左下角有个五角星的图标,是可以美化提示词的,这个目前倒是 bolt 都有的功能。
另外还可以通过 Github 或者 Figma 来生成项目图片。

下面就是嘎嘎开始干活了。

等他写完,就可以在界面的右侧看到写完的H5程序。
界面很简单,左侧就是对话区域,右侧就是产品的展示区域
小细节:在使用移动端展示的时候,还可以选择对应的手机型号

步骤二:调整
1、错误修复
这个交互我觉得做的特别好,不用粘贴错误,直接就在界面上点击“Attempt fix”就可以了,这真的是纯 vibe coding ,粘贴复制都不用了。🤦♂️
如果有错误,继续就可以了。

这个交互我觉得做的特别好,不用粘贴错误,直接就在界面上点击“Attempt fix”就可以了,这真的是纯 vibe coding ,粘贴复制都不用了。🤦♂️
如果有错误,继续就可以了。

2、UI 调整:主题
刚开始其实 UI 并不是太好看,我的主题色是绿色的,所以我也不知道让它弄什么样的好看。
再帮我美化下 UI 界面
就输入了上面一句话,刚开始的 UI 如下图

最后看下对比效果
左边是最开始生成的,右边是我让他优化后的样子。还是有很多细节优化的。

刚开始其实 UI 并不是太好看,我的主题色是绿色的,所以我也不知道让它弄什么样的好看。
再帮我美化下 UI 界面
就输入了上面一句话,刚开始的 UI 如下图

最后看下对比效果
左边是最开始生成的,右边是我让他优化后的样子。还是有很多细节优化的。

3、UI 修复方式一:截图
另外如果样式有问题,可以截图粘贴到对话框,然后输入提示词修改。

另外如果样式有问题,可以截图粘贴到对话框,然后输入提示词修改。

4、UI 修复方式二:选择元素
这里就是我要说的可以选择界面上的元素,然后针对某些元素进行改写
bolt 的方式这几输入提示词
v0 比较高级,选择后,可以直接修改 div 的一些样式参数,比如:宽高、字体、布局、背景、阴影。精准调节。(低代码+vibe coding)

经过多轮修复,觉得功能差不多了,就可以转战本地 Cursor 就继续下一步了。
这里就是我要说的可以选择界面上的元素,然后针对某些元素进行改写
bolt 的方式这几输入提示词
v0 比较高级,选择后,可以直接修改 div 的一些样式参数,比如:宽高、字体、布局、背景、阴影。精准调节。(低代码+vibe coding)

经过多轮修复,觉得功能差不多了,就可以转战本地 Cursor 就继续下一步了。
步骤三:本地 Cursor 修改
1、同步代码到 Github
点击右上角的「Integrations」里的 Github。

下面就会提示你链登录 Github

接着授权就可以

然欧输入你需要创建的项目名称

点击右上角的「Integrations」里的 Github。

下面就会提示你链登录 Github

接着授权就可以

然欧输入你需要创建的项目名称

2、本地下载代码
使用 git 工具把代码下载到本地
git 就类似游戏的存档工具,每一个步骤都可以存档,当有问题的时候,就可以从某个存档恢复了。
当然:这里需要提前安装好 Git,如果有不懂的可以联系我,我来帮你解决。这你就不多说了
打开你的 Github 仓库页面,复制 HTTPS 的地址

然后使用下面的命令,就可以下载到本地了。
git clone 你的代码仓库地址

下一步就是安装代码的依赖包
这里需要 nodejs 环境,同样就不多说了,不懂的可以私聊

下一步就是启动

接着就是浏览器打开上面的地址:http://localhsot:3000,就可以看见上面写好的页面。
默认打开是按照 pc 的全屏显示的,可能看着有些别扭

我们打开 F12,打开调试窗口,如下图
点击右侧类似电脑手机的按钮,就可以调到移动端模式显示了,还可以选择对应的机型。

使用 git 工具把代码下载到本地
git 就类似游戏的存档工具,每一个步骤都可以存档,当有问题的时候,就可以从某个存档恢复了。
当然:这里需要提前安装好 Git,如果有不懂的可以联系我,我来帮你解决。这你就不多说了
打开你的 Github 仓库页面,复制 HTTPS 的地址

然后使用下面的命令,就可以下载到本地了。
git clone 你的代码仓库地址

下一步就是安装代码的依赖包
这里需要 nodejs 环境,同样就不多说了,不懂的可以私聊

下一步就是启动

接着就是浏览器打开上面的地址:http://localhsot:3000,就可以看见上面写好的页面。
默认打开是按照 pc 的全屏显示的,可能看着有些别扭

我们打开 F12,打开调试窗口,如下图
点击右侧类似电脑手机的按钮,就可以调到移动端模式显示了,还可以选择对应的机型。

xx 小插曲 xx
原本不想放这里的,结果还是放一下吧,刚好是解决了一个很大的问题
刚开始在 bolt 上面修改的时候,修改后一直报个错误,结果修复了很多次,还是没有解决。

没办法,我就在本地 Cursor 上仔细看了下代码,发现是个引号的问题。

我就在本地 Cursor 中快速修复了下

但是后面惊悚的事情来了,我去 bolt 上调整了下界面样式,结果又给我写成了引号的问题
最后我就发现,可能 bolt 目前对这类的错误还是没有意识。并且看它界面的代码,每次都是从头开始写(难怪要等好一段时间才弄完,究竟是什么设计了?)
最后索性,我仔细看了下代码,删除掉了,没啥大的影响。
目前来看 bolt 这种工具还是有点门槛,解决错误的能力还是没有 Cursor 强大,一不小心页面上的错误就在一起存在,你也不知道它改了啥。
这就需要你对代码还是有基本的认识。
原本不想放这里的,结果还是放一下吧,刚好是解决了一个很大的问题
刚开始在 bolt 上面修改的时候,修改后一直报个错误,结果修复了很多次,还是没有解决。

没办法,我就在本地 Cursor 上仔细看了下代码,发现是个引号的问题。

我就在本地 Cursor 中快速修复了下

但是后面惊悚的事情来了,我去 bolt 上调整了下界面样式,结果又给我写成了引号的问题
最后我就发现,可能 bolt 目前对这类的错误还是没有意识。并且看它界面的代码,每次都是从头开始写(难怪要等好一段时间才弄完,究竟是什么设计了?)
最后索性,我仔细看了下代码,删除掉了,没啥大的影响。
目前来看 bolt 这种工具还是有点门槛,解决错误的能力还是没有 Cursor 强大,一不小心页面上的错误就在一起存在,你也不知道它改了啥。
这就需要你对代码还是有基本的认识。
步骤四:使用本地数据
首先就是把前面下载准备好的图片放到 imgs 目录下

在 Cursor 中让从 imgs 目录中显示图片。

不过这里 Cursor 还是很智能的,访问后都是 404

那么就直接告诉 Cursor 让他解决这个问题。
结果他一下子就找到问题所在了,需要放在 public 目录下,这个放以前你需要去搜索引擎里面找问题,并且有时候你拿让 AI 解决的问题,去搜索引擎找,基本都是牛头不对马嘴的回答。
最后还要去找官方文档看资料,不断的尝试。


首先就是把前面下载准备好的图片放到 imgs 目录下

在 Cursor 中让从 imgs 目录中显示图片。

不过这里 Cursor 还是很智能的,访问后都是 404

那么就直接告诉 Cursor 让他解决这个问题。
结果他一下子就找到问题所在了,需要放在 public 目录下,这个放以前你需要去搜索引擎里面找问题,并且有时候你拿让 AI 解决的问题,去搜索引擎找,基本都是牛头不对马嘴的回答。
最后还要去找官方文档看资料,不断的尝试。


** 前端小结 **
到这里,基本前端的事情就搞完了
1、识别:识别流程,现在都是走前端模拟的流程
2、食物:这里目前应该是很全的功能了,读取本地的 json 数据,有分类标识,还有图片的展示
3、我的:个人中心有尿酸的记录,有曲线图,还有基本的体重指数记录。

到这里,基本前端的事情就搞完了
1、识别:识别流程,现在都是走前端模拟的流程
2、食物:这里目前应该是很全的功能了,读取本地的 json 数据,有分类标识,还有图片的展示
3、我的:个人中心有尿酸的记录,有曲线图,还有基本的体重指数记录。

== 2、后端 ==
步骤零:阿里云大模型准备
背景:需要使用大模型来识别图片,然后返回嘌呤的含量,所以我们需要选择一个大模型的 API 来对接。
这里选择阿里的 qwen 来对接。
登录百炼平台:bailian.console.aliyun.com/
访问API-kEY 的地址:bailian.console.aliyun.com/?tab=model#…
创建一个 API-kEY,并保存好你的 key 信息。

背景:需要使用大模型来识别图片,然后返回嘌呤的含量,所以我们需要选择一个大模型的 API 来对接。
这里选择阿里的 qwen 来对接。
登录百炼平台:bailian.console.aliyun.com/
访问API-kEY 的地址:bailian.console.aliyun.com/?tab=model#…
创建一个 API-kEY,并保存好你的 key 信息。

步骤一:创建必要的配置
先访问找到通义千问 API 的文档的地方

这里我们采用直接复制上面页面的内容,保存到项目下的 docs 目录在的 qwen.md 里面

这里顺便把之前的 prd.md 文档从之前的项目目录拷贝过来了
先访问找到通义千问 API 的文档的地方

这里我们采用直接复制上面页面的内容,保存到项目下的 docs 目录在的 qwen.md 里面

这里顺便把之前的 prd.md 文档从之前的项目目录拷贝过来了
步骤二:创建后端服务模板代码
直接使用下面的提示词,就可以创建一个后端的服务
这里要想为什么要创建后端服务,
一方面主要是需要调用大模型的 API,用到一些KEY 信息,这些是需要保密的,不能在前端被人看到了。
另外一方面,后面如果需要一些登录注册服务,还有食物数据都是需要后端来存储,提供给前端。
请在项目的根目录下创建 backend 目录,在这个 backend 目录下创建一个基于fastify框架的server,保证服务没有问题
同样的不知道什么fastify技术的,找大模型聊就行。



直接使用下面的提示词,就可以创建一个后端的服务
这里要想为什么要创建后端服务,
一方面主要是需要调用大模型的 API,用到一些KEY 信息,这些是需要保密的,不能在前端被人看到了。
另外一方面,后面如果需要一些登录注册服务,还有食物数据都是需要后端来存储,提供给前端。
请在项目的根目录下创建 backend 目录,在这个 backend 目录下创建一个基于fastify框架的server,保证服务没有问题
同样的不知道什么fastify技术的,找大模型聊就行。



步骤三:API 文档+后端业务服务开发
重点来了,这里我就写到一个提示词里面,让他完成的
帮我接入图像理解能力,参考 @qwen.md :
1. 现在在 @/backend 的后端服务器环境中调用ai能力,
2. 使用 .env 文件保存API_KEY,并使用环境变量中的DASHSCOPE_API_KEY.并且.env文件不能提交到git上,提交到git的可以用.env.example文件作为举例供供用户参考
3. 要求使用openai的sdk,并且前端上传base64的图片
4. 后端返回值要求返回json格式,返回的数据能够渲染识别结果中的字段,包括:食物/嘌呤值/是否适合高尿酸患者/食用建议/营养成分估算
5. 在 @/backend 目录下创建 api.md 文件,记录后端接口文档
这里我把 api.md 高亮了,这个是关键,是后面前后端联调的关键,不然 Cursor 是不知道请求字段和响应字段该怎么对接的,到时候数据不对,再来调试就比较麻烦。
所以接口文档务必保证 100% 准确,后面的调试就会很容易。
截图如下:



很贴心的完成功能后,最后帮我们些了 api.md 接口文档,还进行了一些列测试,保证功能是完整的。
这里放出来,Cursor 看是怎么帮我们写这个代码的
- 帮我们组装好了提示词
- 根据 qwen.md 的接口文档,组装请求数据和返回数据,字段都我们的项目符合

重点来了,这里我就写到一个提示词里面,让他完成的
帮我接入图像理解能力,参考 @qwen.md :
1. 现在在 @/backend 的后端服务器环境中调用ai能力,
2. 使用 .env 文件保存API_KEY,并使用环境变量中的DASHSCOPE_API_KEY.并且.env文件不能提交到git上,提交到git的可以用.env.example文件作为举例供供用户参考
3. 要求使用openai的sdk,并且前端上传base64的图片
4. 后端返回值要求返回json格式,返回的数据能够渲染识别结果中的字段,包括:食物/嘌呤值/是否适合高尿酸患者/食用建议/营养成分估算
5. 在 @/backend 目录下创建 api.md 文件,记录后端接口文档
这里我把 api.md 高亮了,这个是关键,是后面前后端联调的关键,不然 Cursor 是不知道请求字段和响应字段该怎么对接的,到时候数据不对,再来调试就比较麻烦。
所以接口文档务必保证 100% 准确,后面的调试就会很容易。
截图如下:



很贴心的完成功能后,最后帮我们些了 api.md 接口文档,还进行了一些列测试,保证功能是完整的。
这里放出来,Cursor 看是怎么帮我们写这个代码的
- 帮我们组装好了提示词
- 根据 qwen.md 的接口文档,组装请求数据和返回数据,字段都我们的项目符合

== 3、联调 ==
其实这里的联调很简单了。就是一句话的事情。
因为之前的前端的拍照图片都是走的模拟的接口,没有真正的调用后端的接口,所以需要换成真正的后端接口。
刚好前面的后端服务写好了 api.md 接口文档
前端修改点,前端目录是当前根目录
1. 也需要加入请求后端的 url 的环境变量,本地调试就默认使用 localhost,线上发布的时候设置环境变量后,前端服务从环境变量获取 url 然后请求到对应的后端服务
2. 食物识别的接口参考 @api.md 文档,请修改需要适配的地方,食物识别的代码在 @identify-page.tsx代码中。



这里要说的是:前面的 api.md 接口文档些的非常准备,这一步的前端请求后端接口,基本都是一遍过,所以后端提供的接口文档一定要准确,这样前端就可以很准确的调用接口传参和取返回值了。
其实这里的联调很简单了。就是一句话的事情。
因为之前的前端的拍照图片都是走的模拟的接口,没有真正的调用后端的接口,所以需要换成真正的后端接口。
刚好前面的后端服务写好了 api.md 接口文档
前端修改点,前端目录是当前根目录
1. 也需要加入请求后端的 url 的环境变量,本地调试就默认使用 localhost,线上发布的时候设置环境变量后,前端服务从环境变量获取 url 然后请求到对应的后端服务
2. 食物识别的接口参考 @api.md 文档,请修改需要适配的地方,食物识别的代码在 @identify-page.tsx代码中。



这里要说的是:前面的 api.md 接口文档些的非常准备,这一步的前端请求后端接口,基本都是一遍过,所以后端提供的接口文档一定要准确,这样前端就可以很准确的调用接口传参和取返回值了。
== 4、测试 ==
其实到这里,基本测试的工作也就完成了。
基本的流程到现在都是跑通的。
不过还是需要多实际测试,这里下面的例子就是,我上传了「黄瓜」的照片,结果没识别,按理说不应该呀。
这里上了点专业的技巧,通过 F12 的调试窗口,看下接口返回的数据。
按照以往经验来说,估计是字段对应不上

所以我就直接和 Cursor 说,可能是字段对应不上。请帮我修复。
测试黄光的食物的时候,后台接口返回的数据是 "purine_level": "low(低嘌呤<50mg)",但是 @getPurineLevel() @getPurineLevelText 没有识别到,请帮我修复
最后从前后端都给我做了修复,字段的匹配对应上了。

最后的总结如下:

其实到这里,基本测试的工作也就完成了。
基本的流程到现在都是跑通的。
不过还是需要多实际测试,这里下面的例子就是,我上传了「黄瓜」的照片,结果没识别,按理说不应该呀。
这里上了点专业的技巧,通过 F12 的调试窗口,看下接口返回的数据。
按照以往经验来说,估计是字段对应不上

所以我就直接和 Cursor 说,可能是字段对应不上。请帮我修复。
测试黄光的食物的时候,后台接口返回的数据是 "purine_level": "low(低嘌呤<50mg)",但是 @getPurineLevel() @getPurineLevelText 没有识别到,请帮我修复
最后从前后端都给我做了修复,字段的匹配对应上了。

最后的总结如下:

== 4、总结 ==
其实到这里基本功能就完成了。
- 前端使用 bolt 工具等生成,快速生成漂亮的 UI 界面和基本完整的前端功能
- bolt工具调整样式、UI 等细节(擅长的)
- Cursor 精修前端小细节
- Cursor 开发完整后端功能
- 写清楚需求,如果知道具体技术栈是最好的
- 写好接口文档,最好人工校验下
- 前后端联调
- @使用后端的接口文档,最好写改动的接口的地方,前后精准对接
- 学会使用浏览器的 F12 调试窗口,特备是接口的请求参数和响应值的学习。
就目前来看,如果你是零基础,那么基本的术语不明白的话,有些问题可能会不好解决
- 寻求 AI 的帮助,遇事不决问 AI,它可以帮你搞定
- 寻求懂行的人来帮助你,比如环境的事情、按照的事情有时候一句话就可以给你讲明白的。
其实到这里基本功能就完成了。
- 前端使用 bolt 工具等生成,快速生成漂亮的 UI 界面和基本完整的前端功能
- bolt工具调整样式、UI 等细节(擅长的)
- Cursor 精修前端小细节
- Cursor 开发完整后端功能
- 写清楚需求,如果知道具体技术栈是最好的
- 写好接口文档,最好人工校验下
- 前后端联调
- @使用后端的接口文档,最好写改动的接口的地方,前后精准对接
- 学会使用浏览器的 F12 调试窗口,特备是接口的请求参数和响应值的学习。
就目前来看,如果你是零基础,那么基本的术语不明白的话,有些问题可能会不好解决
- 寻求 AI 的帮助,遇事不决问 AI,它可以帮你搞定
- 寻求懂行的人来帮助你,比如环境的事情、按照的事情有时候一句话就可以给你讲明白的。
第四阶段:部署+上线
部署这一块其实对普通人门槛还比较高的,问题比较多。
- 域名问题
- 服务器问题
- 如何部署,如何配置
这里我们采用云厂商的部署服务,简化配置文件和部署的流程
但是域名申请还是需要提前准备好的,不过现在我们用的这个云服务暂时现在没有的域名,也有临时域名可以先用。
到这里,其实如果你只是本地看的话,就已经可以了,那么这里我们教一个上线部署的步骤,傻瓜式的,不需要各种配置环境。
我相信大家如果搞独立开发的 Vercel 肯定都熟悉了。这里也介绍下类似的工具,railway.com/,他不仅可以部署前端静态页面,还有后端服务,PostgreSQL、Redis 等数据库也支持一键部署。

部署这一块其实对普通人门槛还比较高的,问题比较多。
- 域名问题
- 服务器问题
- 如何部署,如何配置
这里我们采用云厂商的部署服务,简化配置文件和部署的流程
但是域名申请还是需要提前准备好的,不过现在我们用的这个云服务暂时现在没有的域名,也有临时域名可以先用。
到这里,其实如果你只是本地看的话,就已经可以了,那么这里我们教一个上线部署的步骤,傻瓜式的,不需要各种配置环境。
我相信大家如果搞独立开发的 Vercel 肯定都熟悉了。这里也介绍下类似的工具,railway.com/,他不仅可以部署前端静态页面,还有后端服务,PostgreSQL、Redis 等数据库也支持一键部署。

1、项目的配置文件
railway 部署是需要一些配置文件的,当然我们可以让 Cursor 帮我们搞定。
直接告诉 Cursor 我们需要部署到 railway 上,看还需要什么工作可以做的。
railway 部署是需要一些配置文件的,当然我们可以让 Cursor 帮我们搞定。
直接告诉 Cursor 我们需要部署到 railway 上,看还需要什么工作可以做的。
后端
@/backend 这个后端项目现在需要在railway上去部署,请帮我看看需要哪些部署配置



@/backend 这个后端项目现在需要在railway上去部署,请帮我看看需要哪些部署配置



前端
也是一样,让 Cursor 给我们生成部署的配置文件
当前目录是前端目录,也需要添加railway的部署相关配置



Cursor 会帮我们创建需要的配置文件,那么就可以进入下一步部署了。
也是一样,让 Cursor 给我们生成部署的配置文件
当前目录是前端目录,也需要添加railway的部署相关配置



Cursor 会帮我们创建需要的配置文件,那么就可以进入下一步部署了。
2、提交代码
记得要提交代码,在 Cursor 的页面添加提交代码,推送代码到 Github 上,这样 railway 才可以拉取到代码。
提交代码的时候,可以使用 AI 生成提交信息,也可以自己填写信息


记得还要同步更改


记得要提交代码,在 Cursor 的页面添加提交代码,推送代码到 Github 上,这样 railway 才可以拉取到代码。
提交代码的时候,可以使用 AI 生成提交信息,也可以自己填写信息


记得还要同步更改


3、railway 页面操作
现在会有赠送的额度,并且免费就用也有 512M 的内存机器使用。对于当前下的足够了。
注册登录后,选择 Dashboard 后,点击添加,就可以看到如下的页面,
添加 Github 项目,后续就会授权等操作,继续完成就可以。

下一步就一个你的项目
然后就会跳转到工作区间,会自动部署。

记得不要忘记环境变量

就是在「Variables」标签下,直接添加变量就行。
添加完记得需要重新部署下。
现在会有赠送的额度,并且免费就用也有 512M 的内存机器使用。对于当前下的足够了。
注册登录后,选择 Dashboard 后,点击添加,就可以看到如下的页面,
添加 Github 项目,后续就会授权等操作,继续完成就可以。

下一步就一个你的项目
然后就会跳转到工作区间,会自动部署。

记得不要忘记环境变量

就是在「Variables」标签下,直接添加变量就行。
添加完记得需要重新部署下。
后端环境变量

前端环境变量

当然不过你有错误,可以把 log 里面的错误复制,粘贴到 Cursor 里面,让他解决,我之前部署的项目有个就有问题,通过这个方式,帮我解决了。

当然不过你有错误,可以把 log 里面的错误复制,粘贴到 Cursor 里面,让他解决,我之前部署的项目有个就有问题,通过这个方式,帮我解决了。
4、大功告成
部署完成后怎么访问了,切换到 settings 页面,有个 Networking 部分,可以生成一个 railway 自带的域名,用这个域名就可以访问了,如果你有自己的域名还可以添加一个自己的域名,添加完以后就可以自己访问了。

部署完成后怎么访问了,切换到 settings 页面,有个 Networking 部分,可以生成一个 railway 自带的域名,用这个域名就可以访问了,如果你有自己的域名还可以添加一个自己的域名,添加完以后就可以自己访问了。

5、总结
很开心,跟我走到了这里,基本到这里,算是完成一大步,也就是我们的 MVP 完成了。
现在我们再来总结下前面整体的步骤
1、前端我们通过 bolt 来生成代码,加速前端的设计,让 bolt 这种工具提供我们更多的能力,发挥他的有点
2、后端使用 Cursor 来开发,纯业务逻辑通过提示词还是很好的达到效果。
3、前后端联调,写好接口文档,让 Cursor 必须阅读接口文档,前端再写接口
4、部署配置文件也可以通过 Cursor 来搞定,无所不能
5、中间有任何问题,有任何不懂的都可以找 Cursor 使用 ask 模式搞定。
很开心,跟我走到了这里,基本到这里,算是完成一大步,也就是我们的 MVP 完成了。
现在我们再来总结下前面整体的步骤
1、前端我们通过 bolt 来生成代码,加速前端的设计,让 bolt 这种工具提供我们更多的能力,发挥他的有点
2、后端使用 Cursor 来开发,纯业务逻辑通过提示词还是很好的达到效果。
3、前后端联调,写好接口文档,让 Cursor 必须阅读接口文档,前端再写接口
4、部署配置文件也可以通过 Cursor 来搞定,无所不能
5、中间有任何问题,有任何不懂的都可以找 Cursor 使用 ask 模式搞定。
第五阶段:运营维护+推广
分了「优化」「安全」「推广」三个部分来说这个事情。
- 其实到这里是后续的常态,你不要不断的推广你的产品,去增加访问量。
- 另外就是不断的迭代优化你的功能,提升用户体验,加强本身产品的竞争力。
- 最后、最后、最后就是安全,这个不要忘记了,后面我也会加强后,然后去推广下我的产品,安全很重要,提前做好可以更保护你的服务器和大模型的 API-KEY。
分了「优化」「安全」「推广」三个部分来说这个事情。
- 其实到这里是后续的常态,你不要不断的推广你的产品,去增加访问量。
- 另外就是不断的迭代优化你的功能,提升用户体验,加强本身产品的竞争力。
- 最后、最后、最后就是安全,这个不要忘记了,后面我也会加强后,然后去推广下我的产品,安全很重要,提前做好可以更保护你的服务器和大模型的 API-KEY。
优化
这个是上线了后发现的,就是使用手机拍的照片,一般都比较大,这张图片请求后端的时候,数据量比较大,接口超时了。
那么解决办法:
1、增加后端的请求体的大小
2、压缩图片,然后再请求后端接口
这个是上线了后发现的,就是使用手机拍的照片,一般都比较大,这张图片请求后端的时候,数据量比较大,接口超时了。
那么解决办法:
1、增加后端的请求体的大小
2、压缩图片,然后再请求后端接口
安全
其实这里还是蛮重要的,因为你的服务,还有你的大模型的 KEY,如果服务器被攻击是要付出代价的,最重要的是花掉你的钱呀。
所以这块我还在做,目前想的就是让 Cursor 正题 revivew 代码,看下有什么安全隐患,给我一些解决方案。
其实这里还是蛮重要的,因为你的服务,还有你的大模型的 KEY,如果服务器被攻击是要付出代价的,最重要的是花掉你的钱呀。
所以这块我还在做,目前想的就是让 Cursor 正题 revivew 代码,看下有什么安全隐患,给我一些解决方案。
推广
如果你的产品上线后,需要写文章、发小红书去推广,首先从你的种子用户开始,你的微信群,你的朋友圈都是可以的。
后面积极听取用户心声,持续解决痛点需求,满足用户的痛点,产品就会越来越好。
如果你的产品上线后,需要写文章、发小红书去推广,首先从你的种子用户开始,你的微信群,你的朋友圈都是可以的。
后面积极听取用户心声,持续解决痛点需求,满足用户的痛点,产品就会越来越好。
第六阶段:成本计算
时间成本
从开始到结束上线,手机使用正式的域名访问,大概就是整一天的时间,从早上开始,忙到晚上我就开启了测试,晚上搞完还去外面遛弯了一大圈回来的。
我们就算:10 小时
从开始到结束上线,手机使用正式的域名访问,大概就是整一天的时间,从早上开始,忙到晚上我就开启了测试,晚上搞完还去外面遛弯了一大圈回来的。
我们就算:10 小时
人力成本
哈哈哈哈,很清楚,就我一个人
哈哈哈哈,很清楚,就我一个人
软件成本
bolt:20 元优惠包月(海鲜市场),就算正式渠道,20 刀一个月,当然有免费额度,调整不多,基本够用
Cursor:150教育优惠(海鲜市场),就算正式渠道,20 刀一个月,足足够用
域名:32首年
我们就算满的,折算成人民币,也就是 300 块。
想想 300 块一天你就做出来一个系统(前后端+部署),何况软件都是包月的,一个月你可以产出很多东西,不止这个一个系统。
对比公司开发,一个月的成本前后端两个人,毕业生也的上万了吧,何况还是 5 年经验开发的(市面上的抢手货)。
bolt:20 元优惠包月(海鲜市场),就算正式渠道,20 刀一个月,当然有免费额度,调整不多,基本够用
Cursor:150教育优惠(海鲜市场),就算正式渠道,20 刀一个月,足足够用
域名:32首年
我们就算满的,折算成人民币,也就是 300 块。
想想 300 块一天你就做出来一个系统(前后端+部署),何况软件都是包月的,一个月你可以产出很多东西,不止这个一个系统。
对比公司开发,一个月的成本前后端两个人,毕业生也的上万了吧,何况还是 5 年经验开发的(市面上的抢手货)。
总结
能走到这里的,我希望你给自己一个掌声,确实不容易。
我希望你也有可以通过编程来实现自己的想法和创意。
虽然目前编程对于零基础的人来说确实可能会有些吃劲,但是你我差距也不大,我现在遇到了很多在搞 AI 编程的都是程序员,有房地产行业的、也有产品的。
遇事不决,问 AI
我希望你可以记住这句话,自己的创意+基本问题找 AI,你基本就可以解决 99% 的问题,剩下的 1% 你基本遇不到,遇到了,也不要慌,身边这么多牛人总会有人知道。
作者:志辉AI编程
来源:juejin.cn/post/7517496354244067339
能走到这里的,我希望你给自己一个掌声,确实不容易。
我希望你也有可以通过编程来实现自己的想法和创意。
虽然目前编程对于零基础的人来说确实可能会有些吃劲,但是你我差距也不大,我现在遇到了很多在搞 AI 编程的都是程序员,有房地产行业的、也有产品的。
遇事不决,问 AI
我希望你可以记住这句话,自己的创意+基本问题找 AI,你基本就可以解决 99% 的问题,剩下的 1% 你基本遇不到,遇到了,也不要慌,身边这么多牛人总会有人知道。
来源:juejin.cn/post/7517496354244067339
看了下昨日泄露的苹果 App Store 源码……
新闻
昨日苹果 App Store 前端源码泄露,因其生产环境忘记关闭 Sourcemap,被用户下载了源码,上传到 Github。
目前已经 Fork 和 Star 超 5k:

如果你想要第一时间知道前端资讯,欢迎关注公众号:冴羽
昨日苹果 App Store 前端源码泄露,因其生产环境忘记关闭 Sourcemap,被用户下载了源码,上传到 Github。
目前已经 Fork 和 Star 超 5k:

如果你想要第一时间知道前端资讯,欢迎关注公众号:冴羽
用户如何抓取的源码?
用户 rxliuli 使用 Chrome 插件 Save All Resources 将代码下载了下来。
插件地址为:chromewebstore.google.com/detail/save…

下次你也可以打包下载源码了~
用户 rxliuli 使用 Chrome 插件 Save All Resources 将代码下载了下来。
插件地址为:chromewebstore.google.com/detail/save…

下次你也可以打包下载源码了~
如何看待源码泄露?
其实前端源码泄露对业务本身并没有什么影响,因为前端代码无论是否压缩还是混淆,最终都需传输到浏览器才能运行,本身就具有 “暴露” 属性,SourceMap 只是让代码更易读,更容易调试。
尽管如此,依然不建议在生产环境开启 SourceMap,对普通用户无益,且存在轻微性能开销和源代码暴露的安全风险。
我大致看了下代码,并没有什么密钥之类的信息,所以干点坏事之类的就不用想了。真正有价值的核心代码比如推荐逻辑还是在服务端。
其实前端源码泄露对业务本身并没有什么影响,因为前端代码无论是否压缩还是混淆,最终都需传输到浏览器才能运行,本身就具有 “暴露” 属性,SourceMap 只是让代码更易读,更容易调试。
尽管如此,依然不建议在生产环境开启 SourceMap,对普通用户无益,且存在轻微性能开销和源代码暴露的安全风险。
我大致看了下代码,并没有什么密钥之类的信息,所以干点坏事之类的就不用想了。真正有价值的核心代码比如推荐逻辑还是在服务端。
代码使用 Svelte?
我万万没想到,项目使用的是 Svelte。
Svelte 我自然是很熟的,毕竟我翻译过 Svelte 官网:svelte.yayujs.com/

还写了一本掘金小册《Svelte 开发指南》:s.juejin.cn/ds/QNzfZ4eq…

想一想,使用 Svelte 也在情理之中。
因为 Svelte 就非常适合处理这种页面相对简单、业务逻辑并不复杂的页面。
在实现上 ,与其说 Svelte 是框架,不如说 Svelte 是一个编译器。 它会在构建时就会将代码编译为高效的 JavaScript 代码,因此能够实现高性能的 Web 应用。
Svelte 的核心优势在于:
- 轻量级:核心库只有 3 KB,非常适合开发轻量级项目
- 高性能:构建时优化,而且不使用虚拟 DOM,减少了内存占用和开销,性能更高
- 易上手:学习曲线小,入门门槛低,语法简洁易懂
简而言之,Svelte 非常适合构建轻量级 Web 项目,也是本人做个人项目的首选技术栈。
以后大家如果要做相对简单的项目,又有性能上的追求(比如 KPI),那就可以考虑使用 Svelte。
我万万没想到,项目使用的是 Svelte。
Svelte 我自然是很熟的,毕竟我翻译过 Svelte 官网:svelte.yayujs.com/

还写了一本掘金小册《Svelte 开发指南》:s.juejin.cn/ds/QNzfZ4eq…

想一想,使用 Svelte 也在情理之中。
因为 Svelte 就非常适合处理这种页面相对简单、业务逻辑并不复杂的页面。
在实现上 ,与其说 Svelte 是框架,不如说 Svelte 是一个编译器。 它会在构建时就会将代码编译为高效的 JavaScript 代码,因此能够实现高性能的 Web 应用。
Svelte 的核心优势在于:
- 轻量级:核心库只有 3 KB,非常适合开发轻量级项目
- 高性能:构建时优化,而且不使用虚拟 DOM,减少了内存占用和开销,性能更高
- 易上手:学习曲线小,入门门槛低,语法简洁易懂
简而言之,Svelte 非常适合构建轻量级 Web 项目,也是本人做个人项目的首选技术栈。
以后大家如果要做相对简单的项目,又有性能上的追求(比如 KPI),那就可以考虑使用 Svelte。
用它作为示例学 Svelte ?
我看了下代码,项目代码还是 Svelte 4,而 Svelte 已经到 5 了,Svelte 4 和 5 不论是底层架构还是基础语法都发生了很大的变化,其变化的剧烈程度类似于 Next.js 12 升 Next.js 13,所以想通过这个项目学习 Svelte 就不用想了,都是些过时的语法了,不如直接学 Svelte 5。
作者:冴羽
来源:juejin.cn/post/7569057572436607014
我看了下代码,项目代码还是 Svelte 4,而 Svelte 已经到 5 了,Svelte 4 和 5 不论是底层架构还是基础语法都发生了很大的变化,其变化的剧烈程度类似于 Next.js 12 升 Next.js 13,所以想通过这个项目学习 Svelte 就不用想了,都是些过时的语法了,不如直接学 Svelte 5。
来源:juejin.cn/post/7569057572436607014
当你的Ant-Design成了你最大的技术债

大家好😁
如果你是一个前端,尤其是在B端(中后台)领域,Ant Design(antd)这个名字,你不可能没听过。
在过去的5年里,我们团队的所有新项目,技术选型里的第一行,永远是antd。它专业、开箱即用、文档齐全,拥有一切你想要的组件, 帮我们这些小团队,一夜之间就拥有了大厂的专业门面。
我们靠它,快速地交付了一个又一个项目。
但是,从去年开始,我发现,这个曾经的经典,正在变成我们团队脖子上最重的枷锁。
Ant Design,这个我们当初用来解决技术债的核心组件库,现在,却成了我们最大的技术债本身😖。
这是一篇团队血泪史, 讲一讲感想🤷♂️。
我们为什么会爱上 AntD?
我们必须承认,从无到有阶段,antd是无敌的。
你一个3人的小团队,用上antd,做出来的东西,看起来和阿里几百人团队做的系统,没什么区别。
Table、Form、Modal、Menu... 你需要的一切,它都以一种极其标准的方式给你了。你不再需要自己造轮子。
当你发现@ant-design/pro-components时,一个ProTable,直接帮你搞定了请求、分页、查询表单、工具栏... 你甚至都不用写useState了。
在那个阶段,我们以为我们找到了大结局。
当个性化成为 我们的 KPI
美好可能是短暂的,从我们的产品经理和UI设计师开始👇:
能不能...不要长得这么 Ant Design?🤣

这是我们设计师,在评审会上,小心翼翼提出来的第一句话。
老板也说:我们要做自己的品牌,现在的系统,太千篇一律了!!!
于是,我们接到了第一个简单的需求:把全局的主题色,从橙色改成我们的品牌红。
这很简单,不就是 ConfigProvider嘛🤔。我们改了。
然后,第二个需求来了:这个Modal弹窗的关闭按钮,能不能不要放在右上角?我们要放在左下角,和确认按钮放在一起。(有点反人类🤷♂️)
灾难,就从这里开始了。
antd的Modal组件,根本就没提供这个插槽或prop。我们唯一的办法,是 强改。
于是,我们的代码里,开始出现这种恶臭的CSS:
/* 一个高权重的全局CSS文件 */
.ant-modal-header {
/* ... */
}
/* 嘿,那个右上角的关闭按钮,给我藏起来! */
.ant-modal-close-x {
display: none !important;
}
为了把那个 X 藏起来,我们用了!important。我们亲手打开了潘多拉魔盒。
这个表格的筛选图标,能换成我们自己画的吗?😖
antd的Table,是一个重灾区。它太强大了,也很黑盒。
我们设计师,重新画了一套筛选、排序的图标。但我们发现,antd的Table组件,根本没想过让你换这个。
我们唯一的办法,就是用 CSS选择器,一层一层地穿进antd的DOM结构里,找到那个,然后用background-image去盖掉它。
/* 另一个人写的,更恶臭的CSS */
.ant-table-thead > tr > th.ant-table-column-has-filters .ant-table-filter-trigger {
/* 妈呀,这是啥? */
background: url('our-own-icon.svg') !important;
}
.ant-table-thead > tr > th.ant-table-column-has-filters .ant-table-filter-trigger > svg {
/* 藏起来,藏起来! */
display: none !important;
}
我们被拖累了。
我们花在 覆盖antd默认样式上的时间,已经远远超过了我们自己写一个组件的时间。
压死骆驼的最后一根稻草

我们用了ProTable,它的查询表单和表格是强耦合的。当产品经理提出一个我希望查询表单,在页面滚动时,吸附在顶部的需求时... 我们发现,我们改不动。我们被ProComponents的黑盒,锁死了。
然后我们的vendor.js打包出来,2.5MB。用webpack-bundle-analyzer一看,antd和@ant-design/icons,占了1.2MB。我们为了一个Button和Icon,引入了一个全家桶。antd的按需加载?别闹了,在ProComponents面前,它几乎是全量的。
而且 antd从v3到v4,我们花了一个月。从v4到v5,我们花了半个月。每一次升级,都是一次大型重构,因为我们那些写法一样被CSS覆盖,在新版里,全失效了🤷♂️。
我们本想找一个可靠的组件库,这么久过来,结果它成了债主。
我们真正需要的可能是轮子
我终于想明白了。
Ant Design,它不是一个组件库(Library),它是一个UI框架(Framework)。它是一套解决方案,它有它自己强势的 设计价值观。
当你的需求,和它的价值观一致时,它就是圣经。 当你的需求,和它的价值观不一致时,它就变成枷锁。
我们当初要的,其实是一个带样式的Button;而antd给我的,是一个内置了loading、disabled、onClick时会有水波纹动画、并且必须是蓝色或白色的Button。
我们的自救之路
在我们新的项目中,我忍痛做出了一个决定🤷♂️:
原则上,不再使用antd。
我们新的技术栈,转向了: Tailwind CSS + Headless UI 方案(比如Radix UI)

这个组合,才是我们想要的:
Headless UI:它只提供功能和无障碍。比如,一个Dialog(模态框),它帮我搞定了按Esc关闭、焦点管理。但它没有任何样式。Tailwind CSS:我拿到了这个无样式的Dialog,然后用Tailwind的class,在5分钟内,在AI的帮助下,把它拼成了我们设计师想要的、独一无二的弹窗。
我们拿回了CSS的完全控制权,同时又享受了 AI + 组件开发的便利。
我依然尊敬Ant Design,它在前端B端历史上,是个丰碑。 对于那些从0到1的、对UI没有要求的内部系统,我可能依然会用它。
但对于那些需要品牌、体验、个性化的核心产品,我必须和它说再见了。

因为,当你的组件库开始控制你的设计和性能时,它就不是你的资产了。
而变成你最大的技术债🙌。
来源:juejin.cn/post/7571176484515659828
我是如何将手动的日报完全自动化的☺️☺️☺️
书接上回,上回我们聊了处理重复任务的自动化思维。
其中,我举了用工具自动化公司日报的例子。
今天,我就来详细说说,我到底是怎么做的,以及过程中遇到了哪些问题和挑战。
背景
我们公司使用某第三方系统有一个自定义的数据看板,每天需要向群里发送日报。之前,这项工作由团队成员轮流手动完成:从系统的一个自定义看板复制数据到 Excel,再将表格转为图片,发到群里。
轮到我负责的那一周,我左手边电脑打开系统,右手边打开 Excel,一个个数据复制过去,3.4%、-10%……为避免出错,还要逐一核对。整个过程每天耗时大约 7 到 10 分钟,繁琐又枯燥。
我开始思考:这种重复性工作能不能自动化?
于是,我在群里向大佬们请教,提出了这个问题:

结果,消息已读,没有一个人回复。
那一刻,我暗下决心:我要自己解决这个问题!
初探
于是乎我打开了改系统,开始研究。
该系统大概长这样, 这是一个自定义看板,后台自定义配置出来的,数据是根据配置的规则算出来的,有十几项,我们是需要从每项取3个数据。加起来复制30-40次。

- 手动复制效率低下。
- 浪费时间。
- 容易出错,粘错位置了,又得一个个重新对一遍。
所以我第一步是需要把手动复制拿数据的这个过程,利用脚本自动化了。
流程与任务拆解
我们的思路是这样,先脑子里过一下原来的流程,然后一步步自动化原来的流程。
1、原来手动的流程
- 手动登录系统
- 点击对应面板,一个个复制数据,粘贴到excel里。
- 全部复制完,核对完,右键复制为图片
- 发送到群里。
2、脚本任务拆解
- js逆向登录加密方法,自动化登录,拿到token。
- 利用爬虫抓取数据,拿到我需要的。
- 利用canvas将数据画成表格,然后转成图片。
- 图片传到oss,调用钉钉webhook接口,定时发送到群里
以上我们已经将,手动的流程的任务与自动化需要做的任务一一对应了。
现在我们思路清晰了。
然后我们要做的就是把每个任务逐个攻克即可。
任务分步实现
你不觉得我应该先完成第一个任务——JS 逆向登录加密方法,实现自动化登录并获取 token 吗?
这确实是全自动流程中最核心的一环:没有自动登录获取凭证,后续的数据抓取和操作根本无从谈起。
不过,我初步分析了登录接口,发现参数加密逻辑较复杂,短时间内难以破解。
于是我选择暂时跳过,先手动复制登录凭证,确保后续流程全部打通后再回过头补全自动化登录部分。
1、利用爬虫抓取数据。
首先看板这是个列表,有很多项内容,首先看这个列表怎么来的,服务端渲染还是,调的接口。
然后看能不能完全从页面拿到,我们再考虑抓取方式。
1、如果是服务端渲染的或者数据很快出来的。我们可以考虑抓页面。
2、但是今天这个例子,经过我的研究,我需要的数据,都是异步调接口的,我看还有队列排队逻辑。 说明页面完整展现的时间不稳定,长则几十秒都有可能, 所以我感觉抓页面是不稳定的。
所以我选择抓接口。
1.1内容搜索大法
众多的接口啊,我们怎么找到我要的数据在哪???于是我们利用调试工具,搜索响应内容关键字
例如搜页面中显示的这个标题

通过内容再network搜索内容 找到了列表接口

点开看,确实,里边就是这个列表的数据。
但是没有具体是环比、同比,我要的数字。
再次寻找每一项具体数据的获取接口。
再次通过搜索大法找了好久好久.....
找到了通过每项id和过滤条件去获取具体数据的接口
1.2 接口找齐,开始编码
研究下来。整体逻辑是,先获取面板列表,然后循环列表的每一项,拿着有关联的参数去调详情。
面板的数据列表获取
/**
* 获取重点功能监控面板列表及详情数据
* @returns {Promise<*[]>}
*/
async function queryReportList(dashboard) {
const { id: dashboard_id, common_event_filter } = dashboard
const data = await fetch(
`https://xxx/api/v2/sa/dashboards/${dashboard_id}?is_visit_record=true`,
{
credentials: "include",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
Cookie: Cookie
},
referrer:
`https://xxx/dashboard/?dash_type=lego&id=${dashboard_id}&project=1&product=sensors_analysis`,
method: "GET",
mode: "cors"
}
)
.then(res => res.json())
const result = [];
// 获取控面板的前12个监控项的监控数据。
for (const item of data.items.slice(0, 13)) {
if (item.bookmark) {
// 这里解出来, 调下一个接口要用到。
const data = JSON.parse(item.bookmark.data);
const res = await queryReportByTool({
bookmarkid: item.bookmark.id,
measures: data.measures,
dashboard_id: dashboard_id,
common_event_filter: common_event_filter
});
result.push({
...res,
name: item.bookmark.name
});
console.log(
{
name: item.bookmark.name,
base_number: res.base_number /= 100,
day: res.month_on_month /= 100,
week: res.year_on_year /= 100
}
)
}
}
return result
}
获取每一项具体数据
/**
* 报告列表的报告id去获取具体数据
* @param params
* @returns {Promise }
*/
async function queryReportByTool(params) {
const requestId = Date.now() + ":803371";
const body = {
measures: params.measures,
unit: "day",
by_fields: [],
sampling_factor: null,
from_date: dayjs()
.subtract(14, "day")
.format("YYYY-MM-DD"),
// from_date: "2025-02-28",
to_date: getYesterDay(),
// to_date: "2025-03-13",
detail_and_rollup: true,
enable_detail_follow_rollup_by_values_rank: true,
...
};
try {
const data = await fetch(
`https://xxxx/api/events/compare/report/?bookmarkId=${
params.bookmarkid
}&async=true&timeout=10&request_id=${requestId}`,
{
credentials: "include",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
...
Cookie
},
referrer:
"https://xxxx/dashboard/?dash_type=lego&id=692&project=1&product=sensors_analysis",
body: JSON.stringify(body),
method: "POST",
mode: "cors",
timeout: 10000
}
).then(res => res.json());
if (!data || data.isDone === false) {
return await queryReportByTool(params);
} else {
return data;
}
} catch (e) {
return await queryReportByTool(params);
}
}
1.3 数据拿到
执行一下,数据拿到了,找到了我要的几个字段
PS D:\project2\report> node .\index.js
{
name: 'xxx生成失败率',
base_number: 0.0103,
day: -0.3602,
week: -0.16260000000000002
}
...
{
name: 'xxxx生成失败率',
base_number: 0.017,
day: 0,
week: 0.0241
}
2025-03-18.xlsx文件已保存!
default: 27.917s
1.4 小结
表面看似一帆风顺,因为我是以回忆的视角,实则历经坎坷。目标网站的接口之间关系、参数间的关联,皆需细细揣摩、深入研究。
2、生成图片
node-cavas 生成图片。细节我就不讲了,数据都拿到了,用数据生成一张图片那就看你怎么解决了。
3、图片传到oss,调用钉钉webhook接口,定时发送到群里
传图
我是传到了腾讯云cos
const filePath = `/custom/999/${dashboard.worksheetName}-${dayjs().format('YYYYMMDD')}.jpeg`
const uploadRes = await tencentCos.upload(imageBuffer, filePath, true)
发钉钉群
查看钉钉群机器人api文档,以md格式发送图片链接。
async function sendDingTalkMessage(text) {
// const today = dayjs()
// .format("YYYY-MM-DD")
const token = '1a6e1111111' // 大群机器人
const result = await fetch(`https://oapi.dingtalk.com/robot/send?access_token=${token}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"msgtype": "markdown",
"markdown": {
"title": "监控日报",
// "text": `#### ${title}-${today} \n >  \n`
"text": text
},
"at": {
"isAtAll": true
}
})
}).then(res => res.json())
console.log(result)
if (result.errcode === 0) {
console.log('发送成功')
return true
}
}
到这里可以当做一个脚本每天手动执行一下。 但是还没完全自动化,还差一步
4、js逆向登录加密方法,自动化登录。
就剩下自动登录了。
4.1 为什么要自动化登录?
因为这个系统登录凭证在一定时间内会过期,且不是明文登录的,登录接口参数加密了的。
看到这你就得去研究他的加密规则了
或者止步于此,手动复制登录凭证,本地执行脚本也是可以。
我如果要在服务器上自动化整个流程,必须得让他自动登录拿到登录凭证。
4.2 逆向步骤
4.2.1找到登录接口
先点页面的登录,找到登录接口,在请求调用栈中随便找个位置先打个断点,然后刷新页面,再次点击登录,嘿,您猜怎么着,断住了!!!

4.2.2 顺着调用栈找逻辑
顺着调栈给上找逻辑。所有在前端加密的一定是可以模拟的。
找到了调登录的方法。 看了下这就是调store里的方法passport/login。 
从这再往下就比较不容易了,因为你会发现,就有点乱了。进到的都是混淆的一些abcdefg名字的方法。

但是咱们明确目标就是要找到调用的方法passport/login的位置。
我尝试了如下
- 搜索passport/login关键字
- 搜索接口路径
找了一辈子, 终于找到了调接口的地方
看到这个Me方法,传递了一个 isEncrypted 我猜测就是 是否要加密参数的意思吧。 
别搁Me方法外面蹭了行不行???,赶紧进去看看。
你就给我看这个? 这里面又调了另一个

咱们接着进到xt.request。
您猜怎么着,还没到,这里又进行了一顿操作之后,调了一个名为P的方法。

好好好,继续继续。
4.2.3 找到了加密的位置
到了P方法,终于是没给我玩套娃了啊。
在这一步终于是看到了关键字 isEncrypted。
看了代码确实是判断isEncrypted加密的。

看了后发现这是一个RSA+AES结合的加密方式。
RAS加密密钥, AES加密登录数据。
加密流程总结:
- RSA保护AES密钥的安全传输
- AES保护实际登录数据的机密性
- 双重加密确保登录信息在传输过程中的安全性
为何不用单一的加密方式?
那么你有没有这样的疑问呢?为什么不单独用rsa直接加密数据呢?岂不简单。
当然不行,是有原因的!
RSA长度限制 RSA加密算法对明文长度有严格限制,具体取决于密钥长度和填充方式。以下是不同密钥长度下的最大明文长度(以字节为单位):
- 1024位密钥:最大明文长度约为 117字节
- 2048位密钥:最大明文长度约为 245字节
- 4096位密钥:最大明文长度约为 512字节
所以RSA加密超出长度的会报错的。
所以先生成短密钥,再使用RSA加密AES对称算法的密钥,再用对称密钥加密实际数据。这是实际应用中的常见做法,兼顾安全性和效率
4.2.4 模拟他的加密过程
大致流程
- 把加密逻辑copy啊。
- 补环境。
- 不断尝试直到通过后端校验。
理解后端如何解密和校验
前面我们说到
RAS加密密钥, AES加密登录数据。
那么后端的校验流程就是:
- 私钥解出密钥
- 密钥配和iv、salt等再解出被AES加密的账号密码信息.
知道了这些,那么我们需要做的就是正确加密和传递相关信息,如果校验失败,我们就要来回对比差异,找到问题,不断尝试。
在不断尝试下我成功了。
遇到的问题
- 加密的包的版本跟目标网站用的不一样导致校验失败,后经过漫长的查找找到了一样的版本。
- 还要注意header里带的字段,都要模拟他加密后的带过去。例如这几个。
- salt
- iv等

最终我抽出来的登录加密方法
var b = require("crypto-js");
var jsencrypt = require("nodejs-jsencrypt/bin/jsencrypt").default;
/**
* js逆向回来的方法,模拟xx登录对参数加密
* @param body xx登录参数
* @param public 公钥
* @returns {{headers: {"aes-salt": string, "aes-iv": string, "aes-passphrase": *, "X-Request-Timestamp": string, "X-Request-Id": string, "X-Request-Sign": *}, body: string}}
*/
function encryptLogin(body, public) {
const W = new jsencrypt();
W.setPublicKey(public)
q = b.enc.Utf8.parse(Math.floor(Math.random() * 1e6) + Date.now()).toString();
// q = "31373432353237383135363835";
var re = W.encrypt(q)
, ie = b.lib.WordArray.random(128 / 8)
, fe = b.lib.WordArray.random(128 / 8)
, ue = b.PBKDF2(q, ie, {
keySize: 128 / 32,
iterations: 100
})
, ye = b.AES.encrypt(JSON.stringify(body), ue, {
iv: fe,
mode: b.mode.CBC,
padding: b.pad.Pkcs7
});
const j = "/api/v2/auth/login?is_global=true"
const Ee = parseInt(Date.now() / 1000).toString()
const he = Ee
const Fe = ye.toString()
var bt = "".concat(Ee, "_").concat(he, "_").concat(j, "_").concat(Fe, "_14skjh");
const res = {
headers: {
"aes-salt": ie.toString(),
"aes-iv": fe.toString(),
"aes-passphrase": re,
"X-Request-Timestamp": Ee,
"X-Request-Sign": b.MD5(bt).toString(),
"X-Request-Id": he,
},
body: ye.toString()
}
return res
}
使用登录方法
登录之后存下来cookie供获取数据的接口使用
async function login(public, loginData) {
// 加密登录信息
const encryptOptions = encryptLogin({
...loginData
}, public)
return await fetch("xxx", {
"headers": {
...encryptOptions.headers
},
"method": "POST",
credentials: "include",
body: encryptOptions.body
}).then(res => {
Cookie = res.headers.get('set-cookie')
return res.json()
})
}
任务分步都实现了(自动化了)。
串联起来这四步,整体就实现了。
随后部署到服务器,配置定时任务每天执行。
效果展示

总结
- 先通后补:登录逆向卡壳,先手动Cookie跑通全链,再回填自动化。
- 逆向不怕乱:混淆代码里断点+全局搜索(接口路径/关键字),总能定位加密点。
- 加密常RSA+AES:RSA只加密短密钥,AES加密长数据,补环境+对齐Header字段是关键。
- 贵在坚持:第一天研究无果别灰心,第二天重新上手,灵感与进展常不期而至。
虽然文章写得像一帆风顺,但实则磕磕绊绊——在层层混淆的代码里翻找,第一天方法不对,左冲右突脑壳嗡嗡作响。幸好第二天没放弃,沉下心继续深挖,一步步试错、迭代,终于攻克所有难题。
如果有小伙伴有任何问题或者想跟我探讨细节,欢迎联系!
喜欢的话,点点关注。
来源:juejin.cn/post/7566913899175051299
为何前端圈现在不关注源码了?
大家好,我是双越。前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP。我的代表作有:
- wangEditor 开源 web 富文本编辑器,GitHub 18k star,npm 周下载量 20k
- 划水AI Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
- 前端面试派 系统专业的面试导航,刷题,写简历,看面试技巧,内推工作。开源免费。
开始
大家有没有发现一个现象:最近 1-2 年,前端圈不再关注源码了。
最近 Vue3.6 即将发布,alien-signal 不再依赖 Proxy 可更细粒度的实现响应式,vapor-model 可以不用 vdom 。
Vue 如此大的内部实现的改动,我没发现多少人研究它的源码,我日常关注的那些博客、公众号也没有发布源码相关的内容。
这要是在 3 年之前,早就开始有人研究这方面的源码了,博客一篇接一篇,跟前段时间的 MCP 话题一样。
还有前端工具链几乎快让 Rust 重构一遍了,rolldown turbopack 等产品使得构建效率大大提升。这要是按照 3 年之前对 webpack 那个研究态度,你不会 rust 就不好意思说自己是前端了。
不光是这些新东西,就是传统的 Vue React 等框架源码现在也没啥热度了,我关注每日的热门博客,几乎很少有关于源码的文章了。
这是为什么呢?
泡沫
看源码,其实是一种泡沫,现在破灭了。所谓泡沫,就是它的真实价值之前一直被夸大,就像房地产泡沫。
前几年是互联网发展的红利期,到处招聘开发人员,大家都拿着高工资,随便跳槽就能涨薪 20% ,大家就会误以为真的是自己的能力值这么多钱。
而且,当年面试时,尤其是大公司,为了筛选出优秀的候选人(因为培训涌入的人实在太多),除了看学历以外,最喜欢考的就是算法和源码。
确实,如果一个技术人员能把算法和源码看明白,那他肯定算是一个合格的程序员,上限不好说,但下限是能保证的。就像一个人名牌大学毕业的,他的能力下限应该是没问题的。
大公司如此面试,其他公司也就跟风,面试题在网络上传播,各位程序员也就跟风学习,很快普及到整个社区。
所以,如果不经思考,表面看来:就是因为我会算法、会源码,有这些技能,才拿到一个月几万甚至年薪百万的工资。
即,源码和算法价值百万。
现状
现在泡沫破灭了。业务没有增长了,之前是红利期,现在是内卷期,之前大量招聘,现在大量裁员。
你看这段时间淘宝和美团掐架多严重,你补贴我补贴,你广告我也广告。如果有新业务增长,他们早就忙着去开疆拓土了,没公司在这掐架。
面试少了,算法和源码也就没有发挥空间了。关键是大家现在才发现:原来自己会算法会源码,也会被裁员,也拿不到高工资了。
哦,原来之前自己的价值并不是算法和源码决定的,最主要是因为市场需求决定的。哪怕我现在看再多的源码,也少有面试机会,那还看个锤子!
现在企业预算缩减,对于开发人员的要求更加返璞归真:降低工资,甚至大量使用外包人员代替。
所以开发人员的价值,就是开发一些增删改查的日常 web 或 app 的功能,什么算法和框架源码,真实的使用场景太少。
看源码有用吗?
答案当然是肯定的。学习源码对于提升个人技术能力是至关重要的,尤其是对于初学者,学习前辈经验是个捷径。
但我觉得看 Vue react 这些源码对于开发提升并不会很直接,它也许会潜移默化的提升你的“内功”,但无法直接体现在工作上,除非你的工作就是开发 Vue react 类的框架。
我更建议大家去看一些应用类的源码,例如 UI 组件库的源码看如何封装复杂组件,例如 vue-admin 看如何封装一个 B 端管理后台。
再例如我之前学习 AI Agent 开发,就看了 langChain 提供的 agent-chat-ui 和 Vercel 提供的 ai-chatbot 这两个项目的源码,我并没有直接看 langChain 的源码。
找一些和你实际开发工作相关的一些优秀开源项目,学习他们的设计,阅读他们的源码,这是最直接有效的。
最后
前端人员想学习全栈 + AI 项目和源码,可关注我开发的 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
来源:juejin.cn/post/7531888067218800640
我为什么说全栈正在杀死前端?
大家好,我又来了🤣。
打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。

全栈 👉 成了我们前端工程师内卷的一种方式。仿佛你一个干前端的,要是不懂点BFF、不会配Nginx、不聊聊K8s,你都不好意思跟人说你是资深。
我们都在拼命地,去学Nest.js、学数据库、学运维。我们看起来,变得越来越全能了。
但今天,我想泼一盆冷水🤔:
全栈正在杀死前端。
全栈到底是什么
我们先要搞清楚,现在公司老板们想要的全栈,到底是什么?

他们想要的,不是一个T型人才(在一个领域是专家,同时懂其他领域)。
他们想要的是:一个能干两个人(前端+后端)的活,但只需要付1.5个人的工资。
但一个人的精力,毕竟是有限的。
- 当我花了3个月,去死磕K8s的部署和Nest.js的依赖注入时,我必然没有时间,去研究新出炉的
INP性能指标该如何优化。 - 当我花了半周时间,去设计数据库表结构和BFF接口时,我必然没有精力,去打磨那个React组件的可访问性,无障碍(a11y)和动画细节。
我们引以为傲的前端精神,正在被全栈的广度要求,稀释得一干二净。
全栈的趋势,正在逼迫我们,从一个能拿90分的前端专家,变成一个前后端都是及格的功能实现者。
关于前端体验
做全栈的后果,最终由谁来买单?
是用户。
我们来看看全栈前端主导下,最容易出现的受灾现场:
1.能用就行的交互
全栈思维,是功能驱动的。
数据能从数据库里查出来,通过API发到前端,再用v-for渲染出来,好了,这个功能完成了😁。
至于:
- 列表的虚拟滚动做了吗?
- 图片的懒加载做了吗?
- 按钮的
loading和disabled状态,在API请求时加了吗? - 页面切换的骨架屏做了吗?
- 弱网环境下的超时和重试逻辑写了吗?
- UI测试呢?
抱歉,没时间。我还要去写BFF层的单元测试。
2.无障碍,可访问性(a11y)
你猜一个全栈,在用
来源:juejin.cn/post/7573172586839834676
如何知道同事的工资?
薪资保密,是所有互联网公司的红线之一。作为牛马,虽然能理解制度的初衷,但肯定忍不住想知道身边同事的工资。
直接问当然不行,我提供几个思路:
- 调虎离山,乘 TA 不在,在 TA 电脑上登录 OA/ERP,直接看工资
- 与 TA 搞对象或者搞基,知道工资后,马上分手,继续下一位,直到遍历完所有同事
- 拼命地卷自己,不断升职,成为他们的 +1 或 +2(腾讯是 +2 才有薪酬权)
虽然有可行性,但是作为码农,这些方案都太 low 了,没有丝毫技术含量。
下面,且看老夫的表演:

2016年底,花 100 块买了台摩托罗拉 C118,长这样:

百度百科的介绍如下:
Moto C118是Moto品牌于2006年2月推出的一款直板式基础功能手机
配备 96×64 单色显示屏,支持GSM 900/1800MHz双频网络
没错,我在 2016 年底买了一台 2006 年上市的功能机。很无聊的机器,哪怕在 06 年,我都没正眼瞧过它。
但,这跟工资有啥关系?
一般来说,工资入账时,会有银行的短信提醒。如果能截获短信,不就知道其他人的工资了吗?
经过硬件修改后的 C118,刷入特殊的固件,连接到Linux机器,启动脚本扫描频段,用WireShark抓包,就能收到其他人的短信了:

大概原理是:
GSM 短信未加密,而基站发送短信是广播的。
也就是说,任意的手机(不需要插入 SIM 卡)都能收到连接到同一基站的其他手机的短信,只不过会丢弃不属于自己的短信
特殊的固件就是让手机来者不拒,不要丢弃其他人的短信。具体细节,网上一大堆,请自行搜索OsmocomBB。
我完全不懂硬件,直接买的改好后的 C118,卖家顺便改了 Micro-USB 供电,不需要电池了。我觉得挺漂亮的,就是有点像定时炸弹:

机器到手后,照葫芦画瓢,跑了半天,真抓到了一些推广短信。卧槽,牛逼!
等到发工资那天,提前把脚本跑起来,附近同事的工资,尽收眼底。完美!؏؏☝ᖗ乛◡乛ᖘ☝؏؏
好吧,这只是一个邪恶的想法,从未付诸实施。说起来你可能不信,每次都是收到工资入账短信后,我才想起来脚本没跑。久而久之,就忘了这件事了。
话说回来,即使当初真干了,也是徒劳。因为这个只能抓移动和联通的 GSM 短信,对电信的 CDMA 无效。而且,当时 4G 已经全面铺开,根本抓不到。即使有同事用 GSM 老人机,也因为原理的限制,看不到收件人手机号,并不知道是谁的工资。
最后,说一件趣事:
几年前,某司价值观中的「创新」要改成「创造」,在内网征集最能代表「创造」的动物,点赞数最多的是它:

提议者的配文:
蝙蝠,昼伏夜出,象征着创造力
最重要的是,它擅长倒挂
彼时,网上一堆晒 SP、SSP 的 offer 的校招生,薪资遥遥领先工作多年的老员工。当然,蝙蝠最终还是以最高得票遗憾落选了,虽败犹荣。
类似的倒挂,在我身上也发生过,果断选择了跑路。然后,薪资翻了数倍,敏感内容,略。
来源:juejin.cn/post/7550151424333053992
女朋友被链接折磨疯了,我写了个工具一键解救
有一天女朋友跟我抱怨:工作里被各种链接折腾得头大,飞书和浏览器之间来回切窗口,一会忘了看哪个,心情都被搅乱了。我回头一想——我也一样,办公室每个人都被链接淹没。
“
同事丢来的需求文档、群里转的会议记录、GitLab 的 MR 链接、还有那些永远刷不完的通知——每点一个链接就得在聊天工具和浏览器之间跳转,回来后一秒钟就忘了"本来要点哪个、看哪个"。更别提那些收集了一堆好文章想集中看,或者别人发来一串链接让你"挑哪个好"的时候,光是打开就要折腾半天。
"
这不是注意力不集中,是工具没有帮你省掉这些无意义的切换。
"
于是我做了一个极简 Chrome 插件: Open‑All 。它只做一件事——把你所有网址一次性在新窗口打开。你复制粘贴一次,它把链接都整齐地摆在新标签页里,你只要从左到右按顺序看就行。简单、直接,让你把注意力放在真正重要的事情上
先看效果:一键打开多个链接

这些痛点你肯定也遇到过
每天都在经历的折磨
- 浏览器和飞书、企微、钉钉来回切应用 :复制链接、粘贴、点开、切回来,这套动作做一遍就够烦的了
- 容易忘事 :打开到第几个链接了?这个看过没?脑子根本记不住
- 启动成本高 :一想到链接要一个个点开,就懒得开始了
- 没法对比 :想要横向比较几个方案,但打开方案链接都费劲
具体什么时候最痛苦
- 收集的文章想一口气看完 :平时存了一堆好文章,周末想集中看,结果光打开就累了
- 别人让你帮忙选 :同事发来几个方案链接问你觉得哪个好,你得全部打开才能比较
- 代码 Review :GitLab 上好几个 MR 要看,还有相关的 Issue 和 CI 结果
- 开会前准备 :会议文档、背景资料、相关链接,都得提前打开看看
我的解决方案
设计思路很简单
- 就解决一个问题 :批量打开链接,不搞那些花里胡哨的功能
- 零学习成本 :会复制粘贴就会用
- 让你专注 :少折腾,多干活
能干什么
- 把一堆链接一次性在新窗口打开
- 自动保存你输入的内容,不怕误关
- 界面超简单,点两下就搞定
技术实现
项目结构
shiba-cursor
├── manifest.json # 扩展的"身-份-证"
├── popup.html # 弹窗样式
└── popup.js # 弹窗交互
文件说明:
- manifest.json:扩展身份信息
- popup.html:弹窗样式
- popup.js:弹窗交互
立即尝试
方法一: 从github仓库拉代码,本地安装
5分钟搞定安装:复制代码 → 创建文件 → 加载扩展 → 开始使用!
🚀 浏览项目的完整代码可以点击这里 github.com/Teernage/op…,如果对你有帮助欢迎Star。
方法二:直接从chrome扩展商店免费安装
Chrome扩展商店一键安装:open-all 批量打开URL chromewebstore.google.com/detail/%E6%…,如果对你有帮助欢迎好评。
动手实现
第一步:创建项目文件
创建文件夹
open-all创建manifest.json文件
{
"manifest_version": 3,
"name": "批量打开URL",
"version": "1.0",
"description": "输入多个URL,一键在新窗口中打开",
"permissions": [
"tabs",
"storage"
],
"action": {
"default_popup": "popup.html",
"default_title": "批量打开URL"
}
}
创建popup.html文件
html>
<html>
<head>
<meta charset="utf-8" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 320px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
sans-serif;
color: #333;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.title {
font-size: 18px;
font-weight: 600;
text-align: center;
margin-bottom: 16px;
color: #1d1d1f;
letter-spacing: -0.5px;
}
#urlInput {
width: 100%;
height: 140px;
padding: 12px;
border: 2px solid #e5e5e7;
border-radius: 12px;
font-size: 14px;
font-family: 'SF Mono', Monaco, monospace;
resize: none;
background: #fafafa;
transition: all 0.2s ease;
line-height: 1.4;
}
#urlInput:focus {
outline: none;
border-color: #007aff;
background: #fff;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
}
#urlInput::placeholder {
color: #8e8e93;
font-size: 13px;
}
.button-group {
display: flex;
gap: 8px;
margin-top: 16px;
}
button {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
#openBtn {
background: linear-gradient(135deg, #007aff 0%, #0051d5 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}
#openBtn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
}
#openBtn:active {
transform: translateY(0);
}
#clearBtn {
background: #f2f2f7;
color: #8e8e93;
border: 1px solid #e5e5e7;
}
#clearBtn:hover {
background: #e5e5ea;
color: #636366;
}
#status {
margin-top: 12px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
text-align: center;
display: none;
background: rgba(52, 199, 89, 0.1);
color: #30d158;
border: 1px solid rgba(52, 199, 89, 0.2);
}
.tip {
font-size: 11px;
color: #8e8e93;
text-align: center;
margin-top: 8px;
line-height: 1.3;
}
style>
head>
<body>
<div class="container">
<div class="title">批量打开 URLdiv>
<textarea
id="urlInput"
placeholder="输入 URL,每行一个:
https://www.apple.com
https://www.github.com
https://www.google.com"
>textarea>
<div class="button-group">
<button id="clearBtn">清空button>
<button id="openBtn">打开button>
div>
<div class="tip">输入会自动保存,打开后自动清空div>
<div id="status">div>
div>
<script src="popup.js">script>
body>
html>
创建popup.js文件
document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('urlInput');
const openBtn = document.getElementById('openBtn');
const clearBtn = document.getElementById('clearBtn');
const status = document.getElementById('status');
// 恢复上次保存的输入
chrome.storage.local.get(['savedUrls'], function(result) {
if (result.savedUrls) {
urlInput.value = result.savedUrls;
}
});
// 自动保存输入内容
urlInput.addEventListener('input', function() {
chrome.storage.local.set({savedUrls: urlInput.value});
});
// 清空按钮
clearBtn.addEventListener('click', function() {
urlInput.value = '';
chrome.storage.local.remove(['savedUrls']);
showStatus('已清空');
});
// 打开URL按钮
openBtn.addEventListener('click', function() {
const urls = getUrls(urlInput.value);
if (urls.length === 0) {
showStatus('请输入有效的URL');
return;
}
// 创建新窗口并打开所有URL
chrome.windows.create({url: urls[0]}, function(window) {
for (let i = 1; i < urls.length; i++) {
chrome.tabs.create({
windowId: window.id,
url: urls[i],
active: false
});
}
// 成功打开后清空输入并移除存储
urlInput.value = '';
chrome.storage.local.remove(['savedUrls']);
showStatus(`已打开 ${urls.length} 个URL`);
});
});
// 解析URL
function getUrls(input) {
return input.split('\n')
.map(line => line.trim())
.filter(line => line && (line.startsWith('http://') || line.startsWith('https://')));
}
// 显示状态
function showStatus(message) {
status.textContent = message;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 2000);
}
});
💡 深入理解脚本通信机制
虽然这个插件比较简单,只用到了 popup 和 storage API,但如果你想开发更复杂的插件(比如需要在网页中注入脚本、实现跨脚本通信),就必须理解 Chrome 插件的多脚本架构。
强烈推荐阅读:
👉 大部分人都错了!这才是 Chrome 插件多脚本通信的正确姿势
第二步:安装扩展

- 打开Chrome浏览器
- 地址栏输入:
chrome://extensions/ - 打开右上角"开发者模式"
- 点击"加载已解压的扩展程序"
- 选择刚才的文件夹,然后确定
- 固定扩展
- 点击扩展图标即可使用
最后想说的
这个插件功能很简单,但解决的是我们每天都会遇到的真实问题。它不会让你的工作效率翻倍,但能让你少一些无聊的重复操作,多一些专注的时间。
我和女朋友现在用着都挺爽的,希望也能帮到你。如果你也有类似的困扰,试试看吧,有什么想法也欢迎在评论区聊聊。
你最希望下个版本加什么功能?评论区告诉我!
如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下! 往期实战推荐:
来源:juejin.cn/post/7566677296801071155
逃离鸭科夫5人2周1个亿,我们可以做一个鸡科夫吗?
点击上方亿元程序员+关注和★星标

引言
哈喽大家好,不知道小伙伴们最近有没有关注到一个名叫《逃离鸭科夫》的游戏。
这款游戏在各大社交平台和游戏社区都成为了热门话题,Steam平台上的同时在线人数一度突破30万,其口碑表现也相当出色。
在累计一万七千多条玩家评价中,收获了96%的压倒性好评,整体评价明显优于今年发布的多数新作。
其中更为炸裂的信息,开发这款游戏的团队仅仅只有5个人。

除了有常规的3D美术和数值策划外,还有3个神人:
- 负责游戏设计、战斗编程以及基础美术的制作人。
- 负责游戏内大部分编程的游戏主策。
- 负责游戏内所有美术资产的校招生。
更想不到的是,如此精简的团队,打造出的游戏,仅仅2周时间,收获1个亿,远高于团队预期。
那么问题来了,鸭科夫如此成功,我们可以做一个“鸡科夫”吗?
这个问题还是交给小伙伴们吧,笔者实在是折腾不起,但是呢,我们可以定个小目标,做个简单的小东西起步。
言归正传,本期笔者介绍一下如何在Cocos游戏开发中,制作类似逃离鸭科夫中的激光瞄准器!
本文源码和源工程在文末获取,小伙伴们自行前往。
什么是激光瞄准器?

激光瞄准器是一种安装在武器(如枪支、弓箭等)上的装置,它发射出一束低功率的、可见或不可见的激光束,在被瞄准的物体上投射一个光点,提示使用者弹着点(即子弹预计会命中的位置)。
制作原理

在Cocos游戏开发中,激光瞄准器的制作方法有不少,包括但不限于以下几种:
- Line组件。
- 自定义Shader。
- 粒子系统。
本期我们主要演示Line组件的使用。
Line组件
Line组件用于渲染3D场景中给定的点连成的线段。
Line组件渲染的线段是有宽度的,并且总是面向摄像机,这与billboard组件相似。

Line组件的使用非常简单,我们重点关注他的:
positions: 每个线段端点的坐标。width: 线段宽度,如果采用曲线,则表示沿着线段方向上的曲线变化。color: 线段颜色,如果采用渐变色,则表示沿着线段方向上的颜色渐变。
激光瞄准器制作实例
下面跟随这笔者,一起在Cocos游戏开发中实现一个激光瞄准器。
1.资源准备
老生常谈,有美术搭子的找美术搭子,没有美术搭子的找AI搭子。
笔者拿出做例子最爱的小鸡,本期我们叫他“鸡科夫”。

2.Line组件
新建一个节点,绑定一个Line组件,设置一下线的宽度和颜色,坐标我们在代码中动态设置。

3.写代码
首先声明一个ChickenKF类并挂在Canvas上,绑定好Line组件、鸡科夫和摄像机。

然后监听一下鼠标事件,按下时显示激光,移动瞄准,抬起时关闭激光。

通过鼠标事件和射线检测,确定激光的方向和目标。

计算出来激光的起点和终点,对Line组件的positions进行赋值。

4.效果演示

结语
逃离鸭科夫之所以能够成为爆款,并非偶然,它源于对游戏的热爱、对玩家的重视以及对游戏的执着,因为他们“听人劝吃饱饭”。
假如我们完整复刻出来一个“鸡科夫”,它会如愿成为爆款吗?
评论区说出你的看法。
本文源工程可通过私信发送 ChickenKF 获取。
我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。
AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。
实不相瞒,想要个赞和爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!
推荐专栏:
点击下方灰色按钮+关注。
来源:juejin.cn/post/7569515660930220083
如何用Claude Code 生成顶级UI ❇️
前言
以往我的处理
以往生成UI我会怎么做呢?
- 跟 v0 结对chat,出一版原型,再基于原型样式去迭代
- 或者是使用 stritch 设计一个初版的UI,再进行迭代
- https://v0.app/
- https://stitch.withgoogle.com/
下方是其中一个产品,hi-offer 多次迭代后大致的UI 效果,看起来还可以,只是还没有到很靓丽的程度🫥


那可能有小伙伴会有同样的疑问:
* 我没有UI 设计经验呀,我要怎么快速实现 **靓丽程度** 的 UI 呢?
* 答案是~~抄~~,No,是模仿学习哈哈
给大家一个样例,MotherDucker 的首页。 给大家10秒钟,思索一下。如果你想复刻这种UI风格,用在自己的产品上,你会怎么做?


可能有下面的一些思考
- 截图 UI 给 cluade code 分析
- 截图丢给stitch + 对话迭代
众所周知,OCR 过程,出现很大UI 信息缺失,比如:具体配色数值、阴影、间距、字体等,于是你会发现,最终AI完成的效果可能都没有60% 。
于是核心思路是:解决样式信息大量丢失的问题,通过减少信息代差,让AI coding 完成的UI 风格有不错的效果
好消息是,最近实践了一个工作流,很好地解决了这个问题,随我来,我们只需要核心的五个步骤:

最终成品效果如下,全程vibe coding,详见:4-quadrant-to-do.vercel.app/
- 如果你感觉效果还不错,愉快开始本文之旅吧~~
- 如果你认为效果比较牵强,那么阅读本文之后,你一定可以迭代出更好的UI。



话不多说,我们开始发车 ~~
以往生成UI我会怎么做呢?
- 跟 v0 结对chat,出一版原型,再基于原型样式去迭代
- 或者是使用 stritch 设计一个初版的UI,再进行迭代
- https://v0.app/
- https://stitch.withgoogle.com/
下方是其中一个产品,hi-offer 多次迭代后大致的UI 效果,看起来还可以,只是还没有到很靓丽的程度🫥


那可能有小伙伴会有同样的疑问:
* 我没有UI 设计经验呀,我要怎么快速实现 **靓丽程度** 的 UI 呢?
* 答案是~~抄~~,No,是模仿学习哈哈
给大家一个样例,MotherDucker 的首页。 给大家10秒钟,思索一下。如果你想复刻这种UI风格,用在自己的产品上,你会怎么做?


可能有下面的一些思考
- 截图 UI 给 cluade code 分析
- 截图丢给stitch + 对话迭代
众所周知,OCR 过程,出现很大UI 信息缺失,比如:具体配色数值、阴影、间距、字体等,于是你会发现,最终AI完成的效果可能都没有60% 。
于是核心思路是:解决样式信息大量丢失的问题,通过减少信息代差,让AI coding 完成的UI 风格有不错的效果
好消息是,最近实践了一个工作流,很好地解决了这个问题,随我来,我们只需要核心的五个步骤:

最终成品效果如下,全程vibe coding,详见:4-quadrant-to-do.vercel.app/
- 如果你感觉效果还不错,愉快开始本文之旅吧~~
- 如果你认为效果比较牵强,那么阅读本文之后,你一定可以迭代出更好的UI。



话不多说,我们开始发车 ~~
步骤一:Copy样式上下文,生成初版的html
你需要提供下方的的上下文信息给CC,让他帮忙构建一个html 页面
- 参考的 web UI 截图【长图或多张全屏图】
- copy web 的 html css 样式信息
- prompt
你需要提供下方的的上下文信息给CC,让他帮忙构建一个html 页面
- 参考的 web UI 截图【长图或多张全屏图】
- copy web 的 html css 样式信息
- prompt
截图
prompt
Help me rebuild exact same ui design in signle html as xxx.html, above is extracted css:
Help me rebuild exact same ui design in signle html as xxx.html, above is extracted css:
style info
右键检查,选择html、body 元素,copy style 信息
* html css style
* body css style

例如:
-webkit-locale: "en";
scroll-margin-top: var(--eyebrow-desktop);
animation-duration: 0.0001s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
caret-color: transparent !important;
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
box-sizing: border-box;
border: 0px;
font-size: 100%;
vertical-align: baseline;
text-decoration: none;
scroll-padding-top: var(--header-desktop);
scroll-behavior: auto;
height: 100%;
margin: 0;
padding: 0;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-feature-settings: normal;
font-variation-settings: normal;
-webkit-tap-highlight-color: transparent;
--swiper-theme-color: #007aff;
--toastify-toast-min-height: fit-content;
--toastify-toast-width: fit-content;
--header-mobile: 70px;
--header-desktop: 90px;
--eyebrow-mobile: 70px;
--eyebrow-desktop: 55px;
右键检查,选择html、body 元素,copy style 信息
* html css style
* body css style

例如:
-webkit-locale: "en";
scroll-margin-top: var(--eyebrow-desktop);
animation-duration: 0.0001s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
caret-color: transparent !important;
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
box-sizing: border-box;
border: 0px;
font-size: 100%;
vertical-align: baseline;
text-decoration: none;
scroll-padding-top: var(--header-desktop);
scroll-behavior: auto;
height: 100%;
margin: 0;
padding: 0;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-feature-settings: normal;
font-variation-settings: normal;
-webkit-tap-highlight-color: transparent;
--swiper-theme-color: #007aff;
--toastify-toast-min-height: fit-content;
--toastify-toast-width: fit-content;
--header-mobile: 70px;
--header-desktop: 90px;
--eyebrow-mobile: 70px;
--eyebrow-desktop: 55px;
HTML 预览效果
cc构建的html 页面


cc构建的html 页面


步骤2: 迭代原始UI
这里我觉得原始UI的分格上已经可以了,只是一些细节还不太行,比如按钮hover 的阴影、边框等还需要完善。
我一般会使用下方的prompt 以及 copy 具体标签的CSS 来进一步处理。
这里我觉得原始UI的分格上已经可以了,只是一些细节还不太行,比如按钮hover 的阴影、边框等还需要完善。
我一般会使用下方的prompt 以及 copy 具体标签的CSS 来进一步处理。
prompt
Only code in HTML/Tailwind in a single code block.
Any CSS styles should be in the style attribute. Start with a response, then code and finish with a response.
Don't mention about tokens, Tailwind or HTML.
Always include the html, head and body tags.
Use lucide icons for javascript, 1.5 strokewidth.
Unless style is specified by user, design in the style of Linear, Stripe, Vercel, Tailwind UI (IMPORTANT: don't mention names).
Checkboxes, sliders, dropdowns, toggles should be custom (don't add, only include if part of the UI). Be extremely accurate with fonts.
For font weight, use one level thinner: for example, Bold should be Semibold.
Titles above 20px should use tracking-tight.
Make it responsive.
Avoid setting tailwind config or css classes, use tailwind directly in html tags.
If there are charts, use chart.js for charts (avoid bug: if your canvas is on the same level as other nodes: h2 p canvas div = infinite grows. h2 p div>canvas div = as intended.).
Add subtle dividers and outlines where appropriate.
Don't put tailwind classes in the html tag, put them in the body tags.
If no images are specified, use these Unsplash images like faces, 3d, render, etc.
Be creative with fonts, layouts, be extremely detailed and make it functional.
If design, code or html is provided, IMPORTANT: respect the original design, fonts, colors, style as much as possible.
Don't use javascript for animations, use tailwind instead. Add hover color and outline interactions.
For tech, cool, futuristic, favor dark mode unless specified otherwise.
For modern, traditional, professional, business, favor light mode unless specified otherwise.
Use 1.5 strokewidth for lucide icons and avoid gradient containers for icons.
Use subtle contrast.
For logos, use letters only with tight tracking.
Avoid a bottom right floating DOWNLOAD button.
Only code in HTML/Tailwind in a single code block.
Any CSS styles should be in the style attribute. Start with a response, then code and finish with a response.
Don't mention about tokens, Tailwind or HTML.
Always include the html, head and body tags.
Use lucide icons for javascript, 1.5 strokewidth.
Unless style is specified by user, design in the style of Linear, Stripe, Vercel, Tailwind UI (IMPORTANT: don't mention names).
Checkboxes, sliders, dropdowns, toggles should be custom (don't add, only include if part of the UI). Be extremely accurate with fonts.
For font weight, use one level thinner: for example, Bold should be Semibold.
Titles above 20px should use tracking-tight.
Make it responsive.
Avoid setting tailwind config or css classes, use tailwind directly in html tags.
If there are charts, use chart.js for charts (avoid bug: if your canvas is on the same level as other nodes: h2 p canvas div = infinite grows. h2 p div>canvas div = as intended.).
Add subtle dividers and outlines where appropriate.
Don't put tailwind classes in the html tag, put them in the body tags.
If no images are specified, use these Unsplash images like faces, 3d, render, etc.
Be creative with fonts, layouts, be extremely detailed and make it functional.
If design, code or html is provided, IMPORTANT: respect the original design, fonts, colors, style as much as possible.
Don't use javascript for animations, use tailwind instead. Add hover color and outline interactions.
For tech, cool, futuristic, favor dark mode unless specified otherwise.
For modern, traditional, professional, business, favor light mode unless specified otherwise.
Use 1.5 strokewidth for lucide icons and avoid gradient containers for icons.
Use subtle contrast.
For logos, use letters only with tight tracking.
Avoid a bottom right floating DOWNLOAD button.
原始UI.html 效果
经过两次三次调整后,我觉得work 了


经过两次三次调整后,我觉得work 了


步骤3: 生成STYLE_GUIDE.md
在正式开整我们的web 产品之前,我们需要一个容器,保存上面我们原始UI的所有样式信息,减少信息代差。
这个容器就是STYLE_GUIDE.md,你可以使用下面的 prompt 来生成
在正式开整我们的web 产品之前,我们需要一个容器,保存上面我们原始UI的所有样式信息,减少信息代差。
这个容器就是STYLE_GUIDE.md,你可以使用下面的 prompt 来生成
pormpt
Great, now help me generate a detailed style guide\
In style guide, you must include the following part:
- Overview
- Color Palette
- Typography (Pay attention to font weight, font size and how different fonts have been used together in the project)
- Spacing System
- Component Styles
- Shadows & Elevation
- Animations & Transitions
- Border Radius
- Opacity & Transparency
- Common Tailwind CSS Usage in Project
- Example component reference design code
- And so on...
In a word, Give detailed analysis and descriptions to the project style system, and don't miss any important details.
Great, now help me generate a detailed style guide\
In style guide, you must include the following part:
- Overview
- Color Palette
- Typography (Pay attention to font weight, font size and how different fonts have been used together in the project)
- Spacing System
- Component Styles
- Shadows & Elevation
- Animations & Transitions
- Border Radius
- Opacity & Transparency
- Common Tailwind CSS Usage in Project
- Example component reference design code
- And so on...
In a word, Give detailed analysis and descriptions to the project style system, and don't miss any important details.
生成的STYLE_GUIDE.md
由于cc 给我生成的style-guide 比较长,这里只贴了关键部分,如需查看完整.md, 辛苦移步仓库查看Github
# MotherDuck UI Design System - Style Guide
## Table of Contents
1. [Overview](#overview)
2. [Color Palette](#color-palette)
3. [Typography](#typography)
4. [Spacing System](#spacing-system)
5. [Component Styles](#component-styles)
6. [Shadows & Elevation](#shadows--elevation)
7. [Animations & Transitions](#animations--transitions)
8. [Border Radius](#border-radius)
9. [Opacity & Transparency](#opacity--transparency)
10. [Layout System](#layout-system)
11. [Common Tailwind CSS Usage](#common-tailwind-css-usage)
12. [Example Component Reference](#example-component-reference)
13. [Responsive Design Patterns](#responsive-design-patterns)
---
## Overview
The MotherDuck design system features a **bold, playful, and technical aesthetic** that combines:
- **Brutalist design principles** with heavy borders and sharp corners
- **Vibrant color palette** inspired by data visualization
- **Interactive micro-animations** with shadow-based hover effects
- **Technical typography** mixing Inter for UI and Monaco for code
- **Generous spacing** for a clean, breathable layout
### Design Philosophy
- **Bold & Confident**: Strong borders, high contrast, and clear visual hierarchy
- **Playful & Approachable**: Bright colors, whimsical cloud decorations, and friendly copy
- **Technical & Professional**: Code samples, data-focused messaging, and precise typography
- **Interactive**: Immediate visual feedback on all interactive elements
---
## Color Palette
### Primary Colors
```css
/* Background Colors */
--beige-background: #F4EFEA; /* Main page background */
--white: #FFFFFF; /* Card and section backgrounds */
--dark-gray: #2D2D2D; /* Code editor header */
/* Brand Colors */
--primary-blue: #6FC2FF; /* Primary CTA buttons */
--cyan: #4DD4D0; /* Secondary accent, badges */
--light-blue: #5CB8E6; /* Tertiary accent, banners */
--yellow: #FFD500; /* Top banner, tags, accents */
/* Text & Borders */
--dark: #383838; /* Primary text, borders */
--medium-gray: #666666; /* Secondary elements */
--light-gray: #E0E0E0; /* Dividers, table borders */
/* Accent Colors */
--orange-primary: #FF9500; /* Logo primary */
--orange-secondary: #FF6B00; /* Logo secondary */
--coral: #FF6B6B; /* Error/warning states */
--pink: #FFB6C1; /* Decorative accents */
### Color Usage Guidelines
| Color | Usage | Hex Code | Tailwind Class |
| -------------------- | ------------------------------------------ | --------- | ----------------------------------- |
| **Beige Background** | Main page background, alternating sections | `#F4EFEA` | `bg-[#F4EFEA]` |
| **White** | Cards, modals, content backgrounds | `#FFFFFF` | `bg-white` |
| **Primary Blue** | Primary CTA buttons, focus states | `#6FC2FF` | `bg-[#6FC2FF]` |
| **Cyan** | Badges, secondary highlights | `#4DD4D0` | `bg-[#4DD4D0]` |
| **Light Blue** | Banners, tags, tertiary accents | `#5CB8E6` | `bg-[#5CB8E6]` |
| **Yellow** | Top banner, promotional elements | `#FFD500` | `bg-[#FFD500]` |
| **Dark Gray** | Primary text, all borders | `#383838` | `text-[#383838]` `border-[#383838]` |
| **Medium Gray** | Secondary text, disabled states | `#666666` | `text-gray-600` |
### Color Combinations
**High Contrast Pairings:**
* Yellow background (`#FFD500`) + Dark text (`#383838`)
* White background + Dark borders (`#383838`)
* Primary Blue (`#6FC2FF`) + Dark borders (`#383838`)
**Semantic Colors:**
* **Success**: Cyan (`#4DD4D0`)
* **Warning**: Yellow (`#FFD500`)
* **Error**: Coral (`#FF6B6B`)
* **Info**: Light Blue (`#5CB8E6`)
***
## Typography
### Font Families
```css
/* Primary Font - UI Text */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Secondary Font - Code Samples */
font-family: 'Monaco', 'Courier New', monospace;
```
### Type Scale
| Element | Size | Weight | Line Height | Letter Spacing | Tailwind Classes |
| ---------------------- | -------------------- | -------------- | ----------- | --------------- | --------------------------------------------------------------- |
| **Hero H1** | 96px / 112px / 128px | 700 (Bold) | 1.0 | -0.02em (tight) | `text-6xl lg:text-7xl xl:text-8xl font-bold tracking-tighter` |
| **Section H2** | 48px / 60px | 700 (Bold) | 1.1 | -0.01em (tight) | `text-4xl lg:text-5xl font-bold tracking-tight` |
| **Section H2 (Large)** | 48px | 700 (Bold) | 1.1 | -0.01em (tight) | `text-5xl font-bold tracking-tight` |
| **Card H3** | 36px / 42px | 700 (Bold) | 1.2 | -0.01em (tight) | `text-3xl lg:text-4xl font-bold tracking-tight` |
| **Component H3** | 16px | 600 (Semibold) | 1.3 (snug) | 0 | `text-base font-semibold leading-snug` |
| **Body Large** | 18px / 20px | 500 (Medium) | 1.6 | 0 | `text-lg lg:text-xl font-medium leading-relaxed` |
| **Body Regular** | 16px | 400 (Regular) | 1.5 | 0 | `text-base` |
| **Body Small** | 14px | 500 (Medium) | 1.5 | 0 | `text-sm font-medium` |
| **Caption** | 12px | 400 (Regular) | 1.4 | 0 | `text-xs` |
| **Button Text** | 14px / 16px | 700 (Bold) | 1.0 | 0 | `text-sm font-bold uppercase` / `text-base font-bold uppercase` |
| **Code** | 13px / 14px | 400 (Regular) | 1.8 | 0 | `text-sm code-text leading-relaxed` |
| **Label Small** | 12px | 700 (Bold) | 1.2 | 0.1em (widest) | `text-xs font-bold tracking-widest` |
### Font Weight Guidelines
| Weight | Value | Usage |
| -------------- | ----- | ------------------------------------------------- |
| **Regular** | 400 | Body text, descriptions, table content |
| **Medium** | 500 | Navigation links, emphasized body text, subtitles |
| **Semibold** | 600 | Card headings, feature titles |
| **Bold** | 700 | All headings, buttons, tags, labels |
| **Extra Bold** | 800 | (Not used in current design) |
### Typography Patterns
**Heading Pattern:**
```html
由于cc 给我生成的style-guide 比较长,这里只贴了关键部分,如需查看完整.md, 辛苦移步仓库查看Github
# MotherDuck UI Design System - Style Guide
## Table of Contents
1. [Overview](#overview)
2. [Color Palette](#color-palette)
3. [Typography](#typography)
4. [Spacing System](#spacing-system)
5. [Component Styles](#component-styles)
6. [Shadows & Elevation](#shadows--elevation)
7. [Animations & Transitions](#animations--transitions)
8. [Border Radius](#border-radius)
9. [Opacity & Transparency](#opacity--transparency)
10. [Layout System](#layout-system)
11. [Common Tailwind CSS Usage](#common-tailwind-css-usage)
12. [Example Component Reference](#example-component-reference)
13. [Responsive Design Patterns](#responsive-design-patterns)
---
## Overview
The MotherDuck design system features a **bold, playful, and technical aesthetic** that combines:
- **Brutalist design principles** with heavy borders and sharp corners
- **Vibrant color palette** inspired by data visualization
- **Interactive micro-animations** with shadow-based hover effects
- **Technical typography** mixing Inter for UI and Monaco for code
- **Generous spacing** for a clean, breathable layout
### Design Philosophy
- **Bold & Confident**: Strong borders, high contrast, and clear visual hierarchy
- **Playful & Approachable**: Bright colors, whimsical cloud decorations, and friendly copy
- **Technical & Professional**: Code samples, data-focused messaging, and precise typography
- **Interactive**: Immediate visual feedback on all interactive elements
---
## Color Palette
### Primary Colors
```css
/* Background Colors */
--beige-background: #F4EFEA; /* Main page background */
--white: #FFFFFF; /* Card and section backgrounds */
--dark-gray: #2D2D2D; /* Code editor header */
/* Brand Colors */
--primary-blue: #6FC2FF; /* Primary CTA buttons */
--cyan: #4DD4D0; /* Secondary accent, badges */
--light-blue: #5CB8E6; /* Tertiary accent, banners */
--yellow: #FFD500; /* Top banner, tags, accents */
/* Text & Borders */
--dark: #383838; /* Primary text, borders */
--medium-gray: #666666; /* Secondary elements */
--light-gray: #E0E0E0; /* Dividers, table borders */
/* Accent Colors */
--orange-primary: #FF9500; /* Logo primary */
--orange-secondary: #FF6B00; /* Logo secondary */
--coral: #FF6B6B; /* Error/warning states */
--pink: #FFB6C1; /* Decorative accents */
### Color Usage Guidelines
| Color | Usage | Hex Code | Tailwind Class |
| -------------------- | ------------------------------------------ | --------- | ----------------------------------- |
| **Beige Background** | Main page background, alternating sections | `#F4EFEA` | `bg-[#F4EFEA]` |
| **White** | Cards, modals, content backgrounds | `#FFFFFF` | `bg-white` |
| **Primary Blue** | Primary CTA buttons, focus states | `#6FC2FF` | `bg-[#6FC2FF]` |
| **Cyan** | Badges, secondary highlights | `#4DD4D0` | `bg-[#4DD4D0]` |
| **Light Blue** | Banners, tags, tertiary accents | `#5CB8E6` | `bg-[#5CB8E6]` |
| **Yellow** | Top banner, promotional elements | `#FFD500` | `bg-[#FFD500]` |
| **Dark Gray** | Primary text, all borders | `#383838` | `text-[#383838]` `border-[#383838]` |
| **Medium Gray** | Secondary text, disabled states | `#666666` | `text-gray-600` |
### Color Combinations
**High Contrast Pairings:**
* Yellow background (`#FFD500`) + Dark text (`#383838`)
* White background + Dark borders (`#383838`)
* Primary Blue (`#6FC2FF`) + Dark borders (`#383838`)
**Semantic Colors:**
* **Success**: Cyan (`#4DD4D0`)
* **Warning**: Yellow (`#FFD500`)
* **Error**: Coral (`#FF6B6B`)
* **Info**: Light Blue (`#5CB8E6`)
***
## Typography
### Font Families
```css
/* Primary Font - UI Text */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Secondary Font - Code Samples */
font-family: 'Monaco', 'Courier New', monospace;
```
### Type Scale
| Element | Size | Weight | Line Height | Letter Spacing | Tailwind Classes |
| ---------------------- | -------------------- | -------------- | ----------- | --------------- | --------------------------------------------------------------- |
| **Hero H1** | 96px / 112px / 128px | 700 (Bold) | 1.0 | -0.02em (tight) | `text-6xl lg:text-7xl xl:text-8xl font-bold tracking-tighter` |
| **Section H2** | 48px / 60px | 700 (Bold) | 1.1 | -0.01em (tight) | `text-4xl lg:text-5xl font-bold tracking-tight` |
| **Section H2 (Large)** | 48px | 700 (Bold) | 1.1 | -0.01em (tight) | `text-5xl font-bold tracking-tight` |
| **Card H3** | 36px / 42px | 700 (Bold) | 1.2 | -0.01em (tight) | `text-3xl lg:text-4xl font-bold tracking-tight` |
| **Component H3** | 16px | 600 (Semibold) | 1.3 (snug) | 0 | `text-base font-semibold leading-snug` |
| **Body Large** | 18px / 20px | 500 (Medium) | 1.6 | 0 | `text-lg lg:text-xl font-medium leading-relaxed` |
| **Body Regular** | 16px | 400 (Regular) | 1.5 | 0 | `text-base` |
| **Body Small** | 14px | 500 (Medium) | 1.5 | 0 | `text-sm font-medium` |
| **Caption** | 12px | 400 (Regular) | 1.4 | 0 | `text-xs` |
| **Button Text** | 14px / 16px | 700 (Bold) | 1.0 | 0 | `text-sm font-bold uppercase` / `text-base font-bold uppercase` |
| **Code** | 13px / 14px | 400 (Regular) | 1.8 | 0 | `text-sm code-text leading-relaxed` |
| **Label Small** | 12px | 700 (Bold) | 1.2 | 0.1em (widest) | `text-xs font-bold tracking-widest` |
### Font Weight Guidelines
| Weight | Value | Usage |
| -------------- | ----- | ------------------------------------------------- |
| **Regular** | 400 | Body text, descriptions, table content |
| **Medium** | 500 | Navigation links, emphasized body text, subtitles |
| **Semibold** | 600 | Card headings, feature titles |
| **Bold** | 700 | All headings, buttons, tags, labels |
| **Extra Bold** | 800 | (Not used in current design) |
### Typography Patterns
**Heading Pattern:**
```html
MAKING BIG DATA FEEL SMALL
WHY IT'S BETTER
WHO IS IT FOR?
Analytics that works for everyone
```
**Body Text Pattern:**
```html
DUCKDB CLOUD DATA WAREHOUSE SCALING TO TERABYTES
Is your data all over the place? Start making sense...
Subscribe to MotherDuck news
```
**Text Decoration:**
* Links use `underline` for emphasis
* All-caps text for: buttons, headings, labels, navigation
* Tracking adjustment: `-tracking-tighter` for large headings, `tracking-widest` for small labels
***
## Spacing System
### Base Spacing Scale
The design uses Tailwind's default spacing scale (1 unit = 0.25rem / 4px):
| Value | Pixels | Usage |
| ----- | ------ | ------------------------------ |
| `1` | 4px | Micro spacing, icon gaps |
| `2` | 8px | Tight element spacing |
| `3` | 12px | Small gaps, checkbox spacing |
| `4` | 16px | Default gap, button groups |
| `6` | 24px | Medium spacing, card padding |
| `8` | 32px | Large spacing, section gaps |
| `10` | 40px | Extra large spacing |
| `12` | 48px | Section separation |
| `16` | 64px | Major section separation |
| `20` | 80px | Section padding (vertical) |
| `28` | 112px | Hero section padding (desktop) |
### Component Spacing Patterns
**Section Padding:**
```css
/* Standard Section */
padding: py-20 px-6 /* 80px vertical, 24px horizontal */
/* Compact Section */
padding: py-16 px-6 /* 64px vertical, 24px horizontal */
/* Hero Section */
padding: py-20 lg:py-28 px-6 /* 80px mobile, 112px desktop */
```
**Container Max Width:**
```css
max-w-6xl /* 1152px - Standard content */
max-w-7xl /* 1280px - Wide content */
max-w-4xl /* 896px - Narrow content, forms */
max-w-2xl /* 672px - Very narrow, centered content */
```
**Gap Spacing:**
```css
gap-2 /* 8px - Tight elements (window dots) */
gap-3 /* 12px - Form elements, checkboxes */
gap-4 /* 16px - Button groups, form rows */
gap-6 /* 24px - Grid items (small screens) */
gap-8 /* 32px - Navigation items */
gap-12 /* 48px - Card grid (medium) */
gap-16 /* 64px - Section elements */
```
**Margin Spacing:**
```css
/* Heading Margins */
mb-2 /* 8px - Label to content */
mb-3 /* 12px - Subtitle to content */
mb-6 /* 24px - Small heading to content */
mb-8 /* 32px - Medium heading to content */
mb-16 /* 64px - Large heading to grid */
/* Element Margins */
mb-4 /* 16px - Paragraph to button */
mb-6 /* 24px - Form to submit */
mb-8 /* 32px - Icon to text */
```
Analytics that works for everyone
```
**Body Text Pattern:**
```html
DUCKDB CLOUD DATA WAREHOUSE SCALING TO TERABYTES
Is your data all over the place? Start making sense...
Subscribe to MotherDuck news
```
**Text Decoration:**
* Links use `underline` for emphasis
* All-caps text for: buttons, headings, labels, navigation
* Tracking adjustment: `-tracking-tighter` for large headings, `tracking-widest` for small labels
***
## Spacing System
### Base Spacing Scale
The design uses Tailwind's default spacing scale (1 unit = 0.25rem / 4px):
| Value | Pixels | Usage |
| ----- | ------ | ------------------------------ |
| `1` | 4px | Micro spacing, icon gaps |
| `2` | 8px | Tight element spacing |
| `3` | 12px | Small gaps, checkbox spacing |
| `4` | 16px | Default gap, button groups |
| `6` | 24px | Medium spacing, card padding |
| `8` | 32px | Large spacing, section gaps |
| `10` | 40px | Extra large spacing |
| `12` | 48px | Section separation |
| `16` | 64px | Major section separation |
| `20` | 80px | Section padding (vertical) |
| `28` | 112px | Hero section padding (desktop) |
### Component Spacing Patterns
**Section Padding:**
```css
/* Standard Section */
padding: py-20 px-6 /* 80px vertical, 24px horizontal */
/* Compact Section */
padding: py-16 px-6 /* 64px vertical, 24px horizontal */
/* Hero Section */
padding: py-20 lg:py-28 px-6 /* 80px mobile, 112px desktop */
```
**Container Max Width:**
```css
max-w-6xl /* 1152px - Standard content */
max-w-7xl /* 1280px - Wide content */
max-w-4xl /* 896px - Narrow content, forms */
max-w-2xl /* 672px - Very narrow, centered content */
```
**Gap Spacing:**
```css
gap-2 /* 8px - Tight elements (window dots) */
gap-3 /* 12px - Form elements, checkboxes */
gap-4 /* 16px - Button groups, form rows */
gap-6 /* 24px - Grid items (small screens) */
gap-8 /* 32px - Navigation items */
gap-12 /* 48px - Card grid (medium) */
gap-16 /* 64px - Section elements */
```
**Margin Spacing:**
```css
/* Heading Margins */
mb-2 /* 8px - Label to content */
mb-3 /* 12px - Subtitle to content */
mb-6 /* 24px - Small heading to content */
mb-8 /* 32px - Medium heading to content */
mb-16 /* 64px - Large heading to grid */
/* Element Margins */
mb-4 /* 16px - Paragraph to button */
mb-6 /* 24px - Form to submit */
mb-8 /* 32px - Icon to text */
```
步骤4: 构建原型html
为了验证效果我们叫cc 大哥,参考STYLE_GUIDE.md ,实现一个四象限 to-do list 的.html 原型。 
中间省略我跟他对需求的过程,下方是cc实现的初稿👇
看起来平平无奇,甚至有点糟糕,什么东西嘛这是??🥸
别担心!! 别忘啦,所有的样式信息,都在STYLE_GUIDE.md ,我们可以继续push cc 迭代。

为了验证效果我们叫cc 大哥,参考STYLE_GUIDE.md ,实现一个四象限 to-do list 的.html 原型。 
中间省略我跟他对需求的过程,下方是cc实现的初稿👇
看起来平平无奇,甚至有点糟糕,什么东西嘛这是??🥸
别担心!! 别忘啦,所有的样式信息,都在STYLE_GUIDE.md ,我们可以继续push cc 迭代。

ui 迭代
1:叫替换一下 header 的颜色为style-guide.md 里面的黄色
2:添加图表统计功能
经过几轮的迭代,我们得到了初版的效果



1:叫替换一下 header 的颜色为style-guide.md 里面的黄色
2:添加图表统计功能
经过几轮的迭代,我们得到了初版的效果



步骤5:构建像素级别还原的next app
原生的.html 不方便后续迭代维护,你可以使用下方的prompt 叫CC构建一个next app 开始build 之前可以梳理一下已实现的功能,方便后续迭代
原生的.html 不方便后续迭代维护,你可以使用下方的prompt 叫CC构建一个next app 开始build 之前可以梳理一下已实现的功能,方便后续迭代
prompt
> Great,now you need to build a next app from todo-quadrant.html
- you need to ensure the UI and logic are pixel perfectly restorely 。
- the code structure should be clear enough and The code is highly readable.
- when there is the case that if-else ,your need to use early-return to solve
> Great,now you need to build a next app from todo-quadrant.html
- you need to ensure the UI and logic are pixel perfectly restorely 。
- the code structure should be clear enough and The code is highly readable.
- when there is the case that if-else ,your need to use early-return to solve
保存plan.md

最终的next.app 效果


最终的next.app 效果

其他扩展
当然啦,有了STYLE_GUIDE.md你还可以拓展更多的实践,比如:
- 在 stitch 生成符合风格 ui 设计稿【还可以加上初版的.html】
- 在lovart 生成符合风格的美术素材
- 基于farme motion 生成产品演示动画
- 生成漂亮的产品的幻灯片(html),用一些工具转为ppt 使用
当然啦,有了STYLE_GUIDE.md你还可以拓展更多的实践,比如:
- 在 stitch 生成符合风格 ui 设计稿【还可以加上初版的.html】
- 在lovart 生成符合风格的美术素材
- 基于farme motion 生成产品演示动画
- 生成漂亮的产品的幻灯片(html),用一些工具转为ppt 使用
参考实践
作者:hi大雄
来源:juejin.cn/post/7569777676098814002
来源:juejin.cn/post/7569777676098814002
我本是写react的,公司让我换赛道搞web3D
当你在会议室里争论需求时,智慧工厂的数字孪生正同步着每一条产线的脉搏;
当你对着平面图想象空间时,智慧小区的三维模型已在虚拟世界精准复刻每一扇窗的采光。
当你在CAD里调整参数时,
数字孪生城市的交通流正实时映射每辆车的轨迹;
当你等待客户确认方案时,
机械臂的3D仿真已预演了十万次零误差的运动路径;
当你用二维图纸解释传动原理时,
可交互的3D引擎正让客户‘拆解’每一个齿轮;
当你担心售后维修难描述时,
AR里的动态指引已覆盖所有故障点;
当你用PS拼贴效果图时,
VR漫游的业主正‘推开’你设计的每一扇门;
当你纠结墙面材质时,
光影引擎已算出了午后3点最温柔的折射角度;
从前端到Web3D,
不是换条赛道,
而是打开新维度。
韩老师说过:再牛的程序员都是从小白开始,既然开始了,就全心投入学好技术。
🔴 工具
所有的api都可以通过threejs官网的document,切成中文,去搜:

🔴 平面
⭕️ Scene 场景
场景能够让你在什么地方、摆放什么东西来交给three.js来渲染,这是你放置物体、灯光和摄像机的地方。

import * as THREE from "three";
// console.log(THREE);
// 目标:了解three.js最基本的内容
// 1、创建场景
const scene = new THREE.Scene();
⭕️ camera 相机

import * as THREE from "three";
// console.log(THREE);
// 目标:了解three.js最基本的内容
// 1、创建场景
const scene = new THREE.Scene();
// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);
// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中
⭕️ 物体 cube
import * as THREE from "three";
// console.log(THREE);
// 目标:了解three.js最基本的内容
// 1、创建场景
const scene = new THREE.Scene();
// 2、创建相机
const camera = new THREE.PerspectiveCamera(
75, // 相机的角度
window.innerWidth / window.innerHeight, // 相机的宽高比
0.1, // 相机的近截面
1000 // 相机的远截面
);
// 设置相机位置
camera.position.set(0, 0, 10); // 相机位置 (X轴坐标, Y轴坐标, Z轴坐标)
scene.add(camera); // 相机添加到场景中
// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1); // 创建立方体的几何体 (长, 宽, 高)
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 }); // MeshBasicMaterial 基础网格材质 ({ color: 0xffff00 }) 颜色
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial); // 创建立方体的物体 (几何体, 材质)
// 将几何体添加到场景中
scene.add(cube); // 物体添加到场景中
⭕️ 渲染 render
// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染的尺寸大小 (窗口宽度, 窗口高度)
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement); // 将webgl渲染的canvas内容添加到body
// 使用渲染器,通过相机将场景渲染进来
renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染进来 (场景, 相机)
⭕️ 效果
效果是平面的:

到这里,还不是3d的,如果要加3d,要加一下控制器。
🔴 3d
⭕️ 控制器
添加轨道。像卫星☄围绕地球🌏,环绕查看的视角:
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 目标:使用控制器查看3d物体
// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);
// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement); // 创建轨道控制器 (相机, 渲染器dom元素)
controls.enableDamping = true; // 设置控制器阻尼,让控制器更有真实效果。
function render() {
renderer.render(scene, camera); // 浏览器每渲染一帧,就重新渲染一次
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render); // 浏览器渲染下一帧的时候就会执行render函数,执行完会再次调用render函数,形成循环,每秒60次
}
render();
⭕️ 加坐标轴辅助器
// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5); // 坐标轴(size轴的大小)
scene.add(axesHelper);

⭕️ 设置物体移动
// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

cube.position.x = 3;
// 往返移动
function render() {
cube.position.x += 0.01;
if (cube.position.x > 5) {
cube.position.x = 0;
}
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
⭕️ 缩放
cube.scale.set(3, 2, 1); // xyz, x3倍, y2倍
单独设置
cube.position.x = 3;
⭕️ 旋转
cube.rotation.set(Math.PI / 4, 0, 0, "XZY"); // x轴旋转45度
单独设置
cube.rotation.x = Math.PI / 4;
⭕️ requestAnimationFrame
function render(time) {
// console.log(time);
// cube.position.x += 0.01;
// cube.rotation.x += 0.01;
// time 是一个不断递增的数字,代表当前的时间
let t = (time / 1000) % 5; // 为什么求余数,物体移动的距离就是t,物体移动的距离是0-5,所以求余数
cube.position.x = t * 1; // 0-5秒,物体移动0-5距离
// if (cube.position.x > 5) {
// cube.position.x = 0;
// }
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
⭕️ Clock 跟踪事件处理动画
// 设置时钟
const clock = new THREE.Clock();
function render() {
// 获取时钟运行的总时长
let time = clock.getElapsedTime();
console.log("时钟运行总时长:", time);
// let deltaTime = clock.getDelta();
// console.log("两次获取时间的间隔时间:", deltaTime);
let t = time % 5;
cube.position.x = t * 1;
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
大概是8毫秒一次渲染时间.
⭕️ 不用算 用 Gsap动画库
// 导入动画库
import gsap from "gsap";
// 设置动画
var animate1 = gsap.to(cube.position, {
x: 5,
duration: 5,
ease: "power1.inOut", // 动画属性
// 设置重复的次数,无限次循环-1
repeat: -1,
// 往返运动
yoyo: true,
// delay,延迟2秒运动
delay: 2,
onComplete: () => {
console.log("动画完成");
},
onStart: () => {
console.log("动画开始");
},
});
gsap.to(cube.rotation, { x: 2 * Math.PI, duration: 5, ease: "power1.inOut" });
// 双击停止和恢复运动
window.addEventListener("dblclick", () => {
// console.log(animate1);
if (animate1.isActive()) {
// 暂停
animate1.pause();
} else {
// 恢复
animate1.resume();
}
});
function render() {
renderer.render(scene, camera);
// 渲染下一帧的时候就会调用render函数
requestAnimationFrame(render);
}
render();
⭕️ 根据尺寸变化 实现自适应
// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
// console.log("画面变化了");
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
⭕️ 用js控制画布 全屏 和 退出全屏
window.addEventListener("dblclick", () => {
const fullScreenElement = document.fullscreenElement;
if (!fullScreenElement) {
// 双击控制屏幕进入全屏,退出全屏
// 让画布对象全屏
renderer.domElement.requestFullscreen();
} else {
// 退出全屏,使用document对象
document.exitFullscreen();
}
// console.log(fullScreenElement);
});
⭕️ 应用 图形 用户界面 更改变量
// 导入dat.gui
import * as dat from "dat.gui";
const gui = new dat.GUI();
gui
.add(cube.position, "x")
.min(0)
.max(5)
.step(0.01)
.name("移动x轴")
.onChange((value) => {
console.log("值被修改:", value);
})
.onFinishChange((value) => {
console.log("完全停下来:", value);
});
// 修改物体的颜色
const params = {
color: "#ffff00",
fn: () => {
// 让立方体运动起来
gsap.to(cube.position, { x: 5, duration: 2, yoyo: true, repeat: -1 });
},
};
gui.addColor(params, "color").onChange((value) => {
console.log("值被修改:", value);
cube.material.color.set(value);
});
// 设置选项框
gui.add(cube, "visible").name("是否显示");
var folder = gui.addFolder("设置立方体");
folder.add(cube.material, "wireframe");
// 设置按钮点击触发某个事件
folder.add(params, "fn").name("立方体运动");

🔴 结语
前端的世界,
不该只有Vue和React——
还有WebGPU里等待你征服的星辰大海。"
“当WebGL成为下一代前端的基础设施,愿你是最早站在三维坐标系里的那个人。”
来源:juejin.cn/post/7517209356855164978
AI时代,为什么我放弃Vue全家桶,选择了Next.js + Supabase
AI时代,为什么我放弃Vue全家桶,选择了Next.js + Supabase
12天的项目,我现在2天就能搞定。
这不是吹牛,而是我真实的开发体验。从Vue全家桶切换到Next.js + Supabase后,我的开发效率提升了10倍。
作为一个前端工程师出身的AI创业者,我曾经是Vue全家桶的忠实用户。Vue 3 + Vite + Pinia + Element Plus,这套组合陪我度过了实习和早期项目开发。
但是,当我开始用AI工具写代码后,一切都变了。
痛点:Vue在AI时代的尴尬
最初我还是习惯性地选择Vue,毕竟熟悉。但很快就发现了问题:
AI对Vue的理解让人抓狂
真实场景:我让Claude帮我写一个用户状态管理,结果:
// AI生成的Vue代码 - 问题一堆
const user = reactive({ name: '' }) // 应该用ref?
const userName = ref(user.name) // 重复定义?
// Pinia store在哪?为什么不用?
我花了2小时调试,最后发现AI把Vue 2和Vue 3的语法混在一起了。
更要命的是选择困难症
想要个用户登录系统?Vue全家桶给你10种方案:
- 后端:Express? Koa? Fastify?
- 数据库:MySQL? PostgreSQL? MongoDB?
- 认证:JWT? Session? OAuth?
- 部署:Docker? PM2? Nginx配置?
每个选择都需要调研、对比、踩坑。还没开始写业务逻辑,就已经消耗了大量时间和精力。
转折:AI改变了我的选择标准
直到我接触到Claude Code和各种MCP工具,才意识到问题的根本:
在AI时代,技术栈的选择标准彻底变了。
以前我们选技术栈考虑的是:
- 学习曲线
- 生态丰富度
- 团队熟悉度
- 性能表现
现在必须加上一个新维度:AI友好度。
什么是AI友好度?就是AI工具对这个技术栈的理解程度和支持质量。我发现:
- React/Next.js的训练数据更多 - GitHub上React项目是Vue的好几倍
- TypeScript + React的组合AI最熟悉 - 代码生成质量明显更高
- Next.js生态更适合全栈开发 - 一套框架解决前后端问题
更重要的是,我需要的不是完美的架构,而是快速验证想法的能力。
这已经成为行业共识
不只是我这么想,看看数据:
- GitHub上新项目,70%选择Next.js而非Vue
- Vercel部署量:Next.js项目数是Vue的5倍
- Stack Overflow 2024调查:Next.js超越Vue成为最受欢迎框架
连大厂也在转向:
- Netflix:从自建架构迁移到Next.js
- TikTok:新项目默认选择Next.js + Supabase
- 字节内部:推荐小团队使用"无后端"方案快速原型
我的答案:Next.js + Supabase
最终我选择了这个组合:
前端:Next.js 14 + TypeScript + Tailwind CSS
- AI对React生态理解最深
- TypeScript让AI生成的代码更可靠
- Tailwind CSS的原子化样式AI也很熟悉
后端:Supabase (PostgreSQL + 自动API)
- 零后端配置,专注业务逻辑
- 自动生成TypeScript类型定义
- 内置认证、存储、实时功能
开发工具:Claude Code + AI编程助手
- 代码自动生成和优化
- 实时错误检测和修复建议
- 智能代码补全和重构
最重要的是,你不需要从零搭建。
Next.js官方提供了with-supabase模板,一行命令就能开始:
npx create-next-app -e with-supabase my-app
这个模板已经配置好了:
- ✅ Supabase客户端初始化
- ✅ TypeScript类型定义
- ✅ 用户认证系统
- ✅ 中间件和路由保护
- ✅ 服务端和客户端数据获取
关键是,AI对这个模板非常熟悉。
我让Claude帮我修改代码时,它知道:
createClient()怎么用- 认证状态如何获取
- RLS规则怎么写
- Server Components和Client Components的区别
代码对比见真章
同样是获取用户信息,看看差异:
// Vue + AI:经常出错
const { data } = await $fetch('/api/user') // $fetch是啥?
const user = reactive(data) // 为什么不用ref?
// 类型怎么定义?接口在哪?
// Next.js + Supabase + AI:一气呵成
const { data: user } = await supabase.auth.getUser()
// 自动类型推导,无需手动定义
这就是AI友好度的体现 - 不是技术本身有多先进,而是AI对它的理解有多深。
实战验证:效率的巨大提升
用这套技术栈开发项目,我的体感是:
开发速度提升10倍
以前用Vue全家桶做一个带用户系统的项目:
- Day 1-2: 搭建后端API
- Day 3-4: 配置数据库和认证
- Day 5-7: 前端业务逻辑
- Day 8-10: 联调和部署
现在用Next.js + Supabase:
- Day 1 上午:
npx create-next-app -e with-supabase,完成核心功能 - Day 1: 下午,部署到Vercel
- 完成
真实案例对比
让我用具体数字说话。最近我帮朋友做了一个AI工具的落地页项目:
技术需求:
- 用户注册登录
- 支付集成
- 使用记录追踪
- 响应式设计
- SEO优化
Vue全家桶时代(预估):
- 后端API开发:5天
- 前端开发:4天
- 认证系统:2天
- 部署配置:1天
- 总计:12天
Next.js + Supabase实际用时:
- 模板初始化:30分钟
- Supabase数据库设计:半天
- 前端页面开发:1天
- 支付集成(Stripe):半天
- 部署(Vercel一键):10分钟
- 总计:2天

效率提升关键因素:
- 零后端配置 - Supabase自动生成API
- AI代码生成 - Claude对Next.js生态理解深度
- 模板起步 - with-supabase省去了80%的基础配置
- 类型安全 - TypeScript让AI生成的代码更可靠
踩坑经验:诚实的权衡
当然,这套技术栈也不是万能的:
性能权衡:
- Supabase在复杂查询时确实比自建API慢一些
- 但对于MVP和中小项目(1万用户以下)完全够用
成本考虑:
- 免费额度很慷慨:500MB数据库,50MB存储
- 付费后按使用量计费,比维护服务器便宜
迁移风险:
- 高度依赖Supabase生态
- 但PostgreSQL标准,迁移难度不大
最重要的认知转变:
在AI时代,完美的架构不如快速的验证。
一些实用建议
如果你也在纠结技术栈选择,我的建议是:
1. 评估你的真实需求
选择Next.js + Supabase,如果你:
- 团队规模3人以下
- 需要快速验证想法
- 预期用户量1万以下
- 重视开发效率 > 极致性能
坚持传统技术栈,如果你:
- 团队有专门的后端工程师
- 对性能有极致要求
- 已有大量历史代码
- 数据安全要求极高
2. 立即行动,不要完美主义
# 今天就可以开始
npx create-next-app -e with-supabase my-ai-project
cd my-ai-project
npm run dev
花30分钟体验一下,比看100篇教程有用。
3. 拥抱AI编程助手
推荐使用Claude Code或其他AI编程工具,它们对Next.js + Supabase生态理解最深,能提供:
- 精准的代码生成
- 智能的错误修复
- 最佳实践建议
结语:
从Vue全家桶到Next.js + Supabase,这不只是技术栈的切换,更是开发思维的升级。
在AI时代,最重要的不是掌握最新的框架,而是选择AI最懂的工具,让AI成为你的开发伙伴。
技术为想法服务,想法为使命服务。选择让你更快实现想法的技术栈,就是最好的选择。
来源:juejin.cn/post/7538087794968952884
国产 Canvas 引擎!神器!
写过原生 Canvas 的朋友都懂:
- API 低级到怀疑人生——画个带圆角的矩形就要
20行起步,缩放、拖拽、层级管理全靠自己实现。 - 节点一多直接 PPT——超过
5000个元素,页面卡成幻灯片。
于是,我们一边掉头发,一边默念:“有没有一款库,写得少、跑得快、文档还是中文?”
什么是 LeaferJS
LeaferJS 是一款高性能、模块化、开源的 Canvas 2D 渲染引擎,专注于提供高性能、可交互、可缩放矢量图形的绘图能力。

它采用场景图(Scene Graph)架构,支持响应式布局、事件系统、动画、滤镜、遮罩、路径、图像、文本、滚动视图、缩放、拖拽、节点嵌套、分组等丰富功能。
LeaferJS 的核心优势


高效绘图
- 生成图片、短视频、印刷品:支持导出
PNG、JPEG、PDF、SVG等多种格式,满足印刷级品质需求。 - Flex 自动布局、中心绘制:内置
Flex布局,支持中心绘制,后端可批量生成图片。 - 渐变、内外阴影、裁剪、遮罩、擦除:支持线性渐变、径向渐变、内外阴影、裁剪、遮罩、擦除等高级绘图功能。
UI 交互
- 开发小游戏、互动应用、组态软件:支持跨平台交互事件、手势,
CSS交互状态、光标。 - 动画、状态、过渡、精灵:支持帧动画、状态过渡、精灵图、箭头、连线等交互元素。
图形编辑
- 开发裁剪、图片、图形编辑器:提供丰富的图形编辑功能,高可定制。
- 标尺、视窗控制、滚动条:支持标尺、视窗控制、滚动条等编辑器必备功能。
性能巨兽
LeaferJS 最最核心的一点就是性能至上,和目前市面上比较流行的 Canvas 库对比:


如何快速上手
# 1. 创建项目
npm create leafer@latest my-canvas
cd my-canvas
npm i
npm run dev
// 2. 写代码(index.ts)
import { Leafer, Rect } from 'leafer-ui'
const leafer = new Leafer({ view: window })
const rect = new Rect({
x: 100,
y: 100,
width: 200,
height: 200,
fill: '#32cd79',
cornerRadius: [50, 80, 0, 80],
draggable: true
})
leafer.add(rect)
浏览器访问 http://localhost:5173——圆角矩形已可拖拽!

想加 1 万个?直接 for 循环,依旧丝滑。
使用场景
- 在线设计工具——海报、名片、电商 banner,导出 4K PDF 秒级完成。
- 数据可视化——物联网组态、拓扑图、百万点折线图,放大 20 倍依旧清晰。
- 在线白板——教学、会议、脑图,无限画布 + 实时协作。
- 无代码搭建——拖拽生成页面,JSON 一键转 Canvas 应用。
- 小游戏/动画——跑酷、拼图、营销活动,帧率稳 60,包体小一半。
优秀案例展示
基于 Leafer + vue3 实现画板。

fly-cut 在线视频剪辑工具。

- Github 地址:
https://github.com/x007xyz/flycut
基于 LeaferJS 的贪吃蛇小游戏。

一款美观且功能强大的在线设计工具,具备海报设计和图片编辑功能,基于 leafer.js 的开源版。

- Github 地址:
https://github.com/more-strive/tuhigh
更多优秀案例,可以移步官网

让“国产”成为“首选”
LeaferJS 不是又一个“国产替代”,而是直接把 Canvas 的性能与体验拉到 Next Level。
它让开发者第一次敢在提案里写:“前端百万节点实时交互,没问题。”
如果你受够了原生 Canvas 的笨拙,也踩腻了国外库的深坑,不妨试试 LeaferJS——
- LeaferJS 官网地址:
https://www.leaferjs.com/
来源:juejin.cn/post/7566104702569742355
颜色网站为啥都收费?自己做个要花多少钱?
你是小阿巴,一位没有对象的程序员。

这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。
结果一个大大的付费弹窗阻挡了你!

你心想:可恶,为啥颜色网站都要收费啊?

作为一名程序员,你怎能甘心?
于是你决定自己做一个,不就是上传视频、播放视频嘛?
这时,经常给大家分享 AI 和编程知识的 鱼皮 突然从你身后冒了出来:天真!你知道自己做一个要花多少钱么?

你吓了一跳:我又没做过这种网站,怎么知道要花多少?
难道,你做过?
鱼皮一本正经:哼,当然…… 没有。
不过我做过可以看视频的、技术栈完全类似的 编程学习网站,所以很清楚这类网站的成本。

你来了兴趣:哦?愿闻其详。
鱼皮笑了笑:那我就以 编程导航 项目为例,从网站开发、上线到运营的完整流程,给你算算做一个视频网站到底要花多少钱。还能教你怎么省钱哦~
你点了个赞,并递上了两个硬币:好啊,快说快说!
鱼皮特别感谢朋友们的支持,你们的鼓励是我持续创作的动力 🌹!

⚠️ 友情声明:以下成本是基于个人经验 + 专业云服务商价格的估算(不考虑折扣),仅供参考。
⭐️ 推荐观看本文对应视频版:bilibili.com/video/BV1nJ…
服务器
想让别人访问你的网站,首先你要有一台服务器。
你点点头:我知道,代码文件都要放到服务器上运行,用户通过浏览器访问网站,其实是在向服务器请求网页文件和数据。

那服务器怎么选呢?
鱼皮:服务器的配置要看你的网站规模。刚开始做个小型视频网站,可以用入门配置的轻量应用服务器 (比如 2 核 CPU、2G 内存、4M 带宽) ,一年几百块就够了。

等后续用户多了,服务器带宽跟不上了再升级。比如 4 核 CPU、16G 内存、14M 带宽,一年差不多几千块。

你:几百块?比我想的便宜啊。
鱼皮:没错,国内云服务现在竞争很激烈、动不动就搞优惠。
但是要注意,如果你想做 “那种网站”,就要考虑用海外服务器了(好处是不用备案)。
咳咳,我们不谈这个……
数据库
有了服务器,还得有数据库,用来存储网站的用户信息、视频信息、评论点赞这些数据。
你:这个简单,数据库不就是 MySQL、PostgreSQL 这些嘛,装在服务器上不就行了?

鱼皮:是可以的,但我更建议使用云数据库服务,比如阿里云 RDS 或者腾讯云的云数据库。
你:为啥?不是要多花钱吗?
鱼皮:因为云数据库更稳定,而且自带备份、容灾、监控这些功能,你自己搞的话,还要费时费力安装维护,万一数据丢了可就麻烦了。

你:确实,那得多少钱?
鱼皮:入门级的云数据库(比如 2 核 4G 内存、100GB 硬盘)包年大概 2000 元左右。后面用户多了、数据量大了,就要升级配置(比如 4 核 16G),那一年就要 1 万多了。不过那个时候你已经赚麻了……

Redis
鱼皮:对了,我还建议你加个 Redis 缓存。
你挠了挠头:Redis?之前看过你的 讲解视频。这个是必须的吗?

鱼皮:刚开始可以没有,但如果你想让网站数据能更快加载,强烈建议用。
你想啊,视频网站用户一进来都要查看视频列表、热门推荐这些,如果用 Redis 把热点数据缓存起来,响应速度能快好几倍,还能帮数据库分摊查询压力。

你:确实,网站更快用户更爽,也更愿意付费。那 Redis 要多少钱?
鱼皮:Redis 比数据库便宜一些。入门级的 Redis 服务一年大概 1000 元左右。

你松了口气:也还行吧,看来做个视频网站也花不了多少钱啊!
对象存储
鱼皮:别急,接下来才是重点!
我问问你,视频文件保存在哪儿?
你不假思索:当然是存在服务器的硬盘上!
鱼皮哈哈大笑:别开玩笑了,一个高清视频动不动就几百 MB 甚至几个 G,你那点儿服务器硬盘能存几个视频?

而且服务器带宽有限,如果同时有很多用户看视频,服务器根本撑不住!
你:那咋办啊!
鱼皮:更好的做法是用 对象存储,比如阿里云 OSS、腾讯云 COS。
对象存储是专门用来存海量文件的云服务,它容量几乎无限、可以弹性扩展,而且访问速度快、稳定性高,很适合存储图片和音视频这些大文件。

你:贵吗?
鱼皮:存储本身不贵,100GB 一年也就几十块钱。但 真正贵的是流量费用!
用户每看一次视频,都要从对象存储下载数据,这就产生了流量。
如果一个 1 GB 的视频被完整播放 1000 次,那就是 1000 GB 的流量,大概 500 块钱。
你看那些视频网站,每天光 1 个视频可能就有 10 万人看过,价格可想而知。

你惊讶地说不出话来:阿巴阿巴……

视频转码
鱼皮接着说:这还不够!对于视频网站,你还要做 视频转码。因为用户上传的视频格式、分辨率、编码方式都不一样,你需要把它们统一转成适合网页播放的格式,还要生成不同清晰度的版本让用户选择(标清、高清、超清)。

你:啊,那不是要多存好几个不同清晰度的视频文件?
鱼皮:没错,而且转码本身也是要钱的!
一般按照清晰度和视频分钟数计费。如果你上传 1000 个小时的高清视频,光转码费就得几千块!

CDN 加速
你急了:怎么做个视频网站处处都要花钱啊!有没有便宜点的办法?
鱼皮笑道:可以用 CDN。
你:CDN是啥?听着就高级!
鱼皮:CDN 叫内容分发网络,简单说就是把你的视频缓存到全国各地的服务器节点上。用户看视频的时候,从最近的节点拿数据,不仅速度更快,而且流量费比对象存储便宜不少。

你眼睛一亮:这么好?那不是必用 CDN!
鱼皮:没错,一般建议对象存储配合 CDN 使用。

而且视频网站 一定要做好流量防刷和安全防护!
现在有的平台自带了流量防盗刷功能:

此外,建议手动添加更多流量安全配置。
1)设置访问频率限制,防止短时间被盗刷大量流量


2)还要配置 CDN 的流量告警,超过阈值及时得到通知

3)还要启用 referer 防盗链,防止别人盗用你的视频链接,用你的流量做网站捞钱。

如果不做这些,可能分分钟给你刷破产了!
你:这我知道,之前看过很多你破产和被攻击的视频!
鱼皮:我 ***!

视频点播
你:为了给用户看个视频,我要先用对象存储保存文件、再通过云服务转码视频、再通过 CDN 给用户加速访问,感觉很麻烦啊!

鱼皮神秘一笑:嘿嘿,其实还有更简单的方案 —— 视频点播服务,这是快速实现视频网站的核心。
只需要通过官方提供的 SDK 代码包和示例代码,就能快速完成视频上传、转码、多清晰度切换、加密保护等功能。

此外,还提供了 CDN 内容加速和各端的视频播放器。

你双眼放光:这么厉害,如果我自己从零开发这些功能,至少得好几个月啊!
鱼皮:没错,视频点播服务相当于帮你做了整合,能大幅提高开发效率。
但是它的费用也包含了存储费、转码费和流量费,价格跟前面提到的方案不相上下。

你叹了口气:唉,主要还是流量费太贵了啊……
网站上线还要准备啥?
鱼皮:讲完了开发视频网站需要的技术,接下来说说网站上线还需要的其他东西。
你:啊?还有啥?
鱼皮:首先,你得有个 域名 给用户访问吧?总不能让人家记你的 IP 地址吧?

不过别担心,普通域名一年也就几十块钱(比如我的 codefather.cn 才 38 / 年)。

当然,如果是稀缺的好域名就比较贵了,几百几千万的都有!
你:别说了,俺随便买个便宜的就行……
鱼皮:买了域名还得配 SSL 证书,因为现在做网站都得用 HTTPS 加密传输,不然浏览器会提示 “不安全”,用户看了就跑了。

刚开始可以直接用 Let's Encrypt 提供的免费证书,但只有 3 个月有效期,到期要手动续期,比较麻烦。

想省心的话可以买付费证书,便宜的一年几百块。

你:了解,那我就先用免费的,看来上线也花不了几个钱。
鱼皮:哎,可不能这么说,网站正式上线运营后,花钱的地方可多着呢!尤其是安全防护。
安全防护
做视频网站要面对两大安全威胁。第一个是 内容安全,你总不能让用户随便上传违规视频吧?万一上传了不该传的内容,网站直接就被封了。
你紧张起来:对啊,我人工审核也看不过来啊…… 怎么办?

鱼皮:可以用内容审核服务。视频审核包含画面和声音两部分,比文字审核更贵,审核 1000 小时视频,大概几千块。

你:还有第二个威胁呢?
鱼皮:第二个是最最最难应对的 网络攻击。做视频网站,尤其是有付费内容的,特别容易被攻击。DDoS 流量攻击想把你冲垮、SQL 注入想偷你数据、XSS 攻击想搞你用户、爬虫想盗你视频……

你:这么坏的吗?那我咋防啊!
鱼皮:常用的是 Web 应用防火墙(WAF)和 DDoS 防护服务。Web 防火墙能防 SQL 注入、XSS 攻击这些应用层攻击,而 DDoS 防护能抵御大规模流量冲击。

但是这些商业级服务都挺贵的,可能一年就是几万几十万……

你惊呼:我为了防止被攻击,还要搭这么多钱?!
鱼皮笑了:好消息是,有些云服务商会提供一点点免费的 DDoS 基础防护,还有相对便宜的轻量版 DDoS 防护包。

我的建议是,刚开始就先用免费的,加上代码里做好防 SQL 注入、XSS 这些安全措施,其实够用了。等网站真做起来、有收入了,再花钱买商业级的防护服务就好。

你点了点头:是呀,如果没收入,被攻击就被攻击吧,哼!
鱼皮微笑道:你这心态也不错哈哈。除了刚才说的这些,随着你网站的成熟,还可能会用到很多第三方服务,比如短信验证码、邮件推送、 等等,这些也都是成本。
总成本
讲到这里,你应该已经了解了视频网站的整个技术架构和成本。

最后再总结一下,如果一个人做个小型的视频网站,一年到底要花多少钱?

你看着这个表,倒吸一口凉气:视频网站的成本真高啊……

鱼皮:没错,这还只是保守估计。如果你的网站真火了,每天几万人看视频,一年光流量费就得有几十万吧。
而且刚才说的都只是网站本身的成本,如果你一个人做累了,要组个团队开发呢?
按照一线城市的成本算算,前端开发 + 后端开发 + 测试工程师 + 运维工程师,再加上五险一金,差不多每月要接近 10 万了。
你瞪大眼睛:那一年就是一百万?

鱼皮:没错,人力成本才是最贵的。
你:好了你别说了,我不做了,我不做了!我现在终于理解为什么那些网站都要收费了……
鱼皮:不过说实话,虽然成本不低,但那些网站收费真的太贵了,其实成本远没那么高,更多的是利用人性赚取暴利!
所以比起花钱看那些乱七八糟的网站,把钱和时间投资在学习上,才是最有价值的。
你点了点头:这次一定!再看一期你的教程,我就睡觉啦~

更多
来源:juejin.cn/post/7572961448537882651
就因为package.json里少了个^号,我们公司赔了客户十万块

写这篇文章的时候,我刚通宵处理完一个P0级(最高级别)的线上事故,天刚亮,烟灰缸是满的🚬。
事故的原因,说出来你可能不信,不是什么服务器宕机,也不是什么黑客攻击,就因为我们package.json里的一个依赖项,少写了一个小小的^(脱字符号) 。
这个小小的失误,导致我们给客户A的数据计算模块,在一次平平无奇的依赖更新后,全线崩溃。而我们,直到客户的业务方打电话来投诉,才发现问题。
等我们回滚、修复、安抚客户,已经是7个小时后。按照合同的SLA(服务等级协议),我们公司需要为这次长时间的服务中断,赔付客户十万块。
老板在事故复盘会上,倒没说什么重话,只是默默地把合同复印件放在了桌上。

今天,我不想抱怨什么,只想把这个价值 十万块 的教训,原原本本地分享出来,希望能给所有前端、乃至所有工程师,敲响一个警钟。
事故是怎么发生的?
我们先来复盘一下事故的现场。
我们有一个给客户A定制的Node.js数据处理服务。它依赖了我们内部的另一个核心工具库@internal/core。
在项目的package.json里,依赖是这么写的:
{
"name": "customer-a-service",
"dependencies": {
"@internal/core": "1.3.5",
"express": "^4.18.2",
"lodash": "^4.17.21"
// ...
}
}
注意看,express和lodash前面,都有一个^符号,而我们的@internal/core,没有。
这个^代表什么?它告诉npm/pnpm/yarn:“我希望安装1.x.x版本里,大于等于1.3.5的最新版本。”
而没有^,代表什么?它代表:我只安装1.3.5这一个版本,锁死它,不许变。
问题就出在这里。
上周,core库的同事,修复了一个严重的性能Bug,发布了1.3.6版本,并且在公司群里通知了所有人。
我们组里负责这个项目的同学,看到了通知,也很负责任。他想:core库升级了,我也得跟着升。
于是,他看了看package.json,发现项目里用的是1.3.5。他以为,只要他去core库的仓库,把1.3.5这个tag删掉,然后把1.3.6的tag打上去,CI/CD在下次部署时,重新pnpm install,就会自动拉取到最新的代码。
他错了!
最致命的锁死版本
因为我们的依赖写的是"1.3.5",而不是"^1.3.5",所以我们的pnpm-lock.yaml文件里,把这个依赖的解析规则,彻底锁死在了1.3.5。
无论core库的同事怎么发布1.3.6、1.3.7,甚至2.0.0...
只要我们不去手动修改package.json,我们的CI/CD流水线,在执行pnpm install时,永远、永远,都只会去寻找那个被写死的1.3.5版本。
然后,灾难发生了。
core库的同事,在发布1.3.6后,为了保持仓库整洁,就把1.3.5那个旧的git tag给删掉了。
然后,客户A的项目,某天下午需要做一个常规的文案更新,触发了部署流水线。
流水线执行到pnpm install时,pnpm拿着lock文件,忠实地去找@internal/core@1.3.5这个包...
“Error: Package '1.3.5' not found.”
流水线崩溃了。一个本该5分钟完成的文案更新,导致了整个服务7个小时的宕机😖。
十万块换来的血泪教训
事故复盘会上,我们所有人都沉默了。我们复盘的,不是谁的锅,而是我们对依赖管理这个最基础的认知,出了多大的偏差。
^ (Caret) 和 ~ (Tilde) 不是选填,而是必填
^(脱字符) :^1.3.5意味着1.x.x(x >= 5)。这是最推荐的写法。它允许我们自动享受到所有 非破坏性 的小版本和补丁更新(比如1.3.6,1.4.0),这也是npm install默认的行为。~(波浪号) :~1.3.5意味着1.3.x(x >= 5)。它只允许补丁更新,不允许小版本更新。太保守了,一般不推荐。- (啥也不写) :
1.3.5意味着锁死。除非你是react或vue这种需要和生态强绑定的宿主,否则,永远不要在你的业务项目里这么干!
我们团队现在强制规定,所有package.json里的依赖,必须、必须、必须使用^。
关于lock文件
我们以前对lock文件(pnpm-lock.yaml, package-lock.json)的理解太浅了,以为它只是个缓存。
现在我才明白,package.json里的^1.3.5,只是在定义一个规则。
而pnpm-lock.yaml,才是基于这个规则,去计算出的最终答案。
lock文件,才是保证你同事、你电脑、CI服务器,能安装一模一样的依赖树的唯一路径。它必须被提交到Git。
依赖更新,是一个主动的行为,不是被动的
我们以前太天真了,以为只要依赖发了新版,我们就该自动用上。
这次事故,让我们明白:依赖更新,是一个严肃的、需要主动管理和测试的行为。
我们现在的流程是:

- 使用
pnpm update --interactive:pnpm会列出所有可以安全更新的包(基于^规则)。 - 本地测试:在本地跑一遍完整的测试用例,确保没问题。
- 提交PR:把更新后的
pnpm-lock.yaml文件,作为一个单独的PR提交,并写清楚更新了哪些核心依赖。 - CI/CD验证:让CI/CD在
staging环境,用这个新的lock文件,跑一遍完整的E2E(端到端)测试。
这十万块,是技术Leader(我)的失职,也是我们整个团队,为基础不牢付出的最昂贵的一笔学费。
一个小小的^,背后是整个npm生态的依赖管理的核心。
分享出来,不是为了博眼球,是真的希望大家能回去检查一下自己的package.json。
看看你的依赖前面,那个小小的^,它还在吗?😠
来源:juejin.cn/post/7568418604812632073
微信小游戏包体限制4M,一个字体就11.24M,怎么玩?

引言
哈喽大家好,很多时候,我们的游戏项目为了美观和保证风格的统一,都会用到外部字体库。
但是,外部字体库通常是完整的字库,体积非常的大,例如完整的simkai字体库就达到了11.24MB。

要知道,现在的微信小游戏限制主包的大小不能超过4M,即使你把字体放在分包,占去近50%的代码包大小,想想也不太合适。

因此,我们如果想要能够顺利地在游戏中用上漂亮的字体,那我们得想办法将字库瘦下来。
言归正传,本期将带小伙伴们一起来看下,如何将我们想用的字库从11.24M瘦到不到1M 。
本文源工程可在文末获取,小伙伴们自行前往。
精简字库原理
据了解,一个完整的字库估计有3~4万个汉字,但实际上我们游戏项目需要用到的可能只占10%~20%,甚至更少,像其中的一些汉字囧、烎、嫑、勥、忈、巭、怹、颪、氼、兲,别说用,笔者连读都不会读。(会读的小伙伴请打在评论区,我给你点赞)
游戏项目中,用到文字的地方通常包含下面几个:
- 1.游戏配置(*.json),一般配置里面的中文最多。

- 2.预制体(*.prefab),有些静态的文字通常就在预制体的
Label里。
- 3.场景(*.scene、*.fire)同上。

- 4.代码(*.ts),写死在代码里的。

因此,要瘦身字体,按照以下2个步骤即可:
- 1.通过工具将上述地方的文字提取出来。
- 2.通过工具从字库中的保留我们提取到的文字,其余的删除。
精简字库实例
1.提取中文字
要提取中文字,我们只需要按照上面的原理,遍历我们的游戏项目中的游戏配置、预制体、场景和代码进行匹配即可。
其中遍历文件,笔者使用的是glob。

匹配中文字的正则表达式是/[\u4e00-\u9fff]/g。

2.精简字库
这里我们使用百度出品的字体子集化工具Fontmin。可以直接通过npm install fontmin进行安装。

工具的使用也非常简单,通过传入原字体、保留的字符和字体输出目录,最后通过fontmin.run这个API生成即可。

3.效果演示
通过node font-minifier.js --project=C:\Users\Administrator\Desktop\demo --source=C:\Users\Administrator\Desktop\simkai.ttf传入工程目录和原字体路径即可。

执行结果可以看到扫描的所有文件。

提取到的所有中文字。

生成的文件及其大小。

精简后的字体大小为802K。

更进一步
除去我们遍历出来的游戏设定的中文字,其实还有一部分中文字我们是不确定的,那就是用户自定义的内容,例如名字和聊天文字。
想要处理这一部分文字,我们只能通过预设,猜到用户会自定义的内容,从而预设保留,可以通过网络上分享的常用内容来完成。
此外工具可以集成到插件或者打包系统里面去,这样后续就不用考虑相关问题,自动生成所需字库即可。
结语
通过上述方法,可以将字库大幅度精简到能够使用的状态,但是也会有一定的瑕疵。
不知道小伙伴们有没有更完美的办法呢?
本文源工程可通过私信发送 fontminifier 获取。
我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。
AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。
实不相瞒,想要个赞和爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!
推荐专栏:
来源:juejin.cn/post/7572087181608353842
白嫖党的快乐,我在安卓手机上搭了服务器+内网穿透,再也不用买服务器了
起因
因为去年买的腾讯云服务器到期了,我一看续租的话要459元,作为白嫖党这是万万不能接受的!

于是我就想:能否搞一个简单的服务器,能跑基本的项目就好了。
然后我就在掘金上看到了一篇文章:如何将旧的Android手机改造为家用服务器
最后结合全网,搜到了如下两种方案:
- KSWEB
- Ternux
综合对比下,我选择用Termux试试。
因为去年买的腾讯云服务器到期了,我一看续租的话要459元,作为白嫖党这是万万不能接受的!

于是我就想:能否搞一个简单的服务器,能跑基本的项目就好了。
然后我就在掘金上看到了一篇文章:如何将旧的Android手机改造为家用服务器
最后结合全网,搜到了如下两种方案:
- KSWEB
- Ternux
综合对比下,我选择用Termux试试。
前提
想要跑起来Termux,首先你要有一个安卓手机。
于是我就开始逛咸鱼,最后选了一款IQOO Neo5型号的,12+256G(有些小毛病),花了315元,这个内存跑服务应该是够了的。
想要跑起来Termux,首先你要有一个安卓手机。
于是我就开始逛咸鱼,最后选了一款IQOO Neo5型号的,12+256G(有些小毛病),花了315元,这个内存跑服务应该是够了的。
开始安装
安装Termux
1)通过github或者APKFab应用商店安装Termux。
2)更新和安装基础软件包
pkg update && pkg upgrade -y
pkg install wget curl nano -y
1)通过github或者APKFab应用商店安装Termux。
2)更新和安装基础软件包
pkg update && pkg upgrade -y
pkg install wget curl nano -y
安装nodejs
由于本人是前端开发,所有用的服务都是nodejs写的,所以只安装node相关的东西
pkg install nodejs
// 安装PHP或其他的同理,示例如下:
pkg install php
安装完成后,打印一下看看是否成功了

由于本人是前端开发,所有用的服务都是nodejs写的,所以只安装node相关的东西
pkg install nodejs
// 安装PHP或其他的同理,示例如下:
pkg install php
安装完成后,打印一下看看是否成功了

安装其他
由于我的项目也有nodejs服务端,所以还需要安装以下:
- mysql 数据库
- ssh 远程连接
- redis 缓存
- cpolar 内网穿透(本地部署的项目,外网无法访问,用它来给外网访问)
- nginx 高性能代理
具体的教程就不展示细节了,推荐几个教程地址,仅供参考:
设置完ssh后,就可以在电脑上的Xshell连接登录了,注意了:
默认端口号为8022,不是22
默认端口号为8022,不是22
默认端口号为8022,不是22
连接成功后是这样的

其中有一个用户身份验证,用户名输入whoami查看

由于我的项目也有nodejs服务端,所以还需要安装以下:
- mysql 数据库
- ssh 远程连接
- redis 缓存
- cpolar 内网穿透(本地部署的项目,外网无法访问,用它来给外网访问)
- nginx 高性能代理
具体的教程就不展示细节了,推荐几个教程地址,仅供参考:
设置完ssh后,就可以在电脑上的Xshell连接登录了,注意了:
默认端口号为8022,不是22
默认端口号为8022,不是22
默认端口号为8022,不是22
连接成功后是这样的

其中有一个用户身份验证,用户名输入whoami查看

放入几个项目
我用Xftp传入几个vue项目和nodejs项目

我用Xftp传入几个vue项目和nodejs项目

启动服务
项目放入后,启动的服务应该是只能局域网访问的,几个vue项目都是打包的dist文件,所以需要配置nginx代理,关键配置如下,有多少个项目,就来多少个server就行,慢慢配吧。
因为个人项目不多,也不找其他高大上的管理工具了
# 加解密 配置
server {
listen 5290;
server_name 192.168.3.155;
location / {
root /data/data/com.termux/files/home/vue/rui-utils-crypt/dist;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}
# 个人博客 配置
server {
listen 5173;
server_name 192.168.3.155;
location / {
root /data/data/com.termux/files/home/vue/vite-press-blog/dist;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}
# 若依 - nodejs-vue
server {
listen 5000;
server_name 192.168.3.155;
location / {
# dist为静态资源文件夹,dist内有index.html,
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
index index.html index.htm;
# 解决单页面应用中history模式不能刷新的bug
try_files $uri $uri/ /index.html;
# try_files $uri $uri/ =404;
}
# 服务器代理实现跨域
location /prod-api/ {
proxy_pass http://192.168.3.155:7002/; # 将/api/开头的url转向该域名
#如果报错则使用这一行代替上一行 proxy_pass http://localhost:8000; 将/api/开头的url转向该域名
rewrite "^/prod-api/(.*)$" /$1 break ; # 最终url中去掉/api前缀
}
# 静态资源优化 - 添加 ^~ 前缀提高匹配优先级
location ^~ /assets/ {
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
# include /www/server/panel/vhost/rewrite/60.204.201.111.conf;
#REWRITE-END
#禁止访问的文件或目录
location ~ ^/(.user.ini|.htaccess|.git|.env|.svn|.project|LICENSE|README.md)
{
return 404;
}
#一键申请SSL证书验证目录相关设置
location ~ .well-known{
allow all;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/.well-known/.*.(php|jsp|py|js|css|lua|ts|go|zip|tar.gz|rar|7z|sql|bak)$" ) {
return 403;
}
location ~ .*.(gif|jpg|jpeg|png|bmp|swf|ico)$
{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}
location ~ .*.(js|css)?$
{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
access_log /data/data/com.termux/files/usr/var/log/nginx/access.log;
error_log /data/data/com.termux/files/usr/var/log/nginx/error.log;
}
项目放入后,启动的服务应该是只能局域网访问的,几个vue项目都是打包的dist文件,所以需要配置nginx代理,关键配置如下,有多少个项目,就来多少个server就行,慢慢配吧。
因为个人项目不多,也不找其他高大上的管理工具了
# 加解密 配置
server {
listen 5290;
server_name 192.168.3.155;
location / {
root /data/data/com.termux/files/home/vue/rui-utils-crypt/dist;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}
# 个人博客 配置
server {
listen 5173;
server_name 192.168.3.155;
location / {
root /data/data/com.termux/files/home/vue/vite-press-blog/dist;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}
# 若依 - nodejs-vue
server {
listen 5000;
server_name 192.168.3.155;
location / {
# dist为静态资源文件夹,dist内有index.html,
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
index index.html index.htm;
# 解决单页面应用中history模式不能刷新的bug
try_files $uri $uri/ /index.html;
# try_files $uri $uri/ =404;
}
# 服务器代理实现跨域
location /prod-api/ {
proxy_pass http://192.168.3.155:7002/; # 将/api/开头的url转向该域名
#如果报错则使用这一行代替上一行 proxy_pass http://localhost:8000; 将/api/开头的url转向该域名
rewrite "^/prod-api/(.*)$" /$1 break ; # 最终url中去掉/api前缀
}
# 静态资源优化 - 添加 ^~ 前缀提高匹配优先级
location ^~ /assets/ {
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
# include /www/server/panel/vhost/rewrite/60.204.201.111.conf;
#REWRITE-END
#禁止访问的文件或目录
location ~ ^/(.user.ini|.htaccess|.git|.env|.svn|.project|LICENSE|README.md)
{
return 404;
}
#一键申请SSL证书验证目录相关设置
location ~ .well-known{
allow all;
}
#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/.well-known/.*.(php|jsp|py|js|css|lua|ts|go|zip|tar.gz|rar|7z|sql|bak)$" ) {
return 403;
}
location ~ .*.(gif|jpg|jpeg|png|bmp|swf|ico)$
{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}
location ~ .*.(js|css)?$
{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
access_log /data/data/com.termux/files/usr/var/log/nginx/access.log;
error_log /data/data/com.termux/files/usr/var/log/nginx/error.log;
}
本地访问
本人手机的ip为:192.168.3.155,端口用nginx的配置项即可,在Termux中输入nginx来启动,这样就可以本地访问了。
不知道ip的可以输入ifconfig来查看
先访问一下192.168.3.155:5173
可以看到,在电脑上已经能访问手机上启动的服务了。
但是我们需要外网也能访问,这就需要前面说的内网穿透了。
本人手机的ip为:192.168.3.155,端口用nginx的配置项即可,在Termux中输入nginx来启动,这样就可以本地访问了。
不知道ip的可以输入ifconfig来查看
先访问一下192.168.3.155:5173
可以看到,在电脑上已经能访问手机上启动的服务了。
但是我们需要外网也能访问,这就需要前面说的内网穿透了。
内网穿透
本项目的内网穿透选的是cpolar,教程见上文链接。
因为我装了sv工具,所以我输入sv up cpolar就启动了cpolar,启动后在电脑上输入手机IP + 9200端口号即可登录cpolar后台

配置本地的端口号:
配置完后,就可以在在线隧道列表菜单看到已配置的了
然后我们就可以在公网地址访问了,复制列表的地址,打开:

至此,我们已经可以在外网访问手机上、部署的vue打包项目了。但是此时没有后端服务,接下来我们同时部署后端的服务。
本项目的内网穿透选的是cpolar,教程见上文链接。
因为我装了sv工具,所以我输入sv up cpolar就启动了cpolar,启动后在电脑上输入手机IP + 9200端口号即可登录cpolar后台

配置本地的端口号:
配置完后,就可以在在线隧道列表菜单看到已配置的了
然后我们就可以在公网地址访问了,复制列表的地址,打开:

至此,我们已经可以在外网访问手机上、部署的vue打包项目了。但是此时没有后端服务,接下来我们同时部署后端的服务。
部署nodejs后端
先运行以下命令,启动redis和数据库
redis-server --daemonize yes
mysqld_safe &
然后根据nodejs的启动方法启动即可,一般为node 入口文件.js
我的启动成功如下

对应的前端地址如下:6331dea4.r5.cpolar.top/index
这个前后端是我用nodejs改写的java版若依管理后台,源码地址:gitee.com/ruirui-stud… 我以前的文章也有介绍的
最后,如果需要启动多个nodejs项目,可以用pm2管理
注意:本文的地址可能无法访问,因为手机我有别的用处,不一定随时开着
作者:前端没钱
来源:juejin.cn/post/7537893826595700788
先运行以下命令,启动redis和数据库
redis-server --daemonize yes
mysqld_safe &
然后根据nodejs的启动方法启动即可,一般为node 入口文件.js
我的启动成功如下

对应的前端地址如下:6331dea4.r5.cpolar.top/index
这个前后端是我用nodejs改写的java版若依管理后台,源码地址:gitee.com/ruirui-stud… 我以前的文章也有介绍的
最后,如果需要启动多个nodejs项目,可以用pm2管理
注意:本文的地址可能无法访问,因为手机我有别的用处,不一定随时开着
来源:juejin.cn/post/7537893826595700788
Electron 淘汰!新的跨端框架来了!性能飙升!
用过 Electron 的兄弟都懂,好处是“会前端就能写桌面”,坏处嘛,三座大山压得喘不过气:

- 体积巨婴
空项目打出来100 M+,每次更新又得80 M,用户宽带不要钱? - 内存老虎
开个“Hello World”常驻300 M,再开几个窗口,直接1 G起步,Mac 用户看着彩虹转圈怀疑人生。 - 启动慢动作
双击图标 → 图标跳 → 白屏3秒 → 终于看见界面,节奏堪比56 K猫拨号。
老板还天天催:“两周给我 MVP!”—— 抱着 Electron,就像抱着一只会写代码的胖熊猫,可爱但跑不动。
主角登场:GPUI
Rust 圈最近冒出一个“狠角色”——GPUI。
GPUI,是 Zed 编辑器团队推出的 Rust UI 框架,以 GPU 加速和高效渲染模式悄然崛起。

它不卖广告,纯开源,一句话介绍:直接拿显卡画界面,浏览器啥的全部踢出去。
- 底层用
wgpu,Metal/Vulkan/DX12想调谁就调谁; - 上层给前端味道的
DSL,写起来像React,跑起来却是纯原生; - 安装包
12 M,内存50 M,启动0.4秒,表格滑到60帧不带喘。
说人话:把 Electron 的“胖身子”抽真空,留下一身腱子肉。
亮点:为什么值得换坑?
| 场景 | Electron 现实 | GPUI 现实 |
|---|---|---|
| 安装包 | 100 M+ 是常态 | 12 M 起步,单文件都能带走 |
| 空载内存 | 一开 300 M,再开几个窗口直奔 1 G | 50 M 晃悠,再多窗口也淡定 |
| 启动速度 | 白屏 2~3 秒 | 肉眼可见 0.4 秒 |
| 大数据表格 | 十万行就卡成 PPT | 百万行照样 60 fps,滑到飞起 |
| 主题切换 | 重载 or 重启 | 一行代码,热切换,深色浅色瞬间完成 |

外加 60+现成组件:按钮、表格、树、日历、Markdown、穿梭框……皮肤直接照搬 Web 圈最火的 shadcn/ui,设计师不用改稿,开发直接复制粘贴。
五分钟上手:从零到 Hello Window
① 先装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
一路回车即可,30 秒搞定。
② 新建工程
cargo new my-app && cd my-app
③ 把依赖写进 Cargo.toml
[dependencies]
gpui = "0.2"
gpui-component = "0.1"
④ src/main.rs 写几行
use gpui::*;
fn main() {
App::new().run(|cx: &mut AppContext| {
Window::new("win", cx)
.title("我的第一个 GPUI 窗口")
.build(cx, |cx| {
Label::new("Hello,GPUI!", cx)
})
.unwrap();
});
}
⑤ 跑!
cargo run
三秒后窗口蹦出来,Hello 世界完成。没有黑框,没有白屏,体验跟原生记事本一样丝滑。
写代码像 React,跑起来像 C++
组件化 + 事件回调,前端同学一看就懂:
Button::new("点我下单", cx)
.style(ButtonStyle::Primary)
.on_click(|_, cx| {
println!("订单已发送");
notify_user("成交!", cx);
})
背后是 Rust 的零成本抽象,编译完就是机器码,没有浏览器,没有虚拟机,没有 GC 卡顿,性能直接拉满。
老网页也别扔,一键塞进来
历史项目里还有 React 报表?开 webview 特性就行:
gpui-component = { version = "0.1", features = ["webview"] }
窗口里留一块“浏览器区域”,把旧地址挂进去,零改动复用,妈妈再也不用担心重写代码。
Electron 依然是老大哥,但“胖身子”在 2025 年真的有点跟不上节奏。
新项目、新团队、新想法,不妨给 GPUI 一个机会——试过之后,你可能再也回不去了。
- GPUI 官网:
https://www.gpui.rs/ - GPUI Component 官网:
https://longbridge.github.io/gpui-component/
来源:juejin.cn/post/7568192652287787062
从「[1,2,3].map (parseInt)」踩坑,吃透 JS 数组 map 与包装类核心逻辑
你有没有遇到过这样的诡异场景:明明以为 [1,2,3].map(parseInt) 会返回 [1,2,3],实际运行却得到 [1, NaN, NaN]?
这行看似简单的代码,藏着 JS 数组方法、函数传参、包装类等多个核心知识点的关联。今天我们就从这个经典坑点切入,一步步拆解 map 方法的底层逻辑,顺带理清 NaN、包装类、字符串处理等容易混淆的知识点。
一、先踩坑:为什么 [1,2,3].map (parseInt) 不是 [1,2,3]?
要搞懂这个问题,我们得先明确两个关键:map 方法的参数传递规则,以及 parseInt 的工作原理。
1. map 方法的真正传参逻辑
MDN 明确说明:map 方法会遍历原数组,对每个元素调用回调函数,并将三个参数依次传入回调:
- 当前遍历的元素(item)
- 元素的索引(index)
- 原数组本身(arr)
也就是说,[1,2,3].map(parseInt) 等价于:
javascript
运行
[1,2,3].map((item, index, arr) => {
return parseInt(item, index, arr);
});
这里的关键是:map 会强制传递三个参数给回调,而不是只传我们以为的 “元素本身”。
2. parseInt 的参数陷阱
parseInt 的语法是 parseInt(string, radix),它只接收两个有效参数:
- 第一个参数:要转换的字符串(非字符串会先转字符串)
- 第二个参数:基数(进制,范围 2-36,0 或省略则默认 10 进制)
- 第三个参数会被直接忽略
结合 map 的传参,我们逐次分析遍历过程:
- 第一次遍历:item=1,index=0 → parseInt (1, 0)。基数 0 等价于 10 进制,结果 1。
- 第二次遍历:item=2,index=1 → parseInt (2, 1)。基数 1 无效(必须≥2),结果 NaN。
- 第三次遍历:item=3,index=2 → parseInt (3, 2)。2 进制中只有 0 和 1,3 无效,结果 NaN。
这就是为什么最终结果是 [1, NaN, NaN] —— 不是 map 或 parseInt 本身有问题,而是参数传递的 “错位匹配” 导致的。
3. 正确写法是什么?
如果想通过 map 实现 “数组元素转数字”,正确做法是明确回调函数的参数,只给 parseInt 传需要的值:
javascript
运行
// 方法1:手动控制参数
[1,2,3].map(item => parseInt(item));
// 方法2:使用Number简化
[1,2,3].map(Number);
// 两种写法结果都是 [1,2,3]
二、吃透 map 方法:不止是 “遍历 + 返回”
解决了坑点,我们再深入理解 map 的核心特性 —— 它是 ES6 数组新增的纯函数(不改变原数组,返回新数组),这也是它和 forEach 的核心区别。
1. map 的核心规则(必记)
- 不改变原数组:无论回调函数做什么操作,原数组的元素都不会被修改。
- 返回新数组:新数组长度与原数组一致,每个元素是回调函数的返回值。
- 跳过空元素:map 会忽略数组中的 empty 空位(forEach 也会),但不会忽略 undefined 和 null。
示例验证:
javascript
运行
const arr = [1, 2, 3, , 5]; // 第4位是empty
const newArr = arr.map(item => item * 2);
console.log(newArr); // [2,4,6, ,10](保留空位)
console.log(arr); // [1,2,3, ,5](原数组不变)
2. 实用场景:从基础到进阶
map 的核心价值是 “数据转换”,日常开发中高频使用:
- 基础转换:数组元素的统一处理(如平方、转格式)
javascript
运行
const arr = [1,2,3,4,5,6];
const squares = arr.map(item => item * item); // [1,4,9,16,25,36]
- 复杂转换:提取对象数组的特定属性
javascript
运行
const users = [{name: '张三'}, {name: '李四'}, {name: '王五'}];
const names = users.map(user => user.name); // ['张三', '李四', '王五']
三、延伸知识点:NaN 与包装类,JS 的 “隐式魔法”
在分析 map 和 parseInt 的过程中,我们遇到了 NaN,而 JS 中字符串能调用length方法的特性,又涉及到 “包装类” 的隐式逻辑 —— 这两个知识点是理解 JS “面向对象特性” 的关键。
1. NaN:不是数字的 “数字”
NaN 的全称是 “Not a Number”,但 typeof 检测结果是number,这是它的第一个反直觉点。
什么时候会出现 NaN?
- 无效的数学运算:
0/0、Math.sqrt(-1)、"abc"-10 - 类型转换失败:
parseInt("hello")、Number(undefined) - 注意:
Infinity(6/0)和-Infinity(-6/0)不是 NaN,它们是有效的 “无穷大” 数值。
如何正确判断 NaN?
因为NaN === NaN的结果是false(NaN 不等于任何值,包括它自己),所以必须用专门的方法:
javascript
运行
// 推荐:ES6新增的Number.isNaN(只检测NaN)
Number.isNaN(parseInt("hello")); // true
// 不推荐:window.isNaN(会先转换类型,误判情况多)
isNaN("hello"); // true("hello"转数字是NaN)
isNaN(123); // false
2. 包装类:JS 让 “简单类型” 拥有对象能力
JS 是完全面向对象的语言,但我们平时写的"hello".length、520.1314.toFixed(2),看起来是 “简单数据类型调用对象方法”—— 这背后就是包装类的隐式操作。
包装类的工作流程
当你对字符串、数字、布尔值这些简单类型调用方法时,JS 会自动做三件事:
- 用对应的构造函数(String、Number、Boolean)创建一个临时对象(包装对象);
- 通过这个临时对象调用方法(如 length、toFixed);
- 方法调用结束后,立即销毁临时对象,释放内存。
用代码还原这个过程:
javascript
运行
let str = "hello";
console.log(str.length); // 实际执行过程:
const tempObj = new String(str); // 1. 创建包装对象
console.log(tempObj.length); // 2. 调用方法
tempObj = null; // 3. 销毁对象
关键区别:简单类型 vs 包装对象
javascript
运行
let str1 = "hello"; // 简单类型(string)
let str2 = new String("hello"); // 包装对象(object)
console.log(typeof str1); // "string"
console.log(typeof str2); // "object"
console.log(str1.length === str2.length); // true(方法调用结果一致)
四、拓展:字符串处理的常见误区(length、slice、substring)
包装类让字符串拥有了对象方法,但字符串处理中也有不少容易踩坑的点,结合笔记中的案例总结:
1. length 的 “坑”:emoji 占几个字符?
JS 的字符串用 UTF-16 编码存储,常规字符(如 a、中)占 1 个 16 位单位,emoji 和生僻字占 2 个及以上。length 属性统计的是 “16 位单位个数”,而非视觉上的 “字符个数”:
javascript
运行
console.log('a'.length); // 1(常规字符)
console.log('中'.length); // 1(常规字符)
console.log("𝄞".length); // 2(emoji占2个单位)
console.log("👋".length); // 2(emoji占2个单位)
2. slice vs substring:负数索引与起始位置
两者都用于截取字符串,但处理负数索引和起始位置的逻辑不同:
- 负数索引:slice 支持从后往前截取(-1 是最后一位),substring 会把负数转为 0;
- 起始位置:slice 严格按 “前参为起点,后参为终点”,substring 会自动交换大小值(小的当起点)。
示例对比:
javascript
运行
const str = "hello";
console.log(str.slice(-3, -1)); // "ll"(从后数第3位到第1位)
console.log(str.substring(-3, -1)); // ""(负数转0,0>0无结果)
console.log(str.slice(3, 1)); // ""(3>1无结果)
console.log(str.substring(3, 1)); // "el"(自动交换为1-3)
五、总结:从坑点到体系化知识
回到最初的[1,2,3].map(parseInt),这个坑的本质是 “对 API 参数传递规则的理解不透彻”。但顺着这个坑,我们串联起了:
- map 方法的参数传递、纯函数特性;
- parseInt 的基数规则、类型转换逻辑;
- NaN 的特性与判断方法;
- 包装类的隐式工作流程;
- 字符串处理的常见误区。
JS 的很多 “诡异现象”,本质都是对底层逻辑的不了解。掌握这些核心知识点后,再遇到类似问题时,就能快速定位根源 —— 这也是我们从 “踩坑” 到 “成长” 的关键。
最后留一个小思考:["10","20","30"].map(parseI
来源:juejin.cn/post/7569898158835777577
中石化将开源组件二次封装申请专利,这个操作你怎么看?
开源项目推荐:
一. 前言
昨天看到了一篇关于 “中石化申请基于 vue 的文件上传组件二次封装方法和装置专利,解决文件上传功能开发繁琐问题” 的新闻。
今天特地在专利系统检索了一下,竟然是真的,令人不禁大跌眼镜!用的全是开源组件,最后还把它们变成了自己的专利!这波操作属实厉害啊!


难道以后要用这种方式上传文件,要交专利费了?哈哈....
说来好笑,有掘友指出有单词拼写错误,我又查看一下专利文件,竟然还真有拼写错误...

二. 了解一下
本专利是通过在 vue 页面中自定义 el-upload 组件和 el-progress 组件的使用,解决了文件上传功能开发步骤繁琐和第三方组件无法满足业务需求的问题,实现了简化开发、提高效率和灵活性的效果。
1. 摘要
本发明提供了一种基于 vue 的文件上传组件的二次封装方法和装置,解决了针对于文件上传功能的开发步骤繁琐,复杂,且上传功能的第三方组件无法完全满足业务需求的问题。
该基于 vue 的文件上传组件的二次封装方法包括:在 vue 页面中创建 el‑upload 组件和 el‑progress 组件;
基于所述 el‑upload 组件获取目标上传文件的大小,并判断所述目标上传文件的大小是否符合上传标准;若是,上传所述目标上传文件,并基于所述 el‑progress 组件获取上传进度;上传完成后,对上传的所述目标上传文件进行预处理并存储;
对存储的所述目标上传文件进行封装,并获得 vue 组件。
技术流程图:

二次封装装置模块:

2. 解决的技术问题
现有技术中文件上传功能的开发步骤繁琐复杂,第三方组件无法完全满足业务需求。
3. 采用的技术手段
通过在 vue 页面中引入 el-upload 组件和 el-progress 组件,自定义上传方法和进度条绑定,获取文件大小和上传进度,进行预处理和存储,并将其封装成可重复使用的 vue 组件。
4. 产生的技术功效
简化了文件上传功能的开发步骤,节省了开发时间和效率,避免了代码沉冗,降低了后期维护成本,并提高了文件上传功能的灵活性。
三. 实现一下
这种简单的上传文件+上传进度显示不是最基本的业务封装吗?相信这是每个前端开发工程师必备的基础技能。
所以我们趁热打铁,我们也来实现一下。
我也先来个流程图,梳理一下文件上传过程:

1. el-upload + el-progress 组合
- el-upload 负责文件选择、上传。
- el-progress 负责展示上传进度。
2. 文件大小校验
- 使用 el-upload 的
before-upload钩子,判断文件大小是否符合标准。
3. 上传进度获取
- 使用 el-upload 的
on-progress钩子,实时更新进度条。
4. 上传完成后的预处理与存储
- 上传完成后,触发自定义钩子(如
beforeStore、onStore),进行预处理和存储。
5. 封装为 Vue 组件
- 通过 props、emits、插槽等方式,暴露灵活的接口,便于业务页面集成。
都懒得自己动手,让 Cursor 来实现一下。Cursor 还是一如既往的强大,基本上一次询问就能成功!我表示 Cursor 在手,天下我有!

UploaderWrapper 自定义组件:
<template>
<div class="file-uploader">
<ElUpload
:action="action"
:before-upload="beforeUpload"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-error="handleError"
:limit="limit"
:on-exceed="handleExceed"
:show-file-list="showFileList"
:multiple="multiple"
:accept="accept"
v-model:file-list="fileList"
:on-remove="handleRemove"
>
<template #trigger>
<ElButton type="primary"> 选择文件上传 ElButton>
template>
<template #tip>
<div class="el-upload__tip">
支持的文件类型: {{ accept }},单个文件不超过 {{ maxSize }}MB
div>
template>
ElUpload>
<ElProgress
v-if="isUploading"
:percentage="uploadPercent"
:status="uploadPercent === 100 ? 'success' : ''"
class="mt-4"
/>
div>
template>
<style scoped>
.file-uploader {
width: 100%;
}
.el-upload__tip {
font-size: 12px;
color: #606266;
margin-top: 8px;
}
style>
使用方式:
<template>
<ElCard class="mb-5 w-80">
<template #header> 文件上传演示 template>
<UploaderWrapper
action="/api/upload"
:max-size="5"
:before-store="beforeStore"
:on-store="onStore"
/>
ElCard>
template>
效果如下所示:

声明:“代码仅供演示,不要使用,以免有专利侵权风险,慎重!”
四. 思考一下
从开发者的角度来看,这个专利事件是否能给我们带来了一些值得思考影响和启示:
- 技术创新的边界问题
- 使用开源组件进行二次封装是否应该被授予专利?
- 是否对开源社区的发展可能产生负面影响?
- 对日常开发的影响
- 如果专利获得授权,其他公司使用类似的文件上传组件封装方案是否可能面临法律风险?
- 开发者是否需要寻找替代方案或支付专利费用?
- 对开源社区的影响
- 可能打击开发者对开源项目的贡献热情,自己辛苦开源项目为别人做了嫁衣?
- 是否会影响开源组件的使用和二次开发
- 可能导致更多公司效仿,将开源组件的二次封装申请专利,因为毕竟专利对公司的招投标挺大的
五. 后记
“中石化作为传统能源企业,都能积极拥抱前端技术,还将内部技术方案申请专利,体现了他们对知识产权的重视?”
那我们是不是要在技术创新和知识产权保护之间找到平衡点,既要保护创新,又不能阻碍技术的发展。
而作为开发者的我们呢?这么简单的封装都能申请专利成功的话,那么...,大家有什么想法,是不是现在强的可怕?哈哈...
专利来源于国家知识产权局
申请公布号:CN120122937A
来源:juejin.cn/post/7514858513442078754















