阅读视图

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

每日一题-二指输入的的最小距离🔴

二指输入法定制键盘在 X-Y 平面上的布局如上图所示,其中每个大写英文字母都位于某个坐标处。

  • 例如字母 A 位于坐标 (0,0),字母 B 位于坐标 (0,1),字母 P 位于坐标 (2,3) 且字母 Z 位于坐标 (4,1)

给你一个待输入字符串 word,请你计算并返回在仅使用两根手指的情况下,键入该字符串需要的最小移动总距离。

坐标 (x1,y1) (x2,y2) 之间的 距离 是 |x1 - x2| + |y1 - y2|。 

注意,两根手指的起始位置是零代价的,不计入移动总距离。你的两根手指的起始位置也不必从首字母或者前两个字母开始。

 

示例 1:

输入:word = "CAKE"
输出:3
解释: 
使用两根手指输入 "CAKE" 的最佳方案之一是: 
手指 1 在字母 'C' 上 -> 移动距离 = 0 
手指 1 在字母 'A' 上 -> 移动距离 = 从字母 'C' 到字母 'A' 的距离 = 2 
手指 2 在字母 'K' 上 -> 移动距离 = 0 
手指 2 在字母 'E' 上 -> 移动距离 = 从字母 'K' 到字母 'E' 的距离  = 1 
总距离 = 3

示例 2:

输入:word = "HAPPY"
输出:6
解释: 
使用两根手指输入 "HAPPY" 的最佳方案之一是:
手指 1 在字母 'H' 上 -> 移动距离 = 0
手指 1 在字母 'A' 上 -> 移动距离 = 从字母 'H' 到字母 'A' 的距离 = 2
手指 2 在字母 'P' 上 -> 移动距离 = 0
手指 2 在字母 'P' 上 -> 移动距离 = 从字母 'P' 到字母 'P' 的距离 = 0
手指 1 在字母 'Y' 上 -> 移动距离 = 从字母 'A' 到字母 'Y' 的距离 = 4
总距离 = 6

 

提示:

  • 2 <= word.length <= 300
  • 每个 word[i] 都是一个大写英文字母。

教你一步步思考 DP:记忆化搜索 -> 递推 -> 空间优化(Python/Java/C++/Go)

一、分析

示例 1 的 $\textit{word} = \texttt{CAKE}$,敲完 $\texttt{E}$ 就结束了,所以最后一定有一根手指停在 $\texttt{E}$。

另一根手指呢?最后一定停在 $\texttt{K}$ 吗?不一定,如果第一根手指输入 $\texttt{CA}$,第二根手指输入 $\texttt{KE}$,那么第一根手指最后会停在 $\texttt{A}$,第二根手指最后会停在 $\texttt{E}$。

只要我们能实时跟踪两根手指的位置(在哪个字母),就能暴力搜索所有输入的过程。

二、状态定义与状态转移方程

根据上面的讨论,定义 $\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2)$ 表示在手指 1 位于字母 $\textit{finger}_1$,手指 2 位于字母 $\textit{finger}_2$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i]$ 的最小移动总距离。

:从右往左递归,主要是方便把递归翻译成从左往右的递推。从左往右递归也是可以的。

讨论用哪根手指输入 $\textit{word}[i]$:

  • 用手指 1,那么接下来要解决的问题是,在手指 1 位于字母 $\textit{word}[i]$,手指 2 位于字母 $\textit{finger}_2$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{word}[i],\textit{finger}_2)$,加上从 $\textit{finger}_1$ 到 $\textit{word}[i]$ 的距离。
  • 用手指 2,那么接下来要解决的问题是,在手指 1 位于字母 $\textit{finger}_1$,手指 2 位于字母 $\textit{word}[i]$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{finger}_1,\textit{word}[i])$,加上从 $\textit{finger}_2$ 到 $\textit{word}[i]$ 的距离。

这两种情况取最小值,就得到了 $\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2)$,即

$$
\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2) = \min
\begin{cases}
\textit{dfs}(i-1, \textit{word}[i],\textit{finger}_2) + \textit{dis}[\textit{finger}_1][\textit{word}[i]] \
\textit{dfs}(i-1, \textit{finger}_1,\textit{word}[i]) + \textit{dis}[\textit{finger}_2][\textit{word}[i]] \
\end{cases}
$$

其中 $\textit{dis}[x][y]$ 表示从字母 $x$ 到字母 $y$ 的距离。这个二维数组可以在跑 $\textit{dfs}$ 之前预处理出来。

递归边界:$\textit{dfs}(-1, \textit{finger}_1, \textit{finger}_2)=0$。没有字母需要输入,无需移动。

递归入口:$\textit{dfs}(n-2, \textit{word}[n-1], \textit{finger}_2)$。最后一定有一根手指在 $\textit{word}[n-1]$,另一根手指的位置不确定,枚举 $\textit{finger}_2$。

三、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

注意:$\textit{memo}$ 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 $0$,并且要记忆化的 $\textit{dfs}(i, \textit{finger}_1, \textit{finger}_2)$ 也等于 $0$,那就没法判断 $0$ 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 $-1$。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

具体请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

###py

# 预处理两个字母的距离
COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        word = [ord(ch) - ord('A') for ch in word]  # 避免在 dfs 中频繁调用 ord

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, finger1: int, finger2: int) -> int:
            if i < 0:
                return 0

            # 手指 1 移到 word[i]
            res1 = dfs(i - 1, word[i], finger2) + dis[finger1][word[i]]

            # 手指 2 移到 word[i]
            res2 = dfs(i - 1, finger1, word[i]) + dis[finger2][word[i]]

            return min(res1, res2)

        n = len(word)
        # 最后一定有一根手指在 word[-1],另一根手指的位置不确定,枚举
        return min(dfs(n - 2, word[-1], finger2) for finger2 in range(26))

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        // 预处理两个字母的距离
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();
        int n = s.length;

        int[][][] memo = new int[n][26][26];
        for (int[][] mat : memo) {
            for (int[] row : mat) {
                Arrays.fill(row, -1); // -1 表示没有计算过
            }
        }

        int ans = Integer.MAX_VALUE;
        // 最后一定有一根手指在 s[n-1],另一根手指的位置不确定,枚举
        for (int finger2 = 0; finger2 < 26; finger2++) {
            ans = Math.min(ans, dfs(n - 2, s[n - 1] - 'A', finger2, s, memo));
        }
        return ans;
    }

    private int dfs(int i, int finger1, int finger2, char[] word, int[][][] memo) {
        if (i < 0) {
            return 0;
        }

        if (memo[i][finger1][finger2] != -1) { // 之前计算过
            return memo[i][finger1][finger2];
        }

        // 手指 1 移到 word[i]
        int w = word[i] - 'A';
        int res1 = dfs(i - 1, w, finger2, word, memo) + dis[finger1][w];

        // 手指 2 移到 word[i]
        int res2 = dfs(i - 1, finger1, w, word, memo) + dis[finger2][w];

        int res = Math.min(res1, res2);
        memo[i][finger1][finger2] = res; // 记忆化
        return res;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    // 预处理两个字母的距离
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        vector memo(n, vector(26, vector<int>(26, -1))); // -1 表示没有计算过

        auto dfs = [&](this auto&& dfs, int i, int finger1, int finger2) -> int {
            if (i < 0) {
                return 0;
            }

            int& res = memo[i][finger1][finger2]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }

            // 手指 1 移到 word[i]
            int w = word[i] - 'A';
            int res1 = dfs(i - 1, w, finger2) + dis[finger1][w];

            // 手指 2 移到 word[i]
            int res2 = dfs(i - 1, finger1, w) + dis[finger2][w];

            res = min(res1, res2);
            return res;
        };

        int ans = INT_MAX;
        // 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
        for (int finger2 = 0; finger2 < 26; finger2++) {
            ans = min(ans, dfs(n - 2, word[n - 1] - 'A', finger2));
        }
        return ans;
    }
};

###go

var dis [26][26]int

func init() {
// 预处理两个字母的距离
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
n := len(word)
memo := make([][26][26]int, n)

var dfs func(int, byte, byte) int
dfs = func(i int, finger1, finger2 byte) (res int) {
if i < 0 {
return 0
}

p := &memo[i][finger1][finger2]
if *p != 0 { // 之前计算过
return *p - 1
}
defer func() { *p = res + 1 }() // 记忆化的时候加一,这样就无需初始化 memo 为 -1 了

// 手指 1 移到 word[i]
w := word[i] - 'A'
res1 := dfs(i-1, w, finger2) + dis[finger1][w]

// 手指 2 移到 word[i]
res2 := dfs(i-1, finger1, w) + dis[finger2][w]

return min(res1, res2)
}

ans := math.MaxInt
// 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
for finger2 := range byte(26) {
ans = min(ans, dfs(n-2, word[n-1]-'A', finger2))
}
return ans
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$ 或 $\mathcal{O}(n|\Sigma|^2)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。如果用哈希表保存状态,则时间复杂度为 $\mathcal{O}(n|\Sigma|)$,否则瓶颈在创建 $\textit{memo}$ 数组上。理由见下一章。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$ 或 $\mathcal{O}(n|\Sigma|^2)$。理由见下一章。

四、状态优化

回顾上面的代码,在输入 $\textit{word}[i]$ 之前,一定有一根手指在 $\textit{word}[i+1]$。这意味着,知道了 $i$,就知道了其中一根手指的位置。所以 $\textit{finger}_1$ 和 $\textit{finger}_2$ 中的一个是多余的。

定义 $\textit{dfs}(i, \textit{anotherFinger})$ 表示在一根手指位于字母 $\textit{word}[i+1]$,另一根手指位于字母 $\textit{anotherFinger}$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i]$ 的最小移动总距离。

讨论用哪根手指输入 $\textit{word}[i]$:

  • 用位于 $\textit{word}[i+1]$ 的手指输入 $\textit{word}[i]$,那么另一根手指仍然位于字母 $\textit{anotherFinger}$,所以接下来要解决的问题是,在一根手指位于字母 $\textit{word}[i]$,另一根手指位于字母 $\textit{anotherFinger}$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{anotherFinger})$,加上从 $\textit{word}[i+1]$ 到 $\textit{word}[i]$ 的距离。
  • 用位于 $\textit{anotherFinger}$ 的手指输入 $\textit{word}[i]$,那么位于 $\textit{word}[i+1]$ 的手指就变成了「另一根手指」,所以接下来要解决的问题是,在一根手指位于字母 $\textit{word}[i]$,另一根手指位于字母 $\textit{word}[i+1]$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i-1]$ 的最小移动总距离,即 $\textit{dfs}(i-1, \textit{word}[i+1])$,加上从 $\textit{anotherFinger}$ 到 $\textit{word}[i]$ 的距离。

这两种情况取最小值,就得到了 $\textit{dfs}(i, \textit{anotherFinger})$,即

$$
\textit{dfs}(i, \textit{anotherFinger}) = \min
\begin{cases}
\textit{dfs}(i-1, \textit{anotherFinger}) + \textit{dis}[\textit{word}[i+1]][\textit{word}[i]] \
\textit{dfs}(i-1, \textit{word}[i+1]) + \textit{dis}[\textit{anotherFinger}][\textit{word}[i]] \
\end{cases}
$$

递归边界:$\textit{dfs}(-1, \textit{anotherFinger})=0$。没有字母需要输入,无需移动。

递归入口:$\textit{dfs}(n-2, \textit{anotherFinger})$。最后一定有一根手指在 $\textit{word}[n-1]$,另一根手指的位置不确定,枚举 $\textit{anotherFinger}$。

###py

# 预处理两个字母的距离
COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        word = [ord(ch) - ord('A') for ch in word]  # 避免在 dfs 中频繁调用 ord

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, another_finger: int) -> int:
            if i < 0:
                return 0

            # 在 word[i+1] 的手指移到 word[i]
            res1 = dfs(i - 1, another_finger) + dis[word[i + 1]][word[i]]

            # 另一根手指移到 word[i],原来在 word[i+1] 的手指变成 another_finger
            res2 = dfs(i - 1, word[i + 1]) + dis[another_finger][word[i]]

            return min(res1, res2)

        n = len(word)
        # 最后一定有一根手指在 word[-1],另一根手指的位置不确定,枚举
        return min(dfs(n - 2, another_finger) for another_finger in range(26))

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        // 预处理两个字母的距离
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();
        int n = s.length;

        int[][] memo = new int[n][26];
        for (int[] row : memo) {
            Arrays.fill(row, -1); // -1 表示没有计算过
        }

        int ans = Integer.MAX_VALUE;
        // 最后一定有一根手指在 s[n-1],另一根手指的位置不确定,枚举
        for (int anotherFinger = 0; anotherFinger < 26; anotherFinger++) {
            ans = Math.min(ans, dfs(n - 2, anotherFinger, s, memo));
        }
        return ans;
    }

    private int dfs(int i, int anotherFinger, char[] word, int[][] memo) {
        if (i < 0) {
            return 0;
        }

        if (memo[i][anotherFinger] != -1) { // 之前计算过
            return memo[i][anotherFinger];
        }

        // 在 word[i+1] 的手指移到 word[i]
        int w = word[i] - 'A';
        int res1 = dfs(i - 1, anotherFinger, word, memo) + dis[word[i + 1] - 'A'][w];

        // 另一根手指移到 word[i],原来在 word[i+1] 的手指变成 anotherFinger
        int res2 = dfs(i - 1, word[i + 1] - 'A', word, memo) + dis[anotherFinger][w];

        int res = Math.min(res1, res2);
        memo[i][anotherFinger] = res; // 记忆化
        return res;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    // 预处理两个字母的距离
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        vector memo(n, vector<int>(26, -1)); // -1 表示没有计算过

        auto dfs = [&](this auto&& dfs, int i, int another_finger) -> int {
            if (i < 0) {
                return 0;
            }

            int& res = memo[i][another_finger]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }

            // 在 word[i+1] 的手指移到 word[i]
            int w = word[i] - 'A';
            int res1 = dfs(i - 1, another_finger) + dis[word[i + 1] - 'A'][w];

            // 另一根手指移到 word[i],原来在 word[i+1] 的手指变成 another_finger
            int res2 = dfs(i - 1, word[i + 1] - 'A') + dis[another_finger][w];

            res = min(res1, res2);
            return res;
        };

        int ans = INT_MAX;
        // 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
        for (int another_finger = 0; another_finger < 26; another_finger++) {
            ans = min(ans, dfs(n - 2, another_finger));
        }
        return ans;
    }
};

###go

var dis [26][26]int

func init() {
// 预处理两个字母的距离
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
n := len(word)
memo := make([][26]int, n)

var dfs func(int, byte) int
dfs = func(i int, anotherFinger byte) (res int) {
if i < 0 {
return 0
}

p := &memo[i][anotherFinger]
if *p != 0 { // 之前计算过
return *p - 1
}
defer func() { *p = res + 1 }() // 记忆化的时候加一,这样就无需初始化 memo 为 -1 了

// 手指 1 移到 word[i]
w := word[i] - 'A'
res1 := dfs(i-1, anotherFinger) + dis[word[i+1]-'A'][w]

// 手指 2 移到 word[i]
res2 := dfs(i-1, word[i+1]-'A') + dis[anotherFinger][w]

return min(res1, res2)
}

ans := math.MaxInt
// 最后一定有一根手指在 word[n-1],另一根手指的位置不确定,枚举
for anotherFinger := range byte(26) {
ans = min(ans, dfs(n-2, anotherFinger))
}
return ans
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(n|\Sigma|)$,单个状态的计算时间为 $\mathcal{O}(1)$,所以总的时间复杂度为 $\mathcal{O}(n|\Sigma|)$。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$。保存多少状态,就需要多少空间。

五、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[i+1][\textit{anotherFinger}]$ 的定义和 $\textit{dfs}(i,\textit{anotherFinger})$ 的定义是一样的,都表示在一根手指位于字母 $\textit{word}[i+1]$,另一根手指位于字母 $\textit{anotherFinger}$ 的情况下,输入 $\textit{word}$ 的前缀 $[0,i]$ 的最小移动总距离。这里 $+1$ 是为了把 $\textit{dfs}(-1,\textit{anotherFinger})$ 这个状态也翻译过来,这样我们可以把 $f[0]$ 作为初始值。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[i+1][\textit{anotherFinger}] = \min
\begin{cases}
f[i][\textit{anotherFinger}] + \textit{dis}[\textit{word}[i+1]][\textit{word}[i]] \
f[i][\textit{word}[i+1]] + \textit{dis}[\textit{anotherFinger}][\textit{word}[i]] \
\end{cases}
$$

初始值 $f[0][\textit{anotherFinger}]=0$,翻译自递归边界 $\textit{dfs}(-1, \textit{anotherFinger})$。

答案为 $f[n-1][\textit{anotherFinger}]$,翻译自递归入口 $\textit{dfs}(n-2, \textit{anotherFinger})$。

###py

# 预处理两个字母的距离
COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        f = [[0] * 26 for _ in word]
        for i, (x, y) in enumerate(pairwise(word)):
            x = ord(x) - ord('A')
            y = ord(y) - ord('A')
            for another_finger in range(26):
                f[i + 1][another_finger] = min(f[i][another_finger] + dis[y][x], f[i][y] + dis[another_finger][x])
        return min(f[-1])

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        // 预处理两个字母的距离
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();
        int n = s.length;

        int[][] f = new int[n][26];
        for (int i = 0; i < n - 1; i++) {
            int x = s[i] - 'A';
            int y = s[i + 1] - 'A';
            for (int anotherFinger = 0; anotherFinger < 26; anotherFinger++) {
                f[i + 1][anotherFinger] = Math.min(f[i][anotherFinger] + dis[y][x], f[i][y] + dis[anotherFinger][x]);
            }
        }

        int ans = Integer.MAX_VALUE;
        for (int res : f[n - 1]) {
            ans = Math.min(ans, res);
        }
        return ans;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    // 预处理两个字母的距离
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        vector<array<int, 26>> f(n);
        for (int i = 0; i < n - 1; i++) {
            int x = word[i] - 'A', y = word[i + 1] - 'A';
            for (int another_finger = 0; another_finger < 26; another_finger++) {
                f[i + 1][another_finger] = min(f[i][another_finger] + dis[y][x], f[i][y] + dis[another_finger][x]);
            }
        }
        return ranges::min(f[n - 1]);
    }
};

###go

var dis [26][26]int

func init() {
// 预处理两个字母的距离
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
n := len(word)
f := make([][26]int, n)

for i := range n - 1 {
x, y := word[i]-'A', word[i+1]-'A'
for anotherFinger := range 26 {
f[i+1][anotherFinger] = min(f[i][anotherFinger]+dis[y][x], f[i][y]+dis[anotherFinger][x])
}
}

return slices.Min(f[n-1][:])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(n|\Sigma|)$。

六、空间优化

由于 $f[i+1]$ 只依赖 $f[i]$,那么 $f[i-1]$ 及其之前的数据就没用了。

例如计算 $f[2]$ 的时候,数组 $f[0]$ 不再使用了。

那么干脆把 $f[2]$ 填到 $f[0]$ 中,$f[3]$ 填到 $f[1]$ 中,$f[4]$ 填到 $f[0]$ 中,$f[5]$ 填到 $f[1]$ 中 …… 只用两个长为 $26$ 的数组滚动计算

此外,由于 $\textit{dis}[x][y] = \textit{dis}[y][x]$,我们可以交换转移方程中的 $\textit{dis}$ 的两个维度,这样在同一个内层循环中只会访问 $\textit{dis}[x]$ 这一个数组。

###py

COLUMN = 6
get_dis = lambda a, b: abs(a // COLUMN - b // COLUMN) + abs(a % COLUMN - b % COLUMN)
dis = [[get_dis(i, j) for j in range(26)] for i in range(26)]

class Solution:
    def minimumDistance(self, word: str) -> int:
        f = [0] * 26
        nf = [0] * 26
        for x, y in pairwise(word):
            x = ord(x) - ord('A')
            y = ord(y) - ord('A')
            dis_x = dis[x]
            for another_finger in range(26):
                nf[another_finger] = min(f[another_finger] + dis_x[y], f[y] + dis_x[another_finger])
            f, nf = nf, f
        return min(f)

###java

class Solution {
    private static final int[][] dis = new int[26][26];

    static {
        final int COLUMN = 6;
        for (int i = 0; i < 26; i++) {
            for (int j = 0; j < 26; j++) {
                dis[i][j] = Math.abs(i / COLUMN - j / COLUMN) + Math.abs(i % COLUMN - j % COLUMN);
            }
        }
    }

    public int minimumDistance(String word) {
        char[] s = word.toCharArray();

        int[] f = new int[26];
        int[] nf = new int[26];

        for (int i = 0; i < s.length - 1; i++) {
            int x = s[i] - 'A';
            int y = s[i + 1] - 'A';
            for (int anotherFinger = 0; anotherFinger < 26; anotherFinger++) {
                nf[anotherFinger] = Math.min(f[anotherFinger] + dis[x][y], f[y] + dis[x][anotherFinger]);
            }
            int[] tmp = f;
            f = nf;
            nf = tmp;
        }

        int ans = Integer.MAX_VALUE;
        for (int res : f) {
            ans = Math.min(ans, res);
        }
        return ans;
    }
}

###cpp

int dis[26][26];

auto init = [] {
    constexpr int COLUMN = 6;
    for (int i = 0; i < 26; i++) {
        for (int j = 0; j < 26; j++) {
            dis[i][j] = abs(i / COLUMN - j / COLUMN) + abs(i % COLUMN - j % COLUMN);
        }
    }
    return 0;
}();

class Solution {
public:
    int minimumDistance(string word) {
        int n = word.size();
        int f[26]{}, nf[26];

        for (int i = 0; i < n - 1; i++) {
            int x = word[i] - 'A', y = word[i + 1] - 'A';
            for (int another_finger = 0; another_finger < 26; another_finger++) {
                nf[another_finger] = min(f[another_finger] + dis[x][y], f[y] + dis[x][another_finger]);
            }
            swap(f, nf);
        }

        return ranges::min(f);
    }
};

###go

var dis [26][26]int

func init() {
const column = 6
for i := range 26 {
for j := range 26 {
dis[i][j] = abs(i/column-j/column) + abs(i%column-j%column)
}
}
}

func minimumDistance(word string) int {
var f, nf [26]int

for i := range len(word) - 1 {
x, y := word[i]-'A', word[i+1]-'A'
for anotherFinger := range 26 {
nf[anotherFinger] = min(f[anotherFinger]+dis[x][y], f[y]+dis[x][anotherFinger])
}
f, nf = nf, f
}

return slices.Min(f[:])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

不计入预处理的时间和空间。

  • 时间复杂度:$\mathcal{O}(n|\Sigma|)$,其中 $n$ 是 $\textit{word}$ 的长度,$|\Sigma|=26$ 是字符集合的大小。
  • 空间复杂度:$\mathcal{O}(|\Sigma|)$。

专题训练

见下面动态规划题单的「§7.6 多维 DP」。

分类题单

如何科学刷题?

  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站@灵茶山艾府

二指输入的的最小距离

方法一:动态规划

我们用 dp[i][l][r] 表示在输入了字符串 word 的第 i 个字母后,左手的位置为 l,右手的位置为 r,达到该状态的最小移动距离。这里的位置为指向的字母编号,例如 A 对应 0B 对应 1,以此类推,而非字母在键盘上的位置。这样做的好处是将字母的位置映射成一个整数而非二维的坐标,使得我们更加方便地进行状态转移。

那么如何进行状态转移呢?我们首先需要看出一个非常重要的性质:对于状态 dp[i][l][r],要么 word[i] == l,要么 word[i] == r,即在输入了第 i 个字母后,左手和右手中至少有一个在 word[i] 的位置。我们可以根据这两种情况,分别进行状态转移:

  • word[i] == l 时,左手在 word[i] 的位置。我们需要考虑在输入字符串 word 的第 i - 1 个字母时,是左手还是右手在 word[i - 1] 的位置:

    • 如果左手在 word[i - 1] 的位置,那么在输入第 i 个字母时,左手从 word[i - 1] 移动至 word[i],状态转移方程为:

      dp[i][l = word[i]][r] = dp[i - 1][l0 = word[i - 1]][r] + dist(word[i - 1], word[i])
      
    • 如果右手在 word[i - 1] 的位置,那么由于第 i 个字母使用了左手,右手就没有移动,即 word[i - 1] == r。同时,在输入 word[i1] 之前的左手位置也无关紧要,可以为任意的 l0,状态转移方程为:

      dp[i][l = word[i]][r = word[i - 1]] = dp[i - 1][l0][r = word[i - 1]] + dist(l0, word[i])
      
  • word[i] == r 时,右手在 word[i] 的位置。我们需要考虑在输入字符串 word 的第 i - 1 个字母时,是右手还是左手在 word[i - 1] 的位置:

    • 如果右手在 word[i - 1] 的位置,那么在输入第 i 个字母时,右手从 word[i - 1] 移动至 word[i],状态转移方程为:

      dp[i][l][r = word[i]] = dp[i - 1][l][r0 = word[i - 1]] + dist(word[i - 1], word[i])
      
    • 如果左手在 word[i - 1] 的位置,那么由于第 i 个字母使用了右手,左手就没有移动,即 word[i - 1] == l。同时,在输入 word[i] 之前的右手位置也无关紧要,可以为任意的 r0,状态转移方程为:

      dp[i][l = word[i - 1]][r = word[i]] = dp[i - 1][l = word[i - 1]][r0] + dist(r0, word[i])
      

对于每一个状态 dp[i][l][r],我们取它所有转移中的最小值,即为输入了字符串 word 的第 i 个字母后,左手的位置为 l,右手的位置为 r,达到该状态的最小移动距离。

在这个动态规划中,我们还需要考虑不合法的状态以及边界状态。对于某一个不合法的状态,如果用它来进行状态转移,可能会使得 dp[i][l][r] 取到一个更小且不合法的值。因此,我们一般会给所有不合法的状态赋予一个非常大的值(例如 C++ 中的整数最大值 INT_MAX),这样即使用它来进行状态转移,也会因为本身值非常大的缘故,对最优解没有任何影响。在考虑边界状态时,由于题目中规定两根手指的起始位置是零代价的,因此对于字符串中的第 0 个字母 word[0],输入它的最小移动距离为 0。此时要么左手的位置为 word[0],要么右手的位置为 word[0],因此我们可以将所有的 dp[0][l = word[0]][r] 以及 dp[0][l][r = word[0]] 作为边界状态,它们的值为 0

###C++

class Solution {
public:
    int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return abs(x1 - x2) + abs(y1 - y2);
    }

    int minimumDistance(string word) {
        int n = word.size();
        int dp[n][26][26];
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < 26; ++j) {
                fill(dp[i][j], dp[i][j] + 26, INT_MAX >> 1);
            }
        }
        for (int i = 0; i < 26; ++i) {
            dp[0][i][word[0] - 'A'] = dp[0][word[0] - 'A'][i] = 0;
        }
        
        for (int i = 1; i < n; ++i) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = getDistance(prev, cur);
            for (int j = 0; j < 26; ++j) {
                dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][prev][j] + d);
                dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][prev] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = getDistance(k, cur);
                        dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                        dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                    }
                }
            }
        }

        int ans = INT_MAX >> 1;
        for (int i = 0; i < 26; ++i) {
            for (int j = 0; j < 26; ++j) {
                ans = min(ans, dp[n - 1][i][j]);
            }
        }
        return ans;
    }
};

###Python

class Solution:
    def minimumDistance(self, word: str) -> int:
        n = len(word)
        BIG = 2**30
        dp = [[[BIG] * 26 for x in range(26)] for y in range(n)]
        for i in range(26):
            dp[0][i][ord(word[0]) - 65] = 0
            dp[0][ord(word[0]) - 65][i] = 0
    
        def getDistance(p, q):
            x1, y1 = p // 6, p % 6
            x2, y2 = q // 6, q % 6
            return abs(x1 - x2) + abs(y1 - y2)

        for i in range(1, n):
            cur, prev = ord(word[i]) - 65, ord(word[i - 1]) - 65
            d = getDistance(prev, cur)
            for j in range(26):
                dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][prev][j] + d)
                dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][prev] + d)
                if prev == j:
                    for k in range(26):
                        d0 = getDistance(k, cur)
                        dp[i][cur][j] = min(dp[i][cur][j], dp[i - 1][k][j] + d0)
                        dp[i][j][cur] = min(dp[i][j][cur], dp[i - 1][j][k] + d0)
        
        ans = min(min(dp[n - 1][x]) for x in range(26))
        return ans

###Java

class Solution {
    private int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    }

    public int minimumDistance(String word) {
        int n = word.length();
        int[][][] dp = new int[n][26][26];
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < 26; ++j) {
                for (int k = 0; k < 26; ++k) {
                    dp[i][j][k] = Integer.MAX_VALUE / 2;
                }
            }
        }
        
        for (int i = 0; i < 26; ++i) {
            dp[0][i][word.charAt(0) - 'A'] = 0;
            dp[0][word.charAt(0) - 'A'][i] = 0;
        }
        
        for (int i = 1; i < n; ++i) {
            int cur = word.charAt(i) - 'A';
            int prev = word.charAt(i - 1) - 'A';
            int d = getDistance(prev, cur);
            
            for (int j = 0; j < 26; ++j) {
                dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][prev][j] + d);
                dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][prev] + d);
                
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = getDistance(k, cur);
                        dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                        dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                    }
                }
            }
        }
        
        int ans = Integer.MAX_VALUE / 2;
        for (int i = 0; i < 26; ++i) {
            for (int j = 0; j < 26; ++j) {
                ans = Math.min(ans, dp[n - 1][i][j]);
            }
        }
        return ans;
    }
}

###C#

public class Solution {
    private int GetDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.Abs(x1 - x2) + Math.Abs(y1 - y2);
    }

    public int MinimumDistance(string word) {
        int n = word.Length;
        int[,,] dp = new int[n, 26, 26];
        
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < 26; ++j) {
                for (int k = 0; k < 26; ++k) {
                    dp[i, j, k] = int.MaxValue / 2;
                }
            }
        }
        
        for (int i = 0; i < 26; ++i) {
            dp[0, i, word[0] - 'A'] = 0;
            dp[0, word[0] - 'A', i] = 0;
        }
        for (int i = 1; i < n; ++i) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = GetDistance(prev, cur);
            
            for (int j = 0; j < 26; ++j) {
                dp[i, cur, j] = Math.Min(dp[i, cur, j], dp[i - 1, prev, j] + d);
                dp[i, j, cur] = Math.Min(dp[i, j, cur], dp[i - 1, j, prev] + d);
                
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = GetDistance(k, cur);
                        dp[i, cur, j] = Math.Min(dp[i, cur, j], dp[i - 1, k, j] + d0);
                        dp[i, j, cur] = Math.Min(dp[i, j, cur], dp[i - 1, j, k] + d0);
                    }
                }
            }
        }
        
        int ans = int.MaxValue / 2;
        for (int i = 0; i < 26; ++i) {
            for (int j = 0; j < 26; ++j) {
                ans = Math.Min(ans, dp[n - 1, i, j]);
            }
        }
        return ans;
    }
}

###Go

func getDistance(p, q int) int {
    x1, y1 := p/6, p%6
    x2, y2 := q/6, q%6
    return abs(x1 - x2) + abs(y1 - y2)
}

func abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

func minimumDistance(word string) int {
    n := len(word)
    dp := make([][26][26]int, n)
    
    for i := 0; i < n; i++ {
        for j := 0; j < 26; j++ {
            for k := 0; k < 26; k++ {
                dp[i][j][k] = 1 << 30
            }
        }
    }
    
    firstChar := int(word[0] - 'A')
    for i := 0; i < 26; i++ {
        dp[0][i][firstChar] = 0
        dp[0][firstChar][i] = 0
    }
    
    for i := 1; i < n; i++ {
        cur := int(word[i] - 'A')
        prev := int(word[i-1] - 'A')
        d := getDistance(prev, cur)
        
        for j := 0; j < 26; j++ {
            dp[i][cur][j] = min(dp[i][cur][j], dp[i-1][prev][j]+d)
            dp[i][j][cur] = min(dp[i][j][cur], dp[i-1][j][prev]+d)
            
            if prev == j {
                for k := 0; k < 26; k++ {
                    d0 := getDistance(k, cur)
                    dp[i][cur][j] = min(dp[i][cur][j], dp[i-1][k][j]+d0)
                    dp[i][j][cur] = min(dp[i][j][cur], dp[i-1][j][k]+d0)
                }
            }
        }
    }
    
    ans := 1 << 30
    for i := 0; i < 26; i++ {
        for j := 0; j < 26; j++ {
            ans = min(ans, dp[n-1][i][j])
        }
    }
    return ans
}

###C

int getDistance(int p, int q) {
    int x1 = p / 6, y1 = p % 6;
    int x2 = q / 6, y2 = q % 6;
    return abs(x1 - x2) + abs(y1 - y2);
}

int minimumDistance(char* word) {
    int n = strlen(word);
    int*** dp = (int***)malloc(n * sizeof(int**));
    for (int i = 0; i < n; ++i) {
        dp[i] = (int**)malloc(26 * sizeof(int*));
        for (int j = 0; j < 26; ++j) {
            dp[i][j] = (int*)malloc(26 * sizeof(int));
            for (int k = 0; k < 26; ++k) {
                dp[i][j][k] = INT_MAX / 2;
            }
        }
    }
    
    for (int i = 0; i < 26; ++i) {
        dp[0][i][word[0] - 'A'] = 0;
        dp[0][word[0] - 'A'][i] = 0;
    }
    for (int i = 1; i < n; ++i) {
        int cur = word[i] - 'A';
        int prev = word[i - 1] - 'A';
        int d = getDistance(prev, cur);
        
        for (int j = 0; j < 26; ++j) {
            dp[i][cur][j] = fmin(dp[i][cur][j], dp[i - 1][prev][j] + d);
            dp[i][j][cur] = fmin(dp[i][j][cur], dp[i - 1][j][prev] + d);
            
            if (prev == j) {
                for (int k = 0; k < 26; ++k) {
                    int d0 = getDistance(k, cur);
                    dp[i][cur][j] = fmin(dp[i][cur][j], dp[i - 1][k][j] + d0);
                    dp[i][j][cur] = fmin(dp[i][j][cur], dp[i - 1][j][k] + d0);
                }
            }
        }
    }
    
    int ans = INT_MAX / 2;
    for (int i = 0; i < 26; ++i) {
        for (int j = 0; j < 26; ++j) {
            if (ans > dp[n - 1][i][j]) {
                ans = dp[n - 1][i][j];
            }
        }
    }
    
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < 26; ++j) {
            free(dp[i][j]);
        }
        free(dp[i]);
    }
    free(dp);
    
    return ans;
}

###JavaScript

var minimumDistance = function(word) {
    const n = word.length;
    const getDistance = (p, q) => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26);
        for (let j = 0; j < 26; j++) {
            dp[i][j] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
        }
    }
    
    const firstChar = word.charCodeAt(0) - 65;
    for (let i = 0; i < 26; i++) {
        dp[0][i][firstChar] = 0;
        dp[0][firstChar][i] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][prev][j] + d);
            dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][prev] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                    dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                }
            }
        }
    }
    
    let ans = Number.MAX_SAFE_INTEGER;
    for (let i = 0; i < 26; i++) {
        for (let j = 0; j < 26; j++) {
            ans = Math.min(ans, dp[n - 1][i][j]);
        }
    }
    return ans;
};

###TypeScript

function minimumDistance(word: string): number {
    const n = word.length;
    const getDistance = (p: number, q: number): number => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp: number[][][] = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26);
        for (let j = 0; j < 26; j++) {
            dp[i][j] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
        }
    }
    const firstChar = word.charCodeAt(0) - 65;
    for (let i = 0; i < 26; i++) {
        dp[0][i][firstChar] = 0;
        dp[0][firstChar][i] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][prev][j] + d);
            dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][prev] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][cur][j] = Math.min(dp[i][cur][j], dp[i - 1][k][j] + d0);
                    dp[i][j][cur] = Math.min(dp[i][j][cur], dp[i - 1][j][k] + d0);
                }
            }
        }
    }
    
    let ans = Number.MAX_SAFE_INTEGER;
    for (let i = 0; i < 26; i++) {
        for (let j = 0; j < 26; j++) {
            ans = Math.min(ans, dp[n - 1][i][j]);
        }
    }
    return ans;
}

###Rust

impl Solution {
    fn get_distance(p: i32, q: i32) -> i32 {
        let x1 = p / 6;
        let y1 = p % 6;
        let x2 = q / 6;
        let y2 = q % 6;
        (x1 - x2).abs() + (y1 - y2).abs()
    }

    pub fn minimum_distance(word: String) -> i32 {
        let n = word.len();
        let word_chars: Vec<char> = word.chars().collect();
        let mut dp = vec![vec![vec![i32::MAX >> 1; 26]; 26]; n];
        let first_char = (word_chars[0] as u8 - b'A') as usize;
        for i in 0..26 {
            dp[0][i][first_char] = 0;
            dp[0][first_char][i] = 0;
        }
        
        for i in 1..n {
            let cur = (word_chars[i] as u8 - b'A') as i32;
            let prev = (word_chars[i - 1] as u8 - b'A') as i32;
            let d = Self::get_distance(prev, cur);
            
            for j in 0..26 {
                let j_i32 = j as i32;
                dp[i][cur as usize][j] = dp[i][cur as usize][j].min(
                    dp[i - 1][prev as usize][j].saturating_add(d)
                );
                dp[i][j][cur as usize] = dp[i][j][cur as usize].min(
                    dp[i - 1][j][prev as usize].saturating_add(d)
                );
                
                if prev == j_i32 {
                    for k in 0..26 {
                        let d0 = Self::get_distance(k as i32, cur);
                        dp[i][cur as usize][j] = dp[i][cur as usize][j].min(
                            dp[i - 1][k][j].saturating_add(d0)
                        );
                        dp[i][j][cur as usize] = dp[i][j][cur as usize].min(
                            dp[i - 1][j][k].saturating_add(d0)
                        );
                    }
                }
            }
        }
        
        let mut ans = i32::MAX >> 1;
        for i in 0..26 {
            for j in 0..26 {
                ans = ans.min(dp[n - 1][i][j]);
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$O(|\Sigma|N)$,其中 $N$ 是字符串 word 的长度,$|\Sigma|$ 是可能出现的字母数量,在本题中 $|\Sigma| = 26$。对于状态 dp[i][l][r],枚举 i 需要的时间复杂度为 $O(N)$,在此之后,如果 word[i] == l,根据上面的状态转移方程:

    • 如果左手在 word[i - 1] 的位置,那么单次状态转移的时间复杂度为 $O(1)$,需要对所有的 r 都进行转移,总时间复杂度为 $O(|\Sigma|)$;

    • 如果右手在 word[i - 1] 的位置,那么 r == word[i - 1]。虽然我们要枚举 l0,但是合法的 r 只有一个,因此总时间复杂度也为 $O(|\Sigma|)$。

    如果 word[i] == r,分析的过程相同,在此不再赘述。这样总时间复杂度即为 $O(|\Sigma|N)$。

  • 空间复杂度:$O(|\Sigma|^2 N)$。

方法二:动态规划 + 空间优化

在方法一中,我们提到了一条重要的性质:对于状态 dp[i][l][r],要么 word[i] == l,要么 word[i] == r,即在输入了第 i 个字母后,左手和右手中至少有一个在 word[i] 的位置。那么对于每一个 i,我们其实只需要存储 $2|\Sigma|$ 而不是 $|\Sigma|^2$ 个状态。例如我们可以用 dp[i][op][rest] 表示状态,其中 op 的值只能为 01op == 0 表示左手在 word[i] 的位置,op == 1 表示右手在 word[i] 的位置,而 rest 表示不在 word[i] 位置的另一只手的位置。这样我们在状态转移方程几乎不变的前提下,减少了动态规划需要的空间。

那么我们是否还可以继续进行优化呢?我们可以发现,在方法一中,状态转移方程具有高度对称性,那么我们可以断定,dp[i][op = 0][rest]dp[i][op = 1][rest] 的值一定是相等的。这是因为 dp[i][op = 0][rest] 表示左手在 word[i] 的位置且右手在 rest 的位置,如果我们将左右手互换,那么我们同样可以使用 dp[i][op = 0][rest] 的移动距离使得右手在 word[i] 的位置且左手在 rest 的位置,而这恰好就是 dp[i][op = 1][rest]

因此我们可以直接使用 dp[i][rest] 进行状态转移,其表示一只手在 word[i] 的位置,另一只手在 rest 的位置的最小移动距离。我们并不需要关心具体哪只手在 word[i] 的位置,因为两只手是完全对称的。这样以来,我们将三维的动态规划优化至了二维,大大减少了空间的使用。

###C++

class Solution {
public:
    int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return abs(x1 - x2) + abs(y1 - y2);
    }

    int minimumDistance(string word) {
        int n = word.size();
        vector<vector<int>> dp(n, vector<int>(26, INT_MAX >> 1));
        fill(dp[0].begin(), dp[0].end(), 0);
        
        for (int i = 1; i < n; ++i) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = getDistance(prev, cur);
            for (int j = 0; j < 26; ++j) {
                dp[i][j] = min(dp[i][j], dp[i - 1][j] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; ++k) {
                        int d0 = getDistance(k, cur);
                        dp[i][j] = min(dp[i][j], dp[i - 1][k] + d0);
                    }
                }
            }
        }

        int ans = *min_element(dp[n - 1].begin(), dp[n - 1].end());
        return ans;
    }
};

###Python

class Solution:
    def minimumDistance(self, word: str) -> int:
        n = len(word)
        BIG = 2**30
        dp = [[0] * 26] + [[BIG] * 26 for _ in range(n - 1)]
        
        def getDistance(p, q):
            x1, y1 = p // 6, p % 6
            x2, y2 = q // 6, q % 6
            return abs(x1 - x2) + abs(y1 - y2)

        for i in range(1, n):
            cur, prev = ord(word[i]) - 65, ord(word[i - 1]) - 65
            d = getDistance(prev, cur)
            for j in range(26):
                dp[i][j] = min(dp[i][j], dp[i - 1][j] + d)
                if prev == j:
                    for k in range(26):
                        d0 = getDistance(k, cur)
                        dp[i][j] = min(dp[i][j], dp[i - 1][k] + d0)

        ans = min(dp[n - 1])
        return ans

###Java

class Solution {
    private int getDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    }

    public int minimumDistance(String word) {
        int n = word.length();
        int[][] dp = new int[n][26];
        for (int i = 0; i < n; i++) {
            Arrays.fill(dp[i], Integer.MAX_VALUE / 2);
        }
        Arrays.fill(dp[0], 0);
        
        for (int i = 1; i < n; i++) {
            int cur = word.charAt(i) - 'A';
            int prev = word.charAt(i - 1) - 'A';
            int d = getDistance(prev, cur);
            
            for (int j = 0; j < 26; j++) {
                dp[i][j] = Math.min(dp[i][j], dp[i - 1][j] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; k++) {
                        int d0 = getDistance(k, cur);
                        dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + d0);
                    }
                }
            }
        }
        
        int ans = Integer.MAX_VALUE / 2;
        for (int value : dp[n - 1]) {
            ans = Math.min(ans, value);
        }
        return ans;
    }
}

###C#

public class Solution {
    private int GetDistance(int p, int q) {
        int x1 = p / 6, y1 = p % 6;
        int x2 = q / 6, y2 = q % 6;
        return Math.Abs(x1 - x2) + Math.Abs(y1 - y2);
    }

    public int MinimumDistance(string word) {
        int n = word.Length;
        int[,] dp = new int[n, 26];
        
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < 26; j++) {
                dp[i, j] = int.MaxValue / 2;
            }
        }
        
        for (int j = 0; j < 26; j++) {
            dp[0, j] = 0;
        }
        for (int i = 1; i < n; i++) {
            int cur = word[i] - 'A';
            int prev = word[i - 1] - 'A';
            int d = GetDistance(prev, cur);
            
            for (int j = 0; j < 26; j++) {
                dp[i, j] = Math.Min(dp[i, j], dp[i - 1, j] + d);
                if (prev == j) {
                    for (int k = 0; k < 26; k++) {
                        int d0 = GetDistance(k, cur);
                        dp[i, j] = Math.Min(dp[i, j], dp[i - 1, k] + d0);
                    }
                }
            }
        }
        
        int ans = int.MaxValue / 2;
        for (int j = 0; j < 26; j++) {
            ans = Math.Min(ans, dp[n - 1, j]);
        }
        return ans;
    }
}

###Go

func getDistance(p, q int) int {
    x1, y1 := p / 6, p % 6
    x2, y2 := q / 6, q % 6
    return abs(x1 - x2) + abs(y1 - y2)
}

func abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

func minimumDistance(word string) int {
    n := len(word)
    dp := make([][]int, n)
    for i := range dp {
        dp[i] = make([]int, 26)
        for j := range dp[i] {
            dp[i][j] = 1 << 30
        }
    }
    for j := 0; j < 26; j++ {
        dp[0][j] = 0
    }
    
    for i := 1; i < n; i++ {
        cur := int(word[i] - 'A')
        prev := int(word[i-1] - 'A')
        d := getDistance(prev, cur)
        
        for j := 0; j < 26; j++ {
            dp[i][j] = min(dp[i][j], dp[i-1][j]+d)
            if prev == j {
                for k := 0; k < 26; k++ {
                    d0 := getDistance(k, cur)
                    dp[i][j] = min(dp[i][j], dp[i-1][k]+d0)
                }
            }
        }
    }
    
    ans := 1 << 30
    for j := 0; j < 26; j++ {
        ans = min(ans, dp[n-1][j])
    }
    return ans
}

###C

int getDistance(int p, int q) {
    int x1 = p / 6, y1 = p % 6;
    int x2 = q / 6, y2 = q % 6;
    return abs(x1 - x2) + abs(y1 - y2);
}

int minimumDistance(char* word) {
    int n = strlen(word);
    int** dp = (int**)malloc(n * sizeof(int*));
    for (int i = 0; i < n; i++) {
        dp[i] = (int*)malloc(26 * sizeof(int));
        for (int j = 0; j < 26; j++) {
            dp[i][j] = INT_MAX / 2;
        }
    }
    for (int j = 0; j < 26; j++) {
        dp[0][j] = 0;
    }
    
    for (int i = 1; i < n; i++) {
        int cur = word[i] - 'A';
        int prev = word[i - 1] - 'A';
        int d = getDistance(prev, cur);
        
        for (int j = 0; j < 26; j++) {
            dp[i][j] = fmin(dp[i][j], dp[i - 1][j] + d);
            if (prev == j) {
                for (int k = 0; k < 26; k++) {
                    int d0 = getDistance(k, cur);
                    dp[i][j] = fmin(dp[i][j], dp[i - 1][k] + d0);
                }
            }
        }
    }
    
    int ans = INT_MAX / 2;
    for (int j = 0; j < 26; j++) {
        if (ans > dp[n - 1][j]) {
            ans = dp[n - 1][j];
        }
    }
    for (int i = 0; i < n; i++) {
        free(dp[i]);
    }
    free(dp);
    
    return ans;
}

###JavaScript

var minimumDistance = function(word) {
    const n = word.length;
    const getDistance = (p, q) => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
    }
    
    for (let j = 0; j < 26; j++) {
        dp[0][j] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][j] = Math.min(dp[i][j], dp[i - 1][j] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + d0);
                }
            }
        }
    }
    
    let ans = Math.floor(Number.MAX_SAFE_INTEGER / 2);
    for (let j = 0; j < 26; j++) {
        ans = Math.min(ans, dp[n - 1][j]);
    }
    return ans;
};

###TypeScript

function minimumDistance(word: string): number {
    const n = word.length;
    const getDistance = (p: number, q: number): number => {
        const x1 = Math.floor(p / 6), y1 = p % 6;
        const x2 = Math.floor(q / 6), y2 = q % 6;
        return Math.abs(x1 - x2) + Math.abs(y1 - y2);
    };
    
    const dp: number[][] = new Array(n);
    for (let i = 0; i < n; i++) {
        dp[i] = new Array(26).fill(Math.floor(Number.MAX_SAFE_INTEGER / 2));
    }
    for (let j = 0; j < 26; j++) {
        dp[0][j] = 0;
    }
    
    for (let i = 1; i < n; i++) {
        const cur = word.charCodeAt(i) - 65;
        const prev = word.charCodeAt(i - 1) - 65;
        const d = getDistance(prev, cur);
        
        for (let j = 0; j < 26; j++) {
            dp[i][j] = Math.min(dp[i][j], dp[i - 1][j] + d);
            
            if (prev === j) {
                for (let k = 0; k < 26; k++) {
                    const d0 = getDistance(k, cur);
                    dp[i][j] = Math.min(dp[i][j], dp[i - 1][k] + d0);
                }
            }
        }
    }
    
    let ans = Math.floor(Number.MAX_SAFE_INTEGER / 2);
    for (let j = 0; j < 26; j++) {
        ans = Math.min(ans, dp[n - 1][j]);
    }
    return ans;
}

###Rust

impl Solution {
    fn get_distance(p: i32, q: i32) -> i32 {
        let x1 = p / 6;
        let y1 = p % 6;
        let x2 = q / 6;
        let y2 = q % 6;
        (x1 - x2).abs() + (y1 - y2).abs()
    }

    pub fn minimum_distance(word: String) -> i32 {
        let n = word.len();
        let word_bytes = word.as_bytes();
        let mut dp = vec![vec![i32::MAX >> 1; 26]; n];
        
        for j in 0..26 {
            dp[0][j] = 0;
        }
        
        for i in 1..n {
            let cur = (word_bytes[i] - b'A') as i32;
            let prev = (word_bytes[i - 1] - b'A') as i32;
            let d = Self::get_distance(prev, cur);
            
            for j in 0..26 {
                let j_i32 = j as i32;
                dp[i][j] = dp[i][j].min(dp[i - 1][j].saturating_add(d));
                
                if prev == j_i32 {
                    for k in 0..26 {
                        let d0 = Self::get_distance(k as i32, cur);
                        dp[i][j] = dp[i][j].min(dp[i - 1][k].saturating_add(d0));
                    }
                }
            }
        }
        
        let mut ans = i32::MAX >> 1;
        for j in 0..26 {
            ans = ans.min(dp[n - 1][j]);
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$O(|\Sigma|N)$。

  • 空间复杂度:$O(|\Sigma|N)$。

「清晰&图解」巧妙的动态规划

常规做法

思路

我们将左指和右指所在的键位组成,看成一个状态。每次输入一个字母时,则其中一个手指会进行移动,移动的过程即是状态转移的过程。并且由于字母输入的顺序是固定的,每一个字母都可以看成一个阶段,字母不断输入的过程即是阶段的递增,例如第一个字母为第一个阶段,第二个字母为第二个阶段,后面以此类推。

因此,我们需要一个三维的状态来表示整个动态规划的过程,包括当前考虑的字母下标左指的键位右指的键位

二指组成形成的状态:

image.png

三维状态:

image.png

接下来,让我们思考状态如何进行转移。假设字符串为 CAKE,并且此时阶段为 1,即当前考虑字母是 A。在这个阶段下,左右指会存在一种现象,要么左指为 A ,要么右指为 A,此时才能输入字母 A

对于左指为 A,表示我们通过移动左指来到达这个阶段,而右指是没有移动的。总结来说,这个阶段下,左指会A,右指不变。因此,我们需要遍历上一个阶段左指和右指的所有情况,并且转移到下一个阶段时,只移动左指(dp[1][A][R] = Math.min(dp[1][A][R], dp[0][L][R] + move(L, A)))。

注意观察,如果上一个阶段右指为 R,此时这个阶段右指也必须保持不变,同样为 R

image.png

  • 阶段 1 的右指和阶段 0 的右指键位相同。
  • 阶段 1 的左指键位为 A。

对于右指为 A 的情况同理。

代码

###java

class Solution {
    public int minimumDistance(String word) {
        // 初始化
        int[][][] dp = new int[301][26][26];
        for (int i = 1; i <= 300; i++) {
            for (int j = 0; j < 26; j++) {
                Arrays.fill(dp[i][j], Integer.MAX_VALUE);
            }
        }
        int ans = Integer.MAX_VALUE;
        char[] ca = word.toCharArray();
        // 遍历每个字母
        for (int i = 1; i <= word.length(); i++) {
            int v = ca[i - 1] - 'A';
            // 遍历上一个阶段左指键位
            for (int l = 0; l < 26; l++) {
                // 遍历上一个阶段右指键位
                for (int r = 0; r < 26; r++) {
                    // 判断上一个阶段的状态是否存在
                    if (dp[i - 1][l][r] != Integer.MAX_VALUE) {
                        // 移动左指
                        dp[i][v][r] = Math.min(dp[i][v][r], dp[i - 1][l][r] + help(l, v));
                        // 移动右指
                        dp[i][l][v] = Math.min(dp[i][l][v], dp[i - 1][l][r] + help(r, v));
                    }
                    if (i == word.length()) {
                        ans = Math.min(ans, dp[i][v][r]);
                        ans = Math.min(ans, dp[i][l][v]);
                    }
                }
            }
        }
        return ans;
    }
    // 计算距离
    public int help(int a, int b) {
        int x = a / 6, y = a % 6;
        int x2 = b / 6, y2 = b % 6;
        return (int)(Math.abs(x - x2)) + (int)(Math.abs(y - y2));
    }
}

复杂度分析

  • 时间复杂度:$O(26 * 26 * N)$,其中 N 为字符串 word 的长度。
  • 空间复杂度:$O(26 * 26 * N)$,其中 N 为字符串 word 的长度。

空间优化

思路

由于每个阶段只和上个阶段相关,我们可以使用滚动数组思想,循环利用数组,例如 i % 2 代表当前阶段,(i - 1) % 2 代表上一个阶段

值得注意的是,每次我们计算出新数组后dp[i % 2],需要重新初始化另外一个数组dp[(i - 1) % 2],读者可尝试注释相关代码, 观察结果。

代码

###java

class Solution {
    public int minimumDistance(String word) {
        // 初始化
        int[][][] dp = new int[2][26][26];
        for (int i = 0; i < 26; i++) {
            Arrays.fill(dp[1][i], Integer.MAX_VALUE);
        }
        int ans = Integer.MAX_VALUE;
        char[] ca = word.toCharArray();
        // 遍历每个字母
        for (int i = 1; i <= word.length(); i++) {
            int v = ca[i - 1] - 'A';
            // 遍历上一个阶段左指键位
            for (int l = 0; l < 26; l++) {
                // 遍历上一个阶段右指键位
                for (int r = 0; r < 26; r++) {
                    // 判断上一个阶段的状态是否存在
                    if (dp[(i - 1) % 2][l][r] == Integer.MAX_VALUE) {
                        continue;
                    }
                    if (dp[(i - 1) % 2][l][r] != Integer.MAX_VALUE) {
                        // 移动左指
                        dp[i % 2][v][r] = Math.min(dp[i % 2][v][r], dp[(i - 1) % 2][l][r] + help(l, v));
                        // 移动右指
                        dp[i % 2][l][v] = Math.min(dp[i % 2][l][v], dp[(i - 1) % 2][l][r] + help(r, v));
                    }
                    if (i == word.length()) {
                        ans = Math.min(ans, dp[i % 2][v][r]);
                        ans = Math.min(ans, dp[i % 2][l][v]);
                    }
                }
            }
            // 重新初始化另外一个数组
            for (int l = 0; l < 26; l++) {
                for (int r = 0; r < 26; r++) {
                    dp[(i - 1) % 2][l][r] = Integer.MAX_VALUE;
                }
            }

        }
        return ans;
    }
    // 计算距离
    public int help(int a, int b) {
        int x = a / 6, y = a % 6;
        int x2 = b / 6, y2 = b % 6;
        return (int)(Math.abs(x - x2)) + (int)(Math.abs(y - y2));
    }
}

复杂度分析

  • 时间复杂度:$O(26 * 26 * N)$,其中 N 为字符串 word 的长度。
  • 空间复杂度:$O(26 * 26 * 2)$

时间优化

思路

我们再重新观察一下这三个维度信息,分别是:字母下标左指的键位右指的键位。由于每次需要按下一个字母,左指键位或者右指键位必然有一个是这个字母的键位,因此字母下标也隐含着一个指头的键位信息,使用三个维度显然会有冗余,我们可以重新设计一种新的状态:字母下标(可以代表第一个指头键位),另外一个指头的键位

每次按下一个字母时,要么是字母下标所在的指头(第一个指头)移动,要么是另外一个指头移动。

第一个指头移动的状态转移图如下:

image.png

  • 状态 1 的另外一个指头键位等于状态 0 另外一个指头键位
  • dp[1][r] = Math.min(dp[1][r], dp[0][r] + move(word[0], word[1]))

另外一个指头移动的状态转移图如下:

image.png

  • 注意两个指头顺序交换,第一个指头变成另外一个指头,另外一个指头变成第一个指头。
  • 状态 1 的另外一个指头键位等于状态 0 第一个指头键位
  • dp[1][word[0]] = Math.min(dp[1][word[0]], dp[0][r] + move(r, word[1]))

代码

###java

class Solution {
    public int minimumDistance(String word) {
        // 初始化
        int len = word.length();
        int ans = Integer.MAX_VALUE;
        char[] ca = word.toCharArray();
        // 第一个字母的初始值为 0,从第二个字母开始考虑。
        int[][] dp = new int[2][26];
        Arrays.fill(dp[1], Integer.MAX_VALUE);
        
        // 遍历每个字母
        for (int i = 2; i <= word.length(); i++) {
            int v = ca[i - 1] - 'A';
            // 遍历上一个阶段键位
            for (int j = 0; j < 26; j++) {
                if (dp[i % 2][j] == Integer.MAX_VALUE) {
                    continue;
                }
                int preV = ca[i - 2] - 'A';
                dp[(i + 1) % 2][j] = Math.min(dp[(i + 1) % 2][j], dp[i % 2][j] + help(preV, v));
                dp[(i + 1) % 2][preV] = Math.min(dp[(i + 1) % 2][preV], dp[i % 2][j] + help(j, v));
                if (i == word.length()) {
                    ans = Math.min(ans, dp[(i + 1) % 2][j]);
                    ans = Math.min(ans, dp[(i + 1) % 2][preV]);
                }
            }
            Arrays.fill(dp[i % 2], Integer.MAX_VALUE);
        }
        return ans;
    }
    // 计算距离
    public int help(int a, int b) {
        int x = a / 6, y = a % 6;
        int x2 = b / 6, y2 = b % 6;
        return (int)(Math.abs(x - x2)) + (int)(Math.abs(y - y2));
    }
}

复杂度分析

  • 时间复杂度:$O(26 * N)$,其中 N 为字符串 word 的长度。
  • 空间复杂度:$O(26 * 2)$

 


如果该题解对你有帮助,点个赞再走呗~

构建无障碍组件之Meter Pattern

Meter Pattern 详解:构建无障碍计量器组件

Meter(计量器)是一种图形化显示数值的组件,用于展示在特定范围内变化的数值。本文基于 W3C WAI-ARIA Meter Pattern 规范,详解如何构建无障碍的 Meter 组件。

一、Meter 的定义与核心概念

1.1 什么是 Meter

Meter 是一种图形化显示数值的组件,具有以下特征:

  • 显示一个在定义范围内变化的数值
  • 通常以视觉形式呈现(如进度条、仪表盘、电池图标等)
  • 数值有明确的最小值最大值限制

1.2 Meter vs Progressbar

Meter 和 Progressbar 容易混淆,但它们有明确的区别:

特性 Meter Progressbar
用途 显示当前状态值(如电量、油量) 显示任务进度(如加载中、完成百分比)
数值变化 随时间自然变化 随任务推进单向增长
典型场景 电池电量、磁盘使用率、温度 文件上传、表单提交、安装进度

重要提示

  • Meter 不应用于表示进度(如加载或任务完成百分比)
  • Meter 不适用于没有明确最大值的情况(如世界人口数量)

1.3 核心术语

术语 说明
Value 计量器当前显示的数值
Minimum Value 计量器的最小值(aria-valuemin
Maximum Value 计量器的最大值(aria-valuemax
Current Value 当前值(aria-valuenow
┌─────────────────────────────────────────────────────────────┐
│                      Meter Container                        │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │████████████████████████████████░░░░░░░░░░░░░░░░░░░░░│    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│   0%          25%          50%          75%        100%     │
│   ↑                                                  ↑      │
│  Minimum                                        Maximum     │
│(aria-valuemin)                              (aria-valuemax) │
│                                                             │
│                         Current: 60%                        │
│                       (aria-valuenow)                       │
└─────────────────────────────────────────────────────────────┘

二、HTML <meter> 标签 vs ARIA role="meter"

在实现 Meter 组件时,我们有两种选择:原生 HTML <meter> 标签ARIA role="meter"

2.1 两种方式对比

特性 HTML <meter> ARIA role="meter"
本质 原生 HTML5 语义化标签 ARIA 角色属性
浏览器支持 现代浏览器原生支持 所有支持 ARIA 的浏览器
可定制性 样式受限(浏览器控制) 完全可定制
代码简洁度 简洁,内置语义 需要显式声明 ARIA 属性
辅助技术识别 自动识别为 meter 通过 role 识别为 meter

2.2 使用 HTML <meter> 标签(推荐)

HTML5 提供了原生的 <meter> 标签,它自动具有 role="meter" 的语义,无需额外声明:

<meter value="60" min="0" max="100">60%</meter>

<meter> 标签的属性:

属性 说明 示例值
value 当前值 "60"
min 最小值 "0"
max 最大值 "100"
low 低值阈值 "25"
high 高值阈值 "75"
optimum 最佳值 "90"

示例:带颜色区间的电池电量

<meter 
  value="45" 
  min="0" 
  max="100" 
  low="20" 
  high="80" 
  optimum="90"
  aria-label="电池电量">
  45%
</meter>
  • low 以下:浏览器通常显示为红色(危险)
  • lowhigh 之间:黄色(警告)
  • high 以上:绿色(正常)

2.3 使用 ARIA role="meter"

当需要完全自定义样式(如电池图标、仪表盘、信号格等)时,使用 ARIA 方式:

<div role="meter" aria-label="电池电量" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100">
  <!-- 自定义视觉表现 -->
</div>

2.4 如何选择?

优先使用 <meter> 标签:

  • 简单的进度条场景
  • 不需要复杂自定义样式
  • 追求代码简洁性

使用 ARIA role="meter"

  • 需要自定义视觉样式(电池图标、仪表盘等)
  • 特殊形状或动画效果
  • 需要兼容旧浏览器

2.5 两种方式的等价关系

以下两种实现在辅助技术眼中是等价的

<!-- 方式1:HTML 原生标签 -->
<meter value="60" min="0" max="100" aria-label="电池电量">60%</meter>

<!-- 方式2:ARIA 实现 -->
<div role="meter" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" aria-label="电池电量">
  60%
</div>

注意<meter> 标签已经内置了 role="meter" 语义,不需要额外添加 role 属性。

三、WAI-ARIA 角色与属性(ARIA 方式)

当使用 <meter> 标签时,以下 ARIA 属性自动处理,无需手动声明:

HTML 属性 对应的 ARIA 属性 说明
value aria-valuenow 自动映射
min aria-valuemin 自动映射
max aria-valuemax 自动映射

当使用 role="meter" 时,需要手动声明以下属性:

3.1 必需属性

Meter 组件需要以下 ARIA 属性:

属性 说明 示例值
aria-valuenow 当前值(必须在 min 和 max 之间) "60"
aria-valuemin 最小值 "0"
aria-valuemax 最大值 "100"
aria-labelaria-labelledby 计量器的可访问标签 "电池电量"
<div
  role="meter"
  aria-label="电池电量"
  aria-valuenow="60"
  aria-valuemin="0"
  aria-valuemax="100">
  <!-- 计量器视觉表现 -->
</div>

3.2 可选属性

aria-valuetext

当仅显示百分比不够友好时,使用 aria-valuetext 提供更友好的值描述:

<div
  role="meter"
  aria-label="电池电量"
  aria-valuenow="50"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="50% (6小时) 剩余">
  <!-- 计量器视觉表现 -->
</div>

辅助技术会读取 aria-valuetext 而不是简单的百分比数值。

3.3 属性关系

aria-valuemin < aria-valuenow < aria-valuemax
      ↑              ↑               ↑
    最小值         当前值          最大值
      0             60              100

约束条件

三、键盘交互规范

Meter 组件没有特定的键盘交互,因为它通常是一个只读组件,用户不能直接操作它。

如果 Meter 是可交互的(如可调节的范围选择器),应该使用 role="slider" 而不是 role="meter"

四、实现方式

4.1 基础 Meter 结构

<div
  class="meter"
  role="meter"
  aria-label="电池电量"
  aria-valuenow="75"
  aria-valuemin="0"
  aria-valuemax="100">
  <div class="meter-bar">
    <div 
      class="meter-fill" 
      style="width: 75%;">
    </div>
  </div>
  <span class="meter-value">75%</span>
</div>

4.2 使用 aria-valuetext 的示例

<div
  class="meter"
  role="meter"
  aria-label="剩余存储空间"
  aria-valuenow="45.5"
  aria-valuemin="0"
  aria-valuemax="128"
  aria-valuetext="45.5 GB 已使用,共 128 GB">
  <div class="meter-bar">
    <div 
      class="meter-fill" 
      style="width: 35.5%;">
    </div>
  </div>
  <span class="meter-value">45.5 GB / 128 GB</span>
</div>

4.3 带颜色状态的 Meter

根据数值范围显示不同颜色(如危险、警告、正常):

<div
  class="meter meter-danger"
  role="meter"
  aria-label="CPU 使用率"
  aria-valuenow="95"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="95%,危险">
  <div class="meter-bar">
    <div 
      class="meter-fill" 
      style="width: 95%;">
    </div>
  </div>
  <span class="meter-value">95%</span>
</div>

五、常见应用场景

5.1 电池电量显示

<div
  role="meter"
  aria-label="电池电量"
  aria-valuenow="45"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="45% (约3小时) 剩余">
  <!-- 电池图标:外框 + 电量填充 -->
  <div class="battery-icon">
    <div class="battery-body">
      <div class="battery-level" style="width: 45%;"></div>
    </div>
    <div class="battery-cap"></div>
  </div>
  <span>45%</span>
</div>

5.2 磁盘使用率

<div
  role="meter"
  aria-label="磁盘使用率"
  aria-valuenow="72"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="72% 已使用 (360 GB / 500 GB)">
  <div class="meter-bar">
    <div style="width: 72%"></div>
  </div>
  <span>72% 已使用</span>
</div>

5.3 温度显示

<div
  role="meter"
  aria-label="CPU 温度"
  aria-valuenow="65"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuetext="65°C">
  <div class="meter-bar">
    <div style="width: 65%"></div>
  </div>
  <span>65°C</span>
</div>

5.4 信号强度

<div
  role="meter"
  aria-label="WiFi 信号强度"
  aria-valuenow="3"
  aria-valuemin="0"
  aria-valuemax="4"
  aria-valuetext="3格信号 (良好)">
  <div class="signal-bars">
    <span class="bar active"></span>
    <span class="bar active"></span>
    <span class="bar active"></span>
    <span class="bar"></span>
  </div>
</div>

六、最佳实践

6.1 正确选择使用场景

使用 Meter

  • 电池电量
  • 磁盘/存储使用率
  • 温度、压力等物理量
  • 信号强度
  • 任何有明确范围的数值

不使用 Meter(改用 Progressbar):

  • 文件上传进度
  • 安装进度
  • 任务完成百分比
  • 任何表示"进度"的场景

不使用 Meter(改用其他组件):

  • 世界人口(无最大值)
  • 可调节的数值(使用 Slider)

6.2 提供清晰的标签

始终为 Meter 提供描述性的标签:

<!-- 好的示例 -->
<div role="meter" aria-label="电池电量">...</div>

<!-- 不好的示例 -->
<div role="meter">...</div>

6.3 使用 aria-valuetext 增强可读性

当纯百分比不够直观时,使用 aria-valuetext

<!-- 好的示例 -->
<div
  role="meter"
  aria-label="电池"
  aria-valuenow="50"
  aria-valuetext="50% (6小时) 剩余">
  ...
</div>

<!-- 不好的示例 -->
<div
  role="meter"
  aria-label="电池"
  aria-valuenow="50">
  ...
</div>

6.4 确保数值在有效范围内

// 确保 aria-valuenow 在有效范围内
function updateMeter(element, value) {
  const min = parseFloat(element.getAttribute('aria-valuemin'));
  const max = parseFloat(element.getAttribute('aria-valuemax'));
  
  // 限制值在范围内
  const clampedValue = Math.max(min, Math.min(max, value));
  
  element.setAttribute('aria-valuenow', clampedValue);
}

6.5 视觉与 ARIA 值保持一致

确保视觉表现和 ARIA 属性值同步更新:

function setMeterValue(element, value) {
  const min = parseFloat(element.getAttribute('aria-valuemin'));
  const max = parseFloat(element.getAttribute('aria-valuemax'));
  
  // 更新 ARIA 值
  element.setAttribute('aria-valuenow', value);
  
  // 更新视觉表现
  const percentage = ((value - min) / (max - min)) * 100;
  const fillElement = element.querySelector('.meter-fill');
  fillElement.style.width = percentage + '%';
  
  // 更新文本
  const valueElement = element.querySelector('.meter-value');
  valueElement.textContent = Math.round(percentage) + '%';
}

6.6 考虑颜色对比度

确保 Meter 的不同状态颜色具有足够的对比度:

.meter-fill {
  background-color: #3b82f6; /* 蓝色 - 正常 */
}

.meter-warning .meter-fill {
  background-color: #f59e0b; /* 黄色 - 警告 */
}

.meter-danger .meter-fill {
  background-color: #ef4444; /* 红色 - 危险 */
}

七、总结

Meter 组件虽然简单,但正确使用 ARIA 属性对于无障碍体验至关重要:

  1. 使用正确的 rolerole="meter" 用于显示范围内的数值
  2. 设置必需的属性aria-valuenow aria-valuemin aria-valuemax
  3. 提供清晰的标签:使用 aria-labelaria-labelledby
  4. 增强可读性:使用 aria-valuetext 提供更友好的值描述
  5. 区分使用场景:Meter vs Progressbar vs Slider

遵循 W3C Meter Pattern 规范,我们能够创建既美观又无障碍的计量器组件,为所有用户提供清晰的状态信息。

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

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

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

Divide节点的核心地位

在Unity URP(通用渲染管线)的ShaderGraph系统中,Divide节点作为数学运算的核心模块,其功能远不止于简单的数值除法。它采用逐元素运算机制,能够处理标量、向量和矩阵等多种数据类型,在材质动态控制、特效实现与性能优化中发挥关键作用。例如,在昼夜交替系统中,Divide节点可通过时间参数驱动场景光照的平滑过渡;在角色受伤特效中,它能精确控制屏幕红色渐变的强度。

Divide节点的功能特性与数据兼容性

基础运算机制

Divide节点执行逐元素除法运算,其输入输出遵循以下规则:

  • 标量运算:当输入为标量时,节点执行数值除法。例如,将基础纹理颜色值除以0.5可提升整体亮度,常用于动态调整材质的明暗表现。
  • 向量运算:支持二维(UV坐标)、三维(RGB颜色)和四维(RGBA颜色)向量运算。例如,UV坐标与旋转矩阵的除法可实现纹理扭曲效果,无需依赖复杂的顶点着色器操作。
  • 矩阵运算:适用于复杂空间变换,如摄像机投影矩阵的除法可优化移动端渲染性能。

输入输出类型与数据兼容性

Divide节点的输入输出类型需严格匹配,以避免运行时错误:

  • 输入类型:支持标量(单值)、向量(多通道)和矩阵(变换数据)。实际应用中,标量常用于控制效果强度(如雾效浓度),向量则处理空间坐标与色彩信息。
  • 输出类型:根据输入自动推断。例如,两个RGB向量相除后,输出仍为RGB向量,但需注意避免除零错误导致的数值溢出。

与其他节点的协同作用

Divide节点常与Multiply、Add等节点配合,构建复杂运算链:

  • 亮度调节:通过标量除法控制材质明暗,再结合Multiply节点实现对比度增强。
  • 纹理混合:将基础纹理与遮罩纹理相除,生成基于像素值的混合效果,适用于UI元素的淡入淡出。
  • 空间变换:UV坐标与旋转矩阵的除法可替代传统顶点着色器操作,显著提升渲染效率。

Divide节点的应用场景与实战案例

场景1:动态材质控制

在昼夜交替系统中,Divide节点通过时间参数驱动场景光照变化:

  1. 时间参数生成:使用Time节点获取游戏时间,并将其转换为0-1范围的标量值。
  2. 光照强度计算:将基础光照颜色除以时间参数,实现从白天到黑夜的平滑过渡。
  3. 材质应用:将计算结果连接至PBR Master节点的BaseColor输入,完成动态光照调整。

场景2:角色受伤特效

当角色生命值低于阈值时,Divide节点可控制屏幕红色渐变的强度:

  1. 生命值映射:将角色当前生命值除以最大生命值,生成0-1范围的标量值。
  2. 颜色混合:将标准红色向量除以生命值标量,实现强度随生命值降低而增强的效果。
  3. 屏幕叠加:使用Screen节点将混合颜色与场景颜色叠加,生成受伤视觉反馈。

场景3:性能优化技巧

在移动端开发中,Divide节点可通过以下方式优化性能:

  • 参数缓存:将重复计算的标量值(如时间参数)存储为变量,避免每帧重新计算。
  • 节点嵌套:将复杂运算链封装为自定义节点,减少图形编辑器中的节点数量。
  • 数据类型匹配:确保输入输出类型一致,避免运行时类型转换开销。

常见问题与解决方案

问题1:除零错误

当除数为零时,Divide节点会返回极大值或NaN,导致材质显示异常。解决方案:

  • 输入验证:在除法前添加条件判断,确保除数不为零。
  • 默认值设置:使用Lerp节点在除数为零时返回默认值,避免数值溢出。

问题2:性能瓶颈

复杂运算链可能导致渲染帧率下降。优化方案:

  • 简化运算:将多级除法合并为单次运算,减少节点连接数。
  • 动态卸载:在非关键帧(如角色静止时)暂停复杂运算,降低CPU负载。

问题3:数据类型不匹配

输入输出类型不一致会导致编译错误。调试方法:

  • 类型检查:在节点属性面板中查看输入输出类型,确保兼容性。
  • 中间转换:使用Vector3ToVector4等节点进行类型转换,避免直接连接不匹配数据。

进阶技巧:Divide节点的高级应用

技巧1:动态纹理扭曲

通过UV坐标与噪声图的除法,实现动态扭曲效果:

  1. 噪声生成:使用Noise节点生成随机噪声图。
  2. 坐标修正:将UV坐标除以噪声图的缩放因子,生成扭曲后的坐标。
  3. 纹理采样:使用SampleTexture2D节点采样扭曲后的坐标,输出最终纹理。

技巧2:法线贴图增强

将法线贴图的RGB值与标量相除,可增强表面细节:

  1. 法线采样:使用SampleTexture2D节点采样法线贴图。
  2. 强度控制:将法线向量除以标量值(如0.5),提升凹凸感。
  3. 光照计算:将增强后的法线连接至PBR Master节点的Normal输入,优化光照效果。

技巧3:粒子系统优化

在粒子特效中,Divide节点可控制粒子大小与速度:

  1. 生命周期映射:将粒子当前生命周期除以最大生命周期,生成0-1范围的标量。
  2. 大小调整:将基础粒子大小除以生命周期标量,实现粒子随年龄缩小。
  3. 速度控制:将粒子速度向量除以生命周期标量,模拟重力衰减效果。

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

Markdown 里写公式,别只知道 LaTeX!试试 HTML 标签,简单到飞起

家人们,谁懂啊!每次在 Markdown 笔记里遇到数学公式,虽然知道 LaTeX 语法很强大,但就是写个小小的下标或者上标,都要去查 _{} 或者 ^{},写完还要前后加 $,效率低到离谱。😫

直到今天,我突然发现——HTML 的 <sub><sup> 标签,在 Markdown 里竟然可以直接用!

今天就来跟家人们分享一下这个超简单的小技巧,让你的公式输入速度快到飞起!🚀

一、痛点场景:我只是想写个 H₂O 而已

举个栗子,如果你在 Markdown 里用 LaTeX 写水分子式:

$$H_{2}O$

是不是觉得有点麻烦?既要记忆语法,又要多敲好几个字符。万一公式多了,整个文档的阅读体验在源码模式下简直是噩梦……

二、救星登场:<sub><sup> 标签

1. <sub> 下标标签

<sub> 标签定义下标文本。比如你想写水的化学式 H₂O,只需:

H<sub>2</sub>O

渲染效果:H2O

2. <sup> 上标标签

<sup> 标签定义上标文本。比如你想写勾股定理 a² + b² = c²

a<sup>2</sup> + b<sup>2</sup> = c<sup>2</sup>

渲染效果:a2 + b2 = c2

是不是简单粗暴?根本不需要记忆任何 LaTeX 指令!并且所有markdown 编译器均支持

三、更多实用场景展示

场景 写法 显示效果
摄氏度 35<sup>。</sup>C 35。C
版权符号 Copyright<sup>©</sup> 2025 Copyright© 2025
数学指数 2<sup>n</sup> 2n
同位素 <sup>14</sup>C 14C
脚注参考 这是一句话<sup>[1]</sup> 这是一句话[1]

四、优点总结

  1. 零学习成本:只要你懂一点点 HTML,立刻上手。
  2. 跨平台兼容:几乎所有支持 Markdown 的编辑器(Typora、VS Code、Obsidian、Notion)都完美支持内嵌 HTML 标签。
  3. 代码可读性高:相比 LaTeX 的花括号,标签语义更清晰。
  4. 适合轻量场景:当你不需要复杂的矩阵、积分时,用标签快得多。

五、什么时候还是要用 LaTeX?

虽然 <sub><sup> 很好用,但如果遇到以下情况,还是得老老实实写 LaTeX

  • 复杂的分数:$\frac{1}{2}$
  • 根号:$\sqrt{2}$
  • 求和、积分符号
  • 矩阵排版

结论: 简单上下标用 HTML 标签,复杂公式再用 LaTeX,两者结合,效率最高!

写在最后

有时候我们总想找复杂的插件、学复杂的语法来解决一个问题,殊不知最简单的 HTML 原生能力就藏在 Markdown 的底层支持里。

希望这个小分享能让家人们的笔记更清爽、打字更快!如果你也有类似的 Markdown 偷懒小技巧,欢迎在评论区分享哦~ 👇

Markdown 虽小,技巧不少,我们一起探索! 🎉

nslookup Cheatsheet

Basic Syntax

Core nslookup command forms.

Command Description
nslookup example.com Look up a domain using the default resolver
nslookup example.com 8.8.8.8 Query a specific DNS server
nslookup -type=mx example.com Query a specific record type
nslookup 192.0.2.1 Run a reverse DNS lookup
nslookup Start interactive mode

Common Lookups

Quick checks for hostnames and addresses.

Command Description
nslookup example.com Look up A and AAAA records using the default resolver
nslookup www.example.com Check a hostname or subdomain
nslookup localhost Verify local name resolution
nslookup 127.0.0.1 Reverse lookup for the local loopback address
nslookup 192.0.2.1 Reverse lookup for a public IP address

Record Types

Use -type to query specific DNS records.

Command Description
nslookup -type=a example.com Query IPv4 address records
nslookup -type=aaaa example.com Query IPv6 address records
nslookup -type=mx example.com Query mail exchanger records
nslookup -type=ns example.com Query authoritative name servers
nslookup -type=txt example.com Query TXT records
nslookup -type=soa example.com Query the SOA record
nslookup -type=cname www.example.com Check whether a hostname is an alias
nslookup -type=any example.com Run an ANY query

Specific DNS Servers

Compare answers from different resolvers.

Command Description
nslookup example.com 8.8.8.8 Query Google Public DNS
nslookup example.com 1.1.1.1 Query Cloudflare DNS
nslookup example.com 9.9.9.9 Query Quad9
nslookup -type=mx example.com 8.8.8.8 Query MX records from a specific resolver
nslookup -type=txt example.com 1.1.1.1 Compare TXT answers between resolvers

Interactive Mode

Run multiple queries in one session.

Command Description
nslookup Open interactive mode
set type=mx Switch the active query type to MX
set type=txt Switch the active query type to TXT
server 8.8.8.8 Change the active DNS server
example.com Query a domain after entering interactive mode
exit Leave the interactive session

Troubleshooting

Quick checks for common nslookup errors.

Issue Check
NXDOMAIN Verify the domain name and make sure it exists
SERVFAIL Try another resolver such as 8.8.8.8 or 1.1.1.1
Connection timed out; no servers could be reached Check network access and verify /etc/resolv.conf
Non-authoritative answer Normal cached response from a resolver
No answer The queried record type is not set for that name

Related Guides

Use these guides for fuller DNS troubleshooting workflows.

Guide Description
nslookup Command in Linux Full nslookup guide with practical examples
How to Use the dig Command to Query DNS in Linux Detailed dig guide for deeper DNS debugging
ping Cheatsheet Quick connectivity checks before DNS troubleshooting
IP command cheatsheet Inspect interfaces, addresses, and routes
curl Cheatsheet Test HTTP reachability after DNS resolution succeeds

nslookup Command in Linux: Query DNS Records

When a website does not load or email stops arriving, the first thing to check is whether the domain resolves to the correct address. The nslookup command is a quick way to query DNS servers and inspect the records behind a domain name.

nslookup ships with most Linux distributions and works on macOS and Windows as well. It supports both one-off queries from the command line and an interactive mode for running multiple lookups in a row.

This guide explains how to use nslookup with practical examples covering record types, reverse lookups, and troubleshooting.

Syntax

txt
nslookup [OPTIONS] [NAME] [SERVER]
  • NAME — The domain name or IP address to look up.
  • SERVER — The DNS server to query. If omitted, nslookup uses the server configured in /etc/resolv.conf.
  • OPTIONS — Query options such as -type=MX or -debug.

When called without arguments, nslookup starts in interactive mode.

Installing nslookup

On most distributions nslookup is already installed. To check, run:

Terminal
nslookup -version

If the command is not found, install it using your distribution’s package manager.

Install nslookup on Ubuntu, Debian, and Derivatives

Terminal
sudo apt update && sudo apt install dnsutils

Install nslookup on Fedora, RHEL, and Derivatives

Terminal
sudo dnf install bind-utils

Install nslookup on Arch Linux

Terminal
sudo pacman -S bind

The nslookup command is bundled with the same packages that provide dig .

Look Up a Domain Name

The simplest use is passing a domain name as an argument:

Terminal
nslookup linux.org
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72
Name: linux.org
Address: 172.67.73.26

The first two lines show the DNS server that answered the query. Everything under “Non-authoritative answer” is the actual result. In this case, linux.org resolves to three IPv4 addresses.

“Non-authoritative” means the answer came from a resolver’s cache rather than directly from the domain’s authoritative name server.

Query a Specific DNS Server

By default, nslookup queries the resolver configured in /etc/resolv.conf. To query a different server, add it as the last argument.

For example, to query Google’s public DNS:

Terminal
nslookup linux.org 8.8.8.8
output
Server: 8.8.8.8
Address: 8.8.8.8#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72
Name: linux.org
Address: 172.67.73.26

This is useful when you want to compare results across different resolvers or verify whether a DNS change has propagated to public servers.

Query Record Types

By default, nslookup returns A (IPv4 address) records. Use the -type option to query other record types.

MX Records (Mail Servers)

MX records identify the mail servers responsible for receiving email for a domain:

Terminal
nslookup -type=mx google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com mail exchanger = 10 smtp.google.com.

The number before the mail server hostname is the priority. A lower number means higher priority.

NS Records (Name Servers)

NS records show which name servers are authoritative for a domain:

Terminal
nslookup -type=ns google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com nameserver = ns1.google.com.
google.com nameserver = ns2.google.com.
google.com nameserver = ns3.google.com.
google.com nameserver = ns4.google.com.

TXT Records

TXT records store arbitrary text data, commonly used for SPF, DKIM, and domain ownership verification:

Terminal
nslookup -type=txt google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com text = "v=spf1 include:_spf.google.com ~all"
google.com text = "facebook-domain-verification=22rm551cu4k0ab0bxsw536tlds4h95"
google.com text = "docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"

The output may include many entries. The example above shows a subset of the TXT records returned for google.com.

AAAA Records (IPv6)

AAAA records return the IPv6 address of a domain:

Terminal
nslookup -type=aaaa google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: google.com
Address: 2a00:1450:4017:818::200e

SOA Record (Start of Authority)

The SOA record contains administrative information about the domain, including the primary name server, the responsible email address, and timing parameters for zone transfers:

Terminal
nslookup -type=soa google.com
output
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com
origin = ns1.google.com
mail addr = dns-admin.google.com
serial = 897592583
refresh = 900
retry = 900
expire = 1800
minimum = 60

The serial number increments each time the zone is updated. DNS secondaries use it to decide whether they need a zone transfer.

CNAME Records

CNAME records point one domain name to another:

Terminal
nslookup -type=cname www.github.com

If a CNAME record exists, the output shows the canonical name the alias points to. If the domain does not have a CNAME record, nslookup returns No answer.

Run an ANY Query

To ask the DNS server for an ANY response, use -type=any:

Terminal
nslookup -type=any google.com

ANY queries do not reliably return every record type for a domain. Many DNS servers return only a subset of records or refuse the query entirely.

Reverse DNS Lookup

A reverse lookup finds the hostname associated with an IP address. Pass an IP address instead of a domain name:

Terminal
nslookup 208.118.235.148
output
148.235.118.208.in-addr.arpa name = ip-208-118-235-148.twdx.net.

Reverse lookups query PTR records. They are useful for verifying that an IP address maps back to the expected hostname, which matters for mail server configuration and security checks.

Interactive Mode

Running nslookup without arguments starts an interactive session where you can run multiple queries without retyping the command:

Terminal
nslookup
output
>

At the > prompt, type a domain name to look it up:

output
> linux.org
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
Name: linux.org
Address: 104.26.14.72
Name: linux.org
Address: 104.26.15.72

You can change query settings during the session with the set command. For example, to switch to MX record lookups and then query a domain:

output
> set type=mx
> google.com
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
google.com mail exchanger = 10 smtp.google.com.

To change the DNS server:

output
> server 8.8.8.8
Default server: 8.8.8.8
Address: 8.8.8.8#53

Type exit to leave interactive mode.

Interactive mode is convenient when you need to test several domains or record types in a row without running separate commands each time.

Debugging DNS Issues

The -debug option shows the full query and response details, including TTL values and additional sections that nslookup normally hides:

Terminal
nslookup -debug linux.org

The debug output is verbose, but it is helpful when you need to see TTL values, check whether answers are authoritative, or trace unexpected behavior.

nslookup vs dig

Both nslookup and dig query DNS servers, but they differ in output and capabilities:

  • nslookup produces simpler, more readable output. It also has an interactive mode that is convenient for quick checks.
  • dig provides detailed, structured output with sections (QUESTION, ANSWER, AUTHORITY, ADDITIONAL) and supports advanced options like +trace for tracing the full resolution path and +dnssec for verifying DNSSEC signatures.

For quick lookups and basic troubleshooting, nslookup is often faster to type and read. For in-depth DNS debugging, dig gives you more control and detail.

Troubleshooting

nslookup returns NXDOMAIN
The domain does not exist or is misspelled. Verify the domain name and check that it is registered.

nslookup returns SERVFAIL
The DNS server could not process the query. Try a different resolver to isolate the problem:

Terminal
nslookup linux.org 1.1.1.1

If public resolvers return the correct answer, the issue is with your configured resolver.

Connection timed out; no servers could be reached
This means nslookup could not contact the DNS server. Check your network connection and verify that /etc/resolv.conf contains a reachable name server. A firewall may also be blocking outbound DNS traffic on port 53.

Non-authoritative answer appears on every query
This is normal. It means the answer came from a resolver’s cache, not directly from the domain’s authoritative server. The result is still valid.

Quick Reference

For a printable quick reference, see the nslookup cheatsheet .

Task Command
Look up a domain nslookup example.com
Query a specific DNS server nslookup example.com 8.8.8.8
Query MX records nslookup -type=mx example.com
Query NS records nslookup -type=ns example.com
Query TXT records nslookup -type=txt example.com
Query AAAA (IPv6) records nslookup -type=aaaa example.com
Query SOA record nslookup -type=soa example.com
Query CNAME record nslookup -type=cname example.com
Run an ANY query nslookup -type=any example.com
Reverse DNS lookup nslookup 192.0.2.1
Start interactive mode nslookup
Enable debug output nslookup -debug example.com

FAQ

Can I use nslookup to check DNS propagation?
Yes. Query the same domain against several public DNS servers and compare the results. For example, run nslookup example.com 8.8.8.8, nslookup example.com 1.1.1.1, and nslookup example.com 9.9.9.9. If the answers differ, the change has not fully propagated.

Is nslookup deprecated?
The ISC (the organization behind BIND) once marked nslookup as deprecated in favor of dig, but later reversed that decision. nslookup is actively maintained and included in current BIND releases. It remains a practical tool for quick DNS lookups.

What does “Non-authoritative answer” mean?
It means the response came from a caching resolver, not from one of the domain’s authoritative name servers. The data is still accurate, but it may be slightly behind if a DNS change was made very recently and the cache has not expired yet.

Conclusion

The nslookup command is a quick way to query DNS records from the command line. Use -type to look up MX, NS, TXT, AAAA, and other record types, and pass a server argument to test against a specific resolver. For deeper DNS debugging, pair it with dig .

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

背景

上个月,我接手了一个DeFi收益聚合器项目的前端开发。产品经理提了一个需求:要在仪表盘首页展示用户可能感兴趣的几个热门Uniswap V3流动性池的实时数据,包括24小时交易量、总流动性和当前手续费率。

我的第一反应是:“简单,直接用 ethers.jsviem 去读合约的 public 变量和事件不就行了?” 于是,我吭哧吭哧写了段代码,通过 useEffect 轮询调用池子合约的 slot0 函数获取当前价格,再通过 provider.getLogs 拉取最近24小时的 Swap 事件来计算交易量。本地测试时,面对一个池子还好。一上线,用户钱包里要是多几个池子,页面直接卡死,RPC调用次数爆炸,速度慢得让人想砸键盘。我意识到,对于这种需要聚合和分析历史链上数据的场景,直接与节点交互是条死路。这时,我想起了那个听过很多次但一直没亲手用过的工具——The Graph。

问题分析

The Graph 的核心是一个去中心化的索引协议,它监听区块链事件,将数据按照定义好的模式(Subgraph)处理后存入可高效查询的数据库。对于前端来说,我们不用再关心如何从海量事件日志里筛选和计算,只需要像调用API一样,用GraphQL查询语句去获取已经处理好的结构化数据。

我的需求很明确:查询Uniswap V3在以太坊主网上特定池子的聚合数据。理论上,我不需要自己部署Subgraph,因为Uniswap官方已经维护了一个非常完善的 Uniswap V3 Subgraph。我的任务就是在前端React应用中,学会如何与这个已部署的Subgraph进行交互。

最初的尝试是直接用 fetchaxios 向Subgraph的GraphQL端点发送POST请求。这确实能跑通,但很快遇到了问题:1. 需要手动管理查询字符串和变量,容易出错;2. 缺乏类型安全,返回的数据结构全靠猜;3. 没有内置的请求状态(loading, error)管理,需要自己用useState和useEffect封装,很繁琐。我需要一个更“React”的、类型友好的解决方案。

核心实现

第一步:环境搭建与GraphQL客户端选择

首先,我创建了一个新的React + TypeScript项目(或者在你的现有项目中操作)。关键的依赖是 @apollo/clientgraphql。Apollo Client 是一个强大的GraphQL状态管理库,它提供了React Hook(如 useQuery)、缓存、错误处理等开箱即用的功能,能极大简化前端与The Graph的交互。

npm install @apollo/client graphql

接下来,我需要初始化Apollo Client,并配置其连接到Uniswap V3的Hosted Service端点。

这里有个坑:The Graph的Hosted Service端点URL结构是 https://api.thegraph.com/subgraphs/name/<用户名>/<子图名称>。对于Uniswap V3以太坊主网,用户名是 uniswap,子图名称是 uniswap-v3。千万别去官方文档里找“API Key”,Hosted Service在查询限额内是免费的,直接使用即可。

我创建了一个文件 lib/apolloClient.ts 来配置客户端:

// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

// Uniswap V3 以太坊主网 Subgraph 端点
const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

// 创建 Apollo Client 实例
// 注意:默认缓存策略可能不适合实时性极高的数据,对于交易量等数据可以考虑调整fetchPolicy
export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network', // 优先返回缓存,同时在后台更新
    },
    query: {
      fetchPolicy: 'network-only', // 对于主动查询,总是从网络获取
    },
  },
});

第二步:编写GraphQL查询并生成类型

这是核心步骤。我需要去 The Graph Explorer 找到 uniswap/uniswap-v3 子图,研究其数据模式(Schema)。我需要的池子(Pool)数据,在Schema中对应 Pool 实体,里面包含了 id(合约地址)、totalValueLockedUSDvolumeUSDfeesUSDtoken0token1 等字段。

为了获取24小时数据,子图通常会有类似 poolDayData 的时间序列实体。经过探索,我发现查询最近24小时数据的最佳方式是:先查询 Pool 实体本身获取当前快照数据(如TVL),再关联查询其最新的 poolDayData(按日期排序取第一条)来获取过去24小时的交易量和手续费。

我创建了一个GraphQL查询文件 queries/poolData.graphql

# queries/poolData.graphql
query PoolData($poolId: String!) {
  # 查询池子基础信息
  pool(id: $poolId) {
    id
    totalValueLockedUSD
    feeTier
    token0 {
      id
      symbol
      decimals
    }
    token1 {
      id
      symbol
      decimals
    }
    # 关联查询最近的日数据(过去24小时)
    poolDayData(first: 1, orderBy: date, orderDirection: desc) {
      volumeUSD
      feesUSD
      date
    }
  }
}

注意这个细节$poolId 是池子的合约地址,但在The Graph中,id 字段通常是全小写的地址字符串。所以从链上获取的地址,在传入查询变量前最好先 .toLowerCase() 处理一下,避免查不到数据。

接下来,为了让TypeScript认识查询返回的数据结构,我使用GraphQL Code Generator来自动生成类型。这需要额外配置,但一劳永逸。简单起见,我也可以手动定义类型,但对于复杂查询,自动生成更可靠。这里我展示手动定义的方式,更贴近快速上手的场景:

// types/poolData.ts
export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

第三步:创建自定义React Hook

为了让数据获取逻辑可以在组件中优雅复用,我决定将其封装成一个自定义Hook:usePoolData

// hooks/usePoolData.ts
import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

// 直接在Hook中定义GraphQL查询,避免额外文件
// 注意:gql`...` 是Apollo Client的模板标签函数,用于解析GraphQL查询字符串
const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined; // 池子合约地址
  skip?: boolean; // 是否跳过查询
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  // 使用 useQuery Hook
  // 它自动处理 loading, error 状态,并返回 data
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(), // 关键:地址转小写
      },
      skip: !poolId || skip, // 如果没有poolId或主动跳过,则不执行查询
      // fetchPolicy: 'network-only' // 可以根据需要覆盖默认策略
    }
  );

  // 对返回的数据进行简单处理和类型断言
  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch, // 用于手动刷新数据
  };
};

这个Hook的设计非常“React”:它接收依赖项(poolId),管理内部状态,并返回一个清晰的状态对象。在组件中使用时,我可以轻松地根据 loading 显示加载框,根据 error 显示错误信息,用 poolData 渲染UI。

第四步:在组件中集成并使用

最后,我在一个React组件中使用这个Hook。假设我要显示USDC/ETH 0.05%费率的池子(一个非常常见的池)。

// components/PoolCard.tsx
import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

// 已知的 Uniswap V3 USDC/ETH 0.05% 池地址(以太坊主网)
const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) {
    return <div className="p-4 border rounded-lg">加载池数据中...</div>;
  }

  if (error) {
    return (
      <div className="p-4 border rounded-lg bg-red-50 text-red-700">
        查询失败: {error.message}
      </div>
    );
  }

  if (!poolData) {
    return <div className="p-4 border rounded-lg">未找到池子数据</div>;
  }

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div className="p-4 border rounded-lg shadow-sm bg-white">
      <h3 className="font-bold text-lg">
        {poolData.token0.symbol} / {poolData.token1.symbol} Pool
      </h3>
      <p className="text-sm text-gray-500">费率: {Number(poolData.feeTier) / 10000}%</p>
      <div className="mt-3 space-y-2">
        <div>
          <span className="text-gray-600">总锁定价值 (TVL): </span>
          <span className="font-semibold">
            ${Number(tvl).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div>
          <span className="text-gray-600">24小时交易量: </span>
          <span className="font-semibold">
            ${Number(dailyVolume).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div className="text-xs text-gray-400">
          池地址: {poolData.id}
        </div>
      </div>
    </div>
  );
};

export default PoolCard;

至此,一个完整的、从The Graph获取Uniswap V3池数据并展示的前端功能就实现了。代码清晰、类型安全、且易于维护和扩展。

完整代码

以下是关键文件的完整代码汇总,你可以复制到一个新的React + TypeScript项目中运行测试:

1. 安装依赖:

npx create-react-app my-graph-demo --template typescript
cd my-graph-demo
npm install @apollo/client graphql

2. 配置 Apollo Client (src/lib/apolloClient.ts):

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
    query: {
      fetchPolicy: 'network-only',
    },
  },
});

3. 定义类型 (src/types/poolData.ts):

export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

4. 创建自定义Hook (src/hooks/usePoolData.ts):

import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined;
  skip?: boolean;
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(),
      },
      skip: !poolId || skip,
    }
  );

  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch,
  };
};

5. 创建展示组件 (src/components/PoolCard.tsx):

import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;
  if (!poolData) return <div>无数据</div>;

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
      <h3>{poolData.token0.symbol} / {poolData.token1.symbol} Pool</h3>
      <p>费率: {Number(poolData.feeTier) / 10000}%</p>
      <div>
        <div>TVL: ${Number(tvl).toLocaleString()}</div>
        <div>24h Volume: ${Number(dailyVolume).toLocaleString()}</div>
      </div>
      <small>地址: {poolData.id}</small>
    </div>
  );
};

export default PoolCard;

6. 在应用入口集成 (src/App.tsx):

import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import PoolCard from './components/PoolCard';
import './App.css';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 池数据看板 (The Graph)</h1>
        <PoolCard />
        {/* 可以在这里添加更多 PoolCard,传入不同的 poolId */}
      </div>
    </ApolloProvider>
  );
}

export default App;

运行 npm start,你应该能看到一个显示USDC/ETH池数据的卡片。

踩坑记录

  1. “池子找不到 (Pool not found)”:这是我遇到的第一个也是最多人踩的坑。我确认地址没错,但查询返回 null。后来在The Graph的Discord社区提问才知道,Subgraph中存储的地址 id 字段全是小写。而我从链上或Etherscan复制的地址可能是大小写混合的校验和格式。解决方法:在将地址作为变量传入查询前,务必执行 .toLowerCase()

  2. 查询超时或响应慢:第一次查询一个不常被查询的冷门池子时,可能会遇到响应时间较长的情况。这是因为The Graph的索引器需要为这次查询执行索引工作。解决方法:对于用户体验要求高的场景,前端要做好加载状态提示。另外,可以检查Subgraph的健康状态,有时是公共端点负载问题。

  3. 数据类型不匹配:GraphQL查询返回的数字,即使是 BigInt 在Subgraph中,通过API返回时也是字符串格式。直接用于计算会出错。解决方法:在前端使用前,用 Number()parseFloat() 或更适合大数的库如 BigNumber.js (ethers.js自带) 进行转换。我的示例中用了 Number(),对于TVL和交易量这种可能很大的数,在生产环境中建议使用 ethers.BigNumberBigInt 来处理。

  4. “Cannot read property ‘symbol’ of null”:在测试时,我传了一个非Uniswap V3池的地址,查询返回的 pool 不为 null,但内部的 token0token1 可能为 null(如果子图索引不完整)。解决方法:在组件渲染中使用可选链操作符 ?. 或进行严格的空值检查,就像我在示例中处理 poolDayData[0] 一样。

小结

这次实战让我彻底把The Graph从“听说过”变成了“上手用过”。它的核心价值在于将复杂的链上数据索引、聚合工作从前端剥离,让开发者能像查询普通API一样高效获取结构化数据。对于构建数据驱动的DeFi、NFT应用前端,它几乎是必备工具。下一步,我可以探索更复杂的查询(如分页获取多个池子、历史时间序列分析),甚至尝试为自己项目的合约部署一个专属的Subgraph。

Vue3 日历组件选型指南:五大主流方案深度解析

在 Vue3 项目开发中,日历组件是日程管理、预约系统、数据可视化等场景的核心组件。不同项目对日历的功能需求差异极大——有的只需基础日期选择,有的需要支持多日程展示、自定义节假日、拖拽调整等复杂功能。本文从「易用性、扩展性、性能」三个维度,深入分析 5 款主流 Vue3 日历组件,并提供选型建议,帮助开发者快速找到适配场景的最佳方案。

一、Vue3 Datepicker:轻量无依赖的基础款

Vue3 Datepicker 是一款纯 Vue3+TypeScript 开发的日历组件,主打轻量与无依赖特性。该组件体积仅约 5KB,却提供了日期范围选择、禁用日期、自定义格式等实用功能。其样式简洁,开发者可通过 CSS 轻松覆盖默认样式,同时完美适配移动端与 PC 端。得益于纯 Vue3 的实现方式,该组件对 Composition API 和 Options API 都有良好的兼容性。

安装命令:

npm install vue3-datepicker --save

使用示例:

<template>
  <div class="basic-calendar">
    <Datepicker
      v-model="selectedDate"
      :disabled-dates="disabledDates"
      format="YYYY-MM-DD"
      placeholder="选择日期"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import Datepicker from 'vue3-datepicker';
import 'vue3-datepicker/dist/index.css';

const selectedDate = ref<Date | null>(null);

const disabledDates = (date: Date) => {
  const day = date.getDay();
  return day === 0 || day === 6;
};
</script>

<style scoped>
.basic-calendar {
  width: 300px;
  margin: 20px;
}
</style>

适用场景:表单中的生日选择、订单日期筛选等轻量级日期选择需求,以及追求极小打包体积的项目。

二、Element Plus Calendar:生态集成的标准化选择

Element Plus Calendar 是饿了么团队出品的企业级日历组件,与 Element Plus 组件库深度集成,视觉风格统一。该组件支持月视图、周视图、日视图三种模式切换,提供日程数据绑定能力,开发者可自定义单元格内容展示。内置国际化功能、日期范围选择、禁用日期等基础能力,并提供完整的 TypeScript 类型定义,可与 Vue3+Vite 开发环境无缝配合。

安装命令:

npm install element-plus --save

使用示例:

<template>
  <div class="el-calendar-demo">
    <el-calendar v-model="currentDate">
      <template #date-cell="{ data }">
        <p :class="data.isSelected ? 'is-selected' : ''">
          {{ data.day.split('-').pop() }}
        </p>
        <span v-if="scheduleMap[data.day]" class="schedule-count">
          {{ scheduleMap[data.day] }}条日程
        </span>
      </template>
    </el-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElCalendar } from 'element-plus';
import 'element-plus/dist/index.css';

const currentDate = ref<Date>(new Date());

const scheduleMap = ref({
  '2026-02-06': 3,
  '2026-02-08': 1,
  '2026-02-10': 2,
});
</script>

<style scoped>
.is-selected {
  color: #409eff;
  font-weight: bold;
}
.schedule-count {
  font-size: 12px;
  color: #f56c6c;
}
</style>

适用场景:使用 Element Plus 组件库的中后台管理系统,需要快速实现标准化日历和日程功能的项目。

三、FullCalendar Vue3:复杂场景的全功能方案

FullCalendar Vue3 是基于业界知名的 FullCalendar 核心库封装的 Vue3 组件,专为复杂日程管理场景设计。该组件支持月视图、周视图、日视图、列表视图、时间轴视图等 10 余种视图类型,提供了日程拖拽、调整时长、重复日程设置、自定义事件渲染等丰富功能。组件兼容 Vue3 的组合式 API,可与 Pinia 或 Vuex 状态管理库无缝集成。此外,还支持 Google 日历和 iCal 导入,具备国际化与时区切换能力。

安装命令:

npm install @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction

使用示例:

<template>
  <div class="full-calendar-demo">
    <FullCalendar
      :plugins="calendarPlugins"
      initialView="dayGridMonth"
      :events="calendarEvents"
      editable="true"
      selectable="true"
      @dateClick="handleDateClick"
      @eventClick="handleEventClick"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';

const calendarPlugins = ref([dayGridPlugin, interactionPlugin]);

const calendarEvents = ref([
  { title: '产品评审会', start: '2026-02-06', end: '2026-02-07', color: '#409eff' },
  { title: '版本发布', start: '2026-02-09', color: '#67c23a' },
]);

const handleDateClick = (info: any) => {
  alert(`选择了日期: ${info.dateStr}`);
};

const handleEventClick = (info: any) => {
  alert(`点击了日程: ${info.event.title}`);
};
</script>

<style scoped>
.full-calendar-demo {
  width: 90%;
  margin: 20px auto;
}
</style>

适用场景:企业 OA 系统、会议室预约、课程表管理等复杂日程管理场景,需支持拖拽操作、多视图切换、复杂事件配置的项目。

四、Vant4 Calendar:移动端友好的轻量选择

Vant4 Calendar 是有赞团队出品的移动端日历组件,专为移动端 H5 和小程序场景优化。该组件在交互设计上充分考虑移动端特性,支持滑动切换月份、手势操作等移动端常见交互方式。功能方面支持日期范围选择、快捷日期选择(如近 7 天、近 30 天)、自定义弹窗样式等实用能力。组件体积仅约 8KB,性能表现优异,支持按需引入,与 Vant4 组件库整体风格保持一致。

安装命令:

npm install vant --save

使用示例:

<template>
  <div class="vant-calendar-demo">
    <van-button @click="showCalendar = true">选择日期</van-button>
    <van-calendar
      v-model:show="showCalendar"
      v-model="selectedDate"
      type="range"
      :min-date="minDate"
      :max-date="maxDate"
      @confirm="handleConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { VanCalendar, VanButton } from 'vant';
import 'vant/lib/index.css';

const showCalendar = ref(false);
const selectedDate = ref<[Date, Date]>([new Date(), new Date()]);
const minDate = ref(new Date('2026-01-01'));
const maxDate = ref(new Date('2026-12-31'));

const handleConfirm = (dates: [Date, Date]) => {
  console.log('选择的日期范围:', dates);
  showCalendar.value = false;
};
</script>

适用场景:移动端 H5 页面、小程序项目,需轻量级、交互友好的日期选择功能。

五、Vue3 Simple Calendar:极简逻辑的定制基石

Vue3 Simple Calendar 是一款独特的日历组件,它不包含任何样式封装,仅提供核心日历逻辑。该组件基于 Vue3 Composition API 开发,体积仅 3KB,没有任何第三方依赖。开发者可以完全自定义 UI 和交互方式,组件只负责处理日历的基本逻辑,如月份切换、日期选中、日期渲染回调等。

安装命令:

npm install vue3-simple-calendar --save

使用示例:

<template>
  <div class="custom-calendar">
    <simple-calendar
      v-model="currentMonth"
      @date-click="handleDateClick"
    >
      <template #header="{ year, month, prevMonth, nextMonth }">
        <div class="calendar-header">
          <button @click="prevMonth">上一月</button>
          <h3>{{ year }}年{{ month }}月</h3>
          <button @click="nextMonth">下一月</button>
        </div>
      </template>
      <template #day="{ date, isToday, isWeekend }">
        <div
          class="day-cell"
          :class="{ today: isToday, weekend: isWeekend, selected: selectedDate === date }"
        >
          {{ date.getDate() }}
        </div>
      </template>
    </simple-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SimpleCalendar from 'vue3-simple-calendar';

const currentMonth = ref<Date>(new Date());
const selectedDate = ref<Date | null>(null);

const handleDateClick = (date: Date) => {
  selectedDate.value = date;
  console.log('选中日期:', date);
};
</script>

<style scoped>
.custom-calendar {
  width: 350px;
  margin: 20px;
}
.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}
.day-cell {
  width: 50px;
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.today {
  background-color: #409eff;
  color: white;
  border-radius: 50%;
}
.weekend {
  color: #f56c6c;
}
.selected {
  border: 2px solid #67c23a;
  border-radius: 50%;
}
</style>

适用场景:需要品牌化设计日历 UI、特殊交互效果的项目,或仅需复用核心日历逻辑的定制化开发场景。

六、选型指南与核心原则

面对多种日历组件选择,开发者需要根据项目实际情况做出判断。以下是各组件的核心对比:

组件类型 核心优势 适用场景 打包体积
Vue3 Datepicker 轻量无依赖、易定制 基础日期选择、表单场景 ~5KB
Element Plus Calendar 大厂背书、生态集成 中后台标准化日程功能 ~15KB
FullCalendar Vue3 全功能、复杂交互 企业 OA、预约系统 ~100KB
Vant4 Calendar 移动端适配、交互友好 移动端 H5、小程序 ~8KB
Vue3 Simple Calendar 完全自定义、极简逻辑 个性化 UI、定制化交互 ~3KB

连载05-Claude Skill 不是抄模板:真正管用的 Skill,都是从实战里提炼出来的

别再直接 Fork 别人的 Claude Skill:真正有用的 Skill,都是从项目里长出来的

AI Coding 系列第 05 篇 · 核心工具

封面

我第一次批量导入公开 Skill 模板的时候,是真的以为自己走了捷径。

GitHub 上一堆 star 很高的仓库,code review、需求分析、文档编写、调研、拆任务,看起来什么都有。我当时的想法很直接:既然别人已经把常见工作流整理好了,我直接 fork 一份,全量导入,不就能让 Claude 立刻更稳、更懂项目吗?

结果用了几天,我反而越来越不放心。

不是因为它“明显做错了什么”,而是因为它总在看起来没问题的地方出问题。格式完整,措辞专业,检查项也不少,可真正让我在项目里反复吃亏的那几件事,它一次都没替我盯住。异步链里是不是又漏了 await,这次 migration 有没有回滚方案,新同学是不是又顺手写了 throw new Error(),数据库 schema 改了之后 Prisma 类型是不是也一起更新了。

它会提醒一堆“大家普遍都应该注意”的东西,却不知道“我们团队到底最怕什么”。

后来我才明白,问题不是 Skill 机制不好,而是我导入的根本不是“自己的 Skill”,只是别人整理好的经验。

这些经验当然有价值,但它们解决的是共性问题,不会天然长成你项目里的“肌肉记忆”。

真正有用的 Skill,恰恰应该做一件事:

把那些你本来总要重复提醒、总会漏掉、总会在项目里反复踩坑的动作,固化成默认动作。

也就是一句话:

Skill 的本质,不是收藏经验,而是固化默认动作。

这篇文章会从最基本的边界讲起,一路走到 SKILL.md、源码机制、任务类型和可执行能力。内容不少,但我尽量只保留真正有助于你在项目里把 Skill 用起来的部分。


先说结论

如果你只记住这几条,这篇文章就已经值回时间:

  • 对所有任务都生效的规则,写进 CLAUDE.md;只对某类重复任务生效的,做成 Skill;只对这一次有效的,写进 Prompt
  • 只有“输入相对稳定、输出有模式、而且容易漏步骤”的任务,才值得沉淀为 Skill
  • 通用 Skill 模板只能当原材料,项目级 Skill 必须自己裁剪、自己维护
  • description 不是装饰字段,它承担了触发场景的职责,最好把 Use when... 直接写进去,关键词前置、长度克制
  • Claude 启动时主要只看 frontmatter,Skill 正文在真正触发时才按需载入
  • allowed-tools 是权限边界,不是行为建议;paths 是条件激活,不是说明文字
  • 第一个 Skill 不要挑最关键的任务,先拿中等风险任务练手

一、公开 Skill 模板为什么一开始很香,后来却越用越别扭

我现在反而会对“看起来很全”的公开 Skill 模板保持一点警惕。

不是因为它们没用,而是因为它们太容易制造一种错觉:好像什么都覆盖到了,但真正最重要的东西其实没进去。

公开模板最常见的问题,不是方向错,而是下面这三种。

1. 太宽泛

它什么都管一点,但什么都不够深。

它会告诉你“注意异常处理”“注意性能”“注意安全”,这些当然没错。但这些话本身不构成你项目里的工作流。它不知道你们统一用的是 AppError,不知道你们数据库变更必须检查回滚,也不知道你们哪几个目录历史包袱最重。

2. 太嘈杂

50 行模板里,真正有价值的可能只有 5 行。

剩下的 45 行不是完全没用,而是在和那 5 行争夺 Claude 的注意力。对于 agent 来说,规则不是越多越强。很多时候,8 行写透项目约束的 Skill,比 50 行“样样都提一点”的模板更有用。

3. 太不像你的项目

这点最致命。

公开模板知道“大家普遍应该注意什么”,但不知道“你们团队反复死在哪些地方”。而真正有价值的 Skill,恰恰应该把那些项目特有、团队高频踩坑的东西固化下来。

说得更直白一点:你把一个新同事扔进团队,给他一份行业通用培训材料,当然比什么都不给强;但如果你不告诉他“我们团队最容易出错的是哪三件事”,他依然干不好你最在意的活。

所以正确姿势不是“找一个最全的模板直接用”,而是:

先借鉴,再裁剪,最后只留下真正属于你项目的那几条。

公开模板到项目 Skill 的提炼路径


二、先把 Prompt、CLAUDE.md、Skill 这三件事彻底分清楚

很多人不是不会写 Skill,而是一开始就把这三件事混在一起了。

判断方法其实很简单,只问一个问题:

这个要求的作用范围到底有多大?

  • 这个要求对所有任务都成立吗?如果是,放 CLAUDE.md
  • 这个要求只对某一类任务成立吗?如果是,做成 Skill
  • 这个要求只对这一次成立吗?如果是,写进 Prompt

举几个特别典型的例子:

“所有 throw 必须是 AppError
这是全局规则。不管你是在写新功能、修 bug,还是做重构,都要遵守。它应该进 CLAUDE.md

“代码审查时按固定顺序检查数据库、异步和错误处理”
这只在 code review 这种任务里才触发,它不是全局规则,而是任务模板,所以应该做成 Skill

“这次先只分析原因,不要动代码”
这只对当前这次任务有效,应该写进 Prompt

最容易搞混的是 CLAUDE.mdSkill。它们都能约束 Claude 的行为,但本质完全不同:

  • CLAUDE.md 是永远生效的规则
  • Skill 是遇到对应任务才触发的模板

如果要打个比方:

  • CLAUDE.md 是交通规则
  • Skill 是导航路线
  • Prompt 是你这次上车前临时交代的一句话

这三层一旦分清楚,后面 80% 的混乱都会自动消失。

Prompt、CLAUDE.md、Skill 的边界图

一个常见误判:很多问题根本不需要写 Skill

我后来发现,很多人想写 Skill,并不是因为真的存在一个稳定、重复、值得沉淀的任务,而是因为这一次和 Claude 协作得不顺

比如目标没说清,边界没收紧,上下文没给够,或者你真正缺的是一条全局规则,却误以为自己需要一份任务模板。这个时候你如果急着把它沉淀成 Skill,本质上只是把一次性的混乱模板化。

几个很常见的误判场景是:

  • 这次需求本身还在摇摆,连你自己都没想清楚要什么
  • 这个问题只发生过一次,下次未必还会以同样的形状出现
  • 你真正缺的是全局约定,比如错误处理、目录规范、命名规则
  • 你只是想表达“这次先别改代码”“这次先只分析原因”这种一次性约束

写 Skill 之前,先问自己一句话:

这个问题下次还会以差不多的形状再来一次吗?如果不会,先别急着写 Skill。


三、什么时候一个任务真的值得被沉淀成 Skill

不是所有重复任务都值得沉淀。

我现在给自己的标准其实很克制,就一句话:

同一类任务做了三次以上,而且每次都要重新给 Claude 解释背景。

反过来说,如果某个任务每次背景和目的都完全不同,就不值得沉淀。比如“写文档”这个动作本身很常见,但公司文档、API 文档、用户手册的写法完全不同,它们应该是三个不同的 Skill,而不是一个叫“写文档”的通用模板。

在真正开始写之前,我会先做三个检查。

1. 输入是否稳定

“根据 Figma 设计稿生成 React 组件”这种任务,输入格式相对稳定,比较适合沉淀。

“根据 SQL 查询结果生成图表”这种任务,每次数据格式和图表类型都可能差很多,Skill 会很难写得稳。

2. 输出是否有共同模式

“写 Pull Request 描述”很适合,因为它天然就有固定框架:改了什么、为什么改、怎么测试。

但“和 AI 讨论技术方案”这种任务,每次深度、重点、结论都不同,就不太适合硬沉淀成一个模板。

3. 有没有容易漏掉的关键步骤

最值得沉淀成 Skill 的任务,通常不是“最复杂”的任务,而是那些不特别提醒就容易漏一步的任务。

Skill 最有价值的地方,不是让 Claude 变得更聪明,而是把你每次最容易忘的检查项,固化成默认动作。

所以一个任务如果同时满足下面三点:

  • 输入相对稳定
  • 输出有共同模式
  • 总有一两步容易漏

它就很值得沉淀成 Skill。

什么任务值得沉淀成 Skill


四、从一个真实痛点开始,走完 Skill 的提炼过程

光讲判断标准还是有点抽象,不如走一遍完整例子。

代码审查,几乎每个后端工程师每周都在做,也是最容易进入“重复解释”困境的任务。用它来走一遍完整的 Skill 提炼过程会很清楚。

你反复踩的坑

假设你们团队每周都做代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

先设计内容,再去想格式

一个好 Skill,先别急着写文件。先把内容层想清楚,只要回答四个问题:

1. 什么时候用

不是写“代码审查”四个字,而是写清楚触发场景。

❌ 代码审查
✅ 当我提交 PR 前,检查我的 TypeScript 后端代码是否符合项目约定

差别在于:模糊的描述会让 Claude 在不该用的时候乱触发,而具体的场景描述更容易精准命中。

2. 按什么顺序做

步骤尽量不要超过五步。

你从公开模板里借灵感,但通用模板有 50 行,而你真正关心的可能只有四件事:改动范围、错误处理、异步操作、数据库查询。

1. 读完整个改动的 diff,确认改动是否只涉及这个 PR 的范围
2. 检查错误处理:所有 throw 都必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题,关键查询是否 explain 过

3. 输出长什么样

不要写“请清晰输出”。这种话几乎没有约束力。直接给格式。

🔴 Critical: ...
🟡 Warning: ...
✅ Suggestion: ...
Summary: X critical issues to fix before merge.

4. 什么时候不适用

写清楚边界比写清楚功能更重要。

比如:

  • 不审查 UI 层代码
  • 不关注代码风格
  • 改动超过 500 行先拆 PR

这些“我不做什么”的声明,往往比“我会做什么”更能防止 Claude 越界。

到这里,你脑子里其实已经有一个能用的 Skill 了。下一步只是把它放进 Claude Code 认识的格式里。


五、真正落到 SKILL.md 文件层,哪些字段值得你认真写

一个完整的 SKILL.md,通常会长这样:

---
name: code-review
description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
arguments:
  - target
---

# Code Review

## 步骤
1. 读取 ${target} 的改动 diff
2. 检查错误处理:所有 throw 必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题

## 输出格式
🔴 Critical: ...
🟡 Warning: ...
✅ 通过: ...

这里最值得你认真写的,其实是下面几个字段。

name
名字别太抽象。要让人一眼知道它是做什么的。像 helperutilstools 这种名字几乎没有路由价值,远不如 code-reviewpr-summaryapi-conventions 这种具体命名。

description
这是现在最关键的字段。它不只是“简介”,还承担了“触发场景”的职责。你最好直接把 Use when... 写进去,而不是写一句空话。更重要的是,别把它写成长段说明文。关键词尽量前置,长度最好控制在 250 字符左右,太长往往只会稀释命中信号。官方还特别提醒,description 最好用第三人称去写,像 “Analyzes pull requests...” 这种句式,比 “I can help...” 或 “You can use this...” 更稳。

allowed-tools
它决定这个 Skill 具备哪些能力。这个字段后面我会在源码部分展开讲,因为它比很多人想象的更“硬”。

arguments
让 Skill 接受参数,比如目标文件、目录、分支名。${target} 会在正文里被替换成你传进去的实际值;如果你喜欢按位置拿参数,也可以用 $0$1 这类方式。

还有几个很好用,但不是每次都要上的字段。

argument-hint
告诉调用者这个 Skill 期待什么参数。

model: haiku
简单任务可以指定更轻量的模型,直接省成本。像格式化、重命名、简单改写这类工作,很多时候没必要上更重的模型。

paths
让 Skill 只在某些路径下激活。适合模块边界明确的项目。

context: fork
高风险操作放进独立上下文,避免污染主会话。

disable-model-invocation: true
禁止 Claude 自动触发,只允许你手动 /skill-name 调用。部署、发版、发邮件这类有副作用的 Skill,应该优先考虑加上。

大多数 Skill 根本不需要把字段填满。真正实用的思路不是“功能全”,而是“正好够用”。

如果一个 Skill 只是做常规代码审查,namedescriptionallowed-toolsarguments 往往就够了。只有当你真的遇到参数化、模块隔离、上下文隔离这些需求时,再往上加。

SKILL.md 不是整个 Skill,它只是入口

很多人以为一个 Skill 就是一份 SKILL.md。其实不是。

更实用的做法通常是:把 SKILL.md 控制在足够短、足够清楚的范围里,让它承担“入口”和“调度”职责;真正长的规范、示例、脚本都拆出去。

一个 Skill 目录完全可以长这样:

my-skill/
├── SKILL.md
├── reference.md
├── examples/
│   └── sample.md
└── scripts/
    └── helper.py

这里的关键点不是“可以放很多文件”,而是:这些文件不会自动加载,必须在 SKILL.md 里显式引用。

比如:

## 参考资料
- 完整的 API 规范见 [reference.md](reference.md),需要查接口细节时读它
- 期望的输出格式见 [examples/sample.md](examples/sample.md)

这个设计和前面说的懒加载是同一套思路:不是 Skill 触发时把所有材料都灌进上下文,而是只在真正需要的时候再去读。

所以:

  • reference.md 适合放项目特有知识,比如内部 API 规范、禁用库、架构约定
  • examples/ 适合放期望输出样例,帮助 Claude 对齐格式
  • scripts/ 适合放真正可执行的辅助脚本,让 Skill 不只是“描述怎么做”,还能“先把上下文准备好”

这点很重要,因为它决定了 Skill 的上限不是“几行 prompt”,而是“一个有入口、有知识、有执行能力的局部工作流”。

Skill 放在哪,决定它是谁的能力

这点很容易被忽略,但工程上很重要。

同样是一个 Skill,放在不同位置,意义完全不一样:

  • ~/.claude/skills:你个人所有项目都能用,适合个人长期习惯
  • .claude/skills:只在当前项目生效,适合团队项目约定
  • <plugin>/skills:跟着插件走,适合做模块化分发

如果不同层级里恰好有同名 Skill,优先级也不是平均的。官方规则更接近:企业级配置优先于个人级,个人级优先于项目级;插件 Skill 因为带命名空间,通常不会和前面这些直接撞名。

官方文档里甚至把这件事讲得很直接:Skill 存放的位置,本身就是它的作用域设计。

这背后的工程含义非常大。

如果你把一个强项目耦合的 Skill 放进个人目录,它就会带着这个项目的假设跑到别的仓库里;反过来,如果你把一个本该跨项目复用的通用 Skill 只塞在项目目录里,它的复用价值又被锁死了。

在 monorepo 里,这件事更有意思。Claude Code 会自动发现子目录下的 .claude/skills/。也就是说,你完全可以让 packages/frontend/.claude/skills/ 只服务前端包,让 packages/backend/.claude/skills/ 只服务后端包,而不是把所有知识都堆在仓库根目录。

这时 Skill 就不只是“提示词文件”,而是团队知识的分发机制:

  • 个人层的 Skill,固化的是你的工作习惯
  • 项目层的 Skill,固化的是团队约定
  • 包级 Skill,固化的是模块边界里的局部知识

如果你能把这层想清楚,很多“这个规则到底该放哪”的问题,答案会比只看内容本身更清楚。


六、如果只停在经验层,这篇还差半口气:我后来去翻了源码

前面这些判断,靠经验其实也能总结出来。

但我后来还是不太满足。因为有几个问题如果不看实现,心里总会悬着:

  • description 到底是不是自动触发的关键?
  • allowed-tools 到底只是提示,还是硬限制?
  • paths 到底是真过滤,还是只是写给人看的说明?

我后来去翻了一遍源码,结论是:这些字段比我一开始以为的更“硬”。

1. 为什么触发逻辑主要看 frontmatter,而不是正文

loadSkillsDir.ts 里有一个函数 estimateSkillFrontmatterTokens,注释写得非常直接:

/**
 * Estimates token count for a skill based on frontmatter only
 * (name, description, whenToUse) since full content is only loaded on invocation.
 */
export function estimateSkillFrontmatterTokens(skill: Command): number {
  const frontmatterText = [skill.name, skill.description, skill.whenToUse]
    .filter(Boolean)
    .join(' ')
  return roughTokenCountEstimation(frontmatterText)
}

这段代码背后的意思非常重要。

Claude Code 启动时,主要只把每个 Skill 的 frontmatter 信息算进上下文。Skill 正文不是一开始就全量塞进去,而是在你真正触发它的时候才加载。

这直接解释了两件事。

第一,Claude 不是先把你整篇 Skill 读完再判断要不要触发,它先看的就是前面这几行。换句话说,触发效果主要取决于 frontmatter,不取决于正文写得多漂亮。

第二,Skill 多不等于上下文立刻爆炸,因为启动时压进去的不是全文,而是 frontmatter。

源码里保留了 whenToUse 这个概念,但从现在的文档实践看,推荐做法已经更偏向把触发描述直接写进 description。所以对大多数人来说,最稳的策略不是纠结“要不要额外写一个触发字段”,而是把 description 写得具体、可命中、带触发场景。

比如:

description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.

比“代码审查 Skill”这种描述强太多了。

源码注释里其实还暗含了一个很实用的提醒:触发描述不是越长越稳。冗长的 whenToUsedescription 不会线性提高命中率,很多时候只是在白白消耗首轮缓存和注意力。所以对这个字段最好的优化,不是“多写一点”,而是“把真正会命中的词放到前面”。

2. 为什么 allowed-tools 不是建议,而是权限边界

这一点是我看源码之后感受最强的一处。

Skill 执行时,getPromptForCommand 会在返回内容之前把 allowedTools 写进工具权限上下文:

getAppState() {
  const appState = toolUseContext.getAppState()
  return {
    ...appState,
    toolPermissionContext: {
      ...appState.toolPermissionContext,
      alwaysAllowRules: {
        ...appState.toolPermissionContext.alwaysAllowRules,
        command: allowedTools,
      },
    },
  }
}

这说明 allowed-tools 不是“提醒 Claude 尽量这样做”,而是权限层的强制限制。

比如一个 code review Skill 只开放 ReadGrepGlobBash(git diff *),那它就不是“理论上不该写文件”,而是从架构上根本没有写文件的能力Bash(git diff *) 这种写法也不是装饰,它真的只允许 git diff 开头的命令,其他 Bash 调用会被挡住。

这让我对 allowed-tools 的理解完全变了。它不是“不信任模型”,而是最小权限设计。就像你给数据库只读账号只开 SELECT 权限,不是因为你怀疑这账号会作恶,而是因为这个任务本来就不该拥有写权限。

3. 为什么 paths 不是说明文字,而是条件激活机制

源码里,带 paths 的 Skill 在加载时会被单独分流到一个 conditionalSkills Map:

// Separate conditional skills (with paths frontmatter) from unconditional ones
for (const skill of deduplicatedSkills) {
  if (skill.type === 'prompt' && skill.paths && skill.paths.length > 0
      && !activatedConditionalSkillNames.has(skill.name)) {
    newConditionalSkills.push(skill)
  } else {
    unconditionalSkills.push(skill)
  }
}

// Store conditional skills for later activation when matching files are touched
for (const skill of newConditionalSkills) {
  conditionalSkills.set(skill.name, skill)
}

// 最后只返回无条件的 Skill
return unconditionalSkills

这段逻辑的含义是:带 paths 的 Skill,根本不会像普通 Skill 一样直接进入启动时上下文。它会先待在一个“条件激活区”里,只有当你在会话里碰到了匹配路径的文件,它才会被真正激活。

这点对复杂项目非常有价值。

比如你给支付模块写一个 paths: src/payment/** 的 Skill,在你处理用户系统、文章系统、管理后台时,这个 Skill 对 Claude 几乎是隐身的。只有当你真的进入 src/payment/ 相关文件,它才“出现”。

这也是我现在很认同的一种团队实践:不要在根目录堆一个什么都想管的大 Skill 集合,而是让复杂模块在自己的目录附近维护自己的 Skill。

4. 为什么大型项目不该只在根目录维护一套总 Skill

还有一个很容易被忽略,但工程上非常实用的机制:Claude Code 会从当前文件所在目录一路向上寻找 .claude/skills

源码大概是这样:

// Walk up to cwd but NOT including cwd itself
while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude', 'skills')
  // ...check if exists, then load
  currentDir = dirname(currentDir)
}

// Sort by path depth (deepest first) so skills closer to the file take precedence
return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)

这里最关键的是最后一行:deepest first。也就是说,越靠近当前文件的 Skill,优先级越高。

这意味着你放在 src/auth/.claude/skills/ 里的 Skill,可以自然覆盖根目录下更通用的同名 Skill。对 monorepo 或大仓库来说,这个机制非常好用:

  • packages/api/.claude/skills/ 可以放 API 专属 Skill
  • packages/web/.claude/skills/ 可以放前端专属 Skill
  • 根目录只保留真正的全局规则

如果把上面四点放在一起看,设计 Skill 的顺序其实会变得很清楚:

  • 先把 frontmatter 写准,再去打磨正文步骤
  • 先按最小权限收紧 allowed-tools,再考虑要不要给更多能力
  • 只有模块边界明确时再上 paths,不要为了“高级”硬加
  • 多目录项目优先做“离代码更近”的局部 Skill,而不是维护一个大而全的总模板

5. 为什么长对话里,Skill 不会轻易“失忆”

还有一个很多人会担心的问题:会话一长、上下文一压缩,前面调过的 Skill 会不会就悄悄失效了?

从实现思路看,Claude Code 不是简单把它们扔掉,而是会把最近调用过的 Skill 重新注入压缩后的上下文。工程上你可以把它理解成:Skill 不是“一次触发完就全靠模型自己记住”,而是一个可以被系统再次带回来的工作单元。

当然,这也不是说你可以无限制地把 Skill 写成超长文档。实现上会有保留预算,比如最近调用的 Skill 只会保留前一段核心内容,而不是把所有正文永久塞在上下文里。所以前面那条原则依然成立:把 frontmatter 写准,把正文写短,把真正长的材料拆到 reference 或脚本里。

Skill 运行机制与条件激活


七、不是所有 Skill 都应该让 Claude 自动触发

到这里,其实已经够你写出一个基础可用的 Skill 了。

但如果你真的准备在项目里长期用,接下来有一个问题迟早会遇到:

这个 Skill 到底应该让 Claude 自动触发,还是只能我手动触发?

这背后其实对应两种完全不同的 Skill。

1. 参考型 Skill:给 Claude 补充背景知识

这类 Skill 的作用不是“执行一个任务”,而是“把某个项目知识注入到当前工作里”。

比如 API 设计规范、错误处理约定、数据库命名规则。这些东西你希望 Claude 在写代码、改代码、review 代码时,只要场景合适就自动想起来。

这类 Skill 的特点是:

  • 倾向自动触发
  • 内容会留在主对话上下文里
  • 更像“局部规范”而不是“独立任务”

比如:

---
name: api-conventions
description: API design patterns and conventions for this codebase. Use when writing or reviewing API endpoints.
---

响应格式统一用 { success, data, timestamp }
禁止在 controller 层直接写 SQL,通过 service 层操作
所有异步函数必须有 try-catch,错误统一 throw new AppError()

2. 任务型 Skill:给 Claude 一个要完成的动作

这类 Skill 有明确边界,通常还可能带副作用。

比如 /deploy/send-release-email/prepare-release-notes/migrate-db。这类 Skill 更像一段可执行流程,而不是知识注入。

这类 Skill 的特点是:

  • 通常应该手动触发
  • 有副作用时最好加 disable-model-invocation: true
  • 有风险时再配 context: fork

比如:

---
name: deploy
description: Deploy the application to production. Manual trigger only.
context: fork
disable-model-invocation: true
allowed-tools:
  - Bash(npm run test)
  - Bash(npm run build)
  - Bash(git push *)
---

1. 跑完整测试:npm run test
2. 确认测试全绿后构建:npm run build
3. 推送到部署分支
4. 等待 CI 完成并检查健康状态

这里的关键不是字段多了,而是触发权变了。

你真正要想清楚的问题是:

这件事我愿不愿意让 Claude 自己判断“现在该触发了”?

如果答案是“不愿意”,那就不要让它自动触发。

3. disable-model-invocation: trueuser-invocable: false 不是一回事

这是一个很容易混淆,但又非常关键的区别。

默认情况下,你和 Claude 都可以调用一个 Skill。你可以手动 /skill-name,Claude 也可以在觉得合适时自动加载它。

但很多人会把下面两个字段混为一谈:

  • disable-model-invocation: true
  • user-invocable: false

它们看起来都像“限制调用”,其实限制的是两件完全不同的事。

disable-model-invocation: true 的意思是:Claude 不能自己触发,只有你能手动触发。 这类 Skill 适合部署、发版、发邮件、推送消息这类你必须自己掌握时机的动作。更重要的是,它还会把这个 Skill 的描述从 Claude 的常驻上下文里拿掉,平时的上下文成本直接归零,只有你手动调用时才完整加载。

user-invocable: false 的意思则是:你不能从 / 菜单里把它当命令来点,但 Claude 仍然可以在合适时自动用它。 这类 Skill 更适合背景知识,比如老系统架构、内部缩写、遗留约定。这些东西你希望 Claude 在相关任务里自动想起来,但你并不需要一个显眼的 /legacy-context 命令天天挂在菜单里。

所以更准确的判断应该是:

  • 有副作用、要你亲自控制时机:disable-model-invocation: true
  • 只是背景知识、不适合被人手动当命令点:user-invocable: false
  • 想限制 Claude 到底能不能调用某些 Skill:去配权限规则,而不是只盯菜单显示

这组区别值得写进脑子里,因为它直接决定了 Skill 的“触发权”到底属于谁。

参考型 Skill 与任务型 Skill


八、Skill 的天花板:从静态模板到可执行能力

前面几节讲的,主要还是“怎么把经验写成一个好模板”。但如果 Skill 只能放静态文字,它的上限其实并不高。

真实项目里,很多任务依赖的是实时信息:当前 PR 的 diff、评论区讨论、今天的测试结果、最新 schema、CI 状态。你当然可以在 Skill 里写“先去看这些东西”,但这样一来,最关键的一步又变回 Claude 自己先兜一圈去找。

Claude Code 里真正更有意思的地方是:Skill 不只是 prompt 模板,它还可以在触发瞬间先准备上下文。

1. 动态注入:先拿真实数据,再交给 Claude

比如你要做一个 PR 总结 Skill,不一定要让 Claude 先自己去猜该看哪些信息,你可以直接让 Skill 触发时先把它们准备好:

---
name: pr-summary
description: Summarize the current pull request. Use when asked to review or summarize a PR.
context: fork
allowed-tools:
  - Bash(gh *)
---

## 当前 PR 信息
- diff:!`gh pr diff`
- 评论区讨论:!`gh pr view --comments`
- 涉及文件:!`gh pr diff --name-only`

## 你的任务
基于以上真实数据,总结这个 PR 做了什么、为什么改、有哪些潜在风险。

这个 Skill 真正触发时,前面的命令会先执行,输出直接注入到 prompt 里。Claude 拿到的不是“请你去看看 PR”这种模糊要求,而是已经准备好的真实上下文。

这也是为什么前面说 scripts/ 不是装饰。如果一个 Skill 需要先查状态、先取数、先做一轮预处理,那它就不再只是“告诉 Claude 怎么做”,而是在执行前把材料也一起备好了。更复杂一点时,你完全可以把逻辑放进 scripts/,再通过 CLAUDE_SKILL_DIR 去调用目录里的脚本,让 Skill 触发时先跑一轮取数或整理。

2. 这件事为什么重要:它决定了 Skill 的上限

一旦你理解了动态注入这层能力,就会发现 Skill 的上限根本不只是“几行 prompt”。

它可以同时承担三件事:

  • 定义触发条件
  • 限制工具权限
  • 在执行前准备实时上下文

换句话说,Skill 不是只能做静态模板,它完全可以长成一个带入口、带约束、还能主动取数的局部能力单元。

不要只把 Skill 当成“写给模型的一段话”,而要把它当成“一个局部工作流的入口”。

3. 从架构位置看,Skill 不是 Tool,也不是 Agent

如果再往上抽一层,我现在对 Skill 的理解是:

  • Tool 是原子能力
  • Skill 是任务知识和操作规约
  • Agent 是执行与编排单元

这个分层不一定是官方唯一表述,但作为工程心智模型非常有用。因为一旦把 Skill 放到 Tool 和 Agent 中间去理解,很多判断都会顺下来:

  • 该写成脚本的,别硬写进 Skill 正文
  • 该放进 CLAUDE.md 的全局规则,别误沉淀成任务 Skill
  • 该拆给 SubAgent 的复杂协作,别硬让一个 Skill 扛完

Skill 真正做的事,不是替代 Tool,也不是替代 Agent,而是把“什么场景下、按什么步骤、调用哪些能力”组织成可复用的任务单元。


九、真正让 Skill 变靠谱的,不是写出来,而是验证出来

这是我觉得官方 best practices 里最容易被忽略、但最能拉开水平差距的一点。

很多人写 Skill 的方式是:先凭感觉写一版,再上项目里试试。这样当然也能跑,但它很容易陷进一种错觉里: 你以为自己在“优化 Skill”,其实只是一直在补想象中的问题。

官方更推荐的思路其实更工程化:

先准备评测样例,再写 Skill。

最轻量、也最实用的做法,是先找出三个真实场景:

  • 一个 Claude 原本就能做得不错的
  • 一个 Claude 容易漏步骤的
  • 一个 Claude 容易理解偏、触发错或者输出不稳的

先不用 Skill 跑一遍,记录基线。看它具体错在哪:

  • 是没想到要用这个 Skill
  • 是触发了,但没读对参考资料
  • 是步骤顺序不稳
  • 是输出格式飘了
  • 是该脚本处理的确定性工作,被它自己“猜”过去了

然后再写最小版本的 Skill,只补刚才暴露出来的那几个缺口,而不是一上来把所有可能性都写满。

把这个过程压缩成一句话,其实就是:

  1. 先找失败样例
  2. 再写最小 Skill
  3. 再看它是不是真的修掉了失败样例

如果三轮下来都没有明显提升,那大概率不是 Skill 写得不够多,而是这个问题根本不该沉淀成 Skill。

不要只看结果,还要看 Claude 是怎么“导航”这个 Skill 的

只看最终结果对不对还不够,更重要的是看 Claude 在过程中到底是怎么用这个 Skill 的。

官方专门建议观察 Claude 实际怎么使用 Skill,而不是只看最终结果对不对。比如:

  • 它是不是总在错误顺序里读文件
  • 它是不是老是忽略某个引用文件
  • 它是不是每次都反复读同一个 reference,那这部分也许该往 SKILL.md 主体里提
  • 某个 examples 文件是不是从来没被读过,那它可能根本没价值,或者信号太弱

这些观察特别重要,因为它能直接反推出信息架构是不是合理。

我现在会把一个 Skill 是否成熟,简单看成四个问题:

  • 会不会在该触发的时候触发
  • 触发后会不会按预期去读材料
  • 执行过程会不会漏掉关键步骤
  • 输出结果能不能稳定复现

如果你团队里会混用不同模型,官方还建议至少跨你计划使用的模型测一遍。不是因为所有模型都得兼容,而是因为有些 Skill 对提示强度和结构依赖更高,换模型后会暴露出你原来没看到的问题。

说到底,Skill 的成熟度,不是靠“我觉得写得挺全”来判断,而是靠一组真实任务能不能稳定跑通来判断。


十、写 Skill 时,最常见的四种设计模式,以及最容易踩的反模式

这里先说清楚:下面这四种名字,不是官方文档逐字给出的固定术语,而是我结合 Anthropic 官方 best practices 做的工程化归纳。

但它们确实能覆盖大多数项目里真正会遇到的 Skill 设计问题。

1. 模板驱动模式:解决“输出不稳定”

这类 Skill 的核心不是让 Claude 更会想,而是让它更稳定地按结构输出。

适合:

  • 周报
  • PR 描述
  • 事故复盘
  • 评审报告

它的关键不是“给一个模板”,而是把模板当成输出接口。模板负责格式,SKILL.md 负责路由和规则。模板里不要塞判断逻辑,也不要把一个模板写成一套小程序。

如果一个模板已经长到一百多行,而且开始出现大量条件分支,通常不是你模板写得认真,而是职责已经混了。

2. 脚本增强模式:解决“结果不稳定”

这类 Skill 的核心是:确定性的事交给脚本,不要交给模型猜。

适合:

  • 指标统计
  • CSV 解析
  • 正则匹配
  • Git / PR / CI 状态抓取
  • 需要预处理的上下文准备

官方对这件事的说法很直白:solve, don't punt。能脚本算出来的,就别只写一句“请 Claude 自行分析”。

这类 Skill 真正提高的,不是文风,而是可靠性。你把概率型推理替换成确定性执行,整个 Skill 的下限会明显抬高。

3. 知识分层模式:解决“上下文过载”

这是官方反复强调的 progressive disclosure 思路。

也就是:SKILL.md 只做入口和导航,把大块知识拆进 reference/examples/forms/ 这类文件里,按需读取,而不是一次灌满。

这类模式适合:

  • 领域知识很多的 Skill
  • 不同子领域差异很大的 Skill
  • 需要高级用法、边界情况、案例库的 Skill

它的关键不是“拆文件”,而是“拆得有层次”。官方明确不建议深层嵌套引用。最稳的做法是所有重要材料都从 SKILL.md 一层直达,别让 Claude 从 advanced.md 再跳 details.md 才看到真正关键信息。

4. 工具隔离模式:解决“能力边界失控”

这类模式最容易被低估。

很多人写 Skill 时只关注“让它能做事”,但真正稳定的 Skill 往往同样重视“让它不能乱做事”。

适合:

  • 部署
  • 发版
  • 写数据库迁移
  • 调外部系统
  • 会改文件、发消息、推远端的任务

这一类 Skill 的核心组合通常是:

  • allowed-tools 收到最小
  • 必要时配 context: fork
  • 不希望自动触发时加 disable-model-invocation: true

它不是在限制 Claude 的创造力,而是在设计这个能力包的安全边界。

最常见的反模式

如果把前面四种模式反过来看,最常见的坑基本也就集中在下面这些地方:

  • 一个 Skill 管太多事,什么都想覆盖,最后什么都不够准
  • description 写得很空,只写“处理文档”“帮助分析”这种谁都能套上的话
  • 一上来给一堆方案,不给默认路径,让 Claude 自己从五六种做法里摇摆
  • 模板里写判断逻辑,或者把本该脚本做的事丢给模型推理
  • reference 层级太深,真正关键信息藏在第二跳、第三跳文件里
  • 把会过期的信息硬写进 Skill,比如时间敏感规则、旧接口切换说明
  • 默认假设工具和依赖都已经装好,结果一跑就断
  • 在路径里写 Windows 反斜杠,跨环境直接出问题

你会发现,所谓反模式,本质上就是一句话:

该约束的地方没约束,该拆开的地方没拆开,该交给脚本的地方还在让模型猜。


十一、完整案例:把一个通用 code review 模板,提炼成你项目真正需要的 Skill

上面说了这么多抽象原则,不如走一遍完整例子。

假设你们团队每周都做后端代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

第一步:先确认痛点到底是什么

这一步别着急写模板,先把“你们到底在反复出什么问题”说清楚。

很多团队的问题不是“没有 code review”,而是每次 review 的注意力都被分散了。真正高频出错的点,永远是那几类项目特有的约束。

所以要沉淀的不是“代码审查”这四个字,而是你们团队在代码审查里最容易漏掉的那几类检查。

第二步:从公开模板里提取真正有用的部分

这时候公开模板就有用了,但它的用途不是直接上生产,而是当素材库。

假设你找到一个 50 行的通用 code review 模板。你真正该提取的,可能只有下面这几类东西:

  • 逻辑正确性,尤其是异步操作
  • 项目约定的遵守,比如 AppError、错误处理模式
  • 数据库相关的风险,比如 N+1、索引、查询范围
  • 改动范围是否聚焦,不要顺手改不相干文件

剩下那些跟你们项目关系不大的部分,就应该果断删掉。

第三步:把它压缩成一个真正能用的 Skill

最后落地出来的 Skill,应该更像这样:

---
name: code-review
description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
---

# Code Review Skill

## Steps
1. 读 $ARGUMENTS 的改动 diff,确认改动是否只涉及这个 PR 的范围(不要顺手改无关文件)
2. 检查错误处理:所有 throw 都必须是 throw new AppError(),不能 throw new Error()
3. 检查异步操作:Promise 链是否有遗漏的 await,错误是否被正确 catch
4. 检查数据库查询:是否有 SELECT * 的懒惰写法,是否明显的 N+1 查询,关键查询是否 explain 过

## Output Format
Issues found (Critical → Warning → Info):

🔴 **Line 45**: Missing `.select()` in Prisma query - this will fetch unnecessary columns
🟡 **Line 67**: Potential N+1: loop inside `posts.map()` should use `Promise.all()`**No AppError violations** — all errors properly handled

Summary: 1 critical issue to fix before merge.

## Caveats
- 不审查 UI 层代码(只关心后端逻辑)
- 不关注代码风格(那是 prettier 的事)
- 如果一个改动涉及多个不相干功能,分别提交 PR 再 review

你会发现,到这一步之后,Skill 就不再是“通用模板的中文版”了,而是你们项目真正有用的一个局部工作流。

50 行公开模板,最后可能只剩下 4 个真正属于你项目的核心关注点。但恰恰是这 4 个点,才决定它到底值不值得用。

第四步:在真实使用里继续迭代

Skill 从来不是一次写完的。

比如你用了两周之后,又发现一个常见问题:改了数据库 schema,但忘记更新 Prisma 类型。那就把它加进去:

4.5. 检查 Prisma 类型:如果改了数据库,Prisma schema 和生成的 types 是否都已更新

这时候你会发现,Skill 真正的价值不是“第一次写出来”,而是在真实工作里被持续打磨。


十二、Skill 的维护节奏,比第一次写出来更重要

Skill 不是写好就扔。

如果你写完之后三个月不看,它很快就会从“项目经验”重新退化成“历史遗留文档”。

我更推荐一个更贴近实际的轻量节奏:

前一两周
高频使用,快速迭代。每次用完就问自己三个问题:步骤是不是太复杂?输出是不是太啰嗦?有没有漏掉今天刚踩到的新坑?

稳定之后每周一次
回顾一次。看看最近有没有经常被遗漏的步骤,有没有新的痛点需要加入。对 Skill 这种高频小迭代的东西来说,按周看通常比按月看更合适。

每个月做一次清理
把已经不再是问题的注意事项删掉,把那些已经变成全局共识的规则移进 CLAUDE.md。这一步的重点不是加内容,而是防止 Skill 越写越胖。

Skill 应该越用越精炼,而不是越写越臃肿。


十三、一个特别反直觉,但很重要的经验:第一个 Skill,故意别写最重要的任务

这条我非常想单独拿出来讲。

因为很多人第一次沉淀 Skill,会本能地想挑一个最关键的任务,比如“生产环境发布前检查”“数据库迁移前审查”“支付流程改动 review”。

但工程上更稳的做法,其实正好相反。

大多数人写的第一个 Skill,质量都不会太高。触发条件偏模糊,步骤偏啰嗦,输出格式也不够稳定。这很正常,因为你第一次做这件事时,对“什么是好的 Skill”还没有直觉。

如果你一开始就把它用在最关键的任务上,一旦写得不够好,伤害会非常直接:要么关键场合出问题,要么你从此对这套机制失去信心。

更好的策略是:先拿一个重要程度中等、容错率比较高的任务练手。

比如:

  • 写周报
  • 生成 PR 描述
  • 做一次常规 code review

先跑两周,迭代两三次,等你对 Skill 的节奏有感觉了,再去沉淀真正关键的流程。

第一个 Skill 的目的,不是直接解决最大的问题,而是让你学会怎么写 Skill。


十四、如果你今天就想开始,可以直接做这三个动作

别先想着搭一整套系统,直接从最小动作开始:

任务一: 列出你最近一个月里重复做过三次以上的任务。用第一节的判断法(对所有任务成立?→ CLAUDE.md;只对某类任务成立?→ Skill;只对这次成立?→ Prompt),确认你要处理的是 Skill 还是 CLAUDE.md 的事。

任务二: 为这个任务写一个 Skill。先想清楚四个问题(什么时候用、按什么顺序、输出长什么样、什么时候不适用),然后包装成 SKILL.md。目标是精炼——大多数好 Skill 的有效指令不超过 10 行。记住:第一次的目的是练手,不是写出完美的 Skill。

任务三: 选三个你最近真的遇到过的场景,先不用 Skill 跑一遍,再用 Skill 跑一遍,对比它到底有没有少漏步骤、少返工、少补充解释。然后在 SKILL.md 里补一行今天发现的问题(哪个步骤漏了,或者哪个注意事项需要加)。这就是 Skill 的维护节奏,也是最轻量的评测方法。

真正好的 Skill,几乎都不是第一次就写对的,而是在实际使用里慢慢长出来的。


下篇预告

第 06 篇:Sub-agents 实战——什么时候应该拆任务,怎么设计子任务边界

单个 Claude 实例有上下文上限,复杂任务拆成多个子任务让 Sub-agents 并行处理,理论上能大幅提速。但什么时候值得拆?拆错了会有什么代价?下一篇会拆开 Sub-agents 的真实适用场景,以及最常见的过度设计陷阱。


写在最后

公开 Skill 模板当然有用,但它的价值更像脚手架,而不是成品。

你不需要一个很复杂的 Skill 系统。你需要的,往往只是把团队最容易反复犯错的那几件事提前写下来,让 Claude 每次都替你盯住。

如果你现在想动手,就直接做这三步:列出最近一个月里重复做过三次以上的任务;用"三问法"确认它该放 CLAUDE.mdSkill 还是 Prompt;先写一个只有 5 到 10 行的版本,实际用一次,再立刻改第一轮。不要追求第一版完美——Skill 的价值,从来不是"写出来的那一刻",而是"它开始帮你减少重复错误的那一天"。

评论区想聊一个问题:你现在最卡的,到底是"写不出规则",还是"没分清哪些该放 CLAUDE.md、哪些该做成 Skill"? 这两个问题看起来很像,但解法完全不同。

觉得这篇有帮助,点个赞让更多工程师看到 👍


AI Coding 系列持续更新。用别人的 Skill 模板是起点,不是终点。真正管用的 Skill,只有你自己的项目才能提炼出来。

硅基同事埋的坑,我用2小时才填平:Nuxt 4 路由踩坑:可选参数 [[id]] 与 [id] 的区别

个人网站

在开发博客系统时,遇到了一个路由不生效的问题:/section 可以访问,但 /section/id 却始终无法匹配。折腾了一番后发现是 Nuxt 文件路由的可选参数语法理解有误。

问题背景

需求很简单:

  • /blog → 显示博客列表,默认选中第一篇文章
  • /blog/kubernetes-1-32-release → 显示博客列表,选中指定文章

最初创建了两个路由文件:

pages/
├── [section].vue        # 匹配 /blog
└── [section]/
    └── [id].vue         # 匹配 /blog/xxx

结果:/blog 正常,/blog/kubernetes-1-32-release 却始终匹配不上。

问题原因

最初采用了两个独立文件的方式:

pages/
├── [section].vue        # 匹配 /blog
└── [section]/
    └── [id].vue         # 匹配 /blog/xxx

这种结构看似合理,实则存在多个问题:

  1. 代码重复:两个文件 95% 代码相同,维护成本高
  2. 状态同步:需要额外处理跨页面状态共享
  3. 路由匹配:某些情况下 Nuxt 无法正确区分两个路由,导致 /blog/xxx 匹配失败

解决方案

Nuxt 提供了可选路由参数语法 —— 双括号 [[param]],用一个文件同时处理两种情况:

pages/
└── [section]/
    └── [[id]].vue    # 同时匹配 /blog 和 /blog/xxx

核心区别

语法 含义 匹配示例
[id] 必需参数 /blog/abc ✅ / /blog
[[id]] 可选参数 /blog/abc ✅ / /blog

[[id]] 是 Nuxt/Vue Router 的特殊语法,表示该参数可以存在也可以不存在

实现方案

合并后的 [[id]].vue

<script setup>
const route = useRoute()
const section = route.params.section
const articleId = route.params.id  // 可能为 undefined

// 有 id 用 id,没有则用 firstArticleId
const activeArticleId = ref(articleId || firstArticleId)

// 监听路由变化(SPA 导航时更新)
watch(() => route.params.id, (newId) => {
  if (newId) activeArticleId.value = newId
})

// 点击文章时更新 URL
const handleSelectArticle = (id) => {
  activeArticleId.value = id
  navigateTo(`/${section}/${id}`, { replace: true })
}
</script>

关键点

  1. route.params.id 可能为 undefined:需要提供默认值
  2. 添加路由监听:SPA 内导航时 URL 变化不会重新执行 setup,需要 watch
  3. navigateTo 更新 URL:选中文章时同步 URL,支持分享和书签

调试技巧

在排查过程中,发现 Nuxt 4 的 console.log 在 SSR 阶段可能被过滤。一个实用的做法是在 composable 中添加显眼前缀:

export function useContentArticles(section: string) {
  console.log('>>> [useContentArticles] section:', section)
  console.log('>>> [useContentArticles] cache keys:', Object.keys(sectionDataCache))
  // ...
}

终端输出:

>>> [useContentArticles] section: blog
>>> [useContentArticles] cache keys: [ 'blog', 'interview', 'nuxt4' ]

总结

场景 推荐方案
单一页面 + 可选子路径 [[id]].vue
完全不同的两个页面 分开两个文件
参数必需 [id].vue

可选参数 [[param]] 是 Nuxt 文件路由的利器,用好了可以大幅减少代码重复。但要注意处理 undefined 的情况和路由变化的监听。

延伸阅读
nuxt4完整系列,持续更新中...

内容有帮助?点赞、收藏、关注三连!评论区等你 💪

CSS 技巧:CSS 单位使用指南

写完《现代 CSS 中的相对单位》这节课之后,我原本以为大家会带着一种“原来如此”的轻松感继续往下写代码。但很快,私下就有不同的同学问:为什么这里不用 px ?什么时候该用 remvw0% 到底有什么区别?移动端布局什么该用 rem 还是 vw ?这些问题不仅在一个地方出现,而是在不同平台、不同时间被反复提起。

于是,我决定单独用一节课,把这些零散的问题集中起来,好好聊一聊。我们不再孤立地看某一个单位,而是把 pxemrem% 、视窗单位(如 vwvh)、容器查询单位(如 cqwcqh)以及 exchlh 放在同一个语境下,重新理解它们的作用,以及更重要的——如何做出选择。

CSS 单位选择的本质:不是用什么,而是跟随谁变化

在实际开发中,很多人一开始都会本能地选择 px。它足够简单、直接,几乎不需要额外思考——设计稿量多少就写多少,浏览器呈现出来的结果也高度一致。这种“所见即所得”的确定性,在项目初期确实非常有吸引力。

但随着项目逐渐复杂,这种看似稳妥的选择,往往会开始暴露出问题。页面在不同设备上看起来似乎“差不多”,却总差那么一点协调感;字体系统变得难以统一,局部调整容易牵一发动全身;为了适配各种尺寸,不得不引入越来越多的媒体查询;而组件一旦脱离原有上下文,复用时也常常出现不可预期的表现。

很多人会因此怀疑,是不是一开始就不应该使用 px 。但从结果来看,问题并不在于某一个具体单位,而在于我们没有在一开始建立一套清晰的尺寸关系

这正是这节课真正要解决的核心问题。CSS 单位表面上是在描述“长度”,但它们更本质的作用,其实是在定义一种关系——元素的尺寸,应该跟随变化

当你写下一个值时,本质上是在做一次“绑定”。使用 px ,意味着它几乎不跟随外部环境变化;使用 % ,它会依赖父元素;使用 em ,它会响应当前元素的字体大小(font-size);使用 rem ,则是跟随根元素(html);视窗单位(如 vwvh)绑定的是浏览器的视窗,而 cqwcqh 这样的容器查询单位,则让元素直接跟随其所在的父容器。

也就是说,你并不是在选择“用哪种单位更好”,而是在决定这个元素应该“听谁的”。是跟随屏幕变化,还是跟随父级容器?是依赖全局排版系统,还是保持自身的稳定?一旦换一个角度去看,很多原本让人纠结的问题,其实会自然消失。

你会开始意识到,px 并不是错误,它只是选择了不响应变化;rem 也不是万能方案,它只是把变化集中在一个全局入口;vw 看起来很灵活,但有时会让组件失去边界感;而容器查询单位,则是在回答一个更现代的问题——组件是否可以只关心自己所在的空间,而不是整个页面。

接下来,我们不会从定义出发逐个讲解每个单位,而是先建立一套“尺寸关系模型”,再去分析每种单位适合绑定的对象,最后总结出一套可以直接应用在项目中的选择策略。目标不是让你记住所有单位的细节,而是让你在任何场景下,都能快速判断——这里的尺寸,究竟应该跟随谁变化

CSS 单位的尺寸关系模型

如果把前面所有零散的讨论收拢在一起,其实可以得到一个非常关键的结论:

CSS 单位并不是一堆彼此独立的“语法选项”,它们本质上构成了一整套尺寸依赖关系系统

一旦从这个角度去理解,就可以把所有单位放进一个统一的框架中来看待,也就是所谓的“CSS 单位的尺寸关系模型”。

这个模型的核心可以用一句话概括:每一个尺寸,本质上都是依附在某个参照物上的结果。 你写下的从来不是一个孤立的数值,而是一种“绑定关系”。也就是说,当你在写 CSS 时,你真正做的事情并不是定义一个长度,而是在决定这个长度应该“跟谁产生关系”。

基于这个视角,我们就可以把所有常见单位重新整理一遍。不再按语法分类,而是按照它们“依附谁”来划分。这样整理之后,会得到一个非常清晰的结构——一个由不同“关系层级”组成的尺寸模型

绝对关系:不跟随任何对象

在所有尺寸关系中,“绝对关系”是容易理解的一类,它几乎不依附于任何外部对象。典型代表就是 px 。当你使用 px 时,本质上传达的是一种明确的意图:这个尺寸应当保持稳定,不随着环境变化而改变。

这种特性让 px 在很多场景下非常有价值。比如边框、阴影、精细对齐等细节控制,都需要高度确定性和可预测性,这正是 px 的优势所在。它提供了一种接近“所见即所得”的体验,让你可以精确地掌握视觉结果。

但问题在于,一旦把这种“固定性”扩展到布局层面,比如用在容器尺寸、间距系统或整体排版上,就会开始削弱页面的弹性。页面难以自然适配不同设备,响应能力变差,后期往往需要额外的补丁来弥补。

所以,与其把 px 简单理解为“绝对单位”,不如换一个更本质的视角来看——它其实是一种主动拒绝建立关系的选择

全局关系:跟随根元素

在“在局关系”这一类中,所有尺寸都会统一依附于同一个源头——根元素(html)。最典型的代表就是 rem 。当你使用 rem 时,本质上传达的是一种明确的选择:这个尺寸不由局部环境决定,而是完全跟随全局系统

这种机制非常适合用于构建设计系统。无论是字体层级、间距体系,还是组件的基础尺寸,都可以基于 rem 来建立。一旦调整根元素的字号(font-size),整个页面就会按照既定比例整体缩放,从而实现一致且可控的响应效果。

不过,这种“全局绑定”也有一个前提:你必须建立清晰且合理的全局规则。如果没有这一步,rem 并不会减少复杂度,只是把原本分散在各处的问题,集中转移到了全局层面。换句话说,rem 并不是简化问题的工具,而是放大设计系统价值的工具。

局部关系:跟随当前上下文

“局部关系”这一类单位,强调的是尺寸与当前上下文之间的依赖关系,而不是与整个页面或全局系统的绑定。典型代表是 em% 。其中,em 相对于当前元素的字体大小(font-size),而 % 通常相对于父元素的尺寸计算。需要知道的是,% 是一个较为复杂的单位,它应用在不同属性值时,依附的参照物是不同的。

当你使用这类单位时,其实是在表达这样一种意图:这个元素的大小,不由全局决定,而是由它所处的环境来决定。换句话说,它就是“就地适应”的。

这种特性在组件内部尤其有用。例如按钮、卡片这类 UI 元素,其内边距、间距、子元素尺寸等,都可以随着组件自身的变化而自然缩放,从而形成更具弹性的结构。

不过,这种“局部依赖”也带来一个隐患:它是可以层层嵌套的。一旦组件结构变得复杂,多层 em% 叠加之后,最终的计算结果往往不再直观,甚至难以追踪。因此,在使用这类单位时,需要对层级关系保持足够的控制,避免无意中引入过深依赖链。

环境关系:跟随视口

“环境关系”这一类单位,直接把尺寸绑定到浏览器视口,也就是屏幕本身。典型代表是 vwvh ,以及 vminvmax 等。使用这些单位时,元素的尺寸应当随着屏幕大小按比例变化——屏幕多大,它就多大。

这种能力在很多场景中非常有用。比如全屏布局(如首屏视觉区域)、流式字号,以及一些强依赖视觉比例的设计,都可以通过视口单位获得非常自然的响应效果。

不过,一旦把这类单位用在组件内部,就容易引发问题。因为此时组件的尺寸不再由自身或其容器决定,而是直接被整个页面环境“接管”。结果就是,组件失去了应有的独立性,在不同布局或上下文中变得难以控制和复用。

容器关系:跟随父容器

“容器关系”代表的是一种更现代的尺寸绑定方式——元素不再依赖整个视口,而是直接依附于自身所在的父容器。典型单位是 cqwcqh 等容器查询单位。

当你使用这类单位时,这个元素的尺寸,不再由屏幕决定,而是由它当前所处的空间来决定。也就是说,它只关心“自己有多少可用空间”,而不是整个页面有多大。

这正好解决了传统响应式设计中一个核心限制——媒体查询只能基于视口进行判断,但在实际开发中,组件更关心的往往是自身容器的尺寸,而不是屏幕尺寸本身。

容器单位的出现,让响应式能力从“页面级”下沉到了“组件级”。组件不再依赖外部布局或全局断点,就可以根据自身环境自动调整,从而真正具备独立、自适应的能力。这也是现代 CSS 组件化设计中非常关键的一步。

内容关系:跟随文本与排版度量

“内容关系”这一类单位,关注的已不再是布局结构,而是文本本身的度量。典型代表包括 chexlhrlh 。与前几类不同,它们不依赖视口、容器或父元素,而是直接绑定到字体和排版系统之上。

当你使用这些单位时,其实是在表达一种更偏向内容驱动的设计思路。尺寸应当由文本本身来决定,而不是由外部布局强加。比如,ch 基于字符宽度,常用于控制理想的阅读行长;ex 反映字体的 x-heightlhrlh 则直接与行高(line-height)相关,用来建立稳定的垂直节奏。

这种方式的价值在于,它把“排版经验”转化成了“系统规则”。例如,用 60ch 控制段落宽度,可以自然得到更舒适的阅读长度;用 1lh 控制段落或模块之间的间距,可以让整体节奏始终保持 一致,而不需要反复微调具体数值。

从更高层来看,这类单位所代表的,是一种设计理念的转变:不是让内容去适配布局,而是让布局围绕内容生长

比例关系:无单位

这一类并不是在描述“跟随谁变化”,而是在定义变化本身。与前面所有单位不同,它不关心依附对象,而是直接规定变化的比例,因此,可以把它理解为一种“比例关系”。典型的例子包括:

line-height: 1.5;
flex: 1;
opacity: 0.8;

这些值有一个共同特点:它们表达的不是一个具体长度,而是一个倍数关系。换句话说,它们关注的不是“多大”,而是“多少倍”。以最常见的 line-height: 1.5 为例:

p {
    font-size: 16px;
    line-height: 1.5;
}

这里的 1.5 实际含义是:行高等于当前字体大小的 1.5 倍。如果字体大小从 16px 变成 20px,行高会自动变成 30px

关键在于,这种关系是“内嵌”的,而不是“引用”的。它不像 em 那样依赖字体作为单位,也不像 rem 依赖根元素,而是直接把比例规则写进属性本身。这种表达方式更直接,也更稳定。

正因为如此,在很多场景中,无单位数值反而是更推荐的写法,尤其是在排版中。相比固定的 24px,或者可能受嵌套影响的 1.5emline-height: 1.5 不依赖具体单位,不会产生层级累积问题,还能随着字体变化自动调整。同时,它的语义也更清晰——表达的是“阅读密度”,而不是一个具体尺寸。

这种“比例关系”其实在 CSS 中无处不在,并不仅仅局限于排版。在 Flex 布局中:

.item {
    flex: 1;
}

表达的是空间分配的比例,而不是具体宽度。

在 Grid 中,虽然 fr 看起来像单位,但本质上同样是一种比例系统:

grid-template-columns: 1fr 2fr;

表示的是 1:2 的空间分配关系。再比如透明度、变换等属性:

opacity: 0.5;
scale: 1.2;

同样是在描述相对变化,而不是绝对数值。

从这个角度来看,无单位数值其实承担的是另一种角色:它不负责“绑定关系”,而是负责在既定关系中,定义变化的幅度

在理解了前面的所有关系之后,其实还可以再往前走一步,把整个模型进一步抽象。换一个更简洁的视角来看,CSS 中的所有尺寸,本质上都在回答两个问题:它是否需要变化?如果需要变化,它应该跟随谁变化。

第一个问题是在区分“固定”和“响应”。有些尺寸需要保持稳定,比如边框或精细对齐,这时选择的是“不变化”;而有些尺寸则需要随着环境调整,这时就进入“响应”的范畴。第二个问题则是在确定依附对象——是跟随全局系统、局部上下文、视口环境、容器空间,还是内容本身。

一旦用这种方式思考,单位选择就不再是一个依赖经验或记忆的过程,而是一个可以被推导的决策过程。你不再需要记住“某种场景用某种单位”,而是先明确关系,再自然得出答案。

这也正是“尺寸关系模型”真正重要的地方。很多开发中的问题,并不是单纯因为“单位选错了”,而是关系选错了:在应该使用全局关系的地方使用了局部关系,在应该依赖容器的地方却绑定了视口,在应该围绕内容排版的地方却用了固定值。结果就是,系统逐渐失去一致性,适配变得困难,维护成本不断增加。

而这个模型的价值,在于提供了一个统一的判断框架:先决定关系,再选择单位。一旦顺序颠倒——先选单位,再试图去适配各种场景——问题往往就会不断出现。

最佳使用场景

从这里开始,CSS 单位就不再是一组零散的工具,而是一整套可以被设计、被推理、也可以被复用的系统。你不再是在零碎地选择某一个单位,而是在基于一套清晰的关系模型,去构建整个页面的尺寸逻辑。

接下来,更关键的一步,是把这套模型真正落到实际项目中。也就是说,在具体场景里,你到底该使用哪一类单位。与其去记忆“某种场景对应某种单位”,不如换一种更稳定的思考方式——从“关系”出发,反推出“选择”。一旦你明确了这个元素应该跟随谁变化,单位本身几乎是自然浮现的。

因此,接下来的内容,我们会基于这套“尺寸关系模型”,按顺序逐一拆解每一类单位在真实项目中的最佳使用场景。重点不在于记住规则,而在于理解背后的逻辑,并能够在不同情境中灵活应用。

当你理解到这一层时,CSS 单位这件事就不再是一个依赖经验的选择题,而是一个可以建模、可以推导,甚至可以系统化设计的能力。这一步,基本已经进入“设计系统思维”的范畴了。

px :用于“需在稳定、不参与响应”的细节

px 最适合的使用场景,其实并不在布局层面,而是在那些需要稳定、不参与响应变化的细节上。换句话说,当一个尺寸的变化不会带来收益,甚至可能破坏视觉一致性时,px 往往是最合理的选择。

典型的例子包括细边框(如 1px0.5px)、图标对齐的微调、阴影与描边、分割线,以及一些小范围的视觉校正。这些细节的共同点在于:它们依赖精确的像素控制,一旦随着环境变化而缩放,反而容易产生模糊、偏移或不协调的视觉效果

从这个角度来看,在这些场景中使用 px,反而是一种更“响应式”的选择——因为你在有意识地避免不必要的变化,让关键细节保持稳定。

但需要注意的是,这种优势并不适用于布局层面。如果把 px 用作主要的布局单位,比如容器宽度、模块间距或字体尺寸,一旦页面进入多端适配阶段,这些“固定值”就会迅速变成负担,限制整体的弹性和可扩展性。

rem:用于“全局一致”的系统级尺寸

rem 最适合的使用场景,是构建设计系统中的“全局一致”尺度。它的核心价值在于,把所有尺寸统一绑定到根元素,从而让整个页面在同一个规则下运作。

在实际项目中,rem 常用于定义全局字体体系、统一的间距系统,以及组件的基础尺寸(例如 paddingmargingap)。这些场景有一个共同特点:它们需要在整个页面中保持一致,并且能够随着整体策略一起缩放

例如,你可以基于 rem 定义一套间距系统:

:root {
    --space-1: 0.5rem;
    --space-2: 1rem;
    --space-3: 2rem;
}

当根元素的字号发生变化时,这些间距会自动按比例调整,而不需要逐个修改组件。这种方式可以极大地降低维护成本,同时保证视觉上的一致性。

不过需要注意的是,rem 更适合用于“系统层”,而不是“局部例外”。如果某个组件需要根据自身环境动态变化,比如根据容器或上下文自适应,那么继续使用 rem 反而会让它变得僵硬。换句话说,rem 擅长统一规则,但并不擅长处理局部差异。

em% :用于“组件内部的自适应关系”

em% 这一类单位,最适合用于构建组件内部的自适应关系。它们的特点是依赖当前上下文,而不是全局系统,因此非常适合用来描述组件内部各个元素之间的比例关系。

例如,一个按钮组件可以这样定义:

.button {
    font-size: 1rem;
    padding: 0.5em 1em;
}

这里的 em 实际上传达的是一种关系:按钮的内边距应该随着字体大小变化。也就是说,组件内部的空间是“绑定”在自身排版之上的,而不是写死为某个固定值。

类似的使用场景还有很多,比如图标与文字之间的比例关系(使用 em),子元素宽度占父元素的比例(使用 %),以及卡片内部结构中常见的 100% 宽度元素等。这些都属于在组件内部建立相对关系的典型用法。

这类单位的核心价值在于,让组件具备“自我缩放”的能力。当组件被放入不同的上下文中,比如更大的容器、不同的字号环境,或者不同的布局结构时,它可以自然适配,而不需要额外调整具体数值。

不过需要注意的是,这种依赖关系是可以层层叠加的。一旦嵌套层级过深,em 的计算路径就会变得复杂,最终结果也不再直观。因此,在复杂结构中使用时,需要有意识地控制层级,避免关系链失控。

视窗单位:用于“与屏幕强绑定”的布局或视觉

vwvh 这类视窗单位,最适合用于那些与屏幕强绑定的布局或视觉设计。它们直接以浏览器视口为参照,因此表达的是一种非常明确的关系:尺寸应该随着屏幕变化,而不是由容器或内容决定。

在实际项目中,这类单位常见于全屏布局、流式字号,以及一些依赖屏幕比例的视觉效果。例如:

.hero {
    height: 100vh;
}

或者:

h1 {
  font-size: clamp(2rem, 5vw, 4rem);
}

这些用法的共同点在于:它们让元素的尺寸直接响应视口变化,从而在不同设备上保持一种整体的视觉比例

不过需要注意的是,这种“直接绑定屏幕”的能力,并不适合用在组件内部。一旦在组件中使用 vwvh,组件的尺寸就会脱离自身上下文,被整个页面环境所控制。这会导致组件难以复用,也更难在不同布局中保持稳定表现。因此,视口单位更适合作为页面级或视觉级的工具,而不是组件级的默认选择。

窗器单位:用于“真正可复用的响应式组件”

cqwcqh 等容器查询单位,最核心的使用场景是构建真正可复用的响应式组件。与视口单位不同,它们并不依赖整个页面,而是直接绑定到组件所在的父容器。

例如一个卡片组件:

.card {
    width: 100%;
    padding: 5cqw;
}

这里表达的是一种非常清晰的关系:组件的尺寸不由屏幕决定,而是由它当前所处的容器空间决定。也就是说,组件只关心“自己有多少可用空间”,而不关心整个页面的大小。

这种能力在很多场景中尤为重要。比如同一个组件需要出现在不同布局中(侧边栏、主内容区或网格中),或者组件需要根据可用空间自动调整结构,又或者希望减少对媒体查询的依赖。这些问题,用传统的视口响应方式往往很难优雅解决,而容器单位则可以直接应对。

从更高层来看,容器查询单位的意义在于,让组件具备一种“环境感知能力”,但这个环境是局部的,而不是全局的。组件不再依赖页面断点,而是根据自身所处的上下文自我调整。这也可以说是现代 CSS 从页面级响应式迈向组件级响应式的关键一步。

chlh 等:用于“排版与阅读体验驱动”的设计

chlh 等这类单位,最适合用于那些以排版和阅读体验为核心的内容区域。与前面侧重布局的单位不同,它们直接绑定到文本本身的度量,因此更适合解决“读起来是否舒服”的问题。

典型的应用场景包括:控制文章正文的宽度(例如 max-width: 60ch)、设置段落之间的间距(如 margin-bottom: 1lh),以及建立整体的垂直节奏。这些场景有一个共同点:尺寸不应该由布局决定,而应该由内容本身来驱动

例如:

.article {
    max-width: 65ch;
    line-height: 1.6;
}

通过 ch 控制行长,可以直接得到更理想的阅读宽度,而不需要反复尝试具体数值。再比如:

p {
    margin-bottom: 1lh;
}

使用 lh 来定义间距,可以让段落之间始终与当前的文本节奏保持一致。

这类单位的真正价值,在于它们把原本依赖经验的排版决策,转化为可以复用的系统规则。你不再需要不断“微调数值”,而是可以通过单位本身,让设计自然成立。

无单位值

无单位数值最适合用于那些本质上是在表达“比例关系”,而不是具体尺寸的场景。与其他单位不同,它不依附于某个参考系,而是直接定义一种倍数规则,因此特别适合用来描述变化幅度、分配关系或节奏控制。

在实际开发中,这类写法广泛存在于排版、布局和视觉效果中。例如在排版中:

p {
    line-height: 1.6;
}

这里的 1.6 表达的是行高与字体大小之间的比例关系,而不是一个固定高度。这样做的好处是,无论字体如何变化,行高都会自动按比例调整,从而保持稳定的阅读节奏。这类用法的共同点在于:它们关注的不是尺寸本身,而是尺寸之间的关系。

无单位数值的核心价值在于,它把设计中的“比例”和“节奏”直接表达出来,而不会受到单位或上下文嵌套的干扰。相比使用固定值或依赖单位的写法,它通常更加稳定、可预测,也更贴近设计本意。

从更高层来看,这类写法实际上是在把“经验规则”转化为“数学关系”。你不再是在不断调整一个个具体数值,而是在定义一套可以自动生效的比例系统。这也是它在现代 CSS 中越来越重要的原因。

如果把前面所有使用场景再压缩成一条清晰的决策路径,其实可以用一个非常简单的思考顺序来判断:首先,这个尺寸是否需要变化?如果答案是不需要,那么直接使用 px,让它保持稳定即可。

如果这个尺寸需要变化,接下来要问的就是——它应该跟随谁变化。是跟随全局系统(rem),还是组件内部(em%),是跟随屏幕(vwvh),还是跟随容器(cqwcqh),又或者是跟随内容本身(chlh)。当这个问题有了明确答案,单位的选择几乎是自然得出的结果。

当你用这种方式做决策时,会慢慢意识到一件事:你不再是在“选单位”,而是在设计一套尺寸关系。一旦关系是清晰的,代码本身就会变得更加稳定、可预测,同时也更容易维护。这才是 CSS 单位真正的使用方式。

不过,到这里还只是第一步。如果你已经接受了“尺寸关系模型”的视角,那么接下来一个更贴近真实开发的问题就会浮现出来:在实际项目中,我们几乎不会只使用一种单位——那不同类型的单位应该如何组合使用?

这一步,才是真正体现 CSS 能力的地方。因为一旦进入“混合使用”,你所做的就不再只是选择某一种关系,而是在有意识地组合多种关系,让它们在同一个系统中协同工作。

混合使用的本质:叠加多个“跟随逻辑”

在讨论“混合使用”之前,需要先把一件事说清楚:混合单位并不是随意拼凑,而是在同一个元素上,有意识地叠加不同层级的“依附关系” 。每一种单位,都在负责一部分逻辑,而不是彼此冲突。

举个最简单的例子:

.card {
    padding: 1rem 2em;
}

这段代码里,其实同时存在两种关系。1rem 绑定的是全局系统,用来保证整体的一致性;而 2em 则跟随组件内部的字体大小,用来维持局部的协调性。

从结果来看,这并不是混乱,而是一种分工:全局负责统一规则,局部负责自适应细节。正是这种“关系的叠加”,让组件既能融入整体系统,又能在自身内部保持灵活性。

最常见的组合:全局 + 局部(rem + em

这是最推荐、也最稳定的一种组合方式:让不同层级的关系各司其职,而不是互相干扰。一个常见且有效的模式是——用 rem 定义基础尺寸,用 em 来做组件内部的比例调整。

例如一个按钮组件:

.button {
    font-size: 1rem;       /* 全局控制 */
    padding: 0.5em 1.2em;  /* 局部跟随 */
    border-radius: 0.5rem; /* 系统统一 */
}

这里的结构其实非常清晰。按钮的整体尺寸(如字体大小、圆角)是绑定在全局系统上的,因此可以保持一致性;而内部的间距(padding)则依赖自身字体,从而能够随着组件大小自然缩放。

这种组合方式的核心在于分层:全局负责统一规则,局部负责内部协调。最终的效果是,组件既能融入整体设计系统,又能在自身范围内保持灵活性——既统一,又不僵硬。

布局 + 内容:%fr + chlh

在内容型页面(比如文章或文档)中,一种非常高效且稳定的组合方式是:用布局单位控制结构,用内容单位控制阅读体验。也就是说,先解决“内容放在哪里”,再解决“内容读起来是否舒服”。

例如:

.layout {
    display: grid;
    grid-template-columns: 1fr min(65ch, 100%);
}

在这个结构中,1fr 负责的是整体布局的空间分配,让页面具备弹性;而 65ch 则用来限制文本的最大行长,从而保证阅读的舒适度。两者关注的层面完全不同,却可以自然协同。

再比如段落间距的处理:

.article p {
    margin-bottom: 1lh;
}

这里使用 lh 来控制间距,使其直接跟随文本节奏变化,而不是依赖固定数值。

从整体来看,这种组合的核心在于分工明确:布局单位决定“块在哪里、占多少空间”,而内容单位决定“读起来是否舒适” 。当两者各自负责自己的问题时,页面既能保持结构上的灵活性,又能在阅读体验上更加稳定自然。

响应式组合:rem + vw

这是现代响应式设计中非常常见、也非常实用的一种组合方式。它的核心思路可以概括为一句话:在“稳定”和“流动”之间找到平衡

例如:

h1 {
    font-size: clamp(2rem, 5vw, 4rem);
}

这段代码实际上融合了多层关系。2rem 作为最小值,提供一个基于全局系统的稳定下限;5vw 作为中间值,让字体能够随着视口变化而动态调整;而 4rem 则作为最大值,防止尺寸在大屏上无限增长而失控。

从本质上来看,这种写法并不是简单地混用单位,而是在构建一套“有边界的响应规则”:允许尺寸根据环境变化,但同时为这种变化设定清晰的上下限。这样既能获得流动性的优势,又能避免不可控带来的问题。

这种模式的价值在于,它让响应式不再是“要么固定,要么完全流动”的二选一,而是变成一种可控、可设计的连续变化过程。

组件级响应:容器单位 + rem

当你开始使用容器查询单位时,一种非常自然、也非常实用的组合方式就会出现:让容器决定结构,用 rem 维持系统一致性

例如:

.card {
    padding: 2rem;
    gap: 2cqw;
}

这里其实是两种关系在协同工作。rem 用来定义基础间距,确保组件始终遵循整体设计系统;而 cqw 则根据容器宽度进行微调,让组件在不同空间下都能做出合适的调整。

这种组合的关键在于分工清晰:全局系统负责“统一语言”,容器关系负责“适应环境” 。最终的结果是,组件既能够在不同容器中灵活变化,又不会偏离整体设计风格,从而同时具备一致性和适应性。

固定 + 弹性:px + 其他单位

在实际开发中,“完全响应式”并不总是目标。有些细节本身就需要保持稳定,比如 1px 的边框、精细的阴影、或者分隔线。这些元素一旦随着环境变化,反而容易破坏视觉的清晰度和一致性。

因此,一种非常常见且合理的做法是,把不同类型的尺寸分开处理。例如:

.card {
    padding: 1rem;
    border: 1px solid #ddd;
}

这里的 rem 用来控制整体结构,让组件能够随着全局系统调整;而 px 则用来锁定细节,确保边框始终保持清晰和稳定。

这种组合方式的核心,其实是一种取舍:让该变化的部分具备弹性,让不该变化的细节保持稳定。当这两者各自发挥作用时,页面既有响应能力,又不会失去精致度,这是一种非常健康且实用的平衡。

calc():显式组合关系

当你需要更精细地控制多种尺寸关系时,可以借助 calc() 把这些关系“写出来”。它的作用,不只是做简单计算,而是把原本隐含在设计中的逻辑,转化为清晰可读的表达。

例如:

.container {
    padding: calc(1rem + 2vw);
}

这里实际上是在组合两种关系:1rem 提供一个稳定的基础间距,而 2vw 让间距可以随着屏幕略微放大。也就是说,这个间距既有全局一致性,又具备一定的响应能力。

再比如:

.box {
    height: calc(100vh - 4rem);
}

这里表达的是另一种常见逻辑:用视口高度作为整体空间,再减去一个固定的导航高度,从而得到实际可用区域。

从本质上看,calc() 的价值在于,它让不同关系之间的组合变得明确且可控。你不再需要在脑中“估算”这些关系,而是可以直接用数学形式把它们表达出来,从而让代码本身就成为设计逻辑的一部分。

当你逐渐熟悉这些单位的组合方式之后,会开始看到一个更高层的模式——不同单位,其实在解决不同“层级”的问题。换句话说,它们并不是随意选择的工具,而是在各自的层面上承担不同职责。

例如,px 更偏向视觉细节层,用来处理那些不应该变化的精细部分;rem 属于设计系统层,负责全局的一致性;em% 则工作在组件内部,用于建立局部的自适应关系;vwvh 面向页面环境,与屏幕尺寸直接相关;cqw 这样的容器单位属于组件环境层,让组件根据自身空间变化;而 chlh 则作用于内容层,用来优化排版和阅读体验。

当这些层级被理清之后,一个非常重要的原则也会变得清晰:在混合使用单位时,最容易出问题的情况,是多个单位在“争夺同一个控制权”。比如,一个尺寸既想跟随视口(vw),又想跟随容器(cqw);或者字体既由 rem 控制,又在嵌套中被 em 不断放大。这种情况下,结果往往会变得不可预测,也更难维护。

为了避免这种问题,可以用一个非常简单但实用的判断方式:在写下任何一个尺寸之前,先问自己一句——这个值,主要由谁来决定? 如果答案是唯一的,那么直接选择对应的单位即可;如果答案涉及多个因素,那么就需要通过组合(例如 clamp()calc(),或者分层设计)来明确各自的作用边界。

当你开始有意识地“混合关系”,而不是简单地“混合单位”时,你会发现一个明显的变化:CSS 不再是一个依赖不断试错的过程,而是变成了一套可以被设计、被推导的系统。这一步,其实就是从“会写 CSS”,走向“会设计 CSS”的关键转变。

小结

当你把这些内容全部走完,再回头看最开始的问题——“到底该用 pxrem,还是 vw?”——其实已经不那么重要了。

因为你已经不再依赖某一个“正确答案”,而是拥有了一套可以自己推导答案的思考方式。

你会开始从“关系”出发,而不是从“单位”出发;先判断这个尺寸应该跟随谁,再去选择合适的表达方式。很多过去需要反复试错的问题,也会在这个过程中自然消失。

从更长远来看,这种思维方式的价值,并不仅仅局限在 CSS 单位上。它本质上是在训练你用一种更结构化的方式去理解界面:把页面拆成不同层级,把尺寸拆成不同关系,再通过组合让它们协同工作。

当你做到这一点时,CSS 就不再只是“写样式”,而是在构建一套有逻辑的系统。

而这,也正是从“能实现设计”,走向“能设计系统”的关键一步。

Node.js 如何判断入口文件:从 require.main 到 ES Module 实现

目标:判断当前文件是否被直接执行(而不是被 import)


一、CommonJS(对照)

if (require.main === module) {
  main();
}

二、ES Module 写法(核心)

import { fileURLToPath } from 'url';

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  main();
}

三、关键点(只记这 3 个)

  • import.meta.url → 当前文件(URL)
  • fileURLToPath() → 转为本地路径
  • process.argv[1] → 启动入口文件路径

👉 判断本质:
入口路径 === 当前文件路径

四、推荐封装

import { fileURLToPath } from 'url';

export const isMain = (meta) => process.argv[1] === fileURLToPath(meta.url);

// 使用:
if (isMain(import.meta)) {
  main();
}

五、关键技术点拆解(用于深入理解)

1️⃣ import.meta.url

返回当前模块的 URL,例如:

file:///Users/demo/index.js

2️⃣ fileURLToPath

file:// URL 转换为本地路径:

/Users/demo/index.js

3️⃣ process.argv[1]

Node 启动时的入口文件路径:

node index.js

# 得到:/Users/demo/index.js

怎么在VS Code 调试vue2 源码

总结一下怎么在VS Code 调试vue2 源码

  • 克隆vue2源码到本地
  • 在源码根目录安装依赖 把项目跑起来 生成/dist/vue.js文件
  • 在/examples/ 下随便找个文件(引入的文件要是我们生成的vue.js) 打上断点
  • 安装Live Server插件 把我们打上断点的文件在浏览器打开
  • 在.vscode文件夹下配置launch.json
  • 点击VS Code的Run and Debug图标 就可以进入了

我们开始吧~

克隆vue2源码到本地

去Github克隆源码,克隆后我们用VS Code打开。

git clone https://github.com/vuejs/vue

image.png

在源码根目录安装依赖 把项目跑起来
pnpm i

image.png

image.png

把项目跑起来

npm/pnpm run dev

image.png

bundles /Users/gongzemin/Documents/GitHub/vue/src/platforms/web/entry-runtime-with-compiler.ts → dist/vue.js...

entry-runtime-with-compiler.ts 这个入口文件打包生成 dist/vue.js 这个最终可用的 Vue 文件

生成了dist文件夹 里面有vue.js

image.png

在examples/ 下随便找个文件 打上断点

我们找examples/classic/commits/app.js 在如图位置打上断点

image.png

commits/index.html 这个文件引入了vue.min.js, 我们刚才构建出来的是vue.js文件,我们把引入的文件改成vue.js

<script src="../../../dist/vue.js"></script>
安装Live Server插件 把我们打上断点的文件在浏览器打开

image.png

安装好插件后,打开文件的上下文菜单 可以看到Open with Live Server

image.png

这样我们就可以打开我们的examples/classic/commits/index.html 文件了 是用服务器打开的

image.png

在.vscode文件夹下配置launch.json

注意这里的URL是我们的要调试URL路径

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run parser",
      "runtimeExecutable": "parser",
      "cwd": "${workspaceFolder}/packages/reactivity-transform/node_modules/@vue/compiler-core",
      "args": []
    },
    {
      "type": "chrome",
      "request": "launch",
      "name": "调试Vue源码",
      "url": "http://localhost:5501/examples/classic/commits/index.html",
      "webRoot": "${workspaceFolder}",
      "sourceMaps": true,
      "sourceMapPathOverrides": {
        "webpack:///./*": "${webRoot}/*",
        "webpack:///packages/*": "${webRoot}/packages/*",
        "*": "${webRoot}/*"
      },
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}
点击VS Code的Run and Debug图标

点击Run and Debug图标, 选择调试Vue源码(就是我们配置launch.json里面配置的name)

image.png 看到app.js 进入我们打的断点了

image.png

我们点击Step Into

image.png

就进入Vue()构造函数了

image.png

调试vue3源码方法也一样 参考这篇笔记

VSCode 插件推荐 Copy Filename Pro,快速复制文件、目录和路径的首选

大家好,我是笨笨狗吞噬者,uni-app、varlet、nrm 等众多知名仓库的核心开发,专注于分享 前端技术 和 AI 实践知识,欢迎关注我的微信公众号 前端笨笨狗,或者加我的微信 wxid_olsjlzuh4ivf22 沟通交流!

问题背景

大家平时写代码时,经常会遇到一些很碎的小动作,比如:

  • 想快速拿到组件名、页面名、模块名
  • 想复制路径写 import、写文档或者发消息
  • 想批量整理多个文件名或路径

这些操作不难,但做得多了就很烦,尤其是在目录结构比较复杂的工程里。我也被这类问题折腾了很久,试了插件市场里的很多插件,却总不如意,于是,我就自己写了插件 Copy Filename Pro

插件功能

Copy Filename Pro 主要提供下面几个功能:

复制带文件后缀的文件名

比如我想复制某个 vue 文件的完整文件名

with-filename.gif

复制不带扩展名的文件名

比如我想复制某个 vue 文件的不包含文件后缀的文件名

no-filename.gif

复制目录名

比如我想复制某个文件夹名称

dictory.gif

复制不带拓展名的绝对路径或者相对路径

由于 VSCode 本身有复制路径和相对路径的功能了,所以这里演示如何得到不包含文件后缀的路径

path.gif

一次复制多个文件或目录的信息

比如我想一次复制多个文件名

mul.gif

下载安装

大家可以参考下面的图片安装此插件

zhinan.png

另外,此插件的源码是完全免费公开的,访问 https://github.com/chouchouji/copy-filename-pro 即可获取,如果你有更好的想法和建议,也可以留言给我。

RN中如何处理权限申请(相机、相册、定位、存储)?使用第三方库还是原生封装?

在 React Native(RN)里处理权限申请,本质上有两条路:

一、推荐方案:使用第三方库(更省心 ✅)

最主流的是 👉 react-native-permissions

为什么推荐它?

  • 一套 API 统一处理 iOS + Android
  • 覆盖:相机 / 相册 / 定位 / 麦克风 / 通讯录等
  • 自动处理不同系统版本差异(特别是 Android 10+、13+)

安装

yarn add react-native-permissions

iOS 还需要:

cd ios && pod install

基本用法(以相机为例)

import {request, PERMISSIONS, RESULTS} from 'react-native-permissions';
import {Platform} from 'react-native';

export async function requestCameraPermission() {
  const permission =
    Platform.OS === 'ios'
      ? PERMISSIONS.IOS.CAMERA
      : PERMISSIONS.ANDROID.CAMERA;

  const result = await request(permission);

  switch (result) {
    case RESULTS.GRANTED:
      console.log('已授权');
      break;
    case RESULTS.DENIED:
      console.log('用户拒绝');
      break;
    case RESULTS.BLOCKED:
      console.log('被永久拒绝,需要引导去设置页');
      break;
  }
}

常见权限对应表

功能 iOS Android
相机 CAMERA CAMERA
相册 PHOTO_LIBRARY READ_MEDIA_IMAGES(Android 13+)
定位 LOCATION_WHEN_IN_USE ACCESS_FINE_LOCATION
存储 自动 READ/WRITE_EXTERNAL_STORAGE(已逐步废弃)

跳转系统设置页(很重要)

import {openSettings} from 'react-native-permissions';

openSettings();

二、官方原生 API(不推荐做主方案 ❌)

RN 自带:

👉 PermissionsAndroid(仅 Android)

import {PermissionsAndroid} from 'react-native';

await PermissionsAndroid.request(
  PermissionsAndroid.PERMISSIONS.CAMERA
);

问题:

  • ❌ iOS 不支持(需要自己写 Native)
  • ❌ Android 版本适配麻烦(13+权限拆分)
  • ❌ 代码分散,难维护

三、什么时候用“原生封装”?🤔

只有这些情况才建议:

✅ 场景

  • 需要深度定制(比如蓝牙、后台定位)
  • 使用原生 SDK(高德 / 百度定位)
  • 公司有统一权限中间层

❌ 不建议

  • 普通业务(拍照、选图、定位)
  • 中小项目

四、最佳实践(很关键🔥)

1️⃣ 封装统一权限工具

// permission.ts
export async function requestPermission(type: 'camera' | 'photo' | 'location') {
  // 内部统一处理
}

👉 避免业务代码到处写权限逻辑


2️⃣ 权限申请时机

不要一进 App 就申请 ❌
👉 要“用到时再申请” ✅

例如:

  • 点击“拍照” → 再申请相机权限
  • 点击“上传头像” → 再申请相册

3️⃣ 权限被拒绝的处理

if (result === RESULTS.BLOCKED) {
  Alert.alert(
    '需要权限',
    '请前往设置开启权限',
    [
      {text: '取消'},
      {text: '去设置', onPress: openSettings}
    ]
  );
}

4️⃣ Android 13+ 注意点 ⚠️

存储权限拆分为:

  • READ_MEDIA_IMAGES
  • READ_MEDIA_VIDEO
  • READ_MEDIA_AUDIO

👉 用旧的 READ_EXTERNAL_STORAGE 会失效


五、总结(给你一个明确建议)

👉 90% 场景建议:

  • 用 👉 react-native-permissions

能够插入 DOM 的输入框

简易富文本编辑器

使用input、textarea 这种输入框会出现一个问题,就是无法在其中写入 DOM 结构,浏览器不会把 DOM 进行渲染,这样的话在某些情况下使用他们只会浪费时间,复制粘贴半天,发现没办法放 UI 内容,无敌了孩子。

如果你的内容需要很多操作可以选择去使用富文本编辑器,这里就说一下怎么写一个简单的富文本编辑器。

     <div
        id="editor"
        contenteditable="true" // 赋予容器可编辑的能力
        ref="editorRef"
      ></div>

只要是 DOM 能放的结构,他都可以。

他也有一些缺点,就是没有input简便,好写,而且它只有一部分 input 对应的方法, 比如以下常见方法:

  • input
  • paste
  • blur、focus
  • keydown、keyup

如何插入 DOM(组件) 和文本

插入 DOM

const textNode = document.createTextNode(featureData.description); // 创建文本
const placeholder = document.createElement('span'); // 创建节点
placeholder.contentEditable = false; // 不可编辑
// 变量记录文本节点
featureData.lastTextNode = textNode;
featureData.lastTagHolder = placeholder; 
// 在编辑器最前方进行插入
editor.insertBefore(textNode, editor.firstChild);
editor.insertBefore(placeholder, editor.firstChild);

在vue的程序里面想要在普通函数中动态创建、挂载、操作组件可以通过vue提供的createApp去创建vue的节点

const app = createApp({
    render: () =>
      h(Tag, {
        text: featureData.title, // 组件 props 
        bgColor: featureData.bgColor, // 组件 props 
        onClose: () => {
          featureData.lastApp?.unmount();
          featureData.lastApp = null;
          featureData.lastTextNode?.remove();
          featureData.lastTagHolder?.remove();
          featureData.lastTextNode = null;
          featureData.lastTagHolder = null;
        },
      }),
  });
  app.mount(placeholder);
  featureData.lastApp = app; // 记录app实例进行卸载

h 函数

用于创建虚拟节点,可以渲染多个/嵌套/动态结构。

  1. 渲染组件 vnode 时 children 参数需要通过插槽函数书写,可以通过设置props为null避免将插槽识别为props。
  2. 渲染为 html 的节点 children 可以随意文本或者数组传递多个节点。
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots   // 为组件时需要通过插槽函数
): VNode

h( 
    组件 / 标签名, 
    属性、props、事件, 
    子节点/内容            // 子节点不是插槽就可以省略 props 书写
)
// 多个节点
h(
    'div'
    null,
    [
        h('div','文字') 
    ]
)
// 动态结构
h('div', isShow ? h(Tag) : h('span', '无标签') )

// 组件插槽传递 vnode
h(Components,null,{default:()=>'你的内容'})// 默认插槽

// html节点
h('div',null,['文字', h('span', '内容')])

鼠标选中区域

可以通过选中区域对文本区域进行记录,选中区域内容、获取选区范围等等,可以用于加粗、添加标题。

// 创建鼠标选区
  const range = document.createRange();
  // 设定鼠标选中区域
  range.setStartAfter(textNode); // 在 textNode 后面开始
  range.setEndAfter(textNode);   // 在 textNode 后面结束
  // 获取选区管理
  const sel = window.getSelection();
  // 获取选中文字
  const selectedText = sel.toString()
  // 获取第一个选区
  const range = sel.getRangeAt(0)
  // 移除先前选区
  sel.removeAllRanges();
  // 记录当前鼠标选区
  sel.addRange(range);
❌