普通视图

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

cat Cheatsheet

Basic Syntax

Core cat command forms.

Command Description
cat FILE Print a file to standard output
cat FILE1 FILE2 Print multiple files in sequence
cat Read from standard input until EOF
cat -n FILE Show all lines with line numbers
cat -b FILE Number only non-empty lines

View File Content

Common read-only usage patterns.

Command Description
cat /etc/os-release Print distro information file
cat file.txt Show full file content
cat file1.txt file2.txt Show both files in one stream
cat -A file.txt Show tabs/end-of-line/non-printing chars
cat -s file.txt Squeeze repeated blank lines

Line Numbering and Visibility

Inspect structure and hidden characters.

Command Description
cat -n file.txt Number all lines
cat -b file.txt Number only non-blank lines
cat -E file.txt Show $ at end of each line
cat -T file.txt Show tab characters as ^I
cat -v file.txt Show non-printing characters

Combine Files

Create merged outputs from multiple files.

Command Description
cat part1 part2 > merged.txt Merge files into a new file
cat header.txt body.txt footer.txt > report.txt Build one file from sections
cat a.txt b.txt c.txt > combined.txt Join several text files
cat file.txt >> archive.txt Append one file to another
cat *.log > all-logs.txt Merge matching files (shell glob)

Create and Append Text

Use cat with redirection and here-docs.

Command Description
cat > notes.txt Create/overwrite a file from terminal input
cat >> notes.txt Append terminal input to a file
cat <<'EOF' > config.conf Write multiline text safely with a here-doc
cat <<'EOF' >> config.conf Append multiline text using a here-doc
cat > script.sh <<'EOF' Create a script file from inline content

Pipelines and Common Combos

Practical command combinations with other tools.

Command Description
`cat access.log grep 500`
`cat file.txt wc -l`
`cat file.txt head -n 20`
`cat file.txt tail -n 20`
`cat file.txt tee copy.txt >/dev/null`

Troubleshooting

Quick checks for common cat usage issues.

Issue Check
Permission denied Check ownership and file mode with ls -l; run with correct user or sudo if required
Output is too long Pipe to less (`cat file
Unexpected binary output Verify file type with file filename before printing
File was overwritten accidentally Use >> to append, not >; enable shell noclobber if needed
Hidden characters break scripts Inspect with cat -A or cat -vET

Related Guides

Use these guides for full file-view and text-processing workflows.

Guide Description
How to Use the cat Command in Linux Full cat command guide
head Command in Linux Show the first lines of files
tail Command in Linux Follow and inspect recent lines
tee Command in Linux Write output to file and terminal
wc Command in Linux Count lines, words, and bytes
Create a File in Linux File creation methods
Bash Append to File Safe append patterns

每日一题-连接连续二进制数字🟡

2026年2月28日 00:00

给你一个整数 n ,请你将 1 到 n 的二进制表示连接起来,并返回连接结果对应的 十进制 数字对 109 + 7 取余的结果。

 

示例 1:

输入:n = 1
输出:1
解释:二进制的 "1" 对应着十进制的 1 。

示例 2:

输入:n = 3
输出:27
解释:二进制下,1,2 和 3 分别对应 "1" ,"10" 和 "11" 。
将它们依次连接,我们得到 "11011" ,对应着十进制的 27 。

示例 3:

输入:n = 12
输出:505379714
解释:连接结果为 "1101110010111011110001001101010111100" 。
对应的十进制数字为 118505380540 。
对 109 + 7 取余后,结果为 505379714 。

 

提示:

  • 1 <= n <= 105

sort Command in Linux: Sort Lines of Text

sort is a command-line utility that sorts lines of text from files or standard input and writes the result to standard output. By default, sort arranges lines alphabetically, but it supports numeric sorting, reverse order, sorting by specific fields, and more.

This guide explains how to use the sort command with practical examples.

sort Command Syntax

The syntax for the sort command is as follows:

txt
sort [OPTIONS] [FILE]...

If no file is specified, sort reads from standard input. When multiple files are given, their contents are merged and sorted together.

Sorting Lines Alphabetically

By default, sort sorts lines in ascending alphabetical order. To sort a file, pass the file name as an argument:

Terminal
sort file.txt

For example, given a file with the following content:

file.txttxt
banana
apple
cherry
date

Running sort produces:

output
apple
banana
cherry
date

The original file is not modified. sort writes the sorted output to standard output. To save the result to a file, use output redirection:

Terminal
sort file.txt > sorted.txt

Sorting in Reverse Order

To sort in descending order, use the -r (--reverse) option:

Terminal
sort -r file.txt
output
date
cherry
banana
apple

Sorting Numerically

Alphabetical sorting does not work correctly for numbers. For example, 10 would sort before 2 because 1 comes before 2 alphabetically. Use the -n (--numeric-sort) option to sort numerically:

Terminal
sort -n numbers.txt

Given the file:

numbers.txttxt
10
2
30
5

The output is:

output
2
5
10
30

To sort numerically in reverse (largest first), combine -n and -r:

Terminal
sort -nr numbers.txt
output
30
10
5
2

Sorting by a Specific Column

By default, sort uses the entire line as the sort key. To sort by a specific field (column), use the -k (--key) option followed by the field number.

Fields are separated by whitespace by default. For example, to sort the following file by the second column (file size):

files.txttxt
report.pdf 2500
notes.txt 80
archive.tar 15000
readme.md 200
Terminal
sort -k2 -n files.txt
output
notes.txt 80
readme.md 200
report.pdf 2500
archive.tar 15000

To use a different field separator, specify it with the -t option. For example, to sort /etc/passwd by the third field (UID), using : as the separator:

Terminal
sort -t: -k3 -n /etc/passwd

Removing Duplicate Lines

To output only unique lines (removing adjacent duplicates after sorting), use the -u (--unique) option:

Terminal
sort -u file.txt

Given a file with repeated entries:

file.txttxt
apple
banana
apple
cherry
banana
output
apple
banana
cherry

This is equivalent to running sort file.txt | uniq.

Ignoring Case

By default, sort is case-sensitive. Uppercase letters sort before lowercase. To sort case-insensitively, use the -f (--ignore-case) option:

Terminal
sort -f file.txt

Checking if a File Is Already Sorted

To verify that a file is already sorted, use the -c (--check) option. It exits silently if the file is sorted, or prints an error and exits with a non-zero status if it is not:

Terminal
sort -c file.txt
output
sort: file.txt:2: disorder: banana

This is useful in scripts to validate input before processing.

Sorting Human-Readable Sizes

When working with output from commands like du, sizes are expressed in human-readable form such as 1K, 2M, or 3G. Standard numeric sort does not handle these correctly. Use the -h (--human-numeric-sort) option:

Terminal
du -sh /var/* | sort -h
output
4.0K /var/backups
16K /var/cache
1.2M /var/log
3.4G /var/lib

Combining sort with Other Commands

sort is commonly used in pipelines with other commands.

To count and rank the most frequent words in a file, combine grep , sort, and uniq:

Terminal
grep -Eo '[[:alnum:]_]+' file.txt | sort | uniq -c | sort -rn

To display the ten largest files in a directory, combine sort with head :

Terminal
du -sh /var/* | sort -rh | head -10

To extract and sort a specific field from a CSV using cut :

Terminal
cut -d',' -f2 data.csv | sort

Quick Reference

Option Description
sort file.txt Sort alphabetically (ascending)
sort -r file.txt Sort in reverse order
sort -n file.txt Sort numerically
sort -nr file.txt Sort numerically, largest first
sort -k2 file.txt Sort by the second field
sort -t: -k3 file.txt Sort by field 3 using : as separator
sort -u file.txt Sort and remove duplicate lines
sort -f file.txt Sort case-insensitively
sort -h file.txt Sort human-readable sizes (1K, 2M, 3G)
sort -c file.txt Check if file is already sorted

Troubleshooting

Numbers sort in wrong order
You are using alphabetical sort on numeric data. Add the -n option to sort numerically: sort -n file.txt.

Human-readable sizes sort incorrectly
Sizes like 1K, 2M, and 3G require -h (--human-numeric-sort). Standard -n only handles plain integers.

Uppercase entries appear before lowercase
sort is case-sensitive by default. Use -f to sort case-insensitively, or use LC_ALL=C sort to enforce byte-order sorting.

Output looks correct on screen but differs from file
Output redirection with > truncates the file before reading. Never redirect to the same file you are sorting: sort file.txt > file.txt will empty the file. Use a temporary file or the sponge utility from moreutils.

FAQ

Does sort modify the original file?
No. sort writes to standard output and never modifies the input file. Use sort file.txt > sorted.txt or sort -o file.txt file.txt to save the output.

How do I sort a file and save it in place?
Use the -o option: sort -o file.txt file.txt. Unlike > file.txt, the -o option is safe to use with the same input and output file.

How do I sort by the last column?
Use a negative field number with the -k option: sort -k-1 sorts by the last field. Alternatively, use awk to reorder columns before sorting.

What is the difference between sort -u and sort | uniq?
sort -u is slightly more efficient as it removes duplicates during sorting. sort | uniq is more flexible because uniq supports options like -c (count occurrences) and -d (show only duplicates).

How do I sort lines randomly?
Use sort -R (random sort) or the shuf command: shuf file.txt. Both produce a random permutation of the input lines.

Conclusion

The sort command is a versatile tool for ordering lines of text in files or command output. It is most powerful when combined with other commands like grep , cut , and head in pipelines.

If you have any questions, feel free to leave a comment below.

Java Beat 100%

作者 civitas
2020年12月6日 13:28

原理:

  1. 使用位运算思想
  2. 假设n=3
    • n=1 二进制为1
    • n=2 二进制为10
    • n=3 二进制为11

假设res为最终结果值,我们一般会想到先将1-n的二进制字符串都求出来然后拼接,再转为十进制,比较暴力。

但其实有更简单的算法,因为,拼接的结果无非是res二进制向左移 $x_i$ 位得到的值与所拼接二进制字符串值的和。
因此,事实上,$x_i$ 的值便是求解的关键。

容易看出,$x_i$ 即为值i表示的二进制字符串的位数,如何求得二进制字符串的位数呢?
类比于十进制,10的整数次幂所表示的值的10进制位数刚好差距为1,如$10^0$,$10^1$,$10^2$,$10^3$
类似的,$2^0$,$2^1$,$2^2$,$2^3$所表示的二进制的位数也刚好差距为1
我们可以利用这一点来求解。
如以n=3为例:
n=1时,二进制为1,res向左移动1位,与1相加,res值为1;
n=2时,二进制为10,res向左移动2位,与2相加,res值为6;
n=3时,二进制为11,res向左移动2位,与3相加,res值为27.

因此,我们只需要判断当前n值是否为为2的幂,如果是,位数偏移在之前的基础上加1,否则位数偏移不变
判断n值是否为2的幂方法有很多,在这里我采用了一种比较简单的方法i & (i-1)是否等于0,如果i是2的幂,说明仅某一位为1,其余均为0,那么i-1即为其余位均为1,自然与运算为0。如果难以理解可以想象99和100的关系。

下面是全部代码:

###java

public class Solution {
    private static final int MOD = 1000000007;

    public int concatenatedBinary(int n) {
        int res = 0, shift = 0;
        for (int i = 1; i <= n; i++) {
            if ((i & (i - 1)) == 0) {
                // 说明是2的幂,则进位
                shift++;
            }
            res = (int) ((((long) res << shift) + i) % MOD);
        }
        return res;
    }
}

Golang 简洁写法

作者 endlesscheng
2020年12月6日 12:32

用位运算模拟这个过程:每拼接一个数 $i$,就把之前拼接过的数左移 $i$ 的二进制长度,然后加上 $i$。

由于左移后空出的位置全为 $0$,加法运算也可以写成或运算。

###go

func concatenatedBinary(n int) (ans int) {
    for i := 1; i <= n; i++ {
        ans = (ans<<bits.Len(uint(i)) | i) % (1e9 + 7)
    }
    return
}

连接连续二进制数字

作者 zerotrac2
2020年12月6日 12:16

方法一:模拟

思路与算法

由于我们需要将「十进制转换成二进制」「进行运算」「将结果转换回十进制」这三个步骤,因此我们不妨直接将整个问题在十进制的角度下进行考虑。

假设我们当前处理到了数字 $i$,并且前面 $[1, i-1]$ 的二进制连接起来对应的十进制数为 $x$,那么我们如何将数字 $i$ 进行连接呢?

观察二进制连接的过程,我们可以将这一步运算抽象为两个步骤:

  • 第一步会将之前 $[1, i-1]$ 的二进制数左移若干位,这个位数就是 $i$ 的二进制表示的位数;

  • 第二步将 $i$ 通过加法运算与左移的结果进行相加。

将上面所有的运算转换为 $10$ 进制,我们就可以得到 $x$ 的递推式,即:

$$
x = x \cdot 2^{\textit{len}_2(i)} + i
$$

其中 $\textit{len}_2(i)$ 就表示 $i$ 的二进制表示的位数,它可以通过很多语言自带的 API 很方便地计算出来。但我们还可以想一想如何通过简单的位运算得到 $\textit{len}_2(i)$。

我们可以这样想:由于 $len_2(i-1)$ 和 $len_2(i)$ 要么相等,要么相差 $1$(在二进制数发生进位时),因此我们可以使用递推的方法,在枚举 $i$ 进行上述运算的过程中,同时计算 $\textit{len}_2(i)$。如果 $len_2(i-1)$ 和 $len_2(i)$ 相差 $1$,那么说明 $i$ 恰好是 $2$ 的整数次幂,问题就变成了如何判断 $i$ 是不是 $2$ 的整数次幂,这就有两种常用的方法了:

  • 第一种是找出一个比任何数都大的 $2$ 的整数次幂,比如本题中由于 $n \leq 10^5$,因此我们可以使用 $2^{17}=131072$,那么只要 $131072 ~%~ i = 0$,那么 $i$ 就是 $2$ 的整数次幂。

  • 第二种是使用位运算,由于 $2$ 的整数次幂的二进制表示形如 $(1)_2$ 或者 $(10\cdots0)_2$ 的形式,将其减去 $1$ 是形如 $(0)_2$ 或者 $(01\cdots1)_2$ 的形式,恰好就是将减去 $1$ 之前的二进制表示翻转之后的结果,因此如果 $i ~&~ (i-1) = 0$,即 $i$ 和 $i-1$ 的二进制表示中没有某一位均为 $1$,那么 $i$ 就是 $2$ 的整数次幂。

通过上面的方法,我们就可以 $O(1)$ 地计算出 $\textit{len}_2(i)$ 了。

代码

###C++

class Solution {
private:
    static constexpr int mod = 1000000007;
    
public:
    int concatenatedBinary(int n) {
        // 
        int ans = 0;
        int shift = 0;
        for (int i = 1; i <= n; ++i) {
            // if (131072 % i == 0) {
            if (!(i & (i - 1))) {
                ++shift;
            }
            ans = ((static_cast<long long>(ans) << shift) + i) % mod;
        }
        return ans;
    }
};

###Python

class Solution:
    def concatenatedBinary(self, n: int) -> int:
        mod = 10**9 + 7
        # ans 表示答案,shift 表示 len_{2}(i)
        ans = shift = 0
        for i in range(1, n + 1):
            # if 131072 % i == 0:
            if (i & (i - 1)) == 0:
                shift += 1
            ans = ((ans << shift) + i) % mod
        return ans

复杂度分析

  • 时间复杂度:$O(n)$。

  • 空间复杂度:$O(1)$。

方法二:数学

前言

看不懂也没关系。

思路与算法

设 $[a-t, a]$ 的二进制表示的位数均为 $k$,那么这一部分的和就为:

$$
S = a + (a - 1) 2^k + (a - 2) 2^{2k} + \cdots + (a-t) 2^{tk}
$$

使用高中数学的数列差分求和知识可以解得:

$$
S = \cfrac{\cfrac{2^k(2^{tk}-1)}{2^k-1} + (a-t) 2^{(t+1)k} - a}{2^k - 1}
$$

由于 $k$ 比较小而 $a,t$ 比较大,因此我们可以用快速幂优化计算。令:

$$
\begin{cases}
u = 2^{tk} \
v = (2^k-1)^{-1} \
w = 2^{(t+1)k} \
\end{cases}
$$

其中 $t^{-1}$ 表示在 $t$ 在模 $10^9+7$ 意义下的乘法逆元。带入得:

$$
S = \left(2^k(u-1)v + (a-t)w - a\right)v
$$

在计算 $S$ 的同时需要维护后缀 $0$ 的个数。

代码

###Python

class Solution:
    def concatenatedBinary(self, n: int) -> int:
        mod = 10**9 + 7
        zeroes = 0
        ans = 0
        for k in range(64, 1, -1):   # 任意 64 位无符号整数都可以秒出答案
            if (lb := 2 ** (k - 1)) <= n:
                t = n - lb
                u = pow(2, t * k, mod)
                v = pow(2 ** k - 1, mod - 2, mod)
                w = pow(2, (t + 1) * k, mod)
                x = pow(2, zeroes, mod)
                ans += (2 ** k * (u - 1) * v + (n - t) * w - n) * v * x % mod
                zeroes += (t + 1) * k
                n = lb - 1
        
        ans += pow(2, zeroes, mod)
        return ans % mod

复杂度分析

  • 时间复杂度:$O(\log^2 n)$。

  • 空间复杂度:$O(1)$。

昨天 — 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),我们不仅解耦了代码,更重要的是确立了一套开发规范。未来的微服务或新功能模块,都可以基于此框架快速构建,真正实现了"提质增效"。

❌
❌