普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月5日掘金 前端

Tauri 命令作用域(Command Scopes)精细化控制你的应用权限

作者 HelloReader
2026年3月5日 13:38

一、为什么需要命令作用域?

试想这样一个场景:你的 Tauri 应用需要读取用户 $APPLOCALDATA 目录下的某些配置文件,于是你开放了文件读取命令。但问题来了——这个目录下同时存放着 WebView 的运行时数据(如 Cookies、IndexedDB、Session 信息),一旦被恶意前端代码读取,将造成严重的隐私泄露。

如果权限粒度只能精确到"命令级别",你只能在"全部放开"和"全部禁止"之间二选一。命令作用域(Command Scopes) 正是为解决这一问题而生——它允许你在开放某个命令的同时,精确约束这个命令能操作的资源边界。

二、作用域的核心概念

2.1 allow 与 deny

作用域分为两类,规则简洁而明确:

类型 含义
allow 显式允许命令操作的资源范围
deny 显式拒绝命令操作的资源范围

核心规则:deny 的优先级永远高于 allow 无论 allow 范围有多宽泛,只要资源命中了 deny 规则,访问就会被拒绝,没有任何例外。

2.2 作用域的类型系统

作用域的值类型必须是可被 serde 序列化的 Rust 类型,具体类型由各插件或应用自行定义。不同插件使用不同类型来描述"资源"的概念:

  • fs 插件:使用 glob 路径字符串(如 $HOME/**)描述文件系统路径
  • http 插件:使用 URL 字符串描述允许访问的网络地址

作用域由命令实现层接收并强制执行。这意味着命令开发者必须自行实现作用域校验逻辑,框架本身不会自动过滤。

⚠️ 安全警告:命令开发者有责任确保作用域校验逻辑不存在绕过漏洞(例如路径穿越攻击)。所有校验代码都应经过安全审计。

三、实战:fs 插件的作用域配置

下面以 Tauri 官方 fs 插件为例,完整演示作用域的配置方式。在这个插件中,作用域类型统一为 glob 路径字符串

3.1 定义允许范围:递归访问 APPLOCALDATA

# plugins/fs/permissions/autogenerated/base-directories/applocaldata.toml

[[permission]]
identifier = "scope-applocaldata-recursive"
description = '''
This scope recursive access to the complete $APPLOCALDATA folder,
including sub directories and files.
'''

[[permission.scope.allow]]
path = "$APPLOCALDATA/**"

这里有两个细节值得注意:

  • $APPLOCALDATA 是 Tauri 内置的路径变量,会在运行时被解析为平台对应的目录(Windows 下为 %LOCALAPPDATA%,Linux 下为 ~/.local/share
  • /** 是 glob 通配符,表示递归匹配该目录下所有子目录和文件。若只写 /*,则只匹配一层,不会深入子目录

3.2 定义拒绝范围:保护 WebView 敏感数据

WebView 引擎会在 $APPLOCALDATA 下存储用户会话、缓存等敏感数据,不同平台的存储路径有所差异,因此需要分平台配置拒绝规则:

# plugins/fs/permissions/deny-webview-data.toml

# ---- Linux 平台 ----
[[permission]]
identifier = "deny-webview-data-linux"
description = '''
This denies read access to the $APPLOCALDATA folder on linux as the webview
data and configuration values are stored here.
Allowing access can lead to sensitive information disclosure.
'''
platforms = ["linux"]

[[scope.deny]]
path = "$APPLOCALDATA/**"

# ---- Windows 平台 ----
[[permission]]
identifier = "deny-webview-data-windows"
description = '''
This denies read access to the $APPLOCALDATA/EBWebView folder on windows
as the webview data and configuration values are stored here.
'''
platforms = ["windows"]

[[scope.deny]]
path = "$APPLOCALDATA/EBWebView/**"

platforms 字段是这里的关键——同一个 .toml 文件中可以定义多条权限,每条权限可以通过 platforms 声明其生效的操作系统,做到跨平台差异化配置,无需为每个平台单独维护文件。

两条规则的差异体现了 Linux 和 Windows WebView 实现的不同:

  • Linux:整个 $APPLOCALDATA 都用于存储 WebView 数据,因此整体拒绝
  • Windows:只有 EBWebView 子目录存储 Edge WebView2 的数据,精准拒绝即可

四、分层组合:用权限集构建作用域体系

单个作用域权限如同零件,真正的工程实践是将它们有机组合。Tauri 推荐通过权限集(Permission Set) 进行分层组合,每一层都应有清晰的语义。

第一层:合并拒绝规则,建立安全基线

# plugins/fs/permissions/deny-default.toml

[[set]]
identifier = "deny-default"
description = '''
This denies access to dangerous Tauri relevant files and
folders by default.
'''
permissions = [
    "deny-webview-data-linux",
    "deny-webview-data-windows"
]

deny-default 将两个平台的拒绝规则合并,形成一个平台无关的安全基线。无论应用运行在哪个系统,引用这一个标识符就能自动应用正确的拒绝规则。

第二层:allow + deny 合并,形成合理的访问策略

[[set]]
identifier = "scope-applocaldata-reasonable"
description = '''
This scope set allows access to the APPLOCALDATA folder and subfolders
except for linux, while it denies access to dangerous Tauri relevant
files and folders by default on windows.
'''
permissions = [
    "scope-applocaldata-recursive",  # 允许递归访问
    "deny-default"                   # 但屏蔽危险路径
]

scope-applocaldata-reasonable 的命名本身就是一种设计表达——"合理的(reasonable)APPLOCALDATA 访问策略",在放开访问的同时内置了安全保障,引用者无需关心底层细节。

第三层:作用域 + 命令权限合并,形成完整功能单元

[[set]]
identifier = "read-files-applocaldata"
description = '''
This set allows file read access to the APPLOCALDATA folder and
subfolders except for linux, while it denies access to dangerous
Tauri relevant files and folders by default on windows.
'''
permissions = [
    "scope-applocaldata-reasonable",  # 作用域策略
    "allow-read-file"                 # 开放读取命令
]

read-files-applocaldata 是最终对外暴露的功能级权限集,语义完整、开箱即用:调用者只需引用这一个标识符,就能获得"在 APPLOCALDATA 下安全读取文件"的完整能力。

五、整体设计思路图解

在这里插入图片描述

这种分层设计的好处在于:

  • 关注点分离:allow 规则和 deny 规则各自独立维护
  • 复用性强deny-default 可被所有涉及 APPLOCALDATA 的权限集复用
  • 语义清晰:每一层的命名都能准确表达其意图
  • 易于审计:安全相关的拒绝规则集中管理,不会散落在各处

六、作用域的两种应用场景

配置好的作用域权限集,可以用于两种不同的作用范围:

场景一:全局作用域

将作用域权限集应用于插件的全局 scope,该插件的所有命令都会受到约束。适用于对整个插件统一设定资源访问边界的场景。

场景二:命令级作用域

将作用域权限与特定命令权限组合(如上文 read-files-applocaldata 的做法),仅对该命令生效。适用于不同命令需要不同资源访问策略的场景。

七、实践建议

设计作用域时:

  • glob 路径要严谨/* 只匹配当前层,/** 才会递归,根据实际需要选择,避免无意间开放过宽的权限
  • 始终配套 deny:任何开放系统目录访问的 allow 规则,都应搭配针对敏感子路径的 deny 规则
  • 平台差异显式化:用 platforms 字段将平台逻辑内聚在权限文件中,不要依赖外部条件判断

实现命令时:

  • 作用域校验不能省:框架传入 scope 数据,但校验必须由命令实现层主动执行
  • 防止路径穿越:对用户传入的路径参数进行规范化(canonicalize)后再与 scope 比对
  • 安全审计要落实:校验逻辑上线前应经过独立的代码审查,尤其是涉及文件系统和网络的命令

总结

Tauri 的命令作用域机制提供了远超传统"开/关"粒度的访问控制能力。其核心设计哲学可以归纳为三点:

  1. 精确授权allow 明确放行,deny 兜底屏蔽,两者组合实现精准的资源边界
  2. 分层复用:从原子作用域到安全基线,再到功能权限集,每一层都可独立复用
  3. 平台感知platforms 字段让同一套配置体系优雅地处理跨平台差异

对于构建安全 Tauri 应用的开发者来说,命令作用域是不可忽视的核心机制。合理设计作用域体系,不仅能提升应用安全性,也能让权限配置本身成为一份清晰的"资源访问说明书"。

聊聊 Agent Skills 这个东西

作者 赵_叶紫
2026年3月5日 13:13

1. Agent Skills 是什么

简单说,Agent Skills 就是你写给 AI 看的"操作手册"。它是一个放在特定目录下的 SKILL.md 文件,AI 在遇到相关任务时会自动去读它,然后按里面写的方式干活。

类比一下

把 Cursor Agent 想象成刚入职的新同事,Skills 就是你递给他的操作手册——不是公司规章(那是 Rules),也不是外部系统的接口文档(那是 MCP),而是"碰到这类问题,按这套流程搞定"的具体指南。

为什么需要它?

  • 团队有自己的编码规范,AI 根本不知道
  • 某些重复流程(比如写 commit、处理分页列表)每次都要手动纠正 AI
  • 项目用了私有组件库,AI 总生成错误的组件名
  • 希望 AI 在特定场景下输出固定格式

2. Skills、Rules、MCP — 傻傻分不清楚?

这三个东西确实容易混,但定位其实很清晰:

维度 Skills Rules MCP
本质 领域知识 + 操作手册 行为约束 + 偏好设置 外部工具 / 数据源接口
作用范围 特定任务场景 所有对话 / 所有代码 需要访问外部系统时
触发方式 任务匹配时按需读取 始终注入到系统提示 显式调用工具函数
内容形式 Markdown 操作指南 简短规则列表 函数 schema + 服务端实现
存储位置 ~/.cursor/skills/.cursor/skills/ .cursorrules.cursor/rules/ MCP server 配置
典型例子 "Vue 列表页请求规范" "不输出整文件内容" "调用浏览器截图工具"
维护成本 中(按功能模块维护) 低(全局少量规则) 高(需要服务端部署)

遇到问题,该用哪个?决策很简单:

需要访问外部数据/工具(数据库、浏览器、API)?
  → MCP

是全局性的行为偏好(语言、输出格式、禁止什么)?
  → Rules

是某个具体场景下的专业知识或工作流程?
  → Skills

3. Skill 文件长什么样

目录结构

~/.cursor/skills/
└── skill-name/
    ├── SKILL.md          # 必须有,主文件
    ├── reference.md      # 可选,详细参考文档
    ├── examples.md       # 可选,用法示例
    └── scripts/          # 可选,辅助脚本
        └── validate.py

SKILL.md 格式

---
name: skill-name          # 小写字母 + 连字符,最多 64 字符
description: 第三人称描述,说清楚"什么场景用"和"能做什么"
---

# Skill 标题

## 核心规则 / 操作步骤
...

放哪里的区别

位置 路径 适用场景
个人 Skill ~/.cursor/skills/ 跨项目复用,个人工作流
项目 Skill .cursor/skills/ 团队共享,随代码库一起版本控制

注意:~/.cursor/skills-cursor/ 是 Cursor 内置目录,别往里放自定义 Skill。


4. 我当前整理的 Skill

目前配置了 6 个 Skill,覆盖 Vue 前端开发的核心场景:

api-list-fetch — Vue 列表页 API 请求规范

什么时候触发:写分页列表页、处理请求错误、同步查询状态

核心内容

  • onSuccess 里更新 total / page / size
  • onError 调用 initialPage() 重置状态
  • onComplete 把实际发送的参数同步回 SearchBox 组件
  • 刷新按钮直接调接口,不刷整页

有啥用:避免每次列表页都写出风格不一的分页逻辑,尤其是 onComplete 同步输入框这个细节特别容易漏。

gitc — Git Commit 规范自动提交

触发方式:输入 @gitc <描述>

核心内容

  • 自动识别 type(feat / fix / refactor / perf / docs 等)
  • 把中文描述翻译成英文祈使句
  • 生成符合 @commitlint/config-conventional 的 commit message
  • 直接跑 git add src/ + git commit + git push

示例

@gitc 修复日期格式化 bug
→ git commit -m "fix: correct date formatting"

有啥用:再也不用手动想 commit message 怎么写了,而且能直接过 husky 的 commit-msg 校验。


i18n-text-rules — 英文 i18n 文本大小写规则

什么时候触发:生成或审查英文翻译键

核心规则

场景 规则 示例
表格标题、表单标签、明细项 Title Case(介词小写) List of Items
按钮、下拉选项 Sentence case Save changes

有啥用:英文大小写是最容易出错的细节,规则统一了就不会出现同个页面一会儿 Title Case 一会儿全大写的问题。


ui-standards — UI 间距与边距规范

什么时候触发:写或审查 UI 组件布局

核心规则

  • 按钮间距:ml-6 / mr-6
  • 图标间距:mr-5 / ml-5
  • 文字 + 按钮/输入框:5px
  • 空状态图标与文字:mb-10

有啥用:间距这种东西最容易每个人写法不一样,固化成标准省去很多 review 来回。


vue-coding-standards — Vue 文件编码规范

什么时候触发:写或审查 *.vue 文件

核心规则摘要

规则 内容
代码顺序 propsdatacomputedwatch → 生命周期 → function
常量定义 <script>(非 setup)中定义,减少硬编码
loading 命名 GET 请求用 isFetching,其他用 isSending
函数命名 不加动词前缀:search() 而非 doSearch()
函数写法 声明式函数,不用箭头函数
属性命名 不以 _$ 开头

vue-component-usage — Vue 业务组件引用规范

什么时候触发:写业务组件、引用 UI 组件、拆分页面

核心内容

  1. 组件优先级src/viewComponents/common > src/components > custom-vue3-component
  2. 组件清单:内置 custom-vue3-component 的完整列表(xTable、xBtn、xModal 等 30+ 个)
  3. 页面拆分:List 页面拆成 SearchBox.vue + List.vue + Detail.vue
  4. 查询条件双状态:草稿状态(SearchBox 内部持有)vs 已提交状态(Page 持有的 lastParams)

有啥用:这是内容最多的一个 Skill,解决了"AI 不知道项目有哪些私有组件"的根本问题,同时规范了页面的架构模式。


5. AI 怎么知道该用哪个 Skill

Cursor Agent 处理请求时,会扫描所有 Skill 的 description 字段,判断当前任务是否匹配。匹配到了,就先完整读 SKILL.md,再按里面的指南干活。

所以 description 怎么写,直接决定 Skill 能不能被触发

# 写得太模糊 — 基本不会触发
description: Vue 相关规范

# 写得好 — 包含做什么 + 什么时候用 + 关键词
description: Vue *.vue 文件编码规范,涵盖代码顺序、常量定义、ref 用法、析构赋值、
             函数命名、loading 命名、属性命名等规则。当编写或审查 *.vue 文件代码
             风格、命名规范、变量定义方式时使用。

记住一点:用第三人称写,带上场景触发词("当...时使用")。


6. 怎么写一个好用的 Skill

精简比详尽更重要

Skill 内容会占 AI 的上下文窗口,每一行都在和其他信息抢空间。

  • SKILL.md 建议控制在 500 行以内
  • 只写 AI 默认不知道的东西(别去解释 JavaScript 基础语法)
  • 详细参考内容放 reference.md,主文件里链过去就行

根据任务特性选写法

任务特性 推荐写法
多种方案都行 文字指南(保留 AI 自由发挥空间)
有偏好但可灵活变通 伪代码 / 模板
必须严格一致(如 commit 格式) 具体规则 / 精确示例

核心放主文件,细节放子文件

## 完整 API 参数说明
详见 [reference.md](reference.md)

用例子比用文字描述有效得多

对于输出格式类 Skill,"好的 vs 坏的"比大段描述更直接:

`search()` — 正确
❌ `doSearch()` / `handleSearch()` — 别这么写

7. 还有哪些能用上的场景

这些场景日常容易忽视,但用 Skills 来处理效果很好:

Code Review 自动化

把团队的 CR checklist 写成 Skill,AI 审代码时自动对照:

## Review 必查项
- [ ] 没有硬编码的魔法数字
- [ ] 错误边界处理完整
- [ ] 组件命名符合 PascalCase
- [ ] 没有直接修改 props

文档模板生成

把 PRD、技术方案的固定格式写进 Skill,AI 生成时自动套用结构,不用每次重新描述文档框架。

测试用例规范

规定单测文件命名、describe/it 块结构、Mock 方式,避免每个文件风格各异。

API 接口约定

把后端的统一响应格式(比如 { code, message, result } 结构)、鉴权方式、错误码含义写进 Skill,AI 处理接口调用时自动对齐,不再生成和实际不符的解构代码。


8. 容易踩的坑

把 Rules 的内容写进了 Skill

全局性的行为约束(比如"不输出完整文件")应该放 Rules,不要放进按需触发的 Skill 里。

Skill 内容太宽泛

# 错误:范围太大,触发时啥都匹配不上
name: frontend
description: 前端相关规范

# 正确:聚焦具体场景
name: vue-coding-standards
description: Vue *.vue 文件编码规范...当编写或审查 *.vue 文件时使用

一个 Skill 塞了所有内容

每个 Skill 应该只干一件事。本项目把规范拆成 vue-coding-standardsvue-component-usageui-standards 三个,各管各的,比合并成一个大 Skill 好维护,触发也更准。

没认真写 description

Description 是 AI 发现 Skill 的唯一入口。写得模糊,Skill 基本等于摆设。


9. 动手写第一个 Skill

第一步:确定放哪

  • 个人用、跨项目复用 → ~/.cursor/skills/
  • 团队共享、项目专属 → .cursor/skills/(提交到 git)

第二步:建目录和文件

mkdir ~/.cursor/skills/my-skill
touch ~/.cursor/skills/my-skill/SKILL.md

第三步:写 SKILL.md

---
name: my-skill
description: [第三人称描述能做什么]。当[触发场景]时使用。
---

# My Skill

## 核心规则

1. 规则一
2. 规则二

## 示例

✅ 正确写法
❌ 错误写法

第四步:验证一下

在 Cursor 里触发相关任务,看 AI 有没有引用 Skill 里的内容。也可以直接在对话里点名:按照 my-skill 的规范...


最后总结一下

概念 一句话
Skills 告诉 AI「怎么做这件事」的操作手册
Rules 告诉 AI「所有事都要遵守的规矩」
MCP 给 AI「用外部工具的能力」

Skills 真正的价值在于把团队的隐性知识显性化。那些"大家都懂但 AI 不知道"的规范、模式、约定,通过 Skills 沉淀下来,AI 才能真正融入团队,而不只是个通用代码生成器。

探索JavaScript的秘密令牌:独一无二的`Symbol`数据类型

作者 Lee川
2026年3月5日 12:29

引言

在JavaScript的广阔世界中,数据类型构成了其最基础的语法元素。随着ES6的发布,这个大家庭迎来了两位新成员:BigIntSymbol。如果说BigInt是为了解决大数运算的精度问题,那么Symbol的诞生,则像是一把为对象属性开启“隐私空间”和“唯一命名”的神奇钥匙。本文将带你深入理解这个“独一无二”的简单数据类型。

一、认识Symbol:一种新的简单数据类型

JavaScript的八种数据类型,是每一位开发者的基本功,常被戏称为“七上八下”:

  • 简单数据类型 (7种)

    • 传统numberbooleanstringnullundefined
    • ES6新增bigintsymbol
  • 复杂数据类型 (1种)object

Symbol虽然用起来有点像构造函数Symbol()),但它本质上是简单数据类型。你可以通过typeof操作符来验证这一点。

// 1.js
const id1 = Symbol();
console.log(typeof id1); // 输出:symbol

二、Symbol的核心特性:绝对的独一无二

Symbol最核心、最迷人的特性,就是它的“独一无二性”。每次调用Symbol()函数,都会返回一个全新的、与其他任何Symbol都不同的值,即使它们拥有相同的描述(label)。

// 1.js
const id1 = Symbol();
const id2 = Symbol();
console.log(id1 === id2); // 输出:false

// 2.js
const s1 = Symbol('二哈');
const s2 = Symbol('二哈');
console.log(s1 === s2); // 输出:false

你可以为Symbol传入一个可选的字符串参数作为描述(label) ,例如Symbol('descrption')。这个描述仅仅是为了调试时方便识别,它不会影响Symbol的唯一性。两个描述相同的Symbol,依然是两个完全不同的值。这就像给两把不同的锁都贴上了“书房”的标签,但锁的齿纹(值)完全不同。

三、Symbol的核心应用:作为对象属性的唯一键

Symbol最主要、最实用的场景,就是作为对象的属性键(key) 。在ES6之前,对象的键只能是字符串,这在一个复杂、多人协作的代码库中极易引发命名冲突。

JavaScript是动态语言,任何人都可以轻松修改对象的属性。当项目代码庞大时,你可能会无意中覆盖掉他人定义的重要属性,或者自己的属性被他人覆盖,造成难以排查的Bug。

Symbol的引入,就是为了解决这个问题。用Symbol作为属性名,可以创造出绝对安全的、不会与任何字符串属性或其他Symbol属性冲突的私有属性

1. 如何定义Symbol属性?

你需要使用计算属性名的语法,在[]中写入Symbol变量。

// 2.js
const secretKey = Symbol('secret'); // 创建一个Symbol
console.log(secretKey, '//////'); // Symbol(secret) //////

const a = 'ecut';
const user = {
    [secretKey]: '111222', // 使用Symbol作为键
    email: '123456@qq.com',
    name: '张三',
    'a': '456', // 字符串'a'作为键
    [a]: '123'  // 使用变量a的值`'ecut'`作为键,相当于 `ecut: '123'`
};
console.log(user.ecut, user[a]); // 输出:123 123

2. Symbol属性的独特优势

  • 命名安全secretKey这个属性是独一无二的,全局任何地方都无法用[Symbol('secret')]以外的其他Symbol访问到它,也无法用字符串'secretKey'来访问,这避免了属性被意外覆盖。
  • 标签不影响唯一性:即使两个Symbol描述相同,它们作为键也是互不冲突的。
// 3.html
const classRoom = {
    [Symbol('Mark')]: {grade: 50, gender: 'male'},
    [Symbol('oliva')]: {grade: 80, gender: 'female'},
    // 即使标签(描述)和上面一样,这也是一个新的、独立的属性
    [Symbol('oliva')]: {grade: 85, gender: 'female'}, 
    "dl": ["张三","李四"]
};

上述代码中,第二个[Symbol('oliva')]并没有覆盖第一个,而是创建了一个全新的属性,完美解决了同名标签可能带来的冲突。

3. 枚举与遍历:Symbol的“隐藏”特性

Symbol属性还有一个重要特性:它们不会被常规的遍历方法枚举到。例如,for...in循环、Object.keys()Object.values()Object.entries()以及JSON.stringify()都会“忽略”Symbol属性。

// 3.html
for (const person in classRoom) {
    console.log(classRoom[person], '////'); // 只会打印出 "dl" 的值
}

这使得Symbol属性具备了一定的“私有”和“内置”属性特征,不会被轻易暴露出去。

如果你需要获取对象中所有的Symbol属性,必须使用专门的方法:

// 3.html
const syms = Object.getOwnPropertySymbols(classRoom); // 返回一个包含对象自身所有Symbol键的数组
console.log(syms); // 打印出 [Symbol(Mark), Symbol(oliva), Symbol(oliva)]

// 可以结合map方法获取这些属性的值
const data = syms.map(sym => classRoom[sym]);
console.log(data); // 打印出三个学生的对象数组

四、总结

Symbol是ES6为解决JavaScript长期存在的属性命名冲突和元编程问题而引入的一种优雅方案。它:

  1. 是简单数据类型,独一无二。
  2. 是创建对象唯一键的理想选择,尤其在多人协作和库的开发中,能有效保证属性安全。
  3. 具有“半隐藏”特性,不会被常规方法枚举,需用Object.getOwnPropertySymbols()获取。

掌握了Symbol,你就拥有了在JavaScript对象中创建“命名空间”和“内部插槽”的能力,让你的代码结构更清晰、更健壮。

基于 Rust 与 DeepSeek 大模型的智能 API Mock 生成器构建实录:从环境搭建到架构解析

2026年3月5日 12:28

前言

在现代软件工程中,API 接口的开发与前端联调往往存在时间差。为了解耦前后端开发进度,Mock 数据(模拟数据)的生成显得尤为关键。传统的 Mock 数据生成依赖于静态 JSON 文件或简单的规则引擎,难以覆盖复杂的业务逻辑与语义关联。随着大语言模型(LLM)的兴起,利用 AI 根据 Schema 定义动态生成高保真的模拟数据成为可能。本文详细记录了使用 Rust 语言结合 DeepSeek-V3.2 模型构建智能 Mock 生成器的完整技术路径,涵盖操作系统层面的环境准备、Rust 工具链的深度配置、代码层面的异步架构设计以及编译期的版本兼容性处理。

第一部分:Linux 系统底层的构建环境初始化

Rust 语言的编译与链接过程高度依赖于底层的系统工具链。Rust 编译器 rustc 在生成二进制文件时,需要调用链接器(Linker)将编译后的对象文件(Object Files)与系统库(如 glibc)进行链接。因此,在纯净的 Linux 环境中,首要任务是构建基础的编译环境。

对于基于 Debian 或 Ubuntu 的发行版,系统维护了庞大的软件包仓库。通过更新本地的包索引,可以确保后续安装的软件版本符合安全规范与依赖要求。随后,必须安装 build-essential 软件包组。这是一个元数据包(meta-package),其核心作用是部署构建 Linux 软件所需的核心工具列表,其中包括 GNU C 编译器(gcc)、GNU C++ 编译器(g++)、Make 构建工具以及标准的 C 库头文件(glibc-dev)。

此外,curl 作为一款强大的命令行数据传输工具,支持 DICT、FILE、FTP、FTPS、GOPHER、HTTP、HTTPS 等多种协议,是后续下载 Rust 安装脚本的关键依赖。

在终端执行如下指令进行环境初始化:

sudo apt update 
sudo apt install curl build-essential

系统将自动解析依赖树,下载并安装上述工具链。这一步不仅是为 Rust 准备的,也是任何系统级编程语言在 Linux 上运行的基石。

系统依赖安装过程

上图展示了 apt 包管理器在终端中的执行过程。可以看到系统正在读取软件包列表,并确认安装 curlbuild-essential 及其相关依赖。这是构建可执行程序的物理基础,若缺失这些组件,后续 Rust 的编译过程将因找不到链接器(cc linker)而失败。

第二部分:Rust 工具链的版本管理与部署

Rust 语言的版本迭代速度较快(每 6 周一个稳定版),且存在 Stable、Beta、Nightly 等多个更新通道。直接使用系统包管理器(如 apt)安装的 Rust 版本通常较为滞后。因此,官方推荐使用 rustup 作为 Rust 的安装器和版本管理工具。

通过 curl 下载并执行官方脚本,可以完成 Rust 编译环境的“自举”安装:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

该指令通过 HTTPS 协议下载 rustup-init.sh 脚本,并直接通过管道传递给 sh 执行。脚本执行过程中,会进行以下核心操作:

  1. 检测主机架构:识别当前 CPU 架构(如 x86_64)和操作系统类型(Linux-gnu)。
  2. 下载工具链:获取最新的 Stable 版本工具链,包含 rustc(编译器)、cargo(包管理器与构建工具)、rustfmt(代码格式化工具)以及 clippy(静态分析工具)。
  3. 配置环境变量:将 Rust 的二进制目录 $HOME/.cargo/bin 预配置到系统的 PATH 环境变量中。

Rustup 安装脚本执行界面

上图呈现了 rustup 安装脚本的欢迎界面。界面清晰地列出了即将安装的默认配置:默认主机三元组(x86_64-unknown-linux-gnu)、默认工具链(stable)以及环境变量修改策略(modify profile)。此时选择默认选项(输入 1 或回车)即可开始下载与安装过程。

安装完成后,由于 shell 的环境变量缓存机制,新添加的 PATH 路径不会立即在当前终端会话中生效。为了避免重启终端,可以使用 source 命令(. 是 source 的简写)重新加载环境变量配置文件:

. "$HOME/.cargo/env"

这一步操作直接在当前 shell 进程中执行了脚本,更新了内存中的环境变量表。此时,cargorustc 命令即可被系统识别。为了验证安装的完整性,通过查询版本号确认:

rustc --version
cargo --version

Rust 版本验证与环境变量配置

上图展示了环境加载与版本验证的结果。可以看到 rustccargo 均已正确输出版本号(1.84.0),证明编译器与构建工具已就绪。

为了确保每次登录系统或打开新终端时,Rust 环境自动生效,通常会将加载脚本追加到 Shell 的配置文件(如 ~/.bashrc)中。虽然 rustup 通常会自动处理此事,但手动确认或添加可以防止因 Shell 类型不同(如 zsh、fish)导致的路径丢失问题。

echo '. "$HOME/.cargo/env"' >> ~/.bashrc

配置 Shell 自动加载环境

上图演示了将环境变量加载命令写入 .bashrc 文件的操作。这是 Linux 用户环境配置持久化的标准做法,确保了开发环境的一致性与稳定性。

第三部分:云端 AI 基础设施接入与鉴权

本项目的核心逻辑是调用大语言模型生成 Mock 数据。这需要接入提供 LLM 能力的云服务平台。此处选用蓝耘平台(Lanyun),该平台提供了兼容 OpenAI 接口规范的 API 服务,方便开发者快速集成。

首先需要在平台控制台中创建 API Key。API Key 是服务端识别请求者身份的唯一凭证,必须严格保密。在 HTTP 请求中,该 Key 通常作为 Authorization 头部字段的值,采用 Bearer Token 的格式传输。

https://console.lanyun.net/#/register?promoterCode=0131

蓝耘平台 API Key 创建界面

上图展示了在蓝耘广场控制台中创建 API Key 的操作。创建成功后,系统会生成一串加密字符串,这是后续 Rust 代码中发起网络请求的通行证。

其次,选择合适的模型是影响生成数据质量的关键。DeepSeek-V3.2 模型在代码生成、逻辑推理以及 JSON 格式化输出方面表现优异,非常适合用于处理结构化的 Schema 数据生成任务。

DeepSeek 模型选择界面

上图确认了所选用的模型路径为 /maas/deepseek-ai/DeepSeek-V3.2。这个模型标识符(Model ID)将在后续的 HTTP 请求体中明确指定,以告知网关路由到具体的推理引擎。

第四部分:Rust 异步架构与代码实现

Rust 语言以其内存安全和零成本抽象著称。在编写网络 IO 密集型应用时,Rust 的异步运行时(Async Runtime)提供了极高的并发性能。本项目采用了 tokio 作为异步运行时,配合 reqwest 处理 HTTP 请求,使用 serde 及其派生宏处理 JSON 序列化与反序列化。

1. 数据结构设计与序列化

代码首先定义了一系列结构体(Struct),用于映射 API 请求与响应的 JSON 格式。

#[derive(Debug, Deserialize)]
struct ApiSchema {
    name: String,
    fields: Vec<Field>,
}

#[derive(Debug, Deserialize)]
struct Field {
    name: String,
    #[serde(rename = "type")]
    field_type: String, // 'type' 是 Rust 关键字,需重命名
}

这里使用了 serde crate 的 Deserialize trait。通过属性宏 #[derive(Deserialize)],编译器会自动生成解析 JSON 文本到 Rust 结构体的代码。特别值得注意的是 #[serde(rename = "type")],由于 type 是 Rust 语言的保留关键字,不能直接用作字段名,因此利用 Serde 的属性将其映射为 JSON 中的 type 字段,而在 Rust 代码中存储为 field_type

2. 混合型 Mock 数据生成策略

项目设计了双层生成策略:AI 生成优先,本地算法兜底

generate_mock_value 函数实现了一个确定性的本地生成器。利用 rand crate 生成随机数,结合 chrono 处理时间格式。它通过模式匹配(match)字段类型(如 string, integer, email, uuid 等),返回对应的随机数据。这种设计确保了在网络故障或 AI 服务不可用时,程序依然具有鲁棒性,能够产出基础的 Mock 数据。

3. 异步 HTTP 请求封装

call_ai 函数封装了与 DeepSeek API 的交互逻辑。

async fn call_ai(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let request = ChatRequest {
        model: "/maas/deepseek-ai/DeepSeek-V3.2".to_string(),
        messages: vec![Message {
            role: "user".to_string(),
            content: prompt.to_string(),
        }],
    };
    // ... 发送请求 ...
}

该函数被标记为 async,意味着它返回一个 Future,需要由 Tokio 运行时进行调度。reqwest::Client 负责建立 TLS 连接、处理 HTTP/2 协议协商以及连接池管理。请求头中通过 Bearer xxxxxxxxx 携带了之前获取的 API Key。

4. 主流程逻辑

main 函数使用了 #[tokio::main] 宏,这将原本同步的 main 函数转换为启动 Tokio 运行时的异步入口。程序定义了一个模拟的用户资料 Schema(包含 ID、用户名、邮箱、电话等),然后循环请求 AI 生成数据。

若 AI 请求成功,程序解析返回的 JSON 字符串;若失败,则回退到 generate_mock_data 进行本地生成。这种设计体现了工业级软件开发中的“降级策略”思想。

Rust 代码编辑器视图

上图展示了完整的 main.rs 源代码在编辑器中的概览。代码结构清晰,模块划分明确,引用了 serde_json 处理动态数据类型 Value,展示了 Rust 在处理强类型与动态 JSON 数据转换时的灵活性。

第五部分:依赖管理与编译期的版本危机

Rust 的包管理通过 Cargo.toml 文件声明依赖。本项目依赖了 serde(序列化核心)、serde_json(JSON 支持)、rand(随机数)、chrono(时间日期)、reqwest(HTTP 客户端)以及 tokio(异步运行时)。

在执行编译指令 cargo build --release 时,编译器会对源代码进行词法分析、语法分析、语义分析、优化并最终生成机器码。--release 参数指示编译器开启最高级别的优化(Optimization Level 3),去除调试符号,虽然编译时间变长,但生成的二进制文件体积更小、运行速度更快。

然而,在编译过程中遭遇了意料之外的错误。

编译报错:保留关键字冲突

上图清晰地展示了编译器抛出的错误信息。错误指出 gen 关键字的使用存在问题。深入分析发现,这是 Rust 语言版本迭代带来的兼容性问题。Rust 2024 Edition(2024 版本规范)引入了 gen 作为生成器(Generators)或相关特性的保留关键字。如果项目配置使用的是 Rust 2024 Edition,而代码或依赖库中将 gen 用作变量名或函数名,就会触发语法错误。

为了解决这一问题,必须修改 Cargo.toml 中的 edition 字段。Rust 提供了 edition 机制来在保持向后兼容的同时引入破坏性变更。将 edition = "2024" 回退修改为 edition = "2021",即可告诉编译器使用 2021 版的语法规范进行解析,此时 gen 不被视为关键字,从而解决了命名冲突。

修复 Edition 后的成功编译

上图展示了修改 Edition 版本后,再次执行构建命令的成功界面。可以看到 Cargo 下载了所有依赖 crate,并逐一编译(Compiling),最终完成了 api-mock-generator 的构建,生成了优化后的 Release 版本二进制文件。

第六部分:最终执行与成果验证

编译完成后,二进制文件位于 target/release/ 目录下。直接运行该程序,系统将加载 Schema 定义,向蓝耘平台发起 HTTP 请求,等待 DeepSeek 模型返回生成的 JSON 数据。

测试用的 Schema 定义了一个典型的用户模型:

{
    "name": "User",
    "fields": [
        {"name": "id", "type": "integer"},
        {"name": "username", "type": "string"},
        {"name": "email", "type": "email"},
        ...
    ]
}

程序通过 Prompt Engineering(提示词工程),构造了如下指令发送给 AI:“生成一个符合以下 API schema 的真实 JSON mock 数据...”。这利用了 LLM 强大的语义理解能力,使其生成的 "username" 不仅仅是随机字符串,而是类似 "Alice_99" 这样具有语义的名字;生成的 "profile_url" 也是符合 URL 规范的字符串。

程序运行结果输出

上图展示了程序最终的运行输出。

  1. 初始化:控制台打印出“使用 AI 生成 Mock 数据 for API: User”,表明程序已启动并加载 Schema。
  2. 数据生成:可以看到“AI 记录 #1”、“AI 记录 #2”等输出。每一条记录都是一个格式完美的 JSON 对象。
  3. 数据质量:观察生成的字段,id 是整数,username 是可读的字符串,email 符合邮箱格式,created_at 是标准的 ISO 8601 时间戳,profile_url 是合法的 HTTP 地址。

这证明了 Rust 程序成功地完成了以下复杂流程:序列化 Rust 结构体 -> 构造 HTTP 请求 -> 通过 HTTPS 发送至云端 -> 等待 AI 推理 -> 接收响应 -> 反序列化提取内容 -> 最终展示。

结语

本文完整复盘了一个基于 Rust 语言的 AI Native 应用开发流程。从底层的 Linux 库依赖处理,到 Rust 工具链的搭建;从 SaaS 平台的鉴权配置,到异步代码的逻辑编写;再到通过调整 Rust Edition 解决编译期的关键字冲突,最终实现了一个高效、智能的 Mock 数据生成器。这一过程不仅展示了 Rust 语言在系统编程与网络编程领域的强大能力,也体现了将传统软件工程与现代 AI 能力相结合的无限潜力。通过这种方式,开发者可以将枯燥的数据构造工作通过强类型的代码规范与 AI 的灵活性完美融合,极大提升开发效率。

深入浅出JavaScript事件机制:从捕获冒泡到事件委托

作者 Lee川
2026年3月5日 12:27

引言

在Web开发的世界里,JavaScript之所以强大,其核心特征之一就是其事件驱动模型。理解事件如何被监听、传递和响应,是构建交互式网页的基础。本文将从事件流的核心原理出发,结合代码示例,为你生动解析JavaScript的事件机制、addEventListener的奥秘,以及高效能的“事件委托”模式。

一、事件的生命周期:捕获、目标与冒泡

想象一下,当你点击网页上一个蓝色的方块时,浏览器是如何知道“点击发生了”的呢?这个过程并非一蹴而就,而是遵循一个严谨的、被称为“事件流”的三阶段生命周期。

  1. 捕获阶段(Capture Phase) :事件从文档的根节点(document)开始,像水流一样,沿着DOM树从最外层向最内层的目标元素层层“潜入” 。它问的是:“事件发生在哪里?”
  2. 目标阶段(Target Phase) :事件到达了实际被点击的、最内层的那个元素event.target)。这里是事件真正的“目标”。
  3. 冒泡阶段(Bubble Phase) :事件从目标元素开始,沿着DOM树反向、从内向外“浮出”到文档根节点。它宣告:“事件在这里发生了!”

这个“捕获 -> 目标 -> 冒泡”的过程,是理解所有事件行为的地图。下图清晰地展示了这一流程,其中红色为父元素,蓝色为子元素,而事件正是按照箭头所示的路径传播的:

<!DOCTYPE html>
<html>
<head>
  <style>
  #parent { width: 200px; height: 200px; background-color: red; }
  #child { width: 100px; height: 100px; background-color: blue; }
  </style>
</head>
<body onclick="alert('Body被点击')">
  <div id="parent">
    <div id="child">点击我</div>
  </div>
  <script>
    // 为父元素和子元素注册事件监听器
    document.getElementById('parent').addEventListener('click', function() {
      console.log('parent clicked in 捕获阶段');
    }, true); // 第三个参数为 true,在捕获阶段触发

    document.getElementById('child').addEventListener('click', function() {
      console.log('child clicked (目标阶段)');
    }); // 第三个参数默认为 false,在冒泡阶段触发

    document.getElementById('parent').addEventListener('click', function() {
      console.log('parent clicked in 冒泡阶段');
    }, false); // 第三个参数为 false,在冒泡阶段触发
  </script>
</body>
</html>

代码解析

  • 点击蓝色子元素,控制台输出顺序将是:parent clicked in 捕获阶段-> child clicked (目标阶段)-> parent clicked in 冒泡阶段
  • 关键就在于addEventListener的第三个可选参数useCapture。它为true时,监听器在捕获阶段被触发;为false(默认值)时,在冒泡阶段被触发。这解释了为什么父元素的两个监听器会在不同时间点被调用。

二、阻止事件的“涟漪”:stopPropagation

事件流就像水中的涟漪,会一层层扩散。有时我们需要阻止这个扩散过程,这时就需要event.stopPropagation()方法。它的作用是阻止事件继续在捕获或冒泡阶段向上或向下传播

效果对比

  • stopPropagation:点击子元素,会依次触发父元素(捕获)、子元素、父元素(冒泡)的事件。
  • stopPropagation:如果在子元素的事件监听器中调用了event.stopPropagation(),事件在目标阶段之后就会被“截停”,不再进入冒泡阶段,外层的监听器(如父元素的冒泡监听器、bodyonclick)将不会被触发。
document.getElementById('child').addEventListener('click', function(event) {
  event.stopPropagation(); // 阻止事件冒泡
  console.log('child clicked,但事件不再向上冒泡');
}, false);
// 点击子元素后,父元素在冒泡阶段的监听器和 body 的 onclick 都不会被触发。

三、性能利器:事件委托(Event Delegation)

考虑一个常见场景:一个包含成百上千个<li>项目的待办列表,我们需要为每个<li>添加点击事件。如果按照传统方式为每个<li>单独绑定监听器,会造成巨大的内存开销和性能负担。

事件委托完美地解决了这个问题。其核心思想是利用事件的冒泡机制不在每一个子节点上设置监听器,而是将监听器设置在它们的父节点上。当事件在子元素上触发并冒泡到父元素时,父元素上绑定的监听器会被执行,我们通过event.target属性来精确找到实际被点击的是哪个子元素。

代码示例

<ul id="task-list">
  <li>任务1:学习事件机制</li>
  <li>任务2:编写代码示例</li>
  <li>任务3:理解事件委托</li>
</ul>
<script>
  // 传统方式:为每个 li 单独绑定(低效,不推荐)
  // const allLis = document.querySelectorAll('#task-list li');
  // for(let li of allLis) {
  //   li.addEventListener('click', function(){ console.log(this.innerHTML); });
  // }

  // 事件委托:只绑定一次在父元素上
  document.getElementById('task-list').addEventListener('click', function(event) {
    // 检查被点击的元素是否是我们要监听的 li
    if (event.target.tagName === 'LI') {
      console.log(`你点击了: ${event.target.innerHTML}`);
      // 可以在这里针对不同的 li 进行不同的处理
    }
  });
</script>

事件委托的优势

  1. 节省内存:无论列表多长,都只有一个事件监听器。
  2. 动态友好:新增的<li>元素自动“拥有”点击事件,无需重新绑定。
  3. 代码简洁:逻辑集中在一个处理函数中,易于维护。

四、重要概念与最佳实践

  • DOM事件标准addEventListener属于DOM 2级事件模型,是现代JavaScript中监听事件的标准方式,支持为同一事件添加多个监听器,并能精细控制捕获/冒泡阶段。早期的onclick属性等方式属于DOM 0级事件,功能有限,不推荐在新项目中使用。
  • event.targetvs this:在事件委托中,event.target指向最初触发事件的元素(即被点击的<li>),而this指向绑定监听器的元素(即<ul id=“task-list”>)。理解这个区别至关重要。
  • 监听器的绑定对象:事件监听器必须绑定在单个DOM元素上,不能直接绑定在元素集合(如document.querySelectorAll(‘li')返回的NodeList)上,否则会报错。

总结

JavaScript事件机制是一个从宏观流向(捕获/冒泡)到微观控制(stopPropagation)再到设计模式(事件委托)的完整体系。掌握它,不仅能让你写出正确响应交互的代码,更能让你从性能优化的角度,构建出高效、优雅的Web应用。记住这个核心链条:事件沿着DOM树传播 -> 在特定阶段触发监听器 -> 通过委托实现高效管理

pxcharts Ultra V2.3更新:多维表一键导出 PDF,渲染兼容性拉满!

作者 徐小夕
2026年3月5日 12:23

最近粉丝咨询最多的问题莫过于 pxcharts 多维表是否能导出PDF的能力了。

图片

说实话,我回避了很久。浏览器打印引擎差异大,中文渲染、分页断行、复杂表格适配...每个都是坑。

直到上个月,一个做财务的朋友跟我吐槽:月底导报表,调格式调到凌晨2点。我决定,这功能必须上。

于是在1周的设计和研究下,终于实现了多维表导出PDF的功能。

演示如下:

图片

导出后的PDF文件预览效果:

图片

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

接下来和大家分享一下详细的功能技术实现。

Pxcharts多维表导出PDF功能技术实现

支持将表格数据导出为 PDF 格式,便于用户打印、存档和分享,核心需求包括:

  • 保持表格结构和样式
  • 支持分页(避免行被截断)
  • 支持封面页(统计信息)
  • 状态标签着色
  • 横向/纵向布局可选

技术选型

为了实现这个方案,我们的核心依赖如下:

依赖 版本 用途
jspdf latest 生成 PDF 文件
html2canvas latest 将 HTML 渲染为 Canvas 图像

选型理由

为什么选择 html2canvas + jsPDF?原因如下:

  1. 纯前端实现无需后端服务,保护数据隐私
  2. 样式可控通过 CSS 精确控制 PDF 外观
  3. 兼容性好支持现代浏览器
  4. 生态成熟社区活跃,文档完善

为什么不直接用 jsPDF 的表格 API?

  • jsPDF 的 autoTable 插件对复杂样式支持有限
  • 自定义样式(状态标签着色、交替行背景)实现困难
  • html2canvas 可以复用现有的 HTML/CSS 样式

实现架构

整体流程我这里设计如下:

表格数据
    ↓
生成 HTML(按页)
    ↓
html2canvas 渲染为 Canvas
    ↓
Canvas 转 PNG 图像
    ↓
jsPDF 写入 PDF(每页一张图)
    ↓
下载 PDF 文件

分页策略

关键问题:如何避免表格行在分页时被截断?

我的解决方案:按行预分页

  1. 估算每行高度(约 36px)
  2. 计算每页可容纳行数:rowsPerPage = floor((pageHeight - headerHeight) / rowHeight)
  3. 按行数切分数据,每页独立渲染
  4. 每页都包含表头,方便阅读
const estimateRowHeight36// 每行大约 36px
const headerHeight60// 表头高度
const pageContentHeightPx = Math.round(contentHeight / scale)
const rowsPerPage = Math.floor((pageContentHeightPx - headerHeight) / estimateRowHeight)

// 分页
for (let i0; i < records.length; i += rowsPerPage) {
const pageRecords = records.slice(i, i + rowsPerPage)
  pages.push(renderDataPage(pageRecords, i))
}

核心代码解析

1. 动态导入(SSR 兼容):

const [{ default: jsPDF }, { default: html2canvas }] = awaitPromise.all([
import("jspdf"),
import("html2canvas"),
])

原因jspdf 和 html2canvas 依赖浏览器 API(如 documentwindow),在 Next.js SSR 阶段会报错。使用动态导入确保只在客户端执行。

2. 页面尺寸计算:

const pageDimensions = {
a4: { width: 595, height: 842 },  // pt 单位
a3: { width: 842, height: 1191 },
}

const pdfWidth = orientation === "landscape"
  ? pageDimensions[pageSize].height
  : pageDimensions[pageSize].width

注意:jsPDF 使用 pt(点)作为单位,1pt = 1/72 英寸。

3. HTML 生成

数据页结构这里我预设如下

<divstyle="width:1122px;padding:32px;box-sizing:border-box;background:#fff">
<tablestyle="width:100%;border-collapse:collapse">
<thead><!-- 表头 --></thead>
<tbody><!-- 数据行 --></tbody>
</table>
</div>

关键样式

  • width:1122px固定 canvas 宽度(A4 横向像素)
  • border-collapse:collapse合并表格边框
  • white-space:nowrap防止文本换行

4. Canvas 渲染

const canvasawaithtml2canvas(element, {
scale2,              // 2倍缩放,提高清晰度
useCORStrue,         // 允许跨域图片
allowTainttrue,      // 允许污染 canvas
backgroundColor"#ffffff",
loggingfalse,
})

参数说明

参数 说明
scale: 2 2倍分辨率,PDF 更清晰
useCORS 处理跨域图片(如附件预览图)
allowTaint 允许 canvas 被污染(某些图片需要)

5. PDF 写入

const imgData = canvas.toDataURL("image/png"1.0)
const imgWidth = contentWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width

pdf.addImage(imgData, "PNG", margin, margin, imgWidth, imgHeight)

图像格式选择

  • PNG无损,清晰度高,适合文字
  • JPEG有损压缩,文件小,但不适合文字

样式处理技巧

状态标签着色这里我做了一层数据映射,方便精准还原样式:

constcolorMap: Record<stringstring> = {
"已完成""#dcfce7;color:#16a34a",
"进行中""#dbeafe;color:#2563eb",
"待开始""#fef3c7;color:#d97706",
"已停滞""#f3f4f6;color:#6b7280",
"重要紧急""#fee2e2;color:#dc2626",
}

交替行背景我采用的逻辑判断来动态渲染:

<tr style="background:${idx % 2 === 0 ? "#fff" : "#f8fafc"}">

如果文本出现截断换行,用canvas很难处理,这里我采用如下方案截断处理:

// 方案1:省略号截断(适合固定宽度列)
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">

// 方案2:完全显示(适合自动宽度列)
<spanstyle="white-space:nowrap">

当然还有很多细节的处理,这里就不一一介绍了。我们可以基于这个方案,继续扩展出如下场景:

  1. 水印支持添加企业 Logo 或水印
  2. 页码在页脚添加 "第 X 页 / 共 Y 页"
  3. 图表嵌入将图表大屏的图表嵌入 PDF
  4. 批量导出支持同时导出多个表格

今天就分享到这,后续我们还会持续迭代和更新,打造最强大的多维表格和文档协同系统。

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

如何实现一个「万能」的通用打印组件?

作者 codingWhat
2026年3月5日 12:22

在我们组开发的业务系统中,存在文书种类多、格式不一的场景,但又要求保持一致的打印体验,怎么办呢?难道每次加一种新文书就写一套打印逻辑?不存在的。用「配置 + 动态模板 + iframe 打印」的思路,可以搭出一套一个组件打天下的通用打印方案。


一、先想清楚:我们要解决什么问题?

  • 多种文书:不同业务对应不同的文书模板,字段、布局、样式都不一样。
  • 统一入口:希望小伙伴调用时只关心「打开打印、传文书类型和业务单号」,不用关心具体模板和接口。
  • 可编辑再打:部分文书需要在预览里编辑或填充后再打印,而不是纯静态展示。
  • 打印体验:要能控制打印样式(页眉页脚、分页、字体),并且不把整页 UI 一起打出去。

二、整体架构:三层拆解

可以把通用打印拆成三层,逻辑会非常清晰:

  1. 主组件:包含组件状态提示、调用 iframe 执行打印等功能;
  2. 配置层:文书类型与文书模版要一一对应;
  3. 模板层:每种文书一个 Vue 模板组件,负责展示、编辑字段,同时提供方法给壳层拿去保存/打印。

三、配置层:文书类型与模板的映射

用一份配置集中维护,后续扩展新文书主要就是:加一条配置 + 加一个模板组件。

export const DOC_TYPE = {
  FORM_A: 'FORM_A',  // 例如:某登记表
  FORM_B: 'FORM_B',  // 例如:某告知书
  // ...
};

export const documentTemplates = {
  [DOC_TYPE.FORM_A]: {
    title: '某登记表',
  },
  [DOC_TYPE.FORM_B]: {
    title: '某告知书',
  },
};

export function getTemplateConfig(docType) {
  const config = documentTemplates[docType];
  if (!config) {
    console.warn(`未找到文书类型 ${docType} 的模板配置`);
    return null;
  }
  return config;
}

主组件里使用 getTemplateConfig(docType) 拿配置,这样「加新文书」对主组件来说就是多一个配置键和对应的模板组件啦。


四、壳层:动态组件 + 打印流程

主组件只认「当前 docType 对应哪个模板组件」,用 component :is 动态渲染,这样无需在壳里写一长串 if/else 或 v-if。

4.1 模板区域与动态组件

<!-- 打印区域:唯一 id 便于后面克隆到 iframe -->
<div id="commonPrintArea" class="print-area">
  <component
    :is="templateComponent"
    ref="templateRef"
    :data="printData"
    :numb="numb"
    :template-config="templateConfig"
  />
</div>
computed: {
  templateComponent() {
    const componentMap = {
      FORM_A: 'FormATemplate',
      FORM_B: 'FormBTemplate',
      // 新文书:加一行即可
    };
    return componentMap[this.docType] || null;
  },
},

printData 由你在 init/loadCommonData 里请求接口或直接使用外部传入的数据;templateConfig 来自 getTemplateConfig(this.docType)

4.2 从模板组件拿数据:约定 getData()

打印或保存前,主组件需要拿到当前模板里用户可能改过的内容,所以约定:每个模板组件暴露 getData()方法,返回要落库/打印的纯数据。

// 主组件 methods
getTemplateData() {
  const templateComponent = this.$refs.templateRef;
  if (!templateComponent || typeof templateComponent.getData !== 'function') {
    return null;
  }
  return templateComponent.getData();
},

async handlePrint() {
  const templateData = this.getTemplateData();
  if (!templateData) return;

  const saved = await this.savePrintRecord(templateData);
  if (!saved) return;

  this.executePrint();
  this.$emit('print-success', { docType: this.docType, numb: this.numb, printData: templateData });
}

这样无论是「先保存再打」还是「仅打印」,数据源都统一来自模板的 getData()


五、模板层:可编辑字段与 getData()

模板里会有大量「看起来像下划线填空」的格子,既要可编辑又要打印时样式干净,我们的做法是,用一个可编辑字段的子组件包一层,再在模板里用 v-model 绑定 editableData对象,最后 getData() 直接返回这个对象。

5.1 可编辑字段的子组件(EditableField组件)

用 HTML5的contenteditable属性做内联编辑,通过 v-model和父组件同步;输入法期间用 compositionstart/end 防抖。

<template>
  <span
    ref="editableElement"
    :class="['editable-field', customClass]"
    :contenteditable="editable"
    :data-placeholder="placeholder"
    @blur="handleBlur"
    @input="handleInput"
    @compositionstart="isComposing = true"
    @compositionend="isComposing = false; handleInput($event)"
  />
</template>

<script>
export default {
  name: 'EditableField',
  props: ['value', 'editable', 'placeholder', 'customClass', 'maxlength'],
  data() {
    return { isComposing: false, innerValue: '' };
  },
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        if (!this.isComposing && newVal !== this.innerValue) {
          this.innerValue = newVal || '';
          if (this.$refs.editableElement) this.$refs.editableElement.innerText = this.innerValue;
        }
      },
    },
  },
  methods: {
    handleBlur(e) {
      const text = e.target.innerText.trim();
      this.innerValue = text;
      this.$emit('input', text);
    },
    handleInput(e) {
      if (this.isComposing) return;
      let text = e.target.innerText;
      if (this.maxlength && text.length > this.maxlength) {
        text = text.substring(0, this.maxlength);
        this.$refs.editableElement.innerText = text;
      }
      this.innerValue = text;
      this.$emit('input', text);
    },
  },
};
</script>

模板里用法示例:

<editable-field v-model="editableData.name" placeholder="请输入" custom-class="inline-underline-field" />

打印样式里对 .editable-field.inline-underline-field 等做「无边框、无背景、保下划线」的覆盖,即可做到「屏幕可编辑、纸上像填空」。


六、iframe 打印:只打「这一块」且样式可控

直接 window.print() 会连侧边栏、导航、按钮一起打。我们的做法是:把要打印的那块 DOM 克隆到隐藏的 iframe 里,在 iframe 里注入完整打印样式,再对 iframe 执行 print()

6.1 克隆 + 处理特殊节点(如复选框)

克隆时注意:像 Element UI 的 checkbox,在 iframe 里可能不会按「勾选状态」渲染,所以克隆后先把这类控件转成「勾选用 ☑ / 未勾选用 ☐」的纯文本,再塞进 iframe,这样打印出来稳定一致。

processCheckboxes(container) {
  container.querySelectorAll('.el-checkbox').forEach((el) => {
    const input = el.querySelector('input[type="checkbox"]');
    const isChecked = input && input.checked;
    const checkmark = document.createElement('span');
    checkmark.textContent = isChecked ? '☑' : '☐';
    // 若有 .el-checkbox__label,可把 label 文本和 checkmark 拼成新节点替换 el
    el.parentNode.replaceChild(checkmark, el);
  });
}

6.2 创建 iframe 并写入 HTML + 样式

executePrint() {
  const printArea = document.getElementById('commonPrintArea');
  if (!printArea) return;

  const cloned = printArea.cloneNode(true);
  this.processCheckboxes(cloned);

  const iframe = document.createElement('iframe');
  iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none';
  document.body.appendChild(iframe);

  const printStyles = this.getPrintStyles(); // 见下一小节

  const doc = iframe.contentWindow.document;
  doc.open();
  doc.write(`
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>${this.templateConfig.title}</title>
        <style>
          * { margin: 0; padding: 0; box-sizing: border-box; }
          body { font-family: "Microsoft YaHei", Arial, sans-serif; line-height: 1.5; color: #000; background: #fff; }
          ${printStyles}
        </style>
      </head>
      <body>${cloned.innerHTML}</body>
    </html>
  `);
  doc.close();

  iframe.onload = () => {
    iframe.contentWindow.focus();
    setTimeout(() => {
      iframe.contentWindow.print();
      setTimeout(() => document.body.removeChild(iframe), 500);
    }, 100);
  };
}

这样只有 iframe 里的 body 被打印,且样式完全由你注入的 printStyles 控制。


七、打印样式:基础 + 按文书类型扩展

拆成「基础样式(所有文书共用)」和「按 docType 的扩展样式」,主组件里根据 docType 拼成最终样式字符串。

getPrintStyles() {
  const baseStyles = `
    @page { margin: 0; size: A4; }
    body { margin: 10mm 10mm 15mm 10mm; font-family: "仿宋", serif; }
    .form-table { width: 100%; border-collapse: collapse; border: 2px solid #000; }
    .form-table th, .form-table td { border: 1px solid #000; padding: 6px 8px; }
    .form-table tr { page-break-inside: avoid; }
    .editable-field { border: none !important; background: transparent !important; box-shadow: none !important; }
    .inline-underline-field { border-bottom: 1px solid #333 !important; min-height: 1.2em; }
  `;
  const docTypeStyles = this.getDocTypeSpecificStyles(); // 从 styleMap[docType] 取
  return `${baseStyles}\n${docTypeStyles}`;
}

新增文书时,如需单独调表格列宽、标题字号等,在 getDocTypeSpecificStyles() 的 styleMap 里加一条即可,主组件逻辑不用改。


结尾:按这套思路实现后,业务侧只需要「传 docType + 外部数据) + 监听事件」,就能接住多种文书、可编辑、可保存的通用打印能力啦;后续加新文书也不会再在主组件里堆逻辑,维护成本也会低很多。

async/await和Promise的区别?

作者 光影少年
2026年3月5日 12:21

一、Promise 是什么

PromiseES6 提供的用于处理异步操作的对象

它有三种状态:

pending   (进行中)
fulfilled (成功)
rejected  (失败)

基本写法:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("成功")
  }, 1000)
})

promise
  .then(res => {
    console.log(res)
  })
  .catch(err => {
    console.log(err)
  })

特点:

  • 通过 .then() 处理成功
  • 通过 .catch() 处理错误
  • 可以 链式调用

二、async / await 是什么

async/awaitES2017 提出的异步写法,是基于 Promise 的语法糖。

特点:

  • async 声明函数为异步函数
  • await 等待 Promise 返回结果
  • 写起来像同步代码

例子:

async function getData() {
  const res = await fetch("/api/data")
  const data = await res.json()
  console.log(data)
}

三、核心区别

对比点 Promise async/await
写法 链式调用 同步风格
可读性 一般 更好
错误处理 .catch() try...catch
调试 不太友好 更像同步代码
本质 原生异步对象 Promise 的语法糖

四、代码对比

Promise 写法

function getUser() {
  return fetch("/user")
    .then(res => res.json())
    .then(data => {
      console.log(data)
      return fetch("/order")
    })
    .then(res => res.json())
    .then(order => {
      console.log(order)
    })
    .catch(err => {
      console.log(err)
    })
}

问题:

.then 链式调用太多
代码可读性差

async/await 写法

async function getUser() {
  try {
    const res = await fetch("/user")
    const user = await res.json()

    const res2 = await fetch("/order")
    const order = await res2.json()

    console.log(user, order)
  } catch (err) {
    console.log(err)
  }
}

优点:

逻辑清晰
更像同步代码
更容易维护

五、async/await 的本质

其实:

async function test() {
  return 1
}

等价于:

function test() {
  return Promise.resolve(1)
}

所以:

async函数一定返回Promise

六、await 的作用

await 只能等待 Promise 对象

例如:

const data = await fetch("/api")

等价于:

fetch("/api").then(res => ...)

七、async/await 的限制

1 必须在 async 函数里

错误写法:

const data = await fetch("/api")

正确:

async function getData() {
  const data = await fetch("/api")
}

2 默认是串行执行

const a = await getA()
const b = await getB()

执行顺序:

getA → 完成 → getB

有时候会变慢。


八、并发优化(重要)

如果两个请求 互不依赖

错误写法:

const a = await getA()
const b = await getB()

优化写法:

const [a, b] = await Promise.all([
  getA(),
  getB()
])

这样会 并发执行


九、什么时候用 Promise?

适合:

  • 并发请求
  • 多个异步任务组合
  • Promise.all
  • Promise.race

例如:

Promise.all([api1(), api2(), api3()])

十、什么时候用 async/await?

适合:

  • 顺序执行
  • 复杂逻辑
  • try/catch 错误处理
  • 提高代码可读性

例如:

async function init() {
  const user = await getUser()
  const order = await getOrder(user.id)
}

十一、面试标准回答

可以这样说:

Promise 是 ES6 提供的用于处理异步操作的对象,通过 then 和 catch 进行链式调用。
async/await 是 ES2017 提供的语法,是 Promise 的语法糖,可以让异步代码写起来像同步代码,提高可读性。
async 函数会返回一个 Promise,await 用来等待 Promise 的结果。
在实际开发中,如果是复杂逻辑或者顺序执行,一般使用 async/await;如果是多个异步任务并发执行,通常配合 Promise.all 使用。


十二、再给你一个高级面试点(很多人不会)

很多人不知道:

await 123

也是合法的。

因为 JS 会自动变成:

await Promise.resolve(123)

最后总结

一句话记住:

Promise → 异步机制
async/awaitPromise 的更优雅写法

别再无脑用 `JSON.parse()` 了!这个安全漏洞你可能每天都在触发

作者 前端Hardy
2026年3月5日 11:09

你以为只是解析个字符串?其实黑客已经在你服务器上跑脚本了!

在前端和 Node.js 开发中,JSON.parse() 几乎无处不在:

const data = JSON.parse(localStorage.getItem('user'));
const config = JSON.parse(req.body.payload);
const settings = JSON.parse(fs.readFileSync('config.json'));

简洁、直接、好用——但极其危险

如果你没有对输入做任何校验就调用 JSON.parse(),你正在为应用打开一扇“任意代码执行”的后门。

今天,我们就来揭开 JSON.parse() 背后的安全雷区,并告诉你如何用更安全、更现代的方式处理 JSON 数据。


危险场景一:原型污染(Prototype Pollution)

这是 JSON.parse() 最臭名昭著的安全漏洞之一。

虽然原生 JSON.parse() 本身不会执行代码,但它会忠实地还原对象结构——包括 __proto__constructor.prototype 这类特殊属性。

来看一个真实攻击载荷:

const userInput = '{"__proto__":{"isAdmin":true}}';
const obj = {};
JSON.parse(userInput, (key, value) => {
  obj[key] = value;
  return value;
});
console.log({}.isAdmin); // true!全局对象被污染!

如果这段代码出现在你的登录逻辑、权限校验或配置合并中,攻击者就能:

  • 绕过身份验证(isAdmin: true);
  • 注入恶意属性(如 exec: 'rm -rf /');
  • 篡改全局行为,导致服务崩溃或数据泄露。

尤其在使用 Lodash、merge、assign 等工具库时,风险更高!


危险场景二:拒绝服务(DoS)

恶意构造的 JSON 字符串可导致内存爆炸CPU 耗尽

// 深度嵌套攻击
const evil = '{"a":{"a":{"a":{"a":{"a":{"a": ... }}}}}}';

// 或超大数组
const evil2 = '[1,1,1,...,1]' // 1000 万个元素

调用 JSON.parse(evil) 可能:

  • 占用数 GB 内存;
  • 阻塞事件循环数秒;
  • 直接触发 OOM(Out of Memory)崩溃。

在 API 接口或 Webhook 处理中,这等于把“关机按钮”交给了攻击者。


正确姿势:安全解析 JSON 的三重防护

第一步:限制输入大小

在解析前先检查字符串长度:

function safeParse(str, maxSize = 1024 * 100) { // 100KB
  if (typeof str !== 'string' || str.length > maxSize) {
    throw new Error('Input too large');
  }
  return JSON.parse(str);
}

第二步:禁用危险键(如 __proto__

使用 reviver 函数过滤敏感属性:

function secureJSONParse(str) {
  return JSON.parse(str, (key, value) => {
    if (key === '__proto__' || key === 'constructor') {
      throw new Error('Disallowed key in JSON');
    }
    return value;
  });
}

第三步(推荐):用 Zod / Joi 做运行时校验

这才是现代 JS 工程的最佳实践!

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  isAdmin: z.boolean().optional(),
});

function parseUser(jsonStr: string) {
  const raw = secureJSONParse(jsonStr);
  return UserSchema.parse(raw); // 自动校验 + 类型推导
}

优势:

  • 类型安全(配合 TypeScript 完美);
  • 自动过滤多余字段
  • 明确拒绝非法结构
  • 防止原型污染、字段注入等攻击

特别提醒:Node.js 中的额外风险

在服务端,如果你从以下来源解析 JSON,风险更高:

  • HTTP 请求体(req.body
  • 文件读取(用户上传的 JSON 配置)
  • Redis / 数据库存储的序列化数据
  • 第三方 Webhook 回调

务必在解析前做来源校验 + 结构校验 + 大小限制三重保险!


结语

JSON.parse() 不是“坏 API”,但它是一把没有保险的枪
在现代 Web 开发中,信任任何用户输入 = 自毁程序

下次当你写下 JSON.parse(someString) 时,请自问:

“我确定这个字符串来自可信源吗?它的结构真的安全吗?”

如果答案不确定,请立即切换到 Zod / Joi + 安全解析函数 的组合。

转发给那个还在裸用 JSON.parse() 的队友吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再让 `console.log` 上线了!它正在悄悄拖垮你的生产系统

作者 前端Hardy
2026年3月5日 11:09

你以为只是“打个日志”?其实它在泄露数据、吃光内存、暴露源码!

在开发过程中,console.log() 是我们最亲密的伙伴:

function calculatePrice(items) {
  console.log('items:', items); // 调试用
  return items.reduce((sum, item) => sum + item.price, 0);
}

方便、直观、零成本——但一旦这段代码被部署到生产环境,隐患就开始蔓延

今天我们就来揭开 console.log 在生产环境中的三大“罪状”,并告诉你如何彻底杜绝它。


危害一:敏感信息泄露

这是最致命的问题。

你在本地调试时可能这样写:

console.log('User login:', { email, password });
console.log('DB connection string:', process.env.DB_URL);
console.log('Admin token:', req.headers.authorization);

如果这些日志随代码上线:

  • 用户密码、API 密钥、数据库地址 会直接打印到服务器控制台;
  • 如果你用了 PM2、Docker、K8s 或云平台(如阿里云、AWS),这些日志会被自动采集到日志系统;
  • 任何有日志权限的运维、实习生、外包人员都能看到!
  • 更糟的是,如果日志被错误地公开(比如 GitHub 泄露、ELK 未设权限),黑客将直接拿到“系统钥匙”。

真实案例:2023 年某电商因 console.log 泄露支付密钥,导致数万元盗刷。


危害二:性能损耗与内存泄漏

别小看一个 console.log,它在高并发下是“隐形杀手”。

1. 同步 I/O 阻塞

Node.js 中的 console.log 默认是同步写入 stdout 的(尤其在非 TTY 环境,如 Docker 容器)。
这意味着:每打一行日志,事件循环都会被短暂阻塞。

在 QPS 1000+ 的接口中,频繁 console.log 可能导致:

  • 响应延迟增加 10%~30%;
  • CPU 使用率异常飙升;
  • 请求排队甚至超时。

2. 大对象序列化开销

console.log('Full user object:', hugeUserData); // 包含头像 Buffer、历史订单等

console.log 会调用 .toString() 或内部序列化逻辑,若对象巨大(如图片 Buffer、长数组),会:

  • 消耗大量 CPU;
  • 生成超长字符串,占用堆内存;
  • 触发频繁 GC,甚至 OOM 崩溃。

危害三:暴露源码结构与业务逻辑

生产环境的日志往往会被集中管理(如 Sentry、Datadog、阿里云 SLS)。
如果你不小心把函数名、变量名、内部路径打出来:

console.log('Calling internal service: /v1/billing/calculate-discount');
console.log('Error in function: validatePromoCodeV2');

攻击者就能:

  • 推测你的 API 设计;
  • 发现未公开的内部接口;
  • 结合其他漏洞发起精准攻击(如 IDOR、越权)。

这等于主动给黑客画地图


正确姿势:用专业日志系统替代 console.log

第一步:开发阶段就禁用生产级日志输出

使用环境判断(但不推荐仅靠这个!):

if (process.env.NODE_ENV !== 'production') {
  console.log('Debug info:', data);
}

问题:容易遗漏,且无法防止“忘记删除”的日志。


第二步(强烈推荐):引入专业日志库

使用 WinstonBunyanPino 等结构化日志工具:

import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  transports: [
    new winston.transports.Console(),
    // 生产环境可加文件、Sentry、阿里云 SLS 等
  ],
});

// 安全地记录
logger.debug('User data', { userId: user.id }); // 不会打印完整对象
logger.error('Payment failed', { orderId, reason });

优势:

  • 支持日志级别(debug/info/warn/error);
  • 自动过滤敏感字段(可通过 format 实现);
  • 异步/高性能输出;
  • 与监控系统无缝集成。

第三步:构建时自动清除 console.log

在打包阶段用工具彻底移除:

Webpack:

// webpack.config.js
optimization: {
  minimizer: [
    new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true, // 删除所有 console.*
        },
      },
    }),
  ],
}

Vite / Rollup:

使用插件如 rollup-plugin-stripvite-plugin-remove-console

ESLint(预防):

配置规则禁止提交 console

{
  "rules": {
    "no-console": "warn"
  }
}

配合 Git Hooks(如 husky + lint-staged),提交前自动检查。


终极建议:建立“日志规范”

  • 绝不在生产代码中使用 console.log
  • 所有日志必须通过统一 logger 实例输出
  • 敏感字段(密码、token、身份证)必须脱敏
  • 日志内容需经过安全审计

结语

console.log 是开发的好帮手,但它是生产环境的毒药
一次疏忽,可能导致数据泄露、服务崩溃、甚至法律风险。

记住:

真正的专业,不是能写出功能,而是能守住底线。

从今天起,让 console.log 止步于你的本地开发机。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

我的新同事是个AI:支持skill后,它用TinyVue搭项目还挺溜!

2026年3月5日 10:52

本文由体验技术团队Kagol原创。

一个月前,有用户建议 TinyVue 出几个 Skills,方便 AI 编程。

1.png

必须安排上!

目前 TinyVue 组件库和 TinyRobot AI 对话组件均已支持 Agent Skills,你可以在支持 Skills 的 IDE(比如 VSCode、Cursor、Trae 等) 上配置和使用。

1 演示视频

先看下使用效果(以 Trae 为例)。

TinyVue Skills:让 AI 使用 TinyVue 组件生成前端页面:www.bilibili.com/video/BV1d6…

以 Trae 为例,给大家介绍如何安装和配置 TinyVue Skills。

2 安装 TinyVue Skills

在命令行终端中执行以下命令:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent trae

2.png

安装方式选择 Symlink (Recommended)

安装成功!

3.png

查看 Skills 是否安装成功:

npx skills list -g

4.png

3 开启 TinyVue Skills

打开 Trae 的设置页面,在左侧的【规则和技能】菜单中找到【技能】,开启【tiny-vue-skill】这个技能即可。

5.png

4 在 AI 对话框中使用 TinyVue Skills

在 Trae 中打开 AI 侧栏,输入以下内容:

使用TinyVue组件创建一个登录组件,并集成到App.vue中

AI 会去调用 tiny-vue-skill 技能,根据其中的 SKILL.md 中的描述,去查看对应的组件 API/Demo 文档,然后使用适当的 TinyVue 组件搭建你需要的页面。

这样比 AI 去海量互联网信息中寻找 TinyVue 的用法要准确得多,而且消耗更少的 Token,也不容易产生幻觉。

6.png

如果你正在使用 TinyVue 组件库,强烈推荐你配置上 tiny-vue-skill,让 AI 辅助编码,效率更高!

如果你用的是 VSCode Copilot、Cursor 等其他 IDE也没关系,安装 TinyVue Skills 遵循类似的步骤,只需要把命令中的 --agent 修改成对应的 IDE 即可,以下是对应表格。

比如在 Cursor 中安装 tiny-vue-skill:

npx skills add opentiny/agent-skills -g --skill tiny-vue-skill --agent cursor
Agent --agent 项目内路径 全局路径
Amp amp .agents/skills/ ~/.config/agents/skills/
Antigravity antigravity .agent/skills/ ~/.gemini/antigravity/skills/
Claude Code claude-code .claude/skills/ ~/.claude/skills/
Clawdbot clawdbot skills/ ~/.clawdbot/skills/
Codex codex .codex/skills/ ~/.codex/skills/
Cursor cursor .cursor/skills/ ~/.cursor/skills/
Droid droid .factory/skills/ ~/.factory/skills/
Gemini CLI gemini-cli .gemini/skills/ ~/.gemini/skills/
GitHub Copilot github-copilot .github/skills/ ~/.copilot/skills/
Goose goose .goose/skills/ ~/.config/goose/skills/
Kilo Code kilo .kilocode/skills/ ~/.kilocode/skills/
Kiro CLI kiro-cli .kiro/skills/ ~/.kiro/skills/
OpenCode opencode .opencode/skills/ ~/.config/opencode/skills/
Roo Code roo .roo/skills/ ~/.roo/skills/
Trae trae .trae/skills/ ~/.trae/skills/
Windsurf windsurf .windsurf/skills/ ~/.codeium/windsurf/skills/

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue skill源码:github.com/opentiny/ag… (欢迎 Star ⭐)

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

从入门到精通:Vue3 ref vs reactive 最佳实践与底层原理

作者 QLuckyStar
2026年3月5日 10:50

在 Vue 3 中,ref 和 reactive 是 Composition API 提供的两个核心响应式 API,用于创建响应式状态。它们都基于 JavaScript 的 Proxy(reactive)和 getter/setter(ref 的内部机制)来实现响应式追踪,但在使用场景和行为上有一些关键区别。


1. ref

用途

  • 用于定义基本数据类型(如 stringnumberboolean)的响应式数据。
  • 也可以用于定义对象或数组,此时内部会自动调用 reactive
  • 在模板中使用时,无需 .value,Vue 会自动解包。
  • 在 JavaScript 中访问或修改时,必须通过 .value

示例

import { ref } from 'vue'

const count = ref(0)
console.log(count.value) // 0

count.value++
<template>
  <p>{{ count }}</p> <!-- 自动解包,无需 .value -->
  <button @click="count++">增加</button>
</template>

特点

  • 适用于任何类型。
  • 对于对象/数组,ref 内部会调用 reactive
  • 可以通过 ref() 创建对对象的响应式引用,保留其引用身份(identity)。

2. reactive

用途

  • 专门用于定义对象或数组类型的响应式数据。
  • 不能用于基本数据类型(如 numberstring)。
  • 返回的是一个代理对象(Proxy),直接操作其属性即可,不需要 .value

示例

import { reactive } from 'vue'

const state = reactive({
  count: 0,
  user: { name: 'Alice' }
})

state.count++
state.user.name = 'Bob'
<template>
  <p>{{ state.count }}</p>
  <p>{{ state.user.name }}</p>
</template>

特点

  • 只能用于对象/数组。

  • 返回的是原始对象的代理,无法替换整个对象(否则失去响应性)。

    // ❌ 错误做法:直接赋值新对象会丢失响应性
    state = { count: 1 } 
    
    // ✅ 正确做法:修改属性
    state.count = 1
    

对比总结

特性 ref reactive
支持类型 任意类型(基本类型 + 对象/数组) 仅对象或数组
访问方式(JS 中) 需 .value 直接访问属性
模板中使用 自动解包,无需 .value 直接访问
替换整个对象 可以(重新赋值 .value 不可以(会丢失响应性)
内部实现 基本类型用 getter/setter;对象用 reactive 基于 Proxy
适用场景 简单值、需要替换整个对象的情况 复杂对象状态管理

最佳实践建议

  • 优先使用 ref:尤其在 TypeScript 项目中,ref 的类型推断更直观,且统一使用 .value 有助于代码一致性。
  • 当你有一个复杂的对象状态,并且不需要替换整个对象时,可以使用 reactive
  • 避免混用导致困惑。例如,不要在一个 reactive 对象中嵌套 ref 除非必要(虽然 Vue 会自动解包,但可能影响可读性)。

补充:toRefs 和 toRef

当你从 reactive 对象中解构属性时,会丢失响应性。此时可使用 toRefs 或 toRef

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0 })
const { count } = toRefs(state) // count 是一个 ref

count.value++ // 仍然响应式

TypeScript在React项目中的实战应用指南

2026年3月5日 10:50

在前端工程化日益成熟的今天,TypeScript(以下简称TS)凭借静态类型检查的优势,已成为React项目开发的标配。本文结合实际项目讨论经验,从组件类型约束、React钩子应用等维度,拆解TS在React项目中的落地技巧,帮助开发者写出更严谨、易维护的代码。

TSX文件与组件类型约束:让传参更可控

React项目中.js文件可无缝转为.tsx文件,核心差异在于类型声明——通过类型约束解决组件传参混乱、类型不明确的问题。

基础类型声明

TS的类型声明可覆盖变量、函数、类等场景,核心目的是「让类型可追溯」。比如组件传参时,若子组件未声明接收的属性类型,TS会直接报错,避免运行时因参数类型错误导致的bug。

组件Props类型约束示例

以React函数组件为例,通过interface声明Props类型,明确组件可接收的属性:


import React from 'react';

// 声明组件接收的属性类型
interface AaaProps {
  name: string; // 必传字符串
  age?: number; // 可选数字
  content: React.ReactNode; // 接收JSX/文本等内容
}

// 函数组件约束Props类型
const Aaa: React.FC<AaaProps> = (props) => {
  return <div>姓名:{props.name},内容:{props.content}</div>;
};

// 父组件使用:类型不匹配会直接报错
export default function App() {
  return <Aaa name="张三" content={<div>Hello TS</div>} />;
}

这种方式可灵活扩展/修改Props类型,减少团队协作中「传参格式不一致」的沟通成本。

React类型层级关系

声明JSX相关类型时,需理清三个核心类型的包含关系:


React.Node > React.Element > JSX.Element
  • JSX.Element:最窄的类型,仅包含JSX节点;

  • React.Element:包含所有React元素(如原生DOM元素、自定义组件);

  • React.ReactNode:最宽泛,包含Element、字符串、数字、null、undefined等。

日常开发中,描述接收JSX的参数时,使用React.ReactNodeReact.ReactElement即可满足大部分场景,无需过度细化。

React核心钩子的TS应用:精准约束类型

React的内置钩子(Hooks)结合TS后,能让状态、DOM操作、性能优化更可控,以下是高频钩子的类型用法。

useState:自动推导+手动声明

useState会根据初始值自动推导类型,也可手动声明类型适配复杂场景:


import React, { useState } from 'react';

export default function App() {
  // 自动推导:num为number类型,setNum为Dispatch<SetStateAction<number>>
  const [num, setNum] = useState(0);
  
  // 手动声明:初始值为undefined,指定num为number类型
  const [count, setCount] = useState<number>();
  
  return <div>num: {num}</div>;
}

useRef:DOM操作与变量缓存双场景

useRef有两大用途,TS需针对性声明类型:

场景1:操作DOM元素


import React, { useRef, useEffect } from 'react';

export default function App() {
  // 声明ref为HTMLInputElement类型,初始值null
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // 非空断言+DOM操作:自动提示input的方法(如focus)
    inputRef.current?.focus();
  }, []);
  
  return <input type="text" ref={inputRef} />;
}

场景2:缓存变量


import React, { useRef } from 'react';

export default function App() {
  // 声明ref缓存对象类型
  const dataRef = useRef<{ num: number }>(null);
  dataRef.current = { num: 100 }; // 类型匹配才允许赋值
  
  return <div>App</div>;
}

useReducer:复杂状态的类型约束

useReducer用于管理复杂状态,需通过interface声明stateaction类型,确保reducer函数的入参/返回值类型一致:


import React, { useReducer } from 'react';

// 声明state类型
interface Data {
  result: number;
}

// 声明action类型
interface Action {
  type: string;
  num: number;
}

// reducer函数:约束入参和返回值类型
function reducer(state: Data, action: Action) {
  switch (action.type) {
    case 'add':
      return { result: state.result + action.num };
    case 'minus':
      return { result: state.result - action.num };
    default:
      return { result: 0 }; // 兜底避免返回值类型不明确
  }
}

export default function App() {
  // useReducer类型自动推导:state为Data类型,dispatch匹配Action类型
  const [res, dispatch] = useReducer(reducer, { result: 0 });
  
  return (
    <div>
      <button onClick={() => dispatch({ type: 'add', num: 2 })}>加</button>
      <button onClick={() => dispatch({ type: 'minus', num: 1 })}>减</button>
      <div>结果:{res.result}</div>
    </div>
  );
}

useCallback & useMemo:性能优化+类型缓存

这两个钩子用于性能优化,核心是「缓存函数/值」,TS无需额外声明类型(自动推导),重点关注依赖项:


import React, { useMemo, useCallback, memo } from 'react';

export default function App() {
  const [res, dispatch] = useReducer(reducer, { result: 0 });
  
  // useMemo:缓存值,仅依赖项变化时重新计算
  const count = useMemo(() => {
    return res.result * 10;
  }, [res.result]); // 依赖res.result,避免无效计算
  
  // useCallback:缓存函数,避免组件更新时函数重创建
  const handleClick = useCallback(() => {
    console.log('缓存的函数');
  }, []); // 空依赖:组件更新时函数不重新创建
  
  // 结合memo优化子组件:只有props变化时重新渲染
  const Child = memo((props: { cb: () => void }) => {
    return <button onClick={props.cb}>点击</button>;
  });
  
  return <Child cb={handleClick} />;
}

useEffect/useLayoutEffect:无需额外类型标注

这两个钩子的回调函数返回值(清理函数)或入参均无需手动声明类型,TS会根据回调函数自动推导。

子组件与父组件的ref传递:解决DOM穿透问题

若父组件想获取子组件内部的DOM元素(如input),直接传递ref会报错,需通过React.forwardRef实现ref转发,并声明正确的类型:


import React, { useRef, useEffect, forwardRef } from 'react';

// 声明子组件:ForwardRefRenderFunction<HTMLInputElement> 约束ref类型
const Child: React.ForwardRefRenderFunction<HTMLInputElement> = (props, ref) => {
  // 将ref转发给内部input
  return <input type="text" ref={ref} />;
};

// 包装子组件,实现ref转发
const WrapChild = forwardRef(Child);

export default function App() {
  // 声明ref为input元素类型
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    // 父组件可直接操作子组件的input DOM
    inputRef.current?.focus();
  }, []);
  
  return <WrapChild ref={inputRef} />;
}

总结

TS在React项目中的核心价值是「提前暴露类型问题」,从组件到钩子的类型约束,本质是让「模糊的逻辑」变得「可预期」。实际开发中:

  1. 组件Props使用interface约束,减少传参问题;

  2. 钩子结合TS类型推导,无需过度声明(如useState自动推导);

  3. ref转发、useReducer等复杂场景,精准声明类型即可。

通过TS的类型约束,React项目的可维护性、协作效率会大幅提升,这也是前端工程化的核心趋势。希望本文的实战技巧能帮助你在项目中更好地落地TS+React!

大屏天气展示太普通?视觉升级!用 Canvas 做动态天气遮罩,雷阵雨效果直接封神

作者 李剑一
2026年3月5日 10:37

之前做天气那个模块的时候,突发奇想想做一个大屏实时展示天气状况的蒙版。# Vue实现大屏获取当前所处城市及当地天气(纯免费)

需求

现在大屏上展示天气一般都是在左上/右上做天气的图标/纯文字的展示,虽然看起来非常直观,但是对于大屏这种需要炫酷效果的产品显得不合适。

目前市面上对于天气这一块也并不是非常重视,我接触的大屏项目/产品对这部分基本都没啥要求。

但是能够展示天气效果对于大屏本身有相当不错的加成效果。

屏幕录制 2026-03-05 102909.gif

所以我开发了这个大屏天气展示蒙版组件,能够根据当前天气状况以蒙版的形式展示出来,目前支持多种天气的展示效果。

方案

视频方案

一开始考虑的是纯视频解决方案,首先说这个方案非常的简单,将视频以背景图的形式放在蒙版上,通过 pointer-events: none; 鼠标穿透就能算是完成了。

但是实际操作过程中发现问题比较多,首先是透明背景需要特定格式的视频才能够支持。

必须使用支持 Alpha 通道(透明通道)的视频格式‌。

常见支持透明背景的格式包括:

  • ‌WebM(VP8 或 VP9 编码 + Alpha 通道)‌:Chrome、Firefox 和 WebView2 等 Chromium 内核环境支持良好 。
  • ‌MOV(Apple ProRes 4444 编码)‌:支持 Alpha 通道,但主要在 macOS 和专业软件(如 Final Cut Pro、After Effects)中使用 ‌。
  • ‌MP4(H.265/HEVC 编码)‌:部分平台(如 WebView2)支持含 Alpha 通道的 H.265 视频。

但是我在网上并没有找到相应格式的视频,自己录也弄得不好,所以放弃了。

另外以视频作为背景图在弱网环境下比较难加载,毕竟视频一般都要超过5M以上了。

但是如果有相应的视频,效果做出来绝对是最顶尖的。

GIF动图方案

和视频方案基本一致,唯一的区别是使用 GIF 动图作为背景图使用,效果也非常好。

问题点在于需要UI做一系列的动图效果,GIF 动图在加载上速度也不算太快,毕竟比较好的动图也不会太小。

还有一个问题在于如果屏幕大小发生变化,或者不是标准屏,可能存在图片拉伸/裁切等问题。

如果有UI协助,采用这个方案也非常不错。

Canvas渲染

采用 Canvas 渲染的方案实现这个是我最后的选择,原因有三:

  • Canvas性能开销不算太大,对低端设备相对比较友好
  • Canvas不依赖静态资源,弱网环境下不影响加载效果
  • Canvas能够根据屏幕大小达到自适应效果,避免特殊屏幕尺寸显示异常

采用 Canvas 粒子效果和渐变效果模拟阳光和雨滴、雪花等等状态,实现天气状态。

代码

初始化遮罩层

目前使用的是 Vue3 框架,因为是遮罩层所以采用 pointer-events: none; 鼠标穿透,避免影响大屏正常的操作。

<template>
    <canvas
        ref="weatherCanvas"
        class="weather-mask"
        :style="{ opacity: maskOpacity }"
    ></canvas>
</template>

<style scoped>
.weather-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none; /* 鼠标事件穿透 */
    z-index: 10; /* 确保在内容上方,可根据项目调整 */
}
</style>

这里需要注意,初始化画布的时候要记得设置一下width、height,让画布充满整个屏幕。

晴天效果

晴天效果采用光照渐变效果,在 Canvas 中绘制了一个从左上角到右下角线性渐变的效果,来模拟阳光照射的感觉。

同时增加部分光斑效果,模仿阳光投射在玻璃上的感觉。

// 创建从左上角到右下角的线性渐变(模拟阳光照射)
lightGradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);

// 绘制光斑效果
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 200, ${Math.random() * 0.1 + 0.05})`;
ctx.fill();

雨天效果

雨天采用粒子效果,实现细长的雨丝效果,这里没有做明显的区分,对于小雨、中雨、大雨。

其实想要区分也很简单,只要控制粒子的数量和速度即可。

ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(particle.x, particle.y + particle.height);
ctx.strokeStyle = particle.color.replace('OPACITY', particle.opacity);
ctx.lineWidth = particle.width;
ctx.stroke();

这里我进行了简单的封装,因为雨天、雪天、雾天等等大部分都用到了粒子效果,所以针对粒子的绘制部分进行了封装。

因为下雨是一个连续的绘制过程,所以动画部分做了简单的循环。

const animate = () => {
    updateParticles(props.weatherType);
    animationId = requestAnimationFrame(animate);
};

下雪效果

下雪本质上和下雨区别不大,唯一的区别是粒子的状态、运动速度和运动方向。

这里没有采用雪花造型的粒子,确实做出来了,但是效果并不好,不如这种圆形的效果看起来好一些。

雪花的绘制和雨滴的绘制区别在于,雨滴的宽度是1,而雪花的大小是一定范围内随机的。

ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
ctx.fillStyle = particle.color.replace('OPACITY', particle.opacity);
ctx.fill();

雷阵雨效果

雷阵雨效果是这里面个人觉得做的最好的一个,通过对下雨效果增加随机雷电闪烁屏幕的效果,达到雷阵雨天气的遮罩。

下雨仍然是复用的。

// 绘制主闪电路径
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = `rgba(255, 255, 255, ${thunderAlpha})`;
ctx.lineWidth = Math.random() * 8 + 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(startX, startY);

// 绘制闪电分支
ctx.lineWidth = Math.random() * 4 + 1;
ctx.beginPath();
ctx.moveTo(branch.startX, branch.startY);
let bx = branch.startX;
let by = branch.startY;
const dx = Math.random() * 30 - 15;
const dy = Math.random() * 20 + 5;
ctx.lineTo(bx + dx, by + dy);
bx += dx;
by += dy;
ctx.stroke();

// 闪烁效果
ctx.fillStyle = `rgba(255, 255, 255, ${thunderAlpha * 0.1})`;

总结

这个遮罩我做了好几天,Canvas部分我也不是特别的熟悉,所以很多地方仍然有非常大的优化空间,有感兴趣的朋友可以移步下面的文章获取源代码。

# 动态天气实时渲染动态生成组件,附源代码及详细注释

至于为啥收费,我也是想尝试一下代码还能不能搞到钱,毕竟现在的软件行业白嫖是大家的常态。

如果您能支持1元钱那我不胜感激,如果确实认为不值,自己能够写出更好的,那我也祝福。

一共做个几个效果:晴、雨、雪、雾、雷阵雨、多云、沙尘、阴天。有兴趣的朋友可以运行起来自行查看一下。

async 函数返回的 Promise 状态何时变为 resolved

2026年3月5日 10:29

今天我们来聊一个前端开发中非常基础,但又常常让人感到困惑的问题:async function 返回的 Promise,到底在什么时候会变成 resolved 状态?

核心规则:一句话概括

我们先说结论。记住这句话:

一个 async 函数返回的 Promise,会在函数体内部所有代码执行完毕后,变为 resolved 状态。它的 resolve 值,就是函数 return 语句的值。

听起来很简单,对吧?但魔鬼藏在细节里。我们通过几个例子,一层层剥开来看。

没有 await 的 async 函数

我们从最简单的开始。

async function basicFunc() {
  console.log('Step 1');
  return 'Hello, World!';
}

const promise = basicFunc();
console.log('Promise created:', promise);

promise.then(value => {
  console.log('Promise resolved with:', value);
});

console.log('End of script');

猜猜输出顺序是什么?

  1. Step 1
  2. Promise created: Promise {<pending>}
  3. End of script
  4. Promise resolved with: Hello, World!

关键点

  • • basicFunc 被调用时,同步执行函数体内的 console.log('Step 1')
  • • 然后,遇到 return 'Hello, World!'此时,async 函数会立即返回一个 Promise,并且这个 Promise 的状态会迅速变为 resolved,其值就是 'Hello, World!'
  • • 但是,Promise 的 .then() 回调属于微任务,它会被排入微任务队列,等待当前同步代码(即 console.log('End of script'))全部执行完毕后,才会被执行。

所以,即使没有 await,async 函数返回的 Promise 也会在函数体同步代码执行完毕的瞬间变为 resolved

核心场景:遇到 await 时会发生什么?

await 是 async 函数的灵魂。它的行为直接决定了 Promise 状态变化的时机。

async function funcWithAwait() {
  console.log('Function start');
  const result = await new Promise(resolve => {
    setTimeout(() => {
      console.log('Timer done');
      resolve('Data from timer');
    }, 1000);
  });
  console.log('After await:', result);
  return 'Final Result';
}

const promise = funcWithAwait();
promise.then(value => console.log('Promise resolved:', value));
console.log('Script end');

输出顺序:

  1. Function start
  2. Script end (大约1秒后...)
  3. Timer done
  4. After await: Data from timer
  5. Promise resolved: Final Result

核心结论

await 会暂停 async 函数的执行,但不会暂停外部 Promise 的状态变化。外部 Promise 会一直保持 pending,直到函数体真正执行到最后(遇到 return 或函数结尾),它才会被 resolve

几种特殊情况

1. 没有 return 语句

async 函数可以没有 return

async function noReturn() {
  console.log('Just do something');
  await Promise.resolve();
  // 没有 return 语句
}

noReturn().then(value => {
  console.log('Resolved with:', value); // 输出:Resolved with: undefined
});

规则:如果一个 async 函数没有 return 语句,那么它返回的 Promise 在函数执行完毕后,会以 undefined 作为值被 resolve

2. 抛出错误(Rejection)

如果 async 函数内部抛出错误,或者 await 了一个被 reject 的 Promise,情况就不同了。

async function throwError() {
  console.log('Start');
  throw new Error('Something went wrong!');
  // 或者:await Promise.reject(new Error('...'));
}

throwError()
  .then(value => console.log('Success:', value)) // 这行不会执行
  .catch(error => console.error('Failed:', error.message)); // 输出:Failed: Something went wrong!

规则只要 async 函数体内部(包括 await 表达式)发生了未被捕获的异常,它返回的 Promise 就会立即变为 rejected 状态。  函数后续的代码不会被执行。

3. 返回一个 Promise

如果 async 函数 return 了一个 Promise 对象呢?

async function returnPromise() {
  console.log('Inside async function');
  return new Promise(resolve => {
    setTimeout(() => resolve('Inner Promise Value'), 500);
  });
}

returnPromise().then(value => {
  console.log('Outer promise resolved with:', value); // 输出:Outer promise resolved with: Inner Promise Value
});

这里有一个非常重要的细节。你可能会认为流程是:

  1. async 函数执行完毕。
  2. 外部 Promise 被 resolve,其值是一个新的 Promise 对象。
  3. 外部 Promise 的 .then() 收到这个 Promise 对象。

但事实并非如此!JavaScript 会对 async 函数的返回值进行特殊处理

如果 async 函数的返回值是一个 Promise(我们称为“内部Promise”),那么外部 Promise 的状态将与这个内部 Promise “联动”。外部 Promise 会等待内部 Promise 敲定(settle),然后以相同的状态和值被敲定。

所以上面的例子中:

  • returnPromise 返回的外部 Promise 会等待 setTimeout 500ms。
  • 500ms后,内部 Promise 被 resolve('Inner Promise Value')
  • 紧接着,外部 Promise 也被 resolve('Inner Promise Value'),而不是一个 Promise 对象。 这个特性让 async 函数可以无缝地组合。

对比表格:清晰理解状态变化时机

场景 async 函数返回的 Promise 状态变化时机 resolve 值
普通返回 return value; 函数体同步代码执行到 return 语句时 value
无 return 语句 函数体所有代码执行完毕时 undefined
遇到 await await 的 Promise 解决后,函数体继续执行,直到 return 或结束 return 的值(或 undefined
内部抛出错误 throw error; 错误被抛出的瞬间 变为 rejected,值为 error
await 被拒绝的 Promise await Promise.reject(...) await 表达式得到拒绝结果的瞬间 变为 rejected,值为拒绝原因
返回一个 Promise return somePromise; 等待 somePromise 敲定后,立即以相同状态和值敲定 somePromise 的解决值

希望这篇文章帮你理清了 async 函数与 Promise 状态变化之间的关系。下次当你使用 async/await 时,可以更自信地预判代码的执行流了。

bun+hono实现websocket长链接通许的demo

作者 1024小神
2026年3月5日 10:15

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

hono是一个轻量级的后端框架,支持cf和服务器部署,也支持bun结合,官方文档:hono.dev/docs/helper…

根据官网文档可以创建一个 upgradeWebSocket 的服务:

作为一个子路由,然后添加到bun中:

然后启动服务:

import { websocket } from 'hono/bun'

// export as a serverless function
export default {
    port: 3000,
    fetch: app.fetch,
    websocket,
}

速通Canvas指北🦮——图形、文本与样式篇

2026年3月5日 10:15

引言

本文缘起自笔者开发一个基于 PIXI.js 的在线动画编辑器时,想系统学习 Canvas 相关知识,却发现缺少合适的中文入门资料,于是萌生了撰写这份“速通指北”的想法,欢迎感兴趣的朋友订阅我的 《Canvas 指北》专栏。

第7章:曲线

在进入具体的曲线绘制方法之前,我们先理清一个关键概念:弧线(Arc)曲线(Curve) 在 Canvas 中并不是同一回事。

  • 弧线(上一章的主角)是圆的一部分。它由圆心、半径、起始角和结束角定义,具有固定的曲率(半径恒定)。你可以把它想象成用圆规画出来的一段圆弧——它的弯曲程度是均匀的。

  • 曲线(本章的主角,即贝塞尔曲线)则不局限于圆。它通过起点、终点和一个或多个控制点来“拉伸”形状,可以产生从抛物线到 S 形、波浪形等千变万化的路径。它的曲率是连续变化的,没有固定的半径

在 Canvas 中,弧线使用 arc 和 arcTo 绘制,而曲线则使用 quadraticCurveTo(二次贝塞尔)和 bezierCurveTo(三次贝塞尔)。理解了这一点,我们就能更准确地选择工具来绘制想要的图形。

7.1 二次贝塞尔曲线:quadraticCurveTo

二次贝塞尔曲线由一个控制点定义,其数学本质是一段抛物线弧。虽然抛物线通常有对称轴,但在画布上,随着控制点位置的变化,抛物线弧可以朝向任意方向,看起来可能并不对称。

quadraticCurveTo(cpx, cpy, x, y)
  • (cpx, cpy):控制点坐标

  • (x, y):终点坐标

  • 起点 需通过 moveTo 事先指定。

下面的代码在同一个画布上绘制三条二次贝塞尔曲线,起点均为 (30,100),终点均为 (270,100),但控制点的高度不同。你可以清楚地看到控制点越靠上,曲线被“拉”得越高。

<canvas id="quadraticDemo" width="320" height="200" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('quadraticDemo');
  const ctx = canvas.getContext('2d');

  // 第一条:控制点在 (150, 30) —— 向上拉
  ctx.beginPath();
  ctx.moveTo(30, 100);
  ctx.quadraticCurveTo(150, 30, 270, 100);
  ctx.strokeStyle = 'red';
  ctx.lineWidth = 2;
  ctx.stroke();

  // 第二条:控制点在 (150, 100) —— 与起点终点同高,变成直线
  ctx.beginPath();
  ctx.moveTo(30, 100);
  ctx.quadraticCurveTo(150, 100, 270, 100);
  ctx.strokeStyle = 'green';
  ctx.stroke();

  // 第三条:控制点在 (150, 170) —— 向下拉
  ctx.beginPath();
  ctx.moveTo(30, 100);
  ctx.quadraticCurveTo(150, 170, 270, 100);
  ctx.strokeStyle = 'blue';
  ctx.stroke();
</script>

image.png

效果:红线上拱,绿线平直,蓝线下凹。控制点的 y 坐标直接决定了曲线的弯曲方向。

7.2 三次贝塞尔曲线:bezierCurveTo

三次贝塞尔曲线有两个控制点,能塑造出 S 形、波浪等更复杂的形状。

bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
  • (cp1x, cp1y):第一个控制点(影响曲线起始段的走向)

  • (cp2x, cp2y):第二个控制点(影响曲线结束段的走向)

  • (x, y) :终点坐标

  • 起点 同样由 moveTo 指定。

以下代码从 (50,100)(250,100) 绘制一条三次贝塞尔曲线。第一个控制点 (100,20) 将曲线拉向上方,第二个控制点 (200,180) 将曲线拉向下方,形成优美的 S 形。

<canvas id="bezierDemo" width="320" height="200" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('bezierDemo');
  const ctx = canvas.getContext('2d');

  ctx.beginPath();
  ctx.moveTo(50, 100);
  ctx.bezierCurveTo(100, 20, 200, 180, 250, 100);
  ctx.strokeStyle = 'purple';
  ctx.lineWidth = 3;
  ctx.stroke();
</script>

image.png

理论上通过贝塞尔曲线,你可以绘制出任何图形。如果你对控制点如何影响曲线仍感到抽象,强烈推荐 cubic-bezier.com/ 这个网站。

第8章:文本

Canvas 不仅可以绘制图形,还能直接渲染文本。本章将介绍如何控制文本的字体、位置、对齐方式,以及如何测量文本尺寸,为你在画布上添加文字标签、设计动态文本效果打下基础。

8.1 文本绘制基础

Canvas 提供了两种绘制文本的方法:

  • fillText(text, x, y, maxWidth):在指定位置绘制实心字符。

  • strokeText(text, x, y, maxWidth):在指定位置绘制空心(仅描边)字符。

xy 是文本绘制的起始坐标(具体位置受 textAligntextBaseline 影响,后文会讲)。可选的 maxWidth 参数用于限制文本的最大宽度。

<canvas id="textBasic" width="400" height="150" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('textBasic');
  const ctx = canvas.getContext('2d');

  // 填充文本
  ctx.font = '20px Arial';
  ctx.fillStyle = 'blue';
  ctx.fillText('实心文本', 50, 50);

  // 描边文本
  ctx.font = '20px Arial';
  ctx.strokeStyle = 'red';
  ctx.lineWidth = 1;
  ctx.strokeText('空心文本', 50, 100);
</script>

image.png

这里 font 属性与 CSS 的 font 简写语法一致,用于设置文本的字体大小、字体系列等。默认值为 "10px sans-serif"

8.2 文本布局

8.2.1 水平对齐:textAlign

textAlign 属性决定文本在水平方向上相对于绘图点的对齐方式。可选值:

  • start(默认):在从左到右的语言中左对齐,从右到左的语言中右对齐。

  • end:与 start 相反。

  • left:总是左对齐。

  • right:总是右对齐。

  • center:文本中心与绘图点对齐。

为了直观理解,我们可以绘制一个参考点,然后分别用不同对齐方式绘制文本,观察它们相对于该点的位置。

<canvas id="textAlignDemo" width="400" height="200" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('textAlignDemo');
  const ctx = canvas.getContext('2d');

  // 绘制参考竖线(x = 200)
  ctx.beginPath();
  ctx.strokeStyle = 'gray';
  ctx.setLineDash([5, 5]);
  ctx.moveTo(200, 0);
  ctx.lineTo(200, 200);
  ctx.stroke();
  ctx.setLineDash([]); // 恢复实线

  ctx.font = '16px Arial';
  ctx.fillStyle = 'black';
  ctx.textBaseline = 'middle'; // 让文本垂直居中,便于观察水平对齐

  // 在 x=200 处用不同 textAlign 绘制文本
  ctx.textAlign = 'start';
  ctx.fillText('start', 200, 30);

  ctx.textAlign = 'center';
  ctx.fillText('center', 200, 70);

  ctx.textAlign = 'end';
  ctx.fillText('end', 200, 110);

  ctx.textAlign = 'left';
  ctx.fillText('left', 200, 150);

  ctx.textAlign = 'right';
  ctx.fillText('right', 200, 190);
</script>

1.webp

8.2.2 垂直对齐:textBaseline

textBaseline 属性控制文本在垂直方向上相对于绘图点的对齐方式。可选值:

  • top:文本的顶部(em 方格的上沿)对齐到 y 坐标。

  • hanging:悬挂基线(某些印度字体使用),通常比 top 稍低。

  • middle:文本的中间对齐到 y 坐标。

  • alphabetic(默认):字母基线(拉丁字母的底部,如 a、x 的下沿)。

  • ideographic:表意文字基线(汉字、日文字符的底部),通常比 alphabetic 稍低。

  • bottom:文本的底部(em 方格的下沿)对齐到 y 坐标。

同样,我们可以绘制一条参考横线,观察不同基线相对于该线的位置。

<canvas id="baselineDemo" width="500" height="250" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('baselineDemo');
  const ctx = canvas.getContext('2d');

  // 绘制参考横线(y = 120)
  ctx.beginPath();
  ctx.strokeStyle = 'gray';
  ctx.setLineDash([5, 5]);
  ctx.moveTo(0, 120);
  ctx.lineTo(500, 120);
  ctx.stroke();
  ctx.setLineDash([]);

  ctx.font = '20px Arial';
  ctx.fillStyle = 'black';
  ctx.textAlign = 'left';

  // 绘制不同 baseline 的文本,y 坐标统一为 120
  ctx.textBaseline = 'top';
  ctx.fillText('top', 30, 120);

  ctx.textBaseline = 'hanging';
  ctx.fillText('hanging', 120, 120);

  ctx.textBaseline = 'middle';
  ctx.fillText('middle', 240, 120);

  ctx.textBaseline = 'alphabetic';
  ctx.fillText('alphabetic', 350, 120);

  ctx.textBaseline = 'ideographic';
  ctx.fillText('ideographic', 30, 200);

  ctx.textBaseline = 'bottom';
  ctx.fillText('bottom', 180, 200);
</script>

2.webp

8.2.3 文本方向:direction

direction 属性设置文本的方向,影响 startend 的对齐行为。可选值:

  • ltr:从左到右

  • rtl:从右到左

  • inherit(默认):继承 canvas 或文档的设置

通常用于多语言混合场景。下面简单示例:

ctx.direction = 'rtl';
ctx.textAlign = 'start';
ctx.fillText('نص عربي', 200, 50); // 阿拉伯语文本,从右向左显示

8.3 限制文本宽度:maxWidth 参数

fillTextstrokeText都支持可选的第四个参数 maxWidth。当文本的原始宽度超过 maxWidth 时,浏览器会水平压缩字体(而不是截断或换行)以适应指定宽度。

<canvas id="maxWidthDemo" width="400" height="150" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('maxWidthDemo');
  const ctx = canvas.getContext('2d');

  ctx.font = '24px Arial';
  ctx.fillStyle = 'blue';

  // 无限制
  ctx.fillText('无限制文本', 20, 40);

  // 限制宽度为 100px
  ctx.fillText('压缩文本', 20, 90, 100);
</script>

image.png

8.4 文本度量:measureText()

measureText(text) 方法返回一个 TextMetrics 对象,包含指定文本在当前字体下的尺寸信息。最常用的属性是 width(文本的宽度,单位像素)。

<canvas id="measureDemo" width="400" height="150" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('measureDemo');
  const ctx = canvas.getContext('2d');

  ctx.font = '24px Arial';
  const text = 'Hello Canvas';
  const metrics = ctx.measureText(text);

  // 在画布中央绘制文本
  const x = (canvas.width - metrics.width) / 2;
  const y = 80;

  ctx.fillStyle = 'black';
  ctx.fillText(text, x, y);

  // 绘制一个矩形框表示文本宽度
  ctx.strokeStyle = 'red';
  ctx.lineWidth = 1;
  ctx.strokeRect(x, y - 24, metrics.width, 24); // 粗略框选,基线为 alphabetic
</script>

image.png

第9章:图片

Canvas 不仅能绘制图形和文字,还能直接操作图片。本章将介绍如何在画布上绘制图片、用图片填充形状,以及如何通过裁剪实现有趣的视觉效果。 在 Canvas 中绘制图片,首先需要有一个图片源。通常我们使用 JavaScript 的 Image 对象来加载图片。

<canvas id="canvas1" width="400" height="300"></canvas>
<script>
  const canvas = document.getElementById('canvas1');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  img.src = 'https://via.placeholder.com/150'; // 示例图片
  img.onload = () => {
    ctx.drawImage(img, 50, 50);
  };
</script>

关键点: 一定要在图片的 load 事件之后调用 drawImage,否则画布上不会有任何内容。

最简单的 drawImage 用法是传入图片对象以及目标坐标 (x, y),图片会按照原始尺寸绘制。

image.png

9.2 drawImage 的三种形式

drawImage 方法有三种调用形式,可以满足不同的绘制需求。下面我们用一个完整的示例来对比展示这三种形式。

基础绘制:drawImage(img, x, y) —— 图片保持原始尺寸。

缩放绘制:drawImage(img, x, y, width, height) —— 图片缩放到指定尺寸。

裁剪+缩放绘制:drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) —— 从源图片中裁剪一块区域,再绘制到画布上并缩放。

示例:在同一个画布上,分别用三种方式绘制同一张图片:

<canvas id="compareCanvas" width="600" height="400" style="border:1px solid #ccc"></canvas>
<script>
  const canvas = document.getElementById('compareCanvas');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  img.src = 'https://picsum.photos/200'; // 一张 200x150 的示例图
  img.onload = () => {
    // 1. 基础绘制(原始尺寸)
    ctx.drawImage(img, 30, 30);
    ctx.fillStyle = 'black';
    ctx.font = '12px sans-serif';
    ctx.fillText('基础绘制 (原始尺寸)', 30, 20);

    // 2. 缩放绘制(宽120,高90)
    ctx.drawImage(img, 250, 30, 120, 90);
    ctx.fillText('缩放绘制 (120x90)', 250, 20);

    // 3. 裁剪+缩放绘制:从原图 (50,30) 位置裁剪 100x80 区域,绘制到 (400,30) 处并缩放到 150x120
    ctx.drawImage(img, 50, 30, 100, 80, 400, 30, 150, 120);
    ctx.fillText('裁剪+缩放', 400, 20);
  };
</script>

3.webp

9.3 用图片填充形状:createPattern

createPattern 方法可以基于图片创建一个平铺图案(类似于 CSS 的 background-repeat),然后将这个图案赋值给 fillStyle,之后绘制的所有形状(矩形、圆形、任意路径)都会用该图片平铺填充。

const pattern = ctx.createPattern(img, repetition);
  • repetition 取值:"repeat"(默认,双向平铺)、"repeat-x"(水平平铺)、"repeat-y"(垂直平铺)、"no-repeat"(不平铺)。
<canvas id="patternCanvas" width="400" height="200"></canvas>
<script>
  const canvas = document.getElementById('patternCanvas');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  img.src = 'https://picsum.photos/30/30'; 
  img.onload = () => {
    const pattern = ctx.createPattern(img, 'repeat');
    ctx.fillStyle = pattern;

    // 填充矩形
    ctx.fillRect(20, 20, 150, 100);

    // 填充圆形
    ctx.beginPath();
    ctx.arc(280, 80, 50, 0, Math.PI * 2);
    ctx.fill();
  };
</script>

4.webp

9.4 图片与文字结合:图案文字

同样的技巧也可以用在文字上:将图案赋值给 fillStyle,然后使用 fillText 绘制文字,文字内部就会显示图片平铺效果,形成纹理字。

<canvas id="textureTextCanvas" width="400" height="150"></canvas>
<script>
  const canvas = document.getElementById('textureTextCanvas');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  img.src = 'https://picsum.photos/100';
  img.onload = () => {
    const pattern = ctx.createPattern(img, 'repeat');
    ctx.fillStyle = pattern;
    ctx.font = 'bold 60px Arial';
    ctx.fillText('纹理', 50, 100);
  };
</script>

image.png

9.5 图片与形状结合:裁剪(clip)入门

clip()方法是 Canvas 中一个非常实用的功能:它基于当前路径设置一个裁剪区域,之后所有绘制的内容只显示在该区域内部。超出部分会被隐藏。

使用方法:

先用 beginPath 和绘图命令(如矩形、圆形、任意路径)定义一个路径。

调用 clip(),将当前路径设置为裁剪区域。

执行绘制(图片、图形、文字等),它们只会出现在裁剪区域内。

通常配合 save()restore() 使用,避免裁剪影响后续绘制。

示例:将图片裁剪成圆形头像

<canvas id="clipCanvas" width="200" height="200"></canvas>
<script>
  const canvas = document.getElementById('clipCanvas');
  const ctx = canvas.getContext('2d');
  const img = new Image();
  img.src = 'https://via.placeholder.com/200/ff9900/ffffff?text=头像';
  img.onload = () => {
    ctx.save(); // 保存当前状态(无裁剪)
    // 绘制圆形路径
    ctx.beginPath();
    ctx.arc(100, 100, 80, 0, Math.PI * 2);
    ctx.clip(); // 设置裁剪区域为圆形内部
    // 绘制图片,图片只会在圆形区域内显示
    ctx.drawImage(img, 20, 20, 160, 160);
    ctx.restore(); // 恢复状态,移除裁剪区域

    // 绘制一个边框参考
    ctx.strokeStyle = 'gray';
    ctx.beginPath();
    ctx.arc(100, 100, 80, 0, Math.PI * 2);
    ctx.stroke();
  };
</script>

5.webp

🚀下篇预告:高级操作与动画篇

在下一篇中,你将学到:

  • 坐标变换(平移、旋转、缩放)的灵活运用,轻松实现复杂的图形变换;
  • 像素级操作:读取、修改像素数据,打造自定义滤镜和图像特效;
  • 绘图状态的管理与保存,高效切换样式;
  • 动画循环的实现原理,结合性能优化技巧,打造流畅的交互式动画;
  • 更多实用技巧,助你独立开发完整的动画和图像应用。

学完本篇,你将真正掌握 Canvas 的高级玩法,开启创意编程的大门。

前端缓存踩坑实录:从版本号管理到自动化构建

作者 过小年
2026年3月5日 09:38

在实际开发中,你可能遇到过这样的场景:新版本刚上线,测试环境一切正常,但用户反馈页面出现了奇怪的问题——某个下拉框突然消失了,控制台报错显示变量未定义。你打开浏览器开发者工具,发现 HTML 文件是最新的,但 JS 文件还是旧版本。

这种问题的根源往往出在一个不起眼的地方:

<script src="app.js?version=1.0.0"></script>

开发时修改了 JS 文件,但忘记更新 HTML 中的版本号。新的 HTML 使用了 JS 中定义的新变量,但浏览器加载的却是旧的 JS 文件,因为 URL 没变,强缓存生效了。HTML 是新的,JS 是旧的,自然会出现各种诡异的问题。

更糟糕的是,这种问题在测试环境很难发现。开发者的浏览器缓存经常被清空,测试人员也习惯性地强制刷新,只有真实用户才会遇到。等问题暴露出来,影响面已经很大了。

问题的本质

这个事故暴露了手动管理版本号的几个致命缺陷:

  1. 人为失误不可避免。开发时需要记得同步更新多处版本号,一旦遗漏就会出问题
  2. 团队协作困难。多人开发时容易出现版本冲突,发布流程中版本号的维护变得复杂
  3. 无法精确控制。所有文件共用一个版本号,即使只改了一个文件,也要更新所有引用

这种方式在早期前端开发中很常见,那时候项目规模小,文件数量少,手动维护还能应付。但随着前端工程化的发展,这种做法已经跟不上时代了。

要理解为什么会出现缓存问题,我们需要先了解浏览器的缓存机制。

浏览器缓存的工作原理

浏览器缓存分为强缓存和协商缓存两种。

强缓存

强缓存通过 HTTP 响应头控制,主要涉及两个字段:

Cache-Control: max-age=31536000
Expires: Wed, 04 Mar 2026 15:36:35 GMT

Cache-Control 是 HTTP/1.1 的标准,max-age 指定了资源的缓存时间(秒)。在这个时间内,浏览器会直接从本地缓存读取,不会向服务器发起请求。

Expires 是 HTTP/1.0 的字段,指定一个绝对过期时间。由于依赖客户端时间,容易出现偏差,现在主要用于向下兼容。

当强缓存生效时,浏览器完全不会和服务器通信,这就是为什么更新了服务器文件,用户还是看到旧内容的原因。

协商缓存

协商缓存需要浏览器和服务器进行一次通信,通过对比资源是否变化来决定是否使用缓存。

服务器通过两种方式标识资源:

Last-Modified: Wed, 04 Mar 2026 15:36:35 GMT
ETag: "29322-09SpAhH3nXWd8KIVqB10hSSz66"

Last-Modified 记录文件最后修改时间,浏览器下次请求时会带上 If-Modified-Since 字段。如果文件没变,服务器返回 304 状态码,浏览器使用缓存;如果文件变了,返回 200 和新内容。

ETag 是文件的唯一标识,通常是内容的哈希值。浏览器下次请求时带上 If-None-Match 字段,服务器对比后决定返回 304 还是 200。

ETagLast-Modified 更精确,因为文件修改时间可能变了但内容没变,而 ETag 只关注内容本身。

现代化的解决方案

回到开头的问题,如何避免手动维护版本号的困境?答案是让构建工具自动生成文件指纹。

文件指纹的原理

现代前端构建工具(Webpack、Vite 等)可以根据文件内容生成哈希值,并将其添加到文件名中。当文件内容改变时,哈希值也会改变,浏览器会把它当作一个全新的文件去请求。

Webpack 提供了三种哈希模式:

hash

hash 是项目级别的哈希,整个项目中任何文件改变,所有文件的哈希值都会变。

module.exports = {
  output: {
    filename: '[name].[hash:8].js'
  }
}

这种方式的问题是,即使只改了一个文件,所有文件的缓存都会失效,用户需要重新下载所有资源,浪费带宽。

chunkhash

chunkhash 是入口文件级别的哈希,根据入口文件的依赖关系生成。同一个入口的文件共享相同的哈希值。

module.exports = {
  output: {
    filename: '[name].[chunkhash:8].js'
  }
}

这种方式更合理,只有相关的模块改变时,对应的哈希才会更新。但还有一个问题:如果 JS 文件引入了 CSS 文件,修改 JS 后,即使 CSS 没变,CSS 的哈希也会改变。

contenthash

contenthash 是文件内容级别的哈希,只有文件内容改变,哈希才会改变。

module.exports = {
  output: {
    filename: '[name].[chunkhash:8].js'
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash:8].css'
    })
  ]
}

这是最精确的方式。JS 用 chunkhash,CSS 用 contenthash,各自独立,互不影响。

实际配置示例

一个完整的 Webpack 配置可能是这样的:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: {
    app: './src/index.js',
    vendor: ['react', 'react-dom']
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'js/[name].[chunkhash:8].js',
    chunkFilename: 'js/[name].[chunkhash:8].js'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader']
      },
      {
        test: /\.(png|jpg|gif)$/,
        type: 'asset',
        generator: {
          filename: 'images/[name].[contenthash:8][ext]'
        }
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css'
    })
  ],
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'all'
        }
      }
    }
  }
}

这个配置做了几件事:

  1. JS 文件使用 chunkhash,确保只有相关模块改变时才更新
  2. CSS 文件使用 contenthash,只有内容改变才更新
  3. 图片等资源也使用 contenthash
  4. 将第三方库分离到 vendor 文件,这些库很少变化,可以长期缓存

打包后的文件名类似这样:

dist/
  js/
    app.a1b2c3d4.js
    vendor.e5f6g7h8.js
  css/
    app.i9j0k1l2.css
  images/
    logo.m3n4o5p6.png

HTML 文件的处理

有了文件指纹,还需要解决一个问题:HTML 文件如何引用这些带哈希的文件?

手动维护显然不现实,这时候需要 html-webpack-plugin

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html',
      inject: true
    })
  ]
}

这个插件会自动将打包后的文件注入到 HTML 中:

<!DOCTYPE html>
<html>
<head>
  <link href="css/app.i9j0k1l2.css" rel="stylesheet">
</head>
<body>
  <div id="app"></div>
  <script src="js/vendor.e5f6g7h8.js"></script>
  <script src="js/app.a1b2c3d4.js"></script>
</body>
</html>

但这又带来一个新问题:HTML 文件本身怎么办?如果 HTML 也被强缓存,用户还是会看到旧的引用。

解决方案是让 HTML 走协商缓存,在服务器配置中设置:

location ~ .*\.html$ {
  add_header Cache-Control 'no-cache';
}

no-cache 不是不缓存,而是每次都向服务器确认文件是否更新。如果没更新,返回 304,浏览器使用缓存;如果更新了,返回 200 和新内容。

这样就形成了一个完整的缓存策略:

  • HTML 文件:协商缓存,确保总是最新
  • JS/CSS/图片:强缓存 + 文件指纹,内容变化时自动更新

服务器端的配置

前端构建只是第一步,服务器端也需要配合配置缓存策略。

Nginx 配置示例

server {
  listen 80;
  server_name example.com;
  root /var/www/html;

  # HTML 文件走协商缓存
  location ~ .*\.html$ {
    add_header Cache-Control 'no-cache';
  }

  # 静态资源强缓存一年
  location ~ .*\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control 'public, immutable';
  }
}

expires 1y 会生成 Cache-Control: max-age=31536000,表示缓存一年。

immutable 是一个优化指令,告诉浏览器这个文件永远不会变,即使用户刷新页面也不需要重新验证。这对带哈希的文件特别有用。

最佳实践总结

经过这次生产事故,总结出以下最佳实践:

  1. 永远不要手动管理版本号,让构建工具自动生成文件指纹
  2. HTML 文件使用协商缓存(Cache-Control: no-cache
  3. JS/CSS 使用强缓存 + contenthash(Cache-Control: max-age=31536000, immutable
  4. 图片等资源也使用 contenthash
  5. 将第三方库分离打包,利用长期缓存

核心思想是:让该变的变,让不该变的不变。通过文件指纹,把缓存控制权从时间维度转移到了内容维度,这才是真正可靠的缓存策略。

TypeScript 核心知识点(覆盖 90% 开发场景)

作者 Giant100
2026年3月5日 09:00

TypeScript 核心知识点(覆盖 90% 开发场景)

一、基础语法(20%)—— 搞定类型标注的核心规则

1. 基础类型(必懂)

// 原始类型(TS 自动推断,也可手动标注)
const str: string = "hello";
const num: number = 100;
const bool: boolean = true;
const n: null = null;
const u: undefined = undefined;

// 数组(两种等价写法,推荐前者)
const arr1: string[] = ["a", "b"];
const arr2: Array<number> = [1, 2];

// 元组(固定长度+类型的数组)
const tuple: [string, number] = ["name", 18];

// 任意类型(尽量少用,兜底用 unknown 替代)
let anyVal: any = "任意值";
anyVal = 100; // 无类型校验

// 未知类型(安全的 any,需类型收窄才能用)
let unkVal: unknown = "未知值";
if (typeof unkVal === "string") {
  console.log(unkVal.length); // 类型收窄后可用
}

2. 接口 / 类型别名(核心,80% 场景用 interface)

// 接口(支持扩展、合并,推荐用于对象类型)
interface User {
  id: number;
  name: string;
  age?: number; // 可选属性
  readonly phone: string; // 只读属性
}

// 类型别名(支持联合/交叉类型,更灵活)
type Status = "success" | "error" | "loading"; // 联合类型
type UserWithStatus = User & { status: Status }; // 交叉类型

// 接口扩展
interface AdminUser extends User {
  role: string;
}

3. 函数类型(必懂)

// 函数参数+返回值标注
const add = (a: number, b: number): number => {
  return a + b;
};

// 可选参数(必须放最后)
const getUser = (id: number, name?: string): User => {
  return { id, name: name || "默认名", phone: "123456" };
};

// 函数类型别名(复用函数签名)
type Fn = (x: number) => number;
const double: Fn = (x) => x * 2;

二、核心场景(60%)—— 开发中高频用到的 TS 能力

1. 泛型(核心中的核心,解决类型复用)

// 泛型函数(适配任意类型,保留类型安全)
function wrap<T>(value: T): { data: T } {
  return { data: value };
}
const res1 = wrap<string>("hello"); // { data: string }
const res2 = wrap<number>(100); // { data: number }

// 泛型接口(适配不同结构的返回数据)
interface ApiRes<T> {
  code: number;
  data: T;
}
// 复用:用户列表接口
type UserListRes = ApiRes<User[]>;
// 复用:商品接口
type GoodsRes = ApiRes<{ id: number; price: number }>;

// 泛型约束(限制泛型范围)
function getLength<T extends { length: number }>(obj: T): number {
  return obj.length;
}
getLength("abc"); // 合法
getLength([1,2]); // 合法
// getLength(100); // 报错:数字没有length

2. 类型收窄(处理 unknown / 联合类型)

// 类型守卫(typeof)
function print(val: string | number) {
  if (typeof val === "string") {
    console.log(val.toUpperCase()); // 收窄为string
  } else {
    console.log(val.toFixed(2)); // 收窄为number
  }
}

// 类型守卫(instanceof)
class Animal {}
class Dog extends Animal {
  bark() {}
}
function fn(animal: Animal) {
  if (animal instanceof Dog) {
    animal.bark(); // 收窄为Dog
  }
}

// 类型守卫(in)
interface Cat {
  meow: () => void;
}
function pet(animal: Dog | Cat) {
  if ("bark" in animal) {
    animal.bark();
  } else {
    animal.meow();
  }
}

3. 工具类型(TS 内置,直接用)

不用记底层实现,记住「什么时候用」即可:

// Partial:把所有属性变成可选
type PartialUser = Partial<User>; // { id?: number; name?: string; ... }

// Required:把所有属性变成必选
type RequiredUser = Required<User>; // 去掉age的?

// Pick:挑选指定属性
type UserBase = Pick<User, "id" | "name">; // 只保留id和name

// Omit:排除指定属性
type UserWithoutPhone = Omit<User, "phone">; // 排除phone

// Record:快速定义键值对类型
type Dict = Record<string, number>; // { [key: string]: number }

4. 异步 / Promise 类型(前端请求必用)

// Promise 类型标注
const fetchData = async (): Promise<User> => {
  const res = await fetch("/api/user");
  return res.json();
};

// 调用异步函数
const useData = async () => {
  const user = await fetchData(); // user 是 User 类型
  console.log(user.name);
};

三、React 结合场景(10%)—— 前端开发专属

1. 组件 Props 类型

// 无 React.FC 写法(推荐)
interface ButtonProps {
  text: string;
  onClick?: () => void;
  children?: React.ReactNode;
}
const Button = ({ text, onClick }: ButtonProps) => {
  return <button onClick={onClick}>{text}</button>;
};

2. Hooks 类型标注

// useState(复杂类型手动标注)
const [user, setUser] = useState<User | null>(null);

// useRef(DOM/普通值区分)
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);

// 事件类型(React 内置)
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log(e.currentTarget);
};

四、避坑 & 实战技巧(核心原则)

1. 少用 any,多用 unknown / 类型收窄

any 会关闭 TS 校验,是「类型安全的敌人」,兜底优先用 unknown。

2. 类型定义和数据结构「完全匹配」

// 错误:数组类型和数据不匹配
interface Res {
  items: string[]; // 实际是对象数组
}
// 正确:
interface Res {
  items: { id: number; name: string }[];
}

3. 异步函数必标注 Promise 或让 TS 自动推断

// 推荐:自动推断(简洁)
const test = async () => {
  const res = await instance.get<ApiRes<User>>("/data");
  return res.data;
};
// 推荐:手动标注(清晰)
const test = async (): Promise<ApiRes<User>> => {
  const res = await instance.get<ApiRes<User>>("/data");
  return res.data;
};

总结(90% 场景核心要点)

  1. 基础层:掌握基础类型、接口 / 类型别名、函数类型标注,搞定 80% 的基础类型场景;
  2. 核心层:泛型(复用类型)、类型收窄(处理未知类型)、内置工具类型,搞定复杂场景;
  3. 实战层:Promise 异步类型、React 组件 / Hooks 类型,适配前端业务开发;
  4. 原则层:少用 any、类型和数据匹配、异步函数标注 Promise,保证类型安全。

这套知识点覆盖了前端开发中 90% 的 TS 场景,不用死记硬背,在项目中落地 2-3 个组件 / 接口请求,就能熟练掌握。遇到小众场景(如装饰器、高级类型),按需查文档即可。

❌
❌