普通视图

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

如何一次性生成 60 种语气表达?RWKV 模型告诉你答案 ❗❗❗

作者 Moment
2026年1月14日 15:00

在日常沟通中,我们经常需要根据不同的对象和场景调整语气。向老板汇报工作时需要正式严谨,和同事交流时可以轻松随意,写文案时又需要符合品牌调性。手动调整这些语气不仅耗时,还容易词穷。特别是在需要快速产出多种风格文案的场景下,比如社交媒体运营需要同时准备正式版、幽默版、情感版等多个版本,传统的逐个改写方式效率极低。

基于这样的痛点,我开发了这个 RWKV 并行语气转换工具。它能够接收一段文本,通过 RWKV 大语言模型,一次性并行生成 60 多种不同语气和风格的表达方式,涵盖职场、生活、方言、文学、网络等多个维度,极大提升了内容创作和沟通表达的效率。

项目效果图

从上图可以看到,工具的界面简洁直观。用户只需在底部输入框中输入原始文本,点击发送按钮,系统就会同时生成多种语气版本。每个卡片代表一种风格,包含风格图标、名称和转换后的内容。所有结果实时流式返回,用户可以立即看到生成进度,并且每个结果都支持一键复制,方便快速使用。

核心特性与技术实现

这个项目最大的特点是并行生成能力。传统的语气转换工具通常是串行处理,即逐个风格依次生成,这样会导致等待时间过长。而本工具通过在后端同时处理多个转换请求,前端采用流式渲染技术,实时展示每个风格的生成进度,整体响应速度大幅提升。

在前端技术选型上,项目采用了 React 19 作为 UI 框架,配合 Rsbuild 作为构建工具。相比传统的 WebpackViteRsbuild 提供了更快的构建速度和更简洁的配置体验。样式层面使用了 Tailwind CSS 4,通过精心设计的渐变色彩和流畅的动画效果,打造出现代化的视觉体验。整个界面采用浅色主题,柔和的紫粉渐变背景配合玻璃态效果,既美观又不影响内容的阅读。

项目完整的技术栈包括:React 19 提供强大的 UI 渲染能力,TypeScript 确保类型安全,Rsbuild 负责快速构建,Tailwind CSS 4 处理样式,Lucide React 提供图标支持,Class Variance Authority 管理组件变体。这套组合既保证了开发效率,也确保了运行时性能。

接口请求参数

从接口请求参数可以看到,后端接收的核心数据结构相对简单。contents 字段是一个数组,包含了所有需要转换的 prompt 内容。每个 prompt 都是一个完整的指令,包含了风格要求和用户输入的原始文本。系统会根据这些 prompt 并行调用 RWKV 模型进行生成,同时还支持多种参数调优,比如 temperaturetop_ktop_p 等,以获得更好的生成效果。

接口响应数据

响应数据采用了 Server-Sent EventsSSE)的流式传输方式。每个数据块都是一个 JSON 对象,包含了 choices 数组,其中每个 choice 对应一个风格的生成结果。通过 index 字段标识具体是哪个风格,delta 中的 content 字段则包含了本次推送的文本片段。前端接收到这些数据后,会实时更新对应卡片的内容,用户可以看到文字逐字生成的效果,体验非常流畅。

并发生成的核心实现

整个项目的精髓在于如何实现真正的并发生成。先看生成 contents 数组的逻辑:

function generateStyleContents(userInput: string): string[] {
  const configs = getMergedStyleConfigs();
  return configs.map((config) => {
    if (config.prompt.includes("${{input}}")) {
      return config.prompt.replace(/\$\{\{input\}\}/g, userInput);
    }
    return `${config.prompt}\n\nUser: ${userInput}\n\nAssistant: <think>\n</think>`;
  });
}

这个函数做的事情很简单:遍历所有风格配置,将每个风格的 prompt 模板中的 ${{input}} 占位符替换为用户的真实输入。generateStyleContents 函数会调用 getMergedStyleConfigs() 获取所有风格配置。假设用户输入"明天要开会",经过这个函数处理后,会得到一个包含 60 个完整 prompt 的数组。每个 prompt 都是独立的,包含了该风格的要求描述、约束条件,以及用户输入。

有了这个数组,接下来就是发送请求了。关键在于,我们把整个 contents 数组一次性发送给后端:

const response = await fetch(config.apiUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "*/*",
    "Accept-Language": "zh-CN,zh;q=0.9",
  },
  body: JSON.stringify({
    contents, // 这里是 60 个 prompt 的数组
    max_tokens: 100,
    temperature: 0.95,
    top_k: 50,
    top_p: 0.9,
    pad_zero: true,
    alpha_presence: 1.0,
    alpha_frequency: 1.0,
    alpha_decay: 0.996,
    chunk_size: 128,
    stream: true,
    password: config.password,
  }),
  signal,
});

注意看请求体中的 contents 字段,这就是我们刚才通过 generateStyleContents 函数生成的 60 个 prompt。后端收到这个数组后,会同时启动 60 个生成任务,每个任务对应数组中的一个 prompt。数组的索引位置(0, 1, 2, ..., 59)就是每个任务的 ID,这个 ID 会在返回的 index 字段中体现。

流式响应的解析机制

后端采用 Server-Sent EventsSSE)格式返回流式数据。每个数据块的格式大致是这样的:

data: {"object":"chat.completion.chunk","choices":[{"index":12,"delta":{"content":"明"}},{"index":23,"delta":{"content":"今"}},{"index":5,"delta":{"content":"后"}}]}

data: {"object":"chat.completion.chunk","choices":[{"index":12,"delta":{"content":"天"}},{"index":23,"delta":{"content":"天"}}]}

data: [DONE]

看到了吗?每个 choice 对象都有一个 index 字段。这个 index 就是对应 contents 数组中的位置。比如 index 为 12 的 choice,对应的就是 contents[12] 这个 prompt 的生成结果。前端正是靠这个 index,知道把返回的文本片段更新到哪个风格卡片上。

解析流式数据的代码使用了 fetch API 的流式读取能力:

const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
  const { done, value } = await reader.read();

  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() || "";

  for (const line of lines) {
    const trimmedLine = line.trim();
    if (!trimmedLine || !trimmedLine.startsWith("data: ")) continue;

    const data = trimmedLine.slice(6);
    if (data === "[DONE]") {
      // 所有任务完成
      const completedResults = initialResults.map((result) => ({
        ...result,
        isComplete: true,
      }));
      onUpdate(completedResults);
      continue;
    }

    try {
      const json = JSON.parse(data);
      if (json.choices && Array.isArray(json.choices)) {
        json.choices.forEach((choice: any) => {
          const index = choice.index;
          const deltaContent = choice.delta?.content || "";
          if (deltaContent && initialResults[index]) {
            // 根据 index 找到对应的结果对象,追加文本片段
            initialResults[index].content += deltaContent;
          }
        });
        onUpdate([...initialResults]);
      }
    } catch (e) {
      console.warn("解析 JSON 失败:", e);
    }
  }
}

这段代码的核心逻辑是:

  1. 使用 TextDecoder 逐块解码二进制流,通过 response.body?.getReader() 获取流读取器
  2. 按行分割数据,因为每行是一个完整的 SSE 消息
  3. 提取 "data: " 后面的 JSON 数据
  4. 解析出 choices 数组,遍历每个 choice
  5. 通过 choice.index 找到对应的结果对象,将 choice.delta.content 追加上去
  6. 调用 onUpdate 触发界面更新

这种增量更新的方式非常高效。不同风格的生成速度可能不一样,有的快有的慢,但每个风格的更新是完全独立的,互不干扰。用户可以实时看到每个卡片的内容逐字增加,体验非常流畅。

为什么这种方式能实现真并发

传统的做法是循环调用 API,每次生成一种风格,等这个风格生成完了再生成下一个。如果有 60 种风格,每个风格平均生成 2 秒,那总共需要 120 秒。这种串行的方式效率极低。

而我们这种方式,是把 60 个 prompt 打包成一个数组,一次性发送给后端。后端收到后,会并发地处理这 60 个任务。虽然每个任务还是需要 2 秒,但因为是并发执行,所以总耗时只有 2 秒多一点(加上一些网络延迟和任务调度开销)。

关键点在于:

  • contents 数组的长度决定了并发数量
  • 后端通过 index 标识每个任务的结果
  • 前端通过 index 将结果精确地更新到对应位置

这样就实现了真正的并行生成,效率提升了几十倍。

部署和使用

项目的部署很简单。如果你熟悉 Node.js,直接 npm install 安装依赖,npm run dev 启动开发服务器就能用。构建生产版本也就是一个 npm run build 的事。

如果你更喜欢用 Docker,项目也提供了完整的 Docker 支持。docker compose up --build -d 一条命令搞定,不用操心环境配置的问题。

API 配置

API 配置就两个参数:服务地址和密码。项目根目录下有个 .env 文件,里面写好了默认值。如果你有自己的 RWKV 后端服务,改一下这个文件就行,改完重启一下开发服务器。

PUBLIC_RWKV_API_URL=http://192.168.0.12:8000/v1/chat/completions
PUBLIC_RWKV_PASSWORD=rwkv7_7.2b_webgen

就这么简单。

60 种风格是怎么设计出来的

60 种风格不是拍脑袋想出来的,而是根据实际使用场景一点点积累起来的。最开始只有十几种,后来发现不够用,就不断补充。

职场类是最早做的一批。面向老板、面向客户、面向同事,这三个场景的语气差异非常大。跟老板汇报工作,得用"敬请指示"、"恭候佳音"这种正式表达。跟客户沟通,得强调"为您服务"、"满足您的需求"。跟同事交流,就可以"咱们商量一下"、"一起搞定"。

文学类是后来加的。有用户反馈说想要古风文案,于是就做了红楼梦、三国演义、水浒传这些经典名著的风格。还有诗词歌赋、文言文这些。效果还不错,生成出来的内容确实有那个味道。

方言类比较有意思。东北话、四川话、广东话、上海话,每种方言都有自己的特色词汇。东北话喜欢说"咋整"、"贼拉",四川话爱用"哦豁"、"巴适",广东话常说"饮茶"、"搞掂"。这些方言风格在做地方性推广时特别有用,能快速拉近和用户的距离。

网络用语风格是必须有的。现在的年轻人说话都是"yyds"、"绝绝子"、"EMO 了"这些梗。如果做社交媒体运营,不用这些网络语言,内容就会显得很生硬。所以专门做了几个网络用语风格,紧跟最新的流行趋势。

除了这些大类,还有一些更细分的场景风格。比如道歉、感谢、邀请、拒绝、催促等。这些在日常沟通中经常用到,但很多人不知道怎么表达得既礼貌又不失分寸。有了这些风格,直接套用就行。

实时看到生成进度

因为是流式响应,所以你可以实时看到每个风格的生成进度。不同风格的生成速度可能不一样,有的快有的慢,但每个都是独立更新的,互不影响。

这种体验比传统的"转圈等待"好太多了。你能看到文字一个个蹦出来,知道 AI 确实在工作,而不是卡住了。而且因为是并发的,所以很多风格会同时在生成,界面上到处都在更新内容,看起来特别有动感。

每个卡片右上角有个复制按钮,点一下就复制到剪贴板了。如果对结果不满意,底部有个"重新生成"按钮,会用同样的输入再跑一遍。

后端 API 要求

后端 API 需要支持以下特性:

  1. 接收一个 contents 数组,数组里有多少个 prompt 就要并发处理多少个任务
  2. 返回 SSE 格式的流式数据,每个 choice 必须包含 index 字段用于标识对应的任务
  3. 所有任务完成后发送 "data: [DONE]" 标记

推荐使用 RWKV Lightning 作为后端服务(github.com/RWKV-Vibe/r…

写在最后

这个工具的核心价值就是一个字:快。

传统方式要生成 60 种风格,得等 2 分钟。现在并发生成,只要 2 秒钟。效率提升了 60 倍,这才是真正有用的工具。

当然,60 种风格只是开始。随着使用场景的增加,肯定还会有更多风格加进来。好在添加新风格很简单,改几行配置就行。

如果你有什么想法或建议,欢迎提 IssuePR。这个工具会持续优化,让更多人受益。

项目地址:rwkv-parallel-tone

后端服务:RWKV Lightning

对不起,我很贱:老板还没催,我自己就统计《GitLab年度代码报告》

作者 前端小蜗
2026年1月14日 14:55

大家好,我是小蜗。 这是一个"没事找事"的项目。

效果如下

起因:🐮🐴的自我觉醒

快过年了,看着朋友圈里刷屏的年度报告,我突然萌生了一个念头:给自己的代码也做个年终总结。

毕竟,相比于听歌和网购,这一年我们在 GitLab 上提交的每一行代码、每一次 Commit,才是打工人最真实的牛马痕迹。

我想把这些数据捞出来看看,回顾一下自己这一年到底做了多少需求,修了多少 Bug。顺便用数据量化一下,自己这一年到底有多“牛马”。

但我胆子小,有几个“保命”底线:

  1. 绝对安全:公司代码是红线,绝对不能传到任何第三方平台。
  2. 绝对隐私:工具只能读 Commit 的元数据(增删行数),别去读我具体的代码内容(毕竟屎山不想被 AI 围观)。
  3. 懒人专属:最好是个本地脚本,填个 Token 就能跑,别让我配数据库什么的。

抱着试一试的心态,我把这些碎碎念整理了一下丢给 AI,让它帮我写一段 Prompt。没想到,这竟然是我今年写代码最顺畅的一次。


经过:驾驭 AI ,驾驾驾

以前写这种工具,最烦的就是查 API 文档和写多线程。这次我采用了“指挥官”模式,全程基本没动手写逻辑。

1. 投喂指令,一遍过

我反手就是一个 Prompt 甩过去,着重强调了我的 “安全洁癖”:必须只通过 .env 读 Token,严禁调用 repository_files 这种危险接口。

结果 AI 也是个“老江湖”,出的方案不仅完全遵守了我的安全红线,还主动甚至帮我把 ThreadPoolExecutor 加上了 —— 说真的,要是让我自己写,大概率就写个单线程在那慢慢跑了。几千个 Commit 一分钟就拉完,这体验确实丝滑。

2. 也是有“温度”的统计

光看增删行数其实挺无聊的。我跟 AI 说:“不仅要数据,还要故事。”

于是它帮我整了几个扎心的维度:

  • 高光时刻:一年中最产出的那一天,和熬得最晚的那次提交。(看到那个凌晨 23:47 的提交,我突然想抱抱当时的自己)
  • 摸鱼鉴定:统计了周末提交和深夜代码的比例。看着 25% 的周末提交占比... 算了,都是泪。
  • 生物钟分析:热力图显示我下午 2 点到 4 点效率最高。原来上午真的是用来醒神的。

而且这个 UI,是不是有点那味儿了?Rich 库画出来的表格,发朋友圈也不丢面子。


结果:属于程序员的“年终总结”

从有个想法到代码跑通,也就花了一个晚上的时间。看着终端里跑出来的长长列表,虽然没有网易云那么花哨的 H5,但这种几行命令就能把自己一年工作量化的感觉,真的很 Geek。

现在,我把它开源出来。如果你也没等到 GitLab 官方的报告,或者想给自己这一年的“搬砖生涯”留个纪念,不妨试试这个小脚本。

✨ 放心食用,绝对安全

  • Metadata Only:代码里写死了只调 Commit 统计 API,绝不读取代码具体内容,放心用。
  • 一键运行:PowerShell 和 Shell 脚本都备好了,不想动脑子的直接运行就行。
  • 不存数据:跑完即焚,本地不留痕迹。

🚀 快速开始

源码都放在 GitHub 了:

项目地址: github.com/535803710/a…

你只需要去 GitLab 申请个只读 (read_api) 的 Token,然后:

Windows 兄弟点这里:

git clone https://github.com/535803710/annualstats.git
cd annualstats
.\quick_start.ps1

Mac/Linux 兄弟点这里:

chmod +x quick_start.sh && ./quick_start.sh

写在最后: 2024 过去了,那些红红绿绿的 Diff 不仅仅是代码,也是我们掉过的头发和熬过的夜。 新的一年,祝大家 Warning 归零,Bug 全消,年终奖翻倍!

python win32COM 对象介绍调用Word、WPS 与应用生态

作者 red润
2026年1月14日 14:47

python win32COM 对象介绍调用Word、WPS 与应用生态

在日常工作中,我们经常会用到各种办公软件,但你是否想过这些软件是如何被程序调用的?

这里我使用python语言作为示例

com 对照标识符

office_apps = {
    # Microsoft Office
    "Word.Application": "Microsoft Word",
    "Excel.Application": "Microsoft Excel", 
    "PowerPoint.Application": "Microsoft PowerPoint",
    "Outlook.Application": "Microsoft Outlook",
    
    # WPS Office
    "wps.Application": "WPS Office (新版)",
    "Kwps.Application": "WPS 文字",
    "Kwpp.Application": "WPS 演示", 
    "Ket.Application": "WPS 表格",
    
    # 其他办公软件
    "Photoshop.Application": "Adobe Photoshop",
    "AcroExch.App": "Adobe Acrobat",
}

上面是一个办公软件的“地图”,这些是 Windows 平台上办公软件的 COM 接口标识符。

什么是 COM?

COM(Component Object Model,组件对象模型)是微软在 1990 年代开发的一种二进制接口标准。它允许不同编程语言编写的软件组件进行通信。简单来说,COM 让程序可以通过一个“代号”调用其他软件的功能

办公软件

1. 微软 Office 家族

"Word.Application": "Microsoft Word"          # 文字处理
"Excel.Application": "Microsoft Excel"        # 电子表格
"PowerPoint.Application": "Microsoft PowerPoint"  # 演示文稿
"Outlook.Application": "Microsoft Outlook"    # 邮件管理

微软 Office 是最经典的办公套件,它的 COM 接口命名最为规范:

  • 每个应用都有自己独立的 Application 对象
  • 命名直接明了:软件名.Application
  • 提供了最完整的编程接口

有趣的事实:通过 COM 接口,你可以用 Python等 自动生成报告、处理数据、制作 PPT,实现办公自动化。

2. 金山 WPS 家族

"wps.Application": "WPS Office (新版)",       # 统一入口
"Kwps.Application": "WPS 文字",              # 文字处理
"Kwpp.Application": "WPS 演示",               # 演示文稿
"Ket.Application": "WPS 表格"                # 电子表格

WPS 的命名规则很有意思:

  • K 代表金山(Kingsoft)
  • wps 是核心应用
  • wpp 对应 PowerPoint
  • ket 对应 Excel

WPS 提供了两种调用方式:

  • 统一接口:wps.Application(新版本)
  • 独立应用:分别调用文字、演示、表格

版本演变:从独立的 KwpsKwppKet到统一的 wps.Application

3. Adobe 专业工具

"Photoshop.Application": "Adobe Photoshop"    # 图像处理
"AcroExch.App": "Adobe Acrobat"               # PDF 处理

Adobe 系列是设计领域的标杆:

  • Photoshop 的接口相对独立
  • Acrobat 使用的是 AcroExch.App这个特殊的名字
  • 主要用于专业设计、PDF 处理等场景

实际应用场景

自动化办公

import win32com.client

# 自动生成 Word 报告
def create_report():
    # 尝试 WPS,失败则用 Word
    for app_name in ["wps.Application", "Kwps.Application", "Word.Application"]:
        try:
            word = win32com.client.Dispatch(app_name)
            print(f"使用: {app_name}")
            break
        except:
            continue
    
    doc = word.Documents.Add()
    # ... 添加内容、格式、图表 ...
    doc.SaveAs("月报.docx")
    word.Quit()

批量文档转换

def convert_docs_to_pdf(input_folder, output_folder):
    """批量将文档转换为 PDF"""
    import os
    
    # 检测可用应用
    for app_id in ["wps.Application", "Word.Application"]:
        try:
            app = win32com.client.Dispatch(app_id)
            app.Visible = False
            break
        except:
            continue
    
    for file in os.listdir(input_folder):
        if file.endswith(('.doc', '.docx')):
            doc_path = os.path.join(input_folder, file)
            pdf_path = os.path.join(output_folder, file.replace('.docx', '.pdf'))
            
            doc = app.Documents.Open(doc_path)
            doc.ExportAsFixedFormat(pdf_path, 17)  # 17 表示 PDF
            doc.Close()
    
    app.Quit()

智能应用检测

def detect_office_software():
    """检测系统安装的办公软件"""
    results = {
        "microsoft_office": [],
        "wps_office": [],
        "adobe": []
    }
    
    for prog_id, app_name in office_apps.items():
        try:
            app = win32com.client.Dispatch(prog_id)
            version = getattr(app, "Version", "未知")
            
            if "Word" in app_name or "Excel" in app_name or "PowerPoint" in app_name:
                results["microsoft_office"].append(f"{app_name} (v{version})")
            elif "WPS" in app_name:
                results["wps_office"].append(f"{app_name} (v{version})")
            elif "Photoshop" in app_name or "Acrobat" in app_name:
                results["adobe"].append(f"{app_name} (v{version})")
                
            app.Quit()
        except:
            continue
    
    return results

应用场景

自动化办公

  • 批量处理文档
  • 自动生成报告
  • 数据整理和分析

软件集成

  • 在自家软件中嵌入 Office 功能
  • 实现文档预览、编辑
  • 提供格式转换服务

企业应用开发

  • 企业内部系统集成
  • 工作流自动化
  • 文档管理系统

技术细节

COM 接口的原理

# 创建应用实例
app = win32com.client.Dispatch("Word.Application")

# 调用方法
app.Visible = True  # 显示界面
app.Documents.Open("test.docx")  # 打开文档

# 访问属性
version = app.Version  # 获取版本

错误处理

def safe_dispatch(app_name):
    """安全创建 COM 对象"""
    try:
        app = win32com.client.Dispatch(app_name)
        return app
    except Exception as e:
        print(f"无法创建 {app_name}: {e}")
        return None

实用技巧

版本兼容

# 优先尝试新版,然后回退
wps_progids = [
    "wps.Application",    # 新版
    "Kwps.Application",   # 旧版
    "ket.Application",    # 表格专用
]

for progid in wps_progids:
    try:
        wps = win32com.client.Dispatch(progid)
        break
    except:
        continue

功能检测

def check_feature_support(app):
    """检测应用功能支持"""
    features = {
        "export_pdf": hasattr(app, "ExportAsFixedFormat"),
        "save_as": hasattr(app, "SaveAs2"),
        "visible": hasattr(app, "Visible"),
    }
    return features

总结

  1. COM 接口是 Windows 平台的标准化通信方式
  2. WPS 的接口设计考虑了与 Office 的兼容
  3. 这些接口是自动化办公的基础

ReactNative性能优化实战指南(2026最新版)

作者 wayne214
2026年1月14日 14:19

大家好,我是你们的 RN 老司机。

今天来聊聊 React Native 开发中最头疼的问题——性能优化。在 2026 年初,React Native 已经进入成熟期,新架构(The Architecture,前身为 New Architecture)已成为默认和强制标准,Hermes 引擎也进一步进化。很多老项目如果不优化,卡顿、掉帧、启动慢的问题会越来越明显,用户体验直线下降。

本文基于 React Native 最新版本(0.82+)和社区最佳实践,总结一套实用优化 checklist,帮助你让 App 跑得飞起,接近原生体验!

1. 基础必备:拥抱新架构 + Hermes 引擎

2025 年底的 React Native 0.82 版本是一个里程碑——旧架构彻底移除,新架构成为唯一选择。这意味着:

  • Fabric 渲染器:更高效的 UI 更新,Text 渲染速度提升 20%+。
  • TurboModules:按需懒加载 Native 模块。
  • JSI 接口:JS 和 Native 直接通信,告别旧 Bridge 的序列化瓶颈。

同时,Hermes 引擎仍是默认 JS 引擎,并在 0.82 中引入实验性新版本,进一步降低内存占用、加速冷启动。

优化建议

  • 新项目直接用最新 RN 初始化。
  • 老项目尽快迁移(社区库大多已适配)。
  • 启用 Hermes:Android/iOS 默认开启,结合 AOT 字节码预编译,启动时间可减 30-50%。

2. 减少不必要渲染:React 的核心优化

RN 最常见卡顿源于过度渲染。

关键技巧

  • 函数组件用 React.memo() 包裹。
  • useMemo() 缓存复杂计算,useCallback() 缓存回调函数。
  • 避免内联函数/对象(如 onPress={() => {...}})。
  • 类组件用 PureComponent 或手动 shouldComponentUpdate

这些小改动能减少 50%+ 的重渲染!

3. 列表优化:FlatList 的致命一击

长列表是性能杀手!用好 FlatList 的这些 props:

  • keyExtractor:用稳定唯一 ID(别用 index)。
  • getItemLayout:固定高度项必备,跳过测量,大幅提升滚动。
  • initialNumToRender:10-20。
  • windowSize:调小减内存。
  • removeClippedSubviews:移除视口外视图。
  • 社区推荐:换用 FlashList,性能更猛。

4. 图片与资源优化

大图是内存黑洞。

  • react-native-fast-image 支持缓存、优先级加载。
  • 压缩图片,用 WebP 格式,适配不同屏幕尺寸。
  • 懒加载:结合列表的 viewability 回调。

5. 动画与交互:跑在 Native 线程

别让 JS 线程阻塞!

  • Reanimated 3+ + Gesture Handler,动画直接在 UI/Native 线程运行,轻松 60FPS。
  • 复杂交互避开 JS 计算。

6. 启动速度与包体积优化

  • 代码拆分:动态 import 或 RamBundle。
  • 懒加载非首屏组件(React.lazy + Suspense)。
  • 启用 ProGuard(Android),移除无用库,压缩资源。

7. 其他进阶优化

  • 内存管理:清除定时器、监听器,避免泄漏。
  • 状态管理:用 Redux Toolkit + selector,避免全局重渲染。
  • 监控工具:Flipper、Systrace、Sentry 性能监控,先定位瓶颈再优化。

性能优化 Checklist

类别 关键优化点 预期收益
架构/引擎 新架构 + Hermes 启动快 30%+,内存低
渲染 memo / useMemo / useCallback 重渲染减 50%+
列表 FlatList 核心 props / FlashList 滚动丝滑,无掉帧
图片 FastImage + 压缩 加载更快,内存优化
动画 Reanimated 稳定 60FPS
启动 代码拆分 + 懒加载 首屏秒开

结语

2026 年的 React Native 已经非常强大,但性能优化永远是开发者必修课。先用工具定位问题(推荐 Flipper Performance),再针对性优化,往往事半功倍。

Ant Design 6.0 正式发布:从 V5 到 V6 有哪些变化?

2026年1月13日 17:37

Ant Design 6.0 技术解析:面向未来的 React UI 组件库升级

一、前言:Ant Design 6.0 发布背景.

Ant Design 的发展与社区规模简介

Ant Design 作为蚂蚁集团开源的企业级 React UI 组件库,自 2015 年发布以来已成为全球最受欢迎的前端组件库之一。截至目前,Ant Design 在 GitHub 上已获得超过 90k+ Star,拥有数千名贡献者,被全球数十万个项目采用。它不仅为企业级应用提供了完整的设计规范和高质量组件,更形成了一个活跃的开源生态系统。

2025 年初,Ant Design 6.0 正式稳定发布。这次重大版本升级经历了多次 RFC(Request for Comments)讨论和 Alpha 版本迭代,充分吸收了社区反馈,确保了升级的稳定性和前瞻性。

为什么值得关注这次重大版本升级

随着 Ant Design v5 进入稳定维护周期,v6 正式成为未来主力版本。这次升级不仅仅是版本号的变化,更代表着:

  • 技术架构的现代化:全面拥抱 React 18+ 特性,为 React 19 做好准备
  • 性能的显著提升:引入 React Compiler 和零运行时 CSS 方案
  • 开发体验的优化:更灵活的主题定制和语义化 DOM 结构
  • 生态的持续扩展:与 Ant Design X 2.0 同步发布,覆盖 AI UI 场景

对于使用 Ant Design 的开发者和团队来说,了解 v6 的核心变化,规划合理的升级路径,将直接影响项目的长期技术选型和维护成本。

二、核心升级方向概览

面向未来的技术架构升级

Ant Design 6.0 将最低 React 版本要求提升至 18,全面支持 React 18 的并发特性(Concurrent Features)。这不仅确保了组件库能够充分利用 React 的最新能力,也为即将到来的 React 19 做好了技术储备。通过启用 React Compiler,v6 在构建产物层面实现了更优的性能优化。

平滑迁移,减少升级成本

与以往大版本升级不同,Ant Design v6 特别注重平滑迁移体验。官方承诺:从 v5 升级到 v6 无需使用兼容包或 codemod 工具,大部分项目可以通过简单的依赖升级和少量代码调整完成迁移。这种设计理念大大降低了升级成本,让开发者能够更快享受新版本带来的优势。

性能与体验优化

v6 在性能优化上做了多方面努力:

  • React Compiler:自动优化组件渲染性能
  • CSS Variables 原生样式:零运行时开销,主题切换更流畅
  • 语义化 DOM 结构:更好的可访问性和样式定制能力
  • 默认模糊蒙版:提升视觉层级体验

这些优化共同构成了 v6 在性能和用户体验上的显著提升。

三、从 V5 到 V6 的主要变化

1. React 兼容性与基础架构

最低 React 版本提升至 18

Ant Design 6.0 不再支持 React 17 及以下版本。这个决策意味着:

  • 组件库可以充分利用 React 18 的并发渲染、自动批处理等特性
  • 开发者需要确保项目已升级到 React 18+
  • 为未来 React 19 的新特性预留了技术空间
// package.json 依赖要求
{
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  }
}
启用 React Compiler

v6 在构建产物中启用了 React Compiler(React 团队推出的编译时优化工具),能够:

  • 自动识别并优化组件的重渲染逻辑
  • 减少不必要的 memo 和 useCallback 使用
  • 提升整体运行时性能

2. 样式体系重大调整:CSS Variables 与零运行时

从 CSS-in-JS 向纯 CSS 变量体系过渡

这是 v6 最重大的架构变化之一。v5 使用的是运行时 CSS-in-JS 方案(基于 @ant-design/cssinjs),而 v6 默认采用纯 CSS 变量模式:

v5 样式方案(运行时):

// 运行时动态生成样式
const useStyle = () => {
  return {
    container: {
      backgroundColor: token.colorBgContainer,
      padding: token.padding
    }
  }
}

v6 样式方案(零运行时):

/* 编译时生成 CSS 变量 */
.ant-btn {
  background-color: var(--ant-color-primary);
  padding: var(--ant-padding);
}

这种转变带来的优势:

  • 零运行时开销:样式不再需要 JavaScript 动态插入,减少了首屏渲染时间
  • 更好的性能:避免了运行时样式计算和注入
  • SSR 友好:服务端渲染场景下性能更优
  • 移除 IE 支持:不再需要为旧浏览器做兼容处理
实时主题切换与多主题支持更轻量

CSS Variables 使得主题切换变得极其简单和高效:

import { ConfigProvider, theme } from 'antd';

// 动态切换主题
<ConfigProvider
  theme={{
    token: {
      colorPrimary: '#00b96b',
      borderRadius: 2,
    },
  }}
>
  <App />
</ConfigProvider>

// 多主题切换无需重新渲染整个应用
const switchTheme = (isDark) => {
  document.documentElement.setAttribute(
    'data-theme', 
    isDark ? 'dark' : 'light'
  );
};

3. 语义化 DOM 结构与可定制性提升

全面引入组件语义化结构(Semantic Structure)

v6 重构了组件的 DOM 结构,使其更符合 HTML5 语义化标准:

<!-- v5 结构 -->
<div class="ant-card">
  <div class="ant-card-head">
    <div class="ant-card-head-wrapper">
      <div class="ant-card-head-title">标题</div>
    </div>
  </div>
  <div class="ant-card-body">内容</div>
</div>

<!-- v6 语义化结构 -->
<article class="ant-card">
  <header class="ant-card-header">
    <h3 class="ant-card-title">标题</h3>
  </header>
  <section class="ant-card-body">内容</section>
</article>

这种改进带来的好处:

  • 更好的可访问性(a11y):屏幕阅读器能更准确理解页面结构
  • SEO 友好:搜索引擎能更好地解析内容
  • 样式定制更直观:语义化标签让 CSS 选择器更清晰
ConfigProvider classNames / styles 支持提升定制能力

v6 增强了 ConfigProvider 的定制能力,支持通过函数动态生成类名和样式:

<ConfigProvider
  button={{
    classNames: {
      root: ({ size, type }) => `custom-btn-${size}-${type}`,
    },
    styles: {
      root: ({ token }) => ({
        borderRadius: token.borderRadiusLG,
      }),
    },
  }}
>
  <Button size="large" type="primary">按钮</Button>
</ConfigProvider>

这种方式让全局样式定制更加灵活和类型安全。

4. API 调整与废弃逻辑清理

删除 v4 废弃 API

v6 彻底清理了所有在 v4 中标记为废弃、并在 v5 中保留的兼容逻辑。这包括:

  • 移除了旧的 Form.create() HOC 模式
  • 清理了 Icon 组件的字符串类型支持
  • 统一了事件回调参数格式
统一 API 命名与行为

v6 对一些历史遗留的 API 进行了统一:

// v5 中的不一致命名
<Dropdown placement="bottomLeft" />
<Tooltip placement="topLeft" />

// v6 统一为
<Dropdown placement="bottom-start" />
<Tooltip placement="top-start" />

5. 新组件与增强功能

v6 带来了多个新组件和功能增强:

新增 Masonry(瀑布流)组件

image.png

import { Masonry } from 'antd';

<Masonry
  columns={3}
  gap={16}
  items={dataSource}
  renderItem={(item) => <Card>{item.content}</Card>}
/>
Tooltip 支持平移/滑动

image.png

<Tooltip 
  title="提示内容" 
  trigger="hover"
  mouseEnterDelay={0.5}
  mouseLeaveDelay={0.1}
>
  <Button>悬停查看</Button>
</Tooltip>
InputNumber 新增 spinner 模式

image.png

<InputNumber 
  spinner={{ 
    mode: 'inline', // 或 'separate'
    upIcon: <UpOutlined />,
    downIcon: <DownOutlined />
  }} 
/>
Drawer 支持拖拽调整大小

image.png

<Drawer
  open={visible}
  resizable
  defaultWidth={400}
  minWidth={300}
  maxWidth={800}
>
  {content}
</Drawer>
默认模糊蒙版(mask blur)增强层级体验

image.png

<Modal
  open={visible}
  maskStyle={{ backdropFilter: 'blur(8px)' }}
>
  {content}
</Modal>

这些新特性显著提升了组件的丰富性和用户交互体验。

四、从 v5 升级到 v6:实战建议与注意事项

升级前的准备事项

在开始升级之前,建议完成以下准备工作:

  1. 升级到最新的 v5 版本并清理警告

    npm install antd@^5.latest
    # 运行项目,检查并修复所有控制台警告
    
  2. 确认 React 版本

    npm install react@^18.0.0 react-dom@^18.0.0
    
  3. 评估 IE 支持需求

    • v6 不再支持 IE 浏览器
    • 如果项目必须支持 IE,需要继续使用 v5
  4. 检查自定义主题和样式覆盖

    • 记录当前的主题配置
    • 检查是否有直接操作 .ant- 类名的 CSS

可能遇到的不兼容变更点

样式生成机制变化
// v5 中可能有效的样式覆盖
.ant-btn {
  background: red !important; // 可能需要调整
}

// v6 推荐使用 CSS Variables
.ant-btn {
  background: var(--ant-color-primary);
}

// 或通过 ConfigProvider 定制
<ConfigProvider
  theme={{
    components: {
      Button: {
        colorPrimary: 'red',
      },
    },
  }}
>
移除的 API 示例
// ❌ v5 废弃 API(v6 中已移除)
<Form.Item name="username" rules={[{ required: true }]}>
  <Input />
</Form.Item>

// ✅ v6 推荐写法(实际上 v5 也推荐)
<Form.Item 
  name="username" 
  rules={[{ required: true, message: '请输入用户名' }]}
>
  <Input />
</Form.Item>

升级策略与逐步实践

步骤 1:安装 v6
npm install antd@6
# 或
yarn add antd@6
# 或
pnpm add antd@6
步骤 2:运行开发环境
npm run dev

观察控制台是否有错误或警告,记录所有问题。

步骤 3:修复不兼容问题

根据控制台提示,逐一修复:

  • 替换已移除的 API
  • 调整样式覆盖方式
  • 更新主题配置格式
步骤 4:利用 ConfigProvider 全局管理
// main.tsx 或 App.tsx
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';

function App() {
  return (
    <ConfigProvider
      locale={zhCN}
      theme={{
        token: {
          colorPrimary: '#1890ff',
          borderRadius: 4,
        },
        components: {
          Button: {
            controlHeight: 32,
          },
        },
      }}
    >
      <YourApp />
    </ConfigProvider>
  );
}
步骤 5:渐进式测试
  • 先在开发环境充分测试
  • 在测试环境进行完整回归测试
  • 确认无误后再部署到生产环境

五、生态扩展:Ant Design X 与未来方向

Ant Design X 2.0 同步发布

与 Ant Design 6.0 同步,蚂蚁集团还发布了 Ant Design X 2.0。这是一个专注于 AI UI 场景的组件库,提供了:

  • 对话式交互组件:Chat、Bubble、Conversations
  • AI 输入增强:Prompts、Sender、Attachments
  • 智能建议:Suggestions、ThoughtChain
  • 流式渲染:支持 AI 生成内容的流式展示
import { Bubble, Sender, Conversations } from '@ant-design/x';

<Conversations
  items={messages}
  renderItem={(item) => (
    <Bubble
      content={item.content}
      avatar={item.avatar}
      typing={item.typing}
    />
  )}
/>
<Sender onSubmit={handleSend} />

Ant Design X 的发布标志着 Ant Design 生态正在积极拥抱 AI 时代的 UI 需求。

未来计划

根据官方路线图,Ant Design 团队未来将重点关注:

  1. 持续完善移动交互体验

    • 优化触摸交互
    • 提升响应式布局能力
    • 增强移动端手势支持
  2. 强化可访问性(a11y)

    • 完善 ARIA 属性
    • 优化键盘导航
    • 提升屏幕阅读器兼容性
  3. 跟进 React 新特性提升性能

    • 持续优化 React 19 兼容性
    • 探索 Server Components 支持
    • 利用新的并发特性
  4. 扩展 AI 场景组件

    • 丰富 Ant Design X 组件库
    • 提供更多 AI 交互模式
    • 支持多模态内容展示

六、结语

Ant Design 6.0 是一次面向未来的重大升级。通过引入零运行时 CSS 方案、语义化 DOM 结构、React Compiler 等现代化技术,v6 在性能、开发体验和可维护性上都实现了显著提升。

对于开发者而言,v6 的升级成本相对可控。官方承诺的平滑迁移路径、详细的升级文档,以及活跃的社区支持,都为升级提供了有力保障。同时,v6 与 Ant Design X 2.0 的协同发布,也展示了 Ant Design 生态在 AI 时代的前瞻性布局。

建议开发者根据项目实际情况规划升级路径:

  • 新项目:直接采用 v6,享受最新特性和最佳性能
  • 维护项目:评估升级收益,制定渐进式升级计划
  • 遗留项目:如无特殊需求,可继续使用 v5(官方将持续维护)

无论选择何种路径,Ant Design 6.0 都代表着 React UI 组件库发展的新方向,值得每一位前端开发者关注和学习。


参考资源:

前端向架构突围系列 - 框架设计(七):反应式编程框架Flower的设计

2026年1月13日 13:35

写在前面

这是《前端像架构突围 - 框架设计》系列的最终章。 我们不专门去说框架、聊响应式, 我们去学思想、看更上层的东西。

在前六章,我们聊了面向对象的本质、开闭原则的威力、以及接口职责的隔离。如果说那些是“内功心法”,那么今天我们要聊的,就是如何铸造一把趁手的“兵器”。

我们将从零开始构思一个名为 Flower 的反应式框架。

但请注意,这不是一篇“教你写 Vue 响应式原理”的教程。相反,这是一次关于**“反思”**的旅程。我们要探讨的是:当自动挡的反应式系统在复杂业务中失控时,我们该如何通过架构设计,找回丢失的控制权。


一、 引言:对“魔法”的恐惧

在写这一章之前,我其实犹豫过一段时间。不是因为反应式编程有多难实现,而是因为——我太清楚它有多容易失控了

在很多项目初期,反应式系统简直是天使:状态一改,视图自动更新,逻辑看起来干净又优雅。我们沉浸在 v-modeluseEffect 的便利中,享受着“声明式编程”的红利。

但当状态从 10 个变成 100 个,当依赖关系像蜘蛛网一样交织,当业务逻辑开始变得诡谲多变时,你会慢慢发现,那个曾经乖巧的系统开始“反噬”了:

  • 幽灵更新:改了一个看似无关的字段,为什么会导致半个页面重渲染?
  • 调试黑洞:数据流像一团乱麻,打断点都不知道该打在哪里,只能靠 console.log 碰运气。
  • 心智负担:新人不敢动核心状态,因为“它好像被很多地方依赖了,但我不知道具体是哪”。

这时候你会意识到:系统并不是在“响应变化”,而是在被变化牵着走。

Flower 的设计,正是从这种不安感开始的。

image.png


二、 核心定义:Flower 的边界

如果只是实现一个简单的反应式库,网上有无数个版本的 Object.definePropertyProxy 教程。

但架构师的职责不是“实现功能”,而是**“划定边界” 。在设计 Flower 之初,我做的第一个决策不是它“要有什么”,而是它“不要什么”**。

Flower 不解决以下问题:

  • UI 如何渲染(那是 React/Vue 的事)
  • 组件如何生命周期管理
  • 路由与网络请求

Flower 只解决一个核心命题:

  • 变化管理:变化从哪里产生?它如何有序地流向需要它的地方?

这是一个刻意“做小”的决策。因为我越来越确信:反应式系统一旦什么都想管(比如把 HTTP 请求也裹进响应式里),最终就会变成一团难以维护的泥球。

image.png


三、 设计决策 A:状态不是对象,而是“责任”

在很多主流框架中,状态(State)通常被建模为一个普通对象(Object)。

// 常见的做法
const state = reactive({ count: 0 });
state.count++; // 既是读取,又是修改,还是触发器

对象很方便,但它违反了我们之前提到的 SRP(单一职责原则) 。一个简单的对象属性,同时承担了“数据容器”、“读取接口”、“写入接口”和“变化通知”四个职责。

在 Flower 中,我决定剥夺状态的“对象身份”

我们将状态设计为原子信号(Atom Signal) ,并强制分离读写权限。这其实是 CQS(命令查询职责分离) 在前端的一次微观落地。

工程实现:

// Flower 的设计风格
const [count, setCount] = createSignal(0);

// count() -> 这是一个 Getter,只负责读取和依赖收集
// setCount() -> 这是一个 Setter,只负责写入和通知更新

为什么要这么麻烦?

因为这带来了**“引用透明性”**。

  • 如果你拿到的是 count,我知道你只能读,绝不可能悄悄修改它导致 Bug。
  • 如果你拿到了 setCount,我知道你是“生产者”,你要对变化负责。

通过 API 的设计,我们在代码层面强行约束了开发者的行为。这不是限制,这是保护。

image.png


四、 设计决策 B:拒绝“隐式依赖”的诱惑

自动依赖收集(Auto-Dependency Collection)是现代前端框架最迷人的“魔法”。

// 魔法:你没写任何订阅代码,但它就是工作了
effect(() => {
  console.log(state.name); // 自动收集了 state.name 的依赖
});

它确实好用,但在复杂工程中,我越来越警惕这种“悄悄发生的事情”。当依赖是隐式的,你就很难回答: “为什么这个函数执行了?”

Flower 在这里做了一个极其保守,甚至可以说“反潮流”的选择:显式依赖(Explicit Dependency)

我们参考了 DIP(依赖倒置原则) 的思想:高层逻辑不应该依赖于“运行时悄悄发生的读操作”,而应该依赖于“明确声明的契约”。

工程实现:

// Flower 的设计风格:你需要告诉我你关心什么
effect(
  // 1. 显式声明依赖列表(像 React 的 deps 数组,但更严格)
  [count, name], 
  // 2. 回调函数,参数即为依赖的当前值
  (currentCount, currentName) => {
    console.log(`Update: ${currentCount}, ${currentName}`);
  }
);

这种设计看起来“没那么聪明”,甚至有点啰嗦。但它换来的是确定性

在 Code Review 时,我看一眼依赖列表,就知道这个 Effect 会被什么触发。这种可推理性(Reasonability) ,在维护三年以上的老项目时,比什么魔法都珍贵。

image.png


五、 设计决策 C:调度器——解决“菱形依赖”难题

很多手写的反应式库(Toy Implementation)都会遇到一个经典 Bug: “闪烁”或“过渡态”

想象一下:A 变了,B 依赖 AC 依赖 A,而 D 同时依赖 BC。 当 A 更新时,D 可能会被触发两次(一次来自 B 的路径,一次来自 C 的路径),甚至在第一次触发时读到不一致的数据。这就是著名的 “菱形依赖问题” (Diamond Problem)

这就是为什么我说:“更新机制不是性能问题,而是正确性问题。”

Flower 引入了一个核心模块:调度器 (Scheduler)

工程实现:

调度器的核心逻辑是**“推-拉结合” (Push-Pull)**:

  1. Push 阶段:当信号变化时,不立即执行回调,而是标记所有脏节点(Dirty Marking)。
  2. Pull 阶段:在微任务(Microtask)队列中,按照拓扑排序(Topological Sort)的顺序,一次性计算出最终状态。
// 简化的调度逻辑
let dirtyQueue = new Set();

function schedule(effect) {
  dirtyQueue.add(effect);
  // 利用 Promise.resolve() 延迟到微任务执行
  queueMicrotask(flush);
}

function flush() {
  // 在这里进行排序、去重、批量执行
  // 确保 D 只会执行一次,且是在 B 和 C 都更新完之后
}

通过引入调度层,Flower 保证了:每一次更新,都是系统达到“稳定态”后的结果。 中间过程的动荡,被框架内部消化了。

image.png


六、 删繁就简:Flower 到底剩下了什么?

在设计过程中,我不断地问自己: “如果把 Flower 一层层剥开,删到不能再删,它还剩下什么?”

最后留下的,其实只有三个核心概念,它们构成了 Flower 的骨架:

  1. Signal (信号源) :负责定义数据和权限。
  2. Derive (计算属性) :负责数据的转换与派生。
  3. Effect (副作用) :负责与外部世界(如 DOM、日志)交互。

没有复杂的 Class,没有难以理解的配置对象,没有黑魔法。

这让我再次确认了一个架构真理:框架的价值,不在于提供了多少能力,而在于它限制了多少可能性。

Flower 限制了你随意修改状态的权力,限制了你隐式建立依赖的自由,但它给予了你**“系统无论怎么变,依然尽在掌握”**的安全感。

image.png


七、 结语:从“术”到“道”

回顾《前端向架构突围》的第二章,我们从面向对象的“类与继承”,一路走到设计原则的“SOLID”,最后落地到 Flower 框架的设计。

如果你仔细回味,会发现 Flower 的每一个设计决策,都是前面那些枯燥原则的投影

  • createSignal单一职责原则 的体现。
  • 显式依赖依赖倒置原则 的落地。
  • 不可变接口接口隔离原则 的实践。

架构设计并不是在追求“更聪明的算法”或“更短的代码”,而是在复杂的业务洪流面前,你是否愿意为系统设下清晰而坚定的边界。

反应式编程只是一个切入口。真正重要的,是你如何面对“变化”本身。

至此,框架设计篇章暂告一段落。但我们的突围之路才刚刚开始。在接下来的章节中,我们将走出代码的微观世界,去挑战更为宏大的工程化体系

互动思考: 在你的项目中,是否遇到过“不知道为什么这个组件又重新渲染了”的崩溃时刻?如果让你重新设计,你会更倾向于 Vue 的“自动收集”还是 React 的“显式依赖”?为什么?

深度实践:Spec-Coding,AI 独立完成复杂 UI 开发的可行性方案

2026年1月13日 11:51

核心协作流程图

graph TD
    %% 阶段 0: 启动与深度探测
    Start[输入原型 URL / 新页面需求] --> HookB_Probe[激活 Hook B: 视觉协议桥接]
    
    subgraph MCP_Layer [MCP 感知层: AI 的五感]
        MCP_Nav[navigate_page: 访问原型]
        MCP_Snap[take_snapshot: DOM 审计]
        MCP_Shot[take_screenshot: 视觉采样]
    end

    HookB_Probe --> MCP_Nav
    MCP_Nav --> MCP_Snap
    MCP_Snap --> MCP_Shot

    %% 阶段 1: Spec 生成与动态校准
    MCP_Shot --> Spec_Gen[生成结构化 Spec & Task.md]
    
    subgraph Dynamic_Alignment [动态校准闭环]
        Spec_Gen -->|循环采样探测| MCP_Snap
        MCP_Snap -->|特征反馈| Spec_Gen
    end

    %% 阶段 2: 规则注入
    Spec_Gen --> Rules_Layer[Rules 注入: AI 驱动 UI 实现严格规范]
    
    subgraph Core_Rules [核心底线约束]
        R1[组件映射: FuncTable/DyForm]
        R2[视觉规范: Dark Mode/禁绿令]
        R3[架构模式: MVC 分离/i18n]
    end
    
    Rules_Layer -.-> R1 & R2 & R3
    R1 & R2 & R3 -.->|微调任务细则| Spec_Gen

    %% 阶段 3: 增量任务执行
    Spec_Gen --> Task_Anchor[配置环境锚点: 双网址锁定]
    Task_Anchor --> Task_Exec[原子任务执行: 阶段 1-4]

    %% 阶段 4: 执行中的 Hook 校验
    subgraph Task_Hook [任务执行 UI Hook 闭环]
        T_Before[执行前: MCP 记录状态]
        T_Code[生成并应用代码]
        T_After[执行后: MCP 截图对比]
        
        T_Before --> T_Code --> T_After
    end

    Task_Exec --> T_Before
    T_After -->|存在偏差| T_Code
    T_After -->|校验一致| Delivery[高质量交付 & 代码合并]

    %% 样式定义
    style MCP_Layer fill:#e1f5fe,stroke:#01579b
    style Core_Rules fill:#fff3e0,stroke:#e65100
    style Task_Hook fill:#f3e5f5,stroke:#4a148c
    style Dynamic_Alignment stroke-dasharray: 5 5

一、 为什么 Spec 实践是 AI 开发质量的终极保证?

在 AI 开发的早期,我们往往追求“一句话生成页面”。但回到真实的业务场景,这种方式产出的代码几乎不可用。AI 会产生严重的幻觉,或者为了追求视觉一致性而进行无意义的过度设计

通过 Spec-Coding (规范驱动) 协议的实践,我们并不是在寻找一种“最聪明”的模型,而是构建一套质量与效率的确定性底线

  • 消除幻觉约束:Spec 为 AI 提供了清晰的操作边界,防止其随意引入非标库、手写原生 HTML 或滥用内联样式。
  • 解决逻辑断层:通过预定义的架构协议,确保 AI 生成的代码逻辑自洽,而非一堆无法运行的 UI 占位符。
  • 规模化交付的共性:当团队所有 AI 开发都遵循同一套 Spec,产出的代码在可维护性和一致性上能达到高度统一,避免了“千人千面”的代码污染。

二、 核心架构:Rules, Hook & MCP 的协同链路

该方案的可行性核心在于将“人眼识别的规范”转化为“机器执行的自动化指令”。我们建立了一份**《AI 驱动:UI 实现与任务执行严格规范》**作为核心指导文档。其底层逻辑如下:

1. 协作流程图

graph TD
    A[原型输入 URL/链接] --> B{是否有 Figma 设计稿?}
    B -- 无 Figma / 只有原型 --> C[激活 Hook B: 深度探测]
    B -- 有 Figma --> D[DLS 变量映射 & 截图还原]
    
    C --> E[MCP Chrome DevTools]
    E --> F[核心意图识别]
    
    F --> G[生成结构化 Spec & Task.md]
    G -.->|不断调用 MCP 采样校验| E
    
    G --> H[Rules 约束注入: UI 实现严格规范]
    H -.->|根据规范微调任务细则| G
    
    H --> I[原子任务执行]
    I --> J[Hook A: 合规性自检]
    J --> K[MCP 循环校验]
    
    K -- 视觉/代码偏差 --> I
    K -- 校验通过 --> L[高质量交付]

2. 三大支柱的职责分工

  • Rules (规则层) :以《AI 驱动:UI 实现与任务执行严格规范》为准则,定义了视觉底线、组件映射规则(如强制复用标准表格/表单组件)以及架构模式(如 MVC 分离),确保 AI 输出不偏离团队基建。
  • Hook (钩子层) :作为“拦截器”,在代码写入前校验组件复用、样式原子化及请求模式,并负责从视觉信号到开发任务的翻译。
  • MCP (能力层) :赋予 AI “五感”。通过 Chrome DevTools MCP,让 AI 能够实时感知浏览器真实的运行环境,解决“盲目编码”带来的视觉与逻辑偏差。

三、 深度实践中的难点与破解之道

在跑通这一链路的过程中,我们重点攻克了几个令开发者头疼的工程难题:

1. 解决 UI 还原与组件抽象的冲突

  • 难点:AI 倾向于通过堆砌 CSS 实现像素级还原,这往往导致嵌套极深、难以维护。
  • 解决:利用 MCP 视觉审计,将页面拆解为组件树。AI 必须先确认布局重心,再应用 Rules 进行组件映射。这意味着 AI 会优先思考“这里该用哪个公共组件”,而不是“这里该写哪个 CSS 属性”。

2. 规避代码不可用与逻辑空洞

  • 难点:生成的代码往往缺失 Loading、Error 处理或 API 链路不通。
  • 解决:实施 “环境锚点”强制化。在 Task 顶部锁定预览地址,AI 在生成每一阶段逻辑后,必须通过 MCP 回访页面验证交互是否闭环,而非一次性“盲写”到底。

3. 处理“只有原型,没有设计稿”的极端情况

这是最能体现方案可行性的场景。当手中只有一个运行中的 Web 原型 URL 时,通过 Hook B 驱动 MCP 探测,AI 能自动提取 DOM 结构和交互链路,生成高度还原的 Spec。这在老旧项目重构或竞品分析场景下极大地降低了 UI 逆向工程的成本。


四、 两种实践路径的体验总结

场景 A:无 Figma,只有 Web 原型 (对齐模式)

在这种场景下,我们通过 Chrome DevTools MCP 强行补齐了信息差。AI 通过“观察”原型的 Spacing 节奏和交互反馈,实现了高保真的代码重构。虽然过程涉及多轮 Hook 校验,但产出的代码健壮性远超人工手动临摹。

场景 B:基于 Figma (MCP 驱动模式)

当拥有 Figma 时,通过Figma MCPDLS (Design Language System) 变量与截图,还原度可直接达到 95% 以上。此时 AI 的工作重心不再是像素,而是转向 Rules 合规性,重点确保 API 请求模式、控制器逻辑完全符合团队的工程标准。


五、 总结:AI 开发的本质是协议的胜利

通过本次实践,我们发现:如果说大模型的能力决定了生成的上限,那么严密的协议则决定了交付的下限。协议的作用,是让 AI 在处理复杂业务时,从随机的灵感闪现转化为稳定的工程输出

折腾完这一整套体系后,最直观的感受是:AI 编程终于从‘抽卡撞运气’变成了‘照方抓药’。当模型能力(智商)足够强时,这套 Spec 就像是一条工业传送带,把 AI 那些天马行空的幻觉收拢进工程规范里。毕竟对我们业务开发来说,一个听话、稳定、不乱写代码的 AI,远比一个偶尔能写出‘神来之笔’但大多时候在挖坑的 AI 要靠谱得多

JSI入门指南

2026年1月13日 11:25

JSI入门指南

前言

要想彻底理解React Native新架构,JSI是绕不过去的槛。所以本文作为React Native源码剖析的第二篇文章。阅读本文需要了解一些简单的现代C++知识。新架构RN基本上是基于C++ 17开发的。学习现代C++可以极大的扩宽知识边界,提升能力范围。而且现代C++更加规范,学习难度降低,是性价比极高的选择。当然,仅作为源码阅读的需要,并不用深入学习C++,甚至不用会写代码,只要能看懂一些语法足够了。如果想更进一步,学习基本的现代C++开发,可以帮助我们开发React Native的纯C++ TurboModule,可以极大的优化RN App的性能,以及应用范围,在后面的RN新架构的一些列介绍文章中,相信对这一点一定体会极深。

JSI 概述

什么是JSI?

JSI(JavaScript Interface) 是 React Native 新架构中的一个核心组件,本质上是一个由 C++ 实现的轻量级接口层,用来连接:

  • 一边是 JavaScript 运行时(如 Hermes、V8)
  • 另一边是原生代码(主要是 C++,间接连到 Java/Obj‑C/平台 API)

它的目标是取代旧的异步 Bridge(JSON 消息桥),让 JS 与原生之间可以 直接、高性能地互相调用,大幅降低通信开销和延迟。

简单说,以前 JS 和原生要靠发 JSON 消息来通信,现在通过 JSI,双方可以像直接函数调用那样对话,并且可以互相保存对方对象的引用。

JSI 的核心特点

  1. 直接、低开销的 JS ↔ 原生通信

    • JSI 允许 JavaScript 保存 C++ 对象引用,C++ 也能保存 JS 对象引用,通过内存引用直接调用方法

    • 不再需要把数据转成 JSON 再跨线程传递,去掉序列化/反序列化开销,尤其对大数据量(如相机帧缓冲、图像、音频样本等)非常关键

    • 性能密集型库(例如 VisionCamera、Reanimated、Skia 等)借助 JSI 才能在 React Native 里做到接近原生的实时性能

  2. 支持同步和异步调用

    • 旧 Bridge 只能异步,很多场景需要“立刻拿到结果”很别扭。

    • JSI 提供:

      • 异步调用:常规推荐方式,避免阻塞 JS 线程或 UI 线程。

      • 同步调用:在确实需要“立即返回值”的场景(例如获取剪贴板、当前位置等)可以直接从原生拿值返回给 JS,而不用 Promise / 回调

      • 这种“既支持同步又支持异步”的模式,让 React Native 在易用性和性能之间有了更多空间。

  3. 用 C++ 实现、为性能而生

    • JSI 自身是 C++ API,提供 jsi::Runtimejsi::Objectjsi::Functionjsi::Value 等低层接口,用于:

      • 在原生代码中操作 JS 值与对象;
      • 注册可供 JS 调用的原生函数;
      • 直接与 JS 引擎交互
      • 由于是本地编译代码,减少了中间层和解释开销,带来更好的 启动时间与运行时性能
  4. 脱离旧 Bridge,成为新架构的基础。React Native 新架构的几个关键词:JSI、TurboModules、Fabric、Codegen,其中 JSI 是整个系统的“底座”

    • TurboModules:新一代原生模块系统,通过 JSI 实现 JS 与原生模块的直接调用,而不是通过老 Bridge

    • Fabric 渲染器:UI 事件与渲染更新通过 JSI 与 JS 运行时通信,使得界面更新更高效、更可控

    • Codegen:从类型化的 JS/TS 声明自动生成原生 C++/平台代码,这些代码通过 JSI 与 JS 通信

    • JSI 还是一个 与 JS 引擎无关的接口层,因此可以支持 Hermes、V8 等多种引擎,不再绑定于 JavaScriptCore

  5. 更适合高性能与跨平台原生模块。借助 JSI,你可以:

    • 写 纯 C++ 模块,然后在 Android 和 iOS 上复用这一套实现,只需很少的平台胶水代码
    • 直接在原生层访问设备能力(相机、蓝牙、GPS 等),并暴露给 JS 使用,同时保持高性能
    • 将复杂实例对象(数据库连接、图像缓冲、音频样本等)直接暴露给 JS 层,而不是一堆 JSON 数据
JSI vs Bridge
特性 Bridge (旧架构) JSI (新架构)
通信方式 JSON 序列化/反序列化 直接内存访问
性能 慢(每次调用都序列化) 快(零序列化开销)
同步调用 ❌ 不支持 ✅ 支持
类型安全 ❌ 运行时检查 ✅ C++ 类型系统
内存开销 高(JSON 字符串) 低(直接引用)

JSI 类型系统

类型层级结构
ICast (接口)
  └─ Runtime (抽象类)

Pointer (基类 - 不可拷贝,可移动)
  ├─ PropNameID (属性名)
  ├─ Symbol (ES6 Symbol)
  ├─ BigInt (大整数)
  ├─ String (字符串)
  └─ Object (对象)
       ├─ Array (数组)
       ├─ ArrayBuffer (二进制缓冲区)
       └─ Function (函数)

Value (联合类型 - 可存储任意 JS 值)
  ├─ undefined
  ├─ null
  ├─ boolean
  ├─ number
  ├─ Symbol
  ├─ BigInt
  ├─ String
  └─ Object
Runtime - JS 引擎抽象
class Runtime : public ICast {
 public:
  // 1. 执行 JavaScript 代码
  virtual Value evaluateJavaScript(
      const std::shared_ptr<const Buffer>& buffer,
      const std::string& sourceURL) = 0;

  // 2. 创建 JS 对象
  virtual Object createObject() = 0;
  virtual Array createArray(size_t length) = 0;
  virtual Function createFunctionFromHostFunction(
      const PropNameID& name,
      unsigned int paramCount,
      HostFunctionType func) = 0;

  // 3. 访问全局对象
  virtual Object global() = 0;

  // 4. 属性操作
  virtual Value getProperty(const Object&, const PropNameID& name) = 0;
  virtual void setPropertyValue(
      const Object&, const PropNameID& name, const Value& value) = 0;

  // 5. 微任务队列管理
  virtual void queueMicrotask(const Function& callback) = 0;
  virtual bool drainMicrotasks(int maxMicrotasksHint = -1) = 0;
};

引擎的具体实现(如:Hermes、JSC、V8)

// Hermes 实现示例
class HermesRuntime : public Runtime {
 public:
  Value evaluateJavaScript(...) override {
    // Hermes 特定的 JS 执行逻辑
  }

  Object createObject() override {
    // 调用 Hermes API 创建对象
  }
};
Value - 通用 JS 值类型
class Value {
 private:
  enum ValueKind {
    UndefinedKind,
    NullKind,
    BooleanKind,
    NumberKind,
    SymbolKind,
    BigIntKind,
    StringKind,
    ObjectKind,
  };

  union Data {
    bool boolean;
    double number;
    Pointer pointer;  // Symbol/String/Object
  };

  ValueKind kind_;
  Data data_;  // 8 字节(64 位)
};

类型检查与转换

void processValue(Runtime& runtime, const Value& value) {
  if (value.isUndefined()) {
    // 处理 undefined
  } else if (value.isNull()) {
    // 处理 null
  } else if (value.isBool()) {
    bool b = value.getBool();  // 断言检查
    bool b2 = value.asBool();  // 抛出异常
  } else if (value.isNumber()) {
    double d = value.getNumber();
  } else if (value.isString()) {
    String str = value.getString(runtime);
    std::string utf8 = str.utf8(runtime);
  } else if (value.isObject()) {
    Object obj = value.getObject(runtime);
  }
}
Object - JS 对象

创建对象

// 1. 空对象
Object obj(runtime);  // 等价于 JS: {}

// 2. 带原型的对象
Value proto = runtime.global().getProperty(runtime, "MyPrototype");
Object obj = Object::create(runtime, proto);

// 3. HostObject(C++ 对象)
class MyHostObject : public HostObject {
  Value get(Runtime& rt, const PropNameID& name) override {
    if (name.utf8(rt) == "value") {
      return Value(42);
    }
    return Value::undefined();
  }
};

auto ho = std::make_shared<MyHostObject>();
Object obj = Object::createFromHostObject(runtime, ho);

属性操作

Object obj(runtime);

// 设置属性(支持多种类型)
obj.setProperty(runtime, "name", "John");  // const char*
obj.setProperty(runtime, "age", 30);       // int
obj.setProperty(runtime, "active", true);  // bool

// 获取属性
Value name = obj.getProperty(runtime, "name");
if (name.isString()) {
  std::string str = name.getString(runtime).utf8(runtime);
}

// 检查属性
if (obj.hasProperty(runtime, "age")) {
  // 属性存在
}

// 删除属性
obj.deleteProperty(runtime, "age");
Function - JS 函数

创建 C++ 函数供 JS 调用

// 定义 C++ lambda函数
auto myFunc = [](Runtime& runtime,
                  const Value& thisVal,
                  const Value* args,
                  size_t count) -> Value {
  // 参数校验
  if (count < 2) {
    throw JSError(runtime, "Expected 2 arguments");
  }

  if (!args[0].isNumber() || !args[1].isNumber()) {
    throw JSError(runtime, "Arguments must be numbers");
  }

  // 执行逻辑
  double sum = args[0].getNumber() + args[1].getNumber();
  return Value(sum);
};

// 注册到全局对象
auto funcName = PropNameID::forAscii(runtime, "myAdd");
Function func = Function::createFromHostFunction(
    runtime, funcName, 2, myFunc);

runtime.global().setProperty(runtime, "myAdd", func);

// JS 中调用:
// const result = myAdd(10, 20);  // 30

从 C++ 调用 JS 函数

// 获取 JS 函数
Value callback = obj.getProperty(runtime, "onClick");
if (callback.isObject() && callback.getObject(runtime).isFunction(runtime)) {
  Function func = callback.getObject(runtime).getFunction(runtime);

  // 方式 1:无 this,传递参数
  Value result = func.call(runtime, Value(10), Value(20));

  // 方式 2:带 this 上下文
  Object thisObj(runtime);
  Value result2 = func.callWithThis(runtime, thisObj, Value(10));

  // 方式 3:作为构造函数调用
  Value instance = func.callAsConstructor(runtime, Value("arg1"));
}
Array - JS 数组
// 创建数组
Array arr = Array::createWithElements(runtime, 1, 2, "hello", true);

// 访问元素
size_t length = arr.size(runtime);
for (size_t i = 0; i < length; i++) {
  Value element = arr.getValueAtIndex(runtime, i);
}

// 修改元素
arr.setValueAtIndex(runtime, 0, Value(100));

// 转换为普通 Object
Object obj = arr.asObject(runtime);  // 类型安全转换

JS与C++的调用机制

HostObject

HostObject是一个非常重要的概念,它的作用就是将一个C++对象直接暴露给JS层使用。

更具体的说:

  • HostObject 是一个 C++ 类,完整的类是 facebook::jsi::HostObject
  • 你可以通过它把原生对象(例如图片、存储、数据库连接等)暴露给 JS
  • JS 访问它的属性和方法时,看起来就像在用普通的 JS 对象

如何使用HostObject

1.在 C++ 中定义 HostObject:
class NativeStorage : public facebook::jsi::HostObject {
public:
  int expirationTime = 60 * 60 * 24; // 默认 1 天

  // 读属性:nativeStorage.xxx
  jsi::Value get(jsi::Runtime& runtime, const jsi::PropNameID& name) override {
    auto prop = name.utf8(runtime);
    if (prop == "expirationTime") {
      return jsi::Value(expirationTime);
    }

    // 也可以在这里返回“方法”,例如 setObject / object(见后文)
    // 否则:
    return jsi::Value::undefined();
  }

  // 写属性:nativeStorage.xxx = ...
  void set(jsi::Runtime& runtime, const jsi::PropNameID& name, const jsi::Value& value) override {
    auto prop = name.utf8(runtime);
    if (prop == "expirationTime" && value.isNumber()) {
      expirationTime = (int)value.asNumber();
    }
  }
};

这里get方法相当于在定义:JS 中访问某个属性名时,底层到底要操作哪个 C++ 字段或执行什么逻辑

2.把 HostObject 实例挂到 JS 运行时
void NativeStorage::install(jsi::Runtime& runtime) {
  // 创建NativeStorage对象
  auto instance = std::make_shared<NativeStorage>();

  // 再从 HostObject 创建 JS 对象
  auto object = jsi::Object::createFromHostObject(runtime, instance);

  // 挂到 global 上,供 JS 使用:global.nativeStorage
  runtime.global().setProperty(runtime, "nativeStorage", object);
}

这个过程可以称为安装,在适当的时机(通常是 JS runtime 已经创建好之后),调用这个 install 就行。

3.在 JS 侧使用
// 属性读写:对应 C++ 中 get/set 覆写
nativeStorage.expirationTime = 1000;
console.log(nativeStorage.expirationTime);  // -> 1000

从 JS 视角看,这就是一个普通对象;从 C++ 视角看,它是一个持有原生资源和逻辑的类实例。

4.添加方法

HostObject 不只可以暴露数据属性,还可以在 get() 中给某个属性名返回一个 HostFunction,这样这个属性在 JS 中就是一个 可调用方法

例如在上面 NativeStorage 的基础上,给它加上 setObject / object 方法:

jsi::Value get(jsi::Runtime& runtime, const jsi::PropNameID& name) override {
  auto prop = name.utf8(runtime);

  if (prop == "expirationTime") {
    return jsi::Value(expirationTime);
  }

  if (prop == "setObject") {
    return jsi::Function::createFromHostFunction(
      runtime,
      jsi::PropNameID::forAscii(runtime, "setObject"),
      2, // 参数个数:key, value
      [](jsi::Runtime& rt, const jsi::Value& thisVal,
         const jsi::Value* args, size_t count) -> jsi::Value {
        // 这里做参数转换 + 原生存储逻辑
        // 比如用 NSUserDefaults 或 SharedPreferences 等
        return jsi::Value(true);
      }
    );
  }

  if (prop == "object") {
    return jsi::Function::createFromHostFunction(
      runtime,
      jsi::PropNameID::forAscii(runtime, "object"),
      1,
      [](jsi::Runtime& rt, const jsi::Value& thisVal,
         const jsi::Value* args, size_t count) -> jsi::Value {
        // 这里从原生存储中读取并返回
        // return jsi::String::createFromUtf8(rt, ...);
        return jsi::Value::undefined();
      }
    );
  }

  return jsi::Value::undefined();
}

那么在JS 层就可以这样使用:

nativeStorage.setObject('greeting', 'Hello JSI!');
const text = nativeStorage.object('greeting');

C++ 调用JS

调用 JS 函数,大概可以分两种情况,分别是回调函数和Promise 交互

回调函数
void registerClickHandler(Runtime& runtime) {
  // 获取 JS 回调
  Value onClickValue = runtime.global().getProperty(runtime, "onClick");

  if (!onClickValue.isObject()) {
    throw JSError(runtime, "onClick is not defined");
  }

  Object onClickObj = onClickValue.getObject(runtime);
  if (!onClickObj.isFunction(runtime)) {
    throw JSError(runtime, "onClick is not a function");
  }

  Function onClick = onClickObj.getFunction(runtime);

  // C++ 事件触发时调用
  // 必须在 JS 线程执行
  onClick.call(runtime, 
      String::createFromAscii(runtime, "button1"),
      Value(100),  // x
      Value(200)); // y
}
Promise 交互
Value createPromise(Runtime& runtime) {
  // 获取 Promise 构造函数
  Object promiseConstructor = runtime.global()
      .getPropertyAsObject(runtime, "Promise");

  // 创建 executor 函数
  auto executor = [](Runtime& rt, const Value&, const Value* args, size_t count) {
    Function resolve = args[0].getObject(rt).getFunction(rt);
    Function reject = args[1].getObject(rt).getFunction(rt);

    // 模拟异步操作
    std::thread([&rt, resolve = std::move(resolve)]() mutable {
      std::this_thread::sleep_for(std::chrono::seconds(1));

      // 实际需要 MessageQueue 调度
      resolve.call(rt, Value(42));
    }).detach();

    return Value::undefined();
  };

  Function executorFunc = Function::createFromHostFunction(
      runtime,
      PropNameID::forAscii(runtime, "executor"),
      2,
      executor);

  // 调用 new Promise(executor)
  return promiseConstructor.asFunction(runtime)
      .callAsConstructor(runtime, executorFunc);
}
访问 JS 对象属性
void opObject(Runtime& runtime, const Object& obj) {
  // 1. 获取所有属性名
  Array propNames = obj.getPropertyNames(runtime);
  size_t length = propNames.size(runtime);

  std::cout << "Object properties:" << std::endl;
  for (size_t i = 0; i < length; i++) {
    Value nameValue = propNames.getValueAtIndex(runtime, i);
    if (nameValue.isString()) {
      std::string name = nameValue.getString(runtime).utf8(runtime);

      // 2. 获取属性值
      Value propValue = obj.getProperty(runtime, name.c_str());

      // 3. 类型判断
      std::string type;
      if (propValue.isUndefined()) type = "undefined";
      else if (propValue.isNull()) type = "null";
      else if (propValue.isBool()) type = "boolean";
      else if (propValue.isNumber()) type = "number";
      else if (propValue.isString()) type = "string";
      else if (propValue.isObject()) type = "object";

      std::cout << "  " << name << ": " << type << std::endl;
    }
  }
}

线程安全

由于JSI并不是线程安全的,如果直接在子线程调用JSI的相关接口,会导致闪退。因此,在使用JSI时,线程安全问题十分重要,必须谨慎。

  • jsi::Runtime 的实现(Hermes/JSC/V8)其内部状态、GC、对象分配都不是线程安全的
  • 必须保证所有 JSI 操作在单一 JS 线程上串行执行
  • 跨线程访问会导致数据竞争、内存损坏、崩溃

我们来看一下JSI提供的解决方案,源码react-native/packages/react-native/ReactCommon/callinvoker/ReactCommon/CallInvoker.h

class CallInvoker {
 public:
  // 异步调度到 JS 线程(最常用)
  virtual void invokeAsync(std::function<void(jsi::Runtime&)>&& func) noexcept = 0;
  
  // 同步调用(阻塞当前线程直到 JS 线程执行完成)
  virtual void invokeSync(std::function<void(jsi::Runtime&)>&& func) = 0;
  
  virtual ~CallInvoker() = default;
};

也就是说,我们必须通过invokeAsync把执行相关JSI操作的闭包发送到JS线程执行。这里的invokeAsync可以从任意线程安全调用,不阻塞JS线程。注意,JS线程的阻塞,会直接导致UI的卡顿。

接下来,我们看一个结合Promise的JSI异步线程处理的完整示例:

#include <jsi/jsi.h>
#include <ReactCommon/CallInvoker.h>
#include <thread>
#include <chrono>

using namespace facebook::jsi;

class NetworkModule : public jsi::HostObject {
 private:
  std::shared_ptr<CallInvoker> jsInvoker_;

 public:
  NetworkModule(std::shared_ptr<CallInvoker> jsInvoker) 
      : jsInvoker_(std::move(jsInvoker)) {}

  Value get(Runtime& runtime, const PropNameID& name) override {
    auto methodName = name.utf8(runtime);
  
    if (methodName == "fetchAsync") {
      return unction::createFromHostFunction(
          runtime,
          name,
          1,
          [this](Runtime& rt, const Value&, const Value* args, size_t count) -> Value {
            std::string url = args[0].getString(rt).utf8(rt);
        
            // 1. 获取 Promise 构造函数
            Object promiseConstructor = rt.global().getPropertyAsObject(rt, "Promise");
        
            // 2. 创建 executor 函数
            auto executor = Function::createFromHostFunction(
                rt,
                PropNameID::forAscii(rt, "executor"),
                2,
                [this, url](Runtime& runtime, const Value&, const Value* args, size_t) -> Value {
                  // 3. 保存 resolve/reject(使用 shared_ptr 延长生命周期)
                  auto resolve = std::make_shared<Function>(
                      args[0].getObject(rt).getFunction(rt));
                  auto reject = std::make_shared<Function>(
                      args[1].getObject(rt).getFunction(rt));
              
                  // 4. 后台线程执行
                  std::thread([this, resolve, reject, url]() {
                    try {
                      // 模拟网络请求
                      std::this_thread::sleep_for(std::chrono::seconds(1));
                      std::string result = "Response from " + url;
                  
                      // 5. 调度到 JS 线程
                      jsInvoker_->invokeAsync([resolve, result](Runtime& rt) {
                        // createFromUtf8 这类JSI API必须在JS线程执行
                        resolve->call(rt, String::createFromUtf8(rt, result));
                      });
                    } catch (const std::exception& e) {
                      jsInvoker_->invokeAsync([reject, msg = std::string(e.what())](Runtime& rt) {
                        reject->call(rt, String::createFromUtf8(rt, msg));
                      });
                    }
                  }).detach();
              
                  return Value::undefined();
                });
        
            // 6. 返回 new Promise(executor)
            return promiseConstructor.asFunction(rt).callAsConstructor(rt, executor);
          });
    }
  
    return Value::undefined();
  }
};

这样,上层JS 调用fetchAsync方法时,就会得到一个Promise对象,直到底层的子线程执行完任务后,将结果返回,上层Promise才会返回结果。整个耗时操作都由底层C++线程完成,不会阻塞JS线程,在整个耗时任务期间,JS线程都可以继续执行其他任务。

一些工具类

关于Promise 辅助类的使用。可以查看头文件react-native/packages/react-native/ReactCommon/react/nativemodule/core/ReactCommon/TurboModuleUtils.h

namespace facebook::react {

struct Promise : public LongLivedObject {
  Promise(jsi::Runtime &rt, jsi::Function resolve, jsi::Function reject);

  void resolve(const jsi::Value &result);
  void reject(const std::string &message);

  jsi::Function resolve_;
  jsi::Function reject_;
};

using PromiseSetupFunctionType = std::function<void(jsi::Runtime &rt, std::shared_ptr<Promise>)>;
jsi::Value createPromiseAsJSIValue(jsi::Runtime &rt, PromiseSetupFunctionType &&func);

} 

使用示例:

// 在 HostFunction 中返回 Promise
return createPromiseAsJSIValue(rt, [jsInvoker](Runtime& rt, std::shared_ptr<Promise> promise) {
  // 异步操作
  std::thread([jsInvoker, promise]() {
    // 后台工作...
  
    // 完成后调度到 JS 线程
    jsInvoker->invokeAsync([promise](Runtime& rt) {
      promise->resolve(Value(42));  // 或 promise->reject("error")
    });
  }).detach();
});

LongLivedObject 可以防止过早的内存回收:

class MyData : public LongLivedObject {
 public:
  MyData(Runtime& rt) : LongLivedObject(rt) {}
  
  void done() {
    allowRelease();  // 允许被回收
  }
};

// 使用
auto data = std::make_shared<MyData>(runtime);
LongLivedObjectCollection::get(runtime).add(data);  // 防止回收
// ... 使用 data
data->allowRelease();  // 不需要时记得释放

常见错误示例

错误 1:直接在后台线程调用 JSI
// ❌ 错误
std::thread([&runtime, callback]() {
  callback.call(runtime, Value(42));  // CRASH!
}).detach();

// ✅ 正确
std::thread([jsInvoker, callback = std::move(callback)]() mutable {
  jsInvoker->invokeAsync([callback = std::move(callback)](Runtime& rt) {
    callback.call(rt, Value(42));
  });
}).detach();
错误 2:在异步回调中直接使用 runtime 引用
// ❌ 错误:runtime 引用可能失效
void asyncOp(Runtime& runtime, Function callback) {
  std::thread([&runtime, callback]() {  // 引用捕获危险
    jsInvoker->invokeAsync([&runtime, callback](Runtime&) {
      callback.call(runtime, Value(42));  // runtime 可能已销毁
    });
  }).detach();
}

// ✅ 正确:使用 lambda 传入的 runtime
void asyncOp(Runtime& runtime, Function callback, std::shared_ptr<CallInvoker> jsInvoker) {
  jsInvoker->invokeAsync([callback = std::move(callback)](Runtime& rt) {
    callback.call(rt, Value(42));  // 使用 lambda 参数rt
  });
}
错误 3:忘记 Promise 生命周期管理
// ❌ 错误:Promise 可能被释放
auto promise = std::make_shared<Promise>(rt, resolve, reject);
std::thread([promise]() {
  // Promise 可能被释放
}).detach();

// ✅ 正确:使用 createPromiseAsJSIValue(自动管理)
return createPromiseAsJSIValue(rt, [](Runtime& rt, std::shared_ptr<Promise> promise) {
  // promise 已自动加入 LongLivedObjectCollection
});

关于createPromiseAsJSIValue函数,前面已经演示过了。

错误处理

看一个完整JSI错误处理示例:

Value safeCall(Runtime& runtime, const Function& func, const Value* args, size_t count) {
  try {
    return func.call(runtime, args, count);
  } catch (const JSError& e) {
    // JS 异常
    std::cerr << "JS Error: " << e.getMessage() << std::endl;
    std::cerr << "Stack: " << e.getStack() << std::endl;
    throw;
  } catch (const JSINativeException& e) {
    // JSI 原生异常
    std::cerr << "Native Error: " << e.what() << std::endl;
    throw;
  } catch (const std::exception& e) {
    // 其他 C++ 异常
    std::cerr << "C++ Error: " << e.what() << std::endl;
    throw JSError(runtime, e.what());
  }
}

追踪详细的异常栈:

void executeWithStackTrace(Runtime& runtime, const std::string& code) {
  try {
    runtime.evaluateJavaScript(
        std::make_shared<StringBuffer>(code),
        "debug.js");
  } catch (const JSError& e) {
    std::cerr << "=== JavaScript Error ===" << std::endl;
    std::cerr << "Message: " << e.getMessage() << std::endl;
    std::cerr << "Stack:\n" << e.getStack() << std::endl;
  
    // 可以进一步解析堆栈
    std::istringstream stream(e.getStack());
    std::string line;
    int frameNum = 0;
    while (std::getline(stream, line)) {
      std::cerr << "  #" << frameNum++ << " " << line << std::endl;
    }
  }
}

useRef存在的潜在性能问题

作者 Jolyne_
2026年1月13日 10:39

最近在学习ahooks源码时,在useCreation时,发现官方文档写了这样一句话

image.png

第一眼后,我心想:useRef 不是只会执行一次吗?并且不会随组件生命周期变化吗?然后根据官方文档的例子试了一下,发现 useRef 在存储复杂数据类型时(比如对象),确实有性能问题

import { Button } from "antd";
import { useRef, useState } from "react";

class Subject {
  constructor() {
    console.log("===constructor===");
    this.data = Math.random();
  }
  data: number;
}

const RefQuestion = function RefQuestion() {
  const { current } = useRef(new Subject());
  const [, setFlag] = useState({});
  return (
    <div className="pt-10">
      <p>{current.data}</p>
      <Button
        onClick={() => {
          setFlag({});
        }}
      >
        Rerender
      </Button>
    </div>
  );
};

export default RefQuestion;

在上面的代码中,组件初始化时,会执行一次 new Subject(),控制台会打印一次 ===constructor===

image.png

当我点击按钮触发组件重新渲染时,虽然渲染的值没有变,但是控制台依旧打印了===constructor===

image.png

也就是说明:组件重新渲染时,虽然React内部判断后,返回了首次渲染时的值,但是在每次组件渲染时,都会执行 new Subject() 实例化过程,即使每次实例化后,都丢弃了实例化对象,而重复实例化对象就是一种无效的内存开销,即性能存在隐患

因此我重新看了下React文档对于 useRef 的说明:

image.png

可见 React 官方就已经对这种情况进行了说明,并且也给出了解决方案,思想与单例模式一致

function Video() {  
    const playerRef = useRef(null);  
    if (playerRef.current === null) {  
        playerRef.current = new VideoPlayer();  
    }  
    // ...

然后我们回过头来,可以看看 useCreation 是怎么做的

import type { DependencyList } from 'react';
import { useRef } from 'react';
import depsAreSame from '../utils/depsAreSame';

const useCreation = <T>(factory: () => T, deps: DependencyList) => {
  const { current } = useRef({
    deps,
    obj: undefined as T,
    initialized: false,
  });
  if (current.initialized === false || !depsAreSame(current.deps, deps)) {
    current.deps = deps;
    current.obj = factory();
    current.initialized = true;
  }
  return current.obj;
};

export default useCreation;
  • 如果是首次渲染(initialized === false)或者 依赖项发生了改变(!depsAreSame(current.deps, deps)),则执行 factory()函数创建值
  • 如果不满足条件,直接返回之前的值(此时不会走 factory()

这样就保证了通过 useCreation 创建的值一定是 memosized,依赖改变前是保证不会重新计算的

我们将 useCreation 代替 useRef 试试

import { useCreation } from "ahooks";
import { Button } from "antd";
import { useState } from "react";

class Subject {
  constructor() {
    console.log("===constrcutor=====");
    this.data = Math.random();
  }
  data: number;
}

const RefQuestion = function RefQuestion() {
  const [flag, setFlag] = useState({});
  const foo = useCreation(() => new Subject(), []);
  return (
    <div className="pt-10">
      <p>{foo.data}</p>
      <Button
        onClick={() => {
          setFlag({});
        }}
      >
        Rerender
      </Button>
    </div>
  );
};

export default RefQuestion;

点击按钮,组件重新渲染,不会执行 new Subject() 的实例化过程

image.png

那如果依赖发生改变,每次也只会重新计算一次

import { useCreation } from "ahooks";
import { Button } from "antd";
import { useState } from "react";

class Subject {
  constructor() {
    console.log("===constrcutor=====");
    this.data = Math.random();
  }
  data: number;
}

const RefQuestion = function RefQuestion() {
  const [flag, setFlag] = useState({});
  const foo = useCreation(() => new Subject(), [flag]);
  return (
    <div className="pt-10">
      <p>{foo.data}</p>
      <Button
        onClick={() => {
          setFlag({});
        }}
      >
        Rerender
      </Button>
    </div>
  );
};

export default RefQuestion;

如图,我点击了八次按钮,依赖改变了八次

image.png

所以,useRef并不是只会执行一次,而是每次组件渲染都执行,只不过如果之前有结果就返回之前的结果而已。

Lottie-web 源码解析(一):从 JSON Schema 认识 Lottie 动画的本质📒

作者 哨卫哥
2026年1月13日 10:34

Lottie 的本质是 After Effects 动画的 Web 播放器,其工作流程清晰简洁:

  1. 设计 → 设计师在 After Effects(AE) 中制作矢量动画
  2. 导出 → 通过 Bodymovin 插件将动画导出为轻量的 data.json 文件
  3. 播放 → web 开发者使用 lottie-web 库解析该 JSON,并在浏览器中实时渲染动画

若想深入理解 Lottie 的动画机制,从 JSON Schema 入手是一条清晰的路径。Lottie 是一个完全由数据驱动的动画系统,掌握其 JSON 结构,就等于握住了理解动画渲染逻辑的钥匙。当然,Lottie JSON 的配置项较为丰富,不必追求一次性全部记住。本文旨在帮助大家建立初步认知,为后续学习渲染引擎打下基础,做到“知其然,亦知其所以然”。

一、初识 data.json :动画数据的顶层结构

Lottie 动画的本质是一套由数据驱动的图形绘制指令集,而 data.json 文件正是这套指令的完整载体。其顶层结构定义了动画的全局元信息,如同电影的“导演台本”,设定了动画播放的画布尺寸、时间线与基础环境。理解这些字段,是后续深入解析图层、关键帧等复杂属性的根基。

顶层元数据:动画的全局定义

下表列出了 data.json 根对象(Root Object)中的核心元数据字段,它们共同框定了动画的时空属性与渲染基础。

字段 类型 描述 示例值 单位/备注
v string Bodymovin 插件版本:生成此 JSON 所用的插件版本号,不同版本可能支持不同的特性。 "5.7.4" -
fr number 帧率(Frame Rate) :动画的播放速率,直接决定动画的流畅度与时间计算基准。 3060 帧/秒 (fps)
ip number 起始帧(In Point) :动画时间轴开始的绝对帧编号。 0
op number 结束帧(Out Point) :动画时间轴结束的绝对帧编号。动画总时长 = (op - ip) / fr 秒。 90
w number 画布宽度:动画合成(Composition)的逻辑宽度。 800 像素 (px)
h number 画布高度:动画合成的逻辑高度。 600 像素 (px)
nm string 合成名称(Name) :对应 After Effects 中合成的名称,便于识别。 "元数据演示动画" -
ddd integer 3D 图层标识:标识此合成中是否包含 3D 图层或摄像机0 代表否(默认),1 代表是。 0 -

综合以上字段,一个定义了800x600画布、30fps、时长3秒的2D动画,其顶层结构示例如下:

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "nm": "元数据演示动画",
  "ddd": 0,
}

核心组成:图层、资源与字形

assetschars 与 layers 是构成 Lottie 动画内容的三个核心数据数组。它们分别定义了可复用的素材、矢量字形以及图层的序列与属性,共同描述了动画的全部视觉信息与动态行为。

assets - 可复用资源库

assets 数组用于集中定义动画中可被多次引用的静态或动态资源。此设计实现了资源复用,有助于优化文件体积并保证引用的一致性。

资源类型 类型标识 (ty) 内容描述 引用字段 主要用途
图像资源 "img" 包含图像ID、尺寸及文件路径或Base64编码数据。 refId 静态位图元素,如图标、背景。
预合成资源 "precomp" 一个完整的子合成,包含独立的图层(layers)与时间轴。 refId 可重复使用的动画片段,如通用动效组件。

引用机制:图层通过其 refId 属性与 assets 中对应ID的资源进行关联。渲染时,引擎据此查找并绘制相应资源。

chars - 矢量字形定义

chars 数组存储了将文字转换为矢量形状后的数据。当动画需要对文字进行路径变形、描边动画等超越普通文本渲染能力的操作时,会使用此数组。

  • 产生条件:在After Effects中对文本图层执行“从文字创建形状”,或在导出时启用“字符形状”选项。
  • 数据结构:每个项定义了字符、字体信息及其矢量路径(data),该路径数据包含轮廓、描边与填充等属性。
  • 核心用途:实现对文字字符的精细化控制,用于制作字形变形、笔画动画等效果。

layers - 动画图层序列

layers 是一个有序数组,定义了所有图层的叠加顺序、基本属性及关键帧动画。它是组织动画渲染逻辑的主体。

  • 渲染顺序:数组索引从0开始,图层按索引升序从底层到顶层依次渲染。
  • 核心属性:每个图层对象包含类型(如形状、图像、文本)、空间变换属性、蒙版以及关键帧动画数据。
  • 关键作用assets 和 chars 中定义的资源需在此被引用,并通过图层的动画属性驱动,才能成为最终动画的一部分。

协作关系assets 和 chars 作为资源定义,为 layers 提供素材;layers 则负责组织这些素材,并通过其时间轴和属性控制动画的最终表现。一个完整的结构示例如下:

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "nm": "元数据演示动画",
  "ddd": 0,
  "assets": [],
  "layers": [],
  "chars": []
}

二、图层系统 - 理解 Layers 结构⭐️

layers 数组是 Lottie JSON 中承载所有动画图层信息的主体。它定义了图层的堆叠顺序、属性及关键帧动画,是构成动画视觉呈现与动态行为的核心数据结构。Lottie 主要包含以下六种基础图层类型:

类型标识 (ty) 图层类型 核心描述
0 预合成图层 (Precomp Layer) 嵌套合成层,通过 refId 引用 assets 中定义的预合成资源,实现动画复用与模块化管理。
1 纯色图层 (Solid Layer) 纯色填充层,定义固定颜色的矩形区域,常用作背景或遮罩。
2 图像图层 (Image Layer) 静态图片层,通过 refId 引用 assets 中定义的图像资源。
3 空对象图层 (Null Layer) 不可见辅助层,主要用于通过其 ks 属性(变换数据)驱动子图层动画,或作为动画控制器。
4 形状图层 (Shape Layer) 矢量图形层,由路径、描边、填充等属性构成,是制作变形、路径动画的基础。
5 文本图层 (Text Layer) 文字图层,可定义字体、颜色、段落样式,并支持逐字符动画。

所有图层共享一组基础属性,用于定义其在合成中的基本状态与时空关系。

以下属性为所有图层类型共有的基础属性:

属性 类型 必选 描述 示例值 默认值
ty number 图层类型标识,决定图层的行为和数据结构(见上表) 0-5 -
nm string / number 图层名称,在 After Effects 中设置,用于表达式和调试 "按钮背景", "Layer 1" -
ind number 图层索引,在整个合成中唯一,用于父子关联和表达式引用 1, 2, 3 -
ip number 入点 (In Point),图层开始显示的帧数 0, 30 -
op number 出点 (Out Point),图层结束显示的帧数 90, 180 -
st number 起始时间 (Start Time),图层的时间偏移量 0, 10 -
sr number 时间拉伸 (Stretch),控制图层播放速度的系数 1 (正常), 0.5 (减速), 2 (加速) 1
ks object 变换属性 (Transform),包含位置、缩放、旋转、透明度等关键帧数据 见下方详解 -
ao number 自动定向 (Auto-Orient),沿路径自动调整图层方向(布尔值:0/1) 0 (关闭), 1 (开启) 0
ddd number 3D 图层标识,标记该图层是否为 3D 图层(布尔值:0/1) 0 (2D), 1 (3D) 0
parent number 父图层索引,指向父图层的 ind 值,实现层级控制和联动变换 1, 5 -
cl string CSS 类名 (Class),在 SVG/HTML 渲染器中作为 HTML class "background", "icon" -
ln string HTML ID,在 SVG/HTML 渲染器中作为 HTML id 属性 "layer-bg", "main-icon" -

以上13个通用属性中,有 3个ipopnm)与根对象中的概念完全一致,仅作用域不同。1个ddd)与根对象概念相似但有区别:根对象的 ddd 表示合成“是否包含”3D元素,而图层的 ddd 表示其“本身是否为”3D图层。其余 9个tyindstsrksaoparentclln)则是图层专有的核心属性,它们共同定义了图层在时间与空间中的基本状态。

接下来,我们将首先深入解析其中最核心的 ks(变换属性)  对象,它是驱动所有图层运动与变化的关键。

三、变换属性 - 位置、旋转、缩放⭐️

ks 对象概览

ks(Transform)是所有图层的核心动画容器,它定义了图层在空间中的位置、大小、旋转、透明度等基础变换状态。其名称 ks 是 Keyable Styled Properties(可关键帧的样式化属性)  的缩写,这精准概括了其两大核心特性:

  • k(Keyable) :指这些属性可被设置为关键帧,是实现所有逐帧动画的基础。
  • s(Styled) :指这些属性可被动态驱动或赋予样式,支持通过数据或表达式进行复杂控制。

因此,ks 对象不仅描述图层的静态空间状态,更是承载其所有运动与变换动画的数据载体。所有基础动画(如移动、缩放、旋转、淡入淡出)都通过在此对象内定义关键帧来实现。

ks 的完整属性表

属性 全称 默认值 作用
a Anchor Point [0, 0, 0] 锚点:图层变换的基准中心点(支点)。
p Position [0, 0, 0] 位置:图层在画布中的坐标。
s Scale [100, 100, 100] 缩放:图层的尺寸比例(以百分比表示)。
r Rotation 0 旋转:图层的旋转角度(单位为度)。
o Opacity 100 不透明度:图层的透明度(100为完全不透明,0为完全透明)。
sk Skew 0 倾斜:图层的倾斜角度(单位为度)。
sa Skew Axis 0 倾斜轴:定义倾斜操作所沿的轴向角度(单位为度)。

 3D 图层特殊形式:当 ddd=1 时,p 可能被拆分为 px、py、pz 三个独立属性。

属性值的两种数据形态

每个变换属性的值均以统一的结构表示,分为 静态值 与 动画值 两种形态,通过 a 字段区分。

1. 静态值 (a: 0)

属性在整个时间轴上保持不变,结构简洁。

{
  "r": {
    "a": 0,        // 静态标识
    "k": 45        // 固定值:旋转45度
  }
}

2. 动画值 (a: 1)

属性值随时间变化,通过关键帧数组 (k) 定义。

{
  "r": {
    "a": 1,        // 动画标识
    "k": [         // 关键帧数组
      {
        "t": 0,    // 时间点(帧)
        "s": [0],  // 起始值
        "e": [360] // 结束值
      },
      {
        "t": 60,   // 下一关键帧时间点
        "s": [360] // 该帧的数值(结束时省略"e")
      }
    ]
  }
}

关键帧核心字段说明

  • t (time) :关键帧所处的时间点(帧序号)。
  • s (start value) :此关键帧的起始值。
  • e (end value) :朝向下一关键帧的目标值。最后一个关键帧可省略。
  • i / o:定义属性变化速率(缓动)的贝塞尔曲线控制点,将在后续章节详述。

此结构是 Lottie 实现所有基础运动(位移、缩放、旋转、淡入淡出)的通用数据范式。

🎨小试牛刀:动手验证核心概念

通过前面的学习,我们已经掌握了 Lottie JSON 的顶层结构和核心属性。现在,让我们通过一个具体的例子,动手验证如何配置一个基础的静态场景。

在本例中,我们将使用一张图片,创建一个静态的图片图层。您将在下图中看到(一个静态图,一个旋转图):

录屏2026-01-07 11.51.03 (1).gif

说明:上图的 GIF 展示了包含旋转动画的完整效果。而下面的 JSON 配置,我们将首先完成左侧静态图片的搭建。这能让我们专注于已学的静态属性配置a: 0)。右侧的旋转动画,将在我们学习了关键帧系统后,通过简单地修改 r(旋转)属性即可实现。

以下是完整的 Lottie JSON 配置,已附上详尽的注释:

{
  // ========== 顶层元数据:动画的全局定义 ==========
  "v": "5.7.4",              // Bodymovin 插件版本
  "fr": 30,                  // 帧率:30fps
  "ip": 0,                   // 起始帧:从第0帧开始
  "op": 90,                  // 结束帧:到第90帧结束(总时长 = (90-0)/30 = 3秒)
  "w": 800,                  // 画布宽度:800像素
  "h": 600,                  // 画布高度:600像素
  "nm": "变换属性演示",        // 合成名称
  "ddd": 0,                  // 3D标识:0表示不包含3D图层
  
  // ========== Assets:可复用资源库 ==========
  "assets": [
    {
      "id": "image_0",       // 资源ID,供图层通过 refId 引用
      "w": 500,              // 图片原始宽度
      "h": 500,              // 图片原始高度
      "u": "",               // 图片URL基础路径(空表示使用完整路径)
      "p": "https://img11.360buyimg.com/img/jfs/t1/383242/14/16667/276460/695dd5ccF565013e8/02761c81c82b92d4.png",  // 图片完整URL
      "e": 0                 // 是否嵌入:0表示外部链接
    }
  ],
  
  // ========== Layers:图层序列(从下到上渲染) ==========
  "layers": [
    // ---------- 图层1:静态图片(展示静态值 a:0) ----------
    {
      "ddd": 0,              // 该图层不是3D图层
      "ind": 1,              // 图层索引:1(唯一标识)
      "ty": 2,               // 图层类型:2 = Image Layer(图像图层)
      "nm": "静态图片",       // 图层名称
      "refId": "image_0",    // 引用 assets 中 id 为 "image_0" 的资源
      "sr": 1,               // 时间拉伸:1 = 正常速度
      
      // ===== ks:变换属性(Transform)=====
      "ks": {
        // ----- o:不透明度(Opacity)-----
        "o": {
          "a": 0,            // 静态值标识(a=0 表示不动画)
          "k": 100           // 固定值:100%不透明
        },
        
        // ----- r:旋转(Rotation)-----
        "r": {
          "a": 0,            // 静态值标识
          "k": 45            // 固定值:旋转45度
        },
        
        // ----- p:位置(Position)-----
        "p": {
          "a": 0,            // 静态值标识
          "k": [250, 300, 0] // 固定位置:[x, y, z] = 左侧250px,顶部300px
        },
        
        // ----- a:锚点(Anchor Point)-----
        "a": {
          "a": 0,            // 静态值标识
          "k": [250, 250, 0] // 锚点在图片中心(500x500图片的中心点)
        },
        
        // ----- s:缩放(Scale)-----
        "s": {
          "a": 0,            // 静态值标识
          "k": [30, 30, 100] // 缩放到30%(显示为150x150)
        }
      },
      
      "ao": 0,               // 自动定向:关闭
      "ip": 0,               // 图层入点:第0帧开始显示
      "op": 90,              // 图层出点:第90帧结束显示
      "st": 0,               // 起始时间偏移:0(无偏移)
      "bm": 0                // 混合模式:0 = Normal(正常)
    }
  ],
  
  // ========== Chars:矢量字形定义(本例未使用)==========
  "chars": []
}

这个示例是一个“知识检查点”,清晰地展示了:

  • 顶层元数据 (vfrwh...) 定义了动画的舞台(3秒、800x600的画布)。
  • 资源 (assets)  定义了唯一可用的图片素材,并通过 id 标识。
  • 图层 (layers)  通过 refId 引用该资源,将其实例化到舞台上。
  • ks 变换对象 决定了这个实例的最终状态:位于画布左侧,缩小至30%,并旋转了45度。所有属性均以静态值 (a: 0)  定义。

至此,您已经掌握了如何构建一个 Lottie 动画的静态骨架——定义舞台、准备素材、放置元素并设置其初始状态。在上方的预览图中,您也看到了动画的潜力:只需将 r(旋转)属性的 a 值从 0 改为 1,并配上关键帧数据,静态图片就能旋转起来。

那么,a: 1 模式下的 k 数组究竟如何定义?多个关键帧之间如何平滑过渡?动画的运动节奏又由什么控制?接下来,我们就将深入动画的核心,解析关键帧、插值与缓动的完整系统。


四、深入动画 - 关键帧、插值与缓动⭐️

在上一章的"小试牛刀"中,我们看到了静态图片与旋转图片的对比效果。右侧的旋转效果正是通过关键帧动画实现的。现在,让我们深入解析这个旋转动画的完整JSON配置,揭开Lottie动画系统的核心机制。

录屏2026-01-07 11.51.03 (1).gif

让我们对比一下 demo 中两个图层的配置,看看静态图片是如何"动"起来的:

静态图片(左侧):

"r": {
  "a": 0,            // 静态值标识
  "k": 45            // 固定值:旋转45度
}

旋转图片(右侧):

"r": {
  "a": 1,            // 动画值标识(a=1 表示有关键帧动画)
  "k": [             // 关键帧数组
    {
      "i": { "x": [0.667], "y": [1] },   // 入缓动
      "o": { "x": [0.333], "y": [0] },   // 出缓动
      "t": 0,                            // 时间点:第0帧
      "s": [0]                           // 起始值:0度
    },
    {
      "t": 90,                           // 时间点:第90帧
      "s": [360]                         // 结束值:360度
    }
  ]
}

核心差异总结:

特征 静态值 (a: 0) 动画值 (a: 1)
k 的类型 单个数值或数组 关键帧对象数组
时间轴 无时间概念,始终保持固定值 在时间轴上定义多个状态点
缓动控制 通过 io 控制运动节奏
适用场景 静态属性(如固定位置、固定角度) 需要随时间变化的属性(移动、旋转、缩放等)

关键帧数组结构解析

当属性设置为动画值(a: 1)时,k 字段不再是一个简单值,而是一个关键帧对象数组。每个关键帧对象定义了动画在特定时间点的状态。

核心字段说明

字段 类型 必选 描述
t number 时间点(Time):该关键帧在时间轴上的位置,单位为帧。
s array 起始值(Start Value):该关键帧的属性值。对于旋转是 [角度],位置是 [x, y, z]
e array 结束值(End Value):从当前关键帧到下一关键帧的目标值。通常省略,渲染器会自动使用下一关键帧的 s 值。
i object 入缓动(In Tangent):定义进入当前关键帧时的速度变化曲线(贝塞尔控制点)。
o object 出缓动(Out Tangent):定义离开当前关键帧时的速度变化曲线(贝塞尔控制点)。
h number 保持帧(Hold):值为 1 时,表示该关键帧到下一关键帧之间不插值,保持当前值(阶跃动画)。

说明

  1. s 的数据类型:虽然官方 schema 中定义为 number,但在实际使用中,s 通常是数组(如 [0] 表示旋转 0 度,[100, 200, 0] 表示位置)。这是为了统一处理单维和多维属性。
  2. e 字段的实际使用:在大多数情况下,e 字段会被省略,渲染器会自动从下一个关键帧的 s 值推断目标值。只有在需要显式控制插值目标时才会使用。
  3. 最后一帧:最后一个关键帧通常只有 ts,不需要 ioe(因为没有下一个关键帧)。

缓动控制点的结构

io 对象定义了贝塞尔曲线的控制点,用于控制动画的加速度:

{
  "i": {
    "x": [0.667],  // 入缓动的 X 轴控制点(时间维度,范围 0-1)
    "y": [1]       // 入缓动的 Y 轴控制点(数值维度,通常 0-1,但可超出)
  },
  "o": {
    "x": [0.333],  // 出缓动的 X 轴控制点
    "y": [0]       // 出缓动的 Y 轴控制点
  }
}
  • x 数组:控制时间进度的变化率(水平方向)
  • y 数组:控制数值变化的速率(垂直方向)
  • 数组长度:对于单维属性(如旋转 r),数组长度为 1;对于多维属性(如位置 p),数组长度对应维度数(如 [0.5, 0.3] 表示 X、Y 两个维度的独立控制)

前端知识关联i 与 o 定义的贝塞尔曲线,其作用与 CSS 中的 transition-timing-function 或 animation-timing-function 完全一致,用于创造非匀速的动画效果。区别在于,CSS 使用一个二维的 cubic-bezier(x1, y1, x2, y2),而 Lottie 的 i 和 o 允许为每个属性维度单独定义控制点,控制更为精细。

工具推荐:如果您需要直观地创建或理解贝塞尔曲线,强烈推荐使用在线工具 cubic-bezier.com。您可以在那里调整曲线并获取对应的 cubic-bezier() 值,其原理与 Lottie 的 i/o 控制点相通,是理解和调试动画缓动的绝佳助手。

关键帧的工作流程

以 demo 中的旋转动画为例,让我们逐步拆解渲染器如何处理关键帧:

第 1 步:解析关键帧数组

"k": [
  {
    "t": 0,        // 第0帧
    "s": [0],      // 旋转角度 0°
    "i": { "x": [0.667], "y": [1] },
    "o": { "x": [0.333], "y": [0] }
  },
  {
    "t": 90,       // 第90帧
    "s": [360]     // 旋转角度 360°
  }
]

渲染器识别出:

  • 动画从第 0 帧开始,到第 90 帧结束
  • 起始角度 0°,结束角度 360°
  • 需要在这 90 帧之间进行插值计算

第 2 步:插值计算(以第 45 帧为例)

当播放到第 45 帧时,渲染器需要计算此时的旋转角度:

  1. 计算时间进度progress = (45 - 0) / (90 - 0) = 0.5(已完成 50%)
  2. 应用缓动函数:根据 io 的贝塞尔控制点,将线性进度 0.5 转换为缓动后的进度(假设为 0.55
  3. 计算属性值angle = 0 + (360 - 0) × 0.55 = 198°

第 3 步:渲染当前帧

渲染器将计算出的 198° 应用到图层的旋转属性,完成该帧的绘制。

关键帧的连续性

在多个关键帧的场景中,渲染器会:

  • 定位当前区间:找到当前时间点所在的关键帧区间(如第 30 帧位于第 0 帧和第 90 帧之间)
  • 使用对应缓动:应用该区间起始关键帧的 o(出缓动)和结束关键帧的 i(入缓动)
  • 独立插值:对于多维属性(如位置 [x, y, z]),每个维度独立进行插值计算

本章小结

至此,我们已经掌握了 Lottie 动画系统的“动力源”:关键帧、插值与缓动

  • 静态与动画的开关:通过 a 字段 (0 或 1) 切换属性的静态与动态模式,是理解 Lottie 动画逻辑的第一课。
  • 关键帧定义状态k 数组中的每个关键帧对象,通过 t (时间) 和 s (数值) 在时间轴上锚定了动画的各个“关键时刻”。
  • 缓动赋予灵魂i (入缓动) 与 o (出缓动) 所定义的贝塞尔曲线,控制了数值变化的速率与节奏,是让动画摆脱机械感、获得生命力的关键。
  • 渲染器执行插值:在关键帧之间,Lottie 渲染器会依据缓动曲线,为每一帧实时计算出精确的属性值,从而创造出平滑的动画效果。

您已经了解了从定义到渲染的完整链条。关于贝塞尔曲线的数学原理多维属性的独立插值策略以及更复杂的表达式动画,都属于更深入的话题。掌握了本章的核心数据模型,您已经具备了自行解析绝大多数 Lottie 动画、并理解其运动逻辑的能力。


五、形状系统 - 矢量图形

在 Lottie 的六种基础图层中,形状图层(Shape Layer,ty: 4  占据着独特而核心的地位。其他图层类型——如空对象图层(用于控制)、预合成图层(用于嵌套)、图像图层(静态位图)和文本图层(字形动画)——虽然在特定场景下不可或缺,但它们在数据结构和动画能力上相对简单。

形状图层(ty: 4)是 Lottie 实现复杂矢量动画的核心。与其他图层类型相比,它拥有最丰富的专有属性和最强的动画表现力。

形状图层的结构:ty: 4 与 shapes 数组

当一个图层的 ty 值为 4 时,它就是一个形状图层。其核心数据容器是 shapes 数组(在早期的 Lottie 版本中可能标记为 it)。该数组是一个有序的列表,定义了构成最终矢量图形的所有基础元素,例如路径、描边、填充等。

  • 有序渲染与叠加shapes 数组中的元素严格遵循数组索引顺序(从 0 到 n-1)进行渲染。在视觉上,这意味着索引值更大的元素(后渲染)会叠加在索引值更小的元素(先渲染)之上。例如,shapes[2] 会覆盖在 shapes[0] 和 shapes[1] 之上。这一规则是组织复杂图形层级的基础。
  • 元素类型:每个元素都是一个独立的对象,并通过 ty 字段来声明自己的类型(如 gr 表示组,sh 表示路径等)。

四种基础形状类型速览

shapes 数组中可以包含多种图形元素,其中基础形状主要有以下四种:

类型标识 (ty) 名称 描述 核心动画属性
rc 矩形 定义矩形或圆角矩形。 p (位置), s (尺寸), r (圆角半径)
el 椭圆 定义圆形或椭圆形。 p (中心点), s (半径/尺寸)
sr 星形/多边形 定义星形或多边形,可控制角数、内外径等。 p (中心点), ir/or (内/外半径), pt (角数)
sh 自由路径 由贝塞尔曲线构成的任意形状路径,是矢量图形的基础。 ks (路径数据,包含顶点与贝塞尔控制点)

说明rcelsr 本质上是参数化形状,它们会在导出时被转换为最终的 sh(路径)数据。但在 JSON 中,它们作为逻辑元素存在,便于理解和编辑。

自由路径的核心:顶点与贝塞尔控制点

自由路径(ty: ‘sh‘)是最基础、最灵活的形状元素。其核心数据存储在 ks 属性中,它定义了一系列顶点(Vertex)  以及连接这些顶点的贝塞尔曲线

一个路径的关键帧数据通常包含以下字段:

{
  “a”: 1, // 表示路径数据是动画的
  “k”: {
    “i”: [[0, 0]], // 入控制点 (In Tangent)
    “o”: [[0, 0]], // 出控制点 (Out Tangent)
    “v”: [[50, 50]], // 顶点 (Vertex)
    “c”: true // 路径是否闭合 (Closed)
  }
}
  • v (顶点) :一个二维数组,定义了路径在空间中经过的关键点坐标 [x, y]
  • i (入控制点) :定义曲线进入当前顶点时的方向与张力。
  • o (出控制点) :定义曲线离开当前顶点时的方向与张力。
  • c (闭合) :布尔值,true 表示路径的首尾顶点应连接,形成封闭图形。

工作原理v 定义了“骨架”,i 和 o 则定义了连接骨架的“肌肉”曲线。通过为这些数据添加关键帧,即可实现路径的形变、绘制等复杂动画。

知识关联:两种贝塞尔曲线
您可能已经发现,这里的 i 和 o 与第四章关键帧缓动中的 i 和 o 字段同名,且都代表贝塞尔曲线的控制点。这是 Lottie 中贝塞尔曲线的两种核心应用:

维度 空间贝塞尔曲线 (本章) 时间贝塞尔曲线 (第四章)
作用 定义空间中的图形形状 定义时间上的属性变化速率(缓动)。
控制点 (i/o) 控制顶点处曲线的方向与曲率,决定路径形态。 控制关键帧处动画速度的快慢,决定运动节奏。
坐标空间 位于画布的二维/三维空间 (如 [x, y])。 位于时间-进度二维空间,X轴是时间(0-1),Y轴是进度(0-1)。
直观感受 拖拽控制柄,改变的是线的弯曲程度 拖拽控制柄,改变的是动画的先快后慢

底层一致性:尽管应用不同,但两者都基于三次贝塞尔曲线的数学模型。理解这一点后,无论是调整路径平滑度还是动画缓动,您操作的都是同一种“控制点”逻辑。这也解释了为何可视化工具 cubic-bezier.com 对理解两者都有帮助:虽然它主要用于缓动(时间),但其对曲线形态的直观展示,同样有助于您想象空间路径中控制点对形状的影响。

形状组(gr):嵌套与组织

为了管理复杂的图形,Lottie 引入了 形状组(ty: ‘gr‘ 。组可以将多个形状元素(包括其他组)打包为一个逻辑整体。

  • it 数组:组的核心属性,是一个数组,用于包含其子元素(如路径、填充、描边或其他组)。

  • 作用

    1. 层次化管理:像文件夹一样组织图形,使结构清晰。
    2. 统一变换:组可以拥有自己的 ks(变换)属性。对该组应用的变换(如移动、缩放)会同时影响其内部所有子元素。
    3. 动画复用:通过控制组的变换,可以轻松实现整个图形模块的动画。

六、形状样式 - 填充与描边

在上一章中,我们探索了形状图层如何通过路径、椭圆、矩形等元素定义图形的几何骨架。然而,只有几何形状是“不可见”的。要让图形真正被渲染出来,就需要为其赋予样式

本章将介绍形状系统中负责视觉呈现的两大核心样式元素:填充(Fill)  与描边(Stroke) 。它们为形状的内部轮廓提供颜色、渐变和不透明度等视觉效果,是矢量图形从“线框”变为“画面”的关键。

6.1 样式的作用:让形状可见

在 shapes 数组中,样式元素(如 flst)与形状元素(如 shel)地位平等,通过渲染顺序相互结合。

  • 堆叠规则it 数组中的元素按索引顺序依次绘制。这意味着索引更大的元素(后绘制的)会叠加在索引更小的元素(先绘制的)之上。在图形构建中,通常先定义"形状"元素(如 elsh),再定义为其着色的"样式"元素(如 flst)。
  • 独立性与组合性:样式元素是独立的,可以自由组合。一个形状可以同时拥有填充和描边,也可以只有其中一种。它们共同附着于其上方最近且未闭合的图形元素或组。

填充(Fill):纯色与渐变

填充用于为形状的内部区域着色。Lottie 支持两种填充类型。

纯色填充(ty: 'fl')

这是最基础的填充类型,使用单一颜色。

{
  "ty": "fl", // 类型:Fill (纯色填充)
  "c": {      // Color (颜色)
    "a": 0,
    "k": [0.2, 0.6, 1, 1] // RGBA 数组,值范围 0-1
  },
  "o": { ... } // Opacity (不透明度)
}

渐变填充(ty: 'gf')

Lottie 支持线性渐变和径向渐变,为填充带来丰富的色彩过渡。

{
  "ty": "gf", // 类型:Gradient Fill (渐变填充)
  "t": 1,     // 渐变类型:1-线性,2-径向
  "g": {
      "p": 2,
      "k": {
        "a": 0,
        "k": [0, 0.2, 0.6, 1, 1, 1, 0, 1, 0, 1]
      }
    }
  "s": { ... }, // 起点 (线性) / 起始点 (径向)
  "e": { ... }  // 终点 (线性) / 结束点 (径向)
}
  • g 对象:是渐变的核心。p 定义色标数量,k 是一个扁平的数组,每 5 个数字为一组,表示一个色标的 [位置, R, G, B, A]。所有色标数据按顺序连接。

描边(Stroke):轮廓与样式

描边用于绘制形状的轮廓线。它拥有比填充更丰富的属性来控制线条的视觉表现。

纯色描边(ty: 'st')

{
  "ty": "st", // 类型:Stroke (描边)
  "c": { ... }, // 颜色,同填充
  "w": {      // Width (线宽)
    "a": 0,
    "k": 5    // 线宽为5像素
  },
  "lc": 2,    // Line Cap (线帽): 1-平头, 2-圆头, 3-方头
  "lj": 2,    // Line Join (连接): 1-斜接, 2-圆角, 3-斜面
  "ml": 10,   // Miter Limit (斜接限制)
  "o": { ... } // 不透明度
}
  • w (Width) :描边的粗细,支持动画。
  • lc (Line Cap) :定义线段端点的样式。
  • lj (Line Join) :定义线段转折处的连接样式。
  • ml (Miter Limit) :当 lj 为 1 (斜接) 时,控制斜接长度与线宽的比例上限,防止尖角过长。

渐变描边(ty: 'gs')

渐变描边的数据结构与渐变填充 (gf) 高度相似,包含 t (类型)、g (渐变数据) 等属性,区别在于它应用于轮廓线而非填充区域。

样式的通用属性

填充和描边共享一些控制其最终呈现效果的通用属性:

属性 类型 描述
o object 不透明度 (Opacity) 。通过 a 和 k 控制,值范围为 0 到 100(100 为完全不透明)。这与 CSS 中 0-1 的范围不同,请注意区分。
bm number 混合模式 (Blend Mode) 。定义当前样式如何与下方已有的像素进行混合。常见值:0 (正常)、1 (相乘)、2 (屏幕) 等,对应 After Effects 中的混合模式。

🎨小试牛刀:画个圆

现在,让我们综合运用以上概念,创建一个最简单的形状:一个蓝色的实心圆。我们将通过 JSON 配置,清晰地展示形状图层的 shapes 数组是如何组织起来的。

image.png

以下是实现该圆形的精简版 JSON 配置,我们省略了前面章节已详细讲解的通用图层属性(如 ks 变换),将焦点完全放在形状图层特有的 shapes 数组上:

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "layers": [
    {
      "ty": 4, // ⭐️ 核心标识:这是一个形状图层
      "nm": "带描边圆形",
      "ind": 1,
      // 🔽 此处省略了图层的 `ks` 变换属性(如 p, a, s, r, o)
      // 在完整文件中,它们用于将整个图层定位在画布中心,例如:
      // "ks": { "p": { "a": 0, "k": [400, 300, 0] }, ... }
      
      "shapes": [ // ⭐️ 本章核心:形状与样式数组
        {
          "ty": "gr", // 类型:gr (Group),一个形状组
          "nm": "圆形组",
          "it": [ // 组内元素列表,按索引顺序 0→1→2→3 渲染
            // 1. 形状定义:椭圆 (el) - 先绘制,定义几何轮廓
            {
              "ty": "el",
              "nm": "椭圆路径",
              "p": { "a": 0, "k": [0, 0] }, // 位置:相对于组的中心
              "s": { "a": 0, "k": [100, 100] } // 尺寸:宽高100px,即圆形
            },
            // 2. 样式定义:填充 (fl) - 其次绘制,为形状内部着色
            {
              "ty": "fl",
              "nm": "蓝色填充",
              "c": { 
                "a": 0, 
                "k": [0.2, 0.6, 1, 1] // 颜色:RGBA,此为蓝色
              },
              "o": { "a": 0, "k": 100 } // 不透明度:100%
            },
            // 3. 样式定义:描边 (st) - 最后绘制,为形状轮廓添加边线
            {
              "ty": "st",
              "nm": "白色描边",
              "c": { 
                "a": 0, 
                "k": [1, 1, 1, 1] // 颜色:白色
              },
              "w": { "a": 0, "k": 5 }, // 线宽:5像素
              "lc": 2, // 线帽:2 = 圆头
              "lj": 2  // 连接:2 = 圆角
            },
            // 4. 必需的组变换 (tr) - 必须放在最后,控制整个组的变换
            {
              "ty": "tr",
              "nm": "组变换"
              // 其内部属性 (p, a, s, r, o) 通常保持默认值 [0, 0, 100, 0, 100]
            }
          ]
        }
      ]
    }
  ]
}

七、形状修改器 - Trim、Repeater

在掌握了形状的构建与样式之后,我们迎来了形状系统的最后一块拼图:修改器(Modifiers) 。它们不是独立形状,而是附加在现有形状或组之上的“效果处理器”,能够以非破坏性的方式动态改变图形的外观或行为,是实现复杂、程式化动画的关键。

修改器的概念:作用范围与顺序

修改器是一种特殊类型的元素,其 ty 值定义在 shapes 或 it 数组中。它们不直接渲染,而是像一个处理器,作用于排列在它之前的特定元素

  • 作用逻辑:在 it 数组中,修改器按照从前往后的顺序执行,每个修改器会作用于它之前已定义的特定元素。例如,Trim Path 裁剪其之前的路径,Repeater 重复其之前的整个形状组合。
  • 顺序关键:修改器的位置决定了其作用范围和最终效果。例如,[形状 → 样式 → Trim] 会裁剪已着色的形状;而 [形状 → Trim → 样式] 会先裁剪路径,再为裁剪后的部分着色。

Trim Path(tm):路径裁剪动画

Trim Path(路径裁剪)是最常用的修改器之一,它通过控制路径的“起止点”来创造笔触绘制、擦除、扫描等动画效果。

核心属性:

{
  "ty": "tm", // 类型:Trim Paths
  "s": { // Start(起点百分比)
    "a": 1,
    "k": [{ "t":0, "s":[0] }, { "t":90, "s":[100] }] // 从0%到100%
  },
  "e": { // End(终点百分比)
    "a": 1,
    "k": [{ "t":0, "s":[100] }, { "t":90, "s":[100] }] // 保持100%
  },
  "o": { // Offset(偏移)
    "a": 0,
    "k": 0 // 范围0-360°,整体偏移裁剪区域
  }
}
  • s (Start) :路径可见部分的起点,范围 0-100%。动画化此属性可实现“生长”动画。
  • e (End) :路径可见部分的终点,范围 0-100%。通常 e >= s
  • o (Offset)裁剪区域的整体偏移量,范围 0-360°。它可以让裁剪的起止点沿路径循环移动,常用于创建“追逐”或“循环扫描”效果。

典型应用:通过动画 s 和 e 属性,可以实现经典的“笔画书写”或“进度条填充”效果。

Repeater(rp):重复器

Repeater(重复器)能将它之前的所有图形元素(包括形状、样式甚至其他修改器)复制多次,并对每个副本应用递增的变换,快速创建阵列、放射状、循环等复杂图案。

核心属性:

{
  "ty": "rp", // 类型:Repeater
  "c": { // Copies(副本数量)
    "a": 0,
    "k": 5 // 生成5个副本(包含原始图形)
  },
  "o": { // Offset(副本索引偏移)
    "a": 0,
    "k": 0 // 控制从哪个“虚拟副本”开始渲染,可用于动画
  },
  "m": 1, // Composite(合成模式):1=Above(后续副本在上方),2=Below(后续副本在下方)
  "tr": { // Transform(每个副本的增量变换)
    "p": { "a":0, "k":[20, 0] }, // 位置增量:每个副本右移20px
    "s": { "a":0, "k":[90, 90] }, // 缩放增量:每个副本缩小至90%
    "r": { "a":0, "k":[30] } // 旋转增量:每个副本旋转30度
  }
}
  • c (Copies) :生成的副本总数(包含原始图形)。
  • o (Offset) :控制从哪个“虚拟副本”开始渲染,可用于动画。
  • m (Composite) :控制副本的堆叠顺序。1 表示新副本叠在上方,2 表示新副本叠在下方。
  • tr (Transform) :定义每个新副本相对于前一个副本的变换增量,是创造规律性变化的关键。

其他修改器简介

除了上述两个,Lottie 还提供了其他实用的修改器来扩展图形能力:

类型标识 (ty) 名称 核心作用 关键属性与备注
rd Round Corners (圆角) 将路径的所有尖角转换为指定半径的圆角。 r:圆角半径。
mm Merge Paths (合并路径) 将多个路径合并为一个(类似布尔运算)。 mm:合并模式(如相加、相减、交集等)。 ⚠️ 注意:官方文档标注此功能目前不被支持,使用时需谨慎测试。

🎨小试牛刀:loading动画

让我们将学到的 Trim Path 知识应用到实践中,制作一个经典的 loading 动画。下面的示例将展示如何通过动画 o(偏移)属性来创建持续旋转的圆环进度效果。

录屏2026-01-08 17.48.53.gif

{
  "v": "5.7.4",
  "fr": 30,
  "ip": 0,
  "op": 90,
  "w": 800,
  "h": 600,
  "nm": "Trim Path演示",
  "layers": [
    {
      "ty": 4,
      "nm": "圆形绘制动画",
      "ind": 1,
      "ks": {
        "p": { "a": 0, "k": [400, 300, 0] }
      },
      "shapes": [
        {
          "ty": "gr",
          "nm": "圆形组",
          "it": [
            // 1. 圆形路径
            {
              "ty": "el",
              "nm": "圆形路径",
              "p": { "a": 0, "k": [0, 0] },
              "s": { "a": 0, "k": [200, 200] }
            },
            // 2. 描边样式
            {
              "ty": "st",
              "nm": "描边",
              "c": { "a": 0, "k": [0.2, 0.8, 1, 1] },
              "w": { "a": 0, "k": 12 },
              "lc": 2,
              "lj": 2
            },
            // 3. Trim Path 修改器 ⭐️
            {
              "ty": "tm",
              "nm": "Trim Path",
              "s": {  // 起点百分比 (0-100)
                "a": 1,
                "k": [
                  { "t": 0, "s": [0], "e": [100] },
                  { "t": 90, "s": [100] }
                ]
              },
              "e": {  // 终点百分比 (0-100)
                "a": 0,
                "k": 100
              },
              "o": {  // 偏移角度 (0-360)
                "a": 0,
                "k": 0
              }
            },
            // 4. 组变换
            { "ty": "tr" }
          ]
        }
      ]
    }
  ]
}

八、高级特性 - 蒙版、效果、表达式

本章将简要介绍 Lottie 中几个高级但常用的特性。理解这些概念有助于您阅读和分析更复杂的动画文件,但在实际创作中,请注意它们在不同平台和渲染器中的支持程度可能有所差异。

蒙版(Mask):masksProperties 数组

蒙版用于控制图层的显示区域,实现剪切、遮罩等效果。在图层对象中,通过 masksProperties 数组定义。

json

"masksProperties": [
  {
    "mode": "a",           // 蒙版模式:a=相加,s=相减,i=相交等
    "pt": {                // 路径(Path),定义蒙版形状
      "a": 0,
      "k": { "v": [[0,0], [100,0], [100,100], [0,100]], "c": true }
    },
    "o": { "a": 0, "k": 100 },  // 不透明度(Opacity)
    "inv": false,                // 是否反转(Inverted)
    "nm": "蒙版1"                // 名称(Name)
  }
]

关键属性:

  • mode:蒙版混合模式。除了常用的 "a"(Add,相加)和 "s"(Subtract,相减),Lottie Schema 还定义了其他模式,但并非所有都被完全支持。

    模式代码 名称 (英文) 作用效果
    "n" None (无) 禁用蒙版
    "a" Add (相加) 合并多个蒙版区域
    "s" Subtract (相减) 从现有区域中减去
    "i" Intersect (相交) 只保留蒙版重叠区域
    "l" Lighten (变亮) 保留较亮区域
    "d" Darken (变暗) 保留较暗区域
    "f" Difference (差异) 显示颜色差异区域
  • pt:蒙版路径,其数据结构(含 vio)与形状图层中的自由路径(sh)完全相同。

  • o:蒙版的不透明度。

  • inv:布尔值,为 true 时反转蒙版区域。

效果(Effects):ef 数组

Lottie 支持部分 After Effects 内置效果,通过图层的 ef 数组定义。请注意,支持的效果非常有限,且并非所有AE效果都能被完美支持或渲染。

根据官方 Schema 文档,有明确定义的效果类型包括:

  • 填充 (Fill): ty: 21
  • 描边 (Stroke): ty: 22
  • 色调 (Tint): ty: 20
  • 三色调/专业色阶 (Tritone/Pro Levels): ty: 23

json

"ef": [{
  "ty": 21,                 // 效果类型:21 = 填充 (Fill)
  "nm": "颜色叠加",
  "en": 1,                  // 启用 (Enabled)
  "ef": [{
    "ty": 10, 
    "nm": "颜色", 
    "v": { "a": 0, "k": [1, 0, 0, 1] } // 红色
  }]
}]

重要说明:“高斯模糊 (ty: 29)”和“发光 (ty: 27)”等效果在公开的官方 Schema 文档中未有明确定义。它们可能由 Bodymovin 插件导出,但不一定被所有 Lottie 渲染器支持,在实际使用前必须进行充分的兼容性测试。

表达式(Expression):x 字段

表达式是 After Effects 中用于创建属性间动态关联的脚本语言。在 Lottie 中,表达式可以存储在属性的 x 字段中。

json

"p": {
  "a": 1,
  "k": [{"t":0, "s":[0,0]}, {"t":30, "s":[100,100]}],
  "x": "loopOut('cycle')"  // 表达式:循环播放动画
}

表达式的作用与局限性

  • 作用:可以创建循环 (loopOut)、随机 (wiggle)、数学关联等复杂动画逻辑,无需大量关键帧。
  • 局限性平台支持极不完整。仅有少数最基础的表达式可能在部分平台上被识别,复杂的表达式通常会被忽略或导致动画错误。在需要跨平台稳定播放的动画中,应尽量避免使用表达式。

混合模式(Blend Mode):bm 字段

混合模式控制当前图层如何与下层图层进行颜色混合。在图层(根对象)或形状样式元素(如 flst)中通过 bm 字段定义。

json

"bm": 3  // 叠加模式 (Overlay)

混合模式值速查表 (根据官方 /helpers/blendMode.json):

模式 (英文) 模式 (英文)
0 Normal (正常) 8 Hard Light (强光)
1 Multiply (相乘) 9 Soft Light (柔光)
2 Screen (屏幕) 10 Difference (差值)
3 Overlay (叠加) 11 Exclusion (排除)
4 Darken (变暗) 12 Hue (色相)
5 Lighten (变亮) 13 Saturation (饱和度)
6 Color Dodge (颜色减淡) 14 Color (颜色)
7 Color Burn (颜色加深) 15 Luminosity (明度)

注意:混合模式在 SVG/HTML 渲染器中支持较好,在其他渲染器(如 Canvas)或某些移动端平台中可能需要降级处理或不被支持。


九、结语

本文系统性地解析了 Lottie JSON 的核心数据结构。以下是全文知识要点的回顾与总结:

核心数据结构总览

模块 关键对象/字段 核心作用与要点
顶层结构 vfripopwh 定义动画全局信息:版本、帧率、时间轴、画布尺寸。
assetslayerschars 三大数据支柱:可复用资源、图层序列、矢量字形。
图层系统 ty (0-5) 标识六种图层类型:预合成、纯色、图像、空对象、形状、文本。
indparentipopst 控制图层索引、父子关系、时间属性(入点、出点、起始时间)。
ks 变换属性容器,包含锚点(a)、位置(p)、缩放(s)、旋转(r)、透明度(o)等。
动画系统 ks 下的 a 字段 属性动画开关:0为静态值,1为动画值(关键帧数组)。
关键帧 k 数组 定义动画轨迹,包含时间(t)、值(s/e)、缓动(i/o)。
形状系统 shapes 数组 形状图层的核心容器,元素按索引顺序渲染叠加。
ty: el/rc/sr/sh 基础图形:椭圆、矩形、星形、自由路径(贝塞尔曲线定义)。
ty: gr (组) 使用 it 数组组织子元素,实现层级管理与统一变换。
样式系统 ty: fl (填充) 定义形状填充色(纯色或渐变)。
ty: st (描边) 定义轮廓线样式,包括线宽(w)、端点(lc)、连接(lj)。
修改器 ty: tm (Trim) 路径裁剪,通过动画起点(s)、终点(e)、偏移(o)实现绘制效果。
ty: rp (Repeater) 图形重复器,通过副本数(c)和增量变换(tr)创建阵列。
高级特性 masksProperties 蒙版数组,通过路径(pt)和模式(mode)控制图层显示区域。
ef (效果), bm (混合模式) 实现滤镜与图层混合,需注意平台支持度
x (表达式) 支持简单表达式驱动属性,跨平台支持有限

十、写在最后:笔者的思考💡

在整理 Lottie 的技术细节时,我反复思考一个核心问题:我们看到的这套 JSON 结构,到底是由什么决定的?

最直接的答案是“为了在网页上播放”。但这只是目的,并未解释其形态。我的思路分两步推进:第一,是 Web 的渲染能力(如 Canvas)限制了它的设计;第二,是否有更底层的蓝本在主导结构。

通过逐项对比,我找到了更关键的依据。Lottie JSON 的结构,本质上是对 After Effects 内部动画数据模型的直接翻译。  例如,JSON 中的 ks 对象精确对应了 AE 图层的“变换”属性组,shapes 数组则完全复现了 AE 形状层的堆叠逻辑。设计者的首要任务,是为 AE 的动画状态提供一个无损且精确的数据描述格式

那么,Web 技术(如 Canvas/SVG)的作用是什么?我认为它主要扮演了  “支持度评估”与“性能优化”的角色。它并未改变数据描述的根本方式,而是基于实现难度与性能成本,划定了哪些 AE 高级功能可以(或不可以)被包含在这个格式中。例如,一些复杂的实时滤镜可能因性能考量而被排除。

这自然引向更深一层:AE 自身的这套强大模型又是如何建立的?  它并非凭空创造,而是对更早行业的数字化融合。其“合成”与“图层”概念源自电影工业的胶片叠加流程;“关键帧”动画继承自传统手绘动画的生产方式;而所有视觉变换的根基,则是计算机图形学提供的数学工具(如坐标变换、贝塞尔曲线)。

因此,学习 Lottie 最有效的方法,并非孤立记忆 JSON 字段,而是理解它作为“AE 模型的数据接口”这一定位。掌握 AE 的核心概念,就能理解 Lottie 绝大部分的设计逻辑。这揭示了一种高效的学习路径:当面对一个出色的“技术转译层”时,直接研究它所转译的源系统,往往是理解其设计最快的方式。

前端向架构突围系列 - 框架设计(六):解析接口职责的单一与隔离

2026年1月13日 10:17

写在前面

这是《前端像架构突围》系列的第六篇。

在上一篇我们聊了 契约继承原则 ,今天我们把显微镜聚焦得更细一点,聊聊**“接口”**。

很多同学看到“接口职责单一”和“接口隔离”,第一反应是:跟我前端切图有什么关系?”

关系大了。你是否经历过一个 Table 组件写了 30 多个 props?你是否见过一个 useCommon Hook 里塞进了登录、埋点、弹窗和格式化逻辑?

前端的**“腐烂” ,往往不是因为技术栈落后,而是因为接口设计的边界模糊**。今天我们不谈枯燥的 SOLID 定义,只谈在前端组件、Hooks 和数据层设计中,如何利用**“隔离”**思维,从根本上消灭“上帝组件”。

image.png


一、 前端视角的“接口”究竟是什么?

在架构师的眼里,前端的 Interface 绝不仅仅是 TypeScript 里的 interface Props {}

前端的“接口”,是模块与外界通信的全部契约。 它包含三个维度:

  1. 数据契约:组件的 Props、Vue 的 Emits、以及后端返回的 JSON 结构。
  2. 逻辑契约:Hooks (Composables) 暴露出的 value 和 function。
  3. 交互契约:组件通过 ref 暴露给父组件的实例方法(如 modalRef.open())。

“职责单一”与“隔离”的核心目标只有一个:降低耦合,控制变化的影响范围。

如果你的组件因为“UI调整”要改,因为“后端字段更名”要改,甚至因为“埋点库升级”也要改,那这个组件就成了**“变化磁铁”**,它违反了单一职责,迟早会崩塌。


二、那些违反 ISP (接口隔离原则) 的反模式

我们先来看一个典型的“车祸现场”。这是一个展示用户信息的卡片组件。

反模式 1:全量依赖(贪婪接口)

// 类型定义:后端返回的完整的用户数据模型
interface User {
  id: string;
  name: string;
  avatar: string;
  email: string;
  role: 'admin' | 'user';
  settings: { theme: string; notify: boolean };
  // ... 可能还有20个字段
}

interface UserCardProps {
  user: User; //  罪魁祸首:直接依赖整个 User 对象
  onEdit: (u: User) => void;
}

const UserCard = ({ user, onEdit }: UserCardProps) => {
  // 组件其实只用到了 avatar 和 name
  return (
    <div className="card">
      <img src={user.avatar} />
      <span>{user.name}</span>
      <button onClick={() => onEdit(user)}>Edit</button>
    </div>
  );
};

为什么这是架构上的坏味道?

  1. 语义污染UserCard 本质上只需要“图片”和“名字”。如果你强制传入整个 User 对象,导致我在“好友列表”里复用这个组件时,必须构造一个假的 User 对象(这就叫 mocking hell)。
  2. 不必要的重渲染:如果 User 对象里的 settings.theme 变了,UserCard 会感知到 props 变化从而 re-render,尽管它根本不在乎 theme。
  3. 类型系统的脆弱性:后端如果把 email 字段删了,虽然 UserCard 没用到 email,但 TypeScript 可能会在父组件传参处报错,因为类型契约断了。

破局方案:按需声明(最小知识原则)

架构师的解法是:组件不应该依赖它不需要的东西。

// 1. 定义组件真正关心的接口(ISP)
interface UserCardProps {
  avatarUrl: string;
  displayName: string;
  onEdit: () => void; // 甚至不需要回传 User,由父组件闭包处理
}

// 2. 只有 UI 关注点
const UserCard = ({ avatarUrl, displayName, onEdit }: UserCardProps) => {
  return (
    <div className="card">
      <img src={avatarUrl} />
      <span>{displayName}</span>
      <button onClick={onEdit}>Edit</button>
    </div>
  );
};

// 3. 在父组件层进行“适配”
const Parent = () => {
  const { data: user } = useUser();
  
  return (
    <UserCard 
      avatarUrl={user.avatar}
      displayName={user.name}
      onEdit={() => handleEdit(user.id)}
    />
  );
};

架构收益: UserCard 从“特定业务组件”进化成了“通用 UI 组件”。现在它可以展示“当前用户”,也可以展示“推荐好友”,甚至可以展示“宠物信息”(只要有图和名字)。


三、配置地狱 vs 组合隔离

另一种常见的违反“职责单一”的场景,出现在通用组件的设计上。

为了复用,我们经常往组件里加 flag。

反模式 2:上帝组件(God Component)

// 一个试图满足所有人的 List 组件
interface ListProps {
  data: any[];
  //  职责混乱:既负责渲染列表,又负责头部,又负责搜索,又负责分页
  showSearch?: boolean;
  searchPlaceholder?: string;
  onSearch?: (val: string) => void;
  showPagination?: boolean;
  total?: number;
  renderHeader?: boolean;
  headerTitle?: string;
  // ... props 爆炸
}

随着业务迭代,这个组件内部会充斥着 if (showSearch) { ... } 的判断。每次修改任何一个小逻辑,都要小心翼翼防止改坏了其他功能。

破局方案:组合优于配置 (Composition over Configuration)

我们要利用 React/Vue 的 Slot (插槽)Children 机制,将职责隔离给外部。

// 职责单一:List 只管渲染列表
const List = ({ children }) => <div className="list">{children}</div>;
List.Item = ({ title }) => <div className="item">{title}</div>;

// 职责单一:Search 只管搜索
const SearchBar = ({ onSearch }) => <input onChange={...} />;

// 业务层:自由组合
const UserListFeature = () => {
  return (
    <div className="container">
      {/* 搜索职责隔离 */}
      <SearchBar onSearch={handleSearch} />
      
      {/* 列表职责隔离 */}
      <List>
        {users.map(u => (
          <List.Item key={u.id} title={u.name} />
        ))}
      </List>
      
      {/* 分页职责隔离 */}
      <Pagination total={100} />
    </div>
  );
};

架构收益:

  • List 组件 不再需要知道“搜索”的存在。
  • SearchBar 组件 可以单独优化、单独复用。
  • 如果哪天产品经理说“把搜索框放到列表底部”,你只需要调整 JSX 的顺序,而不需要去修改 List 组件内部那复杂的 if/else 渲染逻辑。

四、Hooks 与逻辑层的职责隔离

UI 隔离大家多少有点概念,但逻辑层的隔离往往是重灾区。我们经常看到一个 useTable 承担了所有工作。

混杂逻辑

const useTable = (apiEndpoint) => {
  // 1. 数据获取
  const [data, setData] = useState([]);
  
  // 2. 分页状态
  const [page, setPage] = useState(1);
  
  // 3. 筛选逻辑
  const [filters, setFilters] = useState({});
  
  // 4. URL 同步逻辑 (副作用)
  useEffect(() => {
     history.push(`?page=${page}`);
  }, [page]);
  
  // 5. 甚至还有 Excel 导出逻辑
  const exportExcel = () => { ... };

  return { data, page, setPage, exportExcel, ... };
}

这违反了 SRP。如果你只想换个 URL 同步库(比如从 react-router 换到 next/router),你得去改这个核心 Hook,风险极大。

逻辑拆分与组装 (Headless 思想)

好的架构应该是积木式的:

// 1. 纯粹的分页逻辑 (无副作用)
const usePagination = (initialPage = 1) => { ... };

// 2. 纯粹的数据请求 (不关心 UI)
const useFetchData = (params) => { ... };

// 3. 独立的 URL 同步逻辑
const useUrlSync = (state) => { ... };

// 4. 业务层 Hook:负责组装 (Orchestration)
const useUserTableLogic = () => {
  const { page, setPage } = usePagination();
  const { filters } = useFilters();
  
  // 组装逻辑:当 page 变了,去请求数据
  const { data, loading } = useFetchData({ page, ...filters });
  
  // 组装副作用:状态变了同步 URL
  useUrlSync({ page, filters });
  
  return { data, loading, page, setPage };
};

架构收益: * usePagination 可以被任何列表、轮播图复用。

  • 测试 usePagination 不需要 mock API 请求。
  • 修改 URL 同步逻辑不会影响数据请求逻辑。

五、数据接口的终极隔离 (ACL)

最后一个关键点是前端与后端的接口隔离

很多前端项目直接在组件里使用后端的字段名:

// 糟糕的代码:UI 深度耦合后端字段
<div>{data.user_real_name_v2}</div>
<div>{data.is_vip_flag === 1 ? 'VIP' : 'Normal'}</div>

如果后端重构,把 user_real_name_v2 改成了 realName,把 is_vip_flag 改成了布尔值,你的项目里可能有 50 个文件要跟着改。

架构突围方案:引入 Adapter(适配器)层。

// api/user.ts
// 定义前端需要的纯净 Model
interface UserModel {
  name: string;
  isVip: boolean;
}

// 适配器:将后端脏数据清洗为前端标准数据
const adaptUser = (serverData: any): UserModel => ({
  name: serverData.user_real_name_v2 || serverData.name, // 甚至可以做兼容
  isVip: serverData.is_vip_flag === 1
});

// 组件层只消费 UserModel,完全不知道 serverData 的存在
const UserProfile = ({ user }: { user: UserModel }) => {
  return <div>{user.name} - {user.isVip ? 'VIP' : ''}</div>
};

这就是**“数据接口隔离”**。无论后端怎么变,变化只止步于 adaptUser 函数,UI 层稳如泰山。


六、 总结与思考

在《前端像架构突围》的语境下, “接口职责单一隔离”不仅仅是代码洁癖,它是应对系统复杂度的核心手段。

  • 对 Props 隔离:让组件更通用,减少无谓渲染。
  • 对 Children 隔离:用组合代替配置,消灭上帝组件。
  • 对 Hooks 隔离:逻辑解耦,提升可测试性。
  • 对 API 隔离:建立防腐层,保护前端代码的稳定性。

下一步行动建议: 现在打开你项目里的 components 文件夹,找出一个 Props 超过 10 个的组件,或者一个代码行数超过 300 行的 Hook。试着问自己: “这个模块是不是承担了太多的职责?” ,然后尝试用本文提到的“按需声明”或“组合模式”进行一次重构。

架构能力的提升,就发生在这一次次对“边界”的重新审视中。


互动话题

在业务中更新迭代过快时, 可以不去关心这些东西, 但这些东西的输出, 更多的是要去转变你的思维, 让你有一个概念、印象这是一个潜移默化的转变过程, 让你看问题、看框架时、看业务时, 能站在上一层。

你的项目中是否也有那种“改一行代码,整个页面都崩了”的祖传组件?欢迎在评论区分享你的“屎山”重构血泪史!

深度解析:如何彻底终结 Flutter 异步操作中的 BuildContext 崩溃?

作者 JarvanMo
2026年1月13日 09:34

这种情况我们都见过: 你在本地跑着 App,点下“提交”按钮,API 请求顺利完成,页面成功跳转。一切看起来都完美无缺。于是你信心满满地合并了 PR,发布上线。结果,后台日志突然就开始对着你疯狂咆哮

image.png

Looking up a deactivated widget’s ancestor is unsafe.

At this point the state of the widget’s element tree is no longer stable.

“查找已停用 Widget 的祖先节点是不安全的。”

“此时,该 Widget 对应的 Element 树状态已不再稳定。”

你尝试在本地复现这个 Bug,但怎么试都没问题。 这就是所谓的异步间隙(Async Gap) 。如果你没有针对它做防御性编程,那么此时此刻,那些网速较慢的用户正在经历频繁的闪退。

“我电脑上运行好好的” —— 这个陷阱

作为工程师,我们习惯在飞速的 Wi-Fi 和模拟器上做测试。点一下按钮,API 只要 100 毫秒就返回了。在结果回来之前,我们根本没时间切换页面。

但在现实世界里,你的用户可能用的是 3G 甚至更慢的网络。

  1. 他们点下“登录”。
  2. 请求卡住了 3 秒。
  3. 用户等烦了(或者意识到邮箱填错了),于是按了**“返回”**键。
  4. 此时,Widget 被销毁(Disposed),页面彻底消失了。
  5. 紧接着,API 终于返回成功了。

你的代码在 await 之后恢复执行,它顺手抓起 context 准备跳转到首页……然而,这个 context 指向的 Widget 此时已经在**垃圾回收器(GC)**里排队了。

解决方案:一行微小的代码,省去巨大的头疼

在 Flutter 3.7 之前,处理这个问题很烦人。你必须在 State 类里检查 mounted 属性,代码写起来很不优雅。现在,我们有了一个简单且统一的方案:

context.mounted

这个属性允许你检查 context 是否依然有效,以及它是否还在 Widget 树中。 每当你完成一个异步调用,并准备执行以下操作时:

  • 页面跳转 (Navigation)
  • 弹出对话框 (Dialogs)
  • 显示底栏通知 (Snackbars)
  • 查找主题 (Theme lookups)
  • 任何需要用到 context 的操作

……你都必须加上一层防护。

不安全的写法:

void onButtonTapped(BuildContext context) async {  
    await myLongRunningTask();  
  
// 如果用户已经离开页面,这一行就会导致 App 崩溃。
    Navigator.pop(context);  
}

安全的写法:

void onButtonTapped(BuildContext context) async {
  // 1. 开始异步任务(例如 API 请求或耗时计算)
  await myLongRunningTask();
  
  // 2. 防御性检查(核心守卫语句)
  // 如果此时 Widget 已经从树中卸载,则立即停止执行后续代码。
  if (!context.mounted) return;

  // 3. 只有在 context 依然有效(Mounted)的情况下,
  // 此时使用 context 进行导航或 UI 操作才是安全的。
  Navigator.pop(context);
}

为什么资深工程师必须在意这一点?

这不仅仅是为了避免开发阶段出现红屏报错,更是**防御性编程(Defensive Programming)**的核心体现。

我们无法掌控网络状况,也无法左右用户的行为。 我们唯一能控制的,是当这两者“掉链子”时,我们的代码将如何应对。 养成随手加上 if (!context.mounted) return; 的习惯虽然只是个微小的细节,但它正是脆弱的 Demo 演示健壮的生产级工程之间的分水岭。

最后一个问题

你最近检查过 Firebase Crashlytics 日志里的这个特定报错吗?或者,你还有哪些曾让你抓狂的“隐形”Bug?

欢迎在评论区留言——我很期待听到你们的调试“血泪史”。

2025 年 HTML 年度调查报告公布!好多不知道!

作者 冴羽
2026年1月14日 14:12

前言

近日,「State of HTML 2025」年度调查报告公布。

这份报告收集了全球数万名开发者的真实使用经验和反馈,堪称是 Web 开发领域的“年度风向标”。

让我们看看 2025 年,大家都用了 HTML 的哪些功能。

注:State of JS 2025 还未公布,欢迎关注公众号:冴羽,第一时间获取报告结果。

特性 Top 5

开发者使用最多的特性 Top 5 分别是:

  1. 地标元素(Landmark Elements)

其实就是<aside>, <article>, <main>, <nav>, <section>这些,想必你也经常使用。

  1. tabindex 属性:使 HTML 元素可聚焦,允许或阻止它们按顺序获得焦点。
<div role="button" tabindex="0">I’m Tabbable</div>
  1. <svg>(内联 SVG)
<svg>
  <circle cx="50" cy="50" r="50" />
</svg>
  1. <canvas>
<canvas width="200" height="200"></canvas>
  1. loading="lazy":懒加载
<img src="picture.jpg" loading="lazy" /> <iframe src="supplementary.html" loading="lazy"></iframe>

表单 Top 5

开发者使用最多的表单功能 Top 5 分别是:

  1. <input type="color">:颜色选择器
<input type="color" />
  1. <datalist>:供用户选择的表单控件
<input name="country" list="countries" />
<datalist id="countries">
  <option>Afghanistan</option>
  ...
</datalist>
  1. input.showPicker():打开具有选择器的表单控件(颜色选择器、日期输入框等)
<input id="dateInput" type="date" /> <button onclick="dateInput.showPicker()">Select date</button>
  1. contenteditable="plaintext-only":允许编辑元素的原始文本,但不允许进行富文本格式设置
<h2 class="title" contenteditable="plaintext-only"></h2>
  1. Customizable Select:可自定义样式和样式的下拉控件
select,
::picker(select) {
  appearance: base-select;
}

图形和多媒体 Top 5

开发者使用最多的图形和多媒体功能 Top 5 分别是:

  1. <svg>(内联 SVG)
<svg>
  <circle cx="50" cy="50" r="50" />
</svg>
  1. <canvas>
<canvas width="200" height="200"></canvas>
  1. ctx.drawElement():使开发者可以在 HTML 元素上绘制 <canvas>
<canvas id="canvas" layoutsubtree="true">
  <p>Hello world!</p>
</canvas>
<script type="module">
  const ctx = canvas.getContext("2d");
  const text = canvas.querySelector("p");
  ctx.drawElement(text, 30, 0);
</script>
  1. WebGL:一个基于 OpenGL 的底层 API
const gl = canvas.getContext("webgl");
  1. colorSpace:设置图形的颜色空间
const ctx = canvas.getContext("2d", {colorSpace: "display-p3"});

内容 Top 5

开发者使用最多的图形和多媒体功能 Top 5 分别是:

  1. 内容安全策略 (CSP):网站向浏览器发出的一组指令,用于帮助检测和缓解 XSS 攻击
Content-Security-Policy: script-src 'self';
  1. <template>:内容在加载页面时不会呈现,但随后可以在运行时使用 JavaScript 实例化
<template id="counter">
  <div class="counter">Clicked {{ times }} times</div>
</template>
  1. Intl.LocaleAPI:国际化 API
const us = new Intl.Locale("en-US");
  1. HTML 模块:通过 JS imports 导入 HTML 文件,并访问其元素和 JS 导出
<script type="module">
  import { TabList } from "./tablist.html" with { type: 'html' };
  customElements.define("tab-list", TabList);
</script>
  1. Sanitizer API:element.setHTML() 以及 Document.parseHTML() API,通过清理 HTML 中不受信任的字符串来防止 XSS 攻击。
greeting.setHTML('Hello ' + nameInput.value);

交互 Top 5

开发者使用最多的交互功能 Top 5 分别是:

  1. <details><summary>:隐藏或显示内容
<details>
  <summary>Details</summary>
  Longer content
</details>
  1. <dialog>:对话框
<dialog id="confirm">
  <form method="dialog">
    Are you sure?
    <button value="1">Yes</button>
    <button value="0">No</button>
  </form>
</dialog>
  1. <details name>:手风琴效果
<details open name="sidebar_panel">
  <summary>Main info</summary>
  <!-- controls -->
</details>
<details name="sidebar_panel">
  <summary>Style</summary>
  <!-- controls -->
</details>
  1. popover:弹出窗口
<button popovertarget="foo">Toggle the popover</button>
<div id="foo" popover>Popover content</div>
  1. element.before():将一个元素移动到另一个元素之前的 DOM 方法
referenceElement.before(newElement);

性能 Top 5

开发者使用最多的性能功能 Top 5 分别是:

  1. loading="lazy":懒加载
<img src="picture.jpg" loading="lazy" /> <iframe src="supplementary.html" loading="lazy"></iframe>
  1. srcset 和 sizes 属性:提供多个源图像,以帮助浏览器选择正确的图像
<img srcset="fairy-med.jpg 480w, fairy-large.jpg 800w" sizes="(max-width: 600px) 480px, 800px" src="fairy-large.jpg" alt="Elva dressed as a fairy" />
  1. fetchpriority 属性:浏览器优先获取该资源
<img src="logo.svg" fetchpriority="high" />
  1. <img sizes="auto" loading="lazy">sizes="auto" 属性会在图像加载之前为其预留布局空间,从而避免一些布局偏移
<img sizes="auto" loading="lazy" />
  1. blocking="render":阻止渲染(但不阻止解析),直到某些资源加载完毕
<script blocking="render" async src="async-script.js"></script>

Web 组件 Top 5

开发者使用最多的 Web 组件功能 Top 5 分别是:

  1. 自定义元素
<my-switch start="On" end="Off">Wi-Fi</my-switch>
  1. 定义自定义元素
class MyElement extends HTMLElement { … }
customElements.define("my-element", MyElement);
  1. Shadow DOM:将外部不可见的元素封装起来,并使用不影响页面其余部分的 CSS 对其进行样式设置
this.shadowRoot = this.attachShadow({ mode: "open" });
  1. slot 属性:将组件 UI 中预定义的部分替换为自己的元素
<my-switch>
  Wi-Fi
  <i slot="start" class="icon-on">On</i>
  <i slot="end" class="icon-off">Off</i>
</my-switch>
  1. 声明 Shadow DOM:使用 HTML 定义 Shadow 树,例如在服务器端渲染 Web 组件时
<host-element>
  <template shadowrootmode="open">
    <!-- Shadow content -->
  </template>
</host-element>

系统功能 Top 5

开发者使用最多的系统功能 Top 5 分别是:

  1. Web Share API:将内容共享给用户选择的各种目标的机制
navigator.share(shareData);
  1. 文件系统访问 API:访问用户本地设备上的文件和目录,并创建可写文件,以便进行更新
const handle = await window.showSaveFilePicker();
const writable = await handle.createWritable();
await writable.write("Hello, world!");
await writable.close();
  1. SpeechRecognition:将麦克风输入转换为文本的 API
const rec = new SpeechRecognition();
rec.lang = "en-US";
rec.addEventListener("result", (e) => console.log(e.results[0][0].transcript));
rec.start();
  1. share_target manifest field:允许 PWA 通过系统共享对话框接收来自其他应用程序共享的数据(文本、文件、URL)
"share_target": {
  "action": "/share",
  "method": "POST",
  "enctype": "multipart/form-data",
  "params": {
    "title": "title",
    "text": "text",
    "url": "url",
    "files": [{ "name": "image", "accept": ["image/*"] }]
  }
}
  1. 文件处理 API:允许 PWA 将自身注册为某些文件类型的处理程序
"file_handlers": [{
  "action": "/open-file",
  "accept": {
    "image/svg+xml": ".svg",
    "image/png": ".png"
  }
}]

无障碍 Top 5

开发者使用最多的无障碍功能 Top 5 分别是:

  1. 地标元素
  2. tabindex 属性:使 HTML 元素可聚焦,允许或阻止它们按顺序获得焦点
<div role="button" tabindex="0">I’m Tabbable</div>
  1. <search>:用于封装搜索用户界面的语义元素
<search>
  <form action="search.php">
    <label>Find: <input name="q" type="search" /></label>
    <button>Go!</button>
  </form>
</search>
  1. focusgroup 属性:使用键盘方向键在可聚焦元素之间进行键盘焦点导航
<div focusgroup="wrap horizontal">
  <!-- child elements -->
</div>

最后

我们通常认为最炫酷的功能会最吸引开发者,比如人工智能 API、3D/XR/AR 或设备 API。

然而,年复一年,最终脱颖而出的却往往是那些看似平淡无奇的功能,甚至是一些非常普通的功能:下拉菜单、组合框、弹出框、对话框、表单验证、文件加载和保存、模板、安全地显示用户生成内容、图标等等。

有人可能会问:“这些功能不是早就有了吗?”

确实是,但问题在于 ——当用户界面无法自定义或设置样式时,它实际上就等于无法使用。

于是你不得不重复造轮子,拼一堆第三方库。结果明明是基础需求,却搞得像 “高端操作”。

但好消息是:HTML 正在变好!

2023 年还在讨论的功能,现在已经在主流浏览器上线了;之前没法用的 Popover API,现在所有主流浏览器都支持了。

虽然开发者的信任要滞后很多……

比如 Popover API 明明已经全支持了,却还是开发者投诉 “浏览器不支持” 最多的功能 —— 不是浏览器没跟上,是我们还没反应过来 “这个功能已经能用了”。

此外,AI 也拖了后腿。

按理说,AI 懂现代 Web 功能,应该能帮我们更快应用新特性,但实际情况是 —— AI 太保守了,推荐的都是 “老办法”,反而让新功能的普及变慢了。

总的来说,HTML 的未来方向很清晰:更灵活、更能表达需求、更贴合开发者实际开发习惯。

最后使用报告中的一句话:

“Web 的进步很少轰轰烈烈,但都是累积的。每多一个基本功能,就少用一次变通方案、少依赖一个库、少写一个脆弱的 hack。等这些基础都到位了,整个 Web 开发都会变轻松。”

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

鸿蒙应用的“任意门”:Deep Linking 与 App Linking 的相爱相杀

作者 SameX
2026年1月14日 13:50

写在前面:本文基于 HarmonyOS Next 的摸爬滚打经验总结。技术这东西更新快,如果哪里说得不对,或者你有更骚的操作,欢迎在评论区拍砖交流。转载请注明出处,谢啦。

做移动端开发,最烦的是什么?是应用像一个个孤岛,互相都不通气。

用户在微信里点个链接,想跳到你的 App 里看详情,结果要么没反应,要么跳出一堆甚至都没听说过的 App 让你选。这就很尴尬了。为了解决这个问题,鸿蒙系统给咱们提供了两把钥匙:一把叫 Deep Linking,一把叫 App Linking

很多兄弟容易搞混,觉得这俩不是一回事吗?确实,目的都是为了“跳转”,但手段和段位可大不一样。今天咱们就来扒一扒这两者的底裤。


一、 Deep Linking:简单粗暴的“土法炼钢”

Deep Linking 说白了,就是利用自定义协议(Scheme)来实现跳转。这招在移动开发界属于“老兵”了。

它是怎么工作的?

你想让别人通过暗号找到你,你就得先起个暗号。比如你做个地图 App,你可以跟系统喊一嗓子:“以后只要有人喊 geo:// 开头的,都归我管!”

这就是 Deep Linking 的核心:自定义 Scheme

  • 优点:门槛低,随便定义。my-super-app://,想怎么写怎么写。
  • 缺点:太随意了。万一隔壁老王也定义了 geo:// 怎么办?这时候系统就懵圈了,只能弹个窗让用户自己选。这一选,用户体验就断档了。而且这玩意儿不安全,谁都能冒充。

怎么配置?

在鸿蒙里,你得在 module.json5 里通过 skills 标签去“抢注”这个暗号。

// module.json5
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "skills": [
          {
            "uris": [
              {
                "scheme": "mychat", // 你的暗号
                "host": "talk.com", // 具体的接头地点
                "path": "room"      // 具体的房间号
              }
            ]
          }
        ]
      }
    ]
  }
}

这一下,只要有链接是 mychat://talk.com/room,系统就会把目光投向你。


二、 App Linking:持证上岗的“正规军”

华为现在大力推的是 App Linking。为啥?因为它正规、安全、体验好。

它强在哪?

App Linking 不再用那些乱七八糟的自定义协议,而是直接用标准的 HTTPS 链接(比如 https://www.example.com)。

这里有个核心逻辑:域名校验。 系统会去验证:“你这个 App 到底是不是这个域名的亲儿子?”。

  • 如果验证通过:用户点击链接,直接拉起 App,没有任何弹窗干扰,丝般顺滑。
  • 如果没安装 App:既然是 HTTPS,那就直接用浏览器打开网页。这就叫“进可攻退可守”,用户永远不会看到 404 或者无响应。

这就特别适合做社交分享、广告引流,或者短信召回老用户。

怎么配置?(重点来了,这里稍微繁琐点)

这玩意儿需要“双向奔赴”:App 端要认领域名,服务器端要认领 App。

  1. 服务器端搞个“介绍信”: 你得在你的网站服务器根目录下,创建一个 .well-known/applinking.json 文件。这文件里写啥?写你 App 的身份证号(APP ID)。 这是为了告诉全天下:这个 App 是我罩着的。
  2. App 端开启“雷达”: 在 AGC 控制台开通服务后,你得在 module.json5 里也配上 skills,不过这次 scheme 必须是 https注意:在 AGC 后台(增长 > App Linking)记得把“域名校验”的开关打开,不然系统懒得去查。

三、 实战:到底怎么跳?

配置好了,怎么触发跳转呢?咱们看代码。

场景 A:我想拉起别人(发起方)

鸿蒙提供了 openLink 接口,这比传统的 startAbility 更适合处理链接跳转。

import { common } from '@ohos.app.ability.common';

// 比如在一个按钮点击事件里
function jumpToTarget(context: common.UIAbilityContext) {
  // 目标链接
  const targetLink = "https://www.example.com/programs?action=showall"; 
  
  const options: common.OpenLinkOptions = {
    // 重点!这里有个开关
    // true: 只要 App Linking(没安装App就可能没反应或者走浏览器逻辑,看系统实现)
    // false: 兼容 Deep Linking 模式,哪怕没校验过域名的 scheme 也能试着跳
    appLinkingOnly: false 
  };

  try {
    context.openLink(targetLink, options).then(() => {
      console.info('跳转成功,走你!');
    }).catch((err) => {
      console.error(`跳转翻车了: ${JSON.stringify(err)}`);
    });
  } catch (paramError) {
    console.error(`参数都有问题: ${JSON.stringify(paramError)}`);
  }
}

如果你非要用 Deep Linking 的那种 geo: 协议,用 startAbility 也是可以的,构建一个 Want 对象就行,但这在 API 12 里显得有点“复古”了。

场景 B:别人拉起我(接收方)

不管是 Deep Linking 还是 App Linking,进了你的门,处理逻辑是一样的。都是在 AbilityonCreate 或者 onNewWant 里接客。

import { UIAbility, Want, AbilityConstant } from '@ohos.app.ability.common';
import { url } from '@ohos.arkts';

export default class EntryAbility extends UIAbility {
  
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleLink(want);
  }

  // 如果 App 已经在后台活着,会走这里
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleLink(want);
  }

  handleLink(want: Want) {
    const uri = want?.uri;
    if (!uri) return; // 没带链接?那是误触吧

    console.info(`收到链接请求: ${uri}`);

    // 解析 URL,这就跟前端解析 location.href 一个德行
    try {
      const urlObject = url.URL.parseURL(uri);
      const action = urlObject.params.get('action');
      
      if (action === "showall") {
        // 路由跳转逻辑:带大伙去“所有节目”页面
        // router.pushUrl(...) 
      }
    } catch (e) {
      console.error("这链接格式不对啊,老铁");
    }
  }
}


四、 总结:该选哪一个?

说了一大堆,最后给兄弟们来个“防纠结指南”:

特性 Deep Linking (土法) App Linking (正规军)
链接长相 myapp://detail https://www.myapp.com/detail
安全性 低 (谁都能用) 高 (域名校验,防伪冒)
没安装App时 报错或无响应 自动打开浏览器网页,体验无缝衔接
唯一性 不保证 (可能弹窗选App) 保证 (唯一归属,一键直达)
适用场景 App 内部页面互跳、非公网环境 外部引流、营销短信、二维码、社交分享

血泪建议: 如果是做对外推广、H5 唤醒 App,无脑上 App Linking。它是未来的主流,而且不用担心“应用未安装”的尴尬。 如果是 App 内部自己跳自己,或者公司内部几个 App 互通,不想搞服务器域名那一套,那 Deep Linking 依然是个轻量级的好选择。

行了,关于鸿蒙的“连接艺术”今天就聊到这。代码写完了记得多测测,别到时候用户点开链接一脸懵逼,那就尴尬了。

vue使用h函数封装dialog组件,以命令的形式使用dialog组件

2026年1月14日 12:30

场景

有些时候我们的页面是有很多的弹窗
如果我们把这些弹窗都写html中会有一大坨
因此:我们需要把弹窗封装成命令式的形式

命令式弹窗

// 使用弹窗的组件
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
function openMask(){
  // 第1个参数:表示的是组件,你写弹窗中的组件
  // 第2个参数:表示的组件属性,比如:确认按钮的名称等
  // 第3个参数:表示的模态框的属性。比如:模态宽的宽度,标题名称,是否可移动
  renderDialog(childTest,{},{title:'测试弹窗'})
}
</script>
// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
export function renderDialog(component:any,props:any, modalProps:any){
 const dialog  = h(
    ElDialog,   // 模态框组件
    {
      ...modalProps, // 模态框属性
      modelValue:true, // 模态框是否显示
    }, // 因为是模态框组件,肯定是模态框的属性
    {
      default:()=>h(component, props ) // 插槽,el-dialog下的内容
    }
  )
 console.log(dialog)
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
//childTest.vue 组件
<template>
  <div>
    <span>It's a modal Dialog</span>
    <el-form :model="form" label-width="auto" style="max-width: 600px">
    <el-form-item label="Activity name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="Activity zone">
      <el-select v-model="form.region" placeholder="please select your zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
  </el-form>
  </div>
</template>
<script setup lang="ts">
import { ref,reactive } from 'vue'
const dialogVisible = ref(true)
const form = reactive({
  name: '',
  region: '',
})
const onSubmit = () => {
  console.log('submit!')
}
</script>

01

为啥弹窗中的表单不能够正常展示呢?

在控制台会有下面的提示信息:
Failed to resolve component:
el-form If this is a native custom element,
make sure to exclude it from component resolution via compilerOptions.isCustomElement
翻译过来就是
无法解析组件:el-form如果这是一个原生自定义元素,
请确保通过 compilerOptions.isCustomElement 将其从组件解析中排除

02

其实就是说:我重新创建了一个新的app,这个app中没有注册组件。
因此会警告,页面渲染不出来。

// 我重新创建了一个app,这个app中没有注册 element-plus 组件。
const app = createApp(dialog)

现在我们重新注册element-plus组件。
准确的说:我们要注册 childTest.vue 组件使用到的东西

给新创建的app应用注册childTest组件使用到的东西

我们将会在这个命令式弹窗中重新注册需要使用到的组件

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog } from "element-plus";
// 引入组件和样式
import ElementPlus from "element-plus";
// import "element-plus/dist/index.css";
export function renderDialog(component:any,props:any, modalProps:any){
 const dialog  = h(
    ElDialog,   // 模态框组件
    {
      ...modalProps, // 模态框属性
      modelValue:true, // 模态框显示
    }, // 因为是模态框组件,肯定是模态框的属性
    {
      default:()=>h(component, props ) // 插槽,el-dialog下的内容
    }
  )
 console.log(dialog)
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

03

现在我们发现可以正常展示弹窗中的表单了。因为我们注册了element-plus组件。
但是我们发现又发现了另外一个问题。
弹窗底部没有取消和确认按钮。
需要我们再次通过h函数来创建

关于使用createApp创建新的应用实例

在Vue 3中,我们可以使用 createApp 来创建新的应用实例
但是这样会创建一个完全独立的应用
它不会共享主应用的组件、插件等。
因此我们需要重新注册

弹窗底部新增取消和确认按钮

我们将会使用h函数中的插槽来创建底部的取消按钮

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
    },
    {
      // 主要内容插槽
      default: () => h(component, props),
      // 底部插槽
      footer:() =>h(
        'div',
        { class: 'dialog-footer' },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('取消')
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

04

点击关闭弹窗时,需要移除之前创建的div

卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div。
2个地方需要移除:1,点击确认按钮。 2,点击其他地方的关闭
05

关闭弹窗正确销毁相关组件

// 封装的弹窗
import { createApp, h } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  console.log('111')
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽
      default: () => h(component, props),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

06

点击确认按钮时验证规则

有些时候,我们弹窗中的表单是需要进行规则校验的。
我们下面来实现这个功能点
传递的组件

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
  >
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Activity zone" prop="region">
      <el-select v-model="ruleForm.region" placeholder="Activity zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    
    <el-form-item label="Activity time" required>
      <el-col :span="11">
        <el-form-item prop="date1">
          <el-date-picker
            v-model="ruleForm.date1"
            type="date"
            aria-label="Pick a date"
            placeholder="Pick a date"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col class="text-center" :span="2">
        <span class="text-gray-500">-</span>
      </el-col>
      <el-col :span="11">
        <el-form-item prop="date2">
          <el-time-picker
            v-model="ruleForm.date2"
            aria-label="Pick a time"
            placeholder="Pick a time"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-form-item>

    <el-form-item label="Resources" prop="resource">
      <el-radio-group v-model="ruleForm.resource">
        <el-radio value="Sponsorship">Sponsorship</el-radio>
        <el-radio value="Venue">Venue</el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="Activity form" prop="desc">
      <el-input v-model="ruleForm.desc" type="textarea" />
    </el-form-item>

  </el-form>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'

import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
  region: string
  date1: string
  date2: string
  resource: string
  desc: string
}
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
  region: '',
  date1: '',
  date2: '',
  resource: '',
  desc: '',
})
const rules = reactive<FormRules<RuleForm>>({
  name: [
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ],
  region: [
    {
      required: true,
      message: 'Please select Activity zone',
      trigger: 'change',
    },
  ],
  date1: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a date',
      trigger: 'change',
    },
  ],
  date2: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a time',
      trigger: 'change',
    },
  ],
  resource: [
    {
      required: true,
      message: 'Please select activity resource',
      trigger: 'change',
    },
  ],
  desc: [
    { required: true, message: 'Please input activity form', trigger: 'blur' },
  ],
})

const submitForm = async () => {
  if (!ruleFormRef.value) {
    console.error('ruleFormRef is not initialized')
    return false
  }
  try {
    const valid = await ruleFormRef.value.validate()
    if (valid) {
      console.log('表单校验通过', ruleForm)
      return Promise.resolve(ruleForm)
    }
  } catch (error) {
    // 为啥submitForm中,valid的值是false会执行catch ?
    // el-form 组件的 validate 方法的工作机制导致的。 validate 方法在表单验证失败时会抛出异常
    console.error('err', error)
    return false
    /**
     * 下面这样写为啥界面会报错呢?
     * return Promise.reject(error)
     * 当表单验证失败时,ruleFormRef.value.validate() 会抛出一个异常。
     * 虽然你用了 try...catch 捕获这个异常,并且在 catch 块中通过 return Promise.reject(error) 返回了一个被拒绝的 Promise
     * 但如果调用 submitForm 的地方没有正确地处理这个被拒绝的 Promise(即没有使用 .catch() 或者 await 来接收错误),
     * 那么浏览器控制台就会显示一个 "Uncaught (in promise)" 错误。
     * 在 catch 中再次 return Promise.reject(error) 是多余的, 直接return false
     * */ 
    /**
     * 如果你这样写
     * throw error 直接抛出错误即可
     * 那么就需要再调用submitForm的地方捕获异常
     * */  
  }
}

defineExpose({
  submitForm:submitForm
})
</script>
// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any) {
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                })
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}

07 关键的点:通过ref拿到childTest组件中的方法,childTest要暴露需要的方法

如何把表单中的数据暴露出去

可以通过回调函数的方式把数据暴露出去哈。

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据, 如验证失败,res 的值有可能是一个false。
                  onConfirm(res)
                  // 怎么把这个事件传递出去,让使用的时候知道点击了确认并且知道验证通过了
                }).catch((error: any) => {
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
                console.log('确定')
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
  })
}
</script>

08

点击确定时,业务完成后关闭弹窗

现在想要点击确定,等业务处理完成之后,才关闭弹窗。 需要在使用完成业务的时候返回一个promise,让封装的弹窗调用这个promise 这样就可以知道什么时候关闭弹窗了

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                    // 注意这里的finally,这样写在服务出现异常的时候会有问题,这里是有问题的,需要优化
                    // 注意这里的finally,这样写在服务出现异常的时候会有问题,这里是有问题的,需要优化
                    callbackResult.finally(() => { 
                      // 弹窗关闭逻辑
                      app.unmount()
                      document.body.removeChild(div)
                    });
                  } else {
                    // 如果不是 Promise,立即关闭弹窗
                    app.unmount()
                    document.body.removeChild(div)
                  }
                }).catch((error: any) => {
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
    // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
    return fetch("https://dog.ceo/api/breed/pembroke/images/random")
     .then((res) => {
       return res.json();
     })
     .then((res) => {
        console.log('获取的图片地址为:', res.message);
     });
  })
}
</script>

09

优化业务组件

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 关闭弹窗,避免重复代码
  const closeDialog = () => {
    // 成功时关闭弹窗
    app.unmount();
    // 检查div是否仍然存在且为body的子元素,否者可能出现异常
    if (div && div.parentNode) {
      document.body.removeChild(div)
    }
  }
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽
      footer:() =>h(
        'div',
        { 
          class: 'dialog-footer',
         
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              onClick: () => {
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                   
                     callbackResult.then(() => {
                      if(res){
                        console.log('111')
                        closeDialog()
                      }
                    }).catch(error=>{
                      console.log('222')
                      console.error('回调函数执行出错,如:网络错误', error);
                      // 错误情况下也关闭弹窗
                      closeDialog()
                    });
                  } else {
                    // 如果不是 Promise,并且验证时通过了的。立即关闭弹窗
                    console.log('333', res)
                    if(res){
                      closeDialog()
                    }
                  }
                }).catch((error: any) => {
                  console.log('44444')
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => '确定'
          )
        ]
      )
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>
<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  renderDialog(childTest,{},{title:'测试弹窗', width: '700'}, (res)=>{
    console.log('通过回调函数返回值', res)
      // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
      return fetch("https://dog.ceo/api/breed/pembroke/images/random")
      .then((res) => {
        return res.json();
      })
      .then((res) => {
          console.log('获取的图片地址为:', res.message);
      });
  })
}
</script>

眼尖的小伙伴可能已经发现了这一段代码。 1,验证不通过会也会触发卸载弹窗 2,callbackResult.finally是不合适的

image

10

最终的代码

// 封装的弹窗
import { createApp, h, ref } from "vue";
import { ElDialog, ElButton, ElForm, ElFormItem, ElInput, ElSelect, ElOption } from "element-plus";
import ElementPlus from "element-plus";

export function renderDialog(component: any, props: any, modalProps: any, onConfirm: (data: any) => any ) {
  // 关闭弹窗,避免重复代码
  const closeDialog = () => {
    // 成功时关闭弹窗
    app.unmount();
    // 检查div是否仍然存在且为body的子元素,否者可能出现异常
    if (div && div.parentNode) {
      document.body.removeChild(div)
    }
  }
  // 第4个参数是回调函数
  const instanceElement = ref()
  console.log('111', instanceElement) 
  const isLoading = ref(false)
  // 创建弹窗实例
  const dialog = h(
    ElDialog,
    {
      ...modalProps,
      modelValue: true,
      onClose: ()=> {
        isLoading.value = false
        console.log('关闭的回调')
        app.unmount() // 这样卸载会让动画消失
        // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
        document.body.removeChild(div)
      }
    },
    {
      // 主要内容插槽,这里的ref必须接收一个ref
      default: () => h(component, {...props, ref: instanceElement}),
      // 底部插槽,noShowFooterBool是true,不显示; false的显示底部 
      footer: props.noShowFooterBool ? null : () =>h(
        'div',
        { 
          class: 'dialog-footer',
        },
        [
          h(
            ElButton, 
            {
              onClick: () => {
                console.log('点击取消按钮')
                // 卸载一个已挂载的应用实例。卸载一个应用会触发该应用组件树内所有组件的卸载生命周期钩子。
                app.unmount() // 这样卸载会让动画消失
                // 卸载的同时需要把我们创建的div元素移除,否则页面上会出现很多div
                document.body.removeChild(div)
              }
            },
            () => props.cancelText || '取消'
          ),
          h(
            ElButton,
            { 
              type: 'primary',
              loading: isLoading.value,
              onClick: () => {
                isLoading.value = true
                // submitForm 调用表单组件中需要验证或者暴露出去的数据
                instanceElement?.value?.submitForm().then((res:any) =>{
                  if(!res){
                    isLoading.value = false
                  }
                  console.log('得到的值',res)
                  // 验证通过后调用回调函数传递数据,如验证失败,res 的值有可能是一个false。
                  const callbackResult = onConfirm(res);
                  // 如果回调函数返回的是 Promise,则等待业务完成后再关闭弹窗
                  if (callbackResult instanceof Promise) {
                     callbackResult.then(() => {
                      if(res){
                        console.log('111')
                        closeDialog()
                      }else{
                        isLoading.value = false
                      }
                    }).catch(error=>{
                      console.log('222')
                      console.error('回调函数执行出错,如:网络错误', error);
                      // 错误情况下也关闭弹窗
                      closeDialog()
                    });
                  } else {
                    // 如果不是 Promise,并且验证时通过了的。立即关闭弹窗
                    console.log('333', res)
                    if(res){
                      closeDialog()
                    }else{
                      isLoading.value = false
                    }
                  }
                }).catch((error: any) => {
                  console.log('44444')
                   isLoading.value = false
                  // 验证失败时也可以传递错误信息
                  console.log('验证失败', error)
                })
              }
            },
            () => props.confirmText ||  '确定'
          )
        ]
      ) 
    }
  );
  // 创建一个新的 Vue 应用实例。这个应用实例是独立的,与主应用分离。
  const app = createApp(dialog)
  // 在新实例中注册 Element Plus, 这弹窗中的组件就可以正常显示了
  app.use(ElementPlus);
  // 这个div元素在在销毁应用时需要被移除哈
  const div = document.createElement('div')
  document.body.appendChild(div)
  app.mount(div)
}
<template>
  <div>
    <el-button @click="openMask">点击弹窗</el-button>
  </div>
</template>

<script setup lang="ts">
import childTest from '@/components/childTest.vue'
import { renderDialog } from '@/hooks/dialog'
import { getCurrentInstance } from 'vue';
const currentInstance = getCurrentInstance();
function openMask(){
  console.log('currentInstance',currentInstance)
  const otherProps =  {cancelText:'取消哈', confirmText: '确认哈',showFooterBool:true }
  const dialogSetObject = {title:'测试弹窗哈', width: '700', draggable: true}
  renderDialog(childTest,otherProps,dialogSetObject, (res)=>{
    console.log('通过回调函数返回值', res)
    // 这里返回一个promise对象,这样就可以让业务完成后才关闭弹窗
    return fetch("https://dog.ceo/api/breed/pembroke/images/random")
    .then((res) => {
      return res.json();
    })
    .then((res) => {
        console.log('获取的图片地址为:', res.message);
    });
  })
}
</script>

<style lang="scss" scoped>

</style>
<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
  >
    <el-form-item label="Activity name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Activity zone" prop="region">
      <el-select v-model="ruleForm.region" placeholder="Activity zone">
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>
    
    <el-form-item label="Activity time" required>
      <el-col :span="11">
        <el-form-item prop="date1">
          <el-date-picker
            v-model="ruleForm.date1"
            type="date"
            aria-label="Pick a date"
            placeholder="Pick a date"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col class="text-center" :span="2">
        <span class="text-gray-500">-</span>
      </el-col>
      <el-col :span="11">
        <el-form-item prop="date2">
          <el-time-picker
            v-model="ruleForm.date2"
            aria-label="Pick a time"
            placeholder="Pick a time"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-form-item>

  
    <el-form-item label="Resources" prop="resource">
      <el-radio-group v-model="ruleForm.resource">
        <el-radio value="Sponsorship">Sponsorship</el-radio>
        <el-radio value="Venue">Venue</el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="Activity form" prop="desc">
      <el-input v-model="ruleForm.desc" type="textarea" />
    </el-form-item>

  </el-form>
</template>

<script lang="ts" setup>
import { reactive, ref } from 'vue'

import type { FormInstance, FormRules } from 'element-plus'

interface RuleForm {
  name: string
  region: string

  date1: string
  date2: string


  resource: string
  desc: string
}


const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
  name: 'Hello',
  region: '',
  date1: '',
  date2: '',
  resource: '',
  desc: '',
})



const rules = reactive<FormRules<RuleForm>>({
  name: [
    { required: true, message: 'Please input Activity name', trigger: 'blur' },
    { min: 3, max: 5, message: 'Length should be 3 to 5', trigger: 'blur' },
  ],
  region: [
    {
      required: true,
      message: 'Please select Activity zone',
      trigger: 'change',
    },
  ],
  date1: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a date',
      trigger: 'change',
    },
  ],
  date2: [
    {
      type: 'date',
      required: true,
      message: 'Please pick a time',
      trigger: 'change',
    },
  ],
  resource: [
    {
      required: true,
      message: 'Please select activity resource',
      trigger: 'change',
    },
  ],
  desc: [
    { required: true, message: 'Please input activity form', trigger: 'blur' },
  ],
})

const submitForm = async () => {
  if (!ruleFormRef.value) {
    console.error('ruleFormRef is not initialized')
    return false
  }
  try {
    const valid = await ruleFormRef.value.validate()
    if (valid) {
      // 验证通过后,就会可以把你需要的数据暴露出去
      return Promise.resolve(ruleForm)
    }
  } catch (error) {
    // 为啥submitForm中,valid的值是false会执行catch ?
    // el-form 组件的 validate 方法的工作机制导致的。 validate 方法在表单验证失败时会抛出异常
    console.error('err', error)
    return false
    /**
     * 下面这样写为啥界面会报错呢?
     * return Promise.reject(error)
     * 当表单验证失败时,ruleFormRef.value.validate() 会抛出一个异常。
     * 虽然你用了 try...catch 捕获这个异常,并且在 catch 块中通过 return Promise.reject(error) 返回了一个被拒绝的 Promise
     * 但如果调用 submitForm 的地方没有正确地处理这个被拒绝的 Promise(即没有使用 .catch() 或者 await 来接收错误),
     * 那么浏览器控制台就会显示一个 "Uncaught (in promise)" 错误。
     * 在 catch 中再次 return Promise.reject(error) 是多余的, 直接return false
     * */ 
    /**
     * 如果你这样写
     * throw error 直接抛出错误即可
     * 那么就需要再调用submitForm的地方捕获异常
     * */  
  }
}

defineExpose({
  submitForm:submitForm
})
</script>

那个写 width: 33.33% 的前端,终于被 flex: 1 拯救了

作者 NEXT06
2026年1月14日 12:30

告别百分比计算:从文档流到 Flex 弹性布局的进化之路

在 CSS 的世界里,布局方式的演进就像是一场对“控制权”的争夺战。从最初顺其自然的文档流,到精打细算的 inline-block,再到如今游刃有余的 Flexbox,我们的代码变得越来越优雅。

一、 随波逐流:HTML 文档流

一切布局的起点,都是文档流(Document Flow)

HTML 元素默认就像水流一样:

  • 块级元素 (display: block) :如 div,霸道地独占一行,从上到下垂直排列。适合做容器,但无法并排。
  • 行内元素 (display: inline) :如 span,顺从地从左到右排列,但它有个致命弱点——无法设置宽高,这让它不适合做布局容器。

二、 进阶的烦恼:Inline-block 的爱与恨

为了让元素既能并排(像 inline),又能设置宽高(像 block),开发者们曾大量使用 display: inline-block。

CSS

.item {
    display: inline-block;
    width: 33.33%; /* 经典的百分比计算 */
}

这种方案看似完美,实则暗藏玄机。

它的痛点在于:

  1. 计算繁琐:通过百分比(33.33%)凑成一行,永远无法达到真正的 100% 精确。
  2. 幽灵空白节点:HTML 代码中的换行符会被浏览器解析为空格,导致原本计算好的布局莫名其妙换行。

三、 降维打击:Flex 弹性布局

为了解决上述痛点,CSS3 为我们带来了弹性布局(Flexbox) 。它不再关注具体的百分比,而是关注**“剩余空间”的分配**。

1. 开启上帝视角

只需在父容器上声明一个属性,即可接管子元素的布局规则:

CSS

.box {
    display: flex; /* 开启弹性布局 */
    /* 子元素默认变成“弹性项目”,且默认水平排列 */
}

2. 核心魔法:flex: 1

在提供的代码中,我们看到了这样一行关键代码:

CSS

.item {
    flex: 1; /* 核心代码 */
    background-color: green;
}

flex: 1 到底做了什么?

它相当于告诉浏览器:“不要管我原本有多宽,把父容器剩下的空间平均分给我。”

  • 如果有 3 个 .item,每个盒子自动获得 1/3 的宽度。
  • 如果有 4 个 .item,每个自动获得 1/4 的宽度。

对比优势:

  • 无需计算:不需要手写 33.33% 或 25%。
  • 自动填充:无论增加还是减少子元素,布局自动填满整行,不会有缝隙,也不会溢出。

四、 总结

从 inline-block 到 flex,不仅仅是属性的变化,更是布局思维的转变。

  • 传统布局:我们需要做算术题,小心翼翼地计算像素和百分比。
  • 弹性布局:我们将控制权交给浏览器,声明“分配规则”(如 flex: 1),让布局自动适应容器。

前端开发就是这样,用最少的代码,实现最灵活的效果。下次布局时,记得给容器加一个 display: flex。

前端即导演:用纯 CSS3 原力复刻《星球大战》经典开场

作者 NEXT06
2026年1月14日 12:12

🌌 致敬经典:用纯 CSS3 导演一场“星球大战”开场秀

“前端是代码界的导演。”

我们不需要摄像机,只需要 HTML 构建骨架,CSS 渲染光影。今天,我们就用几行 CSS3 代码,复刻经典的《星球大战》开场 3D 特效。

Video Project.gif

🎬 剧本规划(HTML 结构)

为了还原电影海报的经典站位,我们将结构分为三层:顶部的 "STAR",底部的 "WARS",以及中间那一排神秘的副标题。

codeHtml

<div class="starwars">
    <img src="./star.svg" alt="star" class="star">
    <img src="./wars.svg" alt="wars" class="wars">
    <h2 class="byline">
        <!-- 每个字母单独包裹,为了后续的翻转动画 -->
        <span>T</span><span>h</span><span>e</span>...
    </h2>
</div>

这里有一个细节:副标题 h2 中的每个字母都用 span 包裹,这是为了让每个字母能独立进行 3D 旋转表演。

🎥 搭建舞台(核心 CSS)

1. 完美的绝对居中

在全屏黑背景下,我们需要让 logo 稳稳地悬浮在宇宙中心。这里使用了经典的“绝对定位 + Transform”大法:

CSS

.starwars {
    width: 34em;
    height: 17em;
    position: absolute;
    top: 50%;
    left: 50%;
    /* 自身宽高的一半向回移动,实现精准居中 */
    transform: translate(-50%, -50%);
}

2. 开启上帝视角(3D 景深)

这是本案例的灵魂所在。普通的平面动画无法表现星战字幕“飞向深空”的震撼。我们需要在父容器上开启 3D 空间:

CSS

.starwars {
    /* 视距:模拟人眼距离屏幕 800px 的位置 */
    perspective: 800px;
    /* 保持子元素的 3D 空间关系 */
    transform-style: preserve-3d;
}
  • perspective: 决定了“近大远小”的程度,数值越小,透视感越强烈。
  • transform-style: preserve-3d: 确保子元素在 3D 空间中变换,而不是被压扁在 2D 平面里。

🎞️ 动作设计(关键帧动画)

Step 1: 巨物消逝(Logo 动画)

STAR 和 WARS 两张图片需要经历:透明 -> 出现 -> 缩小复位 -> 飞向深渊 的过程。

我们利用 translateZ 来控制 Z 轴距离,负值越大,离我们越远。

CSS

@keyframes star {
  0% {
    opacity: 0;
    transform: scale(1.5) translateY(-0.75em); /* 初始放大且位置靠上 */
  }
  20% { opacity: 1; } /* 显形 */
  89% {
    opacity: 1;
    transform: scale(1); /* 恢复正常大小 */
  }
  100% {
    opacity: 0;
    transform: translateZ(-1000em); /* 瞬间飞向宇宙深处! */
  }
}

Step 2: 文字起舞(副标题动画)

中间的 The Force Awake 需要有一种“翻转浮现”的神秘感。

注意:span 默认是行内元素,无法应用 Transform,所以必须设置为 display: inline-block。

CSS

.byline span {
  display: inline-block;
  animation: spin-letters 10s linear infinite;
}

@keyframes spin-letters {
  0%, 100% {
    opacity: 0;
    transform: rotateY(90deg); /* 侧身 90 度,相当于隐身 */
  }
  30% { opacity: 1; }
  70% {
    transform: rotateY(0); /* 正对观众 */
    opacity: 1;
  }
}

配合父容器 .byline 的 Z 轴推进动画,文字不仅在自转,还在向镜头推进,层次感瞬间拉满。

🏁 杀青

通过 perspective 构建空间,利用 translateZ 制造纵深,再配合 rotateY 增加动感。不需要复杂的 JS 库,几十行 CSS 就能致敬经典。

前端开发的乐趣,往往就在这些像素的腾挪转移之间。愿原力与你的代码同在!May the code be with you.

源代码

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>html5&css3星球大战</title>
    <link rel="stylesheet" href="./style.css">
</head>
<body>
    <div class="starwars">
        <img src="./star.png" alt="star" class="star">
        <img src="./wars.png" alt="wars" class="wars">
        <h2 class="byline" id="byline">
            <span>T</span>
            <span>H</span>
            <span>E</span>
            <span>F</span>
            <span>O</span>
            <span>R</span>
            <span>C</span>
            <span>E</span>
            <span>A</span>
            <span>W</span>
            <span>A</span>
            <span>K</span>
            <span>E</span>
        </h2>
        </div>
    </div>
</body>
</html>

CSS

/*
  标准 CSS Reset
  基于 Eric Meyer 的 Reset 并结合现代浏览器特性
*/

/* 所有元素应用 border-box 模型,方便布局 */
*,
*::before,
*::after {
  box-sizing: border-box;
}

/* 重置所有元素的内外边距、边框、字体等 */
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
}

/* HTML5 语义化元素设为块级 */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
  display: block;
}

/* 重置列表样式 */
ol,
ul {
  list-style: none;
}

/* 重置表格样式 */
table {
  border-collapse: collapse;
  border-spacing: 0;
}

/* 重置图片、视频等替换元素 */
img,
video,
canvas,
audio,
svg {
  display: block;
  max-width: 100%;
}

/* 重置表单元素 */
button,
input,
select,
textarea {
  /* 继承字体和颜色 */
  font: inherit;
  color: inherit;
  /* 移除默认边框和轮廓 */
  border: none;
  outline: none;
  /* 清除默认样式 */
  background: transparent;
  /* 统一垂直对齐 */
  vertical-align: middle;
}

/* 链接重置 */
a {
  text-decoration: none;
  color: inherit; /* 继承父元素颜色 */
}

/* 防止字体缩放 */
body {
  line-height: 1;
  -webkit-text-size-adjust: 100%;
}

/* 清除浮动(可选) */

.clearfix::after {
  content: "";
  display: table;
  clear: both;
}

/* 业务代码 */
body {
  height: 100vh;
  background:#000 url(./bg.jpg);
}
.starwars {
    /* 声明 支持3D */
  perspective: 800px;
  /* 保持3D 变换 */
  transform-style: preserve-3d;
  /* 相对单位,相对于自身的字体大小 
    默认字体大小是16
  */
  width: 34em;
  height: 17em;
  /* 绝对定位 */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* css 调试手法, 背景颜色调试大法 */
  /* background-color: red; */
}
img {
    /* 高度等比例缩放 */
  width: 100%;
}
.star, .wars, .byline {
  position: absolute;
}
.star {
  top: -0.75em;
}
.wars {
  bottom: -0.5em;
}
.byline {
  left: -2em;
  right: -2em;
  top: 45%;
  /* background: green; */
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.3em;
  font-size: 1.6em;
  color: white;
}
.star{
    /* 动画属性 
    star 动作脚本
    10s animation-duration
    ease-out animation-timing-function
    */
    animation: star 10s ease-out infinite;
}
.wars{
    animation: wars 10s ease-out infinite;
}
.byline{
    animation: move-byline 10s linear infinite;
}
.byline span{
    display: inline-block;
    animation: spin-letters 10s linear infinite;
}

/* 设计动作 动画的关键帧 */
@keyframes star {
    /* 每个关键帧写它的属性 */
    0%{
        opacity: 0;
        transform: scale(1.5) translateY(-0.75em);
    }
    20%{
        opacity: 1;

    }
    89%{
        opacity: 1;
        transform: scale(1);
    }
    100%{
        opacity: 0;
        transform: translateZ(-1000em);
    }
    
}
@keyframes wars{
    0%{
        opacity: 0;
        transform: scale(1.5) translateY(0.5em);
    }
    20%{
        opacity: 1;

    }
    /* 模拟真实效果 不同步 更像是人在操控飞船 */
    90%{
        opacity: 1;
        transform: scale(1);
    }
    100%{
        opacity: 0;
        transform: translateZ(-1000em);
    }
}
@keyframes spin-letters {
   0%,10%{
        opacity: 0;
        /* 钢管舞 */
        transform: rotateY(90deg);
   }
  30%{
        opacity: 1;
      
  }
  70%,86%{
    transform: rotateY(0deg);
    opacity: 1;
  }
  95%,100%{
    opacity: 0;
  }
}
@keyframes move-byline {
    0%{
        transform: translateZ(5em);
    }
    100%{
        transform: translateZ(0);
    }
}

bg.jpg

别再只会 console.log 了!这 15 个 Console 调试技巧,让你的 Debug 效率翻倍

2026年1月14日 11:39

"console.log('到这了')" "console.log('到这了 2')" "console.log('到这了 3')" "console.log('为什么不执行???')" —— 每个程序员的日常

前言:你真的会用 console 吗?

让我猜猜你的调试方式:

console.log(data)
console.log("data:", data)
console.log("========== 分割线 ==========")
console.log(data)
console.log("到这了")
console.log("到这了2")
console.log("为什么不进来???")

如果你中枪了,别担心,你不是一个人。

但是,console 对象其实有超过 20 个方法,而大多数人只会用 console.log

今天,我要带你解锁 console 的全部潜力。

学完这篇文章,你的调试效率至少提升 50%。


第一章:基础进阶——让 console.log 更好用

1.1 技巧一:用对象包裹变量名

这是最简单但最有用的技巧。

❌ 不好的写法:

const userName = "张三"
const userAge = 25
const userEmail = "zhangsan@example.com"

console.log(userName)
console.log(userAge)
console.log(userEmail)

// 输出:
// 张三
// 25
// zhangsan@example.com
// 问题:你根本不知道哪个是哪个!

✅ 好的写法:

const userName = "张三"
const userAge = 25
const userEmail = "zhangsan@example.com"

console.log({ userName, userAge, userEmail })

// 输出:
// { userName: '张三', userAge: 25, userEmail: 'zhangsan@example.com' }
// 清清楚楚,一目了然!

这个技巧利用了 ES6 的对象简写语法,变量名自动成为属性名。

1.2 技巧二:给输出加上 emoji 标签

当你的控制台输出很多的时候,找到你要的那条信息就像大海捞针。

解决方案:用 emoji 做视觉标记!

// 🔍 调试信息
console.log("🔍 正在查找用户:", userId)

// ✅ 成功信息
console.log("✅ 用户登录成功:", user.name)

// ❌ 错误信息
console.log("❌ 登录失败:", error.message)

// ⚠️ 警告信息
console.log("⚠️ 用户权限不足")

// 🚀 性能相关
console.log("🚀 API 响应时间:", responseTime, "ms")

// 📦 数据相关
console.log("📦 接收到的数据:", data)

// 🔄 状态变化
console.log("🔄 状态更新:", oldState, "->", newState)

在一堆黑白文字中,emoji 会非常显眼,让你一眼就能找到关键信息。

1.3 技巧三:使用模板字符串格式化

const user = { name: "张三", age: 25 }
const action = "登录"
const timestamp = new Date().toLocaleTimeString()

// ❌ 拼接字符串(丑陋且难读)
console.log("用户 " + user.name + " 在 " + timestamp + " 执行了 " + action)

// ✅ 模板字符串(清晰优雅)
console.log(`[${timestamp}] 用户 ${user.name} 执行了 ${action}`)

// 输出:[14:30:25] 用户 张三 执行了 登录

1.4 技巧四:console.log 的 CSS 样式

是的,你可以给 console.log 加 CSS 样式!

// 基础用法
console.log("%c这是红色文字", "color: red")
console.log("%c这是大号蓝色文字", "color: blue; font-size: 20px")

// 高级用法:多种样式组合
console.log(
  "%c 成功 %c 操作已完成",
  "background: #4CAF50; color: white; padding: 2px 6px; border-radius: 3px",
  "color: #4CAF50"
)

// 实用示例:创建一个漂亮的日志函数
const prettyLog = {
  success: (msg) =>
    console.log(
      `%c ✓ SUCCESS %c ${msg}`,
      "background: #4CAF50; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #4CAF50"
    ),
  error: (msg) =>
    console.log(
      `%c ✗ ERROR %c ${msg}`,
      "background: #f44336; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #f44336"
    ),
  warning: (msg) =>
    console.log(
      `%c ⚠ WARNING %c ${msg}`,
      "background: #ff9800; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #ff9800"
    ),
  info: (msg) =>
    console.log(
      `%c ℹ INFO %c ${msg}`,
      "background: #2196F3; color: white; padding: 2px 6px; border-radius: 3px; font-weight: bold",
      "color: #2196F3"
    ),
}

// 使用
prettyLog.success("用户登录成功")
prettyLog.error("网络请求失败")
prettyLog.warning("API 即将废弃")
prettyLog.info("当前版本: 2.0.0")

第二章:数据展示——让复杂数据一目了然

2.1 技巧五:console.table() —— 表格展示数据

这可能是最被低估的 console 方法。

当你有一个对象数组时,console.log 的输出是这样的:

const users = [
  { id: 1, name: "张三", age: 25, city: "北京" },
  { id: 2, name: "李四", age: 30, city: "上海" },
  { id: 3, name: "王五", age: 28, city: "广州" },
]

console.log(users)
// 输出一堆难以阅读的嵌套对象...

但如果你用 console.table()

console.table(users)

// 输出一个漂亮的表格:
// ┌─────────┬────┬────────┬─────┬────────┐
// │ (index) │ id │  name  │ age │  city  │
// ├─────────┼────┼────────┼─────┼────────┤
// │    0    │ 1  │ '张三' │ 25  │ '北京' │
// │    1    │ 2  │ '李四' │ 30  │ '上海' │
// │    2    │ 3  │ '王五' │ 28  │ '广州' │
// └─────────┴────┴────────┴─────┴────────┘

还可以只显示特定列:

console.table(users, ["name", "city"])

// 只显示 name 和 city 列
// ┌─────────┬────────┬────────┐
// │ (index) │  name  │  city  │
// ├─────────┼────────┼────────┤
// │    0    │ '张三' │ '北京' │
// │    1    │ '李四' │ '上海' │
// │    2    │ '王五' │ '广州' │
// └─────────┴────────┴────────┘

适用场景:

  • API 返回的数据列表
  • 数据库查询结果
  • 配置项对比
  • 任何数组或对象的可视化

2.2 技巧六:console.dir() —— 查看对象的完整结构

console.log 打印 DOM 元素时,显示的是 HTML 结构。

console.dir 打印 DOM 元素时,显示的是对象属性。

const element = document.querySelector("#app")

console.log(element)
// 输出:<div id="app">...</div>(HTML 结构)

console.dir(element)
// 输出:div#app 的所有属性和方法(对象结构)
// 包括:className, id, innerHTML, style, onclick...

当你需要查看一个对象有哪些属性和方法时,用 console.dir

2.3 技巧七:console.dirxml() —— 查看 XML/HTML 结构

const element = document.querySelector("#app")

console.dirxml(element)
// 以 XML/HTML 树形结构展示元素及其子元素

第三章:分组与层级——让输出更有组织

3.1 技巧八:console.group() —— 分组输出

当你有一堆相关的日志时,可以把它们分组:

console.group("用户信息")
console.log("姓名: 张三")
console.log("年龄: 25")
console.log("城市: 北京")
console.groupEnd()

console.group("订单信息")
console.log("订单号: 12345")
console.log("金额: ¥99.00")
console.log("状态: 已支付")
console.groupEnd()

// 输出:
// ▼ 用户信息
//     姓名: 张三
//     年龄: 25
//     城市: 北京
// ▼ 订单信息
//     订单号: 12345
//     金额: ¥99.00
//     状态: 已支付

3.2 技巧九:console.groupCollapsed() —— 默认折叠的分组

console.groupCollapsed("详细调试信息(点击展开)")
console.log("这是一些详细的调试信息...")
console.log("通常不需要看,但需要时可以展开")
console.log("比如:完整的请求参数、响应数据等")
console.groupEnd()

// 输出:
// ▶ 详细调试信息(点击展开)  ← 默认是折叠的

适用场景:

  • 详细的调试信息(平时不看,出问题时展开)
  • 大量的数据输出
  • 嵌套的对象结构

3.3 技巧十:嵌套分组

console.group("🛒 购物车")

console.group("商品列表")
console.log("iPhone 15 Pro - ¥8999")
console.log("AirPods Pro - ¥1899")
console.groupEnd()

console.group("优惠信息")
console.log("满减: -¥500")
console.log("优惠券: -¥100")
console.groupEnd()

console.log("💰 总计: ¥10298")

console.groupEnd()

// 输出:
// ▼ 🛒 购物车
//     ▼ 商品列表
//         iPhone 15 Pro - ¥8999
//         AirPods Pro - ¥1899
//     ▼ 优惠信息
//         满减: -¥500
//         优惠券: -¥100
//     💰 总计: ¥10298

第四章:性能分析——找出代码瓶颈

4.1 技巧十一:console.time() —— 测量代码执行时间

这是性能调试的神器!

console.time("数据处理")

// 模拟一些耗时操作
const data = []
for (let i = 0; i < 100000; i++) {
  data.push({ id: i, value: Math.random() })
}

console.timeEnd("数据处理")
// 输出:数据处理: 45.123ms

可以同时计时多个操作:

console.time("总耗时")

console.time("获取数据")
const response = await fetch("/api/users")
const users = await response.json()
console.timeEnd("获取数据")
// 输出:获取数据: 234.56ms

console.time("处理数据")
const processedUsers = users.map((user) => ({
  ...user,
  fullName: `${user.firstName} ${user.lastName}`,
}))
console.timeEnd("处理数据")
// 输出:处理数据: 12.34ms

console.time("渲染")
renderUsers(processedUsers)
console.timeEnd("渲染")
// 输出:渲染: 89.01ms

console.timeEnd("总耗时")
// 输出:总耗时: 335.91ms

4.2 技巧十二:console.timeLog() —— 中间计时

console.time("多步骤操作")

await step1()
console.timeLog("多步骤操作", "步骤1完成")
// 输出:多步骤操作: 100.00ms 步骤1完成

await step2()
console.timeLog("多步骤操作", "步骤2完成")
// 输出:多步骤操作: 250.00ms 步骤2完成

await step3()
console.timeEnd("多步骤操作")
// 输出:多步骤操作: 400.00ms

4.3 技巧十三:console.count() —— 计数器

想知道某段代码执行了多少次?

function handleClick() {
  console.count("按钮点击")
  // 其他逻辑...
}

// 点击3次后:
// 按钮点击: 1
// 按钮点击: 2
// 按钮点击: 3

// 重置计数器
console.countReset("按钮点击")

实用场景:

function render(component) {
  console.count(`${component} 渲染次数`)
  // 渲染逻辑...
}

// 检查组件是否有不必要的重复渲染
render("Header") // Header 渲染次数: 1
render("Header") // Header 渲染次数: 2  ← 为什么渲染了两次?
render("Header") // Header 渲染次数: 3  ← 可能有性能问题!

第五章:错误追踪——快速定位问题

5.1 技巧十四:console.trace() —— 打印调用栈

当你想知道"这个函数是从哪里被调用的"时:

function functionA() {
  functionB()
}

function functionB() {
  functionC()
}

function functionC() {
  console.trace("调用栈追踪")
}

functionA()

// 输出:
// 调用栈追踪
//     at functionC (script.js:10)
//     at functionB (script.js:6)
//     at functionA (script.js:2)
//     at script.js:13

这在调试复杂的回调链或事件处理时特别有用。

5.2 技巧十五:console.assert() —— 条件断言

只有当条件为 false 时才输出:

const user = { name: "张三", age: 25 }

// 如果条件为 true,什么都不输出
console.assert(user.name, "用户名不能为空")

// 如果条件为 false,输出错误信息
console.assert(user.age >= 18, "用户必须成年")
console.assert(user.email, "用户邮箱不能为空")
// 输出:Assertion failed: 用户邮箱不能为空

适用场景:

function processOrder(order) {
  console.assert(order, "订单不能为空")
  console.assert(order.items?.length > 0, "订单必须包含商品")
  console.assert(order.totalAmount > 0, "订单金额必须大于0")

  // 如果所有断言都通过,继续处理...
}

第六章:实战技巧——日常开发中的最佳实践

6.1 创建一个增强版 Logger

// logger.js - 一个实用的日志工具

const isDev = process.env.NODE_ENV === "development"

const logger = {
  // 基础日志(只在开发环境输出)
  log: (...args) => {
    if (isDev) console.log("📝", ...args)
  },

  // 信息日志
  info: (...args) => {
    if (isDev)
      console.log(
        "%c ℹ️ INFO ",
        "background: #2196F3; color: white; border-radius: 3px",
        ...args
      )
  },

  // 成功日志
  success: (...args) => {
    if (isDev)
      console.log(
        "%c ✅ SUCCESS ",
        "background: #4CAF50; color: white; border-radius: 3px",
        ...args
      )
  },

  // 警告日志
  warn: (...args) => {
    console.warn("⚠️", ...args) // 警告在生产环境也输出
  },

  // 错误日志
  error: (...args) => {
    console.error("❌", ...args) // 错误在生产环境也输出
  },

  // 分组日志
  group: (label, fn) => {
    if (!isDev) return fn()
    console.group(`📦 ${label}`)
    const result = fn()
    console.groupEnd()
    return result
  },

  // 计时日志
  time: async (label, fn) => {
    if (!isDev) return fn()
    console.time(`⏱️ ${label}`)
    const result = await fn()
    console.timeEnd(`⏱️ ${label}`)
    return result
  },

  // 表格日志
  table: (data, columns) => {
    if (isDev) console.table(data, columns)
  },
}

export default logger

// 使用示例
import logger from "./logger"

logger.info("应用启动")
logger.success("用户登录成功", { userId: 123 })
logger.warn("API 即将废弃")
logger.error("网络请求失败", error)

logger.group("用户数据", () => {
  logger.log("姓名:", user.name)
  logger.log("年龄:", user.age)
})

await logger.time("数据加载", async () => {
  return await fetchData()
})

6.2 调试 API 请求

// 创建一个 API 调试拦截器
const debugFetch = async (url, options = {}) => {
  const requestId = Math.random().toString(36).substr(2, 9)

  console.groupCollapsed(
    `🌐 API 请求 [${requestId}] ${options.method || "GET"} ${url}`
  )

  console.log("📤 请求参数:", {
    url,
    method: options.method || "GET",
    headers: options.headers,
    body: options.body ? JSON.parse(options.body) : undefined,
  })

  console.time(`⏱️ 响应时间 [${requestId}]`)

  try {
    const response = await fetch(url, options)
    const data = await response.clone().json()

    console.timeEnd(`⏱️ 响应时间 [${requestId}]`)
    console.log("📥 响应状态:", response.status, response.statusText)
    console.log("📦 响应数据:", data)

    if (!response.ok) {
      console.error("❌ 请求失败")
    } else {
      console.log("✅ 请求成功")
    }

    console.groupEnd()
    return response
  } catch (error) {
    console.timeEnd(`⏱️ 响应时间 [${requestId}]`)
    console.error("❌ 请求异常:", error.message)
    console.groupEnd()
    throw error
  }
}

// 使用
const response = await debugFetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "张三" }),
})

6.3 调试 React 组件渲染

// 在 React 组件中使用
function UserProfile({ userId }) {
  console.count(`UserProfile 渲染 (userId: ${userId})`)

  useEffect(() => {
    console.log("🔄 UserProfile useEffect 触发", { userId })

    return () => {
      console.log("🧹 UserProfile 清理", { userId })
    }
  }, [userId])

  console.group("📊 UserProfile 渲染详情")
  console.log("Props:", { userId })
  console.log("渲染时间:", new Date().toLocaleTimeString())
  console.groupEnd()

  return <div>...</div>
}

6.4 调试状态变化

// 创建一个状态变化追踪器
function createStateTracker(initialState, name = "State") {
  let state = initialState

  return {
    get: () => state,
    set: (newState) => {
      console.group(`🔄 ${name} 变化`)
      console.log("旧值:", state)
      console.log("新值:", newState)
      console.trace("调用来源")
      console.groupEnd()

      state = newState
      return state
    },
  }
}

// 使用
const userState = createStateTracker({ name: "", loggedIn: false }, "UserState")

userState.set({ name: "张三", loggedIn: true })
// 输出:
// ▼ 🔄 UserState 变化
//     旧值: { name: '', loggedIn: false }
//     新值: { name: '张三', loggedIn: true }
//     调用来源: (调用栈)

第七章:Console 方法速查表

┌─────────────────────────────────────────────────────────────────────────┐
│                        Console 方法速查表                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  📝 基础输出                                                             │
│  ├─ console.log()      普通日志输出                                     │
│  ├─ console.info()     信息日志(某些浏览器有特殊图标)                  │
│  ├─ console.warn()     警告日志(黄色背景)                              │
│  └─ console.error()    错误日志(红色背景)                              │
│                                                                         │
│  📊 数据展示                                                             │
│  ├─ console.table()    以表格形式展示数组/对象                          │
│  ├─ console.dir()      以对象形式展示(查看属性和方法)                  │
│  └─ console.dirxml()   以 XML/HTML 形式展示 DOM 元素                    │
│                                                                         │
│  📁 分组管理                                                             │
│  ├─ console.group()         创建展开的分组                              │
│  ├─ console.groupCollapsed() 创建折叠的分组                             │
│  └─ console.groupEnd()      结束当前分组                                │
│                                                                         │
│  ⏱️ 性能分析                                                             │
│  ├─ console.time()     开始计时                                         │
│  ├─ console.timeLog()  输出中间时间                                     │
│  ├─ console.timeEnd()  结束计时并输出                                   │
│  └─ console.count()    计数器(统计调用次数)                           │
│                                                                         │
│  🔍 调试追踪                                                             │
│  ├─ console.trace()    打印调用栈                                       │
│  ├─ console.assert()   条件断言(条件为 false 时输出)                  │
│  └─ console.clear()    清空控制台                                       │
│                                                                         │
│  🎨 样式输出                                                             │
│  └─ console.log('%c文字', 'CSS样式')  带样式的输出                      │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

第八章:常见问题与注意事项

8.1 生产环境要移除 console

问题: console 语句会影响性能,也可能泄露敏感信息。

解决方案:

// 方案1:使用环境变量控制
if (process.env.NODE_ENV === 'development') {
  console.log('调试信息')
}

// 方案2:使用构建工具移除
// webpack 配置(使用 terser-webpack-plugin)
optimization: {
  minimizer: [
    new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true,  // 移除所有 console
        },
      },
    }),
  ],
}

// 方案3:使用 babel 插件
// babel.config.js
plugins: [
  ['transform-remove-console', { exclude: ['error', 'warn'] }]
]

// 方案4:使用 ESLint 规则
// .eslintrc.js
rules: {
  'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn'
}

8.2 console.log 的异步陷阱

问题: console.log 打印对象时,显示的是引用,不是快照。

const obj = { count: 0 }
console.log(obj) // 可能显示 { count: 1 } 而不是 { count: 0 }
obj.count = 1

解决方案:

// 方案1:使用 JSON.stringify
console.log(JSON.stringify(obj))

// 方案2:使用展开运算符创建浅拷贝
console.log({ ...obj })

// 方案3:使用 JSON.parse + JSON.stringify 创建深拷贝
console.log(JSON.parse(JSON.stringify(obj)))

// 方案4:使用 structuredClone(现代浏览器)
console.log(structuredClone(obj))

8.3 大对象的性能问题

问题: 打印大对象会导致浏览器卡顿。

// ❌ 不好:打印整个大数组
console.log(hugeArray) // 可能有 10000 个元素

// ✅ 好:只打印需要的部分
console.log("数组长度:", hugeArray.length)
console.log("前10个元素:", hugeArray.slice(0, 10))
console.log("第一个元素:", hugeArray[0])

结语:从 console.log 到 console 大师

今天我们学习了 15 个 console 调试技巧:

  1. 用对象包裹变量名 - 自动显示变量名
  2. 用 emoji 标签 - 视觉标记,快速定位
  3. 模板字符串格式化 - 清晰优雅的输出
  4. CSS 样式 - 让日志更醒目
  5. console.table() - 表格展示数据
  6. console.dir() - 查看对象结构
  7. console.dirxml() - 查看 XML/HTML 结构
  8. console.group() - 分组输出
  9. console.groupCollapsed() - 折叠分组
  10. 嵌套分组 - 层级结构
  11. console.time() - 测量执行时间
  12. console.timeLog() - 中间计时
  13. console.count() - 计数器
  14. console.trace() - 调用栈追踪
  15. console.assert() - 条件断言

记住:好的调试习惯能让你的开发效率翻倍。

下次当你想写 console.log('到这了') 的时候,想想有没有更好的方式。


附录:Console 快捷键

浏览器控制台快捷键:

打开控制台:
├─ Windows/Linux: F12  Ctrl + Shift + J
└─ Mac: Cmd + Option + J

控制台内操作:
├─ 清空控制台: Ctrl + L (Windows) / Cmd + K (Mac)
├─ 多行输入: Shift + Enter
├─ 执行代码: Enter
├─ 上一条命令: 
├─ 下一条命令: 
└─ 自动补全: Tab

如果你觉得这篇文章有用,请分享给你那个还在写 console.log('111') 的同事。

也许他需要知道:console 的世界,远比你想象的精彩。 🎨


最后,送给所有程序员一句话:

"调试的艺术,在于问对问题,而不是打更多的 log。" 🔍

愿你的 bug 越来越少,调试越来越快。

Cesium 深入浅出 《一》WGS84、ECEF、经纬高:Cesium 世界坐标到底是什么?

作者 图素
2026年1月8日 20:19

在cesium中存在多种坐标系,因为cesium作为一个空间地理的渲染引擎,需要处理从地理坐标到3D渲染的转换,理解这些坐标之间的联系,是cesium的基础。也是任何一个地理渲染引擎的基础。

这篇文章带大家理解cesium中的坐标系,顺便对比一下three.js的区别。

1、WGS84

WGS 84是全球定位系统(GPS)的基准坐标系统,广泛应用于全球定位和导航。它采用十进制度表示经度和纬度。(这句话是摘录维基百科的介绍)

简而言之这个坐标系统用的是用经纬度来表示位置

2、经纬高

用经纬度和高程来表示空间位置

  • 经度(longitude):范围 [-π, π],单位弧度
  • 纬度(latitude):范围 [-π/2, π/2],单位弧度
  • 高度(height):相对于椭球面的高度,单位米

在 Cesium 中用 Cartographic 表示,注意角度单位为弧度。

3、ECEF(Earth-Centered, Earth-Fixed)

地心地固坐标系,也称为三维笛卡尔坐标

  • 原点:地心
  • X 轴:指向本初子午线与赤道交点
  • Z 轴:指向北极
  • Y 轴:与 X、Z 构成右手系

在 Cesium 中用 Cartesian3 表示,单位为米。这是 Cesium 场景中的世界坐标。

image.png

上图为ECEF坐标系

结论:Cesium的世界坐标系就是ECEF坐标系。所有三维物体都是基于这个坐标系进行摆放,三维物体通过 modelMatrix会把局部坐标系转化为世界坐标系

cesium中常用的坐标转换方法

经纬度->笛卡尔

// 经纬度是角度值时候转为笛卡尔
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, height, ellipsoid?, result?)
// 弧度制时转为笛卡尔
Cesium.Cartesian3.fromRadians(lonRad, latRad, height, ellipsoid?, result?)
// 通过球体转为笛卡尔
ellipsoid.cartographicToCartesian(cartographic, result?)

我们来根据源码来看看经纬度是如何计算出笛卡尔坐标的:

image.png

/**
 * Returns a Cartesian3 position from longitude and latitude values given in radians.
 *
 * @param {number} longitude The longitude, in radians
 * @param {number} latitude The latitude, in radians
 * @param {number} [height=0.0] The height, in meters, above the ellipsoid.
 * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid on which the position lies.
 * @param {Cartesian3} [result] The object onto which to store the result.
 * @returns {Cartesian3} The position
 *
 * @example
 * const position = Cesium.Cartesian3.fromRadians(-2.007, 0.645);
 */
Cartesian3.fromRadians = function (
  longitude,
  latitude,
  height,
  ellipsoid,
  result,
) {
  //>>includeStart('debug', pragmas.debug);
  Check.typeOf.number("longitude", longitude);
  Check.typeOf.number("latitude", latitude);
  //>>includeEnd('debug');

  height = height ?? 0.0;

  const radiiSquared = !defined(ellipsoid)
    ? Cartesian3._ellipsoidRadiiSquared
    : ellipsoid.radiiSquared;

  const cosLatitude = Math.cos(latitude);
  // 如上图可以求得 从原点指向目标点的向量 scratchN 
  scratchN.x = cosLatitude * Math.cos(longitude);
  scratchN.y = cosLatitude * Math.sin(longitude);
  scratchN.z = Math.sin(latitude);
  // 得到球心指向椭球面的向量
  scratchN = Cartesian3.normalize(scratchN, scratchN);

  // 这个地方cesium 用的计算方法我也没有推导过,数学好的可以自己推导一下
  // 最简单的使用解析几何的椭圆公式和向量的交点,可以求出椭圆上的点
  // cesium这里的方法计算方式更简洁一点,通过计算缩放因子把向量缩放到椭圆上
  Cartesian3.multiplyComponents(radiiSquared, scratchN, scratchK);
  const gamma = Math.sqrt(Cartesian3.dot(scratchN, scratchK));
  // 得到椭圆上的点坐标
  scratchK = Cartesian3.divideByScalar(scratchK, gamma, scratchK);  
  // 得到长度为height的地心方向向量的向量
  scratchN = Cartesian3.multiplyByScalar(scratchN, height, scratchN); 

  if (!defined(result)) {
    result = new Cartesian3();
  }
  // 最后把椭圆上的点再验证地心法线平移height,得到ecef坐标
  return Cartesian3.add(scratchK, scratchN, result);
};

笛卡尔->经纬度

Cesium.Cartographic.fromCartesian(cartesian, ellipsoid?, result?)
ellipsoid.cartesianToCartographic(cartesian, result?)

源码的计算过程:还挺复杂的,用的牛顿迭代法,数学好的朋友可以自行去推导。

/**
 * Creates a new Cartographic instance from a Cartesian position. The values in the
 * resulting object will be in radians.
 *
 * @param {Cartesian3} cartesian The Cartesian position to convert to cartographic representation.
 * @param {Ellipsoid} [ellipsoid=Ellipsoid.default] The ellipsoid on which the position lies.
 * @param {Cartographic} [result] The object onto which to store the result.
 * @returns {Cartographic} The modified result parameter, new Cartographic instance if none was provided, or undefined if the cartesian is at the center of the ellipsoid.
 */
Cartographic.fromCartesian = function (cartesian, ellipsoid, result) {
  const oneOverRadii = defined(ellipsoid)
    ? ellipsoid.oneOverRadii
    : Cartographic._ellipsoidOneOverRadii;
  const oneOverRadiiSquared = defined(ellipsoid)
    ? ellipsoid.oneOverRadiiSquared
    : Cartographic._ellipsoidOneOverRadiiSquared;
  const centerToleranceSquared = defined(ellipsoid)
    ? ellipsoid._centerToleranceSquared
    : Cartographic._ellipsoidCenterToleranceSquared;

  //`cartesian is required.` is thrown from scaleToGeodeticSurface
  const p = scaleToGeodeticSurface(
    cartesian,
    oneOverRadii,
    oneOverRadiiSquared,
    centerToleranceSquared,
    cartesianToCartographicP,
  );

  if (!defined(p)) {
    return undefined;
  }

  let n = Cartesian3.multiplyComponents(
    p,
    oneOverRadiiSquared,
    cartesianToCartographicN,
  );
  n = Cartesian3.normalize(n, n);

  const h = Cartesian3.subtract(cartesian, p, cartesianToCartographicH);

  const longitude = Math.atan2(n.y, n.x);
  const latitude = Math.asin(n.z);
  const height =
    CesiumMath.sign(Cartesian3.dot(h, cartesian)) * Cartesian3.magnitude(h);

  if (!defined(result)) {
    return new Cartographic(longitude, latitude, height);
  }
  result.longitude = longitude;
  result.latitude = latitude;
  result.height = height;
  return result;
};

/**
 * Scales the provided Cartesian position along the geodetic surface normal
 * so that it is on the surface of this ellipsoid.  If the position is
 * at the center of the ellipsoid, this function returns undefined.
 *
 * @param {Cartesian3} cartesian The Cartesian position to scale.
 * @param {Cartesian3} oneOverRadii One over radii of the ellipsoid.
 * @param {Cartesian3} oneOverRadiiSquared One over radii squared of the ellipsoid.
 * @param {number} centerToleranceSquared Tolerance for closeness to the center.
 * @param {Cartesian3} [result] The object onto which to store the result.
 * @returns {Cartesian3} The modified result parameter, a new Cartesian3 instance if none was provided, or undefined if the position is at the center.
 *
 * @function scaleToGeodeticSurface
 *
 * @private
 */
function scaleToGeodeticSurface(
  cartesian,
  oneOverRadii,
  oneOverRadiiSquared,
  centerToleranceSquared,
  result,
) {
  //>>includeStart('debug', pragmas.debug);
  if (!defined(cartesian)) {
    throw new DeveloperError("cartesian is required.");
  }
  if (!defined(oneOverRadii)) {
    throw new DeveloperError("oneOverRadii is required.");
  }
  if (!defined(oneOverRadiiSquared)) {
    throw new DeveloperError("oneOverRadiiSquared is required.");
  }
  if (!defined(centerToleranceSquared)) {
    throw new DeveloperError("centerToleranceSquared is required.");
  }
  //>>includeEnd('debug');

  const positionX = cartesian.x;
  const positionY = cartesian.y;
  const positionZ = cartesian.z;

  const oneOverRadiiX = oneOverRadii.x;
  const oneOverRadiiY = oneOverRadii.y;
  const oneOverRadiiZ = oneOverRadii.z;

  const x2 = positionX * positionX * oneOverRadiiX * oneOverRadiiX;
  const y2 = positionY * positionY * oneOverRadiiY * oneOverRadiiY;
  const z2 = positionZ * positionZ * oneOverRadiiZ * oneOverRadiiZ;

  // Compute the squared ellipsoid norm.
  const squaredNorm = x2 + y2 + z2;
  const ratio = Math.sqrt(1.0 / squaredNorm);

  // As an initial approximation, assume that the radial intersection is the projection point.
  const intersection = Cartesian3.multiplyByScalar(
    cartesian,
    ratio,
    scaleToGeodeticSurfaceIntersection,
  );

  // If the position is near the center, the iteration will not converge.
  if (squaredNorm < centerToleranceSquared) {
    return !isFinite(ratio)
      ? undefined
      : Cartesian3.clone(intersection, result);
  }

  const oneOverRadiiSquaredX = oneOverRadiiSquared.x;
  const oneOverRadiiSquaredY = oneOverRadiiSquared.y;
  const oneOverRadiiSquaredZ = oneOverRadiiSquared.z;

  // Use the gradient at the intersection point in place of the true unit normal.
  // The difference in magnitude will be absorbed in the multiplier.
  const gradient = scaleToGeodeticSurfaceGradient;
  gradient.x = intersection.x * oneOverRadiiSquaredX * 2.0;
  gradient.y = intersection.y * oneOverRadiiSquaredY * 2.0;
  gradient.z = intersection.z * oneOverRadiiSquaredZ * 2.0;

  // Compute the initial guess at the normal vector multiplier, lambda.
  let lambda =
    ((1.0 - ratio) * Cartesian3.magnitude(cartesian)) /
    (0.5 * Cartesian3.magnitude(gradient));
  let correction = 0.0;

  let func;
  let denominator;
  let xMultiplier;
  let yMultiplier;
  let zMultiplier;
  let xMultiplier2;
  let yMultiplier2;
  let zMultiplier2;
  let xMultiplier3;
  let yMultiplier3;
  let zMultiplier3;

  do {
    lambda -= correction;

    xMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredX);
    yMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredY);
    zMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredZ);

    xMultiplier2 = xMultiplier * xMultiplier;
    yMultiplier2 = yMultiplier * yMultiplier;
    zMultiplier2 = zMultiplier * zMultiplier;

    xMultiplier3 = xMultiplier2 * xMultiplier;
    yMultiplier3 = yMultiplier2 * yMultiplier;
    zMultiplier3 = zMultiplier2 * zMultiplier;

    func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;

    // "denominator" here refers to the use of this expression in the velocity and acceleration
    // computations in the sections to follow.
    denominator =
      x2 * xMultiplier3 * oneOverRadiiSquaredX +
      y2 * yMultiplier3 * oneOverRadiiSquaredY +
      z2 * zMultiplier3 * oneOverRadiiSquaredZ;

    const derivative = -2.0 * denominator;

    correction = func / derivative;
  } while (Math.abs(func) > CesiumMath.EPSILON12);

  if (!defined(result)) {
    return new Cartesian3(
      positionX * xMultiplier,
      positionY * yMultiplier,
      positionZ * zMultiplier,
    );
  }
  result.x = positionX * xMultiplier;
  result.y = positionY * yMultiplier;
  result.z = positionZ * zMultiplier;
  return result;
}

弧度/角度换算

  • Cesium.Math.toRadians(deg)
  • Cesium.Math.toDegrees(rad)

Three.js 中的世界坐标系

最后我们来对比一下Three.js 中的世界坐标系,看下图,

Three.js 世界坐标中心点可以是任意点,通常放在内容的中心。默认右手坐标系,并且约定+Y向上,相机默认朝 -Z 方向看(所以“前方”常被理解为 -Z)向后(+Z)

image (1).png

three.js 世界坐标系

❌
❌