普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月27日技术

ArcPy,一个基于 Python 的 GIS 开发库简介

作者 GIS之路
2026年2月27日 20:36

^ 关注我,带你一起学GIS ^

ArcPy是什么?下面这是来自ESRI中文官网的原话。

ArcPy 是 Python 站点包,用于以有用且实用的方式使用 Python 执行地理数据分析、数据转换、数据管理以及制图自动化。

ArcPy 主要用于核心 GIS 应用程序。 它是一个 Python 软件包,提供了一种方法来执行与地理数据分析、数据转换、数据管理和地图自动化相关的各种任务,并可使用 Python 访问大约 2,000 个地理处理工具。它需要 ArcGIS 产品才能使用,如  ArcGIS Pro、ArcGIS Server  或  ArcGIS Notebooks。可通过 ArcPy 自动执行重复性任务,创建自定义地理处理工作流并扩展 ArcGIS Pro 的功能。 包括访问行业领先的空间分析和空间机器学习算法。它用于处理本地计算机上的数据、执行分析以及使用 ArcGIS Pro 自动执行任务。

我的理解为ArcPy是ESRI公司开发的基于PythonGIS数据处理、转换、分析的脚本。

AcyPy源于ArcGIS 9.2中所采用的arcgisscripting模块,并且集成在ArcGIS 10中。此后,AcyPy一直集成在ArcGIS 10.x中,并跟随ArcGIS一起发布,笔者最早接触的版本为最经典的版本ArcGIS 10.2,这个版本估计现在仍然有许多的使用者。直到后来ArcGIS Pro问世,AcyPy便集成在ArcGIS Pro中。

ArcPy 提供了一种用于开发Python脚本的功能丰富的动态环境,同时提供每个函数、模块和类的代码实现和集成文档。

下面将以ArcGIS Pro中集成的ArcPy进行讲解。在ArcPy中,主要包含以下十大模块。

这十大模块包含了GIS数据处理、转换、分析的各方面,在学习中,可针对各模块进行专项练习。

既然ArcPy基于Python解释器,那么想要运行ArcPy脚本,就需要安装Python环境,而这已经集成ArcGIS产品中了,在ArcGIS10.x中集成的是Python2ArcGIS Pro中集成了Python3

1. 导入ArcPy

ArcPy模块的导入非常简单,可直接通过import arcpy导入。

# Import arcpy
import arcpy

# Set the workspace environment and run Clip
arcpy.env.workspace = 'E://data//arcpy'
arcpy.analysis.Clip("polygon.shp""clip_feat.shp""E://data//arcpy//standby_clip")

2. 运行ArcPy

Python窗口中写入以下代码。打开ArcGIS Pro软件,选择菜单栏视图View,点击Python window。借助Python窗口交互式控制台,可以通过Python解释程序直接在ArcGIS Pro中运行Python代码,而无需脚本文件。 可在该窗口中运行的Python代码包括单行代码,也包括复杂的多行代码块。在窗口中输入以下代码,按回车运行。

# Import system modules
import arcpy

# Set workspace
arcpy.env.workspace"E://data//arcpy"

# Set local variables
in_features"polygon.shp"
clip_features"clip_feat.shp"
out_feature_class"E://data//arcpy//standby_clip"

# Run Clip
# arcpy.analysis.Clip("polygon.shp", "clip_feat.shp", "E://data//arcpy//standby_clip", 1.25)
arcpy.analysis.Clip(in_features, clip_features, out_feature_class)

也可以使用编辑器写入以上代码,在命令行窗口中运行脚本。

3. 查看帮助

Python提供文档字符串功能。ArcPy中的函数和类在包文档中使用该方法。读取这些消息以及获取帮助的方法之一是运行Python提供的help命令。使用参数运行该命令会显示对象的调用签名和文档字符串。

import arcpy 
help(arcpy)

4. ArcPy 基本词汇

主要介绍了要理解ArcPy帮助需要掌握的一些词汇,具有模块、类、函数等。

5. ArcGIS API for Python

还有一个需要区分一下ArcPyArcGIS API for Python

ArcGIS API for Python 是为WebGIS而设计的。 它是一个为执行GIS可视化和分析、空间数据管理和GIS系统管理任务提供广泛功能的Python库。

既可以交互使用,也可以通过脚本使用,使其成为GIS专业人员的通用工具。ArcGIS API for Python随附于ArcGIS Pro,但也可以与ArcGIS OnlineArcGIS Enterprise配合使用。借助ArcGIS API for Python,您可以创建和操作GIS数据、执行空间分析、将地图和图层发布到Web等。 您可以使用托管在ArcGIS Online 或ArcGIS Enterprise上的GIS数据和服务,并使用Python创建Web应用程序。它用于管理和分析WebGIS数据、自动化管理任务以及创建Web地图和应用程序。

参考资料

  • https://desktop.arcgis.com/zh-cn/arcmap/latest/analyze/python/importing-arcpy.htm#ESRI_SECTION1_5E64CCAB40C24B0DB1ED80EF96176F73
  • https://pro.arcgis.com/zh-cn/pro-app/latest/arcpy/get-started/python-window.htm

GIS之路-开发示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 开发合集(全)

GIS之路公众号2025年度报告

GDAL 遥感影像数据读取-plus

地图海报生成项目定位方式修改

关于 PyQT5 和 GDAL 导入顺序引发程序崩溃的解决记录

关于浏览器无法进入断点的解决记录

小小声说一下GDAL的官方API接口

ArcGIS Pro 添加底图的方式

为什么每次打开 ArcGIS Pro 页面加载都如此缓慢?

GDAL 实现矢量数据转换处理(全)

国产版的Google Earth,吉林一号卫星App“共生地球”来了

日本欲打造“本土版”星链系统

Dify 构建 FE 工作流:前端团队可复用 AI 工作流实战

2026年2月27日 19:04

1. 为什么是“工作流”,不是“聊天”

直接聊天写代码的问题很典型:

  1. 同一需求不同人问法不同,结果波动大
  2. 输出格式不统一,难接工程流程
  3. 无日志闭环,难复盘
  4. 难形成团队资产

Dify 的价值在于:把 Prompt、规范、知识、输出格式、调用链路沉淀为“流程”。

2. 环境准备(macOS 本地)

2.1 基础要求

  1. CPU >= 2 Core
  2. RAM >= 4GB(建议 8GB+)
  3. 已安装 Docker(Docker Desktop)

2.2 验证 Docker

docker --version
docker compose version

如果命令不存在,先装 Docker Desktop。

3. Dify 本地部署(docker compose)

以下基于仓库根目录:dify-main

3.1 启动

cd docker
cp .env.example .env
docker compose up -d

首次启动会拉大量镜像,时间可能较长。

3.2 初始化入口

  1. 首次访问:http://localhost/install
  2. 完成初始化后:http://localhost
  3. 建好之后可以尝试用模版建app

4ea06ad48412d4bca5d14842bab0a811.png

3.3 查看状态

cd docker
docker compose ps

看到 api/web/nginx/db/redis/worker 等服务 Up 即正常。

3.4 查看日志

cd docker
docker compose logs -f api
docker compose logs -f web

3.5 停止服务

cd docker
docker compose down

4. 常见坑(实战里最常见)

4.1 docker-credential-desktop not found

报错示例:拉镜像时提示 credential helper 不存在。
修复:

ln -sf /Applications/Docker.app/Contents/Resources/bin/docker-credential-desktop /usr/local/bin/docker-credential-desktop
ln -sf /Applications/Docker.app/Contents/Resources/bin/docker-credential-osxkeychain /usr/local/bin/docker-credential-osxkeychain

4.2 端口冲突

Dify 默认占用 80/443。如果冲突,改 docker/.envdocker-compose.yaml 端口映射。

5. FE 团队“AI 辅助编码标准化”方案

当前目标:先标准化辅助编码。
暂不做:AI 自动代码审核裁决。

5.1 输入标准(统一请求模板)

每次调用 AI 前,必须给统一结构:

task_type: feature|bugfix|refactor
module: web/app/xxx
goal: 一句话目标
constraints:
  - 不改接口语义
  - 不改共享字段语义
  - 必须通过 lint/typecheck
acceptance:
  - 场景A
  - 场景B
context_refs:
  - PRD链接
  - API文档链接

5.2 输出标准(统一结果结构)

统一输出 JSON,便于落地执行和审计:

{
  "change_plan": ["修改文件A做什么", "修改文件B做什么"],
  "risk_points": ["风险1", "风险2"],
  "implementation_notes": ["关键实现点"],
  "self_check": ["lint", "typecheck", "关键场景手测"]
}

6. 在 Dify 里搭建最小 FE 工作流

推荐先做 Chatflow,节点保持最小闭环:

  1. Start
  2. LLM(基于统一系统提示词)
  3. JSON 校验(格式不合法则重试)
  4. Answer

如果后续增强,再加:

  1. Knowledge Retrieval(规范/PRD/接口文档)
  2. 条件路由(feature/bugfix 分流)
  3. 工具调用(例如文档检索)

7. 系统提示词(可直接用作第一版)

你是前端架构与工程规范助手。目标是给出可执行、可审计的辅助编码方案。

硬约束:
1. 不修改未授权模块
2. 不改变已有字段/接口语义
3. 不绕过 lint/typecheck/test
4. 信息不足时先列“缺失信息”,禁止臆造

输出必须是 JSON,字段固定:
- change_plan: string[]
- risk_points: string[]
- implementation_notes: string[]
- self_check: string[]

要求:
- 优先最小改动原则
- 明确影响面
- 给出可验证的检查步骤

8. 模型与费用怎么理解

  1. Dify 自托管本身不按调用收费
  2. 真正计费来自你接入的模型提供商(OpenAI/Anthropic 等)
  3. 用本地模型可降 API 成本,但会增加本机资源消耗

9. 团队治理(必须做,不然会失控)

  1. Prompt 版本化(像代码一样管理)
  2. 输出 Schema 固定(禁止自由文本漂移)
  3. 结果必须过 CI 门禁(lint/typecheck/test)
  4. 保留调用日志,便于复盘与优化

10. 落地节奏(建议 3 周)

  1. 第 1 周:定义输入/输出标准与红线
  2. 第 2 周:上线 Dify 最小工作流,单模块试点
  3. 第 3 周:扩展到 2-3 个 FE 模块,评估提效与返工率

结语

把 大模型 当聊天工具,收益是个人级的。
把 Dify 当 FE 标准化工作流平台,收益才是团队级的。

Taro是怎么实现一码多端的【底层原理】

2026年2月27日 18:20

1. Taro适配各端整体流程

开始了解各个细节之前,让我们先整体过一遍taro适配各端的流程(接下来将通过微信小程序、h5、React Native三端举例)

我们可简单将taro适配各端的过程分为 编译时运行时:

  • 编译时:将JSX转为平台代码,输出可部署的静态文件
  • 运行时:用户打开应用后如何处理用户交互、更新界面等等

1.1 编译时

整个编译时过程可概括为:

  1. 使用 Babel 解析 JSX 代码为抽象语法树(AST)
  2. 将 AST 转换为不同平台的模板语法
    • 微信小程序 → WXML
    • H5 → HTML
    • React Native → React Native 组件
  3. PostCSS 处理样式单位转换 (px → rpx/rem)

1.1.1 将JSX代码解析为AST (Parsing)

在编译时,Taro使用 @babel/parser 将JSX代码(Taro代码)解析为抽象语法树(AST)

Taro原代码:

function App() {
  return (
    <View className="container">
      <Text>Hello Taro</Text>
    </View>
  );
}

使用 @babel/parser 将JSX代码(Taro代码)解析为抽象语法树(AST):

{
  "type": "JSXElement",
  "openingElement": {
    "name": { "name": "View" },
    "attributes": [
      { "name": "className", "value": "container" }
    ]
  },
  "children": [
    {
      "type": "JSXElement",
      "openingElement": { "name": { "name": "Text" } },
      "children": [{ "type": "JSXText", "value": "Hello Taro" }]
    }
  ]
}

1.1.2 将AST转化为目标平台代码(Transformation)

通过 @babel/traverse 遍历AST,将其转化为目标平台代码

组件名映射:

// React 组件 → 平台组件
{
  'View': {
    'weapp': 'view',
    'h5': 'div',
    'rn': 'View'
  },
  'Text': {
    'weapp': 'text',
    'h5': 'span',
    'rn': 'Text'
  }
}

属性转化:

// className → class (小程序)
// className → className (RN)
// style 对象 → style 字符串 (小程序)
{ color: 'red', fontSize: 14 }"color: red; font-size: 14px"

事件映射:

{
  'onClick': {
    'weapp': 'bindtap',
    'alipay': 'onTap',
    'h5': 'onclick',
    'rn': 'onPress'
  },
  'onChange': {
    'weapp': 'bindinput',
    'h5': 'onchange',
    'rn': 'onChangeText'
  }
}

根据平台生产目标代码:

微信小程序 (WXML):

<view class="container">
  <text>Hello Taro</text>
</view>

H5 (HTML):

<div class="container">
  <span>Hello Taro</span>
</div>

React Native (JSX):

<View className="container">
  <Text>Hello Taro</Text>
</View>

1.2 运行时

当用户与界面交互时,会调用平台api、更新界面等等,taro是怎么做的呢,大致可分为以下流程:

  1. 创建平台无关的虚拟 DOM 树,使用diff算法比较新旧虚拟 DOM 树,计算最小更新集合
  2. 使用适配器模式统一不同平台的 API。
  3. 协调器负责将虚拟 DOM 的变化应用到真实平台。

1.2.1 虚拟 DOM (Virtual DOM)

Taro 使用虚拟 DOM 作为中间层,实现平台无关的 UI 描述。

虚拟节点结构:

class VNode {
  constructor(type, props, children) {
    this.type = type;        // 节点类型
    this.props = props;      // 属性
    this.children = children; // 子节点
  }
}

创建虚拟DOM:

const vnode = createElement(
  'View',
  { className: 'container' },
  createElement('Text', {}, 'Hello'));

// 结果:
// {
//   type: 'View',
//   props: { className: 'container' },
//   children: [
//     { type: 'Text', props: {}, children: ['Hello'] }
//   ]
// }

1.2.2 Diff算法

比较新旧虚拟 DOM 树,计算最小更新集合。

diff过程:

function diff(oldVNode, newVNode) {
  // 1. 节点类型变化 → REPLACE
  if (oldVNode.type !== newVNode.type) {
    return [{ type: 'REPLACE', oldVNode, newVNode }];
  }
  
  // 2. 属性变化 → UPDATE_PROPS
  const propPatches = diffProps(oldVNode.props, newVNode.props);
  
  // 3. 子节点变化 → UPDATE_CHILDREN
  const childPatches = diffChildren(oldVNode.children, newVNode.children);
  
  return [...propPatches, ...childPatches];
}

diff示例:

// 旧节点
<View className="box">
  <Text>Old</Text>
</View>

// 新节点
<View className="box updated">
  <Text>New</Text>
  <Button>Click</Button>
</View>

// Diff 结果
[
  { type: 'PROPS', patches: [{ key: 'className', value: 'box updated' }] },
  { type: 'CHILDREN', patches: [
    { index: 0, patches: [{ type: 'TEXT', value: 'New' }] },
    { index: 1, patches: [{ type: 'CREATE', vnode: Button }] }
  ]}
]

1.2.3 平台适配器 (Platform Adapter)

使用适配器模式统一不同平台的 API。

适配器接口:

class PlatformAdapter {
  createElement(type) {}
  createTextNode(text) {}
  setAttribute(element, key, value) {}
  appendChild(parent, child) {}
  // ... 其他 DOM 操作
}

微信小程序适配器:

class WeappAdapter extends PlatformAdapter {
  createElement(type) {
    // 创建小程序虚拟节点
    return new WeappElement(type);
  }
  
  render(element) {
    // 生成 WXML 模板
    return element.toTemplate();
  }
}

H5适配器:

class H5Adapter extends PlatformAdapter {
  createElement(type) {
    // 创建真实 DOM 节点
    return document.createElement(type);
  }
  
  render(element) {
    // 返回 HTML
    return element.outerHTML;
  }
}

1.2.4 协调器 (Reconciler)

协调器负责将虚拟 DOM 的变化应用到真实平台。

工作流程:

class Reconciler {
  mount(vnode, container) {
    // 1. 创建真实节点
    const node = this.createNode(vnode);
    
    // 2. 挂载到容器
    this.platformAdapter.appendChild(container, node);
  }
  
  update(newVNode) {
    // 1. Diff 计算补丁
    const patches = diff(this.currentVNode, newVNode);
    
    // 2. 应用补丁
    this.applyPatches(patches);
    
    // 3. 更新当前树
    this.currentVNode = newVNode;
  }
}

1.3 整体流程概述

image.png

2. 定位源码,细节拆解

了解了taro整个工作流程我们也许会产生几个问题:

  • babel将jsx代码转化为了ast,taro 是怎么遍历 ast 并将其转化为目标平台代码的
  • taro是怎么实现样式转换的
  • taro是怎么创建虚拟dom树,diff算法的细节
  • taro平台适配器的细节
  • taro是怎么适配不同平台的api,并且更新界面的

接下来我们逐一解答,并标注各自在源码中的实现位置:

2.1 babel将jsx代码转化为了ast,taro 是怎么遍历 ast 并将其转化为目标平台代码的

Taro 的 AST 转换主要在以下源码包中实现:

packages/taro-transformer-wx/
├── src/
│ ├── index.ts # 主入口,定义 Babel 遍历规则
│ ├── render.ts # JSX 渲染逻辑,处理条件、循环等
│ ├── jsx.ts # JSX 元素解析和转换
│ ├── class.ts # 类组件处理
│ └── utils.ts # 工具函数

完整转化流程:

image.png

JSX元素转换:

image.png

条件渲染转化流程:

image.png

循环渲染转换流程:

image.png

2.2 taro是怎么实现样式转换的

Taro 的样式转换主要通过 PostCSS 插件实现,源码位置:

packages/
├── postcss-pxtransform/ # 核心单位转换插件
│ ├── index.js # 主入口 (1-372行)
│ └── lib/
│ └── pixel-unit-regex.js # 像素单位正则匹配

├── taro-webpack5-runner/src/postcss/
│ ├── postcss.mini.ts # 小程序 PostCSS 配置 (7-99行)
│ ├── postcss.h5.ts # H5 PostCSS 配置 (7-106行)
│ └── postcss.harmony.ts # HarmonyOS PostCSS 配置 (7-100行)

├── taro-rn-style-transformer/ # React Native 样式转换
│ └── src/
│ ├── transforms/
│ │ ├── index.ts # 样式转换入口 (156-229行)
│ │ └── postcss.ts # PostCSS 插件配置 (1-113行)
│ └── config/
│ └── rn-stylelint.json # RN 样式校验规则

└── taroize/src/
└── wxml.ts # WXML 样式单位转换 (179-227行)

Taro使用多个 PostCSS 插件协同工作:

postcss-import // 处理 @import 语句

autoprefixer // 添加浏览器前缀

postcss-pxtransform // 单位转换 (核心)

postcss-html-transform // HTML 标签转换

postcss-url // 处理 url()

完整转换流程:

image.png

css单位转换流程:

image.png

平台转换规则对比:

image.png

内联样式转换流程:

image.png

2.3 taro是怎么创建虚拟dom树、diff算法的细节

Taro通过React的diff算法(react-reconciler) 实现新旧DOM树对比,但是创建虚拟DOM节点以及将 React 的更新转换为平台操作都是Taro实现的。

虚拟 DOM → Reconciler → 平台适配器 → 平台特定代码

Taro 虚拟DOM实现源代码位置:

packages/taro-runtime/src/
├── dom/
│ ├── node.ts (1-341行) # TaroNode 基类 ⭐
│ ├── element.ts # TaroElement 元素节点
│ ├── document.ts # TaroDocument 文档对象
│ ├── tree.ts # DOM 树操作
│ └── event-target.ts # 事件目标基类

├── hydrate.ts # 序列化虚拟 DOM
└── utils/index.ts # 工具函数

packages/taro-react/src/
├── reconciler.ts (1-500行) # React Reconciler 集成 ⭐
├── render.ts # 渲染函数
└── props.ts # 属性处理

虚拟DOM创建过程:

image.png

Diff算法详细过程:

image.png

属性diff过程:

image.png

子节点diff过程:

image.png

补丁应用流程:

image.png

2.4 taro平台适配器的细节

React 组件 → Taro Runtime → 平台适配层 → 平台特定代码

Taro 的平台适配器源码实现:

packages/taro-runtime/src/
├── dsl/
│ ├── common.ts (91-415行) # 页面配置创建 ⭐
│ ├── instance.ts # 实例管理
│ └── hooks.ts # 生命周期钩子

├── dom/
│ ├── root.ts # 根元素 (平台渲染入口)
│ ├── node.ts # 节点基类
│ └── element.ts # 元素节点

├── bom/
│ ├── document.ts # 文档对象适配
│ └── window.ts # 窗口对象适配

└── index.ts # 导出接口

packages/taro-platform-*/ # 各平台特定实现
├── taro-platform-weapp/ # 微信小程序
├── taro-platform-h5/ # H5
├── taro-platform-harmony/ # HarmonyOS
└── taro-platform-rn/ # React Native

平台适配器整体框架:

image.png

页面生命周期适配:

image.png

更新流程:

image.png

数据流转:

image.png

2.5 taro是怎么适配不同平台的api,并且更新界面的

核心 API 适配和更新文件源码位置:

packages/taro-runtime/src/
├── dom/
│ ├── root.ts (83-192行) # TaroRootElement 更新队列 ⭐
│ ├── node.ts (329-331行) # enqueueUpdate 入队更新 ⭐
│ ├── element.ts (205-278行) # 元素属性更新
│ └── style.ts (17-154行) # 样式更新

├── dsl/
│ ├── common.ts (91-415行) # createPageConfig 页面配置
│ └── next-tick.ts # nextTick 实现

└── interface/
└── hydrate.ts (6行) # setData 接口定义

packages/taro-api/src/
├── tools.ts # API 工具函数
└── interceptor/ # API 拦截器

packages/taro-h5/src/api/ # H5 API 实现
packages/taro-platform-weapp/ # 微信小程序 API 实现
packages/taro-platform-harmony/ # HarmonyOS API 实现

完整更新流程:

image.png

更新队列详细流程:

image.png

路径计算:

image.png

API统一封装:

image.png

3. Taro简单实现demo

Taro源码下载:github.com/NervJS/taro…

Taro deepwiki地址:

deepwiki.com/NervJS/taro

(用ai辅助实现效率更高)

面试官:JS数组的常用方法有哪些?这篇总结让你面试稳了!

作者 前端Hardy
2026年2月27日 18:03

面试官往往会这么问:“JS 数组的常用方法有哪些?”然后追问:“哪些会改变原数组?哪些不会?”或“能举一个实际使用场景吗?”因此回答不仅要列出方法,还要讲清楚分类、返回值、是否改变原数组、典型用法与坑。

方法分类与速查

操作方法:增、删、改、查

排序方法:reverse、sort

转换方法:join

迭代方法:forEach、map、filter、some、every、find(包含 find、findIndex 等)

一、操作方法(增删改查)

  • push():末尾追加,返回新长度;改原数组
  • unshift():开头插入,返回新长度;改原数组
  • splice(start, 0, ...items):指定位置插入,返回空数组;改原数组
  • concat(...items):合并并返回新数组,不改原数组
let colors = ["red", "green"];
colors.push("blue"); // 3; colors => ["red","green","blue"]
colors.unshift("yellow"); // 4; colors => ["yellow","red","green","blue"]
colors.splice(1, 0, "purple"); // [] => 原数组被修改
let colors2 = colors.concat("black", ["white"]); // 新数组

  • pop():末尾删除,返回被删项;改原数组
  • shift():首项删除,返回被删项;改原数组
  • splice(start, deleteCount):删除指定位置项,返回被删数组;改原数组
  • slice(start, end):拷贝子数组,返回新数组;不改原数组
let colors = ["red", "green", "blue"];
let last = colors.pop(); // "blue"; colors => ["red","green"]
let first = colors.shift(); // "red"; colors => ["green"]
let removed = colors.splice(0, 1); // ["green"]; colors => []
let sub = colors.slice(1, 3); // 新数组,不改原数组

  • splice(start, deleteCount, ...items):删除并插入,返回被删数组;改原数组
let colors = ["red", "green", "blue"];
colors.splice(1, 1, "purple"); // ["green"]; colors => ["red", "purple", "blue"]

  • indexOf(item):返回索引,不存在返回 -1
  • includes(item):返回 boolean
  • find(callback):返回第一个满足条件的元素
  • findIndex(callback):返回第一个满足条件的索引
let arr = [1, 2, 3, 4];
arr.indexOf(3); // 2
arr.includes(5); // false
let found = arr.find(x => x > 2); // 3
let foundIdx = arr.findIndex(x => x > 2); // 2

二、排序方法

reverse():反转数组,改原数组,返回引用 sort(compareFn):排序,改原数组,返回引用

let nums = [3, 1, 4, 1, 5];
nums.reverse(); // [5,1,4,1,3]; 改原数组
nums.sort((a,b)=>a-b); // [1,1,3,4,5]; 改原数组

注意:不传 compareFn 时,按 UTF-16 代码单元排序,对数字排序可能不符合预期,务必传比较函数。

三、转换方法

join(separator):用指定分隔符拼接成字符串,不改原数组

let colors = ["red", "green", "blue"];
colors.join(","); // "red,green,blue"
colors.join("||"); // "red||green||blue"

四、迭代方法(不改原数组)

  • forEach(callback):遍历,无返回值
  • map(callback):映射,返回新数组
  • filter(callback):过滤,返回新数组
  • some(callback):任一满足则 true
  • every(callback):全部满足则 true
  • find(callback):返回第一个满足元素
  • findIndex(callback):返回第一个满足索引
  • reduce/reduceRight:归约,常用于累加、组合
let nums = [1, 2, 3, 4];
let doubled = nums.map(x => x * 2); // [2,4,6,8]
let evens = nums.filter(x => x % 2 === 0); // [2,4]
let has = nums.some(x => x > 3); // true
let all = nums.every(x => x > 0); // true
let first = nums.find(x => x > 2); // 3
let sum = nums.reduce((a,b)=>a+b,0); // 10

五、是否改变原数组一览

改变原数组:push、pop、shift、unshift、splice、sort、reverse

不改变原数组:concat、slice、join、forEach、map、filter、some、every、find、findIndex、reduce、reduceRight、flatMap、flat、indexOf、includes

六、典型面试追问与场景举例

问:如何在不改变原数组的前提下在末尾追加一项?

答:使用 concat 或展开运算符 [...arr, item]。 问:如何移除数组中所有 falsy 值?

答:arr.filter(Boolean)。 问:如何按某属性排序对象数组?

答:arr.sort((a,b)=>a.key.localeCompare(b.key))。 问:forEach 与 map 的区别?

答:forEach 无返回值,仅遍历;map 返回新数组,常用于转换。 问:splice 与 slice 的区别?

答:splice 会改变原数组并支持插入/删除;slice 不会改变原数组,仅拷贝子集。

七、常见坑与避坑建议

  • 直接用 sort() 对数字排序可能出错,务必传比较函数。
  • splice 的参数易混淆,牢记参数顺序与返回值。
  • 在需要保留原数组的场景,避免误用会改变原数组的方法。
  • 注意 map 等迭代方法不会提前终止,如需提前中断请用 some/every 或传统 for。

八、总结

JS 数组方法多且常用,记住“是否改变原数组”是高频考点。建议按“操作、排序、转换、迭代”四个维度掌握,并多在实际项目中用这些方法替代手动循环,代码会更简洁、易读。


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

React 合成事件系统

作者 二二四一
2026年2月27日 18:03

🎯 什么是合成事件?

合成事件(SyntheticEvent) 是React模拟原生DOM事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器。它根据W3C规范定义,兼容所有浏览器,拥有与浏览器原生事件相同的接口。

// React中的事件使用
function Button() {
  const handleClick = (e) => {
    console.log(e) // 这是合成事件对象,不是原生事件
    console.log(e.nativeEvent) // 通过nativeEvent获取原生事件
  }
  
  return <button onClick={handleClick}>点击我</button>
}

🤔为什么需要合成事件?

React设计合成事件主要有三个目的:

  1. 跨浏览器兼容:抹平不同浏览器事件对象的差异,提供一致的API
  2. 性能优化:通过事件委托机制,减少内存消耗
  3. 统一管理:方便事件的事务机制和优先级调度

研究表明,在大型列表中,事件委托可以减少90%以上的事件绑定,显著提升性能。

🏗️ 合成事件的核心原理

1️⃣ 事件委托

React并不是将事件绑定到具体的DOM元素上,而是在顶层统一监听。

版本差异

  • React 16及之前:事件绑定在document
  • React 17+:事件绑定在root容器上(id="root"的DOM元素)
// React 17+ 的事件绑定位置
ReactDOM.createRoot(document.getElementById('root')).render(<App />)
// 所有事件都委托在root元素上

为什么改到root? 这有利于多个React版本共存,避免微前端等场景的冲突。

2️⃣ 事件注册流程

React事件系统的核心架构分为三个层次:

// 简化版的事件注册机制
// 1. 事件注册:registerEvents
// 2. 事件监听:listenToAllSupportedEvents
// 3. 事件合成:SyntheticBaseEvent
// 4. 事件派发:dispatchEvent

事件注册源码简化版

// 注册不同类型的事件
registerSimpleEvents();   // 注册click、keyup等基础事件
registerEvents$2();       // 注册onMouseEnter等单阶段事件
registerEvents$1();       // 注册onChange相关事件
registerEvents$3();       // 注册onSelect相关事件
registerEvents();         // 注册onBeforeInput等事件

3️⃣ 事件存储与分发

React内部维护了一个事件插件系统,采用模块化设计,每个插件负责特定类型的事件处理。

// 简化版的事件分发逻辑
function dispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent) {
  // 找到触发事件的DOM元素对应的fiber节点
  const target = nativeEvent.target
  const targetInst = getClosestInstanceFromNode(target)
  
  // 创建合成事件
  const events = extractEvents(
    domEventName,
    targetInst,
    nativeEvent,
    target
  )
  
  // 按阶段分发事件
  events.forEach(event => {
    runEventsInBatch(event)
  })
}

🔄 合成事件 vs 原生事件

核心区别对比表

对比维度 原生事件 React合成事件
事件名称 纯小写(onclick, onblur) 小驼峰(onClick, onBlur)
处理函数 字符串 函数
阻止默认行为 返回false 必须显式调用preventDefault()
绑定方式 addEventListener JSX属性
内存消耗 每个元素独立绑定 事件委托,统一管理
执行顺序 直接在目标元素触发 冒泡到顶层后统一处理

执行顺序演示

class EventOrderDemo extends React.Component {
  componentDidMount() {
    // 原生事件监听
    this.refs.button.addEventListener('click', () => {
      console.log('1. 原生事件:子元素')
    })
    
    document.addEventListener('click', () => {
      console.log('4. 原生事件:document')
    })
  }
  
  handleParentClick = () => {
    console.log('3. React事件:父元素')
  }
  
  handleChildClick = () => {
    console.log('2. React事件:子元素')
  }
  
  render() {
    return (
      <div onClick={this.handleParentClick} ref="parent">
        <button onClick={this.handleChildClick} ref="button">
          点击我
        </button>
      </div>
    )
  }
}

// 输出顺序:
// 1. 原生事件:子元素
// 2. React事件:子元素
// 3. React事件:父元素
// 4. 原生事件:document

关键结论:原生事件先执行,然后执行React事件,最后执行document上的原生事件。

🏊‍♂️ 事件池机制(⭐️⭐️⭐️)

React 16及之前的事件池

在React 16及更早版本中,React使用事件池来管理合成事件对象。

// React 16 示例
function handleClick(e) {
  console.log(e.target) // 正常输出
  
  setTimeout(() => {
    console.log(e.target) // ❌ null!事件对象已被回收
  }, 100)
}

// 解决方案:使用e.persist()
function handleClickCorrect(e) {
  e.persist() // 从事件池中移除,保留属性
  
  setTimeout(() => {
    console.log(e.target) // ✅ 正常输出
  }, 100)
}

事件池的工作原理

  • 事件对象会被重用,避免频繁创建销毁
  • 事件处理函数执行完后,所有属性会被置为null
  • 默认池大小为10个对象

React 17+ 的变更

重要:React 17 开始,Web端不再使用事件池

// React 17+,不需要e.persist()
function handleClick(e) {
  setTimeout(() => {
    console.log(e.target) // ✅ 正常输出,事件池已移除
  }, 100)
}

官方解释:现代浏览器性能已经足够好,事件池优化带来的收益不及复杂性成本。

🎨 合成事件对象属性

合成事件对象提供了丰富的属性和方法:

function EventPropertiesDemo() {
  const handleEvent = (e) => {
    // 基础属性
    console.log(e.type)           // 事件类型:click
    console.log(e.target)         // 触发事件的DOM元素
    console.log(e.currentTarget)  // 当前处理事件的DOM元素
    console.log(e.nativeEvent)    // 原生事件对象
    
    // 事件方法
    e.preventDefault()   // 阻止默认行为
    e.stopPropagation()  // 阻止冒泡
    
    // 状态查询
    console.log(e.isDefaultPrevented())  // 是否已阻止默认行为
    console.log(e.isPropagationStopped()) // 是否已阻止冒泡
    
    // 其他属性
    console.log(e.bubbles)     // 是否可冒泡
    console.log(e.cancelable)  // 是否可取消
    console.log(e.timeStamp)   // 事件触发时间戳
  }
  
  return <button onClick={handleEvent}>测试事件</button>
}

⚡ 性能优化最佳实践

1️⃣ 使用事件委托

// ❌ 不推荐:为每个列表项绑定事件
function BadList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => handleItem(item)}>
          {item.name}
        </li>
      ))}
    </ul>
  )
}

// ✅ 推荐:使用事件委托
function GoodList({ items }) {
  const handleListClick = (e) => {
    const target = e.target
    if (target.tagName === 'LI') {
      const id = target.dataset.id
      console.log('点击了项目:', id)
    }
  }
  
  return (
    <ul onClick={handleListClick}>
      {items.map(item => (
        <li key={item.id} data-id={item.id}>
          {item.name}
        </li>
      ))}
    </ul>
  )
}

2️⃣ 避免混用原生事件和合成事件

// ❌ 危险:混用可能导致事件不执行
function BadMixing() {
  useEffect(() => {
    document.addEventListener('click', (e) => {
      e.stopPropagation() // 阻止了冒泡,React事件可能收不到
    })
  }, [])
  
  return <button onClick={() => console.log('不会执行')}>点击</button>
}

// ✅ 建议:统一使用React事件
function GoodPractice() {
  return <button onClick={() => console.log('正常执行')}>点击</button>
}

3️⃣ 合理使用preventDefault和stopPropagation

function FormDemo() {
  const handleSubmit = (e) => {
    // ✅ 阻止表单提交的默认行为
    e.preventDefault()
    
    // 处理表单逻辑
    submitForm()
  }
  
  const handleButtonClick = (e) => {
    // 只在必要时阻止冒泡
    if (shouldStopPropagation) {
      e.stopPropagation()
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <button onClick={handleButtonClick}>提交</button>
    </form>
  )
}

🎯 难点解析

Q1:React合成事件和原生事件的区别?

满分回答思路

  1. 定义区别:合成事件是React的跨浏览器包装器,原生事件是浏览器原生实现
  2. 命名方式:合成事件小驼峰(onClick),原生事件全小写(onclick)
  3. 处理函数:合成事件传函数,原生事件传字符串
  4. 阻止默认:合成事件必须用preventDefault(),原生可return false
  5. 绑定机制:合成事件用事件委托统一管理,原生事件直接绑定
  6. 内存优化:合成事件减少内存消耗,原生事件绑定越多内存消耗越大

Q2:合成事件的执行顺序是怎样的?

触发事件 → 原生事件(目标元素)→ React事件(冒泡阶段)→ document事件

关键点:原生事件先执行,如果原生事件阻止冒泡,React事件可能不会执行(阻止合成事件不会影响原生事件)。

Q3:React 17对事件系统做了哪些改进?

  1. 事件绑定位置:从document改为root容器
  2. 移除事件池:不再需要e.persist()
  3. onScroll冒泡:不再冒泡,匹配浏览器行为
  4. 优化微前端:多个React版本可共存

Q4:如何在React事件中获取异步访问事件对象?

// React 16及以前:需要用e.persist()
function handleAsync(e) {
  e.persist()
  setTimeout(() => {
    console.log(e.target)
  }, 100)
}

// React 17+:直接使用即可
function handleAsync(e) {
  setTimeout(() => {
    console.log(e.target) // 没问题
  }, 100)
}

📊 总结:合成事件的核心价值

维度 价值体现
兼容性 抹平浏览器差异,提供一致API
性能 事件委托减少90%+事件绑定
内存 事件池机制(16及以前)减少GC压力
可维护性 统一管理,自动清理,避免内存泄漏
开发体验 声明式API,符合W3C规范,上手简单

一句话总结:

React合成事件是一套基于事件委托、跨浏览器兼容、性能优化的事件系统,它通过顶层监听和统一分发,为开发者提供了稳定高效的事件处理机制。

「九九八十一难」组合式函数到底有什么用?

作者 从文处安
2026年2月27日 17:58

引言

最近接手了一个 Vue 2 的老项目,翻开代码的那一刻,我陷入了沉思。

一个 .vue 文件足足 5000 行代码,data 里定义了 200 多个变量,methods 里塞了 100 多个方法。

相关逻辑散落在 datamethodscomputedwatch 各个角落,方法套方法,变量牵变量。

剪不断、理还乱。

终于明白了 Vue 3 为什么要引入组合式函数(Composables)

Q:有同学就要问了,为什么不用 mixin 实现?

A:在实际工程中使用 mixin ,还不一定比放在同一个组件里面维护起来方便。


组合式函数(Composables)定义

在 Vue 应用的概念中,"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑函数

这个定义中有两个关键点需要理解:有状态逻辑函数形式

什么是有状态逻辑?

在程序设计中,"状态"指的是在程序运行过程中会发生变化的数据。有状态逻辑就是指那些管理着会变化的数据,并且需要对这些数据的变化做出响应的代码逻辑。

阅读下面文章之前,先理解下这两句话:

组合式函数内部可以使用 ref 或 reactive 创建响应式数据,并且这些数据在返回给组件后依然保持响应性

组合式函数可以接收任意参数,可以是普通值或响应式引用(ref)。

举个例子:

  • 无状态逻辑:一个纯函数 add(a, b) => a + b,给定相同的输入,永远返回相同的输出,不依赖任何外部状态。
  • 有状态逻辑:一个计数器,它维护一个当前计数值,可以增加、减少、重置,并且当计数值变化时,使用这个计数值的地方需要自动更新。

在 Vue 中,有状态逻辑通常包含:

  • 响应式数据(ref、reactive)
  • 计算属性(computed)
  • 侦听器(watch)
  • 生命周期钩子(onMounted、onUnmounted 等)

为什么是函数?

组合式函数选择以函数的形式存在,而不是类、对象或其他形式,这是经过深思熟虑的设计:

  1. 组合性:函数可以轻松地相互调用、嵌套、组合。你可以在一个组合式函数中调用另一个组合式函数,形成逻辑的层层封装。

  2. 作用域隔离:每次调用函数都会创建一个新的作用域,这意味着你可以在多个组件中多次调用同一个组合式函数,每次调用都是独立的实例,互不干扰。

  3. 参数传递灵活:函数可以接收参数,返回值,这使得逻辑的输入输出非常清晰。

  4. 符合 JavaScript 惯例:JavaScript 本身就是函数式编程友好的语言,使用函数封装逻辑符合开发者的直觉。

为什么要引入组合式函数(Composables)?

Vue 2 选项式 API 的困境

在 Vue 2 中,我们使用选项式 API(Options API)来组织代码。

这种方式在组件简单时非常直观,但当组件变得复杂时,问题就暴露出来了。

问题一:逻辑碎片化

假设我们要实现一个"鼠标追踪"功能,需要追踪鼠标在页面上的位置。在 Vue 2 中,代码会散落在多个选项中:

<script>
export default {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

可以看到,一个完整的功能被拆分到了 datamountedbeforeUnmountmethods 四个不同的地方。当组件功能越来越多时,阅读代码就需要在不同选项之间来回跳转,理解成本极高。

其实这种编程习惯至今我仍有部分困惑,在书写 vue3 组合式写法时,部分同事还是喜欢将变量、方法、计算属性分类书写,方法放在一起、变量放在一堆,导致维护代码时候仍然会在多个代码块中进行跳转。

问题二:复用困难

Vue 2 提供了 Mixins 来复用逻辑,但它存在严重的问题:

<script>
const mouseTrackingMixin = {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}

export default {
  mixins: [mouseTrackingMixin],
  data() {
    return {
      x: 'I will be overwritten!'  // 命名冲突!
    }
  }
}
</script>

Mixins 的问题包括:

  • 命名冲突:多个 mixin 或组件与 mixin 之间可能有同名属性/方法,导致覆盖
  • 依赖隐式:mixin 内部可能使用了组件的某些属性,但这种依赖关系不明显
  • 数据来源不清晰:当使用了多个 mixin 时,很难分辨某个属性来自哪个 mixin

问题三:TypeScript 支持不友好

选项式 API 的类型推导相对复杂,IDE 的智能提示也不够完善,这在大型项目中是一个明显的短板。

组合式函数的解决方案

组合式函数完美解决了上述问题:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function handleMouseMove(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', handleMouseMove)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', handleMouseMove)
  })

  return { x, y }
}

const { x, y } = useMouse()
</script>

可以看到:

  • 逻辑聚合:所有与鼠标追踪相关的代码都集中在 useMouse 函数中
  • 命名清晰:通过解构赋值,可以清楚地看到 xy 来自 useMouse
  • 无命名冲突:即使有多个组合式函数返回同名属性,也可以通过重命名解决

组合式函数的优势

1. 逻辑组织更清晰

组合式函数允许我们按照功能而不是按照选项来组织代码。相关联的状态和方法可以放在一起,形成内聚的逻辑单元。

<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'
import { useTheme } from './composables/useTheme'

const { x, y } = useMouse()
const { data, error, loading } = useFetch('/api/users')
const { theme, toggleTheme } = useTheme()
</script>

每个组合式函数负责一个独立的功能,代码结构一目了然。

2. 逻辑复用更简单

组合式函数本质上是普通 JavaScript 函数,可以在任何地方调用:

import { useMouse } from './composables/useMouse'

export function useMouseWithDelay(delay = 100) {
  const { x: rawX, y: rawY } = useMouse()
  const x = ref(0)
  const y = ref(0)

  watch([rawX, rawY], debounce(([newX, newY]) => {
    x.value = newX
    y.value = newY
  }, delay))

  return { x, y }
}

你甚至可以在一个组合式函数中调用另一个组合式函数,实现逻辑的组合与扩展。

3. 类型推导更完善

组合式函数天然支持 TypeScript,类型推导非常准确:

import { ref, computed, type Ref, type ComputedRef } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

function useUser(id: Ref<number>) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.name} <${user.value.email}>`
  })

  async function fetchUser() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(`/api/users/${id.value}`)
      user.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return {
    user,
    loading,
    error,
    fullName,
    fetchUser
  }
}

IDE 可以准确推断出 user 的类型是 Ref<User | null>fullName 的类型是 ComputedRef<string>

4. 测试更方便

组合式函数是纯 JavaScript/TypeScript 函数,可以脱离 Vue 组件独立测试:

import { useCounter } from './composables/useCounter'
import { ref } from 'vue'

describe('useCounter', () => {
  it('should increment count', () => {
    const { count, increment } = useCounter()
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('should accept initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
})

组合式函数的使用场景

1. 封装通用状态逻辑

当你发现多个组件中存在相同或相似的状态逻辑时,就应该考虑提取为组合式函数。

典型场景

  • 表单验证逻辑
  • 分页逻辑
  • 加载状态管理
  • 主题切换
  • 国际化

2. 组织复杂组件逻辑

当单个组件变得庞大时,可以使用组合式函数将不同功能的代码分离:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserProfile } from './composables/useUserProfile'
import { useUserPosts } from './composables/useUserPosts'

const { user, login, logout } = useUserAuth()
const { profile, updateProfile } = useUserProfile(user)
const { posts, fetchPosts, createPost } = useUserPosts(user)
</script>

3. 集成第三方库

将第三方库的集成逻辑封装为组合式函数,可以简化使用并提供 Vue 友好的 API:

import { ref, onMounted, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'

export function useDebouncedRef(value, delay = 300) {
  const debouncedValue = ref(value)
  const updater = debounce((newValue) => {
    debouncedValue.value = newValue
  }, delay)

  watch(() => value, (newValue) => {
    updater(newValue)
  })

  onUnmounted(() => {
    updater.cancel()
  })

  return debouncedValue
}

4. 抽象浏览器 API

将浏览器原生 API 封装为响应式的组合式函数:

import { ref, onMounted, onUnmounted } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const value = ref(defaultValue)

  function read() {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = JSON.parse(stored)
    }
  }

  function write() {
    localStorage.setItem(key, JSON.stringify(value.value))
  }

  onMounted(() => {
    read()
    window.addEventListener('storage', read)
  })

  onUnmounted(() => {
    window.removeEventListener('storage', read)
  })

  watch(value, write, { deep: true })

  return value
}

组合式函数的实现规范

基本结构

一个标准的组合式函数通常包含以下部分:

import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

export function useFeatureName(parameter) {
  const state = ref(initialValue)
  const computedValue = computed(() => {
    return state.value * 2
  })

  function doSomething() {
    state.value++
  }

  watch(state, (newValue, oldValue) => {
    console.log(`state changed from ${oldValue} to ${newValue}`)
  })

  onMounted(() => {
    console.log('component mounted')
  })

  onUnmounted(() => {
    console.log('component unmounted')
  })

  return {
    state,
    computedValue,
    doSomething
  }
}

命名约定

  • 函数命名:以 use 开头,采用驼峰命名法,如 useMouseuseFetchuseLocalStorage
  • 文件命名:与函数名一致,如 useMouse.jsuseMouse.ts
  • 目录结构:通常放在 composables/hooks/ 目录下

返回值约定

  • 返回一个对象,包含需要暴露给外部使用的响应式状态和方法
  • 返回的对象通常使用解构赋值接收
  • 如果需要返回响应式引用,不要在返回时解包,保持 ref 形式

参数约定

  • 可以接收普通值、响应式引用(ref)、响应式对象(reactive)作为参数
  • 如果参数可能是响应式的,使用 toValue() 工具函数进行解包:
import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

组合式函数的实现示例

示例一:鼠标追踪器

这是一个经典的组合式函数示例,封装了鼠标位置追踪逻辑:

import { ref, onMounted, onUnmounted } from 'vue'

/**
 * 追踪鼠标在页面上的位置
 * @returns {Object} 包含鼠标 x、y 坐标的响应式引用
 */
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

在组件中使用:

<template>
  <div>鼠标位置:{{ x }}, {{ y }}</div>
</template>

<script setup>
import { useMouse } from './composables/useMouse'

const { x, y } = useMouse()
</script>

示例二:数据请求

封装通用的数据获取逻辑,包含加载状态和错误处理:

import { ref, watchEffect, toValue } from 'vue'

/**
 * 封装数据获取逻辑
 * @param {string|Ref<string>|() => string} url - 请求地址,可以是响应式引用或 getter 函数
 * @returns {Object} 包含 data、error、loading 状态的对象
 */
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error, loading, refetch: fetchData }
}

在组件中使用:

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">加载失败:{{ error.message }}</div>
  <div v-else>
    <pre>{{ data }}</pre>
    <button @click="refetch">重新加载</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useFetch } from './composables/useFetch'

const userId = ref(1)
const { data, error, loading, refetch } = useFetch(
  () => `/api/users/${userId.value}`
)
</script>

示例三:计数器

一个简单但完整的计数器示例,展示参数接收和返回值:

import { ref, computed } from 'vue'

/**
 * 创建一个计数器
 * @param {number} initialValue - 初始值,默认为 0
 * @param {number} step - 步长,默认为 1
 * @returns {Object} 计数器状态和方法
 */
export function useCounter(initialValue = 0, step = 1) {
  const count = ref(initialValue)

  const isPositive = computed(() => count.value > 0)
  const isNegative = computed(() => count.value < 0)
  const isZero = computed(() => count.value === 0)

  function increment() {
    count.value += step
  }

  function decrement() {
    count.value -= step
  }

  function reset() {
    count.value = initialValue
  }

  function set(value) {
    count.value = value
  }

  return {
    count,
    isPositive,
    isNegative,
    isZero,
    increment,
    decrement,
    reset,
    set
  }
}

示例四:表单验证

封装表单验证逻辑,支持自定义验证规则:

import { ref, computed, reactive } from 'vue'

/**
 * 表单验证组合式函数
 * @param {Object} initialValues - 表单初始值
 * @param {Object} rules - 验证规则
 * @returns {Object} 表单状态和验证方法
 */
export function useForm(initialValues, rules) {
  const values = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  const isSubmitting = ref(false)

  const isValid = computed(() => {
    return Object.keys(errors).every(key => !errors[key])
  })

  function validateField(field) {
    const rule = rules[field]
    if (!rule) return true

    const value = values[field]
    const result = rule(value)

    if (typeof result === 'string') {
      errors[field] = result
      return false
    } else {
      errors[field] = ''
      return true
    }
  }

  function validateAll() {
    let allValid = true
    for (const field in rules) {
      if (!validateField(field)) {
        allValid = false
      }
    }
    return allValid
  }

  function setFieldTouched(field) {
    touched[field] = true
    validateField(field)
  }

  function resetForm() {
    Object.assign(values, initialValues)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
    Object.keys(touched).forEach(key => {
      touched[key] = false
    })
  }

  async function handleSubmit(callback) {
    isSubmitting.value = true

    Object.keys(values).forEach(key => {
      touched[key] = true
    })

    if (validateAll()) {
      await callback(values)
    }

    isSubmitting.value = false
  }

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    validateField,
    validateAll,
    setFieldTouched,
    resetForm,
    handleSubmit
  }
}

在组件中使用:

<template>
  <form @submit.prevent="handleSubmit(onSubmit)">
    <div>
      <label>用户名:</label>
      <input
        v-model="values.username"
        @blur="setFieldTouched('username')"
      />
      <span v-if="touched.username && errors.username" class="error">
        {{ errors.username }}
      </span>
    </div>

    <div>
      <label>邮箱:</label>
      <input
        v-model="values.email"
        @blur="setFieldTouched('email')"
      />
      <span v-if="touched.email && errors.email" class="error">
        {{ errors.email }}
      </span>
    </div>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '提交' }}
    </button>
  </form>
</template>

<script setup>
import { useForm } from './composables/useForm'

const initialValues = {
  username: '',
  email: ''
}

const rules = {
  username: (value) => {
    if (!value) return '用户名不能为空'
    if (value.length < 3) return '用户名至少 3 个字符'
    return true
  },
  email: (value) => {
    if (!value) return '邮箱不能为空'
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确'
    return true
  }
}

const {
  values,
  errors,
  touched,
  isSubmitting,
  setFieldTouched,
  handleSubmit
} = useForm(initialValues, rules)

async function onSubmit(formValues) {
  console.log('表单提交:', formValues)
}
</script>

注意点与最佳实践

1. 始终在 setup 函数或 script setup 中调用

组合式函数依赖于 Vue 的组合式 API,必须在组件的 setup() 函数或 <script setup> 中同步调用:

export default {
  setup() {
    const { x, y } = useMouse()
    return { x, y }
  }
}
<script setup>
const { x, y } = useMouse()
</script>

错误示例

export default {
  setup() {
    setTimeout(() => {
      const { x, y } = useMouse()
    }, 1000)
  }
}

2. 返回响应式引用时保持 ref 形式

组合式函数返回的响应式数据应该保持 refreactive 形式,不要在返回时解包:

export function useCounter() {
  const count = ref(0)
  return { count }
}

这样可以让调用者明确知道这是一个响应式引用,并且可以灵活地传递给其他组合式函数。

3. 使用 toValue 处理可能是响应式的参数

当组合式函数接收的参数可能是普通值、ref 或 getter 函数时,使用 toValue 统一处理:

import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

4. 合理使用 shallowRef 和 shallowReactive

对于大型对象或数组,如果只需要监听整体变化而不需要深度响应,使用 shallowRefshallowReactive 可以提升性能:

import { shallowRef } from 'vue'

export function useLargeData() {
  const data = shallowRef([])

  async function fetchData() {
    const response = await fetch('/api/large-data')
    data.value = await response.json()
  }

  return { data, fetchData }
}

5. 清理副作用

在组合式函数中创建的副作用(事件监听、定时器等)必须在组件卸载时清理:

import { onUnmounted } from 'vue'

export function useInterval(callback, delay) {
  let timer = null

  timer = setInterval(callback, delay)

  onUnmounted(() => {
    if (timer) {
      clearInterval(timer)
    }
  })
}

或者使用 Vue 提供的 watchEffectonCleanup

import { watchEffect } from 'vue'

export function useEventListener(target, event, callback) {
  watchEffect((onCleanup) => {
    target.addEventListener(event, callback)

    onCleanup(() => {
      target.removeEventListener(event, callback)
    })
  })
}

6. 避免在组合式函数中直接修改 props

组合式函数不应该直接修改接收到的 props,而应该通过 emit 或其他方式通知父组件:

export function useModelValue(props, emit) {
  const localValue = computed({
    get: () => props.modelValue,
    set: (value) => emit('update:modelValue', value)
  })

  return { localValue }
}

7. 提供合理的默认值

组合式函数的参数应该提供合理的默认值,提高易用性:

export function useDebounce(fn, delay = 300) {
}

8. 文档化你的组合式函数

使用 JSDoc 为组合式函数添加文档,说明参数、返回值和使用示例:

/**
 * 创建一个防抖的响应式引用
 * @template T
 * @param {T} initialValue - 初始值
 * @param {number} delay - 防抖延迟时间(毫秒)
 * @returns {import('vue').Ref<T>} 防抖后的响应式引用
 * @example
 * const searchTerm = useDebouncedRef('', 300)
 * watch(searchTerm, (value) => {
 *   console.log('搜索:', value)
 * })
 */
export function useDebouncedRef(initialValue, delay = 300) {
}

组合式函数 vs 其他方案对比

组合式函数 vs Mixins

特性 组合式函数 Mixins
数据来源 清晰(解构赋值) 不清晰
命名冲突 可重命名解决 静默覆盖
参数传递 支持参数 不支持
逻辑组合 可嵌套调用 困难
TypeScript 支持 完善 较差

组合式函数 vs Renderless Components

Renderless Components(无渲染组件)是 Vue 2 中另一种复用逻辑的方式:

<template>
  <slot :x="x" :y="y" />
</template>

<script>
export default {
  data() {
    return { x: 0, y: 0 }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

对比:

特性 组合式函数 Renderless Components
性能 更好(无组件开销) 有组件实例开销
使用方式 函数调用 组件嵌套
灵活性 更高 受限于组件树
TypeScript 支持 完善 一般

总结

组合式函数是 Vue 3 最具革命性的特性之一,它从根本上改变了我们组织和复用代码的方式。

核心价值

  • 解决逻辑碎片化:将相关联的状态和方法聚合在一起,代码更易读、易维护
  • 简化逻辑复用:以函数形式封装,可在任意组件中复用,无命名冲突之忧
  • 提升开发体验:完善的 TypeScript 支持和 IDE 智能提示
  • 便于测试:纯函数形式,可脱离组件独立测试

使用建议

  • 当发现多个组件存在相同逻辑时,提取为组合式函数
  • 当单个组件变得庞大时,使用组合式函数拆分功能模块
  • 遵循命名约定(use 前缀)和返回值约定
  • 注意清理副作用,避免内存泄漏

从 Vue 2 迁移

  • 不需要一次性重写所有代码,组合式函数可以与选项式 API 共存
  • 可以逐步将 Mixins 重构为组合式函数
  • 利用组合式函数简化新功能的开发

组合式函数不仅是一种技术方案,更是一种关注点分离组合优于继承的设计思想。掌握它,将让你的 Vue 开发体验提升一个台阶。

回到开头那个 5000 行的 Vue 2 组件,如果用组合式函数重构,或许可以变成这样:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserList } from './composables/useUserList'
import { useUserForm } from './composables/useUserForm'
import { usePagination } from './composables/usePagination'
import { useSearch } from './composables/useSearch'
import { useNotification } from './composables/useNotification'

const { user, login, logout } = useUserAuth()
const { users, fetchUsers, deleteUser } = useUserList()
const { form, submitForm, resetForm } = useUserForm()
const { page, pageSize, total, setPage } = usePagination()
const { keyword, filteredUsers } = useSearch(users)
const { showSuccess, showError } = useNotification()
</script>

清晰、简洁、优雅。这就是组合式函数的魅力。

回到标题,相同的业务实现,我不使用组合式函数也能实现。

读完这篇文章,是否可以尝试使用组合式函数,全凭各位看官决定。

写法只是手段,业务实现才是重点。

Props、Context、EventBus、状态管理:组件通信方案选择指南

作者 yuki_uix
2026年2月27日 17:47

写 React 的时间越长,越会遇到一个让人头疼的问题:明明只是想把数据传给某个深层组件,却要穿越好几层中间组件,每一层都得接收并转发这份数据。那些中间组件其实根本用不到这些 props,却因为「路过」不得不背负着它们。

这篇文章是我整理的关于组件通信的一些思考,聊聊各种方案的选择逻辑,以及背后的架构含义。


问题的起源

先描述一个典型场景:做一个电商页面,顶部导航需要显示购物车数量,商品详情页有「加入购物车」按钮。这两个组件相距 5 层嵌套,中间的 LayoutContainerContent 等组件对购物车一无所知,但你不得不让它们每层都接收并向下传递 cartCountaddToCart

// 环境:React
// 场景:典型的 Props Drilling 噩梦

function App() {
  const [cartItems, setCartItems] = useState([]);

  return (
    // 每一层都要传,即使它们完全不关心购物车
    <Layout cartItems={cartItems} setCartItems={setCartItems}>
      <Container cartItems={cartItems} setCartItems={setCartItems}>
        <Content cartItems={cartItems} setCartItems={setCartItems}>
          <ProductDetail cartItems={cartItems} setCartItems={setCartItems} />
        </Content>
      </Container>
    </Layout>
  );
}

这段代码本身不是错误,但它有一种难以言说的「不对劲」。每次修改数据结构,都要改好几层;每次移动组件位置,都要重新梳理 props 链条。

这让我开始思考:组件通信到底是技术问题,还是架构问题?

我的理解是,选择通信方案,本质上是在选择耦合程度——你愿意让哪些组件知道哪些数据?它们之间的关系应该有多紧密?


方案一:Props 传递——最基础,也最被滥用

父子通信用 Props,这没什么好说的。数据向下流,事件向上传,清晰直观:

// 环境:React
// 场景:标准的父子组件通信

function Parent() {
  const [count, setCount] = useState(0);

  return <Child count={count} onIncrement={() => setCount(c => c + 1)} />;
}

function Child({ count, onIncrement }) {
  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={onIncrement}>加一</button>
    </div>
  );
}

Props 的优点是数据流极其清晰,TypeScript 类型安全,也很容易单独测试子组件。但一旦层级变深,就会出现开头说的 Props Drilling 问题。

有一个常被忽视的技巧是组件组合(Component Composition) ,它能在不引入新方案的前提下,缓解这个问题:

// 环境:React
// 场景:用 children 避免中间层传递不必要的 props

// ❌ 传统方式:Layout 被迫接收 user
function App() {
  const [user] = useState({ name: 'Alice' });
  return (
    <Layout user={user}>
      <UserProfile user={user} />
    </Layout>
  );
}

// ✅ 组件组合:Layout 只负责布局结构
function App() {
  const [user] = useState({ name: 'Alice' });
  return (
    <Layout>
      <UserProfile user={user} />
    </Layout>
  );
}

// Layout 组件只接收 children,不关心内容
function Layout({ children }) {
  return <div className="layout">{children}</div>;
}

这个思路很简单:让容器组件只负责「结构」,不承担「内容」。它不需要知道 children 里有什么,自然也不需要传递那些数据。

可以接受 Props 传递的场景:层级不超过 2-3 层,数据关系稳定,不会频繁变动。超过这个范围,就该考虑其他方案了。


方案二:状态提升——兄弟组件的解法

兄弟组件之间无法直接通信,标准做法是把共享状态提升到最近的公共父组件:

// 环境:React
// 场景:两个兄弟组件需要共享计数状态

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <Counter count={count} onIncrement={() => setCount(c => c + 1)} />
      <Display count={count} />
    </>
  );
}

function Counter({ count, onIncrement }) {
  return <button onClick={onIncrement}>点击:{count}</button>;
}

function Display({ count }) {
  return <p>当前计数:{count}</p>;
}

状态提升有一个决策原则:把状态放在最近的需要它的公共祖先上。不要提升过高,否则顶层组件会变得臃肿,而且状态变化时会触发整棵子树的重渲染。

这个方案的局限很明显——当共同父组件距离很远,或者需要数据的组件分散在不同分支时,状态提升就会重新引入 Props Drilling 的问题。


方案三:Context API——跨层级的官方解

Context 的设计目的,就是解决跨层级数据共享的问题。它让深层组件可以直接「订阅」某个数据源,不需要中间层逐层传递:

// 环境:React
// 场景:主题切换,深层组件直接消费 Context

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Layout />
    </ThemeContext.Provider>
  );
}

// 深层组件,直接取值,不需要 Layout 传递任何东西
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
      当前主题:{theme}
    </button>
  );
}

但 Context 有一个容易踩的性能陷阱:只要 Provider 的 value 发生变化,所有订阅了这个 Context 的组件都会重渲染,无论它们实际使用的数据有没有变。

// 场景:Context 的性能问题

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('light');

  // ❌ 把所有数据放在一个 Context:
  // theme 改变时,只用 user 的组件也会重渲染
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      <Header />   {/* 只用 user */}
      <Content />  {/* 只用 theme */}
    </AppContext.Provider>
  );
}

一种常见的处理方式是按关注点拆分 Context

// ✅ 拆分 Context:各自订阅,互不影响
const UserContext = createContext();
const ThemeContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Alice' });
  const [theme, setTheme] = useState('light');

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <Header />
        <Content />
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

function Header() {
  const { user } = useContext(UserContext);
  // theme 变化不会触发 Header 重渲染 ✅
  return <div>{user.name}</div>;
}

结合 useMemo 稳定 value 对象,是另一个常见优化手段:

// 用 useMemo 避免因父组件重渲染导致 value 引用变化
const userValue = useMemo(() => ({ user, setUser }), [user]);

return <UserContext.Provider value={userValue}>...</UserContext.Provider>;

Context 适合的场景:主题、语言/国际化、用户认证信息这类「低频变化、广泛消费」的数据。如果某个数据每秒变化多次,Context 可能不是最佳选择。


方案四:EventBus——完全解耦的代价

有时候,需要通信的两个组件之间没有任何父子或兄弟关系,它们甚至可能属于完全不同的模块。这时 EventBus(发布订阅模式)是一种思路:

// 环境:浏览器 / Node.js
// 场景:简单的 EventBus 实现

class EventBus {
  constructor() {
    this.events = {};
  }

  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
    // 返回取消订阅函数,方便清理
    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    };
  }

  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
}

export const eventBus = new EventBus();

在 React 中使用时,要注意及时清理订阅,否则会有内存泄漏:

// 环境:React
// 场景:组件间通过 EventBus 通信(无父子关系)

function ProductDetail({ product }) {
  const addToCart = () => {
    // 发布事件,不关心谁在监听
    eventBus.emit('cart:add', product);
  };

  return <button onClick={addToCart}>加入购物车</button>;
}

function CartIcon() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const unsubscribe = eventBus.on('cart:add', () => {
      setCount(prev => prev + 1);
    });

    // ✅ 组件卸载时取消订阅,避免内存泄漏
    return unsubscribe;
  }, []);

  return <div>购物车 ({count})</div>;
}

EventBus 的吸引力在于「完全解耦」—— 两个组件互相不知道对方的存在。但这也带来了一个问题:当 bug 出现时,你很难追踪某个事件从哪里发出,有多少个地方在监听。数据流的可见性大幅降低。

如果需要类型安全,可以用 TypeScript 约束事件类型:

// 环境:TypeScript + React
// 场景:类型安全的 EventBus

type Events = {
  'cart:add': { productId: string; quantity: number };
  'toast:show': { message: string; type: 'success' | 'error' };
};

class TypedEventBus {
  private events: { [K in keyof Events]?: Array<(data: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, callback: (data: Events[K]) => void) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event]!.push(callback);
    return () => {
      this.events[event] = this.events[event]!.filter(cb => cb !== callback);
    };
  }

  emit<K extends keyof Events>(event: K, data: Events[K]) {
    this.events[event]?.forEach(cb => cb(data));
  }
}

EventBus 适合的场景:Toast 通知、埋点上报这类「通知型」事件,或者与第三方库之间的通信。不太适合用来管理需要持久化或同步的状态。


方案五:状态管理库——有代价的强大

当应用复杂度到达一定程度,多个不相关的组件都需要访问和修改同一份状态时,引入状态管理库会更合适。

Zustand 是目前相对轻量的选择,API 简洁,没有繁琐的样板代码:

// 环境:React + Zustand
// 场景:全局购物车状态管理

import { create } from 'zustand';

const useCartStore = create(set => ({
  items: [],

  addItem: item =>
    set(state => ({ items: [...state.items, item] })),

  removeItem: id =>
    set(state => ({ items: state.items.filter(i => i.id !== id) })),
}));

// 任意组件中使用,且只订阅自己需要的那部分状态
function CartIcon() {
  // 精确订阅,items 长度不变时不触发重渲染
  const count = useCartStore(state => state.items.length);
  return <div>购物车 ({count})</div>;
}

function ProductDetail({ product }) {
  const addItem = useCartStore(state => state.addItem);
  return <button onClick={() => addItem(product)}>加入购物车</button>;
}

Zustand 的一个优点是选择性订阅—— 组件只会在自己订阅的那部分状态变化时重渲染,性能比 Context 好控制。

Redux Toolkit 则更适合大型团队和需要严格数据流规范的场景,它的 DevTools 支持时间旅行调试,中间件生态也更丰富,但相应地引入了更多约束和概念。

有一点值得注意:不是什么状态都适合放进状态管理库。一个只在局部使用的 Modal 开关状态,用 useState 就够了,把它放进 Redux 是典型的过度设计。

// ❌ 过度设计:Modal 状态没必要全局化
const useModalStore = create(set => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

// ✅ 简单场景就用 useState
function Page() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </>
  );
}

如何选择?

整理一下思路,大体上可以用这个决策流程:

graph TD
    A[需要组件通信] --> B{组件关系?}

    B -->|父子| C[Props]
    B -->|兄弟| D{层级深吗?}
    D -->|1-2 层| E[状态提升]
    D -->|3 层以上| F{数据变化频率?}
    F -->|低频| G[Context]
    F -->|高频| H[Zustand]

    B -->|无关系| I{通信类型?}
    I -->|通知/事件| J[EventBus]
    I -->|状态共享| K{项目规模?}
    K -->|中小型| H
    K -->|大型/团队| L[Redux]

方案的核心差异:

方案 耦合程度 适合场景 主要风险
Props 紧耦合 父子,层级浅 Props Drilling
状态提升 较紧耦合 兄弟,层级浅 父组件臃肿
Context 松耦合 跨层级,低频变化 全量重渲染
EventBus 解耦 通知类,跨模块 数据流难追踪
Zustand 解耦 全局状态,中小型 滥用导致混乱
Redux 解耦+规范 大型项目 样板代码,学习成本

实际项目里通常是组合使用:Props 处理局部父子关系,Context 管理主题和用户信息,Zustand 或 Redux 处理核心业务状态,EventBus 负责 Toast 通知和埋点这类「一发即忘」的事件。


延伸与发散

在整理这些内容时,我产生了几个还没想清楚的问题:

React Server Components 如何改变通信模型? Server Components 本身不支持 state 和 context,如果组件树同时包含 Server 和 Client Components,数据如何在它们之间流动,目前还没有很好地弄明白。

Signals 是更好的答案吗? SolidJS 和 Preact Signals 的响应式模型在性能上有明显优势,组件不会因为无关状态变化而重渲染。React 社区也在讨论类似的方向,但目前还不是主流。

微前端场景下的通信怎么做? 主子应用之间的通信,无论是用 CustomEventqiankun 的全局状态还是 URL 参数,都有各自的取舍,这是另一个值得专门研究的话题。


小结

这篇文章更多是梳理思路,而非给出「最佳实践」的定论。一个让我印象比较深的认知是:选择通信方案,本质上是在选择组件之间的耦合程度。紧耦合的代码容易理解但难以重构,松耦合的代码灵活但追踪成本高——这个权衡在软件架构里是永恒的话题。

实用建议是:从最简单的方案开始,Props 能解决就用 Props,不够用再升级。过度设计的代价往往比技术债更难还清。


参考资料

戴上AI眼镜逛花市——感受不一样的体验

2026年2月27日 17:41

一、引言

“年二八,洗邋遢;年三十,行花街。”对于很多南方人来说,春节前逛花市是雷打不动的仪式感。金桔寓意“大吉大利”,桃花象征“宏图大展”,水仙代表“吉祥如意”……可问题是,如果你是个“花盲”,面对一盆盆争奇斗艳的植物,往往只能跟着感觉走——这盆红果子好看,那盆绿叶子精神,但叫什么、怎么养、有什么讲究,一概不知。

今年春节,如果戴上Rokid的AI眼镜走进花市,情况就完全不同了。目光所及之处,每一盆植物的名字、花语、养护要点甚至春节寓意,都会像魔法一样浮现在眼前。你不再需要偷偷拍照上网查,也不用追着摊主问东问西——戴上它,你就是花市里的“植物学教授”。

二、技术实现思路

要实现这种“所见即所得”的体验,需要一套“端侧实时采集+云侧精准识别+AR无感投射”的混合架构。核心流程如下:

  1. 图像采集:AI眼镜的摄像头以30fps捕捉用户视野中的花卉图像。
  2. 端侧预处理:在眼镜本地进行图像裁剪、亮度增强(应对花市复杂光线)和初步特征提取,大幅压缩上传数据量。
  3. 云侧识别:上传裁剪后的图像至云端AI模型(如百度花卉识别API或自建花卉分类模型),返回最匹配的花卉名称及置信度。
  4. 百科匹配:根据识别结果,从本地或云端知识库中查询对应的花语、养护、寓意等详细信息。
  5. AR投射:将查询结果以半透明卡片形式渲染到用户视野的右上角,避免遮挡花卉主体;同时支持语音播报和交互。

整个流程要求端到端延迟低于500ms,才能实现流畅的“眼神定位-信息浮现”体验。

三、核心代码实现

1. 眼镜端图像采集与预处理

我们基于Rokid CXR-M SDK开发眼镜端应用,通过Camera2 API捕获预览帧,并使用OpenCV进行简单的图像增强。

java

// 花市图像采集服务(针对春节花市场景优化)
public class FlowerMarketCameraService extends Service {
    private CameraDevice mCameraDevice;
    private HandlerThread mBackgroundThread;
    private Handler mBackgroundHandler;
    private ImageReader mImageReader;

    @Override
    public void onCreate() {
        super.onCreate();
        startBackgroundThread();
        openCamera();
    }

    private void openCamera() {
        CameraManager manager = (CameraManager) getSystemService(CAMERA_SERVICE);
        try {
            manager.openCamera("0", new CameraDevice.StateCallback() {
                @Override
                public void onOpened(@NonNull CameraDevice camera) {
                    mCameraDevice = camera;
                    createPreviewSession();
                }
                // 省略其他回调...
            }, mBackgroundHandler);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }

    private void createPreviewSession() {
        // 配置ImageReader,输出YUV_420_888格式,便于OpenCV处理
        mImageReader = ImageReader.newInstance(1280, 720, ImageFormat.YUV_420_888, 2);
        mImageReader.setOnImageAvailableListener(reader -> {
            Image image = reader.acquireLatestImage();
            if (image != null) {
                // 将YUV图像转为Bitmap(简化版)
                Bitmap bitmap = yuv420888ToBitmap(image);
                image.close();
                // 触发端侧预处理:裁剪、亮度增强
                Bitmap processed = preprocessImage(bitmap);
                // 异步上传至云端识别
                uploadToCloud(processed);
            }
        }, mBackgroundHandler);

        try {
            Surface surface = mImageReader.getSurface();
            mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
                    .addTarget(surface)
                    .build();
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }

    private Bitmap preprocessImage(Bitmap original) {
        // 简单图像增强:提高对比度和亮度,适应花市复杂光线
        Bitmap enhanced = original.copy(original.getConfig(), true);
        // 此处调用OpenCV的C++代码进行直方图均衡等操作(省略JNI细节)
        // 返回处理后的Bitmap
        return enhanced;
    }

    private void uploadToCloud(Bitmap bitmap) {
        // 将Bitmap压缩为JPEG并编码为Base64
        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
        String base64Image = Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP);
        // 通过HTTP POST发送到云端识别服务
        OkHttpClient client = new OkHttpClient();
        RequestBody body = new FormBody.Builder()
                .add("image", base64Image)
                .build();
        Request request = new Request.Builder()
                .url("https://api.example.com/flower_recognize")
                .post(body)
                .build();
        client.newCall(request).enqueue(new Callback() {
            @Override
            public void onResponse(Call call, Response response) throws IOException {
                String result = response.body().string();
                // 解析JSON结果,更新AR显示
                updateARDisplay(result);
            }
            // 省略失败处理...
        });
    }

    private void updateARDisplay(String jsonResult) {
        // 通过CXR-M SDK更新AR悬浮窗内容
        FlowerARRenderer.getInstance().updateFlowerInfo(jsonResult);
    }
}

2. 云端花卉识别与百科查询

云端采用Flask搭建识别服务,调用百度AI的通用物体识别API(或自定义花卉模型),并匹配春节花卉百科库。

python

# 春节花卉识别服务 (Flask)
import base64
import json
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)

# 百度AI配置
BAIDU_API_KEY = "YOUR_API_KEY"
BAIDU_SECRET_KEY = "YOUR_SECRET_KEY"
BAIDU_TOKEN_URL = "https://aip.baidubce.com/oauth/2.0/token"
BAIDU_RECOGNIZE_URL = "https://aip.baidubce.com/rest/2.0/image-classify/v1/plant"  # 植物识别API

# 本地春节花卉百科库
with open("spring_flower_encyclopedia.json", "r", encoding="utf-8") as f:
    FLOWER_ENCY = json.load(f)

def get_baidu_token():
    params = {
        "grant_type": "client_credentials",
        "client_id": BAIDU_API_KEY,
        "client_secret": BAIDU_SECRET_KEY
    }
    response = requests.post(BAIDU_TOKEN_URL, params=params)
    return response.json().get("access_token")

@app.route("/flower_recognize", methods=["POST"])
def flower_recognize():
    data = request.get_json()
    image_base64 = data.get("image")
    if not image_base64:
        return jsonify({"error": "No image provided"}), 400

    # 调用百度植物识别API
    token = get_baidu_token()
    headers = {"Content-Type": "application/x-www-form-urlencoded"}
    post_data = {
        "image": image_base64,
        "top_num": 3  # 返回最可能的3个结果
    }
    response = requests.post(
        f"{BAIDU_RECOGNIZE_URL}?access_token={token}",
        headers=headers,
        data=post_data
    )
    result = response.json()

    # 解析识别结果,取置信度最高的花卉名
    flower_name = "未知花卉"
    confidence = 0.0
    if "result" in result and len(result["result"]) > 0:
        top = result["result"][0]
        flower_name = top["name"]
        confidence = round(top["score"] * 100, 2)

    # 查询本地百科
    info = FLOWER_ENCY.get(flower_name, {
        "花语": "暂无",
        "养护要点": "暂无",
        "春节寓意": "暂无"
    })

    # 组装返回数据
    output = {
        "flower_name": flower_name,
        "confidence": confidence,
        "info": info
    }
    return jsonify(output)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

3. AR信息投射

在眼镜端,通过CXR-M SDK提供的AR渲染接口,将云端返回的信息绘制到视野中。

java

// AR花卉信息渲染器(单例)
public class FlowerARRenderer {
    private static FlowerARRenderer sInstance;
    private String mCurrentFlowerInfo = "等待识别...";
    private boolean mIsVisible = false;

    public static FlowerARRenderer getInstance() {
        if (sInstance == null) {
            sInstance = new FlowerARRenderer();
        }
        return sInstance;
    }

    public void updateFlowerInfo(String jsonResult) {
        // 解析JSON,更新显示内容
        // 简化:直接显示花卉名称和花语
        mCurrentFlowerInfo = parseFlowerInfo(jsonResult);
        mIsVisible = true;
        // 触发重绘
        requestRender();
    }

    private String parseFlowerInfo(String json) {
        // 使用Gson解析
        Gson gson = new Gson();
        FlowerResult result = gson.fromJson(json, FlowerResult.class);
        return result.flower_name + " - " + result.info.get("花语");
    }

    public void onDrawFrame(GL10 gl) {
        if (!mIsVisible) return;
        // 使用CXR-M SDK的AR文本绘制API
        // 设置半透明背景,文字颜色为红色(春节喜庆)
        drawTextWithBackground(gl, mCurrentFlowerInfo, 0.8f, 0.1f, 0.3f, 0.9f);
    }

    private void drawTextWithBackground(GL10 gl, String text, float x, float y, float w, float h) {
        // 实际开发中调用CXR-M SDK内置方法,此处省略底层实现
        // 关键:绘制一个半透明黑色矩形,然后在上面绘制白色文字
    }
}

四、技术总结

本方案围绕“端云协同 + 硬件融合 + 场景定制”三个核心,实现了春节逛花市场景下的智能辅助体验:

  • 端云协同:眼镜端负责实时采集和预处理,云端负责高精度识别和百科查询,兼顾实时性和准确性。测试表明,在5G网络下,端到端延迟可控制在400ms以内。
  • 硬件融合:基于Rokid CXR-M SDK深度集成摄像头和AR显示,无需外接设备或频繁操作手机,真正实现“眼神交互”。
  • 场景定制:针对花市复杂光线进行图像增强,百科库专门收录春节常见花卉及其文化寓意,让技术更贴合节日氛围。

未来,这套框架可以轻松扩展到其他春节场景,比如识别年货食品的营养成分和禁忌、解读春联的平仄对仗、甚至识别红包上的吉祥图案寓意。当AI眼镜成为春节的“文化翻译官”,传统习俗将以更生动的方式传承下去。

也许明年的花市上,你就能看到人们戴着这样的眼镜,一边挑花一边会心一笑——科技,终于让年味变得更“懂”我们了。

前端面试复习指南【代码演示多多版】之——HTML

2026年2月27日 17:33

1. 什么是 DOCTYPE

DOCTYPE 是 Document Type Declaration(文档类型声明) 的缩写。它是放在网页源代码最最顶部、<html> 标签之前的一行代码。
<!DOCTYPE html>  就像是你在启动一台新机器前,先告诉它“请使用 2026 年的现代操作手册来理解我”,而不是让它猜着用 1998 年的老古董规则来运行。它是保证网页跨浏览器兼容性的第一行基石。

2. HTML语义化

2.1 什么是HTML语义化

HTML语义化是指使用具有特定含义的HTML标签来构建网页结构,而不是单纯使用<div><span>这类无意义的通用容器
简单来说,就是用合适的标签,放合适的内容

2.2 为什么需要语义化

2.2.1 对搜索引擎友好(SEO)

搜索引擎的爬虫在分析网页时,会依赖HTML标签来判断内容的重要性。语义化标签能帮助搜索引擎:

  • <h1>~<h6> :识别页面标题和内容层级
  • <strong> :知道这是重要内容(而<b>只是加粗)
  • <a> :识别链接
  • <article> :识别独立文章块

2.2.2. 对开发者和维护者友好

代码的可读性大大提高。当你看到<nav>时,立刻知道这里是导航;看到<aside>,知道是侧边栏。这就像代码的“自注释”。

2.3 常用语义化标签

标签 含义 使用场景
<header> 页眉 页面头部、文章头部
<nav> 导航 主导航、侧边栏导航
<main> 主体内容 页面的核心内容,每个页面只用一次
<article> 文章/独立内容 博客文章、新闻帖子、评论
<section> 区块/区域 有主题的内容分组,通常带标题
<aside> 侧边栏/补充内容 侧边栏、广告、相关链接

3. script标签的defer和async

在HTML中,<script>标签的deferasync属性都是用来控制脚本加载和执行时机的,目的是优化页面加载性能。但它们的行为有重要区别。

3.1 先理解默认情况(无defer/async)

html

<script src="script.js"></script>
  • 加载过程:遇到script标签时,立即暂停HTML解析 → 下载脚本 → 执行脚本 → 恢复HTML解析
  • 特点:阻塞式,脚本会按顺序执行
  • 问题:如果脚本很大,页面会白屏等待

3.2 defer(延迟执行)

html

<script defer src="script.js"></script>
  • 加载异步加载(不阻塞HTML解析)
  • 执行时机HTML解析完成后DOMContentLoaded事件之前执行
  • 顺序保持顺序,多个defer脚本按出现顺序执行

示意图:

HTML解析: |=======解析中========|======解析完成======|
script加载:    |--下载--| (不阻塞)
script执行:                           |--执行--|
DOMContentLoaded:                                    触发

3.3 async(异步执行)

<script async src="script.js"></script>
  • 加载异步加载(不阻塞HTML解析)
  • 执行时机下载完成后立即执行(此时可能HTML还没解析完)
  • 顺序不保证顺序,谁先下载完谁先执行

示意图:

HTML解析: |=======解析中========|======解析完成======|
script加载:    |--下载--| (不阻塞)
script执行:           |--执行--| (可能阻塞解析)
DOMContentLoaded:                触发 (可能被脚本延迟)

4. HTML5的新特性

4.1 语义化标签(让结构更有意义)

这是 HTML5 最直观的变化,新增了一系列用于描述页面结构的标签:

标签 描述
<header> 头部区域
<footer> 底部区域
<nav> 导航链接
<article> 独立的内容块(如文章、帖子)
<section> 文档中的节(有主题的内容分组)
<aside> 侧边栏、补充内容
<main> 页面主要内容(每个页面只用一次)

意义:让搜索引擎和屏幕阅读器更好地理解页面结构,也提高了代码的可读性。


4.2 增强型表单(更好的用户体验)

HTML5 为 <input> 增加了许多新的 type 类型,让浏览器提供原生的输入控制:

新增 input 类型:

<input type="email">      <!-- 邮箱格式验证 -->
<input type="url">        <!-- URL 格式验证 -->
<input type="tel">        <!-- 电话号码(不自动验证,但移动端弹出数字键盘) -->
<input type="number">     <!-- 数字输入(带上下箭头) -->
<input type="range">      <!-- 滑块 -->
<input type="date">       <!-- 日期选择器 -->
<input type="color">      <!-- 颜色选择器 -->
<input type="search">     <!-- 搜索框(带清空按钮) -->

新增表单属性:

<input placeholder="提示文字">      <!-- 输入框提示文本 -->
<input required>                   <!-- 必填 -->
<input autofocus>                  <!-- 自动获取焦点 -->
<input pattern="[0-9]{11}">        <!-- 正则表达式验证:11位数字验证 -->
<input min="1" max="100">          <!-- 范围1-100 -->
<input step="5">                   <!-- 每次增减5 -->

意义:减少 JavaScript 表单验证代码,提升用户体验(尤其是移动端)。


4.3 多媒体支持(告别 Flash)

HTML5 提供了原生的音视频标签,无需第三方插件。

视频:

<video src="movie.mp4" controls width="400">
  您的浏览器不支持 video 标签。
</video>
  • 属性:controls(控件)、autoplayloopmutedposter(封面图)

音频:

<audio src="song.mp3" controls>
  您的浏览器不支持 audio 标签。
</audio>

意义:移动端友好、性能更好、更安全(相比 Flash)。


4.4 Canvas 绘图(强大的绘图能力)

<canvas> 是一个画布,通过 JavaScript 在网页上绘制图形、动画、游戏画面。

<canvas id="myCanvas" width="200" height="100"></canvas>
<script>
  const canvas = document.getElementById('myCanvas');
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'red';
  ctx.fillRect(10, 10, 50, 30);
</script>

应用场景:数据可视化图表、小游戏、图像处理、动态特效。


4.5 SVG 支持(矢量图形)

虽然 SVG 不是 HTML5 首创,但 HTML5 将其更好地集成进来,支持在 HTML 中直接嵌入 SVG 代码。

<svg width="100" height="100">
  <circle cx="50" cy="50" r="40" fill="blue" />
</svg>

特点:矢量图、不失真、可被 CSS 和 JavaScript 操作。

Canvas vs SVG:

  • Canvas:像素级、性能好、适合复杂动画(如游戏)
  • SVG:矢量、可交互、适合图标和简单图形

4.6 地理定位

通过 JavaScript 获取用户的地理位置(需要用户授权)。

navigator.geolocation.getCurrentPosition(function(position) {
  console.log('纬度:', position.coords.latitude);
  console.log('经度:', position.coords.longitude);
});

应用:附近商家、导航服务、天气应用。


4.7 Web 存储(替代 Cookie)

HTML5 提供了两种在浏览器端存储数据的方式,比 Cookie 容量更大(一般 5-10MB)、更简单。

localStorage(永久存储):

localStorage.setItem('username', '张三');
let name = localStorage.getItem('username');

sessionStorage(会话级,关闭浏览器就清除):

sessionStorage.setItem('token', 'abc123');

优势:相比 Cookie(4KB),容量大、不随请求发送、API 简单。


4.8 离线应用与缓存

Application Cache(已废弃,但概念重要)和更新的 Service Worker 使网页可以离线访问。

  • manifest 文件(旧方案):指定哪些文件离线可用
  • Service Worker(新方案):更强大的离线缓存、消息推送、后台同步

意义:让网页像原生 App 一样,无网络也能使用。


4.9 拖放 API

HTML5 支持原生的拖放操作。

html

<div draggable="true" ondragstart="drag(event)">可拖拽元素</div>
<div ondrop="drop(event)" ondragover="allowDrop(event)">放置区域</div>

应用:文件上传、看板管理、布局拖拽。


4.10 Web Workers(多线程)

JavaScript 是单线程的,但 Web Workers 允许在后台运行脚本,不阻塞主线程。

javascript

// 创建 worker
const worker = new Worker('worker.js');
// 接收消息
worker.onmessage = function(event) {
  console.log('计算结果:', event.data);
};
// 发送消息
worker.postMessage([10, 20]);

应用:大数据计算、图像处理、加密解密。


4.11 WebSocket(全双工通信)

HTML5 提供了 WebSocket API,实现浏览器与服务器的持久连接,双向实时通信。

javascript

const socket = new WebSocket('ws://example.com');
socket.onmessage = function(event) {
  console.log('收到消息:', event.data);
};
socket.send('Hello Server');

应用:在线聊天、实时游戏、股票行情、协同编辑。


4.12 新的语义元素(文本级)

除了结构标签,还有一些文本级的语义标签:

标签 用途
<mark> 高亮标记(如搜索结果中的关键词)
<time> 时间/日期
<progress> 进度条
<meter> 度量衡(如磁盘使用量)
<details>/<summary> 可折叠的详情区域

4.13 废弃的标签

HTML5 移除了一些纯表现层的标签,要求用 CSS 替代:

已废弃:  <font><center><big><strike><frame><frameset><noframes>

保留但语义改变:  <small>(不再表示小字号,而是"免责声明"类的小字注释)

Mac 环境下通过 SSH 操作服务器,完成前端静态资源备份与更新(全程实操无坑)

作者 panshihao
2026年2月27日 17:31

作为前端开发/运维,经常需要手动更新服务器上的静态资源,从备份旧资源到上传新资源、解压替换,每一步都不能出错——尤其是生产环境,一个误操作可能导致服务异常。

本文以 Mac 电脑为例,全程基于 SSH 操作(无需额外安装复杂工具,系统自带终端即可),完整覆盖「登录服务器 → 备份旧前端资源 → 上传新资源压缩包 → 清空旧资源 → 解压新资源」全流程,新手也能跟着一步到位,附常见报错解决方案,彻底避开 SFTP 命令兼容问题。

适用场景:前端静态资源(Vue/React 打包后的 dist 包,本文以 szyd.zip 为例)更新、服务器文件备份,服务器系统为 Linux(CentOS/Ubuntu 通用)。

一、前置准备(必看)

  • 服务器信息:需知道服务器 IP 地址、登录用户名(如 root)、登录密码、SSH 端口(默认 22,若修改过需记好)
  • 本地准备:Mac 电脑(自带终端,无需额外安装工具)、新前端静态资源压缩包(本文为 szyd.zip);
  • 提前确认:服务器上前端资源存放路径(本文以 /usr/local/nginx 为例,html 文件夹为前端资源根目录,html_backup 为备份目录);
  • 核心前提:已做好旧资源备份(本文会详细步骤,避免误删无法恢复)。

二、全程实操步骤(按顺序执行,不要跳步)

步骤 1:Mac 终端连接服务器(SSH 方式,全程唯一连接方式)

Mac 自带终端原生支持 SSH 连接,无需装任何额外工具,操作稳定、无命令兼容问题,是服务器操作的首选方式。

  1. 打开 Mac 终端(聚焦搜索输入「终端」,回车打开);
  2. 输入 SSH 登录命令,替换为自己的服务器信息: # 格式:ssh 用户名@服务器IP -P 端口(默认22,可省略) ``ssh root@192.168.1.100 -P 22
  3. 输入服务器登录密码(输入时无任何回显,直接输完回车即可,不要慌);
  4. 登录成功后,终端提示符会变成 [root@localhost ~]#(不同服务器可能略有差异),表示已进入服务器 SSH 交互模式,可开始操作服务器文件。

避坑点:若提示「Connection refused」,检查服务器端口是否开放、SSH 服务是否运行(执行 systemctl status sshd 可查看),或确认端口是否修改(非 22 需指定 -P 端口)。

步骤 2:定位到前端资源目录,确认文件

本文前端资源存放在 /usr/local/nginx 目录,先进入该目录并确认文件是否存在,避免后续操作路径错误。

# 进入服务器前端资源根目录(替换为自己的实际路径)
cd /usr/local/nginx

# 查看当前目录下的文件(确认 html、szyd.zip 存在)
ls -l

执行 ls -l 后,应能看到以下文件/文件夹(和自己的实际情况对应):

client_body_temp    conf                fastcgi_temp        html                
html_backup         logs                proxy_temp          sbin                
scgi_temp           uwsgi_temp          szyd.zip

关键确认:html(旧前端资源)、szyd.zip(新前端压缩包)、html_backup(备份目录)均存在,若未上传新压缩包,先执行步骤 4 上传。

步骤 3:备份旧前端资源(核心,避免误删无法恢复)

备份思路:将 html 文件夹下的所有旧资源,拷贝到 html_backup 目录下的日期文件夹(如 20260227,按备份日期命名,方便后续追溯),SSH 原生支持递归拷贝,无需担心兼容问题。

  1. 进入备份目录 html_backup,创建日期备份文件夹(以 20260227 为例): # 进入备份目录 `` cd html_backup ```` # 创建日期文件夹(命名格式:年-月-日 或 年月日,方便区分) `` mkdir 20260227 ```` # 回到 /usr/local/nginx 目录(后续操作需要) ``cd /usr/local/nginx
  2. 递归拷贝 html 下的所有旧资源到备份文件夹(核心备份命令,SSH 稳定支持): # 递归拷贝 html 所有内容到备份文件夹(-r 表示递归,覆盖所有文件/子文件夹) ``cp -r html/* html_backup/20260227/
  3. 备份验证:执行以下命令,能看到 html 里的所有旧文件/文件夹,说明备份成功:ls html_backup/20260227/

重要提醒:备份完成前,绝对不要删除 html 里的旧资源!确认备份成功后,再进行下一步,避免误删后无法恢复。

步骤 4:上传新前端压缩包(若未上传)

若还未将新前端压缩包(szyd.zip)上传到服务器 /usr/local/nginx 目录,在 Mac 终端(保持 SSH 登录状态)执行以下命令,将本地压缩包上传到服务器:

# 格式:scp -P 端口 本地压缩包路径 用户名@服务器IP:服务器目标路径
# 示例(默认端口22,可省略 -P 22;若端口修改,替换为实际端口)
scp -P 22 /Users/你的Mac用户名/Desktop/szyd.zip root@192.168.1.100:/usr/local/nginx/

说明:

  • /Users/你的Mac用户名/Desktop/szyd.zip 是 Mac 本地压缩包的路径(可在访达中找到文件,右键「显示简介」查看路径);
  • 上传过程中会显示进度条,上传完成后,执行 ls /usr/local/nginx 确认 szyd.zip 存在。

步骤 5:清空 html 文件夹的旧资源(关键步骤)

备份成功后,清空 html 文件夹内的所有旧资源(保留 html 文件夹本身,避免误删文件夹导致 Nginx 报错),SSH 命令执行稳定,无需担心权限或兼容问题。

# 确保当前在 /usr/local/nginx 目录(SSH 模式下)
cd /usr/local/nginx

# 清空 html 内所有旧资源(仅删内部内容,不删文件夹,-rf 表示强制递归删除)
rm -rf html/*

验证清空:执行 ls html,输出为空,说明旧资源已删干净。

禁止执行:rm -rf html/(多了一个 /,会删除整个 html 文件夹,导致 Nginx 无法访问前端资源,后果严重!)。

步骤 6:解压新前端压缩包到 html 文件夹

将上传的 szyd.zip 压缩包,解压到 html 文件夹,完成新资源替换,SSH 模式下执行解压命令,兼容所有 Linux 系统。

  1. 执行解压命令(确保当前在 /usr/local/nginx 目录): # 解压 szyd.zip 到 html 文件夹(-d 指定解压目标目录) ``unzip szyd.zip -d html/
  2. 异常处理:若提示 unzip: command not found(服务器未安装 unzip 工具),执行以下命令安装(根据服务器系统二选一): # CentOS/RHEL 系统 `` yum install unzip -y ```` # Ubuntu/Debian 系统 `` apt update && apt install unzip -y ```` # 安装完成后,重新执行解压命令 ``unzip szyd.zip -d html/
  3. 解压验证:执行 ls html,能看到新前端资源(如 index.html、css、js、dist 等),说明解压成功。

步骤 7:(可选)修复文件权限(避免 Nginx 访问报错)

解压后的文件可能权限不足,导致 Nginx 无法读取前端资源,出现 403 报错,在 SSH 模式下执行以下命令赋予权限:

# 进入 /usr/local/nginx 目录
cd /usr/local/nginx

# 赋予 html 文件夹及内部文件可读可执行权限(-R 递归应用到所有子文件/文件夹)
chmod -R 755 html/

# 赋予 Nginx 运行用户权限(多数服务器 Nginx 运行用户为 www,可替换为 nginx)
chown -R www:www html/

步骤 8:完成更新,验证效果

所有操作完成后,执行以下命令确认 Nginx 服务正常(避免权限修改导致服务异常):

# 查看 Nginx 服务状态
systemctl status nginx

# 若服务未运行,启动 Nginx
# systemctl start nginx

# 若修改过配置,重启 Nginx
# systemctl restart nginx

最后,打开浏览器,刷新前端网站,若能正常显示新页面,说明静态资源更新成功;若无法访问,检查 Nginx 服务状态和文件权限。

三、常见报错及解决方案(避坑合集)

1. 登录提示「Connection refused」

原因:服务器 SSH 端口未开放、SSH 服务未运行,或端口修改后未指定;

解决方案:检查服务器防火墙是否开放对应端口(如 22),执行 systemctl status sshd 确认 SSH 服务运行,登录时指定正确端口(ssh root@IP -P 端口)。

2. 解压提示「unzip: command not found」

原因:服务器未安装 unzip 工具;

解决方案:根据服务器系统执行安装命令(CentOS:yum install unzip -y;Ubuntu:apt update && apt install unzip -y)。

3. 刷新网站无法访问,提示 403 Forbidden

原因:html 文件夹或内部文件权限不足,Nginx 无法读取;

解决方案:执行步骤 7 的权限修复命令,确保 html 文件夹权限为 755,且归属 Nginx 运行用户(如 www)。

4. 备份/解压后文件缺失

原因:压缩包损坏,或拷贝/解压时路径错误;

解决方案:重新上传压缩包,执行 unzip -l szyd.zip 检查压缩包内部结构,确认路径正确后重新解压;备份时确保命令为 cp -r html/* html_backup/20260227/

5. 执行 rm 命令提示「Permission denied」

原因:当前用户无 html 文件夹的删除权限;

解决方案:切换到 root 用户(su root),或在命令前加 sudosudo rm -rf html/*)。

四、总结

Mac 环境下通过 SSH 操作服务器,完成前端静态资源备份与更新,核心流程可总结为:

SSH 登录 → 定位目录 → 备份旧资源 → 上传新压缩包 → 清空旧资源 → 解压新资源 → 权限修复 → 验证效果

关键注意点:

  • 备份优先,避免误删旧资源,备份文件夹按日期命名,方便后续追溯和回滚;
  • 删除旧资源时,务必写 html/*,绝对不要误删 html 文件夹本身;
  • 全程使用 SSH 方式,无命令兼容问题,操作稳定,适合生产环境使用;
  • 解压后记得修复文件权限,避免 Nginx 访问报错,更新后验证 Nginx 服务状态。

这套流程适用于大多数前端静态资源更新场景,无论是开发环境还是生产环境,按步骤执行都能避免踩坑。如果你的服务器路径、压缩包名称不同,只需替换对应参数即可直接套用~

最后,觉得有用的话,欢迎点赞收藏,避免后续需要时找不到!

【节点】[ComputeDeformation节点]原理解析与实际应用

作者 SmalBox
2026年2月27日 17:28

【Unity Shader Graph 使用与特效实现】专栏-直达

Compute Deformation 节点是 Unity URP Shader Graph 中一个专门用于处理动态网格变形的高级节点。该节点在实现基于 DOTS(Data-Oriented Technology Stack)的动画系统和实体组件系统(ECS)的渲染流程中扮演着关键角色。通过此节点,开发者可以在保持高性能的同时,实现复杂的网格变形效果,如骨骼动画、蒙皮变形、物理模拟变形等。

在传统的渲染管线中,网格变形通常需要在 CPU 端计算后上传到 GPU,这可能导致性能瓶颈。而 Compute Deformation 节点通过与 Entities Graphics 包和 DOTS Animation 包的深度集成,使得这些计算可以直接在 GPU 端或通过高效的 ECS 系统处理,大大提升了处理大规模动态网格的性能。

描述

核心功能与工作原理

Compute Deformation 节点的主要功能是将预先计算好的变形顶点数据传递到顶点着色器中。这个节点不是直接执行变形计算,而是作为一个数据桥梁,将外部计算系统(如 DOTS Animation 系统)生成的变形结果集成到 Shader Graph 的渲染流程中。

节点的工作原理基于 Unity 的 Entities Graphics 系统,这是一个专门为 ECS 架构设计的高性能渲染后端。当使用此节点时,Shader Graph 会从 _DeformedMeshData 缓冲区中读取 DeformedVertexData 数据。系统使用 _ComputeMeshIndex 属性来确定当前网格对应的变形数据在缓冲区中的具体位置,从而确保每个网格实例都能获取到正确的变形数据。

系统要求与依赖

要正常使用 Compute Deformation 节点,必须满足以下条件:

  • 安装 Entities Graphics package(com.unity.entities.graphics)
  • 安装 DOTS Animation packages(com.unity.animation 和 com.unity.animation.dots)
  • 或者使用自定义的变形数据提供解决方案

Entities Graphics 包提供了基于 ECS 的渲染基础设施,而 DOTS Animation 包则负责处理高性能的动画计算。这两个包的结合为 Compute Deformation 节点提供了必要的数据源和处理框架。

应用场景

Compute Deformation 节点适用于多种需要动态网格变形的场景:

  • 基于 GPU 的骨骼动画和蒙皮网格变形
  • 物理模拟导致的网格变形(如布料、软体)
  • 程序化生成的动态几何形状
  • 大规模人群动画系统
  • 实时变形的环境物体(如被风吹动的植被)

端口

输出端口详解

Compute Deformation 节点提供了三个主要的输出端口,分别对应网格变形后的不同顶点属性:

Position 输出端口

Position 端口输出变形后的顶点位置数据,类型为 Vector 3,在顶点着色器阶段可用。

  • 数据类型:Vector 3
  • 着色器阶段:顶点阶段
  • 功能说明:此端口输出经过变形计算后的顶点世界空间位置。对于每个顶点,该位置反映了所有应用的变形效果(如骨骼变换、形状键等)的最终结果。

在使用此端口时,需要注意变形数据已经包含了从模型空间到世界空间的变换,因此通常不需要再额外应用对象到世界的变换矩阵。

Normal 输出端口

Normal 端口输出变形后的顶点法线数据,类型为 Vector 3,在顶点着色器阶段可用。

  • 数据类型:Vector 3
  • 着色器阶段:顶点阶段
  • 功能说明:此端口输出与变形后顶点位置对应的法线向量。法线的变形计算通常需要考虑顶点变换的逆转置矩阵,以保持正确的表面朝向。

变形后的法线对于光照计算至关重要,特别是在使用基于法线的光照模型(如 Phong 或 Blinn-Phong)时。确保法线数据正确变形可以避免光照异常和视觉瑕疵。

Tangent 输出端口

Tangent 端口输出变形后的顶点切线数据,类型为 Vector 3,在顶点着色器阶段可用。

  • 数据类型:Vector 3
  • 着色器阶段:顶点阶段
  • 功能说明:此端口输出变形后的顶点切线向量。切线数据主要用于法线映射(Normal Mapping)和某些高级着色效果。

与法线类似,切线的变形也需要特殊的处理以保持与表面几何的一致性。在使用法线贴图时,正确的切线数据对于准确计算光照效果至关重要。

端口使用注意事项

  • 所有输出端口都仅在顶点着色器阶段可用,不能在片段着色器阶段直接使用
  • 如果不需要某些变形数据(如只需要位置而不需要法线),可以只连接需要的端口
  • 输出的数据已经是经过所有变形计算后的最终结果,不需要额外的变换处理
  • 在某些情况下,可能需要手动重新归一化法线和切线向量,特别是在变形幅度较大时

实现细节与技术深度

数据流架构

Compute Deformation 节点的数据流涉及多个系统组件的高效协作:

  • 数据准备阶段:DOTS Animation 系统或自定义变形系统在后台计算网格变形,将结果写入 DeformedVertexData 结构
  • 数据存储:变形后的顶点数据被组织在 _DeformedMeshData 缓冲区中,这是一个 GPU 可访问的结构化缓冲区
  • 索引解析:系统使用 _ComputeMeshIndex 来定位特定网格的变形数据在缓冲区中的起始位置
  • 数据传输:在顶点着色器阶段,Compute Deformation 节点从缓冲区读取数据并输出到后续着色阶段

这种架构的优势在于将计算密集的变形处理与渲染流程解耦,允许变形计算在最适合的系统(可能是 CPU 端的 ECS 系统或 GPU 的计算着色器)中执行,而渲染管线只需高效地读取结果数据。

性能优化考虑

使用 Compute Deformation 节点时,有几个关键的性能优化点:

  • 数据布局优化:确保 _DeformedMeshData 缓冲区的数据布局与访问模式匹配,以提高缓存效率
  • 索引计算效率_ComputeMeshIndex 的计算应尽可能简单,避免复杂的运行时计算
  • 缓冲区管理:合理管理变形数据缓冲区的生命周期和内存使用,避免不必要的分配和复制
  • LOD 支持:为不同层次的细节(LOD)提供适当的变形数据,减少不必要的顶点处理

与自定义变形系统的集成

对于不使用 DOTS Animation 包的开发者,Compute Deformation 节点也支持与自定义变形系统的集成。这需要:

  • 实现自定义的变形计算逻辑,生成符合预期的 DeformedVertexData
  • 正确设置 _DeformedMeshData 缓冲区,确保数据格式与节点期望的一致
  • 管理 _ComputeMeshIndex 的分配和更新,确保每个网格实例都能找到对应的变形数据

这种灵活性使得 Compute Deformation 节点可以适应各种不同的技术栈和性能要求。

实际应用示例

基础设置流程

要正确使用 Compute Deformation 节点,需要按照以下步骤进行设置:

  • 在 Unity 项目中安装必要的包(Entities Graphics 和 DOTS Animation)
  • 准备可变形的网格资源,并确保其兼容 ECS 渲染系统
  • 在 Shader Graph 中创建或打开着色器,添加 Compute Deformation 节点
  • 将节点的输出端口连接到相应的主节点输入(如 Position 连接到 Vertex Position)
  • 配置渲染实体和变形系统,确保变形数据正确生成和传递

完整着色器示例

以下是一个使用 Compute Deformation 节点的基本着色器结构示例:

HLSL

// 在顶点着色器阶段,Compute Deformation 节点自动获取变形数据
// 并将结果输出到连接的端口

// 将变形后的位置直接用作顶点位置
VertexPosition = ComputeDeformation.Position;

// 使用变形后的法线进行光照计算
VertexNormal = ComputeDeformation.Normal;

// 使用变形后的切线处理法线贴图
VertexTangent = ComputeDeformation.Tangent;

高级使用技巧

对于更复杂的应用场景,可以考虑以下高级技巧:

  • 将变形数据与其他顶点修改效果结合使用,如顶点着色器中的额外变形或置换映射
  • 使用多个变形数据源,通过权重混合实现更复杂的效果
  • 在片段着色器中基于变形数据实现自定义的着色效果
  • 利用变形数据驱动其他渲染效果,如基于顶点运动向量动态模糊

调试与问题排查

当 Compute Deformation 节点不按预期工作时,可以检查以下几个方面:

  • 确认所有必要的包已正确安装和配置
  • 检查变形数据是否确实被生成并写入缓冲区
  • 验证 _ComputeMeshIndex 是否正确设置,能否正确定位到变形数据
  • 使用 Frame Debugger 或类似的工具检查渲染过程中的数据流
  • 确保着色器变体正确编译,包含必要的变形数据处理代码

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

从 AI 对话应用理解 SSE 流式传输:一项 "老技术" 的新生

作者 hulkie
2026年2月27日 17:24

从 AI 对话应用理解 SSE 流式传输:一项 "老技术" 的新生

最近在开发一个类似 ChatGPT 的 AI 对话应用,深入学习了 SSE(Server-Sent Events)流式传输技术。本文记录我的学习过程和理解,希望对你有帮助。

一、为什么 AI 应用需要流式传输?

如果你用过 ChatGPT、Claude 等 AI 对话产品,一定注意到它们的回复是逐字显示的,而不是等待几十秒后一次性显示完整答案。

这种体验差异巨大:

方式 用户体验
普通接口 发送消息 → 等待 10-30 秒 → 一次性显示完整回答 😴
流式接口 发送消息 → 0.5 秒后开始显示 → 逐字输出 → 完成 🤩

同样的等待时间,流式输出让用户感觉 AI "在思考",而非 "卡死了"。

这背后的技术就是 SSE(Server-Sent Events)


二、SSE 是什么?

一句话定义

SSE 就是在一次 HTTP 请求会话结束前,服务端多次向客户端推送数据。

与普通 HTTP 请求的对比

普通 HTTP 请求:
客户端 ──请求──► 服务端
客户端 ◄──响应── 服务端(一次性返回,连接关闭)

SSE 流式请求:
客户端 ──请求──► 服务端
客户端 ◄──数据1── 服务端
客户端 ◄──数据2── 服务端
客户端 ◄──数据3── 服务端
...
客户端 ◄──结束── 服务端(连接关闭)

核心特点

  • 单向通信:服务端 → 客户端(如果需要双向,用 WebSocket)
  • 基于 HTTP:不需要特殊协议,复用现有基础设施
  • 长连接:一个请求保持打开,直到服务端主动关闭

三、服务端实现:其实很简单

SSE 服务端的核心就三步:

// 1. 设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");

// 2. 多次写入数据(不关闭连接)
res.write("data: 第1块数据\n\n");
res.write("data: 第2块数据\n\n");
res.write("data: 第3块数据\n\n");

// 3. 结束连接
res.end();

SSE 消息格式

event: message
data: {"content": "你好"}

event: done
data: {"content": "", "done": true}

每条消息由 event(可选)和 data 组成,消息之间用 \n\n 分隔。

Express 完整示例

import express from "express";

const app = express();

app.post("/api/chat/stream", async (req, res) => {
  const { message } = req.body;

  // 1. 设置 SSE 响应头
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  // 2. 模拟逐字输出
  const reply = `你好!你说的是:"${message}",这是一个流式响应示例。`;

  for (const char of reply) {
    // 每个字符作为一条消息发送
    res.write(`data: ${JSON.stringify({ content: char })}\n\n`);

    // 模拟打字延迟
    await new Promise((resolve) => setTimeout(resolve, 50));
  }

  // 3. 发送结束标记
  res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
  res.end();
});

app.listen(3001);

四、客户端实现:理解 ReadableStream

浏览器如何感知流式响应?

当浏览器收到响应头 Content-Type: text/event-stream 时,会将响应体包装为一个 ReadableStream 对象,允许我们边接收边处理。

核心代码

async function fetchSSE(message: string) {
  const response = await fetch("/api/chat/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message }),
  });

  // response.body 是一个 ReadableStream
  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  // 循环读取,直到流结束
  while (true) {
    const { done, value } = await reader.read();

    if (done) break; // 流结束

    // 解码并处理数据
    const text = decoder.decode(value);
    console.log("收到:", text);
  }
}

关键问题:await reader.read() 会阻塞吗?

这是我学习时的一个疑惑:while (true) 循环不会卡死吗?

答案是:不会!

reader.read() 是一个 Promise,它会:

  • 有数据时:立即返回 { done: false, value: ... }
  • 没数据时:挂起等待,直到服务端发送数据
  • 连接关闭时:返回 { done: true }

这是异步等待,不是忙轮询,不会占用 CPU。

时间轴:
────────────────────────────────────────────────────────►

前端:       await read()     await read()     await read()
                 │ 挂起等待...    │ 挂起等待...    │
                 ▼                ▼                ▼
服务端:   ──● res.write() ──● res.write() ──● res.end()

五、接入真实 LLM API

如果要接入 OpenAI、Claude 等真实 AI 服务,你的服务端需要:

  1. 接收 三方 API 的 SSE 响应
  2. 转发 给前端
前端 ◄──(SSE)── 你的服务端 ◄──(SSE)── LLM API

OpenAI 示例

import OpenAI from "openai";

const openai = new OpenAI({ apiKey: "sk-xxx" });

app.post("/api/chat/stream", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");

  // 调用 OpenAI,开启流式模式
  const stream = await openai.chat.completions.create({
    model: "gpt-4",
    messages: [{ role: "user", content: req.body.message }],
    stream: true, // 关键:开启流式
  });

  // 遍历流式响应,逐个转发
  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content || "";
    if (content) {
      res.write(`data: ${JSON.stringify({ content })}\n\n`);
    }
  }

  res.end();
});

本质就是:LLM 给你一滴水,你就往前端倒一滴。


六、不同环境的流式处理

SSE 不是浏览器专属,任何支持 HTTP 的环境都能实现:

环境 流式 API
浏览器 response.body.getReader()
Node.js response.on('data', callback)
Dart/Flutter response.stream.listen()
Go io.Reader
Python response.iter_content()

原理都一样:收到一块数据 → 处理一块 → 等待下一块 → 直到结束


七、一项 25 年前的 "老技术"

SSE 背后的核心技术——HTTP 分块传输(Chunked Transfer Encoding)——早在 1999 年 就被纳入 HTTP/1.1 标准(RFC 2616)。

HTTP/1.1 响应头:
Transfer-Encoding: chunked  ← 告诉客户端这是分块传输

这不是什么新发明,而是一项 20+ 年的成熟技术,只是 AI 时代让它重新成为焦点。

AI 之前的应用场景

  • 大文件下载:边读边发,不用先加载到内存
  • 动态网页:边生成边返回,用户先看到框架
  • 实时日志tail -f 式的持续输出
  • 股票行情:实时推送价格变动

你打开任意一个网站,在开发者工具中大概率能看到 Transfer-Encoding: chunked——这技术一直在默默工作。


八、常见误区澄清

误区 1:分片上传也是 HTTP Chunked

错! 前端大文件分片上传是应用层方案,多个 HTTP 请求,每个传一片。

HTTP Chunked 是协议层功能,一个请求内分块传输。

对比 HTTP Chunked 分片上传
方向 服务端 → 客户端 客户端 → 服务端
请求数 1 个 多个
谁来分块 协议自动处理 前端 JS 手动切分

误区 2:SSE 是新技术

错! SSE 规范(EventSource API)2006 年就有了,底层的 Chunked 更是 1999 年的标准。

AI 只是给老技术找到了新的杀手级应用场景。


九、总结

概念 一句话解释
SSE 一次请求内,服务端多次推送数据
服务端 res.write() 多次,res.end() 结束
客户端 reader.read() 循环,done 判断结束
await read() 异步等待,有数据才返回,不是忙轮询
底层原理 HTTP/1.1 Transfer-Encoding: chunked
历史 1999 年标准,2023 年因 AI 翻红

核心认知

技术本身没变,场景变了。很多 "新技术" 只是老技术 + 新包装。

学技术时,理解底层原理比追逐新概念更重要——因为原理不变,概念会反复翻新。


里程碑五:Elpis框架npm包抽象封装并发布

作者 dobym
2026年2月27日 17:23

本文是《大前端全栈实践》学习实践的总结,参考自抖音"哲玄前端"老师的课程内容,在此基础上进行了深度思考与实践。

一、背景与目标

在完成了前四个里程碑的开发后,随着功能模块的增加,业务逻辑与底层架构代码混杂在一起,导致核心架构难以复用。

里程碑五的核心目标是 "业务分离"

方案:封装npm包,抽离业务代码。

二、业务分离

我们将项目拆解为两个部分:

  1. 框架层 (@dobym/elpis) :负责底层驱动,包括 Koa 实例创建、环境识别、自动加载器(Loader)、基础组件库等。
  2. 业务层 (用户Project) :负责具体业务,遵循框架约定的目录结构编写 Controller、Service 和 Config以及自定义Components。

业务目录结构约定

框架采用了约定优于配置的设计理念。只要业务层按照以下结构组织代码,框架就能自动识别并加载:

Project/
├── app/
│   ├── controller/      # 业务逻辑控制器
│   ├── service/         # 业务逻辑服务层
│   ├── middleware/      # 业务中间件
│   ├── pages/           # 业务页面
    │   ├── dashboard/   # 业务启动页面
    │   ├── widgets/     # 业务自定义动态组件
│   ├── router/          # 路由定义
│   └── router-schema/   # 参数校验Schema
├── webpack.config.js    # webpack配置 
├── middleware.js        # 全局中间件调用
├── config/              # 配置文件
│   ├── config.default.js
│   └── config.local.js
├── model/               # DSL文件      
├── index.js             # 入口文件
└── package.json

三、核心实现

框架的核心在于Loader(加载器) 。它利用 Node.js 的模块系统和文件系统能力(如 glob),自动扫描指定目录并挂载到 app 实例上。

3.1 elpis-core统一入口

// elpis-core/index.js
start(options = {}) {
    const app = new Koa();
    // 确定业务代码根路径
    app.businessPath = path.resolve(process.cwd(), './app');
    
    // 按顺序加载核心模块
    app.env = env();                    // 环境识别
    middlewareLoader(app);              // 中间件加载
    routerSchemaLoader(app);            // 参数校验加载
    controllerLoader(app);              // 控制器加载
    serviceLoader(app);                 // 服务层加载
    configLoader(app);                  // 配置加载
    
    // 注册elpis全局中间件
    const elpisMiddlewarePath = path.resolve(__dirname, `..${sep}app${sep}middleware.js`)
    const elpisMiddleware = require(elpisMiddlewarePath)
    elpisMiddleware(app)
 
    // 注册业务全局中间件
    try {
      require(`${app.businessPath}${sep}middleware.js`)(app)
      console.log(`-- [start] load  global bussiness middleware file -- `);
    } catch (error) {
    }

    // 注册路由 
    routerLoader(app)    // 路由加载器
    // ...最后启动elpis框架服务
    app.listen(port);
}

3.2 npm包入口index.js

npm包供用户真正调用的文件

// index.js
// 引入elpis-core
const ElpisCore = require('./elpis-core')
// 引入前端工程化构建方法
const FEBuildDev = require('./app/webpack/dev.js')
const FEBuildProd = require('./app/webpack/prod.js')

module.exports = {
  /**
   * 服务端基础 
   */
  Controller: {
    Base: require('./app/controller/base.js')
  },
  Service: {
    Base: require('./app/service/base.js')
  },

  /**
   * 编译构建前端工程
   * @params env 环境变量 dev/prod 
   */
  frontendBuild(env) {
    if (env === 'local') {
      FEBuildDev()
    } else if (env === 'production') {
      FEBuildProd()
    }
  },
  /**
   * 启动elpis
   * @params options 项目配置,透传到elpis-core 
   */
  serverStart(options = {}) {
    return ElpisCore.start(options)
  }
}

四、总结

里程碑五的完成,标志着 Elpis 从一个单一的练手项目,进化为一个可复用的企业级 Node.js 开发框架

通过将通用逻辑抽离为 npm 包 (@dobym/elpis),我们不仅解耦了代码,更重要的是确立了一套开发规范。未来的微服务或新功能模块,都可以基于此框架快速构建,真正实现了"提质增效"。

Docker本地部署gitlab实践(windows,linux)

2026年2月27日 17:15

Docker本地部署 gitlab(windows&linux)

1. 版本介绍

名词解释:

ce - 社区版(免费) ee - 企业版(收费) jh - 极狐GitLab 中国发行版(包含 CE 和 EE 功能,基础功能免费)

1.1 官方

1.官方:about.gitlab.com/

2.官方ce版本deb安装包:packages.gitlab.com/gitlab/gitl…

3.官方docker镜像:hub.docker.com/r/gitlab/gi…

4.官方docker安装教程:

ps:注意此为ee版(企业版),需在拉取镜像时配置替换为gitlab-ce

5.官方配置说明:docs.gitlab.com/install/doc…

1.2 国内代理

1.中文版介绍:gitlab.cn/

  • 极狐是Gitlab的国内代理

2.国内官方安装文档:gitlab.cn/install Ubuntu存在apt install的简易安装方式

  • 未对Windows提供安装包

2. 初始化部署(windows&linux)

2.1部署

2.1.1 系统准备

windows系统需要安装ubuntu(Hyper-V资源开销大)

网络不好的请查看:https://learn.microsoft.com/zh-cn/windows/wsl/ 下的"旧版本的手动安装步骤"

网络良好的流程:
        1. 控制面板->程序->启用或关闭Windows功能
            --找到'虚拟机平台'"适用Linux的Windows子系统" 勾选后点击确定并重启电脑
        2. 管理员身份运行Powershell 
            wsl --install   下载安装wsl(从微软下载
            )
            wsl --set-default-version 2   设置默认版本WSL2

            wsl --list --verbose    查看版本
            #   NAME      STATE           VERSION
            # * Ubuntu    Running         2

            wsl 进入ubuntu
        

linux 系统不用管

2.1.2 安装docker

windows

docker官网安装:https://www.docker.com/products/docker-desktop/
1. 下载安装Docker Desktop     Download for Windows(版本) 
2. 无需登录打开docker软件进入setting->General
    确保 Use the WSL 2 based engine 被勾选
3. setting->Resources->WSL integration
    确保你的ubuntu已被集成
4. 配置镜像源 setting -> Docker Engine 下加入:
  "registry-mirrors": [
    "https://docker.xuanyuan.me",
    "https://docker.1ms.run",
    "https://docker.m.daocloud.io",
    "https://docker.unsee.tech",
    "https://docker.1panel.live",
    "https://hub.rat.dev",
    "https://docker.rainbond.cc"
  ]
ps:Docker Desktop 自带docker compose

linux

1.安装 docker (以及 docker compose)
    sudo wget -qo-https://get.docker.com/| sh   安装docker
    docker --version 
    
2.可选:将当前用户添加到docker用户组
    sudo usermod -aG docker <用户名>             后续执行docker命令时,不必添加sudo
    
3. GitLab镜像下载(如果.yaml文件配置了image这条便不需要)
    docker pull gitlab/gitlab-ce:latest         最新版(官方)
    "latest"可以换成指定版本号
    
4. 查看版本
    docker --version
    docker compose --version

2.1.3 docker-compose.yaml 文件编写

请选好自己指定的位置创建logs、data、config文件夹

同目录创建docker-compose.yaml文件:

services: #服务
  gitlab: #容器
    # image: gitlab/gitlab-ce #官方社区版本
    image: registry.gitlab.cn/omnibus/gitlab-ce:latest #极狐版 镜像
    container_name: gitlab #容器名
    user: root
    restart: always
    ports: # 接口映射 宿主机端口:docker内部端口  监听外部端口映射docker内部端口
      - "9443:443"
      - "9080:80"
      - "9022:22"
    volumes: # 数据卷地址
      - "./config:/etc/gitlab"
      - "./logs:/var/log/gitlab"
      - "./data:/var/opt/gitlab"
    shm_size: 1g # 共享内存大小 能给大给大点根据各人电脑性能
    logging: #日志
      driver: json-file
      options:
        max-size: "300m" # 日志存储大小 自定义大小
        max-file: "3"

2.1.4 启动docker

在.yaml文件同级目录下执行:

docker compose up -d (-d后台运行)

docker compose ps 查看容器是否运行成功,状态为healthy则为成功

docker compose down 关闭运行并删除容器

docker compose stop 停止容器,不删除(推荐)

docker compose start 启动容器

docker compose logs -f 查看日志等待初始化

ps:gitlab初始化根据电脑性能可能需要2-10分钟之间

2.1.5 网络访问

初始化成功后访问方式 (.yaml文件监听默认80端口映射 9080)

本地访问:localhost:9080

私网访问:私网ip:9080

公网访问:需要配置

  1. 外网穿透(自行查询)

  2. SSH 隧道转发

    ssh -R 9080:localhost:9080 root@你的云服务器IP

    做好nginx代理配置

  3. 申请公网ip(收费)

2.2 初始化

2.2.1 登录

访问成功之后登录

密码在宿主机上查询

docker exec -it gitlab cat /etc/gitlab/initial_root_password

2.2.2 其他操作

设置时区

汉化程度

仓库头设置(ssh)

ps: 都在web页面里设置

CI/CD自动化部署

1. 注册gitlab-runner

1.1在docker-compose.yaml 配置

#如果是和gitlab写一起那就相当于是 两个容器放一个服务里,这个"services:" 可以不写
services:
  gitlab-runner:
    image: gitlab/gitlab-runner:latest
    # image: registry.gitlab.cn/runner/gitlab-runner:latest
    container_name: gitlab-runner
    restart: always
    volumes:
      - /srv/gitlab-runner/config:/etc/gitlab-runner
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - TZ=Asia/Shanghai  # 设置时区,可选
    logging:
      driver: json-file
      options:
        max-size: "10m" # 日志存储大小
        max-file: "3"

PS:同服务内的容器相互通信端口是docker容器的端口不是外部端口,这点在runner注册时会用到

运行 docker compose up -d

1.1.2 获取gitlab token令牌

确定Runner的作用范围

  • 项目级Runner(最常用):进入你的项目 → 左侧菜单 设置 → CI/CD → 展开“Runners”部分 → 点击 “新建项目Runner”。
  • 群组级Runner:进入群组 → 设置 → CI/CD → Runners → “新建群组Runner”。
  • 实例级Runner(需要管理员权限):点击左侧边栏底部 “管理员” → CI/CD → Runners → “新建实例Runner”。

填写配置并创建

  • 在弹出的窗口中,填写Runner的描述(例如 docker-runner)、标签(例如 docker, build)等信息。
  • 点击 “创建Runner”。

获取身份验证令牌

  • 创建成功后,页面会显示一条命令,其中包含一个以 glrt- 开头的令牌,这就是身份验证令牌(Authentication Token)。请立即保存好这个令牌,因为关闭页面后就无法再查看。

1.1.3 注册runner

  1. 进入已运行的 gitlab-runner 容器

docker exec -it gitlab-runner sh

  1. 执行注册命令

    gitlab-runner register

  2. 录入信息

    提示信息 录入内容 说明
    GitLab URL http://gitlab:80 容器间通信 不是外部端口号9080
    Registration token glrt-开头的 获取的token令牌
    Runner description 例:docker-runner runner描述 名称
    Executor docker 选择Docker执行器

一键式命令:

docker exec -it gitlab-runner gitlab-runner register \
  --non-interactive \
  --url "http://gitlab:80" \
  --token "你的glrt-xxxxx令牌" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --description "docker-runner" \
  --tag-list "docker,build"

看到:

Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded!

Configuration (with the authentication token) was saved in "/etc/gitlab-runner/config.toml"

则为成功注册

2 流水线runner

2.1 创建项目&配置

关于ssh配置,项目上传不做过多赘述

1.2.2 .gitlab-ci.yml文件

stages: # 生命一共几个步骤
- build
- test
- deploy
build: # 这是job名称 可以随便写
  stage: build # 这个对应上面的stages
  tags:
  - myProject # tags指定的runner来做这步工作
  script: # 执行代码
  - echo "代码编译..."
test:
  stage: test
  tags:
  - myProject
  script:
  - echo "测试代码..."
deploy:
  stage: deploy
  tags:
  - myProject
  script:
  - echo "测试部署项目..."
  - echo "测试部署完成..."

  #将当前的index.html推送发布到上线的服务器上(需要做免交互ssh配置:自行查询)
  # - scp index.html root@127.0.0.1:/usr/share/nginx/html/    

ps:更复杂的配置请查看

docs.gitlab.cn/docs/jh/ci/…

Tailwind CSS v4 — 当框架猜不透你的心思

作者 parade岁月
2026年2月27日 17:14

你在项目里写下 text-(--brand-color),满心期待文字变成品牌色,刷新页面——字号变了。

颜色没变,字号倒是歪了。你盯着屏幕,开始怀疑人生。

别急,这不是 bug,是 Tailwind 在"猜"你的意图——而且猜错了。

这篇文章会带你走一遍真实的开发场景。从最基础的任意值用法开始,一步步遇到更复杂的情况,直到你理解 Tailwind 为什么会猜错,以及如何优雅地纠正它。


场景一:设计稿给了个非标准值

设计师甩过来一张稿子,标注写着:top: 117px、背景色 #bada55

你翻了一遍 Tailwind 的间距和颜色系统——没有。top-28112pxtop-32128px,不上不下。

这时候就需要任意值(Arbitrary Values)了。用方括号 [] 把具体的 CSS 值包起来:

<div class="top-[117px]">精确定位</div>

<button class="bg-[#bada55]">这个颜色名字挺快乐</button>

<div class="left-[calc(50%-4rem)]">居中偏移</div>

方括号里可以放任何合法的 CSS 值——像素、百分比、calc() 表达式,甚至 var()。Tailwind 会原封不动地把它编译成对应的 CSS。

CSS 变量怎么写?

如果你的值存在 CSS 变量里,v4 提供了一个更简洁的语法——用圆括号 () 代替方括号:

<!-- v4 新语法:圆括号 + 裸变量名 -->
<div class="bg-(--brand-color)">用 CSS 变量设背景色</div>

<!-- 当然,显式写 var() 依然有效 -->
<div class="bg-[var(--brand-color)]">效果一样</div>

这是 v4 相对 v3 的一个重要变化。v3 里 CSS 变量简写用的是方括号 bg-[--brand-color],v4 改成了圆括号 bg-(--brand-color)。这个改动不是为了好看——而是为了解决歧义问题,后面会详细说。


场景二:Tailwind 没有的 CSS 属性

项目里需要用 mask-type 控制 SVG 遮罩行为。你搜了一圈文档,Tailwind 没有提供这个工具类。

任意属性(Arbitrary Properties)登场。用方括号把完整的 属性:值 对写进去:

<div class="[mask-type:luminance]">
  SVG 遮罩使用亮度模式
</div>

它和修饰符(modifier)配合也没问题:

<div class="[mask-type:luminance] hover:[mask-type:alpha]">
  hover 时切换为 alpha 模式
</div>

用任意属性设置 CSS 变量

这个语法还有一个很实用的场景——在 HTML 里直接设置 CSS 变量的值:

<div class="[--scroll-offset:56px] lg:[--scroll-offset:44px]">
  不同断点下设置不同的滚动偏移量
</div>

配合响应式前缀,你可以把 CSS 变量当作"响应式参数"来用,而不用写额外的媒体查询。


场景三:选择器玩不转了

产品经理说:"列表前三项要加下划线,hover 的时候。"

:nth-child(-n+3):hover —— 这选择器 Tailwind 的内置修饰符肯定不够用。

任意变体(Arbitrary Variants)可以搞定:

<ul>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 1 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 2 项</li>
  <li class="[&:nth-child(-n+3)]:hover:underline">第 3 项</li>
  <li>第 4 项(不受影响)</li>
</ul>

方括号里的 & 代表当前元素。Tailwind 会把 & 替换成生成的类名,编译出你需要的选择器。

再来几个例子:

<!-- 所有子 p 元素加上 margin-top -->
<div class="[&_p]:mt-4">
  <p>我有 margin-top</p>
  <p>我也有</p>
</div>

<!-- 当元素有 .is-dragging 类时 -->
<li class="[&.is-dragging]:cursor-grabbing">
  拖拽中换光标
</li>

<!-- @supports 查询 -->
<div class="flex [@supports(display:grid)]:grid">
  支持 grid 就用 grid,否则用 flex
</div>

v4 变体堆叠顺序变了:从左往右读,和 CSS 选择器一致。v3 是从右往左。


场景四:值里面有空格怎么办?

你在写 Grid 布局,需要 grid-template-columns: 1fr 500px 2fr

直接写 grid-cols-[1fr 500px 2fr]?Tailwind 会把空格当作类名分隔符,直接报错。

解决方案:用下划线代替空格。

<div class="grid grid-cols-[1fr_500px_2fr]">
  <!-- 编译后:grid-template-columns: 1fr 500px 2fr -->
</div>

Tailwind 在编译时会自动把下划线转成空格。

但是 URL 里的下划线怎么办?

放心,Tailwind 足够聪明,会保留 URL 里的下划线:

<div class="bg-[url('/what_a_rush.png')]">
  <!-- 不会被转成空格,保持原样 -->
</div>

真的需要下划线呢?

用反斜杠转义:

<div class="before:content-['hello_world']">
  <!-- 编译后:content: 'hello_world' -->
</div>

JSX 里反斜杠被吃了?

JSX 的字符串会把 `` 当转义字符处理。用 String.raw 模板标签:

<div className={String.raw`before:content-['hello_world']`}>
  在 JSX 中安全地使用下划线
</div>

核心场景:Tailwind 猜错了

好,前面都是热身。现在进入本文的重头戏。

问题复现

回到开头的例子。你在 CSS 里定义了一个品牌色变量:

:root {
  --brand-color: #e63946;
}

然后你写下:

<p class="text-(--brand-color)">品牌色文字</p>

你期望的是文字变成红色。但实际效果是——字号变了,颜色没变。

为什么?

因为 text-* 在 Tailwind 里是一个多义命名空间。它同时映射了两种不同的 CSS 属性:

  • text-lgtext-smfont-size(字号)
  • text-red-500text-blackcolor(颜色)

当你写字面值的时候,Tailwind 能从值本身推断出类型:

<!-- Tailwind 看到 22px,推断为 length → font-size -->
<div class="text-[22px]">这是字号</div>

<!-- Tailwind 看到 #bada55,推断为 color → color -->
<div class="text-[#bada55]">这是颜色</div>

22px 明显是长度,#bada55 明显是颜色——推断没问题。

但 CSS 变量是个黑盒

当你写 text-(--brand-color) 的时候,Tailwind 看不到变量里存的是什么。它不知道 --brand-color 是颜色还是尺寸还是别的什么。

这时候 Tailwind 只能猜。而默认的猜测策略可能不符合你的预期——它可能把变量当成了 font-size 而不是 color

于是你的文字不是变红了,而是字号变成了 var(--brand-color),浏览器无法解析为有效字号,表现就很诡异。

解决方案:CSS 数据类型提示

在圆括号里,变量名前面加上类型提示

<!-- 明确告诉 Tailwind:这是颜色 -->
<p class="text-(color:--brand-color)">品牌色文字 ✓</p>

<!-- 明确告诉 Tailwind:这是字号 -->
<p class="text-(length:--font-size)">自定义字号 ✓</p>

语法格式:工具类-(类型:--变量名)

Tailwind 看到 color: 前缀,就知道应该把这个变量编译成 color 属性而不是 font-size。歧义消除。

方括号里的写法

如果你用 var() 的显式写法,类型提示放在方括号开头:

<p class="text-[color:var(--brand-color)]">同样有效</p>

不止 text-*

text-* 是最经典的歧义案例,但不是唯一一个。以下工具类都存在类似的命名空间冲突:

bg-* — 背景相关

<!-- 背景色 -->
<div class="bg-(color:--my-var)">背景颜色</div>

<!-- 背景图 -->
<div class="bg-(image:--my-var)">背景图片</div>

<!-- 背景位置 -->
<div class="bg-(position:--my-var)">背景位置</div>

bg-* 的歧义更多——它可以是颜色、图片、尺寸、位置,不加类型提示几乎必出问题。

border-* — 边框相关

<!-- 边框颜色 -->
<div class="border-(color:--my-var)">边框颜色</div>

<!-- 边框宽度 -->
<div class="border-(length:--my-var)">边框宽度</div>

shadow-* — 阴影相关

<div class="shadow-(color:--my-var)">阴影颜色</div>

decoration-* — 文本装饰

<!-- 装饰线颜色 -->
<div class="decoration-(color:--my-var)">装饰色</div>

<!-- 装饰线粗细 -->
<div class="decoration-(length:--my-var)">装饰粗细</div>

规律总结:只要一个工具类前缀同时对应多种 CSS 属性(颜色 + 尺寸最常见),用 CSS 变量时就需要类型提示。用字面值(如 #fff2px)时不需要,因为 Tailwind 能自动推断。


可用的类型提示一览

Tailwind v4 支持的 CSS 数据类型提示:

类型关键词 匹配什么 示例值
color CSS 颜色 #fffrgb(...)oklch(...)
length 长度 16px1rem2em
percentage 百分比 50%
number 数值 1.50
integer 整数 14
angle 角度 45deg0.25turn
url URL url(...)
image CSS 图片类型 url(...)linear-gradient(...)
position 位置 centertop left
ratio 比例 16/9
line-width 线宽 边框宽度值
bg-size 背景尺寸 covercontain
family-name 字体族名 字体名称

速查表

把全文涉及的语法整理在一起,方便随时翻阅:

场景 语法 示例
字面任意值 工具类-[值] top-[117px]bg-[#bada55]
CSS 变量简写 工具类-(--变量) bg-(--brand-color)
CSS 变量 + var() 工具类-[var(--变量)] bg-[var(--brand-color)]
类型提示(圆括号) 工具类-(类型:--变量) text-(color:--brand-color)
类型提示(方括号) 工具类-[类型:var(--变量)] text-[color:var(--brand-color)]
任意属性 [属性:值] [mask-type:luminance]
设置 CSS 变量 [--变量:值] [--scroll-offset:56px]
任意变体 [选择器]:工具类 [&:nth-child(3)]:underline
空格用下划线 _ 代替空格 grid-cols-[1fr_500px_2fr]
真正的下划线 _ 转义 content-['hello_world']
JSX 中的转义 String.raw`...` String.raw`content-['a_b']`

手写无限画布4 —— 从视觉图元到元数据对象

作者 光头老石
2026年2月27日 17:13

画布上的每一个像素都是稍纵即逝的,真正永恒的,是背后那套被精心设计的元数据(Metadata)规范。

尽管在前面的篇章中,我们一路披荆斩棘,搞定了坐标系、渲染层和基本交互,让演示工程初具雏形。但 Canvas 本质上只是一块没有记忆的像素面板。

要想从理论走向工程落地,实现支持持久化与多人协同的业务,最核心的架构法则在于:必须将画布上的任意元素,都抽象并定义为可传输、可持久化的元数据对象(Metadata Object)。

元数据定义 (Metadata Definition)

我们要彻底抛弃"直接在画布里 new Konva.Rect()" 的思维惯性。

在一个成熟的白板应用架构中,画布引擎只是一个"读报机器",它读的报纸,就是我们定义的元数据规范(Metadata Schema)

为了达到我们最终建立一个类似 Excalidraw 的既定目标,我们在规范数据结构时,绝不能只停留在纯粹的"几何图形"定义上。我们必须在其之上,附加明确的预制业务概念。我们不仅要描述它是一个 Rect(矩形),更要描述它在业务里是一张 StickyNote(便利贴),还是一根 Connector(连接线)。

如下代码,这就是我们实际落地的元数据规范:

// src/schema/types.ts

// 所有图元共享的基因——它们必须遵守的基础契约
export interface BaseElementData {
  id: string; // 唯一宇宙编号,协同与更新的基石
  type: ElementType; // 业务大类
  x: number;
  y: number;
  width: number;
  height: number;
  hitColor: string; // 上一篇的命中测试色值,也要元数据化
  strokeColor: string;
  backgroundColor: string;
  opacity: number;
  zIndex: number; // 层级控制,决定覆盖关系
  isLocked: boolean; // 业务属性:用户是否锁定了该元素
  // ...
}

// 业务派生:形状、文字、线条各有自己的专属字段
export interface ShapeElementData extends BaseElementData {
  type: "rectangle" | "ellipse" | "diamond";
}

export interface LinearElementData extends BaseElementData {
  type: "arrow" | "line";
  points: number[][]; // 途经的折点
  startArrowhead: "arrow" | "triangle" | "none";
  endArrowhead: "arrow" | "triangle" | "none";
  startBindingId: string | null; // 线头绑定的元素 ID
  endBindingId: string | null;
}

// 终极联合类型:无限画布的唯一真理对象
export type CanvasElementData =
  | ShapeElementData
  | TextElementData
  | LinearElementData;

注意一个关键细节:上一篇讲到的命中测试色值 hitColor,也被我们收编进了元数据定义。从此刻起,一个图形的一切——它在哪、它多大、它长什么样、它怎么被点中——全部由这颗 JSON 树的一个节点来描述。再也没有游离在数据结构之外的"野状态"了。

纯元数据驱动带来的红利

当你把屏幕上所有花里胡哨的图形,都严格浓缩成上述哪怕只有几百 KB 大小的纯 JSON 文本时,奇迹发生了:

  1. 绝对纯净的持久化与协同:现在保存用户作品,不过就是做一次 JSON.stringify。而做多人协同,也不过是当某个 Node 的 x 发生改变时,通过 WebSocket 向房间里的其他人广播一个极小的 Diff 补丁 {"id": "node_1", "x": 250}
  2. 极其廉价的时间机器:撤销(Undo)与重做(Redo)再也不是什么黑科技。因为数据被极度抽象了,你只需要使用类似 Immer.js 等不可变数据结构工具,把每一步操作的 JSON 快照(或者 Delta 片段)保存在数组里,指针前后移动,就是时间倒流。
  3. 彻底的跨端解耦:这套 Metadata 甚至都不知道 Canvas 的存在。你可以把同一团 JSON 丢给 Web 端用 Konva 渲染,扔给 iOS 用 CoreGraphics 渲染,或者丢给后端 Node 帮你无头渲染出一张 PDF。

接入状态管理:Zustand

有了元数据定义,接下来的问题是:这颗 JSON 树放在哪?谁来读它、写它、通知别人它变了?

绝不能让 Konva 本身(View 层)既当爹又当妈地去存储这些业务数据,这会导致视图状态和业务逻辑严重耦合。我们引入现代轻量级状态管理库 zustand 作为单一事实来源(Single Source of Truth),对整个工程做一次严格的分层。

打开 src/store.ts,这是整个工程的心脏:

// src/store.ts

export const canvasStore = createStore<CanvasState>((set) => ({
  // 全部元素的 Record 字典,key 为 id
  elements: initialElements,
  // 应用运行时状态(当前工具、缩放、视口偏移、选中态...)
  appState: defaultAppState,

  // ——— 以下全是纯函数式的 Actions ———
  updateElementProps: (id, props) =>
    set((state) => ({
      elements: {
        ...state.elements,
        [id]: { ...state.elements[id], ...props },
      },
    })),

  addElement: (el) =>
    set((state) => ({
      elements: { ...state.elements, [el.id]: el },
    })),

  selectElement: (id) =>
    set((state) => ({
      appState: { ...state.appState, selectedElementIds: id ? [id] : [] },
    })),
  // ...
}));

值得反复品味的是:无论是创建元素、更新坐标、还是切换选中态,Store 里执行的全部都是浅拷贝替换{ ...state.elements, [id]: ... })。没有任何副作用,没有任何直接 DOM 操作。这意味着前面说的 Undo/Redo "时间机器",你只需要把这些 Immutable 快照存进一个栈里就好了——就是这么廉价。


引擎订阅:一个极致的"哑巴渲染器"

Store 管数据,那谁管画面?答案是 src/engine/index.ts——我们的引擎总控 EngineFacade。它做的事情极其克制:只读数据,只画画面

// src/engine/index.ts — 订阅逻辑

this.unsubscribe = canvasStore.subscribe((state) => {
  // 图元变更 → 重新渲染
  if (state.elements !== prevState.elements) {
    this.shapeRenderer.render(state.elements);
  }
  // 选中态变更 → 同步 Transformer 控制框
  if (state.appState.selectedElementIds !== prevState.appState.selectedElementIds) {
    this.selectionManager.syncSelection(state.appState.selectedElementIds);
  }
  // 视口变更 → 同步 Stage 缩放/平移
  if (state.appState.zoom !== prevState.appState.zoom || ...) {
    this.viewportManager.syncViewport(zoom, scrollX, scrollY);
  }
});

请注意这里的引用相等性比较(!==)。Zustand 的不可变数据范式保证了:只有当数据真正改变时,引用才会不同。所以引擎的每一次重绘都是精确触发的——不多画一帧,不少画一帧。

整个数据流形成了一个干净的单向环路

用户操作 → Store 更新元数据 → Engine 监听到变更 → Konva 重绘画面
                ↑                                      │
                └──────── 用户拖拽,Engine 回写坐标 ────┘

Konva 永远不私自修改任何数据。当用户拖拽一个图形时,Engine 层拦截 Konva 的 dragmove 事件,取得新坐标,然后调用 store.updateElementProps(id, { x, y }) 把新位置"汇报"回 Store。Store 更新后触发订阅回调,Engine 再根据新数据重绘——一切都是单向、可追溯的。

而浮在画布之上的 React UI(工具栏、属性面板)也是同一个 Store 的消费者:

// src/App.tsx — 属性面板(精简)
const PropertiesPanel = () => {
  const selectedIds = useCanvasStore(
    (state) => state.appState.selectedElementIds,
  );
  const elements = useCanvasStore((state) => state.elements);
  const updateElementProp = useCanvasStore((state) => state.updateElementProp);

  const el = elements[selectedIds[0]];
  // 从 store 读数据,渲染颜色选择器、描边样式按钮...
  // 用户点击后,直接调用 updateElementProp() 回写 store
};

我们常说,前端框架 React 的核心公式是 UI = f(State)。 而无限白板的架构真谛就是:Canvas = Konva(Metadata)


回望:四层地基已就位

至此,我们用四篇文章,自底向上地垒完了无限画布系统的四层地基:

层级 解决的核心问题 关键技术
坐标系 "无限"与"缩放"的数学本质 世界坐标 ↔ 屏幕坐标变换
渲染层 高性能绘制大量图形 Konva Scene Graph, 局部重绘
交互层 重建事件感知 离屏 Color Picking, Hit Testing
对象层 让画布拥有序列化的组织 元数据 Schema, Zustand 单向数据流

历经四篇文章的打磨,我们从最底层的数学坐标系起步,最终构筑起这套‘可协同、可撤销、可跨端’的数据驱动画布架构。这段工程演进之路的破局关键,其实就是两个字:克制。清晰划定架构的分层边界,想透每一层该做什么,并坚决杜绝越界。

本系列 实例项目已上传GitHub github.com/Seanshi2025… 项目上有完整的架构组织文档。

React 底层原理 & 新特性

作者 牛奶
2026年2月27日 17:11

React 底层原理 & 新特性

本文深入探讨 React 的底层架构演进、核心原理以及最新版本带来的突破性特性。


原文地址

墨渊书肆/React 底层原理 & 新特性


React 版本变动历史

React 自发布以来经历了多个版本的更新,每个主要版本的变动都带来了新的特性和改进,同时也对旧有的API进行了调整或废弃。以下是React几个重要版本的主要变动概述:

React 15 (2016年)

  • 引入Fiber架构:在 React 15后期版本中引入了 Fiber, 提供了更灵活的渲染调度和更换的错误恢复机制。
  • 改进了服务器端渲染:提升了SSR(Server-Side Rendering)的性能 and 稳定性。
  • SVG和MathML的支持增强:更好地支持SVG和MathML元素,使其渲染更加一致和准确。

React 16 (2017年)

  • 全面实施Fiber:Fiber成为了React核心的更新算法,提供了更细粒度的任务调度和更强大的并发模式,使得React应用的性能和响应性有了显著提升。
  • Error Boundaries:引入了错误边界的概念,允许组件捕获其子组件树中的JavaScript错误,并优雅地降级,而不是让整个应用崩溃。
  • Portals:允许将子节点渲染到DOM树的其他位置,为模态框、弹出层等场景提供了更好的解决方案。
  • 支持返回数组的render方法:可以直接从组件的render方法返回多个元素,而不需要额外的包装元素。

React 17 (2020年)

  • 自动批处理更新:默认开启了自动批处理更新,即使开发者没有手动使用 React.startTransitionunstable_batchedUpdates,React也会尝试批处理状态更新,以减少渲染次数。
  • 事件委托改进:改变了事件处理的方式,将事件监听器绑定到 document 上,减少了委托层级,简化了第三方库的继成。
  • 更严格的 JSX 类型检查:增强了对JSX类型的检查,帮助开发者提前发现潜在的类型错误。
  • 无-breaking-change 版本:React 17被设计为一个过渡版本,尽量减少对现有代码的破坏,为未来更大的更新铺路。

React 18 (2022年)

  • 并发模式:进一步深化了Fiber架构的并发特性,通过新的 SuspenseUseTransition API,允许开发者更好地控制组件的加载和更新策略。
  • 自动 hydration:React 18引入了新的渲染模式,包括 Server ComponentsAutomatic Hydration,旨在减少初次加载时间和提高用户体验。
  • 改进的错误处理:增强了错误边界和错误报告的能力,使得调试和问题定位更加容易。
  • StartTransition API:允许开发者标记某些状态更新为低优先级,从而优化UI的响应性和流畅性。

React 19 新特性深度解析 (2024年)

React 19 是一个重大的里程碑,它将许多在 React 18 中处于 Canary/Experimental 阶段的特性正式稳定化,并引入了全新的开发范式。

1. Actions 与异步状态管理

React 19 引入了 Actions 的概念,用于简化异步操作(如表单提交)及其状态管理。

  • useActionState: 自动处理异步函数的 pending 状态和结果。

    function UpdateName({ name, updateName }) {
      const [error, submitAction, isPending] = useActionState(
        async (previousState, formData) => {
          const error = await updateName(formData.get("name"));
          if (error) return error;
          return null;
        },
        null
      );
    
      return (
        <form action={submitAction}>
          <input type="text" name="name" disabled={isPending} />
          <button type="submit" disabled={isPending}>Update</button>
          {error && <p>{error}</p>}
        </form>
      );
    }
    
  • useFormStatus: 子组件无需通过 Props 即可感知父表单的提交状态。

  • useOptimistic: 极致的乐观更新体验。在请求发出时立即更新 UI,请求失败后自动回滚。

2. Server Actions:打通前后端的“虫洞”

Server Actions 允许你在客户端直接调用服务器上的异步函数,是 React 19 的核心特性之一。

  • 指令: 使用 'use server' 标记函数或整个文件。
  • 全链路流程:
    1. 定义: 在服务端定义异步函数。
    2. 序列化: React 自动处理参数的序列化(支持复杂对象、FormData)。
    3. 传输: 客户端调用时,React 发起一个特殊的 POST 请求,将参数序列化后传输。
    4. 执行: 服务器接收请求,反序列化参数,执行逻辑(如操作数据库)。
    5. 响应: 服务器返回执行结果,React 自动刷新相关的客户端数据(通过 Revalidation 机制)。
  • 核心优势:
    • 安全性: 自动包含 CSRF 防护,防止跨站请求伪造。
    • 简化代码: 无需手动编写 API 路由、处理 fetch 和状态更新逻辑。
    • 渐进增强: 在 JS 未加载完成时,表单提交依然可以通过原生的 form action 工作。

3. use API:统一的资源读取

use 是一个全新的运行时 API,可以在渲染时读取 Promises 或 Context。

  • 条件调用: 不同于普通的 Hooks,use 可以在 iffor 循环中调用。
  • 自动 Suspense: 当 use(promise) 还在等待时,React 会自动挂起当前组件并显示最近的 Suspense 占位符。

4. Hook 进阶:useEffectEvent (React 19.2+)

为了解决 useEffect 依赖项过多的问题,React 19.2 引入了 useEffectEvent

  • 设计初衷: 在 useEffect 中,有些逻辑需要读取最新的 propsstate,但不希望这些值的变化触发 Effect 重新运行。

  • 示例:

    function ChatRoom({ roomId, theme }) {
      // 将“纯逻辑事件”抽离
      const onConnected = useEffectEvent(() => {
        showNotification('已连接!', theme); // 始终能拿到最新的 theme
      });
    
      useEffect(() => {
        const connection = createConnection(roomId);
        connection.on('connected', () => {
          onConnected(); // 调用事件
        });
        connection.connect();
        return () => connection.disconnect();
      }, [roomId]); // ✅ theme 变化不再导致重新连接
    }
    
  • 核心逻辑: useEffectEvent 定义的函数具有“反应性”,但它不是“依赖项”。它能捕获最新的闭包值,却不会触发渲染。


5. React Server Components (RSC) 进阶

RSC 不仅仅是服务端渲染,它是一种新的组件架构。

  • 零包体积: 服务端组件的代码不会下载到浏览器,减少了 JS Bundle 大小。
  • 直接访问数据: 可以直接在组件内写 sql 查询或读取文件系统。
  • 混合模式: 通过 'use client' 指令,开发者可以精确定义客户端交互边界。

6. Web Components 原生支持

React 19 终于完美支持了 Web Components,解决了长期以来的“痛点”。

  • 属性与特性的智能映射:
    • 以前: React 总是将属性作为 Attribute 处理,导致无法传递对象或布尔值给 Web Components。
    • 现在: React 会自动检测自定义元素。如果该元素上定义了对应的 Property(属性),React 会优先使用属性赋值;否则使用 setAttribute
  • 原生事件支持:
    • 以前: 开发者需要通过 ref 手动调用 addEventListener
    • 现在: 可以像原生 DOM 一样直接使用 onMyEvent={handleEvent},React 会自动处理事件委托和解绑。
  • 跨团队协作: 这意味着大型企业可以在同一个页面中混合使用 React 组件和基于 Lit、Stencil 开发的 Web Components,而不会产生任何兼容性壁垒。

7. 开发者体验 (DX) 的全面进化

React 19 移除了许多历史包袱,让 API 变得更加直观。

  • 简化 ref 传递:

    • 以前: 必须使用 forwardRef 才能将 ref 传递给子组件。
    • 现在: ref 现在作为一个普通的 prop 传递。你可以直接在函数组件的参数中解构它:
    function MyInput({ placeholder, ref }) {
      return <input placeholder={placeholder} ref={ref} />;
    }
    
  • 文档元数据 (Metadata) 支持:

    • 开发者现在可以直接在组件中渲染 <title>, <meta>, <link>。React 会自动将它们提升(Hoist)到文档的 <head> 部分,并处理去重。
  • 静态资源加载优化:

    • React 19 引入了资源预加载 API,如 preload, preinit
    • 样式表与脚本: 支持在组件中直接声明样式表,React 会确保在组件渲染前样式已加载完成,避免闪烁(FOUC)。

底层原理深度解析

React 的底层设计旨在解决大规模应用中的 UI 响应速度和开发效率问题。其核心逻辑遵循从 “数据描述 (JSX) -> 内存模型 (Fiber) -> 任务调度 (Scheduler) -> 真实渲染 (Commit)” 的流水线。

1. JSX 的本质:声明式描述 UI

JSX(JavaScript XML)是 JavaScript 的语法扩展,本质是 React.createElement 的语法糖。

  • 源码转换JSX 通过 Babel 编译为 _jsx 调用,生成描述 UI 的普通对象(React Element)。
  • 设计初衷
    • 声明式编程:开发者只需关注 UI 的“最终状态”,而非如何操作 DOM。
    • 跨平台一致性React Element 是纯 JSON 对象,不仅能渲染为 DOM,还能渲染为原生应用(React Native)或 Canvas。

2. Fiber 架构:最小工作单元与增量渲染

Fiber 是 React 16 的核心重构,它将渲染过程从不可中断的“递归”变为了可控制的“迭代”。

  • Fiber 节点源码结构

    function FiberNode(tag, pendingProps, key) {
      // 1. 实例属性
      this.tag = tag;                 // 组件类型(Function, Class, Host...)
      this.stateNode = null;          // 对应真实 DOM 或组件实例
      
      // 2. 树结构属性 (单向链表)
      this.return = null;             // 指向父节点
      this.child = null;              // 指向第一个子节点
      this.sibling = null;            // 指向右侧兄弟节点
      
      // 3. 状态属性
      this.memoizedState = null;      // 存储 Hooks 链表
      this.updateQueue = null;        // 存储更新任务 (UpdateQueue)
      
      // 4. 并发与优先级
      this.alternate = null;          // 双缓存指向 (WIP vs Current)
      this.lanes = NoLanes;           // 当前任务优先级
      this.childLanes = NoLanes;      // 子树优先级
    }
    
  • UpdateQueue 内部结构: 每一个 Fiber 节点都有一个 updateQueue,用于存放状态更新。

    const updateQueue = {
      baseState: fiber.memoizedState,
      firstBaseUpdate: null,          // 基础更新链表头
      lastBaseUpdate: null,           // 基础更新链表尾
      shared: {
        pending: null,                // 待处理的循环链表
      },
      effects: null,                  // 存放副作用的数组
    };
    
  • Effect 链表 (副作用清理)

    Commit 阶段,React 会遍历 Effect 链表来执行 DOM 操作、生命周期或 Hooks 的 cleanup

    const effect = {
      tag: tag,                       // Hook 类型 (HookHasEffect | HookPassive)
      create: create,                 // useEffect 的第一个参数
      destroy: destroy,               // useEffect 的返回值 (cleanup)
      deps: deps,                     // 依赖项
      next: null,                     // 指向下一个 Effect
    };
    
  • 核心优势

    • 可中断性:将巨大的更新拆分为细小的 Fiber 任务,主线程可以在任务间隔处理更高优先级的用户输入。
    • 状态持久化:由于 Fiber 节点存储在内存中,即使渲染中断,之前的状态也能被保留,下次继续。

3. Fiber 树的遍历逻辑:深度优先遍历

React 采用“深度优先遍历”算法来处理 Fiber 树,这是一个典型的“递归”转“迭代”的过程。

  • beginWork 阶段:从上往下。

    • 核心逻辑:根据 React Element 的变化,决定是复用现有 Fiber 还是新建。
    • 任务:计算新的 props、计算新的 state、调用生命周期或 Hooks、打上副作用标记(Flags)。
  • completeWork 阶段:从下往上。

    • 核心逻辑
    function completeWork(current, workInProgress, renderLanes) {
      const newProps = workInProgress.pendingProps;
      switch (workInProgress.tag) {
        case HostComponent: // 真实 DOM 节点
          if (current !== null && workInProgress.stateNode != null) {
            // 更新模式:对比 props,记录差异
            updateHostComponent(current, workInProgress, tag, newProps);
          } else {
            // 创建模式:生成真实 DOM,并插入子节点
            const instance = createInstance(type, newProps, ...);
            appendAllChildren(instance, workInProgress);
            workInProgress.stateNode = instance;
          }
          break;
        // ... 其他类型处理
      }
    }
    
    • 任务
      • 构建离屏 DOM 树:在内存中完成 DOM 节点的创建和属性绑定。
      • 副作用冒泡 (Bubble up):将子树的所有 Flags 收集到父节点,这样 Commit 阶段只需遍历根节点的 Flags 链表。
  • 带来的性能体验: 这种双向遍历确保了 React 可以在中途暂停,并在恢复时准确知道当前处理到的位置。通过“副作用冒泡”,Commit 阶段的执行速度得到了极大的提升。

4. 双缓存 (Double Buffering) 机制

React 在内存中维护两棵 Fiber 树:current 树(屏幕显示)和 workInProgress 树(正在构建)。

  • 设计初衷
    • 避免 UI 破碎:如果直接在 current 树上修改,用户可能会看到渲染到一半的页面。
    • 极致性能:构建完成后,只需切换 FiberRoot 指针即可完成整棵树的更新,这种“内存交换”比逐个修改 DOM 节点快得多。

5. Scheduler 与时间切片

Scheduler 是 React 的心脏,负责任务的全局调度。

  • 时间切片 (Time Slicing):React 默认每 5ms 会让出一次主线程。它通过 MessageChannel 模拟宏任务。
  • 设计初衷:即使在执行极其复杂的渲染任务(如万级列表),页面依然能响应用户的点击和输入,彻底解决了 JavaScript 阻塞主线程导致的“卡死”感。

6. Lanes 优先级模型

React 17 引入了基于 31 位位掩码的 Lanes 模型。

  • 设计优势
    • 多任务并行:相比旧的 ExpirationTimeLanes 可以表示“一组”任务优先级。
    • 任务插队:React 可以准确识别出最高优先级任务,优先处理它,并将正在进行的低优先级任务挂起或废弃。

7. 合成事件 (Synthetic Events) 与批处理 (Batching)

React 并不直接使用浏览器的原生事件,而是实现了一套全平台的合成事件机制。

  • 合成事件原理:
    • 事件委派: React 17+ 将事件绑定在 root 容器上,而不是 document
    • 对象池化: (注:React 17 之后已移除池化,改为直接传递)。
    • 跨平台映射: 将不同浏览器的差异(如 transitionend, animationend)封装为统一的 API。
  • 自动批处理 (Automatic Batching):
    • 原理: React 会将多个状态更新合并为一次渲染。
    • React 18/19 的突破: 以前只有在 React 事件处理函数中才有批处理。现在,无论是在 PromisesetTimeout 还是原生事件中,所有的更新都是自动批处理的。
    • 底层实现: 通过 ExecutionContext(执行上下文)标记。当 React 发现处于“更新流程”中时,它不会立即触发渲染,而是将更新放入 UpdateQueue,等待主任务结束后一次性处理。

8. 协调 (Reconciliation) 过程深度拆解

协调是 React 区分“计算”与“渲染”的核心。

  • 阶段拆分:
    1. Render 阶段 (异步/可中断): 生成 Fiber 树,计算差异。
    2. Commit 阶段 (同步/不可中断):
      • BeforeMutation: 处理 DOM 渲染前的逻辑(如 getSnapshotBeforeUpdate)。
      • Mutation: 真正操作 DOM(增删改)。
      • Layout: 渲染后的逻辑(如 useLayoutEffect)。
  • 事务机制 (Transaction):
    • 虽然 React 源码中没有直接命名为 Transaction 的类,但其更新流程遵循典型的事务模式:performSyncWorkOnRoot 开启事务 -> 执行更新 -> commitRoot 结束事务并清理环境。

并发渲染 (Concurrent Rendering) 深度解析

并发渲染是 React 18+ 的核心能力,它改变了 React 处理更新的基础方式。

1. 传统渲染 vs 并发渲染

  • 传统渲染 (Stack Reconciler):渲染过程是同步且不可中断的。如果一个组件树很大,浏览器会一直忙于计算,无法响应用户操作。
  • 并发渲染:React 可以在渲染过程中暂停。如果用户点击了按钮,React 会暂停当前的渲染,处理点击事件,然后再恢复之前的渲染。

2. 并发特性的核心:Transitions

通过 startTransition,开发者可以告诉 React 哪些更新是“不紧急”的。

  • 应用场景:输入框打字是紧急的,下方的搜索结果列表更新是不紧急的。
  • 底层实现startTransition 会将更新标记为低优先级的 Lane,使得紧急更新(输入)可以打断它。

流式 SSR 与 Suspense 架构

React 18+ 彻底重塑了服务端渲染 (SSR) 的工作流程。

1. 传统的 SSR 瓶颈

在 React 18 之前,SSR 必须经历:

  1. 服务器拉取所有数据
  2. 生成整个 HTML
  3. 客户端下载整个 JS
  4. 整个页面进行 Hydration

任何一个环节慢了,用户都会看到白屏或无法交互。

2. 流式 SSR (Streaming SSR)

React 现在支持通过 renderToPipeableStream 将 HTML 分块发送给浏览器。

  • 结合 Suspense: 页面可以先显示外壳,耗时较长的组件(如评论列表)在服务器端准备好后再“流”向客户端,并自动插入到正确位置。
  • 选择性注水 (Selective Hydration): 用户点击了还没注水的组件时,React 会优先为该组件进行注水,提升了交互的实时性。

隐藏的宝藏:Offscreen (Activity) 模式

React 19 引入了 <Activity> 组件(实验性名称为 Offscreen API),开启了“智能预渲染”的大门。

  • 核心原理: 允许 React 在后台渲染组件树,而不将其挂载到真实的 DOM 上。
  • 运行模式:
    • hidden 模式:
      • DOM 隐藏: 组件的 DOM 节点被隐藏或不创建。
      • Effect 卸载: 所有的 useEffect 会执行 cleanup,避免后台任务占用过多资源。
      • 状态保留: 组件内部的 useStateuseReducer 状态会被完整保留。
      • 低优先级更新: 当 React 处理完所有可见任务后,会利用空闲时间悄悄更新 hidden 的树。
    • visible 模式: 组件瞬间恢复可见,useEffect 重新挂载,UI 立即同步到最新状态。
  • 优势与场景:
    • 瞬间回退 (Back Navigation): 用户点击“返回”按钮时,之前的页面可以瞬间重现,无需重新加载数据。
    • 标签页切换 (Tabs): 预先渲染非活跃的 Tab 页面,切换时零延迟。
    • 列表预加载: 当用户滚动列表时,提前渲染屏幕下方的几个节点。

性能优化进阶:Transition Tracing & Profiler

为了帮助开发者量身定制性能方案,React 19 提供了更强大的追踪工具和底层的调度观察能力。

1. Transition Tracing (过渡追踪)

允许开发者监听特定的“过渡任务”的生命周期。

  • onTransitionStart / onTransitionProgress: 可以精准监控 startTransition 开启的任务。

    import { unstable_useTransitionTracing } from 'react';
    
    function SearchPage() {
      unstable_useTransitionTracing('search-results', {
        onTransitionStart: (startTime) => {
          console.log('搜索开始', startTime);
        },
        onTransitionComplete: (endTime) => {
          console.log('搜索渲染完成', endTime);
        }
      });
      // ...
    }
    
  • 核心价值: 帮助开发者识别哪些复杂的渲染导致了 UI 的延迟,从而决定是否需要拆分组件或优化数据结构。

2. DevTools Profiler 增强

现在的 Profiler 可以清晰展示每个任务所属的 Lane(优先级级别)。

  • 优先级可视化: 开发者可以看到哪些任务是 User Blocking(用户阻塞,高优先级),哪些是 Transition(过渡,低优先级)。
  • 任务插队分析: Profiler 会标注出哪些任务是因为被更高优先级的任务“插队”而暂停的,这对于调试复杂的并发逻辑至关重要。

协调过程与 Diff 算法深度解析

协调是计算“变了什么”的过程,而 Diff 是其中的核心算法。

1. 核心策略:O(n) 复杂度

React 的 Diff 算法基于三个预设限制:

  • 同层比较:只比较同级节点,跨层级移动会被视为删除和重新创建。
  • 类型判断:如果节点类型变了,直接销毁旧树,创建新树。
  • Key 标识:通过 key 属性,开发者可以告知 React 哪些元素在不同渲染中是稳定的。

2. 多节点 Diff 的“两次遍历”

  1. 第一轮遍历:从左往右对比新旧节点。如果 keytype 都匹配,复用;否则跳出。
  2. 第二轮遍历:将剩余的旧节点放入 Map。遍历新节点时,尝试从 Map 中通过 key 寻找可复用的节点,从而高效处理位移。

Hooks 底层:基于链表的状态管理

Hooks 的状态存储在 Fiber 节点的 memoizedState 单向链表中。

1. Hook 对象结构

const hook = {
  memoizedState: null, // 存储 useState 的值、useEffect 的 effect 等
  baseState: null,
  baseQueue: null,
  queue: null,         // 状态更新队列
  next: null,          // 下一个 Hook
};

2. 闭包陷阱的本质

当 Hook 在渲染过程中被调用时,它会读取当前 Fiber 的状态。如果异步操作引用了旧的变量,而组件已经重新渲染,就会产生闭包陷阱。这正是 useEffect 依赖项数组存在的原因。


未来展望:React Compiler (React Forget)

为了进一步提升性能,Meta 正在开发 React Compiler

  • 自动记忆化: 目前开发者需要手动使用 useMemouseCallback。编译器将通过静态分析,自动插入这些优化代码。
  • 性能体验: 彻底告别手动性能优化,让 React 应用在默认情况下就拥有极致的运行效率。

总结:Meta 为什么这样设计?

Meta(原 Facebook)之所以设计这套极其复杂的源码结构,其核心目标只有一个:在保证开发者体验(DX)的同时,提供极致的用户体验(UX)。

  1. 响应性优先:通过 FiberScheduler,确保用户操作永远拥有最高优先级。
  2. 内存换速度:通过“虚拟 DOM”和“双缓存”,用内存中的对象运算来换取昂贵的真实 DOM 操作。
  3. 架构的生命力LanesConcurrent Mode 的引入,让 React 从一个简单的 UI 库进化成了一个能处理复杂调度任务的“前端操作系统”。
  4. 全栈融合:React 19 的 Server ActionsRSC 标志着 React 正在从“UI 库”向“全栈框架”迈进,试图统一前后端的开发模型

VUE3响应式原理——从零解析

2026年2月27日 17:08

基本概念

在开始讲解响应式原理之前,我们需要知道两个基本概念:

什么是副作用函数?

即该函数的执行影响到其他函数的执行结果,则称该函数为副作用函数。例如:

const obj = { text: 'test' };

function effect() {
    obj.text = ‘hello’;
}

effect()执行后,其他使用到obj.text的函数中,读取到的值将是hello,而不是text,产生了副作用,故称effect()为副作用函数。

什么是响应式数据?

即当某个数据发生变化时,所有使用该数据的地方都发生了变化,则称该数据为响应式数据。例如:

const obj = { text: 'test' };

function effect() {
    ducoment.body.innerText = obj.text;
}

effect();
obj.text = 'hello';

obj.text的值设置为hello后,若body显示的内容由test变为hello,则称obj是一个响应式数据。


如何实现响应式?

通过上述基本概念的举例说明可以看出,响应式数据涉及到了数据的读取(get)和设置(set)操作——副作用函数执行时,进行了读取操作;数据值改变时,进行了设置操作,同时副作用函数被执行。

那怎么样才能保证对数据进行设置操作时,副作用函数被执行呢?可以在读取操作时使用一个容器将副作用函数保存起来,在设置操作时取出副作用函数执行,就实现了最简单的响应式。

Snipaste_2026-02-27_14-43-27.png

Snipaste_2026-02-27_14-43-34.png 在ES2015+以后,Proxy可以实现拦截数据的getset操作,并进行一些特殊处理。

// 副作用函数
function effect() {
    document.getElementById("result").innerHTML = obj.text;
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        // 读取时将副作用函数存入容器
        bucket.add(effect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        // 设置后将容器中的副作用函数取出逐一执行
        bucket.forEach((fn) => fn());
        return true;
    },
});

然而,在实际应用过程中,副作用函数名称并不都是effect,可能是其他名称,也可能是一个匿名函数。因此,需要改造一下原有的effect函数,允许其接收一个真正的副作用函数,并存到一个变量中,解决副作用函数名称被硬编码的问题。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
function effect(fn){
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        if (activeEffect) {
            bucket.add(activeEffect);
        }

        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach((fn) => fn());
        return true;
    },
});


如何仅触发特定的副作用函数?

上一节中,已经实现了基本的响应式数据。但如果给obj中原本不存在的属性设置数据后,会发现副作用函数被执行了两次,例如下面这段代码:

effect(() => {
    console.log('执行了副作用函数');
})

function exec() {
    obj.text = 'hello';
    obj.name = '张三';
}

exec();

这和预期不一致——原始数据没有name属性,且副作用函数中未读取该属性,exec()执行到最后一行时,不应触发副作用函数的执行。

通过观察可以发现,objtexteffect呈现一种树状结构: Snipaste_2026-02-26_17-28-35.png

拓展可以得到以下情况: Snipaste_2026-02-27_14-27-09.png

targetkeyeffect是一对多的关系,因此单单使用Set是不满足的,需要调整收集副作用函数的容器的数据结构。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
export function effect(fn) {
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new WeakMap();

// 响应式数据
export const obj = new Proxy(data, {

    get(target, key) {
        if (!activeEffect) {
            return target\[key];
        }

        let depsMap = bucket.get(target);
        if (!depsMap) {
            // 如果不存在,则创建一个新的Map
            bucket.set(target, (depsMap = new Map()));
        }

        let effectsSet = depsMap.get(key);
        if (!effectsSet) {
            // 如果不存在,则创建一个新的Set
            depsMap.set(key, (effectsSet = new Set()));
        }

        effectsSet.add(activeEffect);
        return target[key];
    },

    set(target, key, newVal) {
        target[key] = newVal;

        const depsMap = bucket.get(target);
        // 没有收集到有副作用函数的属性,直接返回
        if (!depsMap) {
            return;
        }

        // 取出与属性绑定的所有副作用函数逐一执行
        const effectsSet = depsMap.get(key);
        effectsSet && effectsSet.forEach((fn) => fn());
        return true;
    },
});

基于Rokid CXR-M SDK的AI饮食健康助手开发实战

作者 小明913
2026年2月27日 17:05

引言:当AR眼镜遇上年夜饭

每年春节,面对满桌的鸡鸭鱼肉、煎炸炖煮,无数人陷入“吃还是不吃”的两难——红烧肉到底多少卡?油炸春卷热量有多高?糖醋鱼会不会让血糖飙升?传统的解决方案要么掏出手机打开App手动搜索,要么靠记忆力估算,不仅繁琐,还常常因为“懒得查”而放弃。

基于Rokid CXR-M SDK,可以开发一款手机端识别分析 + 眼镜端AR显示的协同应用:用户只需用眼镜扫过餐桌,系统即可实时识别菜品并投射热量数据至视野中,让健康管理从“刻意为之”变成“自然发生”。

楼主将完整记录从SDK集成、设备连接、图像采集与识别,到AR投射与语音交互的全过程,希望能为其他开发者提供一份可复用的实操指南。

一、系统架构与核心能力

整体架构设计

本系统采用手机为控制中枢、眼镜为显示终端的端云协同架构。CXR-M SDK作为桥梁,封装了底层通信细节,让我们能专注于业务逻辑。

  • 感知层:Rokid Glasses摄像头实时捕获餐桌画面
  • 分析层:手机端运行AI模型进行菜品识别与热量计算
  • 交互层:眼镜端通过AR叠加显示识别结果,支持语音控制

二、项目初始化与设备连接

2.1 环境搭建

首先在项目中配置Rokid Maven仓库和SDK依赖:

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        // 添加Rokid官方Maven仓库
        maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
    }
}

// app/build.gradle.kts
android {
    defaultConfig {
        minSdk = 28 // 必须 ≥28
    }
}

dependencies {
    implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
    implementation("org.tensorflow:tensorflow-lite:2.9.0") // 端侧AI推理
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") // MVVM架构
}

2.2 权限声明与动态申请

AndroidManifest.xml中声明必要权限:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />

Android 12+需动态申请蓝牙权限:

class PermissionHelper(private val activity: AppCompatActivity) {
    fun requestRequiredPermissions(onGranted: () -> Unit) {
        val permissions = mutableListOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.CAMERA
        )
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            permissions += Manifest.permission.BLUETOOTH_SCAN
            permissions += Manifest.permission.BLUETOOTH_CONNECT
        }

        if (permissions.all { 
            activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED 
        }) {
            onGranted()
        } else {
            activity.requestPermissions(permissions.toTypedArray(), REQUEST_CODE)
        }
    }
}

2.3 双通道设备连接

Rokid CXR-M SDK支持蓝牙+Wi-Fi双通道连接:蓝牙通道用于控制指令传输,Wi-Fi通道用于大容量图像数据传输。

class ConnectionManager(private val context: Context) {
    private val tag = "ConnectionManager"
    private var isBluetoothConnected = false
    private var isWifiConnected = false
    private var deviceAuthenticated = false

    fun initDeviceConnection(device: BluetoothDevice) {
        // 1. 初始化蓝牙连接
        CxrApi.getInstance().initBluetooth(context, device, object : BluetoothStatusCallback {
            override fun onConnectionInfo(socketUuid: String?, macAddress: String?, 
                                          rokidAccount: String?, glassesType: Int) {
                socketUuid?.let { uuid ->
                    macAddress?.let { address ->
                        connectBluetooth(uuid, address)
                    }
                }
            }

            override fun onConnected() {
                Log.d(tag, "蓝牙连接成功")
                isBluetoothConnected = true
                initWifiConnection()
            }

            override fun onDisconnected() {
                Log.w(tag, "蓝牙连接断开")
                isBluetoothConnected = false
                deviceAuthenticated = false
                // 尝试重连
            }

            override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
                Log.e(tag, "蓝牙连接失败: ${errorCode?.name}")
            }
        })
    }

    private fun connectBluetooth(uuid: String, mac: String) {
        CxrApi.getInstance().connectBluetooth(context, uuid, mac, bluetoothCallback)
    }

    // 2. 初始化Wi-Fi P2P
    private fun initWifiConnection() {
        if (!isBluetoothConnected) return
        
        val status = CxrApi.getInstance().initWifiP2P(object : WifiP2PStatusCallback {
            override fun onConnected() {
                isWifiConnected = true
                Log.d(tag, "Wi-Fi P2P连接成功")
                // 等待设备认证
            }

            override fun onDisconnected() {
                isWifiConnected = false
                Log.e(tag, "Wi-Fi P2P断开")
            }

            override fun onFailed(errorCode: ValueUtil.CxrWifiErrorCode?) {
                Log.e(tag, "Wi-Fi P2P连接失败: ${errorCode?.name}")
            }
        })
    }

    // 3. 关键:等待设备认证完成
    fun setAuthenticationListener() {
        CxrApi.getInstance().setDeviceAuthListener { token ->
            if (token.isNotEmpty()) {
                deviceAuthenticated = true
                Log.d(tag, "设备认证成功,token: $token")
                // 此时才可进行UI渲染等操作
                onDeviceReady()
            }
        }
    }
}

⚠️ 关键经验onConnected()只代表物理链路通了,但设备认证信息尚未同步。必须等待onDeviceAuthenticated()回调后,才能安全调用openCustomView()等UI接口。

三、核心功能实现

3.1 AI场景触发与图像采集

利用眼镜侧面的AI按键,用户只需长按即可唤醒识别功能:

class FoodRecognitionManager {
    private val tag = "FoodRecognitionManager"
    
    // 设置AI按键监听
    fun setupAiKeyListener() {
        CxrApi.getInstance().setAiEventListener(object : AiEventListener {
            override fun onAiKeyDown() {
                Log.d(tag, "AI按键长按,启动识别流程")
                captureAndRecognize()
            }
            
            override fun onAiKeyUp() {
                // 短按可预留其他功能
            }
            
            override fun onAiKeyCancel() {
                Log.d(tag, "AI按键取消")
            }
        })
    }
    
    // 采集图像并识别
    private fun captureAndRecognize() {
        // 1. 打开相机预览(如果未打开)
        CxrApi.getInstance().openGlassCamera(
            width = 1920,
            height = 1080,
            quality = 85,
            object : CameraStatusCallback {
                override fun onCameraOpened(status: ValueUtil.CxrStatus?) {
                    if (status == ValueUtil.CxrStatus.RESPONSE_SUCCEED) {
                        Log.d(tag, "相机已开启")
                        takePhoto()
                    } else {
                        Log.e(tag, "相机开启失败: ${status?.name}")
                    }
                }
            }
        )
    }
    
    // 2. 拍照获取高清图像
    private fun takePhoto() {
        CxrApi.getInstance().takeGlassPhoto(object : PhotoResultCallback {
            override fun onPhotoTaken(data: ByteArray?) {
                data?.let { imageData ->
                    Log.d(tag, "拍照成功,图片大小: ${imageData.size} bytes")
                    // 发送到AI识别服务
                    recognizeFood(imageData)
                } ?: run {
                    Log.e(tag, "拍照失败: data为空")
                }
            }
            
            override fun onFailed(errorCode: ValueUtil.CxrCameraErrorCode?) {
                Log.e(tag, "拍照失败: ${errorCode?.name}")
            }
        })
    }
}

3.2 手机端AI菜品识别

拍照获得的图像数据通过ByteArray形式返回,我们可以在手机端运行TensorFlow Lite模型进行识别:

class FoodRecognitionService {
    private var tflite: Interpreter? = null
    private val foodDatabase = mapOf(
        "hongshao_rou" to FoodInfo("红烧肉", 480, "高脂肪", "#FF4444"),
        "qingzheng_yu" to FoodInfo("清蒸鱼", 120, "优质蛋白", "#44FF44"),
        "chun_juan" to FoodInfo("春卷", 350, "油炸食品", "#FFAA00"),
        "liangban_huanggua" to FoodInfo("凉拌黄瓜", 45, "低卡推荐", "#44FF44"),
        "niangao" to FoodInfo("年糕", 210, "主食", "#FFAA00")
    )
    
    data class FoodInfo(
        val name: String,
        val calories: Int,      // 千卡/100g
        val tag: String,
        val color: String       // 用于AR显示的颜色
    )
    
    fun recognizeFood(imageData: ByteArray, callback: (FoodInfo?) -> Unit) {
        // 1. 图像预处理
        val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
        val resized = Bitmap.createScaledBitmap(bitmap, 224, 224, true)
        
        // 2. 转换为模型输入格式
        val input = convertBitmapToByteBuffer(resized)
        
        // 3. 执行推理
        val output = Array(1) { FloatArray(5) } // 假设5个分类
        tflite?.run(input, output)
        
        // 4. 解析结果
        val maxIndex = output[0].indices.maxByOrNull { output[0][it] } ?: 0
        val confidence = output[0][maxIndex]
        
        if (confidence > 0.7) {
            val foodKey = when (maxIndex) {
                0 -> "hongshao_rou"
                1 -> "qingzheng_yu"
                2 -> "chun_juan"
                3 -> "liangban_huanggua"
                4 -> "niangao"
                else -> null
            }
            callback(foodDatabase[foodKey])
        } else {
            callback(null) // 识别失败
        }
    }
    
    private fun convertBitmapToByteBuffer(bitmap: Bitmap): ByteBuffer {
        // TensorFlow Lite输入格式转换
        // 具体实现略
    }
}

3.3 AR界面渲染:JSON动态构建

识别完成后,需要在眼镜端显示热量信息。Rokid CXR-M SDK支持通过JSON动态构建自定义界面。

⚠️ 重要限制:Rokid Glasses的光学显示模组对特定波长敏感,所有显示元素必须使用绿色通道(#00FF00) 才能被用户看到。

class ARDisplayManager {
    
    // 构建热量显示界面
    fun showCalorieInfo(foodInfo: FoodRecognitionService.FoodInfo) {
        // 根据热量等级决定提示颜色(但最终只能渲染绿色,颜色用于语义区分)
        val indicatorColor = when (foodInfo.color) {
            "#FF4444" -> "#00FF00" // 警告(高热量)→ 亮绿闪烁
            "#FFAA00" -> "#00AA00" // 中等 → 中绿
            "#44FF44" -> "#008800" // 低卡 → 暗绿
            else -> "#00FF00"
        }
        
        val caloriesJson = """
        {
          "type": "ConstraintLayout",
          "props": {
            "layout_width": "match_parent",
            "layout_height": "match_parent",
            "backgroundColor": "#00000000"
          },
          "children": [
            {
              "type": "LinearLayout",
              "props": {
                "id": "card_background",
                "layout_width": "wrap_content",
                "layout_height": "wrap_content",
                "orientation": "vertical",
                "gravity": "center",
                "backgroundColor": "#BB000000",
                "padding": "16dp",
                "layout_alignParentBottom": "true",
                "layout_centerHorizontal": "true",
                "marginBottom": "40dp"
              },
              "children": [
                {
                  "type": "TextView",
                  "props": {
                    "id": "food_name",
                    "layout_width": "wrap_content",
                    "layout_height": "wrap_content",
                    "text": "${foodInfo.name}",
                    "textSize": "22sp",
                    "textColor": "$indicatorColor",
                    "textStyle": "bold",
                    "gravity": "center"
                  }
                },
                {
                  "type": "TextView",
                  "props": {
                    "id": "calorie_info",
                    "layout_width": "wrap_content",
                    "layout_height": "wrap_content",
                    "text": "${foodInfo.calories} kcal/100g",
                    "textSize": "28sp",
                    "textColor": "$indicatorColor",
                    "textStyle": "bold",
                    "marginTop": "4dp",
                    "gravity": "center"
                  }
                },
                {
                  "type": "TextView",
                  "props": {
                    "id": "food_tag",
                    "layout_width": "wrap_content",
                    "layout_height": "wrap_content",
                    "text": "${foodInfo.tag}",
                    "textSize": "16sp",
                    "textColor": "#AAAAAA",
                    "marginTop": "8dp",
                    "gravity": "center"
                  }
                }
              ]
            }
          ]
        }
        """.trimIndent()
        
        // 打开自定义界面
        val status = CxrApi.getInstance().openCustomView(caloriesJson)
        if (status != ValueUtil.CxrStatus.REQUEST_SUCCEED) {
            Log.e("ARDisplay", "打开自定义界面失败: $status")
        }
    }
    
    // 更新已有界面(用于连续识别)
    fun updateCalorieInfo(foodInfo: FoodRecognitionService.FoodInfo) {
        val updateJson = """
        [
          {
            "action": "update",
            "id": "food_name",
            "props": {
              "text": "${foodInfo.name}"
            }
          },
          {
            "action": "update",
            "id": "calorie_info",
            "props": {
              "text": "${foodInfo.calories} kcal/100g"
            }
          },
          {
            "action": "update",
            "id": "food_tag",
            "props": {
              "text": "${foodInfo.tag}"
            }
          }
        ]
        """.trimIndent()
        
        CxrApi.getInstance().updateCustomView(updateJson)
    }
    
    // 关闭界面
    fun hideCalorieInfo() {
        CxrApi.getInstance().closeCustomView()
    }
}

四、总结与展望

6.1 技术亮点回顾

本文基于Rokid CXR-M SDK,完整实现了春节饮食助手的核心功能:

  1. 端云协同架构:手机端负责AI计算,眼镜端专注AR显示,兼顾性能与功耗
  2. 双通道通信:蓝牙传指令、Wi-Fi传图像,保障流畅体验
  3. JSON动态UI:通过绿色通道渲染,实现轻量级AR界面

6.2 未来可扩展性

基于同样的技术架构,可以扩展更多场景:

  • 糖尿病管理:识别菜品并显示碳水化合物含量
  • 健身指导:根据用户目标推荐蛋白质摄入量
  • 饮食日志:自动记录每餐摄入,生成周报
  • 社交分享:将营养报告分享给家人或营养师

当科技真正“隐于无形”,服务于生活时,它便拥有了最温暖的意义。这个春节,让Rokid眼镜成为你的“热量透视镜”,吃明白,过个健康年。

❌
❌