阅读视图

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

第三篇、基本骨架结构

3.1、基本结构

<!DOCTYPE html>
<html lang="zh_CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>页面标题</title>
</head>

<body>
    <!-- 之后内容写这里 -->
</body>

</html>

3.2、结构介绍

  • <!DOCTYPE html>:指定浏览器用 HTML5 规则解析页面,避免怪异模式,必须放在页面首行
  • <html lang="zh_CN"></html>:HTML 文档的唯一根标签,所有内容必须嵌套在其中
    • lang(必须)属性:声明网页主语言,辅助工具做语言相关的适配处理
      • 常用值:zh-CN(简体中文)、en(英文)
  • <head></head>:存储元信息、资源链接等非可视化内容,支撑网页运行和搜索引擎识别
  • <body></body>:承载所有用户可见的页面内容,是网页呈现的核心载体
  • <!--注释内容-->:注释内容不会被浏览器渲染显示,仅用于开发人员理解代码,可放在 HTML 文档任意位置

3.3、head元素内标签

  • <title>网页标题</title>:定义网页标题(显示在浏览器标签 / 搜索结果),标识网页主题,提升用户识别度,是 SEO 优化的核心要素之一
  • <meta charset="UTF-8" name="viewport" content="width=device-width, initial-scale=1.0"/>:用于定义网页元数据(字符编码、视口、搜索引擎信息等)
    • charset(高频)属性:指定 HTML 文档的字符编码格式(如utf-8),确保浏览器能正确解析页面文字,避免乱码
    • name + content (组合使用)属性:定义各类网页元信息,键值对形式传递
      • name="viewport" content="width=device-width, initial-scale=1.0":适配移动端视口,控制页面缩放
      • name="keywords" content="HTML,前端,教程":设置网页关键词,辅助搜索引擎收录
      • name="description" content="HTML基础教程":设置网页描述,展示在搜索结果页
    • http-equiv + content (组合使用)属性:模拟 HTTP 响应头,控制网页行为
      • http-equiv="refresh" content="5;url=https://www.example.com":5 秒后自动跳转到指定网址
  • <base href="" target=""/>:定义网页中所有相对 URL 的基准地址 / 默认打开方式;一个文档仅能有一个,且必须放在<head></head>内所有含 URL 的标签(如<a><link>)之前
    • href(可选)属性:设置所有相对链接的基准 URL,页面中相对路径会拼接该值作为完整地址
      • 示例:href="https://www.example.com/assets/",则<a href="img/logo.png">实际指向https://www.example.com/assets/img/logo.png
    • target(可选)属性:定义页面中所有链接的默认打开方式,优先级低于链接自身的target属性
      • 常用值:_blank(新窗口打开)、_self(当前窗口打开,默认值)、_parent(父框架打开)、_top(顶层框架打开)
    • 注意:若同时设置hreftarget,需写在同一个<base>标签中,不可拆分定义
  • <link rel="" href="" type=""/> :在 HTML 文档和外部资源间建立关联,核心用于引入样式、定义网页图标等
    • rel(必须)属性:声明当前文档与关联资源的关系类型,浏览器据此识别资源用途
      • stylesheet(关联外部 CSS 样式表)、icon(定义网页图标favicon)、preconnect(提前建立与目标域名的连接,优化资源加载速度)
    • href(必须)属性:指定关联外部资源的 URL 地址(绝对 / 相对路径均可)
    • type(可选)属性:声明资源的 MIME 类型,帮助浏览器识别资源格式,现代浏览器可自动识别,无需手动写
      • text/css(CSS 文件)、image/x-icon(图标文件)
  • <style type="text/css">CSS样式</style> :在网页内部编写 CSS 样式,直接控制页面布局和外观,无需引入外部样式文件
    • type(可选)属性:声明样式表类型,HTML5 中默认值为text/css,可省略不写
  • <script type="text/javascript">JS代码</script> :引入外部 JS 文件(可单标签写法)或编写内部 JS 代码,实现网页动态交互逻辑
    • src(可选)属性:指定外部 JS 文件的 URL 地址(绝对 / 相对路径)(无src时,标签内写内部 JS 代码;有src时,标签内代码无效)
    • type(可选)属性:声明脚本类型,HTML5 中默认值为text/javascript,可省略不写;type="module"时,将 JS 作为 ES6 模块解析,支持import/export语法
    • defer(可选)属性:布尔属性,使脚本延迟执行(HTML 解析完后按顺序执行),不阻塞页面渲染
    • async(可选)属性:布尔属性,使脚本异步加载(加载完立即执行),不保证多个脚本的执行顺序
    • 注意:无defer/async时,<script>会阻塞 HTML 解析;操作 DOM 的脚本建议放<body>末尾或加defer
  • <noscript>内容</noscript> :当页面依赖 JS 实现核心功能,但用户浏览器关闭了 JS、或使用的是极老旧不支持 JS 的浏览器时,<noscript></noscript>内的内容会显示出来,保证用户能看到基础提示或内容,提升页面兼容性

各标签放置位置速览

  • <title>/<meta>/<base>/<link> :仅能放在 <head> 中,是网页基础配置,放 <body> 无效;
  • <style> :优先放 <head>(避免页面闪屏),也可放 <body>(仅局部样式);
  • <script><head>/<body> 都可放:
    • <head>:需加 defer/async(避免阻塞渲染),仅用于初始化代码;
    • <body> 末尾:操作 DOM 首选,不阻塞渲染;
  • <noscript><head>/<body> 都可放:
  • <head>:仅提示 JS 禁用(无可视化内容);
  • <body>:展示替代内容(如提示文案、静态页面),更常用。

3.4、href和src属性

  • href (Hypertext Reference) :超文本引用,用于建立当前文档与外部资源的「关联关系」,浏览器解析时不会暂停当前文档处理,仅记录关联指向
    • 核心特点:关联而非嵌入,资源不会替换当前文档内容,只是建立链接
    • 常用标签:<link>(引入 CSS)、<a>(超链接)、<base>(基准地址)
  • src (Source) :资源地址,用于将外部资源「嵌入」当前文档中,浏览器解析时会暂停当前文档处理,直到资源加载 / 执行完成
    • 核心特点:嵌入并替换,资源会成为文档的一部分,需等待加载执行
    • 常用标签:<script>(引入 JS)、<img>(图片)、<iframe>(内嵌页面)

从原理到手写:彻底吃透 call / apply / bind 与 arguments 的底层逻辑

引言:为什么我们要“手写”这些 API?

在日常开发中,callapplybind 几乎每天都会用到。无论是处理 this 绑定问题、实现函数复用,还是做函数柯里化,它们都是绕不开的基础能力。

但有一个现实问题:

很多人“会用”,但说不清楚为什么这样设计,也不知道边界在哪里。

一旦进入复杂业务场景,比如高阶函数封装、事件回调丢失上下文、React 中函数绑定优化等问题,底层理解不扎实就会成为瓶颈。

本文我们做三件事:

  • 手写实现 call / apply / bind
  • 理解它们的设计哲学与差异
  • 彻底讲清楚 arguments 的本质与演进

目标不是背代码,而是形成“可迁移的工程认知”。


一、手写 call / apply / bind

1.0 先明确三个 API 的语法

func.call(thisArg, arg1, arg2, ...)
func.apply(thisArg, [argsArray])
const newFunc = func.bind(thisArg, arg1, arg2, ...)

区别非常明确:

方法 是否立即执行 参数形式 是否返回函数
call 参数列表
apply 数组
bind 参数列表

核心差异点只有两个:

  1. 是否立即执行
  2. 参数如何传递

1.1 手写 call —— 从“执行函数”开始

第一步:给所有函数添加能力

Function.prototype.hycall = function () {
  console.log("原型链调用了")
}

function foo() {
  console.log("foo函数调用了")
}

foo.hycall()

问题出现了:

只执行了 hycall,没有执行 foo 本身。

我们真正的目标是:

  • 谁调用 hycall
  • 就执行谁

关键点:

var fn = this
fn()

优化版:

Function.prototype.hycall = function () {
  var fn = this
  fn()
}

小结

  • Function.prototype 挂方法 = 所有函数都能用
  • this 指向调用 hycall 的函数
  • call 的第一能力:立即执行函数

1.2 改变 this 指向 —— 显式绑定的核心

默认调用:

fn()  // 默认绑定 → window

我们希望:

foo.hycall({ name: "小吴" })

this 指向传入对象。

关键思路:

借助“隐式绑定规则”

thisArg.fn = fn
thisArg.fn()

实现:

Function.prototype.hycall = function (thisArg) {
  var fn = this

  thisArg.fn = fn
  thisArg.fn()

  delete thisArg.fn
}

对比原生:

foo.hycall({ name: "小吴" })
foo.call({ name: "why" })

图10-1 call调用会执行函数

为什么这样能生效?

因为:

  • obj.fn() 是隐式调用
  • 隐式调用优先级 > 默认绑定
  • 所以 this 指向 obj

这就是“借鸡生蛋”的核心思想。


1.3 处理基本类型问题

问题:

foo.hycall(123)

报错,因为:

123.fn = fn // 不允许

解决方案:

thisArg = Object(thisArg)

最终优化:

Function.prototype.hycall = function (thisArg) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn
  var result = thisArg.fn()
  delete thisArg.fn

  return result
}

图10-4 转化为对象的处理方式结果

小结

  • 基本类型会被装箱
  • null / undefined 特殊处理
  • JS 实现无法做到“完全无痕绑定”

1.4 让 call 支持传参 —— ES6 剩余参数

核心能力:

foo.call(obj, a, b, c)

实现:

Function.prototype.hycall = function (thisArg, ...args) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn
  var result = thisArg.fn(...args)
  delete thisArg.fn

  return result
}

示例:

function foo(num1, num2, num3) {
  console.log(this, num1 + num2 + num3)
}

foo.hycall("小吴", 500, 20, 1)

为什么 call 必须支持参数?

因为:

  • 每次函数调用都会创建新的执行上下文
  • 改变 this 必须在“那次调用”里完成

错误方式:

foo.call("why")
foo(500,20,1) // this 失效

这是典型“刻舟求剑”。


1.5 手写 apply

区别只在参数形式。

Function.prototype.myapply = function (thisArg, argArray) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  thisArg.fn = fn

  argArray = argArray || []
  var result = thisArg.fn(...argArray)

  delete thisArg.fn

  return result
}

关键差异:

  • call:参数列表
  • apply:数组

小结

  • apply 更适合参数本来就是数组的场景
  • 本质逻辑与 call 一致
  • 区别只是“参数结构”

1.6 手写 bind —— 真正的升级版

bind 解决什么问题?

延迟执行 + 参数预设

示例:

function foo(num1, num2, num3, num4) {
  console.log(this, num1, num2, num3, num4)
}

三种用法:

var bar = foo.bind("小吴", 10, 20, 30, 40)
bar()

var bar = foo.bind("小吴")
bar(10, 20, 30, 40)

var bar = foo.bind("小吴", 10, 20)
bar(30, 40)

实现思路

  • 第一次调用 bind:固定 this + 默认参数
  • 返回新函数
  • 第二次执行:合并参数再执行

实现:

Function.prototype.mybind = function (thisArg, ...argArray) {
  var fn = this

  thisArg =
    thisArg !== null && thisArg !== undefined
      ? Object(thisArg)
      : window

  function proxyFn(...args) {
    thisArg.fn = fn

    var finalArgs = [...argArray, ...args]
    var result = thisArg.fn(...finalArgs)

    delete thisArg.fn
    return result
  }

  return proxyFn
}

工程理解

  • call:一次性执行
  • bind:函数工厂
  • bind 本质是“柯里化雏形”

二、认识 arguments

2.1 arguments 是什么?

定义:

类数组对象

特征:

  • 有 length
  • 可索引访问
  • 没有数组原型方法

示例:

function foo() {
  console.log(arguments.length)
  console.log(arguments[1])
  console.log(arguments.callee)
}

foo(10, 20, 30, 40, 50)

2.2 arguments 转数组

三种方式:

Array.prototype.slice.call(arguments)
Array.from(arguments)
[...arguments]

为什么 slice + call 能工作?

我们手写一个 slice:

Array.prototype.hyslice = function (start, end) {
  var arr = this
  start = start || 0
  end = end || arr.length

  var newArray = []

  for (var i = start; i < end; i++) {
    newArray.push(arr[i])
  }

  return newArray
}

var newArray = Array.prototype.hyslice.call(
  ["小吴", "why", "JS高级"],
  1,
  3
)

本质:

强行把 arguments 当作数组的 this


2.3 箭头函数为什么没有 arguments?

箭头函数:

  • 不绑定 this
  • 不绑定 arguments
  • 继承上层作用域

示例:

function foo() {
  var bar = () => {
    console.log(arguments)
  }

  return bar
}

var fn = foo(123)
fn()

图10-5 arguments打印结果

设计目的:

  • 保持语法简洁
  • 强化词法作用域一致性
  • 鼓励使用 ...rest

三、工程层面的思考

3.1 call / apply / bind 的真实差异

能力 call apply bind
改变 this
立即执行
返回函数
参数预设

3.2 实战建议

什么时候用 call?

  • 立即执行
  • 已知完整参数
  • 做方法借用

什么时候用 apply?

  • 参数已经是数组
  • Math.max.apply

什么时候用 bind?

  • 事件回调绑定
  • React 组件方法绑定
  • 部分参数预设

四、复盘与团队落地建议

关键结论

  1. 显式绑定本质是利用隐式调用规则
  2. bind 是对 call 的延迟封装
  3. arguments 是历史产物,优先使用 rest
  4. 所有 this 问题本质都是“调用方式问题”

团队落地建议

  1. code review 中严格检查 this 丢失问题
  2. 优先使用箭头函数 + rest
  3. 对高阶函数封装做统一规范
  4. 面试训练时必须能手写实现

理解 API 不等于掌握它。

真正的掌握,是知道:

  • 它解决什么问题
  • 为什么这样设计
  • 在复杂业务里如何避免踩坑

当你可以自己实现一遍,你就真正站在了语言机制这一层,而不只是使用层。

从“雕琢”到“生成”:AIGC正在重塑数字孪生世界

随着AI技术的发展,数字孪生场景中的一些场景构建变得更简单、高效。

下面来看看小编如何借助AIGC进行角色设备建模,结合Mapmost实现变电场数字孪生平台。

动图封面

Mapmost变电站数字孪生平台

一、角色建模:全流程 AI 驱动的任务生成与动画模拟

为了让虚拟角色在孪生场景中模拟标准化作业流程,提升平台沉浸感,我们构建了一条从概念到动作的完整AI生成管线:

01、概念具象化阶段

通过即梦等AIGC工具,输入结构化描述指令(如“佩戴安全帽、手持巡检终端的变电站作业人员标准T-pose”),可批量生成符合电力作业规范的角色设计原型。

网站指路:jimeng.jianying.com/ai-tool/hom…

即梦出图

02、模型生成阶段

将AI生成的角色图像导入hyper3d.ai进行3D转化。利用平台自动生成角色模型,并根据效果进行角色网格重新拓扑,得到网格完整、纹理准确的T-pose角色模型。

网站指路:hyper3d.ai/?lang=zh

动图封面

03 角色绑定阶段

Mixamo平台中,利用其丰富的标准化动作库与自动绑定技术,为静态模型注入“步行巡视”、“仪表检测”、“异常记录”等预设作业动画。通过动作融合与轨迹编辑,最终生成可沿预定路径执行连续作业任务的动态数字人。

网站指路:www.mixamo.com/#/

动图封面

mixamo角色绑定

借助Mapmost SDK for WebGL的模型加载接口,可实现人员巡逻路径还原。

动图封面

Mapmost变电站数字孪生平台

二、设备建模:基于 AI 图像增强的精细化三维建模

设备模型的真实感与场景融合度,直接影响着数字孪生场景的最终视觉表现。为使设备模型与高斯场景实现深度融合,我们采用**“影像增强+AI重建”**的方案,实现真实细节的保留与自然融合。

01、影像增强处理

将设备图输入即梦,通过定向提示词(如“增加油渍沉积效果”、“模拟金属氧化痕迹”、“保留原有logo与铭牌”)生成贴合设备在实际环境中所产生的脏迹图像。

即梦出图

02 智能三维重建

hyper3d基于多视图重建算法,从增强图像中提取精确的几何结构与高分辨率纹理,生成保留真实细节的模型。特别优化了对复杂机械结构、不规则表面的精度保留,满足场景对精细设备1:1还原的需求。

动图封面

hyper3d建模

03 场景数据融合

使用Mapmost SDK for WebGL加载变电站3DGS模型和生成的设备模型,我们发现,由于生成的模型具备了真实世界中的脏迹效果,因此与高斯模型融合得比较自然。

Mapmost变电站数字孪生平台

AIGC技术日趋成熟,越来越多的工具能帮助我们高效生成场景构建所需的高质量模型,极大降低了数字孪生搭建门槛。希望大家也能结合AIGC和Mapmost产品的能力,构建出更多优秀的数字孪生平台!

立即体验,开始三维开发之旅!

👉 点击访问官网免费试用:

Mapmost官网

uniapp 抽屉实现左滑

直接上代码

<template>
<view style="height: 100vh;background-color: #fff;" @touchstart="touchStart" @touchend="touchEnd">
<view class="" style="width: 100rpx;height: 100rpx;background-color: red;" @click="showDrawer">点击</view>
<uni-drawer ref="showRight" mode="left" width="320">
<scroll-view style="height: 100%;" scroll-y="true">

</scroll-view>
</uni-drawer>
</view>
</template>


<script>
export default {
data() {
return {
startX: 0, 
endX: 0,
info: {}
}
},
computed: {},
methods: {
touchStart(e) {
this.startX = e.changedTouches[0].clientX;
},
touchEnd(e) {
this.endX = e.changedTouches[0].clientX;
const moveDis = this.startX - this.endX;
console.log(moveDis);
if (moveDis == 0) return;
if(moveDis < -25) {
this.showDrawer();
}
},
showDrawer() {
this.$refs.showRight.open();
},
closeDrawer() {
this.$refs.showRight.close();
},
}
}
</script>
<style scoped lang="scss">
</style>

image.png

一篇文章让你读懂Figma的原始数据结构

最近使用AI做还原视觉相关的提效项目,在AI还没出来之前也刚好做过Figma相关的实践和项目,也趁此机会温故知新一下,分享给有需要的童鞋~

一、Figma 原始数据结构

目前通过AI获取到的figma数据,通常都经过一层转换,不是figma的原始数据,要想获取figma的原始数据结构,你需要通过api获取,返回的应该是一个JSON格式的数据结构,主要包含以下内容:

1. 文件基本信息

{
    "name": "haha 教程【一期】",// 文件名称
    "lastModified": "2022-03-05T03:04:14Z",// 最近修改时间
    "thumbnailUrl": "https://xxxxx",// 缩略图
    "version": "2327290316033362691",
    "role": "viewer",
    "editorType": "figma",
    "linkAccess": "inherit",
    "nodes": {} // 内容信息
}

2. 内容信息(nodes)

"nodes": {
    "988:122835": {
        "document": {},// 文档信息
        "components": {// 组件相关
            "543:105272": {
                "key": "a859937fe23b4182a41301d18670ff3d722a0c85",
                "name": "按钮",
                "description": "",
                "remote": true,
                "documentationLinks": []
            },
            "14:450": {
                "key": "82d6496dfd8eb1f47924bf4e77acd32effc6506c",
                "name": "tipsicon",
                "description": "",
                "remote": true,
                "documentationLinks": []
            }
        },
        "componentSets": {},
        "schemaVersion": 0,
        "styles": {}// 样式信息
    }
}

3. 文档结构 (document)

"document": {
    "id": "988:122835",
    "name": "Frame 2036096513",
    "type": "FRAME",
    "scrollBehavior": "SCROLLS",
    "children": [],// 页面内容
}

4. 节点信息

也就是上述children里面的内容信息,里面会包含各种各样的节点,节点类型总览如下:

类型 说明 有子节点 特殊属性
DOCUMENT 文档根 -
CANVAS 页面 backgroundColor
FRAME 画板 layoutMode, clipsContent
GROUP 分组 -
SECTION 分区 FigJam 功能
COMPONENT 组件定义 componentPropertyDefinitions
COMPONENT_SET 变体集 -
INSTANCE 组件实例 componentId, overrides
RECTANGLE 矩形 cornerRadius
ELLIPSE 椭圆 arcData
LINE 线条 -
VECTOR 矢量 fillGeometry
TEXT 文本 characters, style
BOOLEAN_OPERATION 布尔运算 booleanOperation
SLICE 切片 导出用

4.1. 节点通用属性

{
  "id": "953:118218",             // 节点唯一ID
  "name": "节点名称",              // 图层名称
  "type": "FRAME",                // 节点类型
  "visible": true,                // 是否可见
  "locked": false,                // 是否锁定
  "opacity": 1.0,                 // 透明度 0-1
  "blendMode": "PASS_THROUGH",    // 混合模式
  "absoluteBoundingBox": {        // 绝对定位和尺寸
    "x": -2981.0,
    "y": 6883.0,
    "width": 96.0,
    "height": 96.0
   },
  "absoluteRenderBounds": {       // 渲染定位和尺寸
    "x": -2981.0,
    "y": 6883.0,
    "width": 96.0,
    "height": 96.0
  },
  "constraints": {                // 约束
    "vertical": "TOP",
    "horizontal": "LEFT"
  },
  "effects": [],                  // 效果(阴影、模糊等)
  "fills": [],                    // 填充
  "strokes": [],                  // 描边
  "children": []                  // 子节点(容器类型)
}

4.2. 各节点类型详解

1. DOCUMENT(文档根节点)
{
    "id": "0:0",
    "name": "Document",
    "type": "DOCUMENT",
    "children": [] // CANVAS 节点数组
}

2. CANVAS(页面/画布)
{
    "id": "0:1",
    "name": "Page Canvas",
    "type": "CANVAS",
    "backgroundColor": {
        "r": 0.96,
        "g": 0.96,
        "b": 0.96,
        "a": 1
    },
    "children": [] // 页面内的所有元素
}

每个 CANVAS 代表一个页面


3. FRAME(画板/框架)

{
    "type": "FRAME",
    "clipsContent": true, // 是否裁剪超出内容
    "layoutMode": "VERTICAL", // 自动布局方向
    "primaryAxisSizingMode": "AUTO",
    "counterAxisSizingMode": "FIXED",
    "paddingLeft": 16,
    "paddingRight": 16,
    "paddingTop": 16,
    "paddingBottom": 16,
    "itemSpacing": 8, // 子元素间距
    "layoutAlign": "STRETCH",
    "cornerRadius": 8, // 圆角
    "children": []
}

这是一种最常见的节点,绝大部分节点的类型都是它


4. GROUP(组)
{
    "id": "953:118473",
    "name": "Group 2036096687",
    "type": "GROUP",
    "scrollBehavior": "SCROLLS",
    "children": []
}

通常children会有很多内容,但它纯粹的分组,没有自己的样式,样式来自子元素


5. RECTANGLE(矩形)
{
    "id": "953:118218",
    "name": "2",
    "type": "RECTANGLE",
    "scrollBehavior": "SCROLLS",
    "cornerRadius": 8, // 统一圆角
    "rectangleCornerRadii": [
        8,
        8,
        0,
        0
    ], // 分别设置四角
    "fills": [
        {
            "type": "SOLID",
            "color": {
                "r": 1,
                "g": 0,
                "b": 0,
                "a": 1
            }
        }
    ],
    "strokes": [
        {
            "type": "SOLID",
            "color": {
                "r": 0,
                "g": 0,
                "b": 0,
                "a": 1
            }
        }
    ],
    "strokeWeight": 1,
    "strokeAlign": "INSIDE" // INSIDE | OUTSIDE | CENTER
}

6. ELLIPSE(椭圆/圆形)
{
    "id": "I953:118474;953:115456",
    "name": "Ellipse 12175",
    "type": "ELLIPSE",
    "effects": [
        {
            "type": "LAYER_BLUR",
            "visible": true,
            "radius": 4.0
        }
    ],
    "arcData": {
        "startingAngle": 0.0,
        "endingAngle": 6.2831854820251465, // 2π = 完整圆
        "innerRadius": 0.0 // >0 为环形
    },
    "interactions": [],
    "complexStrokeProperties": {
        "strokeType": "BASIC"
    }
}

7. TEXT(文本)
{
    "id": "953:118226",
    "name": "95% OFF",
    "type": "TEXT",
    "strokes": [],
    "strokeWeight": 1.3939393758773804,
    "strokeAlign": "OUTSIDE",
    "absoluteBoundingBox": {
        "x": -2971.0,
        "y": 6882.847167968750,
        "width": 33.0,
        "height": 25.0
    },
    "absoluteRenderBounds": {
        "x": -2969.893066406250,
        "y": 6886.439941406250,
        "width": 30.792480468750,
        "height": 19.55957031250
    },
    "constraints": {
        "vertical": "TOP",
        "horizontal": "LEFT"
    },
    "characters": "95%\nOFF", // 文本内容
    "characterStyleOverrides": [
        21,
        21,
        21,
        21,
        20,
        20,
        20
    ],
    "styleOverrideTable": {
        "21": {
            "fontSize": 13.0
        },
        "20": {
            "fontSize": 8.0,
            "fills": [
                {
                    "blendMode": "NORMAL",
                    "type": "SOLID",
                    "color": {
                        "r": 1.0,
                        "g": 1.0,
                        "b": 1.0,
                        "a": 1.0
                    }
                }
            ]
        }
    },
    "lineTypes": [
        "NONE",
        "NONE"
    ],
    "lineIndentations": [
        0,
        0
    ],
    "style": { // 文本样式
        "fontFamily": "SF Pro",
        "fontPostScriptName": "SFPro-Heavy",
        "fontStyle": "Heavy",
        "fontWeight": 860,
        "textAutoResize": "WIDTH_AND_HEIGHT",
        "fontSize": 13.939393997192383,
        "textAlignHorizontal": "CENTER",
        "textAlignVertical": "BOTTOM",
        "letterSpacing": 0.0,
        "lineHeightPx": 16.634706497192383,
        "lineHeightPercent": 100.0,
        "lineHeightUnit": "INTRINSIC_%"
    }
}

重点关注:

  • characters: 纯文本内容
  • style: 文本样式
  • characterStyleOverrides + styleOverrideTable: 处理富文本

8. VECTOR(矢量路径)
{
    "type": "VECTOR",
    "strokeCap": "ROUND",
    "strokeJoin": "ROUND",
    "fillGeometry": [], // 填充路径数据 
    "strokeGeometry": [] // 描边路径数据 
}

9. BOOLEAN_OPERATION(布尔运算)
{
    "id": "953:118223",
    "name": "Union",
    "type": "BOOLEAN_OPERATION",
    "scrollBehavior": "SCROLLS",
    "children": [],
    "blendMode": "PASS_THROUGH",
    "fills": [],
    "strokes": [],
    "strokeWeight": 1.3939393758773804,
    "strokeAlign": "OUTSIDE",
    "booleanOperation": "UNION",
    "exportSettings": [
        {
            "suffix": "",
            "format": "PNG",
            "constraint": {
                "type": "SCALE",
                "value": 1.0
            }
        }
    ],
    "effects": [
        {
            "type": "DROP_SHADOW",
            "visible": true,
            "color": {
                "r": 0.0,
                "g": 0.0,
                "b": 0.0,
                "a": 0.250
            },
            "blendMode": "NORMAL",
            "offset": {
                "x": -1.3939393758773804,
                "y": 0.0
            },
            "radius": 2.7878787517547607,
            "showShadowBehindNode": false
        }
    ],
    "interactions": []
}

10. INSTANCE(组件实例)
{
    "id": "953:118542",
    "name": "Mask group",
    "type": "INSTANCE",
    "scrollBehavior": "SCROLLS",
    "componentId": "953:117624", // 引用的组件ID
    "overrides": [], // 覆盖的属性
    "children": []
}

这个也比较重要,通过 componentId 可以找到对应的 COMPONENT


11. COMPONENT(组件定义)
{
    "type": "COMPONENT",
    "componentPropertyDefinitions": {
        "Text#1:1": {
            "type": "TEXT",
            "defaultValue": "Button"
        },
        "Variant": {
            "type": "VARIANT",
            "variantOptions": [
                "Primary",
                "Secondary"
            ]
        }
    },
    "children": []
}

12. COMPONENT_SET(组件集/变体)
{
    "type": "COMPONENT_SET",
    "componentPropertyDefinitions": {
        "State": {
            "type": "VARIANT",
            "variantOptions": [
                "Default",
                "Hover",
                "Active"
            ]
        },
        "Size": {
            "type": "VARIANT",
            "variantOptions": [
                "Small",
                "Medium",
                "Large"
            ]
        }
    },
    "children": []
}

这个不多见,跟上述11的COMPONENT一样,只是它的一种集合体


5. 图片信息

有没有发现,上述的节点信息中并没有图片信息,但图片作为设计稿中最不可或缺的元素,怎么能没有图片类型的节点呢?

这是个好问题,然而Figma 中确实没有独立的 IMAGE 节点类型,下面让我们来了解一下图片信息是如何在Figma中存储的吧。

Figma 图片的存储方式

在 Figma 中,图片不是一种独立的节点类型,而是作为填充属性(fills) 附加在其他形状节点上:

你以为的图片存储方式或许是这样:

{ "type": "IMAGE", "src": "xxx.png" } 
 ❌ 没有这种节点

✅ 实际是这样的:

{
    "id": "953:118218",
    "type": "RECTANGLE", // 载体是矩形 
    "fills": [
        {
            "blendMode": "NORMAL", // 填充类型是图片 
            "type": "IMAGE", // 表示填充类型是图片
            "scaleMode": "FILL", // 图片缩放模式:`FILL`、`FIT`、`STRETCH`、`TILE`
            "imageRef": "fb233719d2b7c04499361d3052e21e9f32a1ca8d", // 图片的唯一哈希标识符,用于获取实际图片
            "imageTransform": [ // 2x3 变换矩阵,控制图片的缩放、旋转、位移
                [
                    0.81347656250,
                    0.0,
                    0.09082031250
                ],
                [
                    0.0,
                    0.86035162210464478,
                    0.093750
                ]
            ]
        }
    ]
}

如何获取实际图片

Figma 不直接存储图片数据,而是通过 imageRef 引用。要获取实际图片,需要调用:

# 方法1:获取文件中所有图片 
GET https://api.figma.com/v1/files/{file_key}/images 
# 方法2:导出指定节点为图片 
GET https://api.figma.com/v1/images/{file_key}?ids={node_id}&format=png

图片引用机制

┌─────────────────────────────────────────────────────┐
│  Figma JSON 数据                                     │
│  ┌───────────────────────────────────────────────┐  │
│  │ imageRef: "843b1885be37478f2e04f7f82e52d481..." │  │
│  └───────────────────────┬───────────────────────┘  │
└──────────────────────────┼──────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────┐
│  Figma 图片服务器                                    │
│  通过 /v1/files/{key}/images API 获取               │
│  返回: { imageRef: "https://s3-xxx.amazonaws.com/..."} │
└─────────────────────────────────────────────────────┘

imageTransform 变换矩阵

"imageTransform": [ 
    [0.81347656250, 0.0, 0.09082031250], // [scaleX, skewX, translateX] 
    [0.0, 0.86035162210464478, 0.093750] // [skewY, scaleY, translateY] 
]
  • 第一行:X 轴缩放 81.3%,X 方向偏移 9.1%
  • 第二行:Y 轴缩放 86%,Y 方向偏移 9.4%

总结:Figma 通过 imageRef 哈希值引用图片,实际图片数据存储在 Figma 的云端服务器,需要通过 API 单独获取图片 URL,因此这里需要考虑token过期的问题。

6. 节点解析

我们拿到这些原始数据之后,是需要处理的,因此我们需要对各种节点信息解析

const parseNode = (node) => {
  if (!node) return null
  // console.log('parseNode node==', node)
  
  const base = {
    id: node.id,
    name: node.name,
    type: node.type,
    bounds: node.absoluteBoundingBox,
  };

  switch (node.type) {
    case "TEXT":
      return {
        ...base,
        text: node.characters,
        style: node.style,
      };

    case "RECTANGLE":
    case "ELLIPSE":
      return {
        ...base,
        fills: parseFills(node.fills),
        cornerRadius: node.cornerRadius,
      };

    case "FRAME":
    case "GROUP":
    case "COMPONENT":
    case "INSTANCE":
      return {
        ...base,
        children: node.children?.map(parseNode) || [],
      };

    case "VECTOR":
      return {
        ...base,
        fills: parseFills(node.fills),
        strokes: node.strokes,
      };

    default:
      return base;
  }
};

const parseFills = (fills) => {
  return fills
    ?.filter((f) => f.visible !== false)
    .map((fill) => {
      console.log('fill===', fill)
      switch (fill.type) {
        case "SOLID":
          return { type: "solid", color: rgbaToHex(fill.color) };
        case "IMAGE":
          return { type: "image", ref: fill.imageRef };
        case "GRADIENT_LINEAR":
          return { type: "gradient", stops: fill.gradientStops };
        default:
          return fill;
      }
    });
};

二、Figma位置信息转换

1. 位置信息

a. 核心位置属性

{
    "layoutMode": "HORIZONTAL",
    "counterAxisSizingMode": "FIXED",
    "primaryAxisSizingMode": "FIXED",
    "counterAxisAlignItems": "CENTER",
    "primaryAxisAlignItems": "CENTER",
    "paddingLeft": 8.0,
    "paddingRight": 8.0,
    "paddingTop": 3.0,
    "paddingBottom": 3.0,
    "itemSpacing": 8.0,
    "layoutWrap": "NO_WRAP",
    "absoluteBoundingBox": { // 绝对位置(相对于画布原点)
        "x": 100,
        "y": 200,
        "width": 300,
        "height": 150
    },
    "absoluteRenderBounds": { // 实际渲染边界(包含阴影等效果)
        "x": 95,
        "y": 195,
        "width": 310,
        "height": 160
    },
    "relativeTransform": [ // 相对于父节点的变换矩阵 
        [4, 0, 16], // [scaleX, skewX, translateX] 
        [0, 8, 12] // [skewY, scaleY, translateY] 
    ]
}

b. 属性对比

属性 说明 用途
absoluteBoundingBox 相对画布的绝对坐标 全局定位
absoluteRenderBounds 包含效果的实际渲染区域 导出时使用
relativeTransform 相对父节点的变换矩阵 转换相对位置的关键

2、计算相对位置

方法1:通过 relativeTransform 获取

const getRelativePosition = (node) => {
  const transform = node?.relativeTransform;
  // 处理位置换换
  if (transform && transform.length && transform?.[0].length) {
    return {
      x: transform[0][2], // translateX
      y: transform[1][2], // translateY
    };
  }
  return null;
};

方法2:通过绝对坐标计算

// 绝对位置计算
const calculateRelativePosition = (child, parent) => {
  console.log('=====calculateRelativePosition', child, parent)
  if (!child) return
  const childBox = child?.absoluteBoundingBox;
  const parentBox = parent?.absoluteBoundingBox;
  return {
    x: childBox?.x - parentBox?.x || 0,
    y: childBox?.y - parentBox?.y || 0,
    width: childBox?.width,
    height: childBox?.height,
  };
};


3、转换为 CSS 的几种方式

场景1:绝对定位

function toAbsoluteCSS(node, parent) {
  const rel = calculateRelativePosition(node, parent);
  return {
    position: "absolute",
    left: `${rel.x}px`,
    top: `${rel.y}px`,
    width: `${rel.width}px`,
    height: `${rel.height}px`,
  };
}

场景2:Flex 布局(Auto Layout)

当 Figma 使用 Auto Layout 时:

const toFlexCSS = (node) => {
  if (node.layoutMode === "NONE") return null;
  console.log('node==', node)
  return {
    display: "flex",
    flexDirection: node.layoutMode === "VERTICAL" ? "column" : "row",
    gap: `${node.itemSpacing}px`,
    padding: `${node.paddingTop}px ${node.paddingRight}px ${node.paddingBottom}px ${node.paddingLeft}px`,
    alignItems: mapAlignItems(node.counterAxisAlignItems),
    justifyContent: mapJustifyContent(node.primaryAxisAlignItems),
  };
};

const mapAlignItems = (value) => {
  consle.log('=====value', value);
  // 这里处理下转换
  const map = {
    MIN: "flex-start",
    CENTER: "center",
    MAX: "flex-end",
    BASELINE: "baseline",
  };
  
  return map[value] || "flex-start";
};

场景3:响应式约束(constraints)

const constraintsToCSS = (node, parent) => {
  // console.log('constraintsToCSS', node, parent)
  const { constraints } = node || {};
  const rel = calculateRelativePosition(node, parent);
  const css = { position: "absolute" }; 
  // 水平约束
  switch (constraints?.horizontal) {
    case "LEFT":
      css.left = `${rel.x}px`;
      css.width = `${rel.width}px`;
      break;
    case "RIGHT":
      css.right = `${parent?.absoluteBoundingBox?.width - rel.x - rel.width}px`;
      css.width = `${rel.width}px`;
      break;
    case "LEFT_RIGHT": // 左右拉伸
      css.left = `${rel.x}px`;
      css.right = `${parent?.absoluteBoundingBox?.width - rel.x - rel.width}px`;
      break;
    case "CENTER":
      css.left = "50%";
      css.transform = "translateX(-50%)";
      css.width = `${rel.width}px`;
      break;
    case "SCALE": // 按比例缩放
      css.left = `${(rel.x / parent?.absoluteBoundingBox?.width) * 100}%`;
      css.width = `${(rel.width / parent?.absoluteBoundingBox?.width) * 100}%`;
      break;
  } 
  //console.log('=====css1', css)
  // 垂直约束(类似逻辑)
  switch (constraints?.vertical) {
    case "TOP":
      css.top = `${rel.y}px`;
      css.height = `${rel.height}px`;
      break;
    case "BOTTOM":
      css.bottom = `${parent?.absoluteBoundingBox?.height - rel.y - rel.height}px`;
      css.height = `${rel.height}px`;
      break;
    case "TOP_BOTTOM":
      css.top = `${rel.y}px`;
      css.bottom = `${parent?.absoluteBoundingBox?.height - rel.y - rel.height}px`;
      break;
    case "CENTER":
      css.top = "50%";
      css.transform = (css.transform || "") + " translateY(-50%)";
      css.height = `${rel.height}px`;
      break;
    case "SCALE":
      css.top = `${(rel.y / parent?.absoluteBoundingBox?.height) * 100}%`;
      css.height = `${(rel.height / parent?.absoluteBoundingBox?.height) * 100}%`;
      break;
  }
  console.log('=====css2', css)
  return css;
}


4、完整转换示例

const figmaToCSS = (node, parent) => {
  const rel = calculateRelativePosition(node, parent); // 1. 基础样式
  const css = { width: `${rel.width}px`, height: `${rel.height}px` }; // 2. 判断布局方式
  console.log('====rel', rel)
  if (parent?.layoutMode !== "NONE") {
    // 父元素是 Auto Layout,子元素不需要定位
    // 使用 flex item 属性
    if (node.layoutAlign === "STRETCH") {
      css.alignSelf = "stretch";
    }
    if (node.layoutGrow === 1) {
      css.flexGrow = 1;
    }
  } else {
    // debugger
    // 父元素不是 Auto Layout,使用绝对定位
    Object.assign(css, constraintsToCSS(node, parent));
  } // 3. 如果当前节点是 Auto Layout 容器
  
  if (node?.layoutMode !== "NONE") {
    Object.assign(css, toFlexCSS(node));
  }
  console.log('=====figmaToCSS css', css)
  return css;
};

5、常见转换映射表

尺寸与位置

Figma 属性 CSS 属性 说明
absoluteBoundingBox.x left 需计算相对值
absoluteBoundingBox.y top 需计算相对值
absoluteBoundingBox.width width 直接使用
absoluteBoundingBox.height height 直接使用

Flex 布局(Auto Layout)

Figma 属性 CSS 属性 说明
layoutMode: VERTICAL flex-direction: column 垂直排列
layoutMode: HORIZONTAL flex-direction: row 水平排列
itemSpacing gap 子元素间距
paddingLeft padding-left 左内边距
paddingRight padding-right 右内边距
paddingTop padding-top 上内边距
paddingBottom padding-bottom 下内边距
layoutAlign: STRETCH align-self: stretch 拉伸填充
layoutGrow: 1 flex-grow: 1 自动扩展

约束定位(Constraints)

Figma 属性 CSS 属性 说明
constraints.horizontal: LEFT left: Npx 左对齐固定
constraints.horizontal: RIGHT right: Npx 右对齐固定
constraints.horizontal: LEFT_RIGHT left: Npx; right: Npx 左右拉伸
constraints.horizontal: CENTER left: 50%; transform: translateX(-50%) 水平居中
constraints.horizontal: SCALE left: N%; width: N% 按比例缩放
constraints.vertical: TOP top: Npx 顶部固定
constraints.vertical: BOTTOM bottom: Npx 底部固定
constraints.vertical: TOP_BOTTOM top: Npx; bottom: Npx 上下拉伸
constraints.vertical: CENTER top: 50%; transform: translateY(-50%) 垂直居中
constraints.vertical: SCALE top: N%; height: N% 按比例缩放

样式属性

Figma 属性 CSS 属性 说明
opacity opacity 透明度
cornerRadius border-radius 圆角
fills[].color background-color 背景色
strokes[].color border-color 边框色
strokeWeight border-width 边框宽度
effects[] (DROP_SHADOW) box-shadow 投影
effects[] (LAYER_BLUR) filter: blur() 模糊
clipsContent: true overflow: hidden 裁剪内容

自从AI面世以来,目前真正要用到figma原始数据结构的其实已经并不多,大多数情况下并不需要直接接触figma的原始JSON数据,这里分享一下相关经验,留给需要定制化处理Figma的童鞋。

BroadcastChannel 使用总结

BroadcastChannel 使用总结

概述

BroadcastChannel API 是一个现代浏览器提供的跨窗口通信解决方案。在 WPS 插件 AI 写作项目中,我们使用它来实现弹窗与主窗口之间的数据传递。

项目中的使用场景

场景描述

在项目中,用户在 TaskPane.vue 的 textarea 输入内容后,点击"提示词优化"按钮会打开 ShowDialog.vue 弹窗。当弹窗中生成优化结果后,用户点击"应用优化"按钮,需要将优化后的内容回传到 TaskPane.vue 的 textarea 中。

技术挑战

  • ShowDialog 通过 window.open() 或 WPS 的 wps.ShowDialog() 打开
  • 传统 window.opener.postMessage() 在某些情况下可能不可靠
  • 需要一个稳定的跨窗口通信机制

实现方案

1. 发送方(ShowDialog.vue)

文件位置: src/components/ShowDialog.vue

代码实现:

/**
 * 应用优化结果
 */
const applyOptimization = () => {
  if (streamContent.value.trim()) {
    console.log('Applying optimization result----->', streamContent.value)

    try {
      // 使用 BroadcastChannel 发送消息(推荐方式)
      const channel = new BroadcastChannel('optimization_channel')
      channel.postMessage({
        type: 'optimization_applied',
        content: streamContent.value
      })
      console.log('Message sent via BroadcastChannel')

      // 关闭通道
      setTimeout(() => {
        channel.close()
      }, 100)

      // 同时尝试 postMessage 作为备用方案
      if (window.opener) {
        console.log('Sending message to window.opener')
        window.opener.postMessage({
          type: 'optimization_applied',
          content: streamContent.value
        }, '*')
      }

      ElMessage({
        message: '优化结果已应用',
        type: 'success',
        duration: 3000
      })

      // 延迟关闭,确保消息发送成功
      setTimeout(() => {
        closeDialog()
      }, 500)
    } catch (error) {
      console.error('Error sending message----->', error)
      ElMessage({
        message: '应用失败,请重试',
        type: 'error',
        duration: 3000
      })
    }
  } else {
    ElMessage({
      message: '优化结果为空',
      type: 'warning',
      duration: 3000
    })
  }
}

关键点:

  • 创建名为 optimization_channel 的 BroadcastChannel 实例
  • 使用 postMessage() 发送包含 typecontent 的消息对象
  • 发送完成后延迟关闭通道(100ms)
  • 保留 window.opener.postMessage 作为备用方案
  • 延迟关闭弹窗(500ms)以确保消息发送成功

2. 接收方(TaskPane.vue)

文件位置: src/components/TaskPane.vue

代码实现:

onMounted(async () => {
  // 初始化文档类型
  initDocType()
  // 获取模型类型列表
  await fetchModelTypes()

  // 使用 BroadcastChannel 监听来自优化弹窗的消息(推荐方式)
  const channel = new BroadcastChannel('optimization_channel')
  channel.onmessage = (event) => {
    console.log('Received message via BroadcastChannel----->', event.data)
    if (event.data && event.data.type === 'optimization_applied') {
      console.log('Received optimization result----->', event.data.content)
      inputText.value = event.data.content
      console.log('Input text updated----->', inputText.value)
    }
  }

  // 同时保留 postMessage 监听作为备用方案
  const handleMessage = (event) => {
    console.log('Received postMessage----->', event)
    if (event.data && event.data.type === 'optimization_applied') {
      console.log('Received optimization result via postMessage----->', event.data.content)
      inputText.value = event.data.content
      console.log('Input text updated----->', inputText.value)
    }
  }

  window.addEventListener('message', handleMessage)

  // 清理事件监听器
  onUnmounted(() => {
    console.log('Removing message event listeners')
    channel.close()
    window.removeEventListener('message', handleMessage)
  })

  console.log('TaskPane initialized, ready to receive messages')
})

关键点:

  • 在组件挂载时创建相同名称的 BroadcastChannel 实例
  • 使用 onmessage 监听消息
  • 验证消息类型(event.data.type === 'optimization_applied'
  • 更新 Vue 响应式数据 inputText.value
  • 在组件卸载时关闭通道并移除所有监听器

BroadcastChannel API 详解

基本语法

// 创建通道
const channel = new BroadcastChannel(channelName)

// 发送消息
channel.postMessage(message)

// 接收消息
channel.onmessage = (event) => {
  console.log(event.data)
}

// 关闭通道
channel.close()

特性

  1. 同源限制:只能在同源(相同协议、域名、端口)的窗口间通信
  2. 双向通信:所有监听同一频道的窗口都能接收消息,包括发送方自己
  3. 无需窗口引用:不需要知道目标窗口的引用
  4. 简单高效:API 简洁,性能优秀

消息格式

建议使用对象格式,包含 type 字段用于区分不同的消息类型:

{
  type: 'message_type',
  payload: {
    // 消息内容
  }
}

优势对比

BroadcastChannel vs postMessage

特性 BroadcastChannel window.postMessage
窗口引用 不需要 需要目标窗口引用
通信范围 同源所有窗口 可跨域
使用复杂度 简单 中等
可靠性 依赖窗口引用
浏览器支持 现代浏览器 所有浏览器

为什么选择 BroadcastChannel?

  1. 解耦合:发送方不需要知道接收方的引用
  2. 多窗口支持:可以同时向多个窗口广播消息
  3. 代码简洁:API 更直观,代码更易维护
  4. 可靠性高:不依赖窗口层级关系

浏览器兼容性

BroadcastChannel 在现代浏览器中有良好支持:

  • Chrome 54+
  • Firefox 38+
  • Edge 79+
  • Safari 15.4+

注意: IE 不支持,如果需要兼容 IE,需要使用 window.postMessage 作为降级方案。

最佳实践

1. 消息类型定义

建议定义常量或使用 TypeScript 类型:

// 消息类型常量
const MESSAGE_TYPES = {
  OPTIMIZATION_APPLIED: 'optimization_applied',
  CONTENT_INSERTED: 'content_inserted',
  // ...
}

// 发送
channel.postMessage({
  type: MESSAGE_TYPES.OPTIMIZATION_APPLIED,
  content: optimizedContent
})

2. 错误处理

添加 try-catch 和浏览器兼容性检查:

if (typeof BroadcastChannel !== 'undefined') {
  try {
    const channel = new BroadcastChannel('optimization_channel')
    channel.postMessage(message)
  } catch (error) {
    console.error('BroadcastChannel error:', error)
    // 降级方案
    window.opener?.postMessage(message, '*')
  }
} else {
  // 降级方案
  window.opener?.postMessage(message, '*')
}

3. 通道管理

  • 及时关闭:不再使用时调用 channel.close()
  • 单一职责:每个通道只处理一类消息
  • 命名规范:使用清晰的通道名称

4. 调试技巧

// 发送方
channel.postMessage({
  type: 'optimization_applied',
  content: data,
  timestamp: Date.now(), // 添加时间戳用于调试
  source: 'ShowDialog' // 标识来源
})

// 接收方
channel.onmessage = (event) => {
  console.log(`[${new Date(event.data.timestamp).toISOString()}] Received from ${event.data.source}:`, event.data)
  // 处理消息
}

项目中的应用流程

┌─────────────┐                    ┌──────────────────┐
│  TaskPane   │                    │   ShowDialog     │
│             │                    │                  │
│  (textarea) │                    │  (优化结果展示)   │
└──────┬──────┘                    └────────┬─────────┘
       │                                    │
       │ 1. 用户点击"提示词优化"              │
       │────────────────────────────────────>│
       │                                    │
       │                                    │ 2. 调用 AI API
       │                                    │    生成优化结果
       │                                    │
       │                                    │ 3. 用户点击"应用优化"
       │                                    │
       │ 4. BroadcastChannel.postMessage   │
       │<────────────────────────────────────│
       │    {type: 'optimization_applied',  │
       │     content: '优化后的内容'}        │
       │                                    │
       │ 5. channel.onmessage 接收消息      │
       │    更新 inputText.value            │
       │                                    │
       ▼                                    ▼

常见问题

Q1: 消息没有接收到?

排查步骤:

  1. 确认两个窗口同源(协议、域名、端口相同)
  2. 检查通道名称是否一致
  3. 确认接收方在发送之前已经创建监听
  4. 查看浏览器控制台是否有错误信息

Q2: 消息发送了多次?

可能原因:

  • 监听器被重复注册
  • 组件重复挂载

解决方案:

let channel = null

onMounted(() => {
  if (!channel) {
    channel = new BroadcastChannel('optimization_channel')
    channel.onmessage = handler
  }
})

onUnmounted(() => {
  if (channel) {
    channel.close()
    channel = null
  }
})

Q3: 需要跨域通信怎么办?

使用 window.postMessage 并指定目标源:

// 发送方
otherWindow.postMessage(message, 'https://target-domain.com')

// 接收方
window.addEventListener('message', (event) => {
  if (event.origin === 'https://source-domain.com') {
    // 处理消息
  }
})

总结

BroadcastChannel 是本项目解决跨窗口通信的核心技术,相比传统的 window.postMessage,它提供了更简洁、可靠的通信方式。在实际应用中,我们采用双通道策略(BroadcastChannel + postMessage)来确保消息传递的可靠性,并遵循最佳实践来保证代码的可维护性和扩展性。

参考资料

wps加载项不同窗口间通信

postMessage 失效原因分析

问题现象

在 WPS 插件项目中,ShowDialog.vue 弹窗通过 window.opener.postMessage() 向 TaskPane.vue 发送消息时,消息无法被正确接收。

根本原因

1. 弹窗打开方式的问题

查看 TaskPane.vueopenOptimizeDialog 方法(第 268-288 行):

const openOptimizeDialog = (requestData) => {
  const url = Util.GetUrlPath() + Util.GetRouterHash() + '/show-dialog?data=' + ...

  if (wps && wps.ShowDialog) {
    wps.ShowDialog(url, '提示词优化结果', width, height, false, false)
  } else {
    window.open(url, 'OptimizeDialog', `width=${width},height=${height},left=${left},top=${top}`)
  }
}

问题分析:

情况 A:使用 WPS 的 wps.ShowDialog()

当 WPS API 可用时,使用 wps.ShowDialog() 打开弹窗。这是问题的主要原因

┌─────────────────────────────────────────┐
         WPS 应用程序环境                 
                                         
  ┌──────────────┐    ┌───────────────┐ 
    TaskPane          ShowDialog    
    (主窗口)           (弹窗)         
                                    
   window: A         window: B      
   opener: null      opener: ???      关键问题
  └──────────────┘    └───────────────┘ 
                                       
             WPS 内部通信               
         └────────────────────┘          
                                         
└─────────────────────────────────────────┘

WPS 环境的特殊性:

  • wps.ShowDialog() 可能创建的是一个独立的 WPS 窗口,而不是标准的浏览器弹窗
  • 弹窗运行在不同的 JavaScript 上下文进程
  • window.opener 引用可能为 null 或指向了一个不可访问的对象
  • 即使 window.opener 不为 null,也可能无法正确访问父窗口的方法和属性
情况 B:使用 window.open()

虽然标准浏览器环境下 window.open() 会设置正确的 window.opener 引用,但在 WPS 插件环境中仍可能存在问题:

┌─────────────────────────────────────────┐
      浏览器环境 (WPS 插件上下文)          
                                         
  ┌──────────────┐    ┌───────────────┐ 
    TaskPane    │────>│  ShowDialog    
    (主窗口)           (弹窗)         
                                    
   window: A         window: B      
   opener: null │<───│ opener: A       理论上正确
  └──────────────┘    └───────────────┘ 
                                       
                                       
           postMessage 可能失败的原因:  
           1. 跨域限制                    
           2. 沙箱环境隔离                
           3. 窗口引用丢失                
         └────────────────────────────┘  
                                         
└─────────────────────────────────────────┘

可能的问题:

  1. 跨域安全限制:如果弹窗 URL 的域与主窗口不同,postMessagetargetOrigin 参数为 '*' 时可能被浏览器拦截
  2. WPS 插件沙箱:WPS 可能对插件窗口实施了沙箱隔离,限制了窗口间的直接访问
  3. 窗口引用丢失:在某些情况下,window.opener 可能会被浏览器或 WPS 清空

2. ShowDialog.vue 中 postMessage 的尝试方式分析

const applyOptimization = () => {
  try {
    // 方式1:发送到 opener
    if (window.opener) {
      window.opener.postMessage({...}, '*')
    }

    // 方式2:发送到 parent
    if (window.parent !== window) {
      window.parent.postMessage({...}, '*')
    }

    // 方式3:发送到自己
    window.postMessage({...}, '*')

    // 方式4:WPS 消息机制
    if (window.wps && window.wps.SendMessage) {
      window.wps.SendMessage('optimization_applied', streamContent.value)
    }
  } catch (error) {
    console.error('Error sending message----->', error)
  }
}

各方式失败原因:

方式 失败原因
window.opener.postMessage() opener 为 null 或不可访问(WPS 环境下)
window.parent.postMessage() parent 指向自己(弹窗不是 iframe),或 parent 不可访问
window.postMessage() 发送给当前窗口,无法到达 TaskPane
wps.SendMessage() WPS API 可能不支持此方法,或需要特殊权限

3. 为什么 window.opener 会失效?

技术原因详解:

3.1 WPS 窗口管理机制
传统浏览器环境:
┌─────────────────────────────────────┐
│ 浏览器进程                           │
│  ┌─────────────┐  ┌──────────────┐ │
│  │ 主窗口       │  │ 弹窗         │ │
│  │ window.opener ─>│ 引用指向主窗口│ │ ← 正常工作
│  └─────────────┘  └──────────────┘ │
└─────────────────────────────────────┘

WPS 插件环境:
┌─────────────────────────────────────┐
│ WPS 主进程                           │
│  ┌─────────────┐  ┌──────────────┐ │
│  │ TaskPane    │  │ ShowDialog   │ │
│  │ (进程 A)     │  │ (进程 B)      │ │ ← 进程隔离
│  │             │  │              │ │
│  │ 无直接引用   │  │ opener = null│ │ ← 引用丢失
│  └─────────────┘  └──────────────┘ │
│         ↑                ↑          │
│         └───── IPC ─────┘          │
└─────────────────────────────────────┘

WPS 可能使用了**进程间通信(IPC)**而非传统的浏览器窗口引用,导致:

  • 弹窗在独立的进程或线程中运行
  • JavaScript 的 window.opener 引用无法跨越进程边界
  • 需要使用 WPS 提供的特定 API 进行通信
3.2 安全策略限制

现代浏览器和 WPS 都实施了严格的安全策略:

// 可能的限制场景
1. Sandbox 属性: 如果弹窗被设置了 sandbox 属性
   <iframe sandbox="allow-scripts allow-same-origin">
   // 会阻止 window.opener 访问

2. opener 为 null 的情况:
   // 某些环境下,为了安全,opener 会被设为 null
   window.open(url, '_blank', 'noopener')  // 显式设置 noopener

3. 跨域限制:
   // 即使 postMessage 允许跨域,某些环境仍会拦截
   targetWindow.postMessage(message, '*')  // '*' 可能被拦截
3.3 WPS 插件的特殊性
// WPS 插件可能使用的技术栈
1. ElectronChromium 嵌入式浏览器
   - 多进程架构
   - 窗口隔离
   - 需要使用 IPC 通信

2. WPS 自定义窗口管理器
   - 不遵循标准浏览器窗口行为
   - 自定义的窗口打开/关闭逻辑
   - window.opener 可能未正确设置

3. 安全沙箱
   - 限制窗口间的直接访问
   - 需要通过 WPS API 中转

为什么 BroadcastChannel 能解决问题?

对比分析

postMessage 方式:
┌─────────────┐                    ┌──────────────┐
│  TaskPane   │                    │  ShowDialog  │
│             │                    │              │
│  需要窗口引用 ◄────────────────────── opener     │ ← 失败点
│             │                    │              │
└─────────────┘                    └──────────────┘
       ↑                                   │
       └───────── 直接依赖窗口关系 ──────────┘
                   (脆弱,易断裂)

BroadcastChannel 方式:
┌─────────────┐                    ┌──────────────┐
│  TaskPane   │                    │  ShowDialog  │
│             │                    │              │
│  订阅频道   │                    │  发布消息    │
│     ↓       │                    │     ↓        │
│  Channel   ─┼──────────────────┼─>  Channel    │
│             │                    │              │
└─────────────┘                    └──────────────┘
       ↑                                   │
       └───────── 通过频道解耦 ────────────┘
                   (稳定,可靠)

BroadcastChannel 的优势

  1. 不依赖窗口引用

    // 不需要知道目标窗口的引用
    // 不依赖 window.opener
    const channel = new BroadcastChannel('channel_name')
    channel.postMessage(message)  // 直接发送
    
  2. 同源策略下的安全通信

    // 浏览器保证同源窗口可以通信
    // 无需担心跨域问题
    // 无需传递窗口引用
    
  3. 多窗口广播

    // 一个消息可以到达所有订阅者
    // 不需要知道接收方的数量和状态
    
  4. 生命周期独立

    // 窗口可以随时订阅/取消订阅
    // 不受窗口打开/关闭顺序的影响
    

调试验证

如何验证 window.opener 是否失效

在 ShowDialog.vue 中添加调试代码:

onMounted(() => {
  // 调试:检查 window.opener
  console.log('window.opener:', window.opener)
  console.log('window.opener === null:', window.opener === null)
  console.log('window.opener === undefined:', window.opener === undefined)
  console.log('window.parent:', window.parent)
  console.log('window.parent === window:', window.parent === window)

  // 尝试访问父窗口
  try {
    if (window.opener) {
      console.log('opener.location:', window.opener.location)
    }
  } catch (e) {
    console.error('Cannot access opener:', e)
    // 这里很可能会抛出安全错误
  }
})

预期结果:

  • 在 WPS 环境下:window.opener 很可能为 null 或访问时报错
  • 在标准浏览器:window.opener 应该指向父窗口

如何验证 BroadcastChannel 是否工作

// 在 ShowDialog.vue 发送消息后
const testBroadcastChannel = () => {
  const channel = new BroadcastChannel('test_channel')

  // 监听自己的消息(BroadcastChannel 会广播给所有订阅者,包括自己)
  channel.onmessage = (event) => {
    console.log('Received my own message:', event.data)
  }

  channel.postMessage({ test: 'hello' })

  // 如果能在控制台看到 "Received my own message",说明 BroadcastChannel 工作正常
}

总结

postMessage 失效的核心原因

  1. WPS 环境特殊性wps.ShowDialog() 创建的弹窗不在标准浏览器窗口体系中
  2. 窗口引用丢失window.opener 为 null 或不可访问
  3. 进程隔离:弹窗可能运行在独立进程,无法直接访问父窗口
  4. 安全策略:WPS 可能主动限制窗口间的直接访问

为什么 BroadcastChannel 有效

  1. 解耦窗口关系:不需要窗口引用,通过频道名称通信
  2. 浏览器原生支持:不依赖 WPS API,是浏览器标准 API
  3. 同源安全保障:只要同源就能通信,不受窗口层级影响
  4. 简单可靠:API 简洁,不存在复杂的窗口引用关系

最佳实践

在 WPS 插件或类似环境中,跨窗口通信应优先使用:

  1. BroadcastChannel(首选)- 同源窗口通信
  2. localStorage + storage event(降级方案)- 兼容性更好
  3. SharedWorker(高级场景)- 多窗口共享状态

避免使用:

  • window.opener.postMessage() - 依赖窗口引用,易失效
  • window.parent.postMessage() - 仅适用于 iframe
  • WPS 特定 API(除非有官方文档支持)

参考资料

Everything Claude Code 新手教学指南(中文版)

本文档是对 Everything Claude Code 项目的系统性教学汇总,面向新手,包含术语解释、架构图解、深度分析和知识点详解。


目录

  1. 目录结构与术语解释
  2. 宏观框架理解
  3. 高频 Agent/Skill 分析与 Orchestrate 重点讲解
  4. 教练组深度报告
  5. 新手友好知识点图解

一、目录结构与术语解释

项目目录结构

everything-claude-code/
├── agents/        <- 13个专业"子代理",相当于你的团队成员
├── skills/        <- 55"技能包",是领域知识库
├── commands/      <- 33"斜杠命令",用户直接输入的快捷指令
├── hooks/         <- "钩子",自动化触发器(编辑后自动格式化等)
├── rules/         <- "规则",永远遵守的编码标准和约束
├── mcp-configs/   <- "MCP服务器配置",连接外部系统的桥梁
├── scripts/       <- Node.js脚本,hooks的具体实现逻辑
├── contexts/      <- 示例CLAUDE.md,不同工作模式的预设上下文
├── schemas/       <- JSON Schema定义,约束配置文件格式
├── tests/         <- 测试套件
├── docs/          <- 多语言文档(中文、日文等)
└── examples/      <- 不同项目类型的CLAUDE.md示例

关键术语对照表

术语 中文 类比
Agent 子代理/专员 公司里的专业岗位(架构师、测试员、审计员)
Skill 技能/知识包 岗位培训手册,详细告诉你"怎么做"
Command 斜杠命令 快捷键,一键触发复杂工作流
Hook 钩子/触发器 自动化流水线上的传感器,事件发生时自动执行
Rule 规则 公司制度手册,所有人必须遵守
MCP 模型上下文协议 统一的"外部系统接口标准"
Instinct 本能/直觉 从经验中提炼的"肌肉记忆"
Orchestrate 编排 乐队指挥,按顺序调度多个Agent协作
Handoff 交接 Agent之间的工作交接文档
TDD 测试驱动开发 先写考试题,再写答案

二、宏观框架理解

框架1:与外部系统的沟通架构

                    +---------------------------+
                    |     你(开发者)            |
                    |  输入: /plan /tdd /e2e     |
                    +-------------+-------------+
                                  |
                    +-------------v-------------+
                    |     Claude Code 主进程      |
                    |  (读取 CLAUDE.md + Rules) |
                    +-------------+-------------+
                                  |
              +-------------------+-------------------+
              |                   |                   |
    +---------v--------+  +------v---------+  +------v-----------+
    |  Agents (子代理)  |  | Hooks (自动化)  |  | MCP (外部连接)    |
    |  planner         |  | 编辑后格式化    |  | GitHub           |
    |  code-reviewer   |  | TypeScript检查  |  | Supabase         |
    |  tdd-guide       |  | 构建分析        |  | Vercel           |
    |  security-review |  | 持续学习        |  | Context7         |
    |  architect       |  | 会话持久化      |  | Exa搜索          |
    +------------------+  +----------------+  +------------------+
              |                                      |
              |              +------------------------+
              |              |
    +---------v--------------v---------+
    |         Skills (知识库)           |
    |  coding-standards, tdd-workflow, |
    |  postgres-patterns, etc.         |
    |  <- Agent执行时查阅的参考手册     |
    +----------------------------------+

MCP 是什么? 可以理解为"万能适配器"。Claude Code 本身不能直接操作 GitHub、数据库、部署平台,但通过 MCP 协议,可以连接到这些外部系统。mcp-configs/mcp-servers.json 就是这些适配器的配置清单。

框架2:5层功能分类

整个系统可以分为 5 个层次:

+-------------------------------------------------------------+
| 第5层:编排层 (Orchestration)                                 |
|   /orchestrate -> 串联多个Agent按流水线执行                    |
|   /multi-workflow -> 多模型协作(Claude + Gemini + Codex)     |
+-------------------------------------------------------------+
| 第4层:命令层 (Commands)                                      |
|   用户接口:/plan /tdd /e2e /code-review /build-fix 等        |
|   每个命令对应一个或多个Agent                                  |
+-------------------------------------------------------------+
| 第3层:执行层 (Agents)                                        |
|   实际干活的专员:planner, tdd-guide, code-reviewer 等         |
|   每个Agent有指定的模型(opus/sonnet/haiku)和工具权限          |
+-------------------------------------------------------------+
| 第2层:知识层 (Skills + Rules)                                |
|   Skills = 操作手册(怎么做),Rules = 制度手册(必须怎样)      |
|   Agent执行时参考这一层                                        |
+-------------------------------------------------------------+
| 第1层:自动化层 (Hooks + MCP)                                 |
|   Hooks = 被动自动化(事件驱动)                               |
|   MCP = 主动连接外部系统                                      |
+-------------------------------------------------------------+

框架3:一个典型Feature的完整生命周期

你说: "帮我实现用户认证功能"

  1. /plan(或/orchestrate feature)
     |
     v
  [Planner Agent] -- 分析需求 -> 输出实施计划
     |                         v HANDOFF文档
     v
  [你确认: "OK, 开始吧"]
     |
     v
  2. /tdd
     |
     v
  [TDD-Guide Agent] -- 先写测试(RED) -> 实现代码(GREEN) -> 重构(REFACTOR)
     |                                v HANDOFF文档
     v
  3. 自动触发 Hooks
     |-- post-edit-format.js -> 自动格式化代码
     |-- post-edit-typecheck.js -> TypeScript类型检查
     +-- post-edit-console-warn.js -> 警告遗留的console.log
     |
     v
  4. /code-review
     |
     v
  [Code-Reviewer Agent] -- 审查质量、安全、可维护性
     |                     v HANDOFF文档
     v
  5. /security-review(可选但推荐)
     |
     v
  [Security-Reviewer Agent] -- OWASP Top 10检查
     |
     v
  6. 提交代码 + 创建PR
     +-- Hook自动记录PR URL

框架4:学习与进化系统

你的日常编码操作
       |
       | Hooks 100%捕获
       v
 [observations.jsonl]    <- 原始观察数据(你用了什么工具、做了什么操作)
       |
       | 后台 Haiku 分析
       v
 [Instinct 本能]         <- 提炼出的原子模式
   confidence: 0.3->0.9      (如:"编辑前先Grep""优先函数式风格")
       |
       | /evolve 聚类
       v
 [Skill/Command/Agent]  <- 进化成完整的技能、命令或代理

关键概念:

  • Project-scoped Instinct:React 项目学到的模式不会污染 Go 项目
  • Confidence scoring:0.3=试探性,0.9=几乎确定
  • Promotion:一个模式在2+个项目中出现且高置信 -> 自动提升为全局

三、高频 Agent/Skill 分析与 Orchestrate 重点讲解

日常工作最高频的 Agent/Command

频率 Agent/Command 场景
最高 /plan -> planner 任何新功能开始前,先做计划
最高 /tdd -> tdd-guide 写代码的核心工作流
code-reviewer 代码写完自动触发
/build-fix -> build-error-resolver 构建失败时
/orchestrate 复杂任务全流程自动化
security-reviewer 涉及认证/支付/敏感数据时
architect 系统设计决策时
/learn -> continuous-learning 每次会话结束提炼经验

/orchestrate 深度讲解

Orchestrate 是流水线指挥官。它的核心价值是:你不需要手动一个个调Agent,它帮你按正确顺序串联。

四种预设工作流:

/orchestrate feature "添加用户认证"
  -> planner -> tdd-guide -> code-reviewer -> security-reviewer

/orchestrate bugfix "修复登录失败问题"
  -> planner -> tdd-guide -> code-reviewer

/orchestrate refactor "重构缓存层"
  -> architect -> code-reviewer -> tdd-guide

/orchestrate security "安全审计支付模块"
  -> security-reviewer -> code-reviewer -> architect

自定义工作流:

/orchestrate custom "architect,tdd-guide,code-reviewer" "重新设计缓存层"

核心机制 -- Handoff(交接):

Agent之间不是各干各的,而是通过结构化的Handoff文档传递上下文:

## HANDOFF: planner -> tdd-guide

### Context
实施计划已完成:用户认证使用JWT + Refresh Token

### Findings
- 需要3个新endpoint: /login, /register, /refresh
- 需要中间件验证token

### Files Modified
- 无(规划阶段不修改文件)

### Open Questions
- Token过期时间选15分钟还是30分钟?

### Recommendations
- 从/register endpoint开始TDD

并行优化: 当Agent之间无依赖时,可以并行执行:

code-reviewer    --+
security-reviewer --+--> 合并结果
architect        --+

四、教练组深度报告

教练1:Agent编排深度分析

两套编排体系

体系A:单模型编排 (/orchestrate)
  * 纯 Claude 内部 Agent 链
  * 通过 Handoff 文档传递上下文
  * 零外部依赖,简单可靠
  * 适合:日常功能开发、Bug修复、重构

体系B:多模型协作 (multi-* 系列)
  * Claude 当指挥 + Codex(后端专家) + Gemini(前端专家)
  * 外部模型只能输出 Diff,Claude 拥有"代码主权"
  * 需要 ace-tool MCP + codeagent-wrapper
  * 适合:全栈复杂功能、高质量交付

命令横向对比

命令 外部模型 复杂度 适用场景
/orchestrate feature 日常功能开发
/orchestrate bugfix Bug修复
/orchestrate refactor 代码重构
/orchestrate security 安全审查
/orchestrate custom 自定义Agent链
/workflow Codex+Gemini 最高 全栈复杂功能(6阶段)
/ccg:plan Codex+Gemini 单独规划(禁止写代码)
/ccg:execute Codex或Gemini 执行已规划的任务
/frontend 仅Gemini 纯前端UI任务
/backend 仅Codex 纯后端逻辑任务

多模型协作的信任体系

后端领域           前端领域
+---------+        +---------+
|  CODEX  |        | GEMINI  |
| (权威)  |        | (权威)  |
| API设计  |        | 组件设计 |
| 算法实现 |        | 样式布局 |
| DB优化   |        | 动画交互 |
+----+----+        +----+----+
     |  只输出Diff       |  只输出Diff
     +--------+----------+
              v
       +----------+
       |  CLAUDE  |  <- 代码主权者
       | 综合重构  |  <- 唯一能写文件的
       | 质量把关  |
       +----------+

铁律:外部模型零文件写入权限,所有 Edit/Write 只能由 Claude 执行。

选择命令的决策树

你的任务是什么?
+-- 日常功能/Bug修复 -> /orchestrate feature/bugfix
+-- 纯前端 UI -> /frontend
+-- 纯后端逻辑 -> /backend
+-- 全栈复杂功能
|   +-- 想一步到位 -> /workflow
|   +-- 先规划再执行 -> /ccg:plan -> /ccg:execute
+-- 重构/安全审计 -> /orchestrate refactor/security

编排的5个陷阱

陷阱 说明 正确做法
超时 Codex/Gemini可能需要10分钟+ 设置 timeout: 600000
代码主权混淆 以为外部模型可以直接改文件 外部模型只出Diff
越级执行 /ccg:plan阶段偷偷写代码 规划阶段严禁写代码
需求不完整 评分<7就开干 强制停下来问清楚
信任倒置 前端问题听Codex的 各领域只听对应权威

教练2:Hooks自动化深度分析

完整的Hook事件生命周期

会话开始
  |
  v SessionStart
  +-- 加载上次会话摘要
  +-- 检测包管理器(npm/pnpm/yarn/bun)
  +-- 列出已学技能数量
  |
  v [工具调用循环,每次都走这个流程]
  |
  |  PreToolUse(工具执行前)
  |  +-- Dev Server阻断器 -> 匹配Bash -> exit 2 阻断
  |  +-- Tmux提醒 -> 匹配Bash -> exit 0 警告
  |  +-- Git Push审查提醒 -> 匹配Bash -> exit 0 提示
  |  +-- 非标文档警告 -> 匹配Write -> exit 0 警告
  |  +-- 压缩建议(每50次) -> 匹配Edit|Write -> exit 0 提示
  |  +-- 持续学习观察 -> 匹配* -> 异步捕获
  |       |
  |       v [工具实际执行]
  |       |
  |  PostToolUse(工具执行后)
  |  +-- PR URL记录器 -> 匹配Bash -> 提取PR链接
  |  +-- 构建分析 -> 匹配Bash -> 异步后台分析
  |  +-- JS/TS自动格式化 -> 匹配Edit -> Biome/Prettier
  |  +-- TypeScript类型检查 -> 匹配Edit -> tsc --noEmit
  |  +-- console.log警告 -> 匹配Edit -> 实时提示行号
  |  +-- 持续学习观察 -> 匹配* -> 异步捕获(含输出)
  |       |
  |       v
  |  Stop(Claude回复完成后)
  |  +-- console.log全局审计 -> 扫描所有本次修改的文件
  |
  v [用户执行 /compact 时]
  PreCompact -> 记录压缩日志 + 标注会话文件
  |
  v 会话结束
  SessionEnd
  +-- Hook1: 提取摘要 -> 写入 ~/.claude/sessions/
  +-- Hook2: 评估是否值得提取模式 -> 信号持续学习系统

Hook数据流原理

Claude Code
    | 通过 stdin 传入 JSON
    v
+-----------------------------+
|  Hook 脚本读取 stdin        |
|  处理逻辑 -> 决策           |
|                             |
|  stdout: 必须原样透传JSON   | <- 不传回去工具就挂了!
|  stderr: 警告信息给用户看   |
|                             |
|  exit 0: 放行               |
|  exit 2: 阻断(仅Pre)     |
+-----------------------------+

实用Hook配方

配方 效果
文件超800行阻断 Write + exit 2 如果 lines > 800
新源文件提醒写测试 Write + 检测对应 .test 文件是否存在
Python自动格式化 Edit + 检测 .py -> 执行 ruff format
TODO/FIXME警告 Edit + 正则检测 new_string

教练3:学习进化系统深度分析

v1 -> v2 -> v2.1 进化史

v1 (基础版)                v2 (本能架构)              v2.1 (项目隔离)
--------------            --------------            --------------
Stop Hook触发              Pre/PostToolUse           + 项目作用域
(50-80%可靠)               (100%可靠)                (默认隔离)

整个会话提取                原子"本能"               + 晋升机制
(粗粒度)                   (细粒度)                  (project->global)

无置信度                    0.3-0.9评分              + 跨项目检测

全局存储                    全局存储                  + 每项目独立存储
(污染风险)                 (污染风险)                (安全隔离)

Instinct(本能)的完整生命周期

1步:观察捕获
  每次工具调用 -> observe.sh -> observations.jsonl
  (截断到5000字符,超10MB自动归档)

第2步:项目检测
  git remote URL -> SHA256哈希前12位 = project_id
  (同一仓库不同机器 = 同一ID,跨机器可移植)

第3步:模式分析(后台Haiku代理)
  4类模式检测:
  * 用户纠正 -> "不用X,用Y"
  * 错误解决 -> 同一错误同样修复多次
  * 重复工作流 -> 同样的工具序列反复出现
  * 工具偏好 -> 总是Grep->Edit而不是直接Edit

第4步:本能文件创建
  写入 YAML frontmatter + Markdown
  包含:id、trigger、confidence、domain、scope、evidence

第5步:本能成熟
  +------------------------------------+
  | 置信度增长规则:                     |
  |  观察1-2-> 0.3 (试探)            |
  |  观察3-5-> 0.5 (中等)            |
  |  观察6-10-> 0.7 (强,自动应用)    |
  |  观察11+次 -> 0.85 (核心行为)        |
  |                                     |
  |  确认观察 -> +0.05                   |
  |  矛盾观察 -> -0.10                   |
  |  无观察/周 -> -0.02 (自然衰减)       |
  +------------------------------------+

第6步:晋升(project -> global)
  条件:2+个项目中出现 + 平均置信度 >= 0.8
  命令:/promote

第7步:进化(instinct -> skill/command/agent)
  命令:/evolve
  * 相关本能聚类 -> 自动生成高级结构
  * 工作流本能 -> Command(/new-table 等)
  * 行为本能 -> Skill(functional-patterns 等)
  * 多步骤本能 -> Agent(debugger 等)

项目隔离的关键设计

~/.claude/homunculus/
+-- instincts/personal/          <- 全局本能(跨项目通用)
|   +-- always-validate-input.yaml
+-- projects/
    +-- a1b2c3d4e5f6/           <- React项目
    |   +-- instincts/personal/
    |       +-- use-react-hooks.yaml   <- 不会污染Django项目
    +-- f6e5d4c3b2a1/           <- Django项目
        +-- instincts/personal/
            +-- use-drf-serializers.yaml  <- 不会污染React项目

冲突解决: 同ID本能,项目作用域优先于全局(项目覆盖全局)。


五、新手友好知识点图解

5.1 Claude Code 的"公司架构"比喻

把整个系统想象成一家软件公司:

+-------------------------------------------------------------+
|                    你(CEO / 老板)                            |
|                 下达指令:"帮我实现用户认证"                    |
+----------------------------+--------------------------------+
                             |
                             v
+-------------------------------------------------------------+
|              Claude Code(总经理 / COO)                      |
|                                                              |
|  读取公司制度(Rules)-> 查看操作手册(Skills)                 |
|  -> 安排对应部门(Agents)干活                                 |
|  -> 自动化流程(Hooks)在后台默默运行                          |
|  -> 需要外部资源时通过 MCP 对接                                |
+----------------------------+--------------------------------+
                             |
          +------------------+------------------+
          |                  |                  |
          v                  v                  v
   +----------+       +----------+       +----------+
   | 规划部    |       | 研发部    |       | 质量部    |
   | planner  |       | tdd-guide|       | reviewer |
   | architect|       |          |       | security |
   +----------+       +----------+       +----------+

   用 Opus 模型        用 Sonnet 模型       用 Sonnet 模型
   (高管级思考)        (中层级执行)         (中层级审查)

5.2 Model 选择 = 雇人选择

为什么有 Opus、Sonnet、Haiku 三种模型?

+-------------+   +-------------+   +-------------+
|   Opus 4.6  |   | Sonnet 4.6  |   |  Haiku 4.5  |
| ============|   | ============|   | ============ |
|  首席架构师  |   |  高级工程师  |   |   初级工程师  |
|             |   |             |   |              |
| 能力:最强   |   | 能力:很强   |   | 能力:够用    |
| 速度:较慢   |   | 速度:适中   |   | 速度:最快    |
| 成本:最贵   |   | 成本:适中   |   | 成本:最便宜  |
|             |   |             |   |              |
| 适合:       |   | 适合:       |   | 适合:        |
| * 架构决策   |   | * 写代码     |   | * 后台分析    |
| * 复杂规划   |   | * 代码审查   |   | * 格式化任务  |
| * 深度推理   |   | * 测试编写   |   | * 持续学习    |
+-------------+   +-------------+   +-------------+

核心原则:用最合适(而非最贵)的模型做对应的事
不需要首席架构师去跑格式化,也不要让初级员工做架构决策

5.3 什么是 MCP?(Model Context Protocol)

想象 Claude Code 是一个人,他坐在一间封闭的办公室里。
他很聪明,但:
  x 看不到互联网
  x 打不开 GitHub
  x 连不上数据库
  x 部署不了代码

MCP 就是给这间办公室装上的"电话线":

+------------------------------------------------------------+
|                    Claude Code 的办公室                      |
|                                                             |
|                 +--------------+                            |
|                 |  Claude Code |                            |
|                 +------+-------+                            |
|                        |                                    |
|              MCP 协议(统一接口标准)                         |
|                        |                                    |
|    +-------+-------+---+----+--------+-----------+         |
|    |       |       |        |        |           |         |
|    v       v       v        v        v           v         |
| +------++------++------++------++------++----------+       |
| |GitHub||Supa- ||Vercel||Exa   ||Click-||Context7  |       |
| |      ||base  ||      ||搜索  ||House ||(文档查询)|       |
| |PR/   ||数据库||部署  ||网络  ||分析  ||最新API  |       |
| |Issue ||操作  ||管理  ||搜索  ||查询  ||文档     |       |
| +------++------++------++------++------++----------+       |
|  电话1   电话2   电话3   电话4   电话5   电话6              |
+------------------------------------------------------------+

每根"电话线"就是一个 MCP Server。
mcp-configs/mcp-servers.json 就是电话簿 -- 记录了每根线怎么拨。

注意:同时开启不超过10个MCP,否则会占用太多"上下文窗口"

5.4 什么是"上下文窗口"?为什么要 /compact?

上下文窗口 = Claude 的"工作记忆"(短期记忆)

想象 Claude 的大脑是一张有限大小的桌子:

开始时:桌子很空
+--------------------------------------------------+
|  [你的指令] [CLAUDE.md] [Rules]   ...空...        |
|  ========...                                      |
|  已用 20%                          剩余 80%       |
+--------------------------------------------------+

工作一段时间后:桌子快满了
+--------------------------------------------------+
|  [指令][Rules][文件1][文件2][修改1][修改2]...      |
|  ================================================ |
|  已用 90%                          剩余 10%       |
+--------------------------------------------------+

到了这个阶段,Claude 可能会:
  * 忘记之前的对话内容
  * 做出不一致的决策
  * 自动触发压缩(可能丢失重要上下文)

/compact 的作用 = 整理桌面
+--------------------------------------------------+
|  [压缩后的摘要]  ...空...                         |
|  ========...                                      |
|  已用 15%                          剩余 85%       |
+--------------------------------------------------+

这就是为什么 suggest-compact.js50次工具调用会提醒你:
"你的桌子快满了,要不要整理一下?"

5.5 stdin / stdout / stderr 是什么?

这是 Hook 系统的基础:

每个程序都有3"管道",可以理解为3条传送带:

  stdin  (标准输入)  <- 数据进来的管道
    就像:收件箱
    Hook 从这里读取 Claude 传过来的 JSON 数据

  stdout (标准输出)  <- 数据出去的管道
    就像:发件箱
    Hook 必须把原始 JSON 原样传回去
    注意:如果不传回去,Claude 的工具调用会失败!

  stderr (标准错误)  <- 消息/警告管道
    就像:便签纸
    Hook 把警告信息写到这里,用户会看到
    不影响数据传输

具体流程:

Claude Code                    Hook 脚本                 用户
    |                              |                      |
    |---- stdin (JSON数据) ------->|                      |
    |                              | 处理逻辑...           |
    |                              |                      |
    |<--- stdout (原样JSON) ------|                      |
    |                              |                      |
    |                              |---- stderr (警告) -->|
    |                              |  "[Hook] 超800行!"   |
    |                              |                      |
    |<--- exit code --------------|                      |
    |     exit 0 = 放行            |                      |
    |     exit 2 = 阻断            |                      |

5.6 Exit Code(退出码)是什么?

每个程序结束时都会返回一个数字,告诉调用者"我执行得怎么样":

+----------+--------------------------------------------+
| Exit Code| 含义                                       |
+----------+--------------------------------------------+
|    0     | 一切正常,继续执行                          |
|          | 用于:警告(显示信息但不阻止操作)           |
|----------+--------------------------------------------+
|    2     | 阻断!停止工具执行                          |
|          | 仅 PreToolUse 有效                         |
|          | 用于:危险操作必须阻止(如非tmux跑dev)     |
|----------+--------------------------------------------+
|  其他    | 出错了,但不阻断                            |
| (1,3..)  | Claude 会记录错误但继续执行                 |
+----------+--------------------------------------------+

类比:
  exit 0 = 保安说"你可以进去,但注意安全"  <- 放行+提醒
  exit 2 = 保安说"不行,禁止通行"          <- 硬拦截
  exit 1 = 保安自己摔倒了,但不影响你进去   <- 出错但不阻断

5.7 Matcher(匹配器)工作原理

匹配器决定 Hook 在哪些工具调用时触发:

  "*"        -> 所有工具都触发
               Edit Y  Write Y  Bash Y  Read Y  Grep Y

  "Bash"     -> 只在 Bash 工具时触发
               Edit x  Write x  Bash Y  Read x  Grep x

  "Edit"     -> 只在 Edit 工具时触发
               Edit Y  Write x  Bash x  Read x  Grep x

  "Edit|Write" -> Edit 或 Write 时触发(正则表达式的"或")
               Edit Y  Write Y  Bash x  Read x  Grep x

  "|" 就是正则里的"或"符号

5.8 同步 vs 异步 Hook

同步(默认):Hook 执行完才能继续
-------------------------------------------
时间 ---------------------------------------->

Claude:  [准备工具]--等--等--等--[继续执行]
                      |           ^
Hook:                 +--[执行]--+

如果 Hook 执行很慢,Claude 就被堵住了


异步("async": true):Hook 在后台跑,不阻塞
-------------------------------------------
时间 ---------------------------------------->

Claude:  [准备工具]--[继续执行]--[继续工作]--
                      |
Hook:                 +--[后台执行中...]--[完成]

适合:持续学习的 observe.sh(每次都跑,不能阻塞主流程)
适合:构建分析(耗时,但不需要立即结果)

5.9 什么是 JSONL 格式?

JSON = 一个完整的数据对象
{
  "name": "张三",
  "age": 25,
  "skills": ["JS", "Python"]
}

JSONL = JSON Lines = 每行一个JSON对象
{"event":"tool_start","tool":"Edit"}    <- 第1行
{"event":"tool_done","tool":"Edit"}     <- 第2行
{"event":"tool_start","tool":"Bash"}    <- 第3行
{"event":"tool_done","tool":"Bash"}     <- 第4行

为什么用 JSONL 而不是 JSON?
  Y 可以一行一行追加(append),不用读取整个文件
  Y 文件再大也不怕,可以一行一行读
  Y 某一行坏了不影响其他行
  x 普通 JSON 每次追加要先读取->解析->修改->全量写入(大文件很慢)

这就是为什么 observations.jsonl 用这个格式 --
每次工具调用追加一行,几千次调用也不卡。

5.10 SHA256 哈希 = 指纹

为什么项目检测用 SHA256?

SHA256 是一个"指纹函数",任何输入都会产生唯一的固定长度输出:

输入: "https://github.com/oker/my-react-app.git"
  |
  v SHA256
输出: "a1b2c3d4e5f6..."  <- 取前12位作为 project_id

关键特性:
  Y 同样的输入 -> 永远同样的输出
    你的Mac上:git remote = 同一URL -> 同一hash
    同事的Linux上:同一URL -> 同一hash
    -> 跨机器可移植!

  Y 不同的输入 -> 几乎不可能相同
    React项目URL -> "a1b2c3..."
    Django项目URL -> "f6e5d4..."
    -> 项目之间不会混淆!

  Y 不可逆:从hash推不出原始URL
    -> 隐私安全

5.11 YAML Frontmatter 是什么?

很多文件(Agent、Skill、Instinct)的开头都有这个结构:

+-- YAML Frontmatter(元数据区)----+
|  ---                              |
|  name: planner                    |
|  description: Expert planning...  |
|  model: opus                      |
|  tools: [Read, Grep, Glob]       |
|  ---                              |
+-----------------------------------+
+-- Markdown 正文(内容区)---------+
|  # Planner Agent                  |
|                                   |
|  你是一个规划专家...               |
|  ...具体的指令和模板...            |
+-----------------------------------+

两个 "---" 之间的部分就是 YAML Frontmatter:
  * 用 YAML 格式(key: value)
  * 定义了这个文件的"属性"
  * 系统读取这些属性来决定怎么使用这个文件

类比:
  YAML Frontmatter = 书的封面信息(作者、出版社、ISBN)
  Markdown 正文     = 书的正文内容

5.12 Unified Diff Patch 是什么?

在多模型协作中,外部模型只能输出 Unified Diff Patch。
这是一种"补丁格式",描述文件的修改方式:

--- a/src/auth.ts          <- 修改前的文件
+++ b/src/auth.ts          <- 修改后的文件
@@ -10,6 +10,8 @@          <- 修改的位置(第10行开始)
 import { User } from './models'

 export function login(email: string, password: string) {
+  // 输入验证                    <- "+" 开头 = 新增的行
+  if (!email) throw new Error('Email required')
   const user = await findUser(email)
-  return user                    <- "-" 开头 = 删除的行
+  return generateToken(user)     <- "+" 替换的新行
 }

看懂规则:
  无符号 = 上下文(没改的行)
  +     = 新增或替换的行(绿色)
  -     = 删除的行(红色)

为什么外部模型只输出 Diff 而不直接改文件?
  -> 安全!Claude 审查 Diff 后才决定是否采纳
  -> 相当于:外部专家只能提"修改建议"Claude 是最终拍板的人

5.13 TDD 的 Red-Green-Refactor 图解

TDD = Test-Driven Development(测试驱动开发)

传统方式(先写代码,后补测试):
  写代码 -> 写测试 -> "测试怎么老过不了..."
  问题:测试变成了给代码"打补丁",容易遗漏边界情况

TDD方式(先写测试,再写代码):

  RED(红灯)
  +----------------------------------+
  | 写一个测试                        |
  | 运行 -> 失败 x                    |
  | 这是对的!因为代码还没写           |
  +----------------+-----------------+
                   |
                   v
  GREEN(绿灯)
  +----------------------------------+
  | 写最少的代码让测试通过             |
  | 运行 -> 通过 Y                    |
  | 不要多写!刚好通过就行             |
  +----------------+-----------------+
                   |
                   v
  REFACTOR(重构)
  +----------------------------------+
  | 改善代码质量(提取常量、优化结构) |
  | 运行 -> 仍然通过 Y               |
  | 有测试保护,重构不怕改坏           |
  +----------------+-----------------+
                   |
                   v
              回到 RED(下一个功能点)

循环往复,直到所有功能点都覆盖
覆盖率要求:80%+(关键业务逻辑要100%)

为什么先写测试这么重要?
  1. 迫使你先想清楚"要什么",而不是"怎么做"
  2. 每一行代码都有存在的理由(为了通过某个测试)
  3. 重构时有安全网,改坏了测试会立刻告诉你

5.14 置信度衰减 = 记忆遗忘曲线

本能的置信度不是只升不降的,它会"遗忘":

置信度
  1.0 |
      |
  0.9 |                    --- 反复观察到,持续增长
      |                 ---
  0.8 |---- 晋升线 ----/-------------------------------
      |             ---
  0.7 |          ---
      |       ---
  0.6 |    ---
      | ---
  0.5 |-
      |
  0.4 |
      |              如果长期不再观察到...
  0.3 |                  \
      |                   \-- 每周 -0.02
  0.2 |                    \
      |                     \
  0.1 |                      \-- 最终淡出
      |
  0.0 |-----------------------------------------1周  第2周  第3周  第4周  ...  第20周

这个设计很巧妙:
  Y 你换了编码风格?旧习惯会自然淡出
  Y 不用手动删除过时的本能
  Y 真正核心的行为(一直在用的)不会衰减
  Y 类似人脑的"遗忘曲线" -- 不用的知识自然遗忘

5.15 /orchestrate feature 完整动画

/orchestrate feature "添加购物车功能"

阶段1  Planner Agent (Opus)                              [只读]
+----------------------------------------------+
|  读取代码库结构                                |
|  分析已有的商品模块                            |
|  制定实施计划:                                |
|     Phase A: 购物车数据模型                    |
|     Phase B: API endpoints                    |
|     Phase C: 前端组件                          |
|  输出 HANDOFF -> tdd-guide                    |
+--------------------------+-------------------+
                           | 传递
                           v
阶段2  TDD-Guide Agent (Sonnet)                          [可写]
+----------------------------------------------+
|  RED:   写 cart.test.ts(测试先行)            |
|  GREEN: 写 cart.ts(最小实现通过测试)          |
|  REFACTOR: 重构代码                           |
|  输出 HANDOFF -> code-reviewer               |
|     附带:修改的文件列表 + 测试覆盖率          |
+--------------------------+-------------------+
                           | 传递
                           v
阶段3  Code-Reviewer Agent (Sonnet)                      [只读]
+----------------------------------------------+
|  审查代码质量                                  |
|  检查命名规范                                  |
|  检查错误处理                                  |
|  检查性能问题                                  |
|  输出 HANDOFF -> security-reviewer            |
|     附带:发现的问题列表 + 严重程度             |
+--------------------------+-------------------+
                           | 传递
                           v
阶段4  Security-Reviewer Agent (Sonnet)                  [可写修复]
+----------------------------------------------+
|  OWASP Top 10 检查                            |
|  输入验证检查                                  |
|  SQL注入风险检查                               |
|  XSS攻击风险检查                               |
|  输出最终报告                                  |
|     结论:SHIP / NEEDS WORK / BLOCKED         |
+----------------------------------------------+

六、整体全景图

+======================================================================+
|                    Everything Claude Code 全景图                       |
+======================================================================+
|                                                                       |
|  你 ---- /command -----> Claude Code 主进程                           |
|                            |                                          |
|              +-------------+-------------+                            |
|              |             |             |                             |
|         Rules 规则     Skills 技能    CLAUDE.md                        |
|        (必须遵守)     (参考手册)     (项目说明)                         |
|              |             |             |                             |
|              +-------------+-------------+                            |
|                            |                                          |
|         +------------------+------------------+                       |
|         |                  |                  |                        |
|     Agents 代理        Hooks 钩子        MCP 外部连接                  |
|    (专业团队)         (自动化流程)       (电话线)                       |
|         |                  |                  |                        |
|    +----+----+       +-----+-----+      +----+----+                   |
|    |规划组    |       |编辑时      |      |GitHub   |                   |
|    |planner  |       |*自动格式化 |      |Supabase |                   |
|    |architect|       |*类型检查   |      |Vercel   |                   |
|    |         |       |*console警告|      |Context7 |                   |
|    |研发组    |       |           |      |Exa搜索  |                   |
|    |tdd-guide|       |会话时      |      +---------+                   |
|    |         |       |*加载上下文 |                                    |
|    |质量组    |       |*保存摘要   |                                    |
|    |reviewer |       |*持续学习   |                                    |
|    |security |       +-----------+                                    |
|    +---------+             |                                          |
|         |           持续学习系统                                       |
|         |           +------+------+                                   |
|         |           | 观察 -> 分析 |                                   |
|         |           | -> 本能 -> 进化|                                  |
|         |           +-------------+                                   |
|         |                                                             |
|    +----+---- /orchestrate ----+                                      |
|    |  planner -> tdd -> reviewer | <- 单模型编排                       |
|    |  -> security -> 最终报告    |                                     |
|    +-----------------------------+                                    |
|                                                                       |
|    +---- /workflow (multi-*) --+                                      |
|    |  Claude + Codex + Gemini  | <- 多模型协作                         |
|    |  6阶段全栈开发流程         |                                      |
|    +---------------------------+                                      |
|                                                                       |
+======================================================================+

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

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

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

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

二、作用域的核心概念

2.1 allow 与 deny

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

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

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

2.2 作用域的类型系统

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

五、整体设计思路图解

在这里插入图片描述

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

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

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

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

场景一:全局作用域

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

场景二:命令级作用域

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

七、实践建议

设计作用域时:

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

实现命令时:

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

总结

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

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

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

聊聊 Agent Skills 这个东西

1. Agent Skills 是什么

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

类比一下

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

为什么需要它?

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

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

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

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

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

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

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

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

3. Skill 文件长什么样

目录结构

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

SKILL.md 格式

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

# Skill 标题

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

放哪里的区别

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

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


4. 我当前整理的 Skill

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

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

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

核心内容

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

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

gitc — Git Commit 规范自动提交

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

核心内容

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

示例

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

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


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

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

核心规则

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

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


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

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

核心规则

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

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


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

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

核心规则摘要

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

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

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

核心内容

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

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


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

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

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

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

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

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


6. 怎么写一个好用的 Skill

精简比详尽更重要

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

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

根据任务特性选写法

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

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

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

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

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

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

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

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

Code Review 自动化

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

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

文档模板生成

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

测试用例规范

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

API 接口约定

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


8. 容易踩的坑

把 Rules 的内容写进了 Skill

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

Skill 内容太宽泛

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

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

一个 Skill 塞了所有内容

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

没认真写 description

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


9. 动手写第一个 Skill

第一步:确定放哪

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

第二步:建目录和文件

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

第三步:写 SKILL.md

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

# My Skill

## 核心规则

1. 规则一
2. 规则二

## 示例

✅ 正确写法
❌ 错误写法

第四步:验证一下

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


最后总结一下

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

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

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

引言

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

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

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

  • 简单数据类型 (7种)

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

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

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

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

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

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

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

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

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

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

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

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

1. 如何定义Symbol属性?

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

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

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

2. Symbol属性的独特优势

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

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

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

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

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

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

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

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

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

四、总结

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

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

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

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

前言

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

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

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

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

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

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

sudo apt update 
sudo apt install curl build-essential

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

系统依赖安装过程

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

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

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

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

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

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

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

Rustup 安装脚本执行界面

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

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

. "$HOME/.cargo/env"

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

rustc --version
cargo --version

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

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

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

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

配置 Shell 自动加载环境

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

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

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

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

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

蓝耘平台 API Key 创建界面

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

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

DeepSeek 模型选择界面

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

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

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

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

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

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

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

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

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

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

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

3. 异步 HTTP 请求封装

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

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

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

4. 主流程逻辑

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

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

Rust 代码编辑器视图

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

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

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

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

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

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

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

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

修复 Edition 后的成功编译

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

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

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

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

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

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

程序运行结果输出

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

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

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

结语

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

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

引言

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

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

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

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

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

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

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

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

代码解析

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

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

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

效果对比

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

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

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

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

代码示例

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

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

事件委托的优势

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

四、重要概念与最佳实践

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

总结

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

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

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

图片

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

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

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

演示如下:

图片

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

图片

演示地址:pxcharts.com

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

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

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

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

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

技术选型

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

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

选型理由

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

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

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

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

实现架构

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

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

分页策略

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

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

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

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

核心代码解析

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

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

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

2. 页面尺寸计算:

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

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

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

3. HTML 生成

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

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

关键样式

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

4. Canvas 渲染

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

参数说明

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

5. PDF 写入

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

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

图像格式选择

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

样式处理技巧

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

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

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

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

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

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

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

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

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

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

演示地址:pxcharts.com

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

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

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


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

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

二、整体架构:三层拆解

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

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

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

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

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

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

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

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


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

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

4.1 模板区域与动态组件

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

模板里用法示例:

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

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


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

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

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

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

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

6.2 创建 iframe 并写入 HTML + 样式

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

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

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

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

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

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

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


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

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

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

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


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

async/await和Promise的区别?

一、Promise 是什么

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

它有三种状态:

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

基本写法:

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

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

特点:

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

二、async / await 是什么

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

特点:

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

例子:

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

三、核心区别

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

四、代码对比

Promise 写法

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

问题:

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

async/await 写法

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

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

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

优点:

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

五、async/await 的本质

其实:

async function test() {
  return 1
}

等价于:

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

所以:

async函数一定返回Promise

六、await 的作用

await 只能等待 Promise 对象

例如:

const data = await fetch("/api")

等价于:

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

七、async/await 的限制

1 必须在 async 函数里

错误写法:

const data = await fetch("/api")

正确:

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

2 默认是串行执行

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

执行顺序:

getA → 完成 → getB

有时候会变慢。


八、并发优化(重要)

如果两个请求 互不依赖

错误写法:

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

优化写法:

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

这样会 并发执行


九、什么时候用 Promise?

适合:

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

例如:

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

十、什么时候用 async/await?

适合:

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

例如:

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

十一、面试标准回答

可以这样说:

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


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

很多人不知道:

await 123

也是合法的。

因为 JS 会自动变成:

await Promise.resolve(123)

最后总结

一句话记住:

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

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

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

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

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

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

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

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


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

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

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

来看一个真实攻击载荷:

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

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

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

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


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

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

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

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

调用 JSON.parse(evil) 可能:

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

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


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

第一步:限制输入大小

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

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

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

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

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

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

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

import { z } from 'zod';

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

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

优势:

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

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

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

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

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


结语

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

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

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

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

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


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

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

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

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

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

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

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


危害一:敏感信息泄露

这是最致命的问题。

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

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

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

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

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


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

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

1. 同步 I/O 阻塞

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

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

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

2. 大对象序列化开销

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

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

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

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

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

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

攻击者就能:

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

这等于主动给黑客画地图


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

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

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

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

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


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

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

import winston from 'winston';

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

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

优势:

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

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

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

Webpack:

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

Vite / Rollup:

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

ESLint(预防):

配置规则禁止提交 console

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

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


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

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

结语

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

记住:

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

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


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

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

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

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

1.png

必须安排上!

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

1 演示视频

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

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

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

2 安装 TinyVue Skills

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

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

2.png

安装方式选择 Symlink (Recommended)

安装成功!

3.png

查看 Skills 是否安装成功:

npx skills list -g

4.png

3 开启 TinyVue Skills

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

5.png

4 在 AI 对话框中使用 TinyVue Skills

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

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

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

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

6.png

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

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

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

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

关于OpenTiny

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

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

❌