普通视图

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

每日一题-旋转盒子🟡

2026年5月6日 00:00

给你一个 m x n 的字符矩阵 boxGrid ,它表示一个箱子的侧视图。箱子的每一个格子可能为:

  • '#' 表示石头
  • '*' 表示固定的障碍物
  • '.' 表示空位置

这个箱子被 顺时针旋转 90 度 ,由于重力原因,部分石头的位置会发生改变。每个石头会垂直掉落,直到它遇到障碍物,另一个石头或者箱子的底部。重力 不会 影响障碍物的位置,同时箱子旋转不会产生惯性 ,也就是说石头的水平位置不会发生改变。

题目保证初始时 boxGrid 中的石头要么在一个障碍物上,要么在另一个石头上,要么在箱子的底部。

请你返回一个 n x m 的矩阵,表示按照上述旋转后,箱子内的结果。

 

示例 1:

输入:box = [["#",".","#"]]
输出:[["."],
      ["#"],
      ["#"]]

示例 2:

输入:box = [["#",".","*","."],
            ["#","#","*","."]]
输出:[["#","."],
      ["#","#"],
      ["*","*"],
      [".","."]]

示例 3:

输入:box = [["#","#","*",".","*","."],
            ["#","#","#","*",".","."],
            ["#","#","#",".","#","."]]
输出:[[".","#","#"],
      [".","#","#"],
      ["#","#","*"],
      ["#","*","."],
      ["#",".","*"],
      ["#",".","."]]

 

提示:

  • m == boxGrid.length
  • n == boxGrid[i].length
  • 1 <= m, n <= 500
  • boxGrid[i][j] 只可能是 '#' ,'*' 或者 '.' 。

1861. 遍历一行填充一列

作者 sui-xin-yuan
2022年4月20日 16:58

解题思路

我不明白为什么官方题解写的那么复杂,旋转前第 $i$ 行对应旋转后 $m-1-i$ 列,然后我们用一个 $idx$ 指针遍历旋转前第 $i$ 行的每一个元素,用一个变量 $stones$ 统计每段石头的数量,遇到障碍或结尾就开始把结果填充到 $m-1-i$ 列对应的位置 $[idx-stones,:idx)$,当然还要记得设置 $ans[idx][m-1-i]$ 为障碍物,然后循环执行上述操作直到这行遍历结束。
图片解说如下:
LC1861.png

代码

###C++

class Solution {
public:
    vector<vector<char>> rotateTheBox(vector<vector<char>>& box) {
        const int m = (int)box.size(), n = (int)box[0].size();
        vector<vector<char>>ans(n,vector<char>(m,'.'));
        for (int i = 0; i < m; ++i) {
            int idx = 0;
            while (idx < n) {
                int stones = 0;
                while (idx < n && box[i][idx] != '*') {
                    if (box[i][idx] == '#') {
                        stones++;
                    }
                    idx++;
                }
                //fill stones into ans column m-1-i and rows range [idx-stones,idx)
                for (int j = idx - stones; j < idx; ++j) {
                    ans[j][m - 1 - i] = '#';
                }
                //fill obstacle into ans[idx][m-1-i]
                if (idx < n) {
                    ans[idx][m - 1 - i] = '*';
                }
                idx++;
            }
        }
        return ans;
    }
};

###Java

class Solution {
    public char[][] rotateTheBox(char[][] box) {
        int m = box.length, n = box[0].length;
        char[][] ans = new char[n][m];
        for(char[] row : ans){
            Arrays.fill(row, '.');
        }
        for (int i = 0; i < m; ++i) {
            int idx = 0;
            while (idx < n) {
                int stones = 0;
                while (idx < n && box[i][idx] != '*') {
                    if (box[i][idx] == '#') {
                        stones++;
                    }
                    idx++;
                }
                //fill stones into ans column m-1-i and rows range [idx-stones,idx)
                for (int j = idx - stones; j < idx; ++j) {
                    ans[j][m - 1 - i] = '#';
                }
                //fill obstacle into ans[idx][m-1-i]
                if (idx < n) {
                    ans[idx][m - 1 - i] = '*';
                }
                idx++;
            }
        }
        return ans;
    }
}

Java 模拟,逐行注释(9ms,73.6MB)

作者 hxz1998
2021年5月16日 09:22

解题思路

对于原来的数组,只需要从右向左逐行处理,把石头放到该放的位置上去就可以了。

  • 如果遇到石头,那么挪动石头到可放的位置。
  • 如果遇到障碍物,那么更新可放的位置。

代码

###java

class Solution {
    public char[][] rotateTheBox(char[][] box) {
        int m = box.length, n = box[0].length;
        char[][] ans = new char[n][m];  // 用来构建返回值的二维数组
        // 首先逐行处理,把石头挪到该放的地方去
        for (int i = 0; i < m; ++i) {
            // 首先假设当前 i 行可放的位置是 pos
            int pos = n - 1;
            // 然后从右往左遍历,逐个更新石头的位置
            for (int j = n - 1; j >= 0; --j) {
                if (box[i][j] == '#') {
                    // 遇到了石头,先把它放到该放的位置去
                    box[i][pos--] = '#';
                    // 确保没有覆盖掉起始位置的石头,然后把挪动前的位置置为 空(.)
                    if (pos != j - 1) box[i][j] = '.';
                }
                // 如果遇到了障碍物,那么就更新可放的位置为障碍物的下一个位置(左边)
                else if (box[i][j] == '*') pos = j - 1;

            }
        }
        // 然后把更新后的位置映射到返回值中
        for (int i = 0; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                ans[j][m - 1 - i] = box[i][j];
            }
        }
        return ans;
    }
}

两种方法:正序遍历 / 倒序遍历(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2021年5月16日 00:56

方法一:正序遍历

$\textit{boxGrid}$ 的每一行互相独立,可以分别计算。

单独看每一行,我们需要知道每个障碍物($\texttt{*}$)的左边有多少个石头($\texttt{#}$)。

具体地,设当前障碍物到上一个障碍物之间有 $\textit{cnt}$ 个石头。那么旋转后,当前障碍物的左边有连续 $\textit{cnt}$ 个石头。据此:

  • 在遍历过程中,统计石头的个数 $\textit{cnt}$。
  • 如果下一个格子是障碍物,或者当前格子是最后一个格子,那么从当前格子往前填入连续 $\textit{cnt}$ 个石头,并重置计数器 $\textit{cnt}=0$。

细节:第 $i$ 行的格子旋转后在倒数第 $i$ 列,第 $j$ 列的格子旋转后在第 $j$ 行。所以 $(i,j)$ 旋转后位于 $(j,m-1-i)$。

class Solution:
    def rotateTheBox(self, boxGrid: list[list[str]]) -> list[list[str]]:
        m, n = len(boxGrid), len(boxGrid[0])
        ans = [[''] * m for _ in range(n)]

        for i, row in enumerate(boxGrid):
            cnt = 0
            for j, ch in enumerate(row):
                if ch == '#':  # 石头
                    cnt += 1
                    ch = '.'  # 先把石头清空
                ans[j][-1 - i] = ch
                if j == n - 1 or row[j + 1] == '*':  # 下一个格子是障碍物
                    # 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
                    for k in range(j, j - cnt, -1):
                        ans[k][-1 - i] = '#'
                    cnt = 0  # 重置计数器

        return ans
class Solution {
    public char[][] rotateTheBox(char[][] boxGrid) {
        int m = boxGrid.length;
        int n = boxGrid[0].length;
        char[][] ans = new char[n][m];

        for (int i = 0; i < m; i++) {
            char[] row = boxGrid[i];
            int cnt = 0;
            for (int j = 0; j < n; j++) {
                char ch = row[j];
                if (ch == '#') { // 石头
                    cnt++;
                    ch = '.'; // 先把石头清空
                }
                ans[j][m - 1 - i] = ch;
                if (j == n - 1 || row[j + 1] == '*') { // 下一个格子是障碍物
                    // 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
                    for (int k = j; k > j - cnt; k--) {
                        ans[k][m - 1 - i] = '#';
                    }
                    cnt = 0; // 重置计数器
                }
            }
        }

        return ans;
    }
}
class Solution {
public:
    vector<vector<char>> rotateTheBox(vector<vector<char>>& boxGrid) {
        int m = boxGrid.size(), n = boxGrid[0].size();
        vector ans(n, vector<char>(m));

        for (int i = 0; i < m; i++) {
            auto& row = boxGrid[i];
            int cnt = 0;
            for (int j = 0; j < n; j++) {
                char ch = row[j];
                if (ch == '#') { // 石头
                    cnt++;
                    ch = '.'; // 先把石头清空
                }
                ans[j][m - 1 - i] = ch;
                if (j == n - 1 || row[j + 1] == '*') { // 下一个格子是障碍物
                    // 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
                    for (int k = j; k > j - cnt; k--) {
                        ans[k][m - 1 - i] = '#';
                    }
                    cnt = 0; // 重置计数器
                }
            }
        }

        return ans;
    }
};
char** rotateTheBox(char** boxGrid, int boxGridSize, int* boxGridColSize, int* returnSize, int** returnColumnSizes) {
    int m = boxGridSize, n = boxGridColSize[0];
    char** ans = malloc(n * sizeof(char*));
    *returnColumnSizes = malloc(n * sizeof(int));
    *returnSize = n;
    for (int i = 0; i < n; i++) {
        ans[i] = malloc(m * sizeof(char));
        (*returnColumnSizes)[i] = m;
    }

    for (int i = 0; i < m; i++) {
        char* row = boxGrid[i];
        int cnt = 0;
        for (int j = 0; j < n; j++) {
            char ch = row[j];
            if (ch == '#') { // 石头
                cnt++;
                ch = '.'; // 先把石头清空
            }
            ans[j][m - 1 - i] = ch;
            if (j == n - 1 || row[j + 1] == '*') { // 下一个格子是障碍物
                // 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
                for (int k = j; k > j - cnt; k--) {
                    ans[k][m - 1 - i] = '#';
                }
                cnt = 0; // 重置计数器
            }
        }
    }

    return ans;
}
func rotateTheBox(boxGrid [][]byte) [][]byte {
m, n := len(boxGrid), len(boxGrid[0])
ans := make([][]byte, n)
for i := range ans {
ans[i] = make([]byte, m)
}

for i, row := range boxGrid {
cnt := 0
for j, ch := range row {
if ch == '#' { // 石头
cnt++
ch = '.' // 先把石头清空
}
ans[j][m-1-i] = ch
if j == n-1 || row[j+1] == '*' { // 下一个格子是障碍物
// 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
for k := j; k > j-cnt; k-- {
ans[k][m-1-i] = '#'
}
cnt = 0 // 重置计数器
}
}
}

return ans
}
var rotateTheBox = function(boxGrid) {
    const m = boxGrid.length, n = boxGrid[0].length;
    const ans = Array.from({ length: n }, () => Array(m));

    for (let i = 0; i < m; i++) {
        const row = boxGrid[i];
        let cnt = 0;
        for (let j = 0; j < n; j++) {
            let ch = row[j];
            if (ch === '#') { // 石头
                cnt++;
                ch = '.'; // 先把石头清空
            }
            ans[j][m - 1 - i] = ch;
            if (j === n - 1 || row[j + 1] === '*') { // 下一个格子是障碍物
                // 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
                for (let k = j; k > j - cnt; k--) {
                    ans[k][m - 1 - i] = '#';
                }
                cnt = 0; // 重置计数器
            }
        }
    }

    return ans;
};
impl Solution {
    pub fn rotate_the_box(box_grid: Vec<Vec<char>>) -> Vec<Vec<char>> {
        let m = box_grid.len();
        let n = box_grid[0].len();
        let mut ans = vec![vec!['\0'; m]; n];

        for (i, row) in box_grid.iter().enumerate() {
            let mut cnt = 0;
            for (j, &ch) in row.into_iter().enumerate() {
                let mut ch = ch;
                if ch == '#' { // 石头
                    cnt += 1;
                    ch = '.'; // 先把石头清空
                }
                ans[j][m - 1 - i] = ch;
                if j == n - 1 || row[j + 1] == '*' { // 下一个格子是障碍物
                    // 石头垂直掉落后,从 j 往前 cnt 个格子都是石头
                    for k in j - cnt + 1..=j {
                        ans[k][m - 1 - i] = '#';
                    }
                    cnt = 0; // 重置计数器
                }
            }
        }

        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{boxGrid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。

方法二:倒序遍历 + 双指针

对于每一行 $\textit{row}$,倒着遍历,我们可以直接确定每个石头落入的位置:

  • 如果 $\textit{row}[j]$ 是障碍物,那么它左边最近的石头,在旋转后掉落到 $\textit{row}[j-1]$。我们用一个变量 $k$ 维护石头掉落后的位置,如果 $\textit{row}[j]$ 是障碍物,那么更新 $\textit{k} = j-1$。注:如果 $\textit{row}[j]$ 左边最近的不是石头而是障碍物,那么 $k$ 会继续更新,无需担心石头落到错误的位置。
  • 如果 $\textit{row}[j]$ 是石头,那么它掉落到 $\textit{row}[\textit{k}]$。然后把 $k$ 减一,表示左边下一块石头掉落后的位置。
class Solution:
    def rotateTheBox(self, boxGrid: list[list[str]]) -> list[list[str]]:
        m, n = len(boxGrid), len(boxGrid[0])
        ans = [['.'] * m for _ in range(n)]

        for i, row in enumerate(boxGrid):
            k = n - 1
            for j in range(n - 1, -1, -1):
                if row[j] == '*':  # 障碍物
                    ans[j][-1 - i] = '*'
                    k = j - 1  # 障碍物左边最近的石头,在旋转后掉落到 j-1
                elif row[j] == '#':  # 石头
                    ans[k][-1 - i] = '#'  # 旋转后,石头掉落到 k
                    k -= 1

        return ans
class Solution {
    public char[][] rotateTheBox(char[][] boxGrid) {
        int m = boxGrid.length;
        int n = boxGrid[0].length;
        char[][] ans = new char[n][m];
        for (char[] row : ans) {
            Arrays.fill(row, '.');
        }

        for (int i = 0; i < m; i++) {
            char[] row = boxGrid[i];
            int k = n - 1;
            for (int j = n - 1; j >= 0; j--) {
                if (row[j] == '*') { // 障碍物
                    ans[j][m - 1 - i] = '*';
                    k = j - 1; // 障碍物左边最近的石头,在旋转后掉落到 j-1
                } else if (row[j] == '#') { // 石头
                    ans[k][m - 1 - i] = '#'; // 旋转后,石头掉落到 k
                    k--;
                }
            }
        }

        return ans;
    }
}
class Solution {
public:
    vector<vector<char>> rotateTheBox(vector<vector<char>>& boxGrid) {
        int m = boxGrid.size(), n = boxGrid[0].size();
        vector ans(n, vector<char>(m, '.'));

        for (int i = 0; i < m; i++) {
            auto& row = boxGrid[i];
            int k = n - 1;
            for (int j = n - 1; j >= 0; j--) {
                if (row[j] == '*') { // 障碍物
                    ans[j][m - 1 - i] = '*';
                    k = j - 1; // 障碍物左边最近的石头,在旋转后掉落到 j-1
                } else if (row[j] == '#') { // 石头
                    ans[k][m - 1 - i] = '#'; // 旋转后,石头掉落到 k
                    k--;
                }
            }
        }

        return ans;
    }
};
char** rotateTheBox(char** boxGrid, int boxGridSize, int* boxGridColSize, int* returnSize, int** returnColumnSizes) {
    int m = boxGridSize, n = boxGridColSize[0];
    char** ans = malloc(n * sizeof(char*));
    *returnColumnSizes = malloc(n * sizeof(int));
    *returnSize = n;
    for (int i = 0; i < n; i++) {
        ans[i] = malloc(m * sizeof(char));
        memset(ans[i], '.', m * sizeof(char));
        (*returnColumnSizes)[i] = m;
    }

    for (int i = 0; i < m; i++) {
        char* row = boxGrid[i];
        int k = n - 1;
        for (int j = n - 1; j >= 0; j--) {
            if (row[j] == '*') { // 障碍物
                ans[j][m - 1 - i] = '*';
                k = j - 1; // 障碍物左边最近的石头,在旋转后掉落到 j-1
            } else if (row[j] == '#') { // 石头
                ans[k][m - 1 - i] = '#'; // 旋转后,石头掉落到 k
                k--;
            }
        }
    }

    return ans;
}
func rotateTheBox(boxGrid [][]byte) [][]byte {
m, n := len(boxGrid), len(boxGrid[0])
ans := make([][]byte, n)
for i := range ans {
ans[i] = bytes.Repeat([]byte{'.'}, m)
}

for i, row := range boxGrid {
k := n - 1
for j := n - 1; j >= 0; j-- {
if row[j] == '*' { // 障碍物
ans[j][m-1-i] = '*'
k = j - 1 // 障碍物左边最近的石头,在旋转后掉落到 j-1
} else if row[j] == '#' { // 石头
ans[k][m-1-i] = '#' // 旋转后,石头掉落到 k
k--
}
}
}

return ans
}
var rotateTheBox = function(boxGrid) {
    const m = boxGrid.length, n = boxGrid[0].length;
    const ans = Array.from({ length: n }, () => Array(m).fill('.'));

    for (let i = 0; i < m; i++) {
        const row = boxGrid[i];
        let k = n - 1;
        for (let j = n - 1; j >= 0; j--) {
            if (row[j] === '*') { // 障碍物
                ans[j][m - 1 - i] = '*';
                k = j - 1; // 障碍物左边最近的石头,在旋转后掉落到 j-1
            } else if (row[j] === '#') { // 石头
                ans[k][m - 1 - i] = '#'; // 旋转后,石头掉落到 k
                k--;
            }
        }
    }

    return ans;
};
impl Solution {
    pub fn rotate_the_box(box_grid: Vec<Vec<char>>) -> Vec<Vec<char>> {
        let m = box_grid.len();
        let n = box_grid[0].len();
        let mut ans = vec![vec!['.'; m]; n];

        for (i, row) in box_grid.into_iter().enumerate() {
            let mut k = n - 1;
            for (j, ch) in row.into_iter().enumerate().rev() {
                if ch == '*' { // 障碍物
                    ans[j][m - 1 - i] = '*';
                    k = j - 1; // 障碍物左边最近的石头,在旋转后掉落到 j-1
                } else if ch == '#' { // 石头
                    ans[k][m - 1 - i] = '#'; // 旋转后,石头掉落到 k
                    k -= 1;
                }
            }
        }

        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{boxGrid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。

专题训练

见下面双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

开发了一个管理本地开发环境的软件

2026年5月5日 23:40

333

前言

前阵子换了新电脑,我在整理本地开发环境时,看到一堆需要重新装的,顿时感觉好麻烦。想着都过去这么久了,应该有工具可以做到统一管理,实现快速安装、更新、切换版本吧。

经过一番查找后,找到了mise这个东西,只需要简单的一句命令就能安装java、node、redis、go等工具,而且还支持对这些工具做统一管理(更新、删除),支持三大主流平台(macOS/Windows/Linux)

命令行始终不方便,于是我萌生了一个做GUI的想法,花了亿点时间用Flutter把它开发出来了,欢迎各位有需要的开发者阅读本文。

项目地址

demo-img-1.png

主要解决什么问题?

对于我这种经常切换项目的人来说,纯命令行总感觉不是很直观。

比如我想知道:

  • 当前电脑装了哪些工具?
  • node、python、java、go这些工具当前用的是哪个版本?
  • 哪些项目里的mise.toml覆盖了全局版本?
  • 我现在执行安装、切换、卸载,到底会影响哪些文件?
  • 上一次通过界面执行命令失败了,具体错误是什么?

这些事情,命令行当然都能查,但需要来回敲命令,有时候还要打开配置文件对比。Mise GUI想做的就是把这些常用信息集中到一个界面里,让本地环境管理更直观一点。

实现效果

目前主要做了4个功能:

  • 环境总览
  • 工具版本
  • 项目覆盖
  • 配置管理

环境总览

总览页主要用于快速查看当前机器的开发环境状态。

这里会展示当前系统、已安装工具数量、项目覆盖情况、mise版本以及最近通过界面执行过的操作。

demo-img-1.png

工具版本

工具模块是本软件的核心功能之一。

它会展示每个工具的当前版本和最新版本,比如Flutter、Go、Java、Python等。

demo-img-2.png

点进某个工具后,可以查看这个工具的已安装版本、远端可安装版本、当前版本来源,以及这个版本会影响哪些项目。

这里还做了一些操作入口,比如:

  • 安装新版本
  • 切换当前版本
  • 升级到推荐版本
  • 卸载工具
  • 查看实际命令

这些操作不会直接执行,每次都会先进入命令预览。

安装新工具

安装工具时,只需要输入工具名和版本号。

如果工具支持远端版本查询,界面会把可选版本列出来,方便直接选择。

demo-img-3.png

确认后不会立刻执行,而是先展示即将运行的命令,例如:

mise install redis@8.6.2
mise use --global redis@8.6.2

同时也会提示这次操作会影响哪些文件、影响范围是什么、可能有什么风险。

这个设计主要是为了避免“点一下按钮,背后偷偷执行了一堆命令”的情况。毕竟本地开发环境这种东西,改之前最好知道自己在改什么。

项目覆盖

mise支持项目级配置,比如在项目目录里放一个mise.toml,就可以指定这个项目使用的工具版本。

这个很好用,但是项目一多之后,就很容易忘记哪个项目覆盖了全局版本。

所以我做了一个项目覆盖页面。

demo-img-4.png

你可以添加多个扫描目录,软件会递归查找里面的mise.toml,然后只展示和全局版本存在差异的项目。

比如全局node是20,但是某个项目指定了18,那么这里就能很直观地看出来。

比如:当你想排查“为什么这个项目跑出来的node版本和我想的不一样”,这个功能就派上用场了。

配置管理

配置页主要是用来查看和编辑全局配置以及项目配置。

demo-img-5.png

目前支持:

  • 查看全局配置文件:~/.config/mise/config.toml
  • 选择某个项目查看项目级mise.toml
  • 编辑配置文件
  • 保存前查看diff差异
  • 配置文件变化后自动刷新

此处我特意加了保存前的差异预览。因为配置文件不像普通表单,改错一个版本号或者删错一行,可能就会影响很多项目。

技术栈

这个项目是用Flutter写的,主要技术栈如下:

  • Flutter Desktop:用于构建跨平台桌面应用
  • Dart:主要开发语言
  • Riverpod:状态管理
  • GoRouter:页面路由
  • file_selector:选择扫描目录
  • Process.run:调用本机mise命令

整体代码结构大概是这样:

lib/
  app/                  # 应用启动、路由、主题和外壳
  features/
    dashboard/          # 环境总览
    tools/              # 工具版本、安装、升级、卸载
    projects/           # 扫描目录和项目覆盖
    config/             # 全局与项目配置管理
  repositories/         # 页面数据聚合
  services/             # mise CLI、配置、历史、更新等底层服务
  shared/ui/            # 通用面板、状态、对话框和预览组件

我没有把命令执行逻辑直接写在页面里,而是拆成了:

  • Page:负责界面展示和用户交互
  • Provider:负责页面状态
  • Repository:负责聚合页面需要的数据
  • Service:负责调用mise、读取配置、记录历史等底层逻辑

这样做的好处是后面要扩展功能时,不至于所有逻辑都堆在页面文件里。

和mise CLI的交互

这个软件本质上不是替代mise,而是给mise套了一层图形界面。

所以核心逻辑还是调用本机的mise CLI,例如:

mise --version
mise ls --json
mise current
mise outdated
mise ls-remote --json node
mise install node@20
mise use --global node@20

Flutter里通过Process.run来执行这些命令,然后把输出结果解析成界面需要的数据。

这里有个比较麻烦的点:桌面应用启动时拿到的环境变量,不一定和终端里的环境变量完全一致。

比如你在终端里能执行mise,但GUI应用直接执行可能找不到。

所以我在这里做了一些兜底:

  • 优先读取常见路径,比如/opt/homebrew/bin/mise/usr/local/bin/mise
  • 尝试读取shell环境
  • 如果仍然找不到,就进入安装引导页
  • 给出当前系统对应的安装命令

为什么选择Flutter

这类工具其实用很多技术都能做,比如Electron、Tauri、Qt、SwiftUI等。

最后选择Flutter,主要有几个原因:

  • 我对Flutter比较熟悉
  • 桌面端支持macOS/Windows/Linux
  • UI写起来比较直接
  • 状态管理和组件拆分比较适合做这种工具型应用
  • 最终打包出来是一个桌面应用,使用门槛比较低

用Flutter写的时候,也遇到了一些坑,比如:系统文件选择、macOS打包、公证、不同平台的命令执行差异,这些都需要额外处理。

整体写下来,这种小型桌面工具开发体验还是很不错的。

如何使用

目前可以直接去Release页面下载对应系统的版本:

github.com/likaia/mise…

如果电脑里还没有安装mise,软件启动后会提示安装命令。

也可以先手动安装:

# macOS
brew install mise

# Windows
winget install jdx.mise

# Linux
curl https://mise.run | sh

如果你想本地运行源码:

git clone https://github.com/likaia/mise_gui.git
cd mise_gui

flutter pub get
flutter run -d macos

其他平台可以把运行目标换成:

flutter run -d linux
flutter run -d windows

写在最后

这个软件算是我给自己做的一个小工具,也是我第一个用Flutter作为开发语言写的开源项目。一开始只是因为换电脑时嫌重新配置环境太麻烦,后来越写越觉得这种东西挺适合做成可视化工具。

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于神奇的程序员公众号,未经许可禁止转载💌

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

作者 SmalBox
2026年5月5日 23:33

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

Saturate节点是Unity Shader Graph中一个基础且重要的数学运算节点,它在图形编程和着色器开发中扮演着关键角色。该节点的核心功能是将输入值限制在0到1的范围内,确保输出值永远不会超出这个标准化区间。在实时渲染和图形处理中,这种钳制操作对于保证数值的有效性和防止渲染错误具有不可替代的作用。

Saturate节点的名称来源于色彩理论中的饱和度概念,但在着色器语境中,它更准确地描述了数值范围的限制过程。当输入值小于0时,节点输出0;当输入值大于1时,节点输出1;对于0到1之间的输入值,则保持原样输出。这种简单而强大的功能使得Saturate节点成为着色器编写中最常用的工具之一。

在Unity URP(Universal Render Pipeline)环境中,Saturate节点的应用尤为广泛。URP作为Unity的轻量级渲染管线,对性能优化有着较高要求,而Saturate节点的高效执行特性使其成为实现各种视觉效果的首选方案。无论是处理颜色值、计算光照强度,还是进行复杂的数学运算,Saturate节点都能确保数值始终处于安全范围内。

从技术实现角度看,Saturate节点对应着HLSL中的saturate()函数,这是一个在GPU级别高度优化的指令。现代图形硬件通常对saturate操作提供原生支持,这意味着使用Saturate节点几乎不会带来额外的性能开销。相比之下,使用条件语句手动实现相同的钳制功能往往会导致性能下降,因此Saturate节点不仅是功能上的选择,更是性能优化的最佳实践。

描述

Saturate节点的核心功能是执行数值钳制操作,具体表现为对输入值进行范围限制。当接收到任意数值输入时,节点会自动检查该值是否位于0到1的区间内。如果输入值已经在此范围内,节点会直接输出原始值;如果输入值小于0,节点会将其提升至0;如果输入值大于1,节点会将其降低至1。这种操作在数学上可以表示为:Out = max(0, min(1, In))。

在图形编程中,数值范围的标准化至关重要。许多着色器操作和纹理采样都假设输入值位于0到1之间,超出这个范围的数值可能导致不可预测的渲染结果,包括颜色失真、亮度异常甚至性能问题。Saturate节点通过强制数值标准化,确保了着色器计算的稳定性和一致性。

该节点的应用场景极为广泛。在颜色处理中,Saturate节点可以防止颜色值溢出,确保RGB分量始终处于有效范围内。在光照计算中,它可以限制光照强度,避免过度曝光或负光照的情况。在透明度混合中,它可以保证alpha值不会超出合理范围,防止渲染顺序错误。此外,在基于物理的渲染(PBR)流程中,Saturate节点常用于处理粗糙度、金属度等材质参数的中间计算结果。

从数学特性来看,Saturate操作具有以下几个重要性质:

  • 幂等性:对已经饱和的值再次应用Saturate不会改变结果
  • 单调性:如果输入值增加,输出值不会减少
  • 有界性:输出始终在[0,1]范围内
  • 连续性:在输入范围内操作是连续的

这些数学特性使得Saturate节点在复杂的着色器网络中能够提供可预测的行为,大大简化了调试和优化过程。

在性能方面,Saturate节点是Shader Graph中最轻量级的操作之一。由于对应着GPU的原生指令,它在各种硬件平台上都能高效运行,包括移动设备和低端图形卡。这使得开发者可以放心地在着色器中大量使用Saturate节点,而不必担心性能损耗。

端口

Saturate节点的端口设计体现了其灵活性和通用性。节点包含一个输入端口和一个输出端口,两者都支持动态矢量类型,这意味着它们可以处理各种维度的数据,从简单的浮点数到复杂的四维向量。

输入端口

输入端口标记为"In",是节点接收数据的入口。这个端口的设计具有以下特点:

  • 动态类型支持:输入端口能够自动适应连接的数据类型,包括float、float2、float3和float4。当连接标量值时,节点执行逐分量钳制;当连接矢量时,节点对每个分量独立执行钳制操作。
  • 数值范围无限制:输入端口接受任意范围的浮点数值,包括负数、零、正数,以及超出常规范围的极大或极小值。这种设计使得节点能够处理各种计算中间结果,无需预先对输入进行范围调整。
  • 自动类型转换:当输入类型与预期不符时,Shader Graph会自动进行合理的类型转换。例如,将整数转换为浮点数,或者通过复制分量来匹配维度要求。
  • 多数据流支持:输入端口可以连接来自各种源的數據,包括属性节点、纹理采样节点、数学运算节点,甚至是其他复杂着色器子图的输出。

输出端口

输出端口标记为"Out",是节点处理结果的出口。输出端口具有以下关键特性:

  • 类型一致性:输出类型始终与输入类型完全匹配。如果输入是float3,输出也是包含三个分量的float3,每个分量都独立经过钳制处理。
  • 数值保证:输出端口的每个分量都严格保证在[0,1]范围内,这是节点的核心承诺。开发者可以依赖这一特性来构建安全的着色器逻辑。
  • 下游兼容性:由于输出值被限制在标准化范围内,它可以安全地连接到任何期望0-1范围输入的节点,如颜色混合节点、透明度节点或纹理坐标节点。
  • 链式处理能力:输出端口可以连接到其他Saturate节点或其他数学运算节点,支持复杂的处理流水线。这种设计允许开发者在着色器图中构建多级数值安全机制。

端口交互示例

考虑一个典型的使用场景:计算漫反射光照。假设我们有一个光照强度值,可能因为各种计算而超出正常范围:

光照强度 = 基础光照 + 高光反射 + 环境光

通过将计算结果连接到Saturate节点的输入端口,可以确保最终的光照强度不会过度曝光(大于1)或产生负光照(小于0)。输出端口提供的安全值可以直接用于颜色计算,确保渲染结果的物理正确性。

在矢量处理方面,假设我们有一个float3类型的颜色值,其中某个分量可能因为计算错误而变为负值:

问题颜色 = float3(1.2, -0.3, 0.8)

通过Saturate节点处理后:

安全颜色 = float3(1.0, 0.0, 0.8)

这种自动修正机制防止了颜色异常,同时保持了有效分量的正确性。

生成的代码示例

Saturate节点在最终编译的着色器中会生成对应的HLSL代码。理解这些生成的代码有助于开发者优化着色器性能和调试复杂效果。以下是Saturate节点在不同情况下的代码生成示例及其详细解析。

基础浮点数钳制

当处理单个浮点数输入时,生成的代码最为简单:

void Unity_Saturate_float(float In, out float Out)
{
    Out = saturate(In);
}

这段代码定义了一个函数,接收浮点数输入In,通过HLSL内置的saturate()函数进行处理,然后将结果存储在输出参数Out中。在GPU级别,saturate()操作通常对应着一条原生指令,执行效率极高。

矢量类型处理

对于多维矢量的处理,Saturate节点会生成相应的矢量版本:

void Unity_Saturate_float2(float2 In, out float2 Out)
{
    Out = saturate(In);
}

void Unity_Saturate_float3(float3 In, out float3 Out)
{
    Out = saturate(In);
}

void Unity_Saturate_float4(float4 In, out float4 Out)
{
    Out = saturate(In);
}

这些函数展示了Saturate节点对矢量类型的支持。重要的是,saturate()函数在HLSL中对矢量类型执行逐分量操作,这意味着每个分量都会独立地进行钳制处理。这种操作在GPU上通常是并行执行的,不会带来额外的性能开销。

内联优化

在实际的着色器编译过程中,编译器可能会对Saturate节点进行内联优化。例如,当Saturate节点与其他操作连接时,生成的代码可能是这样的:

// 原始节点网络:Multiply -> Saturate -> Output
float3 originalColor = tex2D(_MainTex, uv).rgb;
float3 brightColor = originalColor * _Brightness;
float3 finalColor = saturate(brightColor);
return float4(finalColor, 1.0);

在这种情况下,编译器会将Saturate操作直接内联到计算流程中,而不是调用独立的函数。这种优化减少了函数调用开销,提高了执行效率。

复杂表达式中的Saturate

当Saturate节点参与复杂数学表达式时,生成的代码会反映其在节点网络中的具体位置:

// 对应一个复杂的颜色处理流程
void surf (Input IN, inout SurfaceOutputStandard o)
{
    float3 baseColor = tex2D(_MainTex, IN.uv_MainTex).rgb;
    float3 emissive = tex2D(_EmissiveTex, IN.uv_EmissiveTex).rgb;
    float intensity = _EmissiveIntensity;

    // Saturate确保混合权重在有效范围内
    float blendFactor = saturate(_BlendAmount);

    // 使用钳制后的值进行混合
    float3 combined = lerp(baseColor, emissive * intensity, blendFactor);

    // 最终颜色也需要钳制以确保有效性
    o.Albedo = saturate(combined);
}

这个例子展示了Saturate节点在复杂着色器中的两种典型用法:一是确保混合参数在有效范围内,二是保证最终输出值的安全性。

性能优化考虑

从生成的代码可以看出,Saturate节点的性能特性非常优秀:

  • 指令优化:saturate()通常编译为单个GPU指令
  • 寄存器效率:操作通常直接在寄存器中完成,不需要临时存储
  • 并行处理:矢量版本能够充分利用GPU的并行计算能力

相比之下,手动实现钳制功能往往效率较低:

// 不推荐的手动实现
float manualSaturate(float x)
{
    return max(0, min(1, x));
}

// 更差的条件语句实现
float badSaturate(float x)
{
    if (x < 0) return 0;
    if (x > 1) return 1;
    return x;
}

这些手动实现方式通常会产生更多的指令和分支,在GPU上的执行效率远低于原生的saturate()函数。

平台兼容性

生成的saturate()代码在所有支持HLSL的平台上都具有良好的兼容性:

  • DirectX平台:完全支持,从Shader Model 2.0开始就包含saturate指令
  • OpenGL/GLSL平台:Unity会自动将saturate()转换为clamp(),确保功能一致
  • 移动平台:无论是iOS的Metal还是Android的GLSL,都能正确转换和支持
  • 控制台平台:所有主流游戏主机平台都提供原生支持

这种跨平台一致性使得开发者可以放心使用Saturate节点,而不必担心平台兼容性问题。


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

昨天 — 2026年5月5日技术

技术选型方法论

作者 Csvn
2026年5月5日 22:49

引言

在软件开发过程中,技术选型往往决定了项目的成败。一个好的技术选型能够提升开发效率、降低维护成本,而错误的选择则可能导致项目延期、技术债务累积,甚至需要推倒重来。本文将介绍一套系统化的技术选型方法论,帮助团队在面临技术决策时做出更明智的选择。

一、技术选型的评估维度

1.1 技术成熟度

成熟度评估要点:

  • 社区活跃度:GitHub stars、贡献者数量、issue 响应速度
  • 文档完善度:官方文档、社区教程、最佳实践
  • 版本稳定性:是否处于稳定版本、更新频率、破坏性变更
  • 生态完整性:相关工具链、第三方库、插件支持

示例代码 - 评估 npm 包成熟度:

// 使用 npm 包成熟度评估工具
const packageInfo = {
  name: 'react',
  version: '18.2.0',
  stars: 215000,
  contributors: 1500,
  lastUpdate: '2026-04-28',
  license: 'MIT'
};

function assessMaturity(info) {
  let score = 0;
  
  // 社区规模
  if (info.stars > 100000) score += 30;
  else if (info.stars > 50000) score += 20;
  
  // 活跃维护
  const daysSinceUpdate = (Date.now() - new Date(info.lastUpdate)) / 86400000;
  if (daysSinceUpdate < 30) score += 25;
  else if (daysSinceUpdate < 90) score += 15;
  
  // 贡献者数量
  if (info.contributors > 1000) score += 20;
  else if (info.contributors > 500) score += 10;
  
  return score;
}

console.log(`成熟度评分:${assessMaturity(packageInfo)}/75`);

1.2 团队适配度

技术选型必须考虑团队的实际能力:

  • 学习曲线:团队成员掌握该技术需要多长时间
  • 现有经验:团队是否已有相关技术栈经验
  • 招聘难度:市场上该技术的开发者数量
  • 培训成本:是否需要外部培训或顾问支持

1.3 业务匹配度

技术应该服务于业务需求:

  • 功能需求:技术能否满足产品功能要求
  • 性能需求:是否支持预期的并发量和响应时间
  • 扩展性:能否支持业务增长
  • 合规要求:是否满足行业标准和法规

二、技术选型决策流程

2.1 需求分析阶段

技术选型需求清单:

功能性需求

  • 支持实时数据同步
  • 离线工作能力
  • 多平台兼容
  • 第三方 API 集成

非功能性需求

  • 响应时间 < 200ms
  • 支持 10 万 + 并发用户
  • 99.9% 可用性
  • 数据加密存储

约束条件

  • 预算限制:$50,000/年
  • 开发周期:6 个月
  • 团队规模:5 人
  • 必须使用 TypeScript

2.2 候选技术筛选

筛选矩阵示例:

技术选项 成熟度 团队熟悉度 功能匹配 成本 总分
React 9 8 9 8 34
Vue 8 6 8 9 31
Angular 8 4 9 7 28

2.3 概念验证 (PoC)

对候选技术进行小规模验证:

// PoC 验证示例:性能测试
async function performanceTest(framework) {
  const startTime = Date.now();
  
  // 模拟 10000 次渲染操作
  for (let i = 0; i < 10000; i++) {
    renderComponent(framework, { id: i, name: `Item ${i}` });
  }
  
  const endTime = Date.now();
  const avgTime = (endTime - startTime) / 10000;
  
  return {
    framework,
    avgRenderTime: avgTime,
    passed: avgTime < 5 // 每渲染操作 < 5ms
  };
}

// 执行测试
const results = await Promise.all([
  performanceTest('react'),
  performanceTest('vue'),
  performanceTest('angular')
]);

console.table(results);

三、常见技术选型陷阱

3.1 过度追求新技术

问题: 盲目使用最新技术,忽视稳定性和团队能力

解决方案:

  • 优先选择经过验证的成熟技术
  • 新技术采用渐进式策略
  • 建立技术雷达,定期评估

3.2 忽视技术债务

问题: 只考虑短期收益,忽略长期维护成本

解决方案:

// 技术债务评估模型
function calculateTechDebt(options) {
  const factors = {
    learningCurve: options.learningCurve * 0.2,
    maintenanceCost: options.maintenanceCost * 0.3,
    communitySupport: (10 - options.communitySupport) * 0.2,
    documentation: (10 - options.documentation) * 0.15,
    vendorLockin: options.vendorLockin * 0.15
  };
  
  return Object.values(factors).reduce((a, b) => a + b, 0);
}

// 使用示例
const debtScore = calculateTechDebt({
  learningCurve: 8,      // 学习曲线难度 (1-10)
  maintenanceCost: 6,    // 维护成本 (1-10)
  communitySupport: 9,   // 社区支持 (1-10)
  documentation: 7,      // 文档质量 (1-10)
  vendorLockin: 3        // 厂商锁定风险 (1-10)
});

console.log(`技术债务评分:${debtScore.toFixed(2)}`);

3.3 团队意见不统一

解决方案:

  • 建立透明的决策流程
  • 使用加权评分矩阵
  • 允许试点项目验证
  • 定期回顾和反馈

四、技术选型最佳实践

4.1 建立技术决策记录 (ADR)

ADR-001: 前端框架选型决策

状态: 已采纳

上下文:

  • 项目类型:企业级 SaaS 应用
  • 团队规模:8 人
  • 开发周期:12 个月

决策: 选择 React 作为前端框架

理由:

  1. 社区生态完善,资源丰富
  2. 团队已有 React 经验
  3. 支持 TypeScript,类型安全
  4. 性能表现优秀

后果:

  • 需要投入 2 周进行团队培训
  • 建立 React 代码规范
  • 定期技术分享

4.2 定期技术回顾

建立季度技术评审机制:

// 技术栈健康度检查
const techHealthCheck = {
  lastReview: '2026-01-15',
  metrics: {
    securityVulnerabilities: 2,
    deprecatedPackages: 5,
    performanceIssues: 3,
    teamSatisfaction: 8.5,
    documentationQuality: 7
  },
  actionItems: [
    '升级依赖包',
    '更新安全补丁',
    '性能优化专项'
  ]
};

function generateReport(health) {
  const score = (
    (10 - health.metrics.securityVulnerabilities) * 2 +
    (10 - health.metrics.deprecatedPackages) +
    (10 - health.metrics.performanceIssues) * 2 +
    health.metrics.teamSatisfaction +
    health.metrics.documentationQuality
  ) / 6;
  
  return {
    overallScore: score.toFixed(1),
    status: score > 7 ? '健康' : '需要关注',
    recommendations: health.actionItems
  };
}

console.log(generateReport(techHealthCheck));

五、总结

技术选型是一个系统化的决策过程,需要综合考虑技术、团队、业务等多个维度。关键要点:

  1. 建立科学的评估体系:使用量化指标,避免主观判断
  2. 重视团队适配度:技术再好,团队无法驾驭也是徒劳
  3. 保持灵活性:技术选型不是一成不变的,要定期回顾
  4. 记录决策过程:便于后续回顾和知识传承
  5. 平衡短期和长期:既要满足当前需求,也要考虑未来发展

记住,没有"最好"的技术,只有"最适合"的技术。技术选型的最终目标是让技术为业务服务,而不是让业务迁就技术。

推荐阅读

  • 《领域驱动设计》
  • 《软件架构实践》
  • 《技术雷达》

前端架构演进:从页面到平台的十年变革

作者 Csvn
2026年5月5日 22:46

引言

前端架构的演进史,就是一部 Web 技术的发展史。从简单的静态页面到如今的复杂单页应用,再到微前端和 Serverless 架构,前端工程师一直在探索更高效的开发模式。本文将带你回顾前端架构的演进历程,理解每个阶段的特点和适用场景。

一、早期阶段:页面式架构(2010 年前)

特点

  • 多页应用(MPA),每次请求都刷新整个页面
  • 后端渲染为主,HTML 由服务器生成
  • 简单的 DOM 操作,jQuery 是主流

代码示例

<!-- 传统的 JSP/PHP 页面 -->
<%@ page language="java" contentType="text/html; charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head>
    <title>用户列表</title>
    <script src="jquery-1.8.0.min.js"></script>
</head>
<body>
    <div id="userList">
        <% for(User user : users) { %>
            <div class="user-item">
                <span><%= user.getName() %></span>
            </div>
        <% } %>
    </div>
    <script>
        $(document).ready(function() {
            $('.user-item').click(function() {
                // 简单的交互
            });
        });
    </script>
</body>
</html>

优缺点

  • ✅ 开发简单,SEO 友好
  • ❌ 页面切换体验差,代码复用性低

二、单页应用时代(2010-2018)

特点

  • 单页应用(SPA),路由在前端控制
  • 前后端分离,API 驱动
  • 组件化开发,Vue/React/Angular 三足鼎立

代码示例

// React + React Router 示例
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/users" element={<UserList />} />
        <Route path="/users/:id" element={<UserDetail />} />
      </Routes>
    </BrowserRouter>
  );
}

// 组件化开发
class UserList extends React.Component {
  state = { users: [] };
  
  async componentDidMount() {
    const res = await fetch('/api/users');
    this.setState({ users: await res.json() });
  }
  
  render() {
    return (
      <ul>
        {this.state.users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

优缺点

  • ✅ 流畅的用户体验,开发效率高
  • ❌ 首屏加载慢,SEO 不友好,大型应用难以维护

三、工程化与模块化(2015-2020)

特点

  • Webpack/Vite 等构建工具成熟
  • ES6+ 模块系统
  • TypeScript 普及,类型安全

代码示例

// TypeScript + ES Module 示例
interface User {
  id: number;
  name: string;
  email: string;
}

class UserService {
  private baseUrl = '/api/users';
  
  async getUsers(): Promise<User[]> {
    const res = await fetch(this.baseUrl);
    return res.json();
  }
  
  async getUser(id: number): Promise<User> {
    const res = await fetch(`${this.baseUrl}/${id}`);
    return res.json();
  }
}

// 使用
const userService = new UserService();
const users = await userService.getUsers();

优缺点

  • ✅ 代码质量提升,可维护性强
  • ❌ 构建复杂度高,学习曲线陡峭

四、微前端时代(2018 至今)

特点

  • 大型应用拆分为多个子应用
  • 独立开发、独立部署
  • qiankun、Module Federation 等方案

代码示例

// qiankun 主应用示例
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'app1',
    entry: '//localhost:8080',
    container: '#container',
    activeRule: '/app1',
  },
  {
    name: 'app2',
    entry: '//localhost:8081',
    container: '#container',
    activeRule: '/app2',
  },
]);

// 子应用导出
export async function bootstrap() {
  console.log('子应用启动');
}

export async function mount(props) {
  ReactDOM.render(<App />, props.container);
}

export async function unmount() {
  ReactDOM.unmountComponentAtNode(props.container);
}

优缺点

  • ✅ 技术栈无关,团队独立,渐进式迁移
  • ❌ 架构复杂,性能开销,样式隔离问题

五、Serverless 与边缘计算(2020 至今)

特点

  • 前端 + 云函数,后端能力前移
  • Next.js/Nuxt.js 的 SSR/SSG
  • 边缘部署,CDN 加速

代码示例

// Next.js 混合渲染示例
export async function getStaticProps() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return { props: { posts }, revalidate: 60 };
}

export default function Blog({ posts }) {
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h1>{post.title}</h1>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

优缺点

  • ✅ 性能优秀,开发体验好,成本可控
  • ❌ 厂商锁定,调试复杂,冷启动问题

六、未来趋势

1. 模块联邦(Module Federation)

Webpack 5 引入的模块联邦,让应用间共享依赖和组件成为可能。

2. 低代码平台

可视化搭建,提升开发效率,降低技术门槛。

3. AI 辅助开发

Copilot、Codeium 等工具,提升编码效率。

4. WebAssembly

性能关键场景,如图像处理、游戏等。

总结

前端架构的演进遵循着几个核心原则:

  1. 用户体验优先:从页面刷新到 SPA,再到 SSR/SSG
  2. 开发效率提升:从手动 DOM 操作到组件化,再到低代码
  3. 可维护性增强:从全局变量到模块化,再到微前端
  4. 性能优化:从完整加载到按需加载,再到边缘计算

选择架构时,需要考虑:

  • 团队规模和技术能力
  • 项目复杂度和生命周期
  • 性能要求和 SEO 需求
  • 预算和运维成本

没有最好的架构,只有最适合的架构。

ShaderToy-山峦+蓝天+白云

2026年5月5日 22:20

知识点

  • 杂色
  • 栅格
  • 山峦
  • 阳光
  • 补光
  • 雾效
  • 蓝天
  • 白云

课前必备

用noise 绘制山峦的算法:wolfram详解山峦算法

课程内容

1.杂色。

image-20241106141239814

2.栅格:降低采样频率,将杂色变成栅格。

02

3.栅格平滑过度。

03

4.云彩:对模糊后的图案进行多次变换叠加。

04

5.云与山:根据云彩的灰度值做起伏,可以画出云与山。

白云

1-杂色

杂色的实现原理就是随机数。

// 坐标系缩放系数
#define PROJECTION_SCALE  1.

// 坐标系
vec2 Coord(in vec2 pos) {
  return PROJECTION_SCALE * 2. * (pos - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 随机数
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 杂色
float Noise(vec2 pos){
  return Random(pos);
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  // float noise=Noise(coord);
  // float noise=Noise(coord*10.);
  float noise=Noise(coord*100.);
  fragColor=vec4(vec3(noise),1.);
}

效果如下:

08

现在就是一个毫无章法的杂色效果。

2-栅格

我们可以将点位取整,从而画出大块的杂色。

float Noise(vec2 pos){
  vec2 i=floor(pos);
  return Random(i);
}

效果如下:

image-20250523155540077

我们可以将coord*5.,使色块更大。

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  float noise=Noise(coord*5.);
  fragColor=vec4(vec3(noise),1.);
}

效果如下:

09

3-山峦的平滑过度

山峦的平滑过度的原理在wolfram详解山峦算法中有详解。

公式如下:

image-20241127151550726

e=a+(b-a)*fx*(1-fy)+(c-a)*fy*(1-fx)+(d-a)*fx*fy

代码如下:

// 坐标系缩放系数
#define PROJECTION_SCALE  1.

// 坐标系
vec2 Coord(in vec2 pos) {
  return PROJECTION_SCALE * 2. * (pos - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 随机数
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 杂色
float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*f.x*(1.-f.y)+(c-a)*f.y*(1.-f.x)+(d-a)*f.x*f.y;
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  float noise=Noise(coord*5.);
  fragColor=vec4(vec3(noise),1.);
}

效果如下:

14

4-山峦透视图

image-20260221100929091

山峦透视图涉及以下知识点:

  • RayMarching 光线推进
  • 山峦的SDF模型
  • 山峦的法线计算
  • 根据法线和平行光计算山峦颜色

整体代码如下:

// 坐标系缩放
#define PROJECTION_SCALE  1.
// 相机视点位
#define CAMERA_POS vec3(12,12,12)
// 相机目标点
#define CAMERA_TARGET vec3(0,0,0)
// 上方向
#define CAMERA_UP vec3(0, 1, 0)
// 光线推进的最远距离
#define RAYMARCH_FAR 200.
// 光线推进次数
#define RAYMARCH_NUM 512
// 光线推进精度,当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001

// RayMarch 数据的结构体
struct RayMarchData {
   // 射线碰撞到的着色点位置
  vec3 ro;
  // 推进距离
  float t;
  // 推进方向
  vec3 rd;
};

// 坐标系
vec2 Coord(in vec2 fragColor) {
  return PROJECTION_SCALE * 2. * (fragColor - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 视图旋转矩阵
mat3 RotateMatrix() {
  //基向量c,视线
  vec3 c = normalize(CAMERA_TARGET-CAMERA_POS );
  //基向量a,视线和上方向的垂线
  vec3 a = cross(c, CAMERA_UP);
  //基向量b,修正上方向
  vec3 b = cross(a, c);
  //正交旋转矩阵
  return mat3(a, b, c);
}

// 二维随机
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 三维平滑噪波
float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*f.x*(1.-f.y)+(c-a)*f.y*(1.-f.x)+(d-a)*f.x*f.y;
}

// 山峦生成函数
float Mount(vec2 p){
  return 2.* Noise(p);
}

// 山峦法线
vec3 MountNormal(vec3 p,float t){
  // 极小值,受到推进距离t的加权
  float epsilon=0.001*t;
  // 采样
  vec2 offsetX1=p.xz-vec2(epsilon,0);
  vec2 offsetX2=p.xz+vec2(epsilon,0);
  vec2 offsetZ1=p.xz-vec2(0,epsilon);
  vec2 offsetZ2=p.xz+vec2(0,epsilon);
  // 法线
  return normalize(vec3(
    Mount(offsetX1)-Mount(offsetX2),
    2.0*epsilon,
    Mount(offsetZ1)-Mount(offsetZ2)
  ));
}

// 山峦SDF
float MountSDF(vec3 pos){
  return pos.y-Mount(pos.xz);
}

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  // 光线推进数据
  RayMarchData rm = RayMarchData(vec3(0),0.,rd);
  float t=0.;
  for(int i=0;i<RAYMARCH_NUM;i++){
    vec3 p=ro+t*rd;
    float h=MountSDF(p);
    rm.ro=p;
    // RAYMARCH_PRECISION 受到推进距离t的加权
    if(abs(h)<RAYMARCH_PRECISION*t||t>RAYMARCH_FAR){
      break;
    }
    // 缩小推进距离
    t+=0.1*h;
  }
  rm.t=t;
  return rm;
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountNormal(rm.ro,rm.t);
    color=sqrt(vec3(dot(vec3(0,1,0),n)));
  }
  return color;
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  vec3 col=render(coord);
  fragColor=vec4(col,1);
}

详细解释一下上面的代码。

RayMarching 光线推进

1.定义相机。

根据相机的视点、目标点和上方向可以计算视图旋转矩阵。

代码如下:

// 相机视点位
#define CAMERA_POS vec3(12,12,12)
// 相机目标点
#define CAMERA_TARGET vec3(0,0,0)
// 上方向
#define CAMERA_UP vec3(0, 1, 0)
    
// 视图旋转矩阵
mat3 RotateMatrix() {
  //基向量c,视线
  vec3 c = normalize(CAMERA_TARGET-CAMERA_POS );
  //基向量a,视线和上方向的垂线
  vec3 a = cross(c, CAMERA_UP);
  //基向量b,修正上方向
  vec3 b = cross(a, c);
  //正交旋转矩阵
  return mat3(a, b, c);
}

2.将fragCoord 坐标转换为一种原点在屏幕中心的屏幕坐标。

// 坐标系缩放
#define PROJECTION_SCALE  1.
// 坐标系
vec2 Coord(in vec2 fragColor) {
  return PROJECTION_SCALE * 2. * (fragColor - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}
void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  //...
}

3.将相机的初始位置定义为(0,0,-2),则从相机向栅格图像中的每个栅格推进的初始方向就是vec3(coord,2.)

4.用相机的视图旋转矩阵旋转初始推进方向,便可得到世界坐标系中的推进方向。

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向  
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  //...
}

5.RayMarchData 是自定义的光线推进数据的结构体,以便于数据管理。

// RayMarch 数据的结构体
struct RayMarchData {
   // 射线碰撞到的着色点位置
  vec3 ro;
  // 推进距离
  float t;
  // 推进方向
  vec3 rd;
};

山峦SDF模型

山峦SDF模型的距离判断原理:在每次光线推进时,计算推进点的高度位置到其正下方的山峦距离。

1.根据推进点的x、z 值,可以算出相应位置的山峦高度。

代码如下:

// 二维随机
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 三维平滑噪波
float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*f.x*(1.-f.y)+(c-a)*f.y*(1.-f.x)+(d-a)*f.x*f.y;
}

// 山峦生成函数
float Mount(vec2 p){
  return 2.* Noise(p);
}

Mount(vec2 p) 函数提升了山体的高度。

2.计算山峦高度到推进点的y 值的距离,若距离小于某个精度值,便认为射线碰到了山体。

否则,根据此距离推进光线。

代码如下:

// 山峦SDF
float MountSDF(vec3 pos){
  return pos.y-Mount(pos.xz);
}

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  // 光线推进数据
  RayMarchData rm = RayMarchData(vec3(0),0.,rd);
  float t=0.;
  for(int i=0;i<RAYMARCH_NUM;i++){
    vec3 p=ro+t*rd;
    float h=MountSDF(p);
    rm.ro=p;
    // RAYMARCH_PRECISION*t:近处精度高,远处精度低
    if(abs(h)<RAYMARCH_PRECISION*t||t>RAYMARCH_FAR){
      break;
    }
    // 缩小推进距离
    t+=0.1*h;
  }
  rm.t=t;
  return rm;
}

当然,这种光线推进的方法并不是绝对严谨的,因为这距离并不是推进点到山体的垂直距离,但其优点是计算快捷。

为了尽量避免距离误差,我们将推进距离缩小。

t+=0.1*h;

山峦的法线计算

山峦的法线可以用于逐片元计算受光程度。

山峦法线的计算原理是:以当前片元为中心,在一个极小范围内计算法线,假设此极小范围是一个平面。

算法示例

假设y=2x+3 是山峦函数,求它x=1处的法线。

image-20260318165526677

理解斜截式的同学肯定能看出它的斜率跟3没关系,且任意位置的法线都是一样的。

它在任意位置的法线都是y=2x上任一非零的点旋转90°的归一化。

如(1,2)旋转90°后的(-2,1)的归一化,即(−0.894, 0.447)。

image-20260318170032428

假设我们不知道斜截式的规律,我们可以换个思路计算其法线。

设精度为0.001。

取x=(1-0.001)和x=(1+0.001)处的y值,即:

y1=2*(1-0.001)+3=4.998
y2=2*(1+0.001)+3=5.002

则x=1处的法线为:

normalize(y1-y2,2*0.001)=normalize(-0.004,0.002)=(−0.894, 0.447)

其原理就是取任意不重合的两点,算一下其相对位置,然后旋转90°,做归一化。

代码实现

山峦法线的代码实现就是以极小范围采样的方式,将上面求二维直线的法线变成求三维平面的法线。

vec3 MountNormal(vec3 p,float t){
  // 极小值,受到推进距离t的加权
  float epsilon=0.001*t;
  // 采样
  vec2 offsetX1=p.xz-vec2(epsilon,0);
  vec2 offsetX2=p.xz+vec2(epsilon,0);
  vec2 offsetZ1=p.xz-vec2(0,epsilon);
  vec2 offsetZ2=p.xz+vec2(0,epsilon);
  // 法线
  return normalize(vec3(
    Mount(offsetX1)-Mount(offsetX2),
    2.0*epsilon,
    Mount(offsetZ1)-Mount(offsetZ2)
  ));
}

大家可以调整epsilon 的大小,观察采样精度对渲染效果的影响。

根据法线和平行光计算山峦颜色

将光线方向与法线做点积运算,便可以得到片元的受光程度。

代码如下:

vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountNormal(rm.ro,rm.t);
    color=sqrt(vec3(dot(vec3(0,1,0),n)));
  }
  return color;
}

在当前的代码里,我们使用的平行光,光线是从正上方打下来的。

sqrt 可以加强山峦的颜色的对比度。

效果分析

image-20260221100929091

通过当前的山峦效果,我们可以看到以下问题:

  • 山体形状太板
  • 缺少细节层次

接下来我们就解决这些问题。

5-山峦圆滑

山峦的圆滑的原理在wolfram详解山峦算法中有详解。

其基本原理就是将山峦平滑过度时的线性补间变成曲线补间。

山峦圆滑

圆滑代码如下:

float Noise(vec2 pos){
  vec2 i=floor(pos);
  vec2 f=fract(pos);
  vec2 u=3.*f*f-2.*f*f*f;
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  return a+(b-a)*u.x*(1.-u.y)+(c-a)*(1.-u.x)*u.y+(d-a)*u.x*u.y;
}

其它代码不变。

现在的山峦还有些单薄,我们对其进行多次变换叠加。

6-山峦叠加

山峦的叠加的原理在wolfram详解山峦算法中有详解。

山峦叠加

相关代码如下:

// 三维噪波
vec3 Noise(vec2 p){
  vec2 i=floor(p);
  vec2 f=fract(p);
  vec2 u=3.*f*f-2.*f*f*f;
  // u的偏导函数
  vec2 du=6.*f-6.*f*f;
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  // 让半山腰的山凹下去
  float x=a+(b-a)*u.x*(1.-u.y)+(c-a)*(1.-u.x)*u.y+(d-a)*u.x*u.y;
  //x的偏导函数
  vec2 yz=du*(vec2(b-a,c-a)+(a-b-c+d)*u.yx);
  return vec3(x,yz);
}

// 山体变换矩阵
// 2可使山体每次迭代后更密集,增加山体细节;旋转矩阵可使山体更加随机、自然
mat2 mountainTF=2.*mat2(0.6,-0.8,0.8,0.6);
// 山峦生成器
float MountainFn(vec2 p,int len){
  // 山峰高度
  float a=0.;
  // 山峰高度的增量,每次迭代后增量减半,使得山峰越高,增量越小
  float b=1.;
  // 山峰斜率的累积,越陡峭,斜率越大,增量越小。即山峰陡峭处更平滑。
  vec2 d=vec2(0);
  for(int i=0;i<len;i++){
    // 1.65可使山体更高; p*0.5可使山体更平缓
    vec3 n=1.65*Noise(p*0.5);
    a+=b*n.x/(1.+dot(d,d));
    // 累积斜率
    d+=n.yz;
    // 变换采样点,使得山体更自然
    p=mountainTF*p;
    // 减小山峰高度的增量
    b*=0.56;
  }
  return a;
}
// 法线贴图
float MountainNormalFn(vec2 p){
  return MountainFn(p,12);
}
// 山峦
float Mountain(vec2 p){
  return MountainFn(p,6);
}
// 计算法线
vec3 MountainNormal(vec3 p,float t){
  // 一种极小值
  vec2 epsilon=vec2(0.001*t,0);
  // 法线
  return normalize(vec3(
    MountainNormalFn(p.xz-epsilon.xy)-MountainNormalFn(p.xz+epsilon.xy),
    2.0*epsilon.x,
    MountainNormalFn(p.xz-epsilon.yx)-MountainNormalFn(p.xz+epsilon.yx)
  ));
}

山峦模型Mountain 和山峦法线MountainNormal 用了2种计算精度,这样可以在渲染效果和速度之间找一个平衡。

mountainTF 是缩放旋转矩阵,对每次叠加山峦进行变换,使之山峦更加自然。

Noise 中返回的yz数据是山峦的梯度,通过梯度可以确定山势的陡峭度,从而在山峰陡峭的地方,让叠加的山峦矮一些,从而更符合山峦的自然规律。

7-阳光

阳光可以理解为平行光,所以定义一个光线方向,打出投影既可。

阳光

相关代码如下:

// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.
// 光线推进的最远距离
#define RAYMARCH_FAR 200.
// 阳光方向
#define SUNLIGHT_DIRECTION normalize(vec3(0.5, 0.2, 0.2))

// 软阴影
float SoftShadow(in vec3 ro, in vec3 rd, float k) {
  float res = 1.;
  for(float t = RAYMARCH_NEAR; t < RAYMARCH_FAR;) {
    float h = MountainSDF(ro + rd * t);
    if(h < RAYMARCH_PRECISION) {
      return 0.;
    }
    res = min(res, k * h / t);
    t += h;
  }
  return res;
}
// 阳光
void AddSunLight(out vec3 color,vec3 p,float t,vec3 n) {
  // 阳光照明
  float l=max(dot(SUNLIGHT_DIRECTION, n), 0.);
  vec3 color1=vec3(0.02, 0.04, 0.11);
  vec3 color2=vec3(1.0);
  color=mix(color1,color2,l);
  // 阳光投影
  float f = SoftShadow(p, SUNLIGHT_DIRECTION, 8.);
  vec3 color3=vec3(0.05, 0.0, 0.09);
  vec3 color4=vec3(0.99, 0.96, 1.0);
  vec3 shadow=mix(color3,color4,f);
  color *= shadow;
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    color=basicColor;
  }
  return color;
}

8-补光

当前山体的投影是纯黑的,我们需要给它补光。

补光的方法有很多,最常见的环境光,但我这里图省事,就给了个垂直于地面的平行光。

相关代码如下:

// 环境光
void AddEnvironmentLight(out vec3 color,vec3 n){
  float l=sqrt(max(dot(vec3(0,1,0), n), 0.));
  vec3 color1=vec3(0.29, 0.32, 0.36);
  vec3 color2=vec3(1.0);
  vec3 ambientColor=mix(color1,color2,l);
  color=mix(color,ambientColor,0.3);
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    color=basicColor;
  }
  return color;
}

效果如下:

环境光

把相机镜头降低,你会发现山峦远处有噪波:

镜头降低

我们可以使用雾效掩盖此问题。

9-雾效

雾效效果:离视点越近越清晰,越远越接近雾色。

雾效

相关代码如下:

// 相机视点位
#define CAMERA_POS vec3(6.55,2.4,12.02)
// 相机目标点
#define CAMERA_TARGET vec3(6.5,1,0)
// 雾效近端距离
#define FOG_NEAR 20.
// 雾效远端距离
#define FOG_FAR FOG_NEAR+10.

// 雾效
void AddFog(out vec3 color,vec3 fogColor,float t){
  color=mix(color,fogColor,smoothstep(FOG_NEAR,FOG_FAR,t));
}
// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=vec3(0);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

当前的远山并没有给一个单纯的白色,而是使其更接近底色,即color=vec3(0)。

接下来我会给底色color一个蓝天的颜色,当远山接近这个颜色的时候,也可以理解为接近了雾色。

10-蓝天

蓝天的颜色是有渐变的,顶部更蓝,远方更白。

蓝天

相关代码如下:

// 天空
vec3 Sky(vec3 rd){
  // 基色
  vec3 basicColor=vec3(0.3, 0.5, 0.85);
  // 渐变色
  vec3 gradientColor = basicColor - rd.y * rd.y * 0.8;
  // 雾白
  vec3 fog=vec3(1);
  // 背景mix值
  float backMountainMix=pow(1.0 - max(rd.y, 0.0), 4.0);
  // 背景色
  vec3 backMountainColor = mix(gradientColor, fog, backMountainMix);
  return backMountainColor;
}

// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=Sky(rm.rd);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

天空的渐变插值就是RayMarch的光线推进方向rd 的y 值。

11-太阳

太阳在阳光洒下的方向,要从山的背面才能看见。

太阳

在天空的绘制方法Sky 中画一个太阳。

// 天空
vec3 Sky(vec3 rd){
  // ...
  // 推进方向与太阳光线方向的点积
  float sunDot = clamp(dot(rd, SUNLIGHT_DIRECTION), 0.0, 1.0);
  float sun=pow(sunDot,4096.)*0.5;
  
  return backMountainColor+sun;
}

太阳的位置可以用RayMarch 方向与阳光方向的点积确定。

12-白云

白云依旧可以用山峦的noise 算法绘制。

白云

相关代码如下:

// 云彩高度
#define CLOUD_HEIGHT 100.

// 白云
mat2 cloudMatrix=mat2(0.6,-0.8,0.8,0.6);
float FractalBrownianNoise(vec2 p){
  float a=0.;
  float fac=1.0;
  float max=fac;
  for(int i=0;i<4;i++){
    a+=fac*Noise(p*0.015).x;
    max+=fac;
    p=2.*cloudMatrix*p;
    fac*=0.5;
  }
  float n=smoothstep(0.5,max,a);
  return n*3.;
}
void AddCloud(out vec3 color,RayMarchData rm){
  vec3 ro=rm.ro;
  vec3 rd=rm.rd;
  vec3 cloudUV=ro+(CLOUD_HEIGHT-ro.y)/rd.y*rd;
  float f=FractalBrownianNoise(cloudUV.xz);
  color=mix(color,vec3(1,0.95,1),f);
}

vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=Sky(rm.rd);
  AddCloud(color,rm);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

这种会绘制云彩noise 有个专门的名称叫Fractal Brownian Noise。

云彩着色点的位置cloudUV 是从rayMarch 的原点ro 推导的。

image-20260505120811048

已知:

  • h 是云彩高度
  • ro 是rayMarch 的原点
  • rd 是rayMarch 的方向

求:rayMarch 推进到云彩上的位置P

解:

P=ro+((h-ro.y)/rd.y)*rd

13-整体代码

整体代码如下:

// 坐标系缩放
#define PROJECTION_SCALE  1.
// 相机视点位
#define CAMERA_POS vec3(6.55,2.4,12.02)
// 相机目标点
#define CAMERA_TARGET vec3(6.5,1,0.)
// 上方向
#define CAMERA_UP vec3(0, 1, 0)
// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.
// 光线推进的最远距离
#define RAYMARCH_FAR 200.
// 光线推进次数
#define RAYMARCH_NUM 512
// 光线推进精度,当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001
// 阳光方向
#define SUNLIGHT_DIRECTION normalize(vec3(0.5, 0.2, 0.2))
// 雾效近端距离
#define FOG_NEAR 20.
// 雾效远端距离
#define FOG_FAR FOG_NEAR+10.
// 云彩高度
#define CLOUD_HEIGHT 100.

// RayMarch 数据的结构体
struct RayMarchData {
   // 射线碰撞到的着色点位置
  vec3 ro;
  // 推进距离
  float t;
  // 推进方向
  vec3 rd;
};

// 坐标系
vec2 Coord(in vec2 fragColor) {
  return PROJECTION_SCALE * 2. * (fragColor - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

// 视图旋转矩阵
mat3 RotateMatrix() {
  //基向量c,视线
  vec3 c = normalize(CAMERA_TARGET-CAMERA_POS );
  //基向量a,视线和上方向的垂线
  vec3 a = cross(c, CAMERA_UP);
  //基向量b,修正上方向
  vec3 b = cross(a, c);
  //正交旋转矩阵
  return mat3(a, b, c);
}

// 二维随机
float Random(vec2 pos){
  vec3 v=fract(vec3(pos.xyx)*.1031);
  v+=dot(v,v.yzx+33.33);
  return fract((v.x+v.y)*v.z);
}

// 三维噪波
vec3 Noise(vec2 p){
  vec2 i=floor(p);
  vec2 f=fract(p);
  vec2 u=3.*f*f-2.*f*f*f;
  // u的偏导函数
  vec2 du=6.*f-6.*f*f;
  float a=Random(i);
  float b=Random(i+vec2(1,0));
  float c=Random(i+vec2(0,1));  
  float d=Random(i+vec2(1,1));
  // 让半山腰的山凹下去
  float x=a+(b-a)*u.x*(1.-u.y)+(c-a)*(1.-u.x)*u.y+(d-a)*u.x*u.y;
  //x的偏导函数
  vec2 yz=du*(vec2(b-a,c-a)+(a-b-c+d)*u.yx);
  return vec3(x,yz);
}

// 山体变换矩阵
// 2可使山体每次迭代后更密集,增加山体细节;旋转矩阵可使山体更加随机、自然
mat2 mountainTF=2.*mat2(0.6,-0.8,0.8,0.6);
// 山峦生成器
float MountainFn(vec2 p,int len){
  // 山峰高度
  float a=0.;
  // 山峰高度的增量,每次迭代后增量减半,使得山峰越高,增量越小
  float b=1.;
  // 山峰斜率的累积,越陡峭,斜率越大,增量越小。即山峰陡峭处更平滑。
  vec2 d=vec2(0);
  for(int i=0;i<len;i++){
    // 1.65可使山体更高; p*0.5可使山体更平缓
    vec3 n=1.65*Noise(p*0.5);
    a+=b*n.x/(1.+dot(d,d));
    // 累积斜率
    d+=n.yz;
    // 变换采样点,使得山体更自然
    p=mountainTF*p;
    // 减小山峰高度的增量
    b*=0.56;
  }
  return a;
}
// 法线贴图
float MountainNormalFn(vec2 p){
  return MountainFn(p,12);
}
// 山峦
float Mountain(vec2 p){
  return MountainFn(p,6);
}
// 计算法线
vec3 MountainNormal(vec3 p,float t){
  // 一种极小值
  vec2 epsilon=vec2(0.001*t,0);
  // 法线
  return normalize(vec3(
    MountainNormalFn(p.xz-epsilon.xy)-MountainNormalFn(p.xz+epsilon.xy),
    2.0*epsilon.x,
    MountainNormalFn(p.xz-epsilon.yx)-MountainNormalFn(p.xz+epsilon.yx)
  ));
}

// 山峦SDF
float MountainSDF(vec3 pos){
  return pos.y-Mountain(pos.xz);
}

// 光线推进
RayMarchData rayMarch(vec2 coord, vec3 ro){
  // 光线推进方向
  vec3 rd=normalize(RotateMatrix()*vec3(coord,2.));
  // 光线推进数据
  RayMarchData rm = RayMarchData(vec3(0),0.,rd);
  float t=0.;
  for(int i=0;i<RAYMARCH_NUM;i++){
    vec3 p=ro+t*rd;
    float h=MountainSDF(p);
    rm.ro=p;
    // RAYMARCH_PRECISION 受到推进距离t的加权
    if(abs(h)<RAYMARCH_PRECISION*t||t>RAYMARCH_FAR){
      break;
    }
    // 缩小推进距离
    t+=0.1*h;
  }
  rm.t=t;
  return rm;
}

// 软阴影
float SoftShadow(in vec3 ro, in vec3 rd, float k) {
  float res = 1.;
  for(float t = RAYMARCH_NEAR; t < RAYMARCH_FAR;) {
    float h = MountainSDF(ro + rd * t);
    if(h < RAYMARCH_PRECISION) {
      return 0.;
    }
    res = min(res, k * h / t);
    t += h;
  }
  return res;
}
// 阳光
void AddSunLight(out vec3 color,vec3 p,float t,vec3 n) {
  // 阳光照明
  float l=max(dot(SUNLIGHT_DIRECTION, n), 0.);
  vec3 color1=vec3(0.02, 0.04, 0.11);
  vec3 color2=vec3(1.0);
  color=mix(color1,color2,l);
  // 阳光投影
  float f = SoftShadow(p, SUNLIGHT_DIRECTION, 8.);
  vec3 color3=vec3(0.05, 0.0, 0.09);
  vec3 color4=vec3(0.99, 0.96, 1.0);
  vec3 shadow=mix(color3,color4,f);
  color *= shadow;
}
// 环境光
void AddEnvironmentLight(out vec3 color,vec3 n){
  float l=sqrt(max(dot(vec3(0,1,0), n), 0.));
  vec3 color1=vec3(0.29, 0.32, 0.36);
  vec3 color2=vec3(1.0);
  vec3 ambientColor=mix(color1,color2,l);
  color=mix(color,ambientColor,0.3);
}
// 雾效
void AddFog(out vec3 color,vec3 fogColor,float t){
  color=mix(color,fogColor,smoothstep(FOG_NEAR,FOG_FAR,t));
}
// 天空
vec3 Sky(vec3 rd){
  // 基色
  vec3 basicColor=vec3(0.3, 0.5, 0.85);
  // 渐变色
  // rd.y∈[-1,1],rd.y * rd.y∈[0,1],rd.y * rd.y * 0.8∈[0,0.8]
  vec3 gradientColor = basicColor - rd.y * rd.y * 0.8;
  // 雾白
  vec3 fog=vec3(1);
  // 背景mix值
  float backMountainMix=pow(1.0 - max(rd.y, 0.0), 4.0);
  // 背景色
  vec3 backMountainColor = mix(gradientColor, fog, backMountainMix);
  
  // 推进方向与太阳光线方向的点积
  float sunDot = clamp(dot(rd, SUNLIGHT_DIRECTION), 0.0, 1.0);
  float sun=pow(sunDot,4096.)*0.5;
  
  return backMountainColor+sun;
}
// 白云
mat2 cloudMatrix=mat2(0.6,-0.8,0.8,0.6);
float FractalBrownianNoise(vec2 p){
  float a=0.;
  float fac=1.0;
  float max=fac;
  for(int i=0;i<4;i++){
    a+=fac*Noise(p*0.03).x;
    max+=fac;
    p=2.*cloudMatrix*p;
    fac*=0.5;
  }
  float n=smoothstep(0.5,max,a);
  return n*2.2;
}
void AddCloud(out vec3 color,RayMarchData rm){
  vec3 ro=rm.ro;
  vec3 rd=rm.rd;
  vec3 cloudUV=ro+(CLOUD_HEIGHT-ro.y)/rd.y*rd;
  float f=FractalBrownianNoise(cloudUV.xz);
  color=mix(color,vec3(1,0.95,1),f);
}
// 渲染
vec3 render(vec2 coord){
  RayMarchData rm=rayMarch(coord,CAMERA_POS);
  vec3 color=Sky(rm.rd);
  AddCloud(color,rm);
  if(rm.t<RAYMARCH_FAR){
    vec3 n=MountainNormal(rm.ro,rm.t);
    vec3 basicColor=vec3(1.0);
    AddSunLight(basicColor,rm.ro,rm.t,n);
    AddEnvironmentLight(basicColor,n);
    AddFog(basicColor,color,rm.t);
    color=basicColor;
  }
  return color;
}

void mainImage(out vec4 fragColor,in vec2 fragCoord){
  vec2 coord=Coord(fragCoord);
  vec3 col=render(coord);
  fragColor=vec4(col,1);, 
}

总结

这一章我们说了杂色、栅格、山峦、阳光、补光、蓝天和白云的绘制,我们把更多的关注点都放在了山峦的形状上,而渲染效果并不是太真实。

后面我会再重点研究基于PBR的渲染。

参考链接:www.bilibili.com/video/BV18P…

Bash Split String: Split a String by Delimiter

When a script reads a CSV row, a colon-separated config line, or a piece of user input, one of the first things you usually need to do is break that string into its individual fields. Bash does not have a dedicated split function, but the shell gives you several ways to do the job with nothing more than built-ins and common core utilities.

This guide explains how to split a string by a delimiter in Bash using read, the IFS variable, tr, awk, and parameter expansion, with safe examples you can drop straight into a script.

The IFS Variable

Word splitting in Bash is controlled by the internal field separator, stored in the IFS variable. By default, IFS contains a space, a tab, and a newline, which is why unquoted expansions split on whitespace. When you set IFS to a single character such as a comma or a colon, Bash uses that character to decide where one field ends and the next begins.

Most of the techniques below work by changing IFS for one command only, so the rest of the script keeps the default behavior.

Splitting a String with read

The cleanest way to split a string into named variables is the read built-in combined with a temporary IFS:

sh
#!/bin/bash

STR="ubuntu:debian:fedora:arch"
IFS=":" read -r first second third fourth <<< "$STR"

echo "$first"
echo "$second"
echo "$third"
echo "$fourth"

Running the script produces one field per line:

output
ubuntu
debian
fedora
arch

The IFS=":" assignment applies only to the read command that follows it, so the global IFS stays untouched. The here-string (<<<) feeds the string into read on standard input, and -r keeps backslashes literal so they are not treated as escape characters.

Splitting Into a Bash Array

When the number of fields is not known ahead of time, split the string into an array instead of a fixed list of variables. Pass -a to read to tell it the target is an array:

sh
#!/bin/bash

STR="1,2,3,4,5"
IFS="," read -r -a numbers <<< "$STR"

for n in "${numbers[@]}"; do
 echo "$n"
done

The numbers array now holds one value per comma-separated field, and the loop prints each one on its own line. Always expand arrays with "${numbers[@]}" inside double quotes so elements that contain spaces stay intact.

For a deeper look at what else arrays can do, see our guide on Bash arrays .

Splitting With a Multi-Character Delimiter

IFS treats each character in its value as a separate delimiter, so setting IFS="::" will still split on single colons. For a multi-character delimiter such as :: or -, convert it to a single character first with sed or use parameter expansion:

sh
#!/bin/bash

STR="alpha::beta::gamma"
IFS="|" read -r -a parts <<< "${STR//::/|}"

for p in "${parts[@]}"; do
 echo "$p"
done

The ${STR//::/|} expansion replaces every occurrence of :: with a pipe character, and then read splits the resulting string on the pipe. Pick a placeholder character that you are sure the input does not contain.

Splitting With tr

When you only need the fields printed one per line, tr is often enough. It translates the delimiter into a newline:

Terminal
printf '%s\n' "red,green,blue" | tr ',' '\n'
output
red
green
blue

This approach pairs well with pipelines. For example, you can count the number of fields by piping the output into wc -l, or loop over the values with a while read block:

sh
#!/bin/bash

STR="red,green,blue"

while IFS= read -r color; do
 echo "Color: $color"
done < <(printf '%s\n' "$STR" | tr ',' '\n')

The process substitution <(...) sends the output of the pipeline into the loop without using a subshell for the loop body, so any variables you set inside the loop remain available after it ends.

Splitting With awk

For more structured splitting, awk is the right tool. It reads input one record at a time and exposes each field as $1, $2, and so on. Use -F to set the field separator:

Terminal
echo "jane:x:1001:1001:Jane Doe:/home/jane:/bin/bash" | awk -F':' '{ print $1, $5 }'
output
jane Jane Doe

awk is also a good pick when the delimiter is a regular expression, when you need to skip a header line, or when you want to act on a specific field without touching the rest. For more patterns, see our awk command guide .

Splitting With Parameter Expansion

Bash parameter expansion can pull a prefix or a suffix off a string without calling any external command. This is the fastest option when you only need one part, not all of them:

sh
#!/bin/bash

PATH_LINE="/usr/local/bin/script.sh"

BASE="${PATH_LINE##*/}"
DIR="${PATH_LINE%/*}"

echo "Directory: $DIR"
echo "Basename: $BASE"
output
Directory: /usr/local/bin
Basename: script.sh

##*/ strips the longest match of */ from the start of the string, leaving the basename. %/* strips the shortest match of /* from the end, leaving the directory. The same pattern works for any delimiter, not only the slash.

For full coverage of these expansions, see our guide on Bash string manipulation .

Preserving Empty Fields

A common surprise with read is that trailing empty fields can be dropped. Consider the string a,,b,:

sh
#!/bin/bash

STR="a,,b,"
IFS="," read -r -a fields <<< "${STR},"

echo "Count: ${#fields[@]}"
printf '[%s]\n' "${fields[@]}"
output
Count: 4
[a]
[]
[b]
[]

Bash keeps empty fields in the middle, but read -a drops a trailing empty field when the delimiter is the last character. Appending one extra delimiter before reading gives read a final empty value to assign, which preserves the trailing field from the original string.

Quick Reference

The table below summarizes which technique to reach for based on what the script needs to do.

Goal Technique
Split into named variables IFS=":" read -r a b c <<< "$STR"
Split into an array IFS="," read -r -a arr <<< "$STR"
Multi-character delimiter Replace with ${STR//::/|} first, then split
Print one field per line printf '%s\n' "$STR" | tr ',' '\n'
Select specific fields awk -F',' '{ print $2 }'
Take prefix or suffix only ${STR%%...} / ${STR##...}

For a printable quick reference, see the bash cheatsheet .

FAQ

How do I split a string by whitespace in Bash?
Leave IFS at its default and use read -r -a arr <<< "$STR". Any run of spaces, tabs, or newlines will act as a separator.

Why does my array lose the last empty field?
If the delimiter is at the very end of the string, read -a can drop the trailing empty element. Append one extra delimiter before reading, for example IFS=',' read -r -a arr <<< "${STR},", when the trailing empty field matters.

Can I split on a newline?
Yes. For line-based strings, use mapfile -t lines <<< "$STR" so each input line becomes one array element. Plain read stops at the first newline unless you change how it reads the input.

Is there a built-in split function in Bash?
No. Word splitting driven by IFS plus the read built-in is the idiomatic replacement for a dedicated split function.

Conclusion

Splitting strings in Bash comes down to picking the right tool for the shape of the input: read with IFS for structured parsing, tr or awk for pipelines, and parameter expansion for quick prefix or suffix work. When the input is untrusted, quote every expansion and handle empty fields on purpose rather than by accident.

第2课:5分钟!用 Trae AI 生成你的第一个后端服务(Bunjs + Elysia)

作者 铁皮饭盒
2026年5月5日 19:28

本文目标: 先建立全局认知,再让 Trae AI 写代码😁

不写一行代码,让 Trae AI 帮你搞定


今天你会学到这些关键词

| 关键词 | 一句话解释 | | :-- | :-- | | Trae | 字节跳动的 AI 编程 IDE,内置 AI 助手,免费使用 | | AGENTS.md | 项目配置文件,告诉 AI 你的技术栈和规范 | | Bun.js | 超快的 JavaScript 运行时,比 Node.js 快 4 倍 | | Elysia | 高性能 Web 框架,比 Express 快 21 倍 |

一句话总结:用 Trae 读取 AGENTS.md,生成基于 Bun.js + Elysia 的后端代码。

图片


⚠️ 【核心提示】本课程最重要的是学会用概念描述需求,让 Trae 生成代码。掌握这个思路后,你可以轻松切换到 Node.js、Python、Java 等任何语言!


今天我们要做什么?

上节课我们用 Express.js 理解了后端的核心逻辑:

接收请求 → 解析验证 → 业务处理 → 数据库操作 → 返回响应

今天,我们用 5 分钟,让 Trae 生成一个完整的后端服务

为什么用 Elysia 框架?

本课程核心是开发思路和提示词工程,并不限制具体编程语言。选择 Elysia 是因为:

  • • 🔥 性能更强:Elysia 是目前性能最强的 TypeScript 框架,基于 TechEmpower Benchmark 官方测试数据:

    | 框架 | 运行时 | 吞吐量 (请求/秒) | 相对性能 | | :-- | :-- | :-- | :-- | | Elysia | Bun | 2,454,631 | 基准 | | Gin | Go | 576,919 | 4.3x 慢 | | Fastify | Node | 415,680 | 5.9x 慢 | | Express | Node | 113,117 | 21.7x 慢 | | NestJS | Node | 105,064 | 23.4x 慢 |

    💡 简单说:Elysia 比 Express 快 21 倍,比 Fastify 快 6 倍,性能堪比 Go 和 Rust 框架!

  • • 🚀 开发体验好:链式 API 设计,代码更简洁优雅

  • • 📦 类型安全:端到端类型安全,自动类型推断

  • • ⚡ 跨运行时:同时支持 Bun 和 Node.js

Elysia 也支持 Node.js!

虽然 Elysia 在 Bun 上性能最佳,但它完全兼容 Node.js

# Node.js 用户安装
npm install elysia
npm install @elysiajs/node  # Node 适配器
// Node.js 版本入口文件
import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

const app = new Elysia()
  .get("/", () => ({ message: "Hello from Node.js!" }))
  .listen(3000, node);  // 使用 node 适配器

本课程使用 Bun 运行,因为更轻量、启动更快。但你可以放心,学到的 Elysia 知识在 Node.js 中完全适用!

你将获得:

  • • ✅ 一个能运行的 HTTP 服务器

  • • ✅ 两个 API 接口

  • • ✅ 请求日志记录

全程不写代码,只写提示词。


第0步:了解 Trae 和 AGENTS.md(推荐)

什么是 Trae?

Trae 是字节跳动推出的 AI 原生 IDE,类似于 Cursor,专为 AI 编程而生:

  • • 🤖 内置 AI 助手:无需配置 API Key,开箱即用

  • • 💬 自然语言编程:用中文描述需求,AI 直接生成代码

  • • 🔧 代码自动补全:比 GitHub Copilot 更智能的上下文理解

  • • 🌟 免费使用:目前完全免费,无额度限制

本课程所有代码都通过 Trae 的 Auto 模型 生成,你只需要复制提示词,Trae 就能帮你写出符合规范的后端代码。

什么是 AGENTS.md?

在开始前,介绍一个提升 AI 生成代码质量的神器 —— AGENTS.md

什么是 AGENTS.md?

AGENTS.md 是放在项目根目录的配置文件,类似于 Cursor 的 .cursorrules。它告诉 AI:

  • • 项目使用什么技术栈

  • • 代码应该遵循什么规范

  • • 文件应该如何组织

为什么用它?

不用 AGENTS.md:

  • • 每次都要在提示词里重复技术栈

  • • AI 生成的代码风格不统一

  • • 需要反复修改才能符合项目规范

使用 AGENTS.md:

  • • 一次定义,永久生效

  • • AI 自动遵循项目规范

  • • 生成的代码直接可用

创建 AGENTS.md

在项目根目录创建 AGENTS.md 文件:

# 项目规范

## 技术栈
- Bun.js + TypeScript
- Elysia 框架(高性能 Web 框架)
- SQLite 数据库(后续课程)

## 代码规范
- 使用 TypeScript 严格模式
- 统一返回格式 { success, data, message }
- 路由使用 RESTful 规范
- 所有 SQL 使用 :name 占位符(防注入)

## 文件结构
- index.ts - 主入口
- db.ts - 数据库连接(后续添加)
- routes/ - 路由定义(后续添加)

在 Trae 中使用

现在,让我们开始创建第一个后端服务!


第1步:安装运行环境(1分钟)

💡 提示:不想安装 Bun?用 Node.js 也可以!

如果你电脑上已经有 Node.js,可以直接跳到第2步,用 npm 代替 bun 安装依赖。

方案 A:安装 Bun(推荐,更轻量更快)

Bun 是什么?

一个超快的 JavaScript 运行时,开箱即用,无需配置。

🚀 为什么推荐 Bun?未来趋势洞察

2025年,Bun 被 Claude 母公司 Anthropic 收购。这意味着什么?

  • • AI 原生支持:未来 AI 自动建站工具很可能默认基于 Bun 运行时

  • • 全栈一体化:Bun = Node.js + Webpack + Jest + npm

  • • TSX 原生支持:直接运行 TypeScript + JSX

  • • 边缘计算:比 Node.js 更轻量,适合 IoT 和边缘部署

💡 预测:未来你只需要输入"帮我做一个电商网站",AI 就能直接生成基于 Bun 的完整全栈应用。

🔥 Bun 真的太强了! 启动速度快 4 倍,内存占用少一半。

包管理速度对比:

| 工具 | 安装时间 | 相对速度 | | :-- | :-- | :-- | | Bun | ~391ms | 基准 ⚡ | | pnpm | ~1,023ms | 2.6x 慢 | | Yarn | ~3,206ms | 8.2x 慢 | | npm | ~4,503ms | 11.5x 慢 |

💡 bun install 比 npm install 快 11 倍

强烈建议:尽量用 Bun!这是面向未来的投资

安装命令:

# macOS / Linux
curl -fsSL https://bun.sh/install | bash

# Windows
powershell -c "irm bun.sh/install.ps1|iex"

📦 命令行下载失败? 可以使用网盘下载:123云盘1859113306.share.123865.com/123pan/na8c…

下载后解压,将 bun.exe 放到系统 PATH 目录即可使用。

Windows 具体操作步骤:

  1. 1. 解压下载的文件,找到 bun.exe

  2. 2. 将 bun.exe 复制到 C:\Windows\System32 目录(需要管理员权限)

  3. 3. 或者创建一个目录如 C:\bun,将 bun.exe 放进去,然后添加到 PATH:

  • • 右键"此电脑" → 属性 → 高级系统设置 → 环境变量

  • • 在"系统变量"中找到 Path,双击编辑

  • • 点击"新建",输入 C:\bun,确定保存

  1. 4. 打开新的命令提示符窗口,输入 bun --version 验证

验证安装:

bun --version
# 输出类似:1.1.0

方案 B:使用 Node.js(如果你已有 Node.js)

如果你不想安装 Bun,Node.js 完全没问题

检查 Node.js 版本:

node --version
# 建议 v18 以上

后续步骤中,把 bun 换成 npm 或 npx 即可:

  • • bun add elysia → npm install elysia

  • • bun run index.ts → npx ts-node index.ts(需先安装 ts-node

✅ 搞定!环境准备完成。


第2步:创建项目(30秒)

Bun 用户

mkdir my-first-backend
cd my-first-backend
bun init -y

安装 Elysia:

bun add elysia

Node.js 用户

mkdir my-first-backend
cd my-first-backend
npm init -y
npm install typescript @types/node ts-node --save-dev
npx tsc --init

安装 Elysia 和 Node 适配器:

npm install elysia @elysiajs/node

修改 package.json 添加启动脚本:

{
  "scripts": {
    "dev""ts-node index.ts"
  }
}

这会生成一个基本的项目结构:

my-first-backend/
├── package.json
├── tsconfig.json
└── index.ts

(可选)创建 AGENTS.md:

echo "# 项目规范

## 技术栈
- Bun.js + TypeScript
- Elysia 框架

## 代码规范
- 统一返回格式 { success, data, message }
- RESTful API 设计" > AGENTS.md

第3步:复制提示词给 AI(1分钟)

在 Trae 中,使用 Auto 模型,复制这段提示词:

用 Elysia 框架创建一个 HTTP 服务器

要求:
1. 监听端口 3000
2. 实现两个路由:
   - GET / 返回 { message: "Hello World", time: 当前时间 }
   - GET /time 返回 { timestamp: 时间戳, iso: ISO格式时间 }
3. 添加请求日志:记录每个请求的方法和路径
4. 启动时打印:Server running at http://localhost:3000

请生成完整的 TypeScript 代码,保存到 index.ts

Trae 会生成类似这样的代码:

import { Elysia } from "elysia";

const app = new Elysia()
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })
  .get("/", () => ({
    message: "Hello World",
    time: new Date().toLocaleString()
  }))
  .get("/time", () => {
    const now = new Date();
    return {
      timestamp: now.getTime(),
      iso: now.toISOString()
    };
  })
  .listen(3000);

console.log(`Server running at http://localhost:${app.server?.port}`);

把代码复制到 index.ts 文件中。

💡 Node.js 用户注意:需要修改代码使用 Node 适配器

import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

const app = new Elysia()
  .get("/", () => ({ message: "Hello World" }))
  .listen(3000, node);  // ← 加上 node 适配器

第4步:运行!(30秒)

Bun 用户

bun run index.ts

Node.js 用户

npm run dev

看到输出:

Server running at http://localhost:3000

✅ 恭喜你,后端服务跑起来了!


第5步:测试接口(2分钟)

打开浏览器,访问:

http://localhost:3000

看到返回:

{
  "message""Hello World",
  "time""2024/1/15 10:30:00"
}

再访问:

http://localhost:3000/time

看到返回:

{
  "timestamp": 1705312200000,
  "iso""2024-01-15T02:30:00.000Z"
}

查看终端日志:

GET /
GET /time

✅ 完美!你的第一个后端服务正常运行。


代码解析

让我们看看 AI 生成的代码,理解它在做什么:

import { Elysia } from "elysia";

const app = new Elysia()           // 创建 Elysia 应用实例
  .onRequest(({ request }) => {    // 注册请求拦截器(日志)
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })
  .get("/", () => ({               // 定义 GET / 路由
    message: "Hello World",
    time: new Date().toLocaleString()
  }))
  .get("/time", () => {             // 定义 GET /time 路由
    const now = new Date();
    return {
      timestamp: now.getTime(),
      iso: now.toISOString()
    };
  })
  .listen(3000);                    // 监听 3000 端口

对应我们上节课学的 5 步法:

| 步骤 | 代码体现 | | :-- | :-- | | 接收请求 | new Elysia()  创建应用,.listen() 监听端口 | | 解析验证 | .get()  自动匹配路由和方法 | | 业务处理 | 路由回调函数中返回不同的数据(时间、消息等) | | 操作数据 | 本例无数据库,直接返回当前时间 | | 返回响应 | 直接返回对象,Elysia 自动序列化为 JSON |

Elysia vs 原生 Bun.serve

| 特性 | 原生 Bun.serve | Elysia 框架 | | :-- | :-- | :-- | | 代码量 | 较多,需手动处理路由 | 简洁,链式 API | | 路由管理 | 手动 if/else 判断 | 声明式 .get()``.post() | | 中间件 | 需自行实现 | 内置 .onRequest() | | 类型安全 | 无 | 端到端类型推断 | | 性能 | 快 | 更快(优化过的路由匹配) |

常用中间件示例

Elysia 有丰富的官方插件生态,常用中间件使用示例:

import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";        // 跨域
import { swagger } from "@elysiajs/swagger";  // API 文档
import { jwt } from "@elysiajs/jwt";          // JWT 认证
import { staticPlugin } from "@elysiajs/static"; // 静态文件

const app = new Elysia()
  // 跨域支持
  .use(cors({
    origin: "*",  // 允许所有域名,生产环境建议指定具体域名
    methods: ["GET""POST""PUT""DELETE"]
  }))
  
  // Swagger API 文档(访问 /swagger)
  .use(swagger({
    documentation: {
      info: {
        title: "我的 API",
        version: "1.0.0"
      }
    }
  }))
  
  // JWT 认证
  .use(jwt({
    secret: "your-secret-key",
    exp: "7d"  // 7天过期
  }))
  
  // 静态文件服务(public 目录)
  .use(staticPlugin({
    prefix: "/static",  // 访问路径前缀
    assets: "public"    // 本地目录
  }))
  
  // 自定义中间件:统一响应格式
  .onAfterHandle(({ response, set }) => {
    // 如果已经是标准格式,直接返回
    if (response && typeof response === "object" && "success" in response) {
      return response;
    }
    // 包装成标准格式
    return {
      success: true,
      data: response,
      message: "操作成功"
    };
  })
  
  // 错误处理中间件
  .onError(({ code, error, set }) => {
    console.error(`错误 [${code}]:`, error);
    set.status = code === "NOT_FOUND" ? 404 : 500;
    return {
      success: false,
      data: null,
      message: error.message || "服务器内部错误"
    };
  })

  .get("/", () => "Hello World")
  .listen(3000);

安装这些插件:

# Bun
bun add @elysiajs/cors @elysiajs/swagger @elysiajs/jwt @elysiajs/static

# Node.js
npm install @elysiajs/cors @elysiajs/swagger @elysiajs/jwt @elysiajs/static

如果出错了怎么办?

问题1:端口被占用

错误信息:

error: Failed to start server. Is port 3000 in use?

解决: 换一个端口

.listen(3001)  // 改成 3001

问题2:模块找不到

错误信息:

error: Cannot find module 'elysia'

解决: 安装依赖

bun add elysia

问题3:代码报错

把错误信息复制给 Trae:

我的代码报错了:
[粘贴错误信息]

请帮我修复。

问题4:浏览器访问没反应

检查:


核心收获

今天你用 5 分钟完成了:

✅ 安装 Bun 环境✅ 创建后端项目✅ 了解 AGENTS.md 的作用✅ 用 Trae 生成 Elysia 代码✅ 运行并测试服务

全程只写了 1 段提示词,0 行代码。


下节课预告

第3课:路由是什么?怎么设计好懂的 API?

我们将:

  • • 理解路由的概念

  • • 学习 RESTful API 设计

  • • 用 AI 生成一个用户管理 API


思考题:

试着修改提示词,让 AI 添加一个新接口 /hello?name=张三,返回 { message: "你好,张三" }

欢迎在评论区分享你的提示词。


如果觉得有帮助,欢迎点赞、在看、转发。

构建无障碍组件之Tooltip Pattern

作者 anOnion
2026年5月5日 17:58

Tooltip Pattern 详解:构建无障碍的提示信息组件

Tooltip(提示框,也称为 PopoverHintInfo BubbleHelp Text)是一种弹出式信息组件,当元素获得键盘焦点或鼠标悬停时显示相关信息。本文基于 W3C WAI-ARIA Tooltip Pattern 规范,详解如何构建无障碍的 Tooltip 组件。

注意:此设计模式仍在完善中,尚未获得任务组共识。进展和讨论记录在 aria-practices 仓库的 issue 128 中。

一、Tooltip 的定义与核心概念

1.1 什么是 Tooltip

Tooltip 是一种弹出式信息组件,具有以下特征:

  • 触发元素获得焦点鼠标悬停时显示
  • 通常有短暂的延迟后才会出现
  • Escape 键或鼠标移出时消失
  • 不接收焦点,焦点始终保持在触发元素上
  • 如果悬停内容包含可聚焦元素,应使用非模态对话框(non-modal dialog)

1.2 核心术语

术语 说明
Trigger Element 触发 Tooltip 显示的元素
Tooltip Container 包含 Tooltip 内容的容器
Delay 显示 Tooltip 前的延迟时间
Dismiss 关闭 Tooltip 的行为
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  Trigger Element                            │    │    │
│  │  │  (button / link / icon)                     │    │    │
│  │  │                                             │    │    │
│  │  │  ┌─────────────────────────────────────┐    │    │    │
│  │  │  │  role="tooltip"                     │    │    │    │
│  │  │  │                                     │    │    │    │
│  │  │  │  Tooltip content appears here       │    │    │    │
│  │  │  │  when trigger is focused or hovered │    │    │    │
│  │  │  │                                     │    │    │    │
│  │  │  └─────────────────────────────────────┘    │    │    │
│  │  │         ↑                                   │    │    │
│  │  │         aria-describedby                    │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  │  Keyboard: Escape to dismiss                        │    │
│  │  Focus: Stays on trigger element                    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.3 典型应用场景

  • 图标按钮说明:解释图标按钮的功能
  • 表单字段提示:提供输入格式要求
  • 缩写解释:解释专业术语或缩写
  • 额外信息:提供补充说明而不占用界面空间
  • 快捷键提示:显示键盘快捷键

二、WAI-ARIA 角色与属性

2.1 基本角色

Tooltip 使用 role="tooltip" 标记。

<button
  aria-describedby="tooltip-id"
  aria-label="保存">
  💾
</button>

<div
  id="tooltip-id"
  role="tooltip"
  class="tooltip">
  保存当前文档 (Ctrl+S)
</div>

2.2 必需属性

属性 说明 示例值
role="tooltip" 标记为提示框角色 -
aria-describedby 触发元素引用 Tooltip "tooltip-id"

2.3 属性详解

aria-describedby

触发元素通过 aria-describedby 属性引用 Tooltip 元素,让辅助技术知道该元素有额外的描述信息:

<!-- 触发元素 -->
<button
  aria-describedby="save-tooltip"
  aria-label="保存">
  💾
</button>

<!-- Tooltip -->
<div
  id="save-tooltip"
  role="tooltip">
  保存当前文档 (Ctrl+S)
</div>

重要提示

  • aria-describedby 应该指向 Tooltip 的 id
  • 即使 Tooltip 当前不可见,aria-describedby 也应该存在
  • 辅助技术会在用户聚焦到触发元素时读出描述信息

三、键盘交互规范

3.1 基本键盘交互

按键 功能
Escape 关闭 Tooltip

3.2 焦点行为

  • 焦点始终保持在触发元素上,Tooltip 不接收焦点
  • 如果 Tooltip 在触发元素获得焦点时显示,则在元素失去焦点时关闭
  • 如果 Tooltip 在鼠标悬停时显示,则在鼠标移出触发元素或 Tooltip 时关闭

3.3 显示与隐藏逻辑要点

实现 Tooltip 的显示与隐藏需要考虑以下要点:

  • 延迟显示:通常设置 500ms 延迟,避免鼠标快速划过时频繁触发
  • 延迟隐藏:通常设置 100ms 延迟,给用户足够时间将鼠标移到 Tooltip 上
  • 焦点触发:元素获得焦点时立即显示,失去焦点时隐藏
  • 键盘关闭:监听 Escape 键关闭 Tooltip
  • 状态管理:使用 aria-hidden 控制可见性,配合 CSS 类名切换

四、实现方式

4.1 基础 Tooltip 结构

<!-- 触发元素 -->
<button
  class="tooltip-trigger"
  aria-describedby="save-tooltip"
  aria-label="保存">
  💾
</button>

<!-- Tooltip -->
<div
  id="save-tooltip"
  role="tooltip"
  class="tooltip"
  aria-hidden="true">
  保存当前文档 (Ctrl+S)
</div>

4.2 JavaScript 实现

class Tooltip {
  constructor(triggerElement, tooltipElement) {
    this.trigger = triggerElement;
    this.tooltip = tooltipElement;
    this.showDelay = 500; // 延迟显示时间(毫秒)
    this.hideDelay = 100; // 延迟隐藏时间(毫秒)
    this.showTimeout = null;
    this.hideTimeout = null;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.trigger.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
    this.trigger.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
    
    // 焦点事件
    this.trigger.addEventListener('focus', this.handleFocus.bind(this));
    this.trigger.addEventListener('blur', this.handleBlur.bind(this));
    
    // 键盘事件
    this.trigger.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseEnter() {
    this.clearHideTimeout();
    this.showTimeout = setTimeout(() => {
      this.show();
    }, this.showDelay);
  }

  handleMouseLeave() {
    this.clearShowTimeout();
    this.hideTimeout = setTimeout(() => {
      this.hide();
    }, this.hideDelay);
  }

  handleFocus() {
    this.show();
  }

  handleBlur() {
    this.hide();
  }

  handleKeyDown(e) {
    if (e.key === 'Escape') {
      this.hide();
    }
  }

  show() {
    this.tooltip.classList.add('tooltip-visible');
    this.tooltip.setAttribute('aria-hidden', 'false');
  }

  hide() {
    this.tooltip.classList.remove('tooltip-visible');
    this.tooltip.setAttribute('aria-hidden', 'true');
  }

  clearShowTimeout() {
    if (this.showTimeout) {
      clearTimeout(this.showTimeout);
      this.showTimeout = null;
    }
  }

  clearHideTimeout() {
    if (this.hideTimeout) {
      clearTimeout(this.hideTimeout);
      this.hideTimeout = null;
    }
  }
}

4.3 表单字段 Tooltip 示例

<div class="form-field">
  <label for="email">邮箱地址</label>
  <input
    type="email"
    id="email"
    aria-describedby="email-tooltip"
    placeholder="example@domain.com">
  <div
    id="email-tooltip"
    role="tooltip"
    class="tooltip"
    aria-hidden="true">
    请输入有效的邮箱地址,格式:username@domain.com
  </div>
</div>
const emailInput = document.getElementById('email');
const emailTooltip = document.getElementById('email-tooltip');
new Tooltip(emailInput, emailTooltip);

五、最佳实践

5.1 提供清晰的描述

Tooltip 内容应该简洁明了,提供有用的信息:

<!-- 好的示例:提供有用的信息 -->
<button
  aria-describedby="format-tooltip"
  aria-label="格式化">
  📝
</button>
<div
  id="format-tooltip"
  role="tooltip">
  格式化选中的文本 (Ctrl+B)
</div>

<!-- 不好的示例:信息冗余 -->
<button
  aria-describedby="bad-tooltip"
  aria-label="保存">
  💾
</button>
<div
  id="bad-tooltip"
  role="tooltip">
  点击此按钮可以保存您的文档
</div>

5.2 避免在 Tooltip 中包含可聚焦元素

如果 Tooltip 需要包含链接、按钮等可聚焦元素,应该使用非模态对话框(non-modal dialog)而不是 Tooltip:

<!-- 错误:Tooltip 中包含可聚焦元素 -->
<div role="tooltip">
  更多信息请<a href="/help">查看帮助文档</a>
</div>

<!-- 正确:使用非模态对话框 -->
<div
  role="dialog"
  aria-modal="false"
  aria-labelledby="dialog-title">
  <h2 id="dialog-title">更多信息</h2>
  <p>更多信息请<a href="/help">查看帮助文档</a></p>
  <button>关闭</button>
</div>

5.3 设置合理的延迟时间

  • 显示延迟:通常 500ms,避免用户快速移动鼠标时频繁显示
  • 隐藏延迟:通常 100ms,给用户足够的时间将鼠标移到 Tooltip 上

5.4 确保 Tooltip 可访问

5.5 考虑移动端体验

在移动设备上,Tooltip 通常通过点击触发:

// 检测触摸设备
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;

if (isTouchDevice) {
  // 触摸设备:点击触发
  trigger.addEventListener('click', (e) => {
    e.preventDefault();
    tooltip.toggle();
  });
} else {
  // 桌面设备:悬停触发
  trigger.addEventListener('mouseenter', () => tooltip.show());
  trigger.addEventListener('mouseleave', () => tooltip.hide());
}

5.6 提供视觉反馈

  • Tooltip 应该有明显的视觉样式(背景色、边框、阴影)
  • 显示/隐藏应该有平滑的过渡动画
  • 确保 Tooltip 不遮挡重要内容

六、常见错误

6.1 忘记设置 aria-describedby

<!-- 错误 -->
<button>💾</button>
<div role="tooltip">保存文档</div>

<!-- 正确 -->
<button aria-describedby="save-tooltip">💾</button>
<div id="save-tooltip" role="tooltip">保存文档</div>

6.2 Tooltip 接收焦点

<!-- 错误:Tooltip 不应该可聚焦 -->
<div role="tooltip" tabindex="0">...</div>

<!-- 正确:Tooltip 不接收焦点 -->
<div role="tooltip">...</div>

6.3 使用 title 属性代替 Tooltip

<!-- 错误:title 属性的可访问性支持不一致 -->
<button title="保存文档">💾</button>

<!-- 正确:使用 ARIA Tooltip -->
<button aria-describedby="save-tooltip">💾</button>
<div id="save-tooltip" role="tooltip">保存文档</div>

6.4 Tooltip 内容过长

Tooltip 应该简洁,如果内容过长,考虑使用其他组件:

<!-- 不好的示例:内容过长 -->
<div role="tooltip">
  这是一个非常长的说明文字,包含了大量的详细信息...
</div>

<!-- 好的示例:简洁明了 -->
<div role="tooltip">
  保存文档 (Ctrl+S)
</div>

七、Tooltip vs 其他组件

7.1 Tooltip vs Dialog

特性 Tooltip Dialog
焦点 不接收焦点 接收焦点
内容 纯文本信息 可包含交互元素
触发方式 悬停/焦点 点击/特定操作
关闭方式 Escape/移出 点击关闭按钮/遮罩
典型用例 简短说明 确认操作、表单填写

7.2 Tooltip vs Popover

特性 Tooltip Popover
内容复杂度 简单文本 可包含丰富内容
交互性 无交互 可包含交互元素
持久性 临时显示 可持久显示
典型用例 功能说明 详细信息展示

八、总结

构建无障碍的 Tooltip 组件需要关注:

  1. 正确的角色:使用 role="tooltip"
  2. 关联属性:触发元素使用 aria-describedby 引用 Tooltip
  3. 焦点管理:Tooltip 不接收焦点,焦点始终保持在触发元素
  4. 键盘交互:支持 Escape 键关闭
  5. 显示逻辑:合理的延迟显示和隐藏
  6. 内容简洁:提供有用的信息,避免冗余
  7. 避免可聚焦元素:Tooltip 中不包含链接、按钮等可聚焦元素
  8. 位置计算:确保 Tooltip 不超出视口边界

遵循 W3C Tooltip Pattern 规范,我们能够创建既实用又无障碍的提示信息组件,为所有用户提供清晰的辅助信息。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

Hermes Agent 从安装到生产:我的完整踩坑记录

作者 tool
2026年5月5日 17:31

Hermes Agent 从安装到生产:我的完整踩坑记录

事情是这样的。

前阵子我把 Hermes Agent 装到了树莓派上,配了六个 AI 分身跑在飞书群里,用了一个多月。中间踩了不少坑——安装、多智能体、记忆引擎、安全加固,每个环节都有「看起来简单,做起来全是细节」的时刻。

这篇文章把我所有的配置经验和踩坑记录整理在一起。从零开始到生产部署,看完直接抄作业。


一、安装

国内服务器直连 GitHub 太慢,用国内镜像。终端跑这一行:

curl -fsSL https://res1.hermesagent.org.cn/install.sh | bash

装完先别急着配,花半小时读文档。顺序建议:

文档不厚,但关键概念必须先搞清楚——Profile、Skill、Memory、Toolset 这四个东西的关系,后面全靠它们。


二、Skills

Skills 是 Hermes 的核心机制。每个 Skill 就是一个可复用的能力模块——可以是一个写作风格、一个发布流水线、一个配图工具。

加载 Skill 之后 Agent 就自动拥有这个能力,不需要每次手动描述。

官方 Skills 市场:hermes-agent.nousresearch.com/docs/skills


三、多智能体

这是 Hermes 最打动我的功能——同一个框架下可以跑好几个不同人设的 Agent,各干各的。

hermes profile list          # 查看智能体列表
hermes profile create editor # 创建
hermes --profile editor setup
hermes --profile editor chat
hermes --profile editor gateway start

我配了六个:

  • default — 通用对话,群聊里的日常问答
  • coder — 全栈开发,终端权限完整,能读写文件跑脚本
  • editor — 公众号运营,写稿改稿排版配图推草稿
  • point — 调研分析,规则设计、成本测算、数据复盘
  • riji — 日记助手,马伯庸风格的日记记录
  • sre — 安全运维,安全加固漏洞排查配置审计

9073dc1ec226785c660d0172f3a4c101.png

设置角色 Soul

每个 Agent 的角色定义写在 ~/.hermes/profiles/{name}/SOUL.md 里。改了之后重启生效。

~/.hermes/profiles/editor/SOUL.md
~/.hermes/profiles/coder/SOUL.md

设置用户偏好

用户身份和偏好写在 ~/.hermes/profiles/{name}/memories/USER.md,同样改完重启。

Memory 记忆

Hermes 有三层记忆:

  1. memory — 持久记忆,注入每轮对话的系统提示
  2. fact_store — 结构化深度记忆,支持实体解析和语义搜索
  3. session_search — 全文检索所有历史会话

全局共享记忆在 ~/.hermes/memories/MEMORY.md,所有智能体共享。

记忆的保存触发:

  • 每 5~10 轮对话自动精简写入
  • 执行 /save/sync/memory 命令立即整理
  • 重启时自动保存一次
  • 内容超阈值自动压缩

四、记忆引擎升级:从「隔天就忘」到「永远记得」

默认的 Holographic 引擎有一个致命问题——只在「会话结束时」保存记忆。

飞书群聊一直开着,同一条会话连跑好几天,永远不会「结束」。结果就是中间的内容一条都不存,隔天全忘。

换了 Hindsight 引擎,问题解决。

对比:

Holographic:手动触发保存、不自动召回、SQLite 存储、无去重 Hindsight:auto_retain 自动保留、auto_recall 自动召回、知识图谱+向量存储、实体解析去重

配置步骤:

pip install hindsight-client hindsight-embed

~/.hermes/.env 里配置 LLM Key(DeepSeek 就行,成本极低):

HINDSIGHT_LLM_API_KEY=sk-your-deepseek-key

创建 ~/.hermes/hindsight/config.json

{
  "mode": "local_embedded",
  "llm_provider": "openai_compatible",
  "llm_model": "deepseek-chat",
  "llm_base_url": "https://api.deepseek.com/v1",
  "retain_every_n_turns": 3,
  "recall_budget": "low",
  "retain_async": true,
  "auto_retain": true,
  "auto_recall": true
}

切换并重启:

hermes config set memory.provider hindsight
systemctl --user restart hermes-gateway
hermes memory status   # 验证:Provider: hindsight, Status: available

几个关键参数:

  • retain_every_n_turns: 3 — 每 3 轮存一次,太频繁浪费 token
  • recall_budget: low — 最低召回精度,够用
  • retain_async: true — 异步存储不阻塞回复

踩坑记录:

  • systemd gateway 不加载 .env,Key 必须同时写入 .env 和 service 文件
  • systemctl --user restart 替代 hermes gateway restart,后者会重写 service 文件导致手动配置丢失

五、接飞书:六个 AI 在同一个群里

六个 Agent 对应六个飞书 App,全在同一个飞书群里。@谁谁干活。

串联流水线:比如写公众号文章——先在群里跟 point 说「帮我调研这个工具的技术原理」,point 出报告 → 丢给 editor「写成公众号文章」→ editor 写稿配图推草稿。整个过程就在飞书群里发几条消息,没开电脑。

权限分级:普通人只能用 default 聊天,coder 和 editor 只有我的账号能调。

跑在树莓派上:树莓派 4B,8GB 内存,不跑本地模型只做调度。内存占用稳定在 2.9GB。夏天加个小风扇。

踩过的坑:

  • 飞书富文本 → Hermes Markdown 需要转换
  • 飞书 Webhook 超时 3 秒,Hermes 做复杂任务可能一两分钟,必须改成异步:收到消息立刻回「正在处理」,跑完再推送结果
  • 群聊消息做窗口管理,保留最近 20 条,防止上下文爆炸

六、安全加固:六层防御塔

把 Agent 接入生产环境,安全问题不能等到出事了再补。

Hermes 的安全是纵深防御体系。就算 LLM 被 Prompt 注入操控了,后面的层还能兜底。

72352f66db34237193ba7082c94dfafe.png

第一层:Tirith 防火墙

独立于 LLM 的审计算子,在实际执行工具调用之前拦一道。

pip install tirith

默认策略建议:

  • rm -rfchmod 777 → 直接阻断
  • sudosu → 直接阻断
  • curlwget 外网 → 走审批
  • git pushgit commit → 走审批
  • 读取 /etc/passwd 和 .env → 告警但不阻断

第二、三层:密钥脱敏 + 隐私脱敏

hermes config set security.redact_secrets true
hermes config set privacy.redact_pii true

API Key、手机号、身份证、邮箱、IP 地址自动打码。

密钥放 .env 文件,绝不写进 config.yaml。每 90 天轮换。

第四层:网络控制

hermes config set security.website_blocklist.enabled true
hermes config set security.allow_private_urls false

域名黑名单至少加 pastebin.comrequestbin.net——数据外泄和 SSRF 最常用的目标。

第五层:审批门禁

approvals:
  mode: manual
  cron_mode: deny

每次工具调用需要人工确认。禁止 Agent 自己建定时任务。

第六层:隐私边界

禁止访问内网地址——192.168.x.x、10.x.x.x、127.0.0.1 全部被拦。

必须开的配置项

配置
security.tirith_enabled true
security.redact_secrets true
privacy.redact_pii true
security.allow_private_urls false
approvals.mode manual
approvals.cron_mode deny
checkpoints true

监控:日常关注网关日志和审计日志。Tirith 每小时阻断超 5 次 = 🚨 查是不是被攻击。


七、好与不好

用了两周,真实感受:

好的地方:上手快、轻量、自动创建和优化 Skill。多 Profile 的设计很实用,六个人设各司其职。

不好的地方:Skill 多了会有重复,需要定期清理。这算是成长的烦恼——用着用着就积累了一堆,得手动整理。不过新版本解决这个问题:给skill库打分,合并相关技能、清理已死技能


八、一句话总结

装 Hermes → 读文档 → 配 Skills → 建多个 Profile → 接飞书 → 切 Hindsight 记忆引擎 → 开六层安全防御。

整个过程不复杂,就是细节多。建议别一上来就搞六个 Agent,先跑通一个 Profile,能用起来再说。

搞 Agent 最大的坑不是技术,是高估了自己对自动化的需求。很多东西你以为需要 AI,其实只需要一个写得好点的脚本。

有问题留言聊,或者你有类似实践也欢迎交流。

下次见。

别把语音 Agent 当成“接两个 API”——用 NestJS 搭一套 ASR + LLM + 流式 TTS 的实时语音助手

作者 swipe
2026年5月5日 16:17

我们现在看到的大多数 AI 助手,已经默认具备语音能力:你说一句话,它先把语音转成文字;大模型理解问题后,边生成文字边输出答案;最后,再把这段答案用自然语音朗读出来。

从表面看,这件事像是把三个能力串起来:

  • ASR(Automatic Speech Recognition,语音识别)
  • LLM(大模型推理)
  • TTS(Text To Speech,语音合成)

但真正做过这类系统,你会发现问题根本不在“有没有接上接口”,而在链路能不能协同工作

很多 Demo 的问题不是不能跑,而是体验不对:

  • 录音能识别,但只能整段上传,交互很生硬
  • 大模型能流式返回,但语音要等整段文本结束后才开始播放
  • 前端能显示文字,但音频播放一顿一顿
  • 文本和语音各走各的,最后很容易出现“字已经出完了,音频还没开始”
  • 一旦中间某条连接断掉,整条语音链路就会失去同步

所以,这篇文章我不打算把它写成“如何分别调用腾讯云 ASR、腾讯云 TTS 和大模型 API”的资料拼盘。我想讲清楚一个更关键的结论:

语音版 AI 助手真正的难点,不是单独把 ASR、LLM、TTS 跑通,而是把“上传式语音识别、SSE 文本流、WebSocket 二进制音频流、服务端事件桥接、前端流式播放”组织成一条低耦合、可持续输出的实时链路。

本文基于一个真实可运行的 NestJS 项目来展开,项目里已经具备这几部分能力:

  • 浏览器录音并上传到 /speech/asr
  • 服务端调用腾讯云 ASR 做语音转文字
  • 文本问题进入 /ai/chat/stream,以 SSE 形式流式输出
  • 服务端将大模型输出通过事件桥接给流式 TTS
  • 腾讯云流式 TTS 返回二进制音频,通过 WebSocket 推给前端
  • 前端用 MediaSource + SourceBuffer 做边收边播

这套设计不一定是生产级语音系统的终点,但非常适合作为一个工程上讲得通、链路上闭得上、博客里讲得清的默认方案。


一、先别急着写代码:语音 AI 助手其实是三条链路

如果你一开始就把语音助手理解成“录音之后调一次接口”,你大概率会把结构做歪。

从工程上看,至少要先拆出三条职责不同的链路:

  1. 输入链路:浏览器录音 -> 上传音频 -> ASR -> 文本
  2. 推理链路:文本问题 -> LLM -> 流式文本输出
  3. 播报链路:流式文本 -> TTS -> 二进制音频 -> 边收边播

这三条链路的通信方式、时序要求、数据形态都不一样:

  • 录音上传是文件型请求,适合 multipart/form-data
  • 大模型文本输出是连续文本流,适合 SSE
  • 音频是二进制数据流,更适合 WebSocket

也就是说,这不是“一个接口做三件事”的问题,而是“多条流如何协作”的问题。

如果把这三段混成一个大接口,通常会出现两个后果:

  • 业务代码耦合严重,后面任何一段升级都很痛苦
  • 文本和音频时序失控,体验会非常差

所以我建议你先把这件事理解成一个多流协同系统,再去看具体实现。


二、这套项目的核心架构是什么

先看整条链路的全貌。本文分析的项目里,NestJS 并不是简单的 API 网关,而是把三种协议和两种外部能力组织起来的中枢。

flowchart LR
    subgraph Browser[浏览器端]
        A[录音采集<br/>MediaRecorder]
        B[上传音频<br/>POST /speech/asr]
        O[文字逐字显示]
        M[语音通道<br/>GET /speech/tts/ws]
        N[流式播放<br/>MediaSource + SourceBuffer]
        P[边收边播]
    end

    subgraph Server[NestJS 服务端]
        C[SpeechController / SpeechService]
        F[AiController<br/>SSE /ai/chat/stream]
        G[AiService + LangChain]
        I[事件桥接<br/>AI_TTS_STREAM_EVENT]
        J[TtsRelayService]
    end

    subgraph Cloud[外部云服务]
        D[腾讯云 ASR]
        K[腾讯云流式 TTS WebSocket]
    end

    A --> B
    B --> C
    C --> D
    D --> E[识别文本]
    E --> F
    F --> G
    G --> H[大模型流式文本]
    H --> O
    H --> I
    I --> J
    J --> K
    K --> L[二进制 MP3 音频帧]
    L --> M
    M --> N
    N --> P

这张图里最值得注意的,不是腾讯云,也不是模型,而是中间这几个“看起来不起眼”的节点:

  • SSE
  • WebSocket
  • AI_TTS_STREAM_EVENT
  • MediaSource
  • SourceBuffer

这几个点决定了语音链路到底是“实时协同”,还是“能跑但体验别扭”。

再看一次时序,你会更直观一些:

sequenceDiagram
    participant U as 用户
    participant FE as 浏览器前端
    participant ASR as NestJS /speech/asr
    participant TCASR as 腾讯云 ASR
    participant AI as NestJS /ai/chat/stream
    participant LLM as 大模型
    participant RELAY as TtsRelayService
    participant TCTTS as 腾讯云流式TTS

    U->>FE: 录音并停止
    FE->>ASR: 上传音频文件
    ASR->>TCASR: SentenceRecognition
    TCASR-->>ASR: 返回识别文本
    ASR-->>FE: text
    FE->>RELAY: 建立 /speech/tts/ws
    FE->>AI: 发起 /ai/chat/stream?ttsSessionId=xxx
    AI->>LLM: 流式生成回答
    LLM-->>AI: 文本 chunk
    AI-->>FE: SSE 文本 chunk
    AI->>RELAY: 发出 chunk 事件
    RELAY->>TCTTS: ACTION_SYNTHESIS 分段文本
    TCTTS-->>RELAY: 二进制音频帧
    RELAY-->>FE: WebSocket 音频数据
    FE->>FE: SourceBuffer appendBuffer
    FE-->>U: 边显示文字边播放语音

理解了这张图,后面的代码就不再是“API 堆砌”,而是各自承担某个链路角色。


三、先看项目结构:这个仓库为什么这么拆

这个项目的 README 仍然是 NestJS 默认模板,真正的信息都在源码里。核心结构大致如下:

src/
  ai/
    ai.config.ts
    ai.controller.ts
    ai.module.ts
    ai.service.ts
  speech/
    speech.config.ts
    speech.controller.ts
    speech.module.ts
    speech.service.ts
    tts-relay.service.ts
    tts-text-segmentation.ts
  common/
    stream-events.ts
  main.ts
public/
  asr.html
  asr-stream.html

这套拆分是合理的:

  • ai/:只关心文本问答链路
  • speech/:只关心语音输入、语音输出以及第三方语音服务接入
  • common/stream-events.ts:作为事件约定,让 AI 和 TTS 解耦
  • public/asr.html:单独验证 ASR 上传链路
  • public/asr-stream.html:完整验证“录音 -> 识别 -> AI -> TTS”链路

四、为什么 ASR 这里我更推荐“录完再识别”而不是一上来就做流式识别

很多人一聊语音系统,就默认“必须实时流式 ASR”。这其实是个常见误区。

要不要流式识别,不应该由技术潮流决定,而应该由交互目标决定。

在这个项目里,语音输入的目标不是做电话机器人,也不是做毫秒级打断对话,而是做一个像豆包那样的单轮语音提问

  1. 用户说完一段话
  2. 系统识别成文本
  3. 大模型开始回答
  4. 回答边生成边播报

在这种场景里,先录完再识别,其实是非常合理的默认方案:

  • 实现复杂度明显更低
  • 浏览器端更容易兼容
  • 后端不需要先处理麦克风实时分片上送
  • 对“问一句 -> 回一句”的交互已经够用

换句话说,流式 ASR 不是默认最优,而是更高阶、更高成本的能力。

如果你的目标只是做一个能交互、可演示、可扩展的语音 AI 助手,把复杂度优先放在“输出链路流式化”上,通常是更划算的。


五、ASR 后端是怎么接的:/speech/asr 这条链路的职责非常清晰

先看控制器:

// src/speech/speech.controller.ts
@Controller('speech')
export class SpeechController {
  constructor(private readonly speechService: SpeechService) {}

  @Post('asr')
  @UseInterceptors(FileInterceptor('audio'))
  async recognize(
    @UploadedFile()
    file?: {
      buffer: Buffer;
      originalname: string;
      mimetype: string;
      size: number;
    },
  ) {
    if (!file?.buffer?.length) {
      throw new BadRequestException(
        '请通过 FormData 的 audio 字段上传音频文件',
      );
    }

    const text = await this.speechService.recognizeBySentence(file);
    return { text };
  }
}

这个接口做得很干净:

  • 它不负责录音
  • 不负责转码
  • 不负责前端 UI
  • 只负责接收 audio 文件并转交给 SpeechService

这就是好接口的样子:边界清楚,职责单一。

再看识别逻辑:

// src/speech/speech.service.ts
@Injectable()
export class SpeechService {
  constructor(@Inject('ASR_CLIENT') private readonly asrClient: AsrClient) {}

  async recognizeBySentence(file: UploadedAudio): Promise<string> {
    const audioBase64 = file.buffer.toString('base64');

    const result = await this.asrClient.SentenceRecognition({
      EngSerViceType: '16k_zh',
      SourceType: 1,
      Data: audioBase64,
      DataLen: file.buffer.length,
      VoiceFormat: 'ogg-opus',
    });

    return result.Result ?? '';
  }
}

这里有几个参数值得讲透,而不是只说“它们怎么填”:

1)为什么要 buffer -> base64

因为这里调用的是云厂商 SDK 的句子识别接口,音频数据需要以指定格式进入请求体。浏览器上传到服务端后,Nest 拿到的是内存中的二进制 Buffer,而腾讯云接口在这个模式下要吃的是 Base64 文本。

也就是说,这一步不是“多余的转换”,而是协议适配。

2)EngSerViceType: '16k_zh' 在表达什么

这个参数不是“中文模式”这么简单,它实际上约束了:

  • 识别语种
  • 采样率预期
  • 模型适配方向

如果你音频本身和服务类型不匹配,识别结果就容易变差,甚至直接报错。很多人觉得“ASR 效果不好”,其实不是模型差,而是输入格式、采样率、编码方案就没对齐。

3)为什么 VoiceFormat 这里用 ogg-opus

因为前端 MediaRecorder 优先录的是:

const preferredMimeType = "audio/ogg;codecs=opus";

前后端格式是对齐的。这个细节非常关键。

如果你前端录出来的是 webm/opus,服务端却告诉云厂商它是 ogg-opus,结果要么识别失败,要么内容异常。音频链路里,格式匹配远比“我觉得差不多”更重要。


六、前端录音链路的设计,为什么比“点一下录音按钮”复杂

public/asr.html 里,项目专门做了一个 ASR 验证页。这个页面的意义并不只是演示,而是把“录音 -> 上传 -> 识别”单独拆出来验证。

核心代码是这段:

mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];

const mimeType = MediaRecorder.isTypeSupported(preferredMimeType)
  ? preferredMimeType
  : fallbackMimeType;

mediaRecorder = new MediaRecorder(mediaStream, { mimeType });

mediaRecorder.ondataavailable = (event) => {
  if (event.data && event.data.size > 0) {
    chunks.push(event.data);
  }
};

mediaRecorder.onstop = async () => {
  const blob = new Blob(chunks, {
    type: mediaRecorder.mimeType || fallbackMimeType,
  });
  const data = await uploadRecording(blob);
  resultEl.textContent = data.text || '(空结果)';
};

mediaRecorder.start(250);

这段代码在整条链路里的位置,是语音输入采集层。它解决的不是识别,而是三个更基础的问题:

  1. 如何向浏览器申请麦克风权限
  2. 如何把录音分片收集起来
  3. 如何在停止录音后整合成可上传的 Blob

注意这里 start(250) 的意义:虽然当前链路是“录完再识别”,但录音过程中仍然是按 250ms 分片收集的。这么做的好处是:

  • 浏览器侧更平滑
  • 后续如果要升级成更实时的链路,基础采集方式不用推翻
  • 可以更容易做波形、计时、录音中状态等 UI

也就是说,这个前端实现虽然现在走的是上传式 ASR,但它没有把自己写死在“纯离线式”的思路里。


七、为什么大模型输出要走 SSE,而不是普通 HTTP 返回一整段文本

语音助手如果只返回整段文本,问题不止是“慢”,而是整个系统没法形成实时反馈。

用户体验上的关键差别在这里:

  • 普通 HTTP:用户必须等待全部生成完成
  • SSE:前端可以随着 chunk 逐步展示答案

在语音场景里,这个差异会进一步放大。因为 TTS 的输入来源就是大模型流式文本。如果文本不流,语音就没法流。

也就是说,TTS 能不能边播,根上取决于 LLM 文本能不能边出。

项目里的 SSE 接口非常简洁:

// src/ai/ai.controller.ts
@Controller('ai')
export class AiController {
  constructor(
    private readonly aiService: AiService,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  @Sse('chat/stream')
  chatStream(
    @Query('query') query: string,
    @Query('ttsSessionId') ttsSessionId?: string,
  ): Observable<{ data: string }> {
    const sessionId = ttsSessionId?.trim();
    if (sessionId) {
      const startEvent: AiTtsStreamEvent = { type: 'start', sessionId, query };
      this.eventEmitter.emit(AI_TTS_STREAM_EVENT, startEvent);
    }

    return from(this.aiService.streamChain(query, sessionId)).pipe(
      map((chunk) => ({ data: chunk })),
    );
  }
}

这里有两个关键点:

第一,接口同时服务了两种消费方

  • 前端通过 SSE 消费文本
  • TTS 服务通过事件总线消费同一份文本流

这意味着这不是一个“只给前端看的接口”,而是整个问答输出链路的上游。

第二,ttsSessionId 把文本流和音频流关联起来了

为什么这里要额外传 ttsSessionId

因为前端和后端之间其实维护着两条通道:

  • 一条是 EventSource 文本流
  • 一条是 WebSocket 音频流

如果没有一个会话 ID 把二者绑定起来,你根本没法知道“这一段文本应该送到哪条 TTS WebSocket 会话里去”。

这就是语音系统和普通文本聊天系统的本质差别之一:你必须处理跨协议、跨通道的会话一致性。


八、AI 模块看起来简单,但其实承担的是“文本流标准化”的职责

再看 AiService

// src/ai/ai.service.ts
@Injectable()
export class AiService {
  private readonly chain: Runnable;

  constructor(
    @Inject('CHAT_MODEL') model: ChatOpenAI,
    private readonly eventEmitter: EventEmitter2,
  ) {
    const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
    this.chain = prompt.pipe(model).pipe(new StringOutputParser());
  }

  async *streamChain(
    query: string,
    ttsSessionId?: string,
  ): AsyncGenerator<string> {
    try {
      const stream = (await this.chain.stream({ query })) as AsyncIterable<unknown>;
      for await (const rawChunk of stream) {
        let chunk = '';
        if (typeof rawChunk === 'string') {
          chunk = rawChunk;
        } else if (
          typeof rawChunk === 'number' ||
          typeof rawChunk === 'boolean' ||
          typeof rawChunk === 'bigint'
        ) {
          chunk = String(rawChunk);
        }
        if (!chunk) continue;

        if (ttsSessionId) {
          this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
            type: 'chunk',
            sessionId: ttsSessionId,
            chunk,
          });
        }
        yield chunk;
      }

      if (ttsSessionId) {
        this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
          type: 'end',
          sessionId: ttsSessionId,
        });
      }
    } catch (error) {
      if (ttsSessionId) {
        this.eventEmitter.emit(AI_TTS_STREAM_EVENT, {
          type: 'error',
          sessionId: ttsSessionId,
          error: error instanceof Error ? error.message : String(error),
        });
      }
      throw error;
    }
  }
}

很多文章写到这里,就会开始说“看,这里用了 LangChain”。但真正有价值的,不是它用了哪个框架,而是这个 Service 做了什么抽象。

我认为这段代码承担的是三层职责:

1)把模型输出统一成字符串流

模型底层返回的 chunk 未必永远是字符串,代码里显式对 number / boolean / bigint 做了兼容转换。这是很实在的工程写法:不要假设上游永远完美,尽量把消费层看到的输出标准化。

2)把文本流同时暴露给两类消费者

  • yield chunk 给 SSE
  • eventEmitter.emit(...) 给 TTS 侧

注意,这里不是“生成两次”,而是一份文本流,多方消费。这在实时系统里非常重要,否则你很容易出现“前端看到的内容”和“语音朗读的内容”不一致。

3)在流的生命周期上补全事件

除了 chunk 之外,它还显式发出了:

  • start
  • end
  • error

这意味着 TTS 侧不只是“收到一点字就说一点字”,而是知道:

  • 什么时候会话开始
  • 什么时候应该收尾
  • 什么时候要终止并清理资源

这就是完整流生命周期管理,而不是简单回调。


九、为什么流式 TTS 不能和 SSE 混在一起

很多第一次做这个系统的人会问:既然已经有 SSE 了,为什么不直接在 SSE 里把音频也发回来?

原因很简单:SSE 适合文本,不适合二进制音频。

如果你强行用 SSE 传音频,一般只有两条路:

  1. 把音频转 Base64 再发
  2. 伪装成文本分块传输

这两种路都不太好:

  • Base64 体积会膨胀
  • 前端要自己解码
  • 时序会更难控制
  • 对播放器非常不友好

而 WebSocket 天然适合持续传二进制帧,所以这里单独开一条 /speech/tts/ws 通道,是一个非常明确的工程判断:

文本输出归 SSE,音频输出归 WebSocket。

这不是“多开一个接口显得复杂”,而是“按数据类型选协议”。


十、真正决定这套系统可扩展性的,是事件桥接而不是 API 调用

这套项目里,我最认可的一点是没有把 TTS 逻辑硬塞进 AiControllerAiService 里,而是通过事件来桥接。

事件定义很简单:

// src/common/stream-events.ts
export const AI_TTS_STREAM_EVENT = 'ai.tts.stream';

export type AiTtsStreamEvent =
  | { type: 'start'; sessionId: string; query: string }
  | { type: 'chunk'; sessionId: string; chunk: string }
  | { type: 'end'; sessionId: string }
  | { type: 'error'; sessionId: string; error: string };

这段代码看似不复杂,但它非常值钱。因为它明确告诉你:AI 模块不关心 TTS 怎么连腾讯云、怎么推前端、怎么关连接,它只负责把“文本流生命周期”以事件方式发出去。

这带来两个工程收益:

1)低耦合

未来你要把腾讯云 TTS 换成别家,实现新的 listener 就行,AI 侧不用改。

2)可演进

今天事件被 TtsRelayService 消费,明天也可以被日志系统、审计系统、字幕系统、消息持久化系统消费。

这就是为什么我说,语音系统的核心不在“调 API”,而在“如何组织流”。


十一、TtsRelayService 才是这套方案最核心的后端实现

如果让我选一个最值得反复讲的文件,那一定是:

  • src/speech/tts-relay.service.ts

它的职责不是“做 TTS”这么简单,而是做了三层中继:

  1. 管理浏览器和服务端之间的 TTS 会话
  2. 管理服务端和腾讯云流式 TTS 之间的 WebSocket 连接
  3. 在文本分段、发送节奏、二进制转发之间做协调

先看客户端会话注册:

registerClient(clientWs: WebSocket, wantedSessionId?: string): string {
  const sessionId = wantedSessionId?.trim() || randomUUID();
  const existing = this.sessions.get(sessionId);
  if (existing) {
    this.closeSession(sessionId, 'client reconnected');
  }

  this.sessions.set(sessionId, {
    sessionId,
    clientWs,
    ready: false,
    pendingChunks: [],
    textBuffer: '',
    closed: false,
  });
  this.sendClientJson(clientWs, { type: 'session', sessionId });
  return sessionId;
}

这里不是简单“建立一个 ws 就完事”,而是创建了一个完整的 session 对象。里面几个字段都非常有用:

  • ready:腾讯云流式 TTS 是否已经可以收文本
  • pendingChunks:还没来得及送出去的待合成文本
  • textBuffer:当前累计但尚未完成分段的文本
  • closed:避免已关闭 session 继续写数据

这说明作者不是把 WebSocket 当成“收发消息”的黑盒,而是把它当成一个有状态流会话来管理。

再看事件消费:

@OnEvent(AI_TTS_STREAM_EVENT)
handleAiStreamEvent(event: AiTtsStreamEvent): void {
  const session = this.sessions.get(event.sessionId);
  if (!session) return;

  switch (event.type) {
    case 'start': {
      this.ensureTencentConnection(session);
      this.sendClientJson(session.clientWs, {
        type: 'tts_started',
        sessionId: session.sessionId,
        query: event.query,
      });
      break;
    }
    case 'chunk': {
      const chunk = event.chunk;
      if (!chunk) return;
      this.queueSpeakableSegments(session, chunk);
      break;
    }
    case 'end': {
      this.queueSpeakableSegments(session, '', true);
      this.flushPendingChunks(session);
      if (session.tencentWs && session.tencentWs.readyState === WebSocket.OPEN) {
        session.tencentWs.send(JSON.stringify({
          session_id: session.sessionId,
          action: 'ACTION_COMPLETE',
        }));
      }
      break;
    }
    case 'error': {
      this.sendClientJson(session.clientWs, {
        type: 'tts_error',
        message: event.error,
      });
      this.closeSession(session.sessionId, 'ai stream error');
      break;
    }
  }
}

这段逻辑的价值在于:它把 TTS 的行为建立在流生命周期事件之上,而不是建立在“文本一下子全来了”之上。

尤其是 end 分支非常重要:

  • 先强制把剩余文本分段刷出去
  • 再把待发送队列 flush
  • 最后给腾讯云发 ACTION_COMPLETE

这表示“输入流结束”的协议语义被显式处理了。如果少了这一步,常见后果就是最后一段语音永远不出。


十二、流式 TTS 最大的坑,不是连接,而是文本分段

很多人第一次做流式 TTS,会把模型每次吐出的 chunk 原封不动地送去合成。结果通常很糟糕:

  • 语音频繁断句
  • 一两个字就触发一次合成
  • 朗读节奏极不自然
  • 网络与云服务调用次数暴涨

所以,这个项目专门做了一个分段器:src/speech/tts-text-segmentation.ts

核心逻辑是:

const SENTENCE_END_RE = /[。!?!?;;\n]/;
const FORCE_SPLIT_RE = /[,,、::\s]/g;
const MIN_FORCE_SPLIT_LENGTH = 18;
const MAX_BUFFER_LENGTH = 48;

export function extractTtsSegments(
  input: string,
  forceFlush = false,
): { segments: string[]; rest: string } {
  let rest = input;
  const segments: string[] = [];

  while (rest) {
    const sentenceMatch = rest.match(SENTENCE_END_RE);
    if (sentenceMatch?.index !== undefined) {
      const endIndex = sentenceMatch.index + sentenceMatch[0].length;
      const segment = finalizeSegment(rest.slice(0, endIndex));
      if (segment) segments.push(segment);
      rest = rest.slice(endIndex).trimStart();
      continue;
    }

    const forcedSplitIndex = findForcedSplitIndex(rest);
    if (forcedSplitIndex > 0) {
      const segment = finalizeSegment(rest.slice(0, forcedSplitIndex));
      if (segment) segments.push(segment);
      rest = rest.slice(forcedSplitIndex).trimStart();
      continue;
    }

    break;
  }

  if (forceFlush) {
    const finalSegment = finalizeSegment(rest);
    if (finalSegment) segments.push(finalSegment);
    rest = '';
  }

  return { segments, rest };
}

这里体现了一个很重要的工程判断:

流式 TTS 追求的不是“字一出来马上说”,而是“在足够低延迟的前提下,让语音仍然像人在说话”。

这段策略基本分三层:

1)优先按句末标点切分

。!?; 这种天然句边界,最适合合成。因为朗读的停顿也会更自然。

2)句末标点迟迟不来时,允许在逗号、顿号、空格附近强制切分

这是为了控制首包延迟。否则模型一直生成长句,但迟迟不出句号,用户就会觉得“怎么还不说话”。

3)最后在流结束时强制 flush

如果最后一段没等到标点,也不能丢,必须在 forceFlush 时兜底发出去。

很多系统之所以语音体验差,不是模型不行,而是文本分段策略太粗糙


十三、腾讯云流式 TTS 的真正接入点,不是 SDK,而是 WebSocket 协议管理

TTS 中继服务还有一个核心职责:维护服务端到腾讯云的流式 WebSocket 连接。

例如这个签名 URL 构建:

private buildTencentTtsWsUrl(sessionId: string): string {
  const now = Math.floor(Date.now() / 1000);
  const params: Record<string, string | number> = {
    Action: 'TextToStreamAudioWSv2',
    AppId: this.appId,
    Codec: 'mp3',
    Expired: now + 3600,
    SampleRate: 16000,
    SecretId: this.secretId,
    SessionId: sessionId,
    Speed: 0,
    Timestamp: now,
    VoiceType: this.voiceType,
    Volume: 5,
  };

  const signStr = Object.keys(params)
    .sort()
    .map((k) => `${k}=${params[k]}`)
    .join('&');
  const rawStr = `GETtts.cloud.tencent.com/stream_wsv2?${signStr}`;
  const signature = createHmac('sha1', this.secretKey)
    .update(rawStr)
    .digest('base64');

  return `wss://tts.cloud.tencent.com/stream_wsv2?...`;
}

这段代码告诉你两件事:

  1. 流式 TTS 本质上是一个 WebSocket 协议接入问题
  2. 真正的复杂度不在“调某个函数”,而在“参数、签名、时序、收尾”这些协议细节

再比如文本发送逻辑:

private sendTencentChunk(session: ClientSession, text: string): void {
  if (!session.tencentWs || session.tencentWs.readyState !== WebSocket.OPEN) {
    session.pendingChunks.push(text);
    return;
  }

  session.tencentWs.send(
    JSON.stringify({
      session_id: session.sessionId,
      message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      action: 'ACTION_SYNTHESIS',
      data: text,
    }),
  );
}

为什么这里不是“收到文本就立刻发”?因为发送之前必须确认:

  • 连接是否建立
  • 会话是否 ready
  • 上游是否还有待合成文本未处理

这就是为什么 pendingChunks 队列存在。它解决的是流的生产速度和消费速度不一致的问题。


十四、前端为什么要用 MediaSource + SourceBuffer,而不是直接拿到 MP3 再播

如果你只是做“整段 TTS 合成后再播放”,那确实可以直接把一个完整音频文件地址塞进 <audio>

但这个项目要解决的是:后端持续推音频二进制,前端边收到边播放。

这时,普通 audio 标签就不够了,你需要一个可以持续追加媒体数据的机制。这就是 MediaSource

看前端核心代码:

function prepareStreamingAudio () {
  if (ttsMediaSource && ttsSourceBuffer) return true;
  if (!window.MediaSource || !MediaSource.isTypeSupported("audio/mpeg")) {
    return false;
  }

  resetTtsPlayer();
  ttsMediaSource = new MediaSource();
  ttsObjectUrl = URL.createObjectURL(ttsMediaSource);
  ttsAudioEl.src = ttsObjectUrl;

  ttsMediaSource.addEventListener("sourceopen", () => {
    ttsSourceBuffer = ttsMediaSource.addSourceBuffer("audio/mpeg");
    ttsSourceBuffer.mode = "sequence";
    ttsSourceBuffer.addEventListener("updateend", flushTtsBufferQueue);
    flushTtsBufferQueue();
  }, { once: true });

  return true;
}

这段代码的含义是:

  • MediaSource 提供一个可动态追加媒体内容的容器
  • SourceBuffer 负责真正追加二进制音频片段
  • sequence 模式意味着按顺序拼接音频流

接下来是关键的队列刷新逻辑:

function flushTtsBufferQueue () {
  if (!ttsSourceBuffer || !ttsMediaSource) return;
  if (ttsSourceBuffer.updating) return;

  if (ttsPendingBuffers.length > 0) {
    const next = ttsPendingBuffers.shift();
    if (next) {
      ttsSourceBuffer.appendBuffer(next);
      if (ttsAudioEl.paused) {
        ttsAudioEl.play().catch(() => {
          setStatus("语音已就绪,请点击播放器开始播报");
        });
      }
    }
    return;
  }

  if (ttsStreamFinal && ttsMediaSource.readyState === "open") {
    try {
      ttsMediaSource.endOfStream();
    } catch {
      // ignore
    }
  }
}

这一段是前端流式音频播放的关键:

  • 如果 SourceBuffer 还在更新,就不能继续 append
  • 如果还有待处理音频帧,就一帧一帧追加
  • 如果流结束了并且队列空了,再 endOfStream

很多人流式播放做不顺,问题就出在这里:不是收不到数据,而是 append 时序没管好。


十五、为什么前端要先建立 TTS WebSocket,再发起 AI SSE 请求

public/asr-stream.html 里,有一个很重要的设计顺序:

await ensureTtsConnection();
await streamAiReply(trimmed);

这个顺序不是随便写的。

原因是:SSE 一旦启动,大模型文本可能马上就开始输出,而文本一输出,服务端就会尝试把 chunk 转发给 TTS。如果这时候前端的 TTS WebSocket 还没准备好,就会出现:

  • 文字已经开始显示
  • 语音通道 session 还没拿到
  • 结果最前面几段音频可能丢失或延迟很大

所以更稳妥的做法是:

  1. 先准备好 TTS WS 通道和 ttsSessionId
  2. 再发起 EventSource 文本流请求
  3. 文本流和音频流用同一个 session 关联

这就是实时系统里的经典原则:

先准备消费端,再启动生产端。


十六、配置项不是“填上就行”,它们决定了系统的行为边界

这个项目里有两组配置文件:

  • src/ai/ai.config.ts
  • src/speech/speech.config.ts

比如模型配置:

const apiKey =
  configService.get<string>('DASHSCOPE_API_KEY') ??
  configService.get<string>('OPENAI_API_KEY');
const baseURL =
  configService.get<string>('DASHSCOPE_BASE_URL') ??
  configService.get<string>('OPENAI_BASE_URL') ??
  'https://dashscope.aliyuncs.com/compatible-mode/v1';
const model = configService.get<string>('MODEL_NAME') ?? 'qwen-plus';

这段写法的工程意义是:

  • 兼容 OpenAI 风格 SDK
  • 允许底层实际使用通义千问兼容接口
  • 模型供应商可替换,但上层调用保持稳定

这比把云厂商调用细节直接写死在业务逻辑里要强得多。

再比如语音配置:

const secretId =
  configService.get<string>('TENCENT_SECRET_ID') ??
  configService.get<string>('SECRET_ID');
const secretKey =
  configService.get<string>('TENCENT_SECRET_KEY') ??
  configService.get<string>('SECRET_KEY');
const appId =
  configService.get<string>('TENCENT_APP_ID') ??
  configService.get<string>('APP_ID');

这个 fallback 设计也挺实用:既兼容语音模块自己的命名,又兼容已有环境变量命名,方便从脚本实验迁移到 NestJS 服务。

此外,这些参数背后都有实际含义:

  • MODEL_NAME:决定回答质量、速度与成本
  • TTS_VOICE_TYPE:决定音色,不只是“换个声音”这么简单,也会影响风格一致性
  • SampleRate:影响音频兼容性与传输成本
  • Codec: mp3:直接决定浏览器流式播放的适配路线

参数不是配置表,而是系统行为控制面。


十七、这套方案做对了什么

如果从技术博客的视角总结,这个项目最值得肯定的地方有五个。

1. 先把 ASR 和完整语音链路分开验证

asr.html 专注于验证录音上传与识别,asr-stream.html 才负责完整交互。这样调试效率非常高。

2. 给文本和音频分别选了合适的协议

  • 文本:SSE
  • 音频:WebSocket

这是正确的边界划分。

3. 用事件总线把 AI 和 TTS 解耦

这一步让整个系统有了继续演化的空间。

4. 文本分段策略考虑了“可听性”

这意味着作者不是只想“跑通”,而是在意真实体验。

5. 前端流式播放没有偷懒

很多 Demo 到 TTS 就退回“整段音频播放”,而这个项目真的做了 MediaSource + SourceBuffer,这是它最接近真实产品体验的地方。


十八、但如果你要把它推进到真实业务,还差哪些东西

这套方案已经很适合作为教程和演示项目,但离生产级还有明显距离。

1. 目前 ASR 仍然是上传式,不是流式

这意味着:

  • 用户必须说完再等识别
  • 无法做边说边识别
  • 无法做更自然的打断式交互

如果你的场景是客服、通话机器人、实时陪练,这会成为瓶颈。

2. 缺少 VAD(静音检测)

目前录音停止主要靠用户点击按钮。真实产品里通常会结合静音检测、自动截断、超时策略,减少用户操作成本。

3. 缺少更完整的错误恢复

比如:

  • 腾讯云 TTS 中途断开如何重连
  • SSE 断流后是否要补偿
  • session 超时如何清理
  • 客户端页面刷新后旧连接如何回收

4. 缺少鉴权与限流

语音系统很容易被滥用,因为它天然涉及高成本外部服务。如果不加认证、配额和频率限制,线上风险会很高。

5. 缺少可观测性

真正的语音体验优化,一定离不开下面这些指标:

  • 录音时长
  • ASR 耗时
  • LLM 首 token 延迟
  • TTS 首包延迟
  • 前端开始播放时间
  • 整体对话完成耗时

如果没有这些指标,你只能凭感觉优化,最后会越改越盲。


十九、这套方案最容易踩的坑,我建议你提前避开

坑 1:前端录音格式和云端识别格式不一致

这是最常见的问题之一。一定要确认:

  • 浏览器录了什么格式
  • Blob.type 是什么
  • 服务端告诉 ASR 的 VoiceFormat 是什么

这三者必须对齐。

坑 2:把每个 token 都立即送进 TTS

这样会让语音像卡壳一样,一两个字就停一次。分段策略一定要做。

坑 3:没有处理浏览器自动播放限制

前端已经做了:

ttsAudioEl.play().catch(() => {
  setStatus("语音已就绪,请点击播放器开始播报");
});

这就是在兜 autoplay 被拦截的情况。很多人本地测得好好的,上线后却发现“没声音”,原因就在这。

坑 4:文本流和音频流没有共享 session

如果没有 ttsSessionId 这层关联,多人并发时非常容易串音。

坑 5:流结束时没有明确收尾

不管是 SSE、TTS 还是前端 MediaSource,你都不能只处理“进行中”,还必须处理:

  • 何时 flush 剩余数据
  • 何时发送 complete
  • 何时 endOfStream
  • 何时 close session

实时系统最怕的不是报错,而是没报错但尾巴没收干净


二十、如果是我继续演进这套系统,我会怎么做

如果你的目标不是只做 Demo,而是把它向更真实的产品推进,我会建议按下面的顺序演进。

第一步:补观测,而不是先改架构

先量化链路延迟,知道瓶颈在哪。

第二步:把上传式 ASR 升级成流式 ASR

这样可以减少等待感,让语音输入更像自然对话。

第三步:增加打断能力

比如:

  • 用户说新问题时,当前 TTS 立刻中断
  • 前端停止播放并清空后续 buffer
  • 服务端结束当前 session

第四步:把 TTS Relay 独立成可复用的语音网关

当前它是项目内 service,但未来完全可以变成一个独立语音中继层,为多个 AI 应用复用。

第五步:把知识库/RAG 接进来

当语音链路稳定后,真正决定业务价值的就不再是“会不会说话”,而是“回答是否可靠”。

到那一步,系统的重点就会从“多流协同”继续延伸到“检索增强 + 语音交互”的组合能力。


二十一、我的最终判断:这类语音 Agent 的默认方案,应该怎么选

如果你的目标是:

  • 做一个可演示、可讲解、可扩展的语音 AI 助手
  • 让用户获得“能说、能看、能听”的闭环体验
  • 不想一上来就被全双工实时语音系统的复杂度拖死

那么我认为这套方案是非常合适的默认起点:

  • 输入:上传式 ASR
  • 文本输出:SSE
  • 语音输出:WebSocket 流式 TTS
  • 服务端协同:事件桥接
  • 前端播放:MediaSource + SourceBuffer

它不是终极架构,但它在复杂度、教学价值和可演进性之间取得了很好的平衡。

反过来说,如果你的目标是:

  • 电话机器人
  • 实时会议同传
  • 全双工强交互语音陪练
  • 极低延迟语音中断

那这套方案就只是过渡阶段,你迟早会走向:

  • 流式 ASR
  • 更强的状态机
  • 更复杂的音频管线
  • 更严格的会话与时延控制

所以,不要把“是否先进”当成选型标准,而要把“当前场景最需要解决什么问题”当成标准。


总结

回到文章开头的那个结论。

很多人做语音 AI 助手时,会把注意力放在:

  • 接哪家 ASR
  • 接哪家大模型
  • 接哪家 TTS

这些当然重要,但它们不是核心矛盾。

真正决定体验和工程质量的,是你能不能把下面这几件事组织成一个协同系统:

  • 录音上传与格式适配
  • 文本流式生成
  • 文本到语音的分段策略
  • SSE 与 WebSocket 的职责分离
  • 服务端事件桥接
  • 前端流式音频播放
  • 生命周期、会话和收尾处理

换句话说,语音 Agent 的本质不是“多接两个接口”,而是“多条流如何在正确的时机,用正确的协议,完成一次完整协作”。

而这,正是这份 NestJS 项目最值得学习的地方。

如果你后面还想继续扩展,我建议下一篇就顺着这条线往下写:

  1. 把上传式 ASR 升级成流式 ASR
  2. 增加打断、重说与会话中止能力
  3. 接入 RAG,让它从“会说话”升级成“会回答业务问题”

到那时,这就不只是一个语音 Demo,而会成为一个真正有业务形态的 AI 应用底座。

Taro小程序生成分享海报解决方案

作者 朱良
2026年5月5日 15:27

场景:在Taro开发中,在商品/职位/文章的详情页需要转发生成一个png海报,如果在Web开发中,直接html+css写海报样式,Vue/Teact中通过Props传入详情信息就可以,然后定位到屏幕视口外,通过html2canvas生成和导出海报样式,但是在小程序中,无法使用html2canvas

尝试寻找第三方库

wxml2canvas/taro-wxml2canvas可以吗,我使用过,证明是不可行的,且不说taro导入第三方原生组件十分麻烦,在Taro4中,和Taro3,Taro2基本经历过多次更新,wxml2canvas/taro-wxml2canvas库已经很久没有维护,实测不可行。

taro-plugin-canvas和taro3-canvas之类的库长时间不更新无法使用;

taro-html-parser和wxParse库也是长时间不更新无法使用;

mp-html这个还在维护库只支持uniapp和原生小程序;

似乎Taro被市场抛弃了,一个纯前端解决生成分享海报的库都没有吗?

尝试Canvas绘制

其实海报就是一个png图片,canvas一样可以绘制,Taro是支持canvas的,但canvas绘制的海报在多文字处理上非常麻烦,样式调整费劲,其实先写html,让AI转为canvas代码,也是还原度低,对于复杂海报样式很难实现,更难二次调整;

尝试SVG绘制

SVG是在浏览器上可行的,UI设计师出稿一个SVG模板,详情页信息(文字/图片)直接拼接/导出到SVG中,生成海报图片,但是小程序不支持SVG,所以依然不可行;

最终解决方案

使用Taro原生的Snapshot组件,只需要写原生的Taro代码,会被截图为画布,渲染结果导出成图片,需要局部开启Skyline模式,但是总算是实现了浏览器中Html2Canvas的效果,参考文档 docs.taro.zone/docs/apis/s…

参考代码

sharePoster.tsx

import { useRef, useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Button, Image } from '@tarojs/components'
import './poster.css'

export default function PosterPage() {
  // 1. 创建 ref 获取海报容器节点(Skyline 节点)
  const posterRef = useRef<View>(null)
  // 2. 声明 Snapshot 实例
  let snapshotInstance: Taro.Snapshot | null = null

  // 🔥 核心:生成海报(截图)
  const createPoster = useCallback(async () => {
    try {
      // 校验节点
      if (!posterRef.current) {
        Taro.showToast({ title: '节点不存在', icon: 'none' })
        return
      }

      // 1. 初始化 Snapshot 实例(官方标准用法)
      snapshotInstance = Taro.createSnapshot()

      // 2. 获取 Skyline 节点的 ID(必须通过 ref 获取)
      const nodeId = posterRef.current._nodeId

      // 3. 执行截图(核心 API)
      const res = await snapshotInstance.takeSnapshot({
        nodeId, // 要截图的 Skyline 节点 ID
        // 可选:自定义截图尺寸/质量
        quality: 1,
        type: 'png'
      })

      // 4. 截图成功:res.tempFilePath 是海报临时路径
      const posterPath = res.tempFilePath
      console.log('✅ 海报生成成功:', posterPath)

      // 可选:预览海报 / 保存到相册
      Taro.showToast({ title: '生成成功', icon: 'success' })
      Taro.previewImage({ urls: [posterPath] })

    } catch (err) {
      console.error('❌ 海报生成失败:', err)
      Taro.showToast({ title: '生成失败', icon: 'none' })
    }
  }, [])

  return (
    <View className='page-container'>
      {/* 🔥 海报容器:Skyline 渲染节点,ref 绑定 */}
      <View ref={posterRef} className='poster-box'>
        {/* 这里写Taro原生代码,海报内容(可自定义:图片、文字、头像等) */}
      </View>

      {/* 生成按钮 */}
      <Button className='create-btn' onClick={createPoster}>
        生成海报
      </Button>
    </View>
  )
}

需要在专门的分享页开启skyline xxx.config.ts

export default {
  navigationBarTitleText: '生成分享海报',
  skyline: { enable: true },
  defaultRenderEngine: 'skyline',
  disableScroll: true
}

建议在详情页点击分享->生成分享海报按钮时,跳转该页面,生成完成后携带res.tempFilePath临时路径 返回详情页,然后展示和下载海报图片。因为skyline模式的无法使用第三方UI组件,建议单独独立为一个局部页面。

HarmonyOS AbilityStage 实战:别把启动参数散落在每个页面里

作者 李游Leo
2026年5月5日 15:07

鸿蒙应用做到后面,真正让人头疼的,往往不是某个页面写得丑,也不是某个按钮样式没调好,而是入口越来越多以后,启动逻辑开始乱。

桌面图标能进来,服务卡片能进来,通知能进来,Deep Link 能进来,应用内部还可能用 Want 拉起另一个 UIAbility。第一版代码一般都挺朴素:在首页 aboutToAppear 里读一下参数,判断要不要跳详情页。刚开始没问题,甚至看起来挺清爽。

但需求一多,首页就很容易变成“入口垃圾桶”。

这里判断通知来源,那里判断服务卡片参数,后面又补一个外部链接解析。冷启动的时候还能凑合,二次拉起、回前台、横竖屏切换、任务栈恢复一来,问题就开始变得有点玄学了。

我之前见过一个挺典型的线上问题:用户从服务卡片点进来,本来应该打开某个订单详情页,结果偶尔落到首页;用户从外部链接再次拉起应用,页面没刷新;还有更隐蔽的,应用已经在后台了,新的 Want 进来以后,全局初始化又跑了一遍,监听注册了两次,后面同一个事件回调两遍。

查日志的时候也挺难受。每个页面都觉得自己只是“顺手处理一下入口参数”,最后谁也说不清这次启动到底是桌面启动、卡片启动,还是二次拉起。

这种问题不能靠再加几个 if 硬顶。Stage 模型下,AbilityStage、Want、UIAbility 这条链路本来就应该承担启动治理的职责。只是很多项目写着写着,把它们当成了“系统自动生成的模板文件”,真正的业务入口反而全塞到页面里了。

image.png

AbilityStage / Want / UIAbility,别分开看

单独看这几个概念,其实都不复杂。

AbilityStage 是 Module 级别的组件管理器,HAP 首次加载时会创建它。UIAbility 是带界面的应用组件,负责创建、销毁、前后台切换这些生命周期。Want 是组件之间传递信息的载体,启动目标、参数、action、uri 这些东西都可以从里面拿。

但工程里真正容易出问题的地方,不是“某个回调怎么写”,而是边界没划清。

我一般会这么分:

  • AbilityStage 管进程级、模块级的东西,比如轻量初始化、Specified 启动模式分流、全局依赖准备。
  • Want 只当入口信息,进来以后尽快转成业务能理解的结构。
  • UIAbility 管窗口、生命周期和启动载荷注入。
  • ArkUI 页面只消费归一后的业务参数,不直接解析原始 Want。

这几条边界看着有点啰嗦,但真到项目里很有用。

后面新增通知入口、服务卡片入口、Deep Link 入口时,不需要每个页面跟着改。入口逻辑集中在入口层,页面只关心“我要展示什么业务状态”。这才比较像一个能长期维护的结构。

先把原始 Want 收敛成 LaunchPayload

很多启动混乱,根源就是页面直接读 Want。

页面一旦开始知道太多入口细节,就会慢慢变成半个路由中心。今天读 scene,明天读 from,后天再补一个 uri,最后首页里一堆参数判断,谁也不敢动。

我更习惯定义一个中间结构,叫 LaunchPayload。它不追求把 Want 的所有字段都复刻一遍,只留下业务真正需要的东西。

// common/launch/LaunchPayload.ets
export enum LaunchScene {
  NORMAL = 'normal',
  CARD = 'card',
  NOTIFICATION = 'notification',
  DEEP_LINK = 'deep_link',
  INTERNAL = 'internal'
}

export interface LaunchPayload {
  scene: LaunchScene
  targetPage: string
  bizId?: string
  uri?: string
  from?: string
  rawAction?: string
  extras: Record<string, string>
  receivedAt: number
}

这里有个小取舍:extras 我只放字符串。

不是说 Want 里不能带别的类型,而是启动参数最好别变成一个“万能对象”。入口传来的东西越杂,页面兜底越麻烦。真要复杂对象,建议传 id,再让业务层去查详情。

启动参数要负责的是“把用户带到哪”,不是“把整个业务现场都搬进来”。这句话挺重要,很多入口混乱都是从这里开始的。

写一个 Want 解析器,别让页面自己猜

下面这个 LaunchPayloadParser 就是专门干脏活的。

它负责把不同来源的 Want 参数,统一整理成业务可读的结构。页面拿到的不是一坨原始参数,而是一份已经归一过的启动载荷。

// common/launch/LaunchPayloadParser.ets
import { Want } from '@kit.AbilityKit'
import { LaunchPayload, LaunchScene } from './LaunchPayload'

export class LaunchPayloadParser {
  static parse(want: Want | undefined): LaunchPayload {
    const params = want?.parameters ?? {}
    const uri = want?.uri ?? ''
    const action = want?.action ?? ''

    const scene = this.parseScene(params, uri, action)
    const bizId = this.readString(params, 'bizId')
    const from = this.readString(params, 'from')

    return {
      scene,
      targetPage: this.resolveTargetPage(scene, bizId, uri),
      bizId,
      uri,
      from,
      rawAction: action,
      extras: this.pickSafeExtras(params),
      receivedAt: Date.now()
    }
  }

  private static parseScene(params: Record<string, Object>, uri: string, action: string): LaunchScene {
    const scene = this.readString(params, 'scene')

    if (scene === 'card') {
      return LaunchScene.CARD
    }

    if (scene === 'notification') {
      return LaunchScene.NOTIFICATION
    }

    if (uri.length > 0) {
      return LaunchScene.DEEP_LINK
    }

    if (action.length > 0) {
      return LaunchScene.INTERNAL
    }

    return LaunchScene.NORMAL
  }

  private static resolveTargetPage(scene: LaunchScene, bizId?: string, uri?: string): string {
    if (scene === LaunchScene.DEEP_LINK && uri) {
      return this.resolveDeepLink(uri)
    }

    if (bizId && bizId.length > 0) {
      return 'pages/Detail'
    }

    return 'pages/Home'
  }

  private static resolveDeepLink(uri: string): string {
    // 这里只做简单示例。
    // 真实项目里建议做白名单解析,别让外部 uri 任意指定页面路径。
    if (uri.includes('/detail')) {
      return 'pages/Detail'
    }

    if (uri.includes('/search')) {
      return 'pages/Search'
    }

    return 'pages/Home'
  }

  private static pickSafeExtras(params: Record<string, Object>): Record<string, string> {
    const allowList: string[] = ['tab', 'keyword', 'source']
    const extras: Record<string, string> = {}

    allowList.forEach((key: string) => {
      const value = this.readString(params, key)
      if (value !== undefined) {
        extras[key] = value
      }
    })

    return extras
  }

  private static readString(params: Record<string, Object>, key: string): string | undefined {
    const value = params[key]
    return typeof value === 'string' ? value : undefined
  }
}

这段代码看起来确实有点啰嗦,但它救命的地方也就在这。

所有入口先过一层白名单,不允许外部参数直接控制内部页面路径;所有参数先转成可控结构,不让页面到处写 want.parameters?.xxx。项目越大,这种“看起来多一层”的代码越值钱。

我自己比较怕那种“先凑合一下”的入口代码。因为入口一旦散了,后面不是不好重构,是没人敢重构。用户从哪里进来、带了什么参数、应该落到哪个页面,全都藏在几个页面生命周期里,查一次问题能把人查麻。

AbilityStage:只做进程级初始化和启动分流

AbilityStage 很容易被误用。

有些项目会把一堆业务初始化都丢进去:数据库、网络、埋点、用户信息、远程配置,全塞上。冷启动一慢,大家又开始怀疑系统回调慢,或者怀疑首屏性能不行。其实很多时候,是自己把太重的东西放错地方了。

我的建议比较保守:AbilityStage 只做轻量、必要、进程级的事情。

比如日志初始化、依赖容器准备、Specified 启动模式的 key 分流。需要 IO、需要用户态、需要网络的初始化,不要一股脑压在这里。

// entry/src/main/ets/entryability/MyAbilityStage.ets
import { AbilityStage, Want } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    // 这里适合做轻量级、进程级准备。
    // 不建议在这里做耗时网络请求,也别依赖页面上下文。
    hilog.info(0x0000, 'AppStage', 'AbilityStage onCreate')
  }

  onAcceptWant(want: Want): string {
    // Specified 启动模式下,系统会通过这个 key 决定复用哪个 UIAbility 实例。
    // key 设计要稳定,不要把时间戳这种随机值塞进去。
    const payload = LaunchPayloadParser.parse(want)

    if (payload.bizId && payload.bizId.length > 0) {
      return `detail_${payload.bizId}`
    }

    return 'main'
  }
}

onAcceptWant 这块很容易写错。

我见过有人为了“保证每次都是新的”,直接返回时间戳。短期看,好像解决了页面不刷新的问题;长期看,其实是把实例复用搞乱了。

Specified 模式要的不是“每次都新开”,而是“同一类业务复用同一个目标实例”。key 的粒度要跟业务场景一致。比如详情页按 id 分流,主入口统一回到 main。别为了省事,把 key 写成一个随机数,那后面任务栈和实例管理都会跟着乱。

UIAbility:冷启动和二次拉起要走同一套逻辑

UIAbility 的生命周期更贴近业务。

用户冷启动时会走 onCreate,窗口创建时走 onWindowStageCreate;应用已有实例再次被拉起时,常见场景会走 onNewWant。如果只在 onCreate 里处理参数,二次拉起就很容易漏。

我一般会在 UIAbility 里保留一个当前启动载荷,然后用 LocalStorage 注入页面。页面不直接碰 Want。

// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayload } from '../common/launch/LaunchPayload'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'

export default class EntryAbility extends UIAbility {
  private storage: LocalStorage = new LocalStorage()
  private latestPayload?: LaunchPayload

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.latestPayload = LaunchPayloadParser.parse(want)
    this.storage.setOrCreate('launchPayload', this.latestPayload)

    hilog.info(0x0000, 'EntryAbility',
      `onCreate scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 页面首次加载时,把归一后的启动载荷注入进去。
    windowStage.loadContent('pages/Home', this.storage, (err) => {
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility',
          `loadContent failed, code=${err.code}, message=${err.message}`)
        return
      }

      hilog.info(0x0000, 'EntryAbility', 'loadContent success')
    })
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 已有实例被再次拉起时,不要重复跑全局初始化。
    // 这里只更新启动载荷,让页面或路由层消费。
    this.latestPayload = LaunchPayloadParser.parse(want)
    this.storage.setOrCreate('launchPayload', this.latestPayload)

    hilog.info(0x0000, 'EntryAbility',
      `onNewWant scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
  }

  onForeground(): void {
    // 恢复轻量资源,例如刷新当前会话状态。
    // 不建议在这里重复解析启动参数。
    hilog.info(0x0000, 'EntryAbility', 'onForeground')
  }

  onBackground(): void {
    // 暂停耗时任务、保存必要状态。
    hilog.info(0x0000, 'EntryAbility', 'onBackground')
  }

  onDestroy(): void {
    // 取消监听、释放 UIAbility 级资源。
    hilog.info(0x0000, 'EntryAbility', 'onDestroy')
  }
}

这段的重点不是 loadContent,而是 onCreateonNewWant 共用同一个解析器。

冷启动和二次拉起不应该分裂成两套业务规则。你今天忘了在 onNewWant 补一个参数,明天就会遇到那种特别烦的现象:应用杀掉后正常,后台唤起异常;从桌面进来正常,从通知进来异常。

这类问题通常不好测,因为测试同学一旦把应用杀掉重进,问题就消失了。

页面只消费 LaunchPayload,不碰原始 Want

页面里可以用 @LocalStorageProp 拿到启动载荷。至于它来自桌面、卡片还是 Deep Link,页面不需要知道太多。

// entry/src/main/ets/pages/Home.ets
import { LaunchPayload, LaunchScene } from '../common/launch/LaunchPayload'

@Entry
@Component
struct Home {
  @LocalStorageProp('launchPayload') launchPayload?: LaunchPayload

  @State tip: string = '正常进入首页'

  aboutToAppear(): void {
    this.consumeLaunchPayload(this.launchPayload)
  }

  onPageShow(): void {
    // 从后台回到前台时,页面可做轻量刷新。
    // 不建议在这里重新猜测启动来源。
  }

  private consumeLaunchPayload(payload?: LaunchPayload): void {
    if (!payload) {
      return
    }

    if (payload.scene === LaunchScene.CARD) {
      this.tip = `从服务卡片进入,业务ID:${payload.bizId ?? '无'}`
      return
    }

    if (payload.scene === LaunchScene.DEEP_LINK) {
      this.tip = `从外部链接进入:${payload.uri ?? ''}`
      return
    }

    if (payload.bizId) {
      this.tip = `准备打开详情:${payload.bizId}`
      return
    }

    this.tip = '正常进入首页'
  }

  build() {
    Column({ space: 16 }) {
      Text('HarmonyOS 启动治理示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.tip)
        .fontSize(15)
        .fontColor('#666666')

      if (this.launchPayload?.targetPage === 'pages/Detail') {
        Button('进入详情')
          .onClick(() => {
            // 项目里可以交给统一 RouterService,
            // 不建议在每个页面散落路由拼接。
          })
      }
    }
    .padding(24)
    .width('100%')
  }
}

这里有个问题经常有人问:为什么不在 UIAbility 里直接路由到详情页?

可以,但要看项目的路由方案。

如果你的登录态、弹窗恢复、页面栈管理、Tab 状态都在页面层或者 RouterService 里,UIAbility 直接跳详情页,有时候反而会绕过业务状态。我的做法是,UIAbility 负责把“启动意图”送到页面,真正的业务路由交给应用内部的 RouterService。

这样页面栈归页面,入口归入口,边界比较清楚。后面要改路由策略,也不用去生命周期回调里翻一堆代码。

生命周期不是背回调顺序,而是定职责

UIAbility 启动到前台时,会触发 onCreate()onWindowStageCreate()onForeground() 这一类生命周期回调。文档顺序看懂不难,难的是每个回调里该放什么、不该放什么。

image.png

我通常按下面这个口径拆:

onCreate:读取 Want,生成 LaunchPayload,准备 UIAbility 级状态。别在这里直接操作还没创建的窗口。

onWindowStageCreate:加载页面,注入 LocalStorage,绑定窗口相关逻辑。窗口级别的东西放这里,不要提前。

onNewWant:已有实例再次被拉起时更新启动意图。这里不要重复初始化全局服务,也不要重复注册监听。

onForeground:应用回到前台,恢复轻量资源,比如刷新会话、恢复播放按钮状态。不要把它当第二个 onCreate

onBackground:暂停耗时任务,保存必要状态。能停的就停,尤其是轮询、定位、长连接这类逻辑。

onDestroy:取消监听、释放资源、打点收尾。不要假设它每次都一定按你期望的时机触发,但该写的清理还是要写。

职责分清以后,很多“偶发问题”就不再玄学了。

比如二次拉起没刷新,就去看 onNewWant;前后台切换重复初始化,就查 onForeground;窗口相关状态异常,就去看 onWindowStageCreate。至少排查方向是明确的,不至于在首页、详情页、路由工具类之间来回翻。

常见坑位:这些地方真的容易埋雷

1. 首页承担了太多入口职责

首页读 Want、首页解析 URI、首页判断通知、首页处理卡片参数,短期确实快,长期基本一定乱。首页是 UI,不是入口网关。

这个坑很多项目都会踩,因为第一版最方便的地方就是首页。但方便不是没有代价,只是代价晚点来。

2. onNewWant 忘了处理

这类 bug 很烦:冷启动正常,后台再次拉起异常;杀掉应用再试,又正常了。

原因往往是只在 onCreate 解析了 Want,已有实例二次拉起时没有更新业务载荷。开发自测时如果习惯每次都杀进程,很容易漏掉。

3. Specified key 设计太随意

onAcceptWant 返回的 key 应该稳定、有业务含义。

随机 key 会让实例复用不可控;key 粒度太粗,会导致不同业务入口抢同一个实例。这个地方别偷懒,最好一开始就按业务场景定规则。

4. 外部参数直接控制页面路径

Deep Link 或外部 Want 里带一个 path,然后你直接 router 到对应页面,这个写法很危险。

至少要做白名单映射。外部参数只能表达意图,不能拿到内部路由的完全控制权。尤其是对外开放的链接入口,更不能相信传进来的每一个字段。

5. 前后台切换重复初始化

onForeground 不是重启。

回前台时做轻量恢复可以,别把登录初始化、数据库初始化、全局监听注册再跑一遍。重复初始化这种问题前期不明显,后面会变成重复请求、重复回调、状态错乱。

6. 页面销毁后,异步回调还在改状态

启动之后经常伴随异步动作,比如查详情、拉配置、校验登录。页面销毁后回调还更新状态,就会出现偶现闪跳或者日志报错。

建议给异步任务加 taskId,或者在页面消失时取消。别让旧任务回来覆盖新页面。

稳定性优化:给启动链路加一个任务号

如果启动入口多,建议给每次 LaunchPayload 分配一个自增序号。后到的启动意图优先级更高,旧任务回来不能覆盖新状态。

这个设计不复杂,但很管用。

// common/launch/LaunchSession.ets
import { LaunchPayload } from './LaunchPayload'

export class LaunchSession {
  private currentSeq: number = 0
  private latest?: LaunchPayload

  next(payload: LaunchPayload): number {
    this.currentSeq += 1
    this.latest = payload
    return this.currentSeq
  }

  isLatest(seq: number): boolean {
    return seq === this.currentSeq
  }

  getLatest(): LaunchPayload | undefined {
    return this.latest
  }
}

页面或 RouterService 使用时:

const seq = launchSession.next(payload)

this.loadBizData(payload).then(() => {
  if (!launchSession.isLatest(seq)) {
    // 旧入口触发的异步结果,不允许覆盖新入口状态。
    return
  }

  // 更新页面或路由状态
})

用户从通知点进来,半秒后又从服务卡片点进来,两次入口都可能触发异步加载。没有序号保护,旧请求后回来就能把新页面状态盖掉。

很多“偶尔跳错详情”的问题,本质就是旧任务覆盖了新任务。这个问题不加日志很难看出来,加了任务号以后,一眼就能看出是谁回来晚了。

哪些场景更适合这么做

这套启动治理不是所有 demo 都需要。一个只有首页和设置页的小工具,没必要上来就搞一堆入口层封装。

但下面几类应用,我建议早点做:

  • 内容类应用:从通知、搜索、外部链接进入文章或视频详情。
  • 办公类应用:服务卡片进入审批、待办、日程详情。
  • 电商和本地生活:活动链接、订单通知、桌面快捷入口都要落到不同业务页。
  • 工具类应用:从分享、文件打开、Deep Link 进入不同编辑模式。
  • 多 UIAbility 应用:主界面、独立编辑器、沉浸式展示页需要不同启动实例策略。

只要入口超过两个,就建议尽早把 Want 解析收敛掉。别等首页堆到几百行再重构,到那时候你已经分不清哪段逻辑是给哪个入口补的了。

结尾:入口治理写早一点,后面少还很多债

HarmonyOS 的 AbilityStage、Want、UIAbility,不只是应用模板里那几个默认文件。它们更像应用的入口骨架。

骨架稳了,页面和业务路由才不会到处补洞。

我的习惯是:AbilityStage 只做轻量进程级准备和分流;Want 进入应用后马上转成 LaunchPayload;UIAbility 统一处理冷启动和二次拉起;页面只消费归一后的业务参数。

看着多了几个类,但后面加入口、查问题、做灰度、做埋点,都会轻松很多。

别把启动参数散落在每个页面里。页面一多,谁都不愿意碰;入口一多,问题就开始像玄学。启动链路这种东西,越早工程化,越不容易在上线后给自己挖坑。

用时7天,花费30元,我vibe coding这个网站

作者 Hooray
2026年5月5日 14:54

动机

我有一款 2 年没更新的产品 One-step-admin ,开发初衷是为了更高效的进行跨业务模块操作,但当时设计的交互方式如今回看并不理想。

四月初的时候,我突然有了一个新的交互思路,但那会更多精力集中在发布 Fantastic-admin v6.0 这个全新版本上。但是我让 gemini 先产出了一份原型,验证了一下交互方案的可行性。

原型

相信已经有人看出端倪了,是的,交互这块参考了 MacOS 上的台前调度。

执行

直到五一节前,我准备开始开发这款产品,并且我希望体验一下完全 vibe coding 是什么感觉。

我暂定名称为 One-step Console,中文叫一步控制台,它算是 One-step-admin 的迭代产品,因为目标其实是一致的,还是为了更高效的进行跨业务模块操作。

目前产品还没有对外发布,我先截取了一些图片,方便你了解产品的核心交互设计。

切换舞台

所有功能模块视为一个独立的窗口,每个舞台默认承载一个窗口,所以可以通过切换舞台来实现快速切换窗口。

这样设计的优势在于,传统的网站一个功能模块就是一个独立的路由,如果需要在多个模块间操作,就需要频繁跳转路由,在这前提下,开发中还需要考虑页面保活(KeepAlive)。但现在只需要切换激活舞台即可。

每个舞台支持多窗口布局

通过拖拽窗口到指定舞台,可以实现多窗口的舞台,每个舞台最多支持 4 个窗口,舞台内的窗口可以进行排序和布局的调整。

收藏舞台

可以将多窗口的舞台添加到收藏,方便下次一键打开预设舞台。

拖拽预览

未激活的舞台会缩小尺寸并停靠在侧边栏上,除了可以点击切换舞台外,也支持通过拖拽进行预览。

侧边栏定位和宽度

可以根据使用习惯,调整侧边栏的位置和宽度,并且也支持控制侧边栏显示舞台数。并且超出数量限制的舞台,并不会直接销毁,所以不用担心再次唤起舞台时,窗口会被重新渲染,这部分设计遵循了 LRU (Least Recently Used) 缓存淘汰算法。

最后

以上就是我用了 7 天时间,总计花费了 30 元,几乎纯靠 vibe coding 开发的一款产品,接下来我会完善后续工作,尽快将它发布出来。

一文教你五分钟学会Zustand,React状态管理更加方便!

作者 Yue168
2026年5月5日 14:16

Zustand 快速入门

说明:本笔记源于我学习B站up"一线柏拉图"的视频
www.bilibili.com/video/BV1Tr…
前置知识:基本掌握React语法,包括useState,useReducer,useContext,状态提升,组件间共享状态等

Zustand(德语"状态")是一个轻量、快速、无模板的 React 状态管理库。它基于 Hook,API 设计极简。


为什么需要 Zustand?

  • React 本身已经提供了状态管理,但随着应用变复杂,内置方案会暴露出各种痛点。
  • 多个组件需要共享用户信息时Context和Provider繁琐
  • 更新逻辑散落各组件,状态修改到处都有,难以追踪
  • 无法在组件外部读取/修改,灵活性差。

1. 安装

npm install zustand

2. 创建存储器 Store

  • Store 就是一个自定义 Hook,用 create 函数创建,用来存储定义好的状态和更新函数。
  • 状态和更新函数都定义在一起,方便维护和测试。
  • 在实际项目中往往会有一个专门的文件夹用于存放你定义的所有的Stores。
import { create } from 'zustand'

//例如定义熊bear
const useBearStore = create((set) => ({
  bears: 0,//定义状态,可以定义多个状态
  owner: "Yue",
  //定义更新函数
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 }),
}))

set 是创建 store 时,create 函数自动注入的第一个参数,专门用来更新状态。你可以把它理解为 Zustand 版的 setState。它接收一个对象或函数。函数可接收当前状态 state,用于基于旧值更新。


3. 在组件中使用

直接调用自定义 Hook 即可获取状态和操作方法,无需useContext的Provider包裹

import useBearStore from './store/bearStore'//导入定义好的Store, 导入的useBearStore叫做Store选择器

function BearCounter() {
  // 订阅多个字段
  const bears = useBearStore((state) => state.bears)
   const owner = useBearStore((state) => state.owner)
  return <h1>🐻 {bears} 只熊</h1>
}

function Controls() {
  // 订阅方法(选择器提取多个值)
  const increase = useBearStore((state) => state.increase)
  const decrease = useBearStore((state) => state.decrease)
  const reset = useBearStore((state) => state.reset)

  return (
    <div>
      <button onClick={increase}>+1</button>
      <button onClick={decrease}>-1</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

// 合并在一个组件中
function App() {
  return (
    <>
      <BearCounter />
      <Controls />
    </>
  )
}

解构赋值(简化)

 const { bears, owner, increase, decrease, reset } = useBearStore(
    {
      bears: state.bears,
      owner: state.owner,
      increase: state.increase,
      decrease: state.decrease,
      reset: state.reset,
    })

4. 更新状态的几种方式

  • 直接传对象(合并,非覆盖)
  • 传入函数(接收 state,返回部分状态)
  • 使用 set 的第二个参数:设为 true 会替换整个状态(慎用)
set({ bears: 5 })                     // 合并
set((state) => ({ bears: state.bears + 1 })) // 函数更新
set({ bears: 0 }, true)               // 替换整个状态(其他字段会丢失)

5. 异步操作

直接在 set 中编写异步逻辑即可,就像普通函数一样。

//例如发送网络请求
const useFishStore = create((set) => ({
  fish: [],
  loading: false,
  fetchFish: async () => {
    set({ loading: true })
    const res = await fetch('/api/fish')
    const fish = await res.json()
    set({ fish, loading: false })
  },
}))

6. 选择器与性能优化

通过选择器只订阅需要的部分,避免不必要的重渲染。

// 只有 bears 变了才重渲染
const bears = useBearStore((state) => state.bears)

// 取多个值用 shallow 比较(需额外导入)
import { useShallow } from 'zustand/shallow'

const { bears, owner, increase, decrease, reset } = useBearStore(
    useShallow((state) => ({
      bears: state.bears,
      owner: state.owner,
      increase: state.increase,
      decrease: state.decrease,
      reset: state.reset,
    }))
  )
  • 当你用选择器返回一个对象或数组时,每次渲染都会创建新的引用,导致组件即使数据没变也会重渲染;
  • useShallow 是 Zustand 提供的一个辅助 Hook,用于浅比较选择器的返回值,避免不必要的重渲染。

7. 中间件

  • 中间件是包装 store 创建过程的函数,通过中间件函数包裹 store 定义来给 store 添加额外功能(如持久化、日志、调试等),而不修改业务逻辑。
  • 可以理解为:在 create 和你的 store 定义之间加一层拦截/增强。
  • Zustand 提供了常用中间件,组合使用时注意顺序(一般从外到内:immer > devtools> subscribeWithSelector > persist > store)。

下面是这四个 Zustand 中间件的详细讲解,从作用、使用场景到如何组合使用,一步步说明。


7.1 immer

作用

让你的状态更新方式更简单: 让你用 "直接修改" 的写法来处理不可变数据,Zustand 内部会自动转为不可变更新。

传统写法 vs immer 写法
// ❌ 传统 immutable 写法(一堆展开运算符)
set((state) => ({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      name: '小明'
    }
  }
}))

// ✅ immer 写法:直接修改, 本质是传统方式的封装
set((state) => {
  state.user.profile.name = '小明'
})
安装与使用
npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  immer((set) => ({
    user: { name: '', age: 0 },
    updateName: (name) =>
      set((state) => {
        state.user.name = name // 直接修改
      }),
  }))
)
适用场景
  • 状态嵌套较深(对象/数组)
  • 烦透了各种 ... 展开运算符
  • 更新逻辑复杂且集中在某个深层字段

7.2 devtools

作用

连接 Redux DevTools 浏览器插件,可视化调试状态变化(历史回放、时间旅行)。

Redux DevTools 浏览器插件:这是一个专门用于调试状态管理的浏览器扩展,虽然名字带 Redux,但 Zustand、MobX、Recoil 等都能用它。

安装

无需额外安装,Zustand 内置。

基础用法
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
    }),
    { name: 'CounterStore' } // DevTools 中显示的名字
  )
)

set 的第三个参数是 action 名称,方便 DevTools 里查看。

适用场景
  • 开发和调试阶段排查状态变更
  • 想知道"什么时候、谁(哪个 action)改了状态"
  • 需要回放时间、查看历史

7.3 subscribe(原生方法)和 subscribeWithSelector(中间件)

7.3.1 原生 subscribe

Zustand 的每个 store 默认自带 subscribe 方法,用于监听整个 store 的变化

作用

subscribe 允许你在组件外部(普通 JS 模块、工具函数、WebSocket 连接等)也能监听状态变化,用于副作用管理,而useEffect 只能在组件内使用。

const useStore = create((set) => ({
  count: 0,
  name: 'Zustand'
}))

// 原生 subscribe:任何字段变化都会触发
const unsub = useStore.subscribe((state, prevState) => {
  console.log('有状态变了')
  console.log('旧状态:', prevState)
  console.log('新状态:', state)
})

特点

  • 只能监听整个 store,无法指定只监听某个字段
  • 回调参数是 (newState, prevState)
  • 适合"只要变化就执行"的场景

7.3.2 subscribeWithSelector 中间件
作用

原生 subscribe 无法选择性地监听特定字段。加上这个中间件后,subscribe 方法被增强,可以传入选择器函数,只在选定数据变化时才触发。

import { subscribeWithSelector } from 'zustand/middleware'

const useStore = create(
  subscribeWithSelector((set) => ({
    count: 0,
    name: 'Zustand'
  }))
)

// 增强后的 subscribe:只监听 count
useStore.subscribe(
  (state) => state.count,          // 选择器
  (newCount, prevCount) => {       // 回调只接收选定的值,新值与旧值
    console.log(`count: ${prevCount}${newCount}`)
  }
)

特点

  • 第一个参数是选择器,第二个是回调函数
  • 回调参数是 (selectedValue, prevSelectedValue),更精准
  • 使用 Object.is 比较选择器返回值,只有真正变化才触发
安装

也是 Zustand 内置。

具体适用场景
  • React 组件外部需要监听特定字段变化
  • 实现自定义事件/副作用(如播放声音、发送分析)
  • WebSocket 状态绑定

7.3.3 核心区别对比
特性 原生 subscribe subscribeWithSelector
需要中间件 ❌ 不需要 ✅ 需要显式添加
监听范围 整个 store 可选择特定字段
回调参数 (state, prevState) (selected, prevSelected)
触发频率 任何字段变化都触发 只有选择的字段变化才触发
性能 粗粒度 精细控制

7.3.4 使用场景例举对比
// 场景1:任何状态变化都要记录(原生 subscribe 就够)
useStore.subscribe((state) => {
  console.log('快照日志:', state)
})

// 场景2:只在 token 变化时重连 WebSocket
useStore.subscribe(
  (s) => s.token,        // 需要 subscribeWithSelector
  (token) => {
    if (token) connectWS(token)
    else disconnectWS()
  }
)

// 场景3:只在 theme 变化时操作 DOM
useStore.subscribe(
  (s) => s.theme,        // 需要 subscribeWithSelector
  (theme) => {
    document.documentElement.dataset.theme = theme
  }
)

7.3.5 联系:作用相同,能力不同(了解即可)
  • 本质相同:都是监听状态变化的底层 API,都返回取消订阅函数
  • 能力递进subscribeWithSelector 是原生 subscribe增强版
  • 内部关系:Zustand 内部实现中,subscribeWithSelector 包装了原生 subscribe,用 Object.is 做比较
// 概念上的伪代码
function subscribeWithSelector(store) {
  return (selector, callback) => {
    let prevValue = selector(store.getState())
    
    // 内部还是调用原生 subscribe
    store.subscribe((state) => {
      const newValue = selector(state)
      if (!Object.is(newValue, prevValue)) {
        callback(newValue, prevValue)
        prevValue = newValue
      }
    })
  }
}

7.3.6 组件外监听 vs 组件内选择器
环境 用什么
React 组件内 useStore(selector)
组件外,监听整个 store 原生 subscribe
组件外,监听特定字段 subscribeWithSelector

总结

原生 subscribe 是基础款,粗粒度监听全部变化。subscribeWithSelector 是通过中间件增强的精准版,让你能选择性监听特定字段,避免无用的回调触发。想要细粒度控制在组件外监听,用后者就对了。


7.4 persist

作用

自动将状态保存到 localStorage / sessionStorage 等,刷新页面后自动恢复。

安装

内置,无需安装。

基础用法
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

const useStore = create(
  persist(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 })),
    }),
    {
      name: 'my-counter',//localStorage 的 key(键名)
      storage: createJSONStorage(() => localStorage), // 默认就是这个,可换sessionStorage等,修改Store存储在浏览器的位置
    }
  )
)
进阶配置(感兴趣可学)
persist(store, {
  name: 'app-store',
  storage: createJSONStorage(() => sessionStorage), // 用 sessionStorage
  partialize: (state) => ({ token: state.token }), // 只持久化 token
  version: 1,
  migrate: (persistedState, version) => {
    // 版本迁移逻辑
    return persistedState
  },
})
适用场景
  • 用户偏好(主题、语言)
  • 购物车、草稿
  • JWT token

7.5 中间件组合顺序

import { create } from 'zustand'
import { persist, devtools, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

const useStore = create(
  devtools(                              // 1. 最外:调试
    subscribeWithSelector(               // 2. 允许选择性订阅
      persist(                           // 3. 持久化
        immer((set) => ({                // 4. 深层更新 + store
          user: { name: '小明', age: 18 },
          incAge: () =>
            set((s) => {
              s.user.age += 1
            }),
        })),
        { name: 'my-store' }
      )
    ),
    { name: 'AppStore' }
  )
)

// 在组件外选择性监听
useStore.subscribe(
  (s) => s.user.age,
  (newAge) => console.log('年龄变成:', newAge)
)

8. 在 React 之外使用

Store 返回的 Hook 上挂载了 getStatesetState,可以直接在 React 外部读取/修改状态。

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 0,
  user: { name: '小明', age: 18 },
  inc: () => set((s) => ({ count: s.count + 1 })),
}))

// ✅ 在任何地方获取当前状态,不再只限于组件内
const currentState = useStore.getState()
console.log(currentState.count)  // 0
console.log(currentState.user.name)  // '小明'

//setState不用受已定义的更新函数的影响,做到随用随定义
// ✅ 在组件外直接更新
useStore.setState({ count: 10 })

// ✅ 传入函数(基于旧状态的更新)
useStore.setState((prev) => ({ count: prev.count + 1 }))

// ✅ 替换整个 state(第二个参数 true)
useStore.setState({ count: 0 }, true)

9. 进阶学习

啥子都能看懂的TypeScript快速入门

作者 Yue168
2026年5月5日 13:54

TypeScript 从小白到快速上手

说明:大部分笔记参考B站尚硅谷TS课程
https://www.bilibili.com/video/BV1YS411w7Bf/?spm_id_from=333.337.search-card.all.click&vd_source=4de192a0ec1fe7b5fc788e72acc90efa
前置知识:JavaScript 基础和 ES6+ 新特性

一、TypeScript 简介

  1. TypeScript 包含了 JavaScript 的所有内容,即:TypeScript 是 JavaScript 的超集
  2. TypeScript 增强了静态类型检查、接口编程和面向对象特性,更适合大型项目的开发。
  3. TypeScript 需要编译为 JavaScript,然后交给浏览器或其他 JavaScript 运行环境执行。

二、为何需要 TypeScript

1. 如今成神的JavaScript其实先天废材(了解)

  • JavaScript 当年诞生时的定位是浏览器脚本语言,用于在网页中嵌入简单的逻辑,且代码量很少。
  • 随着时间的推移,JavaScript 变得越来越流行,如今的 JavaScript 已经可以全栈编程了。
  • 现如今的 JavaScript 应用场景比当年丰富的多,代码量也比当年大很多,随便一个 JavaScript 项目的代码量,可以轻松的达到几万行,甚至十几万行!
  • 然而 JavaScript 当年“出生简陋”,没考虑到如今的应用场景和代码量,逐渐就出现了很多困扰。

2. 成也萧何,败也萧何 ---- 静态类型检查的缺失

  • JavaScript 的弱类型带来了灵活性,但也牺牲了类型安全性。

不清楚的数据类型

let welcome = 'hello'
welcome() // 此行报错: TypeError: welcome is not a function

有漏洞的逻辑

const str = Date.now() % 2 ? '奇数' : '偶数'
if (str !== '奇数') {
    alert('hello')
} else if (str == '偶数') {
    alert('world')
}

访问不存在的属性

const obj = { width: 10, height: 15 }
const area = obj.width * obj.height

低级的拼写错误

const message = 'hello,world'
message.toUpperCase()

3. 静态类型检查

  • 在代码运行前进行检查,发现代码的错误或不合理之处,减小运行时出现异常的几率,此种检查叫『静态类型检查』,TypeScript 的核心就是『静态类型检查』,简言之就是把运行时的错误前置。
  • 同样的功能,TypeScript 的代码量要大于 JavaScript,但由于 TypeScript 的代码结构更加清晰,在后期代码的维护中 TypeScript 却胜于 JavaScript。

三、TypeScript 的编译(了解,没啥用)

浏览器不能直接运行 TypeScript 代码,需要编译为 JavaScript 再交由浏览器解析器执行。

1. 命令行编译

  • 第一步:创建一个 demo.ts 文件,例如:
const person = { name: '李四', age: 18 }
console.log(`我叫${person.name},我今年${person.age}岁了`)
  • 第二步:全局安装 TypeScript
npm i typescript -g
  • 第三步:使用命令编译 .ts 文件
tsc demo.ts

2. 自动化编译

  • 第一步:创建 TypeScript 编译控制文件
tsc --init
  1. 工程中会生成一个 tsconfig.json 配置文件,其中包含着很多编译时的配置。
  2. 观察发现,默认编译的 JS 版本是 ES7,我们可以手动调整为其他版本。
  • 第二步:监视目录中的 .ts 文件变化
tsc --watch  或  tsc -w
  • 第三步:小优化,当编译出错时不生成 .js 文件(也可以修改 tsconfig.json 中的 noEmitOnError 配置)

四、类型声明

使用 : 来对变量或函数形参进行类型声明:

let a: string   // 变量a只能存储字符串
let b: number   // 变量b只能存储数值
let c: boolean  // 变量c只能存储布尔值

a = 'hello'
a = 100         // 警告:不能将类型"number"分配给类型"string"

b = 666
b = '你好'      // 警告:不能将类型"string"分配给类型"number"

c = true
c = 666         // 警告:不能将类型"number"分配给类型"boolean"

// 参数x必须是数字,参数y也必须是数字,函数返回值也必须是数字
function demo(x: number, y: number): number {
    return x + y
}

demo(100, 200)
demo(100, '200')     // 警告:类型"string"的参数不能赋给类型"number"的参数
demo(100, 200, 300)  // 警告:应有2个参数,但获得3个
demo(100)            // 警告:应有2个参数,但获得1个

: 后也可以写字面量类型,不过实际开发中用的不多。

let a: '你好'   // a的值只能为字符串"你好"
let b: 100      // b的值只能为数字100

a = '欢迎'      // 警告:不能将类型"欢迎"分配给类型"你好"
b = 200         // 警告:不能将类型"200"分配给类型"100"

五、类型自动推断

TypeScript 会根据我们的代码进行类型推导,例如下面代码中的变量 d 只能存储数字:

let d = -99   // TypeScript会推断出变量d的类型是数字
d = false     // 警告:不能将类型“boolean”分配给类型“number”

但要注意,类型推断不是万能的,面对复杂类型时推断容易出问题,所以尽量还是明确的编写类型声明!

六、类型总概

JavaScript 中的数据类型

① string ② number ③ boolean ④ null ⑤ undefined ⑥ bigint ⑦ symbol ⑧ object

备注:其中 object 包含:Array、Function、Date、Error 等...

TypeScript 新增类型

  1. 上述所有 JavaScript 类型
  2. 六个新类型:① any ② unknown ③ never ④ void ⑤ tuple ⑥ enum
  3. 两个用于自定义类型的方式:① type ② interface

注意点! 在 JavaScript 中的这些内置构造函数(Number、String、Boolean)用于创建对应的包装对象,在日常开发时很少使用,在 TypeScript 中也是同理,所以在 TypeScript 中进行类型声明时,通常都是用小写的 numberstringboolean。例如:

let str1: string
str1 = 'hello'
str1 = new String('hello')   // 报错

let str2: String
str2 = 'hello'
str2 = new String('hello')

七、常用类型与语法

1. any(了解即可)

any 的含义是:任意类型,一旦将变量类型限制为 any,那就意味着放弃了对该变量的类型检查。注意:any 类型的变量可以赋值给任意类型的变量。

// 明确的表示 a 的类型是 any —— 【显式的 any】
let a: any
a = 100
a = '你好'
a = false

// 没有明确的表示 b 的类型是 any,但 TS 主动推断出来 b 是 any —— 隐式的 any
let b
b = 100
b = '你好'
b = false

let c: any
c = 9
let x: string
x = c   // 无警告

2. unknown(了解即可)

unknown 的含义是:未知类型,适用于起初不确定数据的具体类型,要后期才能确定。

  • unknown 可以理解为一个类型安全的 any
let a: unknown
a = 100
a = false
a = '你好'

let x: string
x = a   // 警告:不能将类型"unknown"分配给类型"string"
  • unknown 会强制开发者在使用之前进行类型检查,从而提供更强的类型安全性。
let a: unknown
a = 'hello'

// 第一种方式:加类型判断
if (typeof a == 'string') {
    x = a
    console.log(x)
}

// 第二种方式:加断言
x = a as string

// 第三种方式:加断言
x = <string>a
  • 读取 any 类型数据的任何属性都不会报错,而 unknown 正好与之相反。
let str1: string
str1 = 'hello'
str1.toUpperCase()   // 无警告

let str2: any
str2 = 'hello'
str2.toUpperCase()   // 无警告

let str3: unknown
str3 = 'hello'
str3.toUpperCase()   // 警告:“str3”的类型为“未知”

// 使用断言强制指定 str3 的类型为 string
(str3 as string).toUpperCase()   // 无警告

3. never(了解即可)

never 的含义是:任何值都不是,即不能有值,例如 undefinednull""0 都不行!

  • 几乎不用 never 去直接限制变量,因为没有意义。
// 指定 a 的类型为 never,那就意味着 a 以后不能存任何的数据了
let a: never
a = 1          // 警告
a = true       // 警告
a = undefined  // 警告
a = null       // 警告
  • never 一般是 TypeScript 主动推断出来的。
  • never 也可用于限制函数的返回值。
// 限制 throwError 函数不需要有任何返回值,任何值都不行
function throwError(str: string): never {
    throw new Error('程序异常退出:' + str)
}

4. void

void 的含义是空,即:函数不返回任何值,调用者也不应依赖其返回值进行任何操作!

  • void 通常用于函数返回值声明。
function logMessage(msg: string): void {
    console.log(msg)
}
logMessage('你好')

注意:编码者没有编写 return 指定函数返回值,所以 logMessage 函数是没有显式返回值的,但会有一个隐式返回值,是 undefined,虽然函数返回类型为 void,但也是可以接受 undefined 的。简单记:undefinedvoid 可以接受的一种“空”。

  • 以下写法均符合规范:
function logMessage(msg: string): void {
    console.log(msg)
}

function logMessage(msg: string): void {
    console.log(msg)
    return
}

function logMessage(msg: string): void {
    console.log(msg)
    return undefined
}

但是有区别:返回值类型为 void 的函数,调用者不应依赖其返回值进行任何操作!

function logMessage(msg: string): void {
    console.log(msg)
}
let result = logMessage('你好')
if (result) {   // 此行报错:无法测试 "void" 类型的表达式的真实性
    console.log('logMessage有返回值')
}

function logMessage(msg: string): undefined {
    console.log(msg)
}
let result = logMessage('你好')
if (result) {   // 此行无警告
    console.log('logMessage有返回值')
}

理解 void 与 undefined(这里看不懂就别管他)

  • void 是一个广泛的概念,用来表达“空”,而 undefined 则是这种“空”的具体实现。
  • 因此可以说 undefinedvoid 能接受的一种“空”的状态。
  • 也可以理解为:void 包含 undefined,但 void 所表达的语义超越了 undefinedvoid 是一种意图上的约定,而不仅仅是特定值的限制。

总结:如果一个函数返回类型为 void,那么:

  1. 从语法上讲:函数是可以返回 undefined 的,至于显式返回,还是隐式返回,这无所谓!
  2. 从语义上讲:函数调用者不应关心函数返回的值,也不应依赖返回值进行任何操作!即使我们知道它返回了 undefined

5. object(了解即可)

关于 objectObject,实际开发中用的相对较少,因为范围太大了。

let a: object   // a的值可以是任何【非原始类型】,包括:对象、函数、数组等
a = {}
a = { name: '张三' }
a = [1, 3, 5, 7, 9]
a = function() {}
a = new String('123')
class Person {}
a = new Person()

// 以下代码,是将【原始类型】赋给 a,有警告
a = 1
a = true
a = '你好'
a = null
a = undefined
let b: Object   // b的值必须是 object 的实例对象(除去 undefined 和 null 的任何值)
b = {}
b = { name: '张三' }
b = [1, 3, 5, 7, 9]
b = function() {}
b = new String('123')
b = 1            // 1 不是 object 的实例对象,但其包装对象是 object 的实例
b = true         // true 不是 object 的实例对象,但其包装对象是 object 的实例
b = '你好'       // 同样可
b = null         // 警告
b = undefined    // 警告

声明对象类型

实际开发中,限制一般对象,通常使用以下形式:

// 限制 person1 对象必须有 name 属性,age 为可选属性
let person1: { name: string, age?: number }
let person2: { name: string; age?: number }
let person3: {
    name: string
    age?: number
}

person1 = { name: '李四', age: 18 }
person2 = { name: '张三' }
person3 = { name: '王五' }
// person3 = { name: '王五', gender: '男' }   // 不合法

索引签名:允许定义对象可以具有任意数量的属性,这些属性的键和类型是可变的。

let person: {
    name: string
    age?: number
    [key: string]: any   // 索引签名
}
person = { name: '张三', age: 18, gender: '男' }

声明函数类型

let count: (a: number, b: number) => number
count = function(x, y) {
    return x + y
}

备注:

  • TypeScript 中的 => 在函数类型声明时表示函数类型,描述其参数类型和返回类型。
  • JavaScript 中的 => 是一种定义函数的语法,是具体的函数实现。

声明数组类型

let arr1: string[]
let arr2: Array<string>
arr1 = ['a', 'b', 'c']
arr2 = ['hello', 'world']

6. tuple(tuple不是关键字!)

元组(Tuple)是一种特殊的数组类型,可以存储固定数量的元素,并且每个元素的类型是已知的且可以不同。元组用于精确描述一组值的类型,? 表示可选元素。

// 第一个元素必须是 string 类型,第二个元素必须是 number 类型
let arr1: [string, number]

// 第一个元素必须是 number 类型,第二个元素是可选的,如果存在,必须是 boolean 类型
let arr2: [number, boolean?]

// 第一个元素必须是 number 类型,后面的元素可以是任意数量的 string 类型
let arr3: [number, ...string[]]

arr1 = ['hello', 123]
arr2 = [100, false]
arr2 = [200]
arr3 = [100, 'hello', 'world']
arr3 = [100]

// arr1 = ['hello', 123, false]   // 不可以

7. enum

枚举(enum)可以定义一组命名常量,它能增强代码的可读性,也让代码更好维护。

数字枚举

enum Direction {
    Up,
    Down,
    Left,
    Right
}

function walk(n: Direction) {
    if (n === Direction.Up) {
        console.log("向【上】走")
    } else if (n === Direction.Down) {
        console.log("向【下】走")
    } else if (n === Direction.Left) {
        console.log("向【左】走")
    } else if (n === Direction.Right) {
        console.log("向【右】走")
    } else {
        console.log("未知方向")
    }
}

walk(Direction.Up)
walk(Direction.Down)

数字枚举的成员的值会自动递增,且具备反向映射的特点。

字符串枚举(了解即可)

enum Direction {
    Up = "up",
    Down = "down",
    Left = "left",
    Right = "right"
}
let dir: Direction = Direction.Up
console.log(dir)   // 输出: "up"

常量枚举

常量枚举使用 const 关键字定义,在编译时会被内联,避免生成额外的代码,减少生成的 JavaScript 代码量并提高运行时性能。

const enum Directions {
    Up,
    Down,
    Left,
    Right
}
let x = Directions.Up

编译后生成的 JavaScript 代码量较小:

"use strict";
let x = 0 /* Directions.Up */;

8. type(了解即可)

type 可以为任意类型创建别名,让代码更简洁、可读性更强,同时能更方便地进行类型复用和扩展。

基本用法

type num = number
let price: num
price = 100

联合类型

联合类型表示一个值可以是几种不同类型之一。

type Status = number | string
type Gender = '男' | '女'

function printStatus(status: Status) {
    console.log(status)
}

function logGender(str: Gender) {
    console.log(str)
}

printStatus(404)
printStatus('200')
logGender('男')
logGender('女')

交叉类型

交叉类型允许将多个类型合并为一个类型。合并后的类型将拥有所有被合并类型的成员。

// 面积
type Area = {
    height: number   // 高
    width: number    // 宽
}

// 地址
type Address = {
    num: number     // 楼号
    cell: number    // 单元号
    room: string    // 房间号
}

// 定义类型 House,是 Area 和 Address 组成的交叉类型
type House = Area & Address

const house: House = {
    height: 180,
    width: 75,
    num: 6,
    cell: 3,
    room: '702'
}

9.类class

  • TypeScript 的类是在 JavaScript ES6 类的基础上,增加了类型声明和面向对象增强特性。
class Animal {
    constructor(public name: string) {}
    
    move(distance: number): void {
        console.log(`${this.name}移动了${distance}米`)
    }
}

class Dog extends Animal {
    constructor(name: string, public breed: string) {
        super(name)  // 必须调用super()
    }
    
    bark(): void {
        console.log(`${this.name}汪汪叫`)
    }
    
    //明确标注这是一个重写方法(推荐)
    override move(distance: number): void {
        console.log(`小狗${this.name}跑了${distance}米`)
        super.move(distance)  // 可选调用父类方法
    }
}

const dog = new Dog('旺财', '金毛')
dog.bark()   // 旺财汪汪叫
dog.move(10) // 小狗旺财跑了10米 \n 旺财移动了10米\

10. 属性修饰符(和Java的语法同理)

  • 前置知识:JS的类class
修饰符 含义 具体规则
public 公开的 可以被:类内部、子类、类外部访问
protected 受保护的 可以被:类内部、子类访问
private 私有的 可以被:类内部访问
readonly 只读 属性无法修改

示例:

class Person {
    public name: string
    age: number   // 未写修饰符,默认 public
    constructor(name: string, age: number) {
        this.name = name
        this.age = age
    }
    speak() {
        console.log(`我叫:${this.name},今年${this.age}岁`)
    }
}

const p1 = new Person('张三', 18)
console.log(p1.name)   // 外部可访问

class Student extends Person {
    constructor(name: string, age: number) {
        super(name, age)
    }
    study() {
        console.log(`${this.age}岁的${this.name}正在努力学习`)
    }
}

属性的简写形式

class Person {
    constructor(
        public name: string,
        public age: number
    ) {}
}

protected 修饰符

class Person {
    constructor(
        protected name: string,
        protected age: number
    ) {}
    protected getDetails(): string {
        return `我叫:${this.name},年龄是:${this.age}`
    }
    introduce() {
        console.log(this.getDetails())
    }
}

const p1 = new Person('杨超越', 18)
p1.introduce()
// p1.getDetails()   // 报错
// p1.name           // 报错

class Student extends Person {
    constructor(name: string, age: number) {
        super(name, age)
    }
    study() {
        this.introduce()
        console.log(`${this.name}正在努力学习`)
    }
}

private 修饰符

class Person {
    constructor(
        public name: string,
        public age: number,
        private IDCard: string
    ) {}
    private getPrivateInfo() {
        return `身份证号码为:${this.IDCard}`
    }
    getInfo() {
        return `我叫:${this.name},今年刚满${this.age}岁`
    }
    getFullInfo() {
        return this.getInfo() + ',' + this.getPrivateInfo()
    }
}

const p1 = new Person('张三', 18, '110114198702034432')
console.log(p1.getFullInfo())
// p1.name          // 报错
// p1.IDCard        // 报错

readonly 修饰符

class Car {
    constructor(
        public readonly vin: string,   // 车辆识别码,只读
        public readonly year: number,  // 出厂年份,只读
        public color: string,
        public sound: string
    ) {}
    displayInfo() {
        console.log(`
            识别码:${this.vin},
            出厂年份:${this.year},
            颜色:${this.color},
            音响:${this.sound}
        `)
    }
}

const car = new Car('1HGCM82633A123456', 2018, '黑色', 'Bose音响')
car.displayInfo()
// car.vin = 'new'   // 错误:只读属性不可修改

11. 抽象类

抽象类是一种无法被实例化的类,专门用来定义类的结构和行为。类中可以写抽象方法,也可以写具体实现。抽象类主要用来为其派生类提供一个基础结构,要求其派生类必须实现其中的抽象方法。

abstract class Package {
    constructor(public weight: number) {}
    // 抽象方法:用来计算运费
    abstract calculate(): number
    // 通用方法
    printPackage() {
        console.log(`包裹重量为:${this.weight}kg,运费为:${this.calculate()}元`)
    }
}

// 标准包裹
class StandardPackage extends Package {
    constructor(
        weight: number,
        public unitPrice: number   // 每公斤的固定费率
    ) {
        super(weight)
    }
    calculate(): number {
        return this.weight * this.unitPrice
    }
}

const s1 = new StandardPackage(10, 5)
s1.printPackage()

// 特快专递
class ExpressPackage extends Package {
    constructor(
        weight: number,
        public unitPrice: number,
        public extraFee: number
    ) {
        super(weight)
    }
    calculate(): number {
        return this.weight * this.unitPrice + this.extraFee
    }
}

使用抽象类的场景

  1. 定义通用接口:为一组相关的类定义通用的行为(方法或属性)时。
  2. 提供基础实现:在抽象类中提供某些方法或为其提供基础实现,这样派生类就可以继承这些实现。
  3. 确保关键实现:强制派生类实现一些关键行为。
  4. 共享代码和逻辑:当多个类需要共享部分代码时,抽象类可以避免代码重复。

12. interface(接口)

interface 是一种定义结构的方式,主要作用是为类、对象、函数等规定一种契约,确保代码的一致性和类型安全。interface 只能定义格式,不能包含任何实现。

定义类结构

interface PersonInterface {
    name: string
    age: number
    speak(n: number): void
}

class Person implements PersonInterface {
    constructor(
        public name: string,
        public age: number
    ) {}
    speak(n: number): void {
        for (let i = 0; i < n; i++) {
            console.log(`你好,我叫${this.name},我的年龄是${this.age}`)
        }
    }
}

const p1 = new Person('tom', 18)
p1.speak(3)

定义对象结构

interface UserInterface {
    name: string
    readonly gender: string   // 只读属性
    age?: number              // 可选属性
    run: (n: number) => void
}

const user: UserInterface = {
    name: "张三",
    gender: '男',
    age: 18,
    run(n) {
        console.log(`奔跑了${n}米`)
    }
}

定义函数结构

interface CountInterface {
    (a: number, b: number): number
}

const count: CountInterface = (x, y) => {
    return x + y
}

接口之间的继承

interface PersonInterface {
    name: string
    age: number
}

interface StudentInterface extends PersonInterface {
    grade: string
}

const stu: StudentInterface = {
    name: "张三",
    age: 25,
    grade: '高三'
}

13. 一些相似概念的区别

13.1 interface 与 type 的区别

相同点:都可以用于定义对象结构,在定义对象结构时两者可以互换。

不同点

  • interface:更专注于定义对象和类的结构,支持继承、合并。
  • type:可以定义类型别名、联合类型、交叉类型,但不支持继承和自动合并。

interface 示例(支持继承和声明合并):

interface PersonInterface {
    name: string
    age: number
}
interface PersonInterface {
    speak: () => void
}
interface StudentInterface extends PersonInterface {
    grade: string
}
const student: StudentInterface = {
    name: '李四',
    age: 18,
    grade: '高二',
    speak() {
        console.log(this.name, this.age, this.grade)
    }
}

type 示例(通过交叉类型实现类似继承):

type PersonType = {
    name: string
    age: number
} & {
    speak: () => void
}
type StudentType = PersonType & {
    grade: string
}
const student: StudentType = {
    name: '李四',
    age: 18,
    grade: '高二',
    speak() {
        console.log(this.name, this.age, this.grade)
    }
}
13.2 interface 与抽象类的区别

相同点:都能定义一个类的格式(定义类应遵循的契约)。

不同点

  • 接口:只能描述结构,不能有任何实现代码,一个类可以实现多个接口。
  • 抽象类:既可以包含抽象方法,也可以包含具体方法,一个类只能继承一个抽象类。

一个类实现多个接口

interface FlyInterface {
    fly(): void
}
interface SwimInterface {
    swim(): void
}

class Duck implements FlyInterface, SwimInterface {
    fly(): void {
        console.log('鸭子可以飞')
    }
    swim(): void {
        console.log('鸭子可以游泳')
    }
}

const duck = new Duck()
duck.fly()
duck.swim()

八、泛型

泛型允许我们在定义函数、类或接口时,使用类型参数来表示未指定的类型,这些参数在具体使用时才被指定具体的类型。泛型能让同一段代码适用于多种类型,同时仍然保持类型的安全性。

泛型函数

function logData<T>(data: T): T {
    console.log(data)
    return data
}
logData<number>(100)
logData<string>('hello')

泛型可以有多个

function logData<T, U>(data1: T, data2: U): T | U {
    console.log(data1, data2)
    return Date.now() % 2 ? data1 : data2
}
logData<number, string>(100, 'hello')
logData<string, boolean>('ok', false)

泛型接口

interface PersonInterface<T> {
    name: string
    age: number
    extraInfo: T
}

let p1: PersonInterface<string>
let p2: PersonInterface<number>
p1 = { name: '张三', age: 18, extraInfo: '一个好人' }
p2 = { name: '李四', age: 18, extraInfo: 250 }

泛型约束

  • 泛型约束用于限制泛型类型参数的范围,确保传入的类型满足特定条件。
interface LengthInterface {
    length: number
}
// 约束规则是:传入的类型 T 必须具有 length 属性
function logPerson<T extends LengthInterface>(data: T): void {
    console.log(data.length)
}
logPerson<string>("hello")
// logPerson<number>(100)   // 报错,因为 number 不具备 length 属性

泛型类

class Person<T> {
    constructor(
        public name: string,
        public age: number,
        public extraInfo: T
    ) {}
    speak() {
        console.log(`我叫${this.name}今年${this.age}岁了`)
        console.log(this.extraInfo)
    }
}

// 测试代码1
const p1 = new Person<number>("tom", 30, 250)

// 测试代码2
type JobInfo = {
    title: string
    company: string
}
const p2 = new Person<JobInfo>("tom", 30, { title: '研发总监', company: '发发发科技公司' })

九、类型声明文件(了解即可)

  • 类型声明文件是 TypeScript 中以 .d.ts 结尾的文件,用于描述 JavaScript 代码的类型信息,让 TypeScript 能够理解和使用普通 JavaScript 库。
  • 很多的第三方工具库都是用JS写的,想要在TS项目里使用它们就必须要有类型声明文件,一般Vite等工具在创建项目时已经自动配置好引入了,不用我们管。

十. 一些进阶的东西(感兴趣的可以深入学习)

❌
❌