普通视图

发现新文章,点击刷新页面。
今天 — 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,其实只需要一个写得好点的脚本。

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

下次见。

比亚迪:4月新能源汽车产量32.23万辆、销量32.11万辆

2026年5月5日 18:30
比亚迪公告,2026年4月新能源汽车产量32.23万辆,销量32.11万辆;本年累计产量103.03万辆,同比下降28.56%,去年累计144.21万辆;累计销量102.16万辆,同比下降26.02%,去年累计138.09万辆。4月出口新能源汽车13.51万辆;4月新能源汽车动力电池及储能电池装机总量约20.98 GWh,2026年累计装机总量约81.19 GWh。

红旗连锁:商投投资受让22.09%公司股份

2026年5月5日 18:10
红旗连锁公告,公司于2026年4月30日收到四川商投投资有限责任公司通知,曹世如、曹曾俊与商投投资协议转让股份事宜已完成过户登记手续,并取得《证券过户登记确认书》。商投投资权益变动前持股数量为2.3亿股,持股比例为16.91%,表决权比例为21.32%;权益变动后持股数量为3亿股,持股比例为22.09%,表决权比例为22.09%。(新浪财经)

亚马逊全面开放旗下物流网络

2026年5月5日 17:36
当地时间4日,美国电商巨头亚马逊宣布,将其全球物流与供应链网络全面开放给所有类型、所有规模的企业使用,不再仅限于入驻商家。根据亚马逊当天发布的声明,这项服务面向全行业第三方企业开放,企业可依托亚马逊的物流体系,完成从原材料运输、跨境转运、仓储分拨到终端成品配送的全链条供应链管理。市场分析人士认为,此举意味着亚马逊进军物流行业,可能会加剧行业竞争。消息公布后,4日当天美股物流板块承压,联合包裹(UPS)股价跌超10%,联邦快递股价跌超9%。亚马逊则上涨1.41%。(央视财经)

威龙股份:控股股东筹划控制权变更 股票停牌

2026年5月5日 17:17
5月5日,威龙股份公告称,公司控股股东星河息壤(浙江)数智科技有限公司正在筹划公司股份协议转让事宜,该事项可能导致公司控制权发生变更。鉴于该事项存在不确定性,公司股票自2026年5月6日开市起停牌,预计停牌时间不超过2个交易日。(每经网)

金利华电:筹划发行股份及支付现金购买中科西光股权并募集配套资金 股票停牌

2026年5月5日 16:54
金利华电公告,公司正在筹划发行股份及支付现金购买西安中科西光航天科技集团有限公司全部或部分股权,并募集配套资金。因有关事项尚存不确定性,公司股票自2026年5月6日开市起停牌,预计在不超过10个交易日内披露交易方案。本次交易不会导致公司实际控制人变更,但尚存较大不确定性,敬请投资者注意风险。(第一财经)
❌
❌