阅读视图

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

每日一题-删列造序 II🟡

给定由 n 个字符串组成的数组 strs,其中每个字符串长度相等。

选取一个删除索引序列,对于 strs 中的每个字符串,删除对应每个索引处的字符。

比如,有 strs = ["abcdef", "uvwxyz"],删除索引序列 {0, 2, 3},删除后 strs["bef", "vyz"]

假设,我们选择了一组删除索引 answer,那么在执行删除操作之后,最终得到的数组的元素是按 字典序strs[0] <= strs[1] <= strs[2] ... <= strs[n - 1])排列的,然后请你返回 answer.length 的最小可能值。

 

    示例 1:

    输入:strs = ["ca","bb","ac"]
    输出:1
    解释: 
    删除第一列后,strs = ["a", "b", "c"]。
    现在 strs 中元素是按字典排列的 (即,strs[0] <= strs[1] <= strs[2])。
    我们至少需要进行 1 次删除,因为最初 strs 不是按字典序排列的,所以答案是 1。
    

    示例 2:

    输入:strs = ["xc","yb","za"]
    输出:0
    解释:
    strs 的列已经是按字典序排列了,所以我们不需要删除任何东西。
    注意 strs 的行不需要按字典序排列。
    也就是说,strs[0][0] <= strs[0][1] <= ... 不一定成立。
    

    示例 3:

    输入:strs = ["zyx","wvu","tsr"]
    输出:3
    解释:
    我们必须删掉每一列。
    

     

    提示:

    • n == strs.length
    • 1 <= n <= 100
    • 1 <= strs[i].length <= 100
    • strs[i] 由小写英文字母组成

    从左到右贪心 + 优化(Python/Java/C++/Go)

    例如 $\textit{strs}=[\texttt{ac},\texttt{ad},\texttt{ba},\texttt{bb}]$,竖着看就是

    $$
    \begin{aligned}
    & \texttt{ac} \
    & \texttt{ad} \
    & \texttt{ba} \
    & \texttt{bb} \
    \end{aligned}
    $$

    第一列是升序,可以不删。

    • 如果删第一列,那么需要完整地比较第二列的四个字母是不是升序。
    • 如果不删第一列,那么对于第二列,由于 $\texttt{d}$ 和 $\texttt{a}$ 前面的字母不同,只看第一列的字母就能确定 $\texttt{ad} < \texttt{ba}$,所以我们不需要比较 $\texttt{d}$ 和 $\texttt{a}$ 的大小。此时第二列分成了两组 $[\texttt{c},\texttt{d}]$ 和 $[\texttt{a},\texttt{b}]$,只需判断组内字母是不是升序,而不是完整地比较第二列的四个字母。

    由此可见,当列已经是升序时,不删更好,后面需要比较的字母更少,更容易满足要求,最终删除的列更少。

    如果列不是升序,那么一定要删(否则最终得到的数组不是字典序排列)。

    优化前

    ###py

    class Solution:
        def minDeletionSize(self, strs: List[str]) -> int:
            n, m = len(strs), len(strs[0])
            a = [''] * n  # 最终得到的字符串数组
            ans = 0
            for j in range(m):
                for i in range(n - 1):
                    if a[i] + strs[i][j] > a[i + 1] + strs[i + 1][j]:
                        # j 列不是升序,必须删
                        ans += 1
                        break
                else:
                    # j 列是升序,不删更好
                    for i, s in enumerate(strs):
                        a[i] += s[j]
            return ans
    

    ###java

    class Solution {
        public int minDeletionSize(String[] strs) {
            int n = strs.length;
            int m = strs[0].length();
            String[] a = new String[n]; // 最终得到的字符串数组
            Arrays.fill(a, "");
    
            int ans = 0;
            next:
            for (int j = 0; j < m; j++) {
                for (int i = 0; i < n - 1; i++) {
                    if ((a[i] + strs[i].charAt(j)).compareTo(a[i + 1] + strs[i + 1].charAt(j)) > 0) {
                        // j 列不是升序,必须删
                        ans++;
                        continue next;
                    }
                }
                // j 列是升序,不删更好
                for (int i = 0; i < n; i++) {
                    a[i] += strs[i].charAt(j);
                }
            }
            return ans;
        }
    }
    

    ###cpp

    class Solution {
    public:
        int minDeletionSize(vector<string>& strs) {
            int n = strs.size(), m = strs[0].size();
            vector<string> a(n); // 最终得到的字符串数组
            int ans = 0;
            for (int j = 0; j < m; j++) {
                bool del = false;
                for (int i = 0; i < n - 1; i++) {
                    if (a[i] + strs[i][j] > a[i + 1] + strs[i + 1][j]) {
                        // j 列不是升序,必须删
                        ans++;
                        del = true;
                        break;
                    }
                }
                if (!del) {
                    // j 列是升序,不删更好
                    for (int i = 0; i < n; i++) {
                        a[i] += strs[i][j];
                    }
                }
            }
            return ans;
        }
    };
    

    ###go

    func minDeletionSize(strs []string) (ans int) {
    n, m := len(strs), len(strs[0])
    a := make([]string, n) // 最终得到的字符串数组
    next:
    for j := range m {
    for i := range n - 1 {
    if a[i]+string(strs[i][j]) > a[i+1]+string(strs[i+1][j]) {
    // j 列不是升序,必须删
    ans++
    continue next
    }
    }
    // j 列是升序,不删更好
    for i, s := range strs {
    a[i] += string(s[j])
    }
    }
    return
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(nm^2)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。比较 $\mathcal{O}(nm)$ 次大小,每次 $\mathcal{O}(m)$。
    • 空间复杂度:$\mathcal{O}(nm)$。

    优化

    回顾前文的例子:

    $$
    \begin{aligned}
    & \texttt{ac} \
    & \texttt{ad} \
    & \texttt{ba} \
    & \texttt{bb} \
    \end{aligned}
    $$

    第一列升序,不删。由于 $\textit{strs}[1][0] < \textit{strs}[2][0]$,后续 $a[1] < a[2]$ 必定成立,所以不需要比较这两个字符串。对于其余相邻字符串来说,由于第一列的字母都一样,所以只需比较第二列的字母,无需比较整个字符串

    怎么维护需要比较的下标(行号)呢?可以用哈希集合,或者布尔数组,或者创建一个下标列表,删除列表中的无需比较的下标。最后一种方法最高效,我们可以用 27. 移除元素 的方法,原地删除无需比较的下标,见 我的题解

    ###py

    class Solution:
        def minDeletionSize(self, strs: List[str]) -> int:
            n, m = len(strs), len(strs[0])
            check_list = list(range(n - 1))
    
            ans = 0
            for j in range(m):
                for i in check_list:
                    if strs[i][j] > strs[i + 1][j]:
                        # j 列不是升序,必须删
                        ans += 1
                        break
                else:
                    # j 列是升序,不删更好
                    new_size = 0
                    for i in check_list:
                        if strs[i][j] == strs[i + 1][j]:
                            # 相邻字母相等,下一列 i 和 i+1 需要继续比大小
                            check_list[new_size] = i  # 原地覆盖
                            new_size += 1
                    del check_list[new_size:]
            return ans
    

    ###java

    class Solution {
        public int minDeletionSize(String[] strs) {
            int n = strs.length;
            int m = strs[0].length();
            int size = n - 1;
            int[] checkList = new int[size];
            for (int i = 0; i < size; i++) {
                checkList[i] = i;
            }
    
            int ans = 0;
            next:
            for (int j = 0; j < m; j++) {
                for (int t = 0; t < size; t++) {
                    int i = checkList[t];
                    if (strs[i].charAt(j) > strs[i + 1].charAt(j)) {
                        // j 列不是升序,必须删
                        ans++;
                        continue next;
                    }
                }
                // j 列是升序,不删更好
                int newSize = 0;
                for (int t = 0; t < size; t++) {
                    int i = checkList[t];
                    if (strs[i].charAt(j) == strs[i + 1].charAt(j)) {
                        // 相邻字母相等,下一列 i 和 i+1 需要继续比大小
                        checkList[newSize++] = i; // 原地覆盖
                    }
                }
                size = newSize;
            }
            return ans;
        }
    }
    

    ###cpp

    class Solution {
    public:
        int minDeletionSize(vector<string>& strs) {
            int n = strs.size(), m = strs[0].size();
            vector<int> check_list(n - 1);
            ranges::iota(check_list, 0);
    
            int ans = 0;
            for (int j = 0; j < m; j++) {
                bool del = false;
                for (int i : check_list) {
                    if (strs[i][j] > strs[i + 1][j]) {
                        // j 列不是升序,必须删
                        ans++;
                        del = true;
                        break;
                    }
                }
                if (del) {
                    continue;
                }
                // j 列是升序,不删更好
                int new_size = 0;
                for (int i : check_list) {
                    if (strs[i][j] == strs[i + 1][j]) {
                        // 相邻字母相等,下一列 i 和 i+1 需要继续比大小
                        check_list[new_size++] = i; // 原地覆盖
                    }
                }
                check_list.resize(new_size);
            }
            return ans;
        }
    };
    

    ###go

    func minDeletionSize(strs []string) (ans int) {
    n, m := len(strs), len(strs[0])
    checkList := make([]int, n-1)
    for i := range checkList {
    checkList[i] = i
    }
    
    next:
    for j := range m {
    for _, i := range checkList {
    if strs[i][j] > strs[i+1][j] {
    // j 列不是升序,必须删
    ans++
    continue next
    }
    }
    // j 列是升序,不删更好
    newCheckList := checkList[:0] // 原地
    for _, i := range checkList {
    if strs[i][j] == strs[i+1][j] {
    // 相邻字母相等,下一列 i 和 i+1 需要继续比大小
    newCheckList = append(newCheckList, i)
    }
    }
    checkList = newCheckList
    }
    return
    }
    

    复杂度分析

    • 时间复杂度:$\mathcal{O}(nm)$,其中 $n$ 是 $\textit{strs}$ 的长度,$m$ 是 $\textit{strs}[i]$ 的长度。
    • 空间复杂度:$\mathcal{O}(n)$。

    专题训练

    见下面贪心题单的「§1.4 从最左/最右开始贪心」。

    分类题单

    如何科学刷题?

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

    删列造序

    贪心的想法,我们从前往后的比较每一个字符,如果发现A[i][j]<A[i-1][j],那么就意味着这一列是必须删除的,但是这样做有一个问题:就是如果之前我们已经确定了这两列的大小关系,那么这个时候即使小于,也不用删除,比如acd,adc,对于这两个字符串来说,当我们比较前面两个字符时就已经确定大小关系了,那么当比较第三个字符时,即使d>c,也是不需要删除的。
    所以针对多个字符串,我们开一个数组vis[n],vis[i]表示第i行和第i-1行是否已经确定大小关系了。
    思路:
    从第0列开始比较,如果发现这一列中有不确定大小关系并且是小于前面行的,就意味着是必须删除的,这个时候就不需要更新vis,否则我们就跟新vis;

    ###java

    class Solution {
        public int minDeletionSize(String[] A) {
            int n = A.length;
            int m = A[0].length();
    
            int[] vis = new int[n];
            int ans = 0;
            for (int i = 0; i < m; i++) {
                boolean isDelete=false;
                for (int j = 1; j < n; j++) {
                    if(vis[j]==1)continue;
                    if(A[j].charAt(i)<A[j-1].charAt(i)){
                        isDelete = true;
                        break;
                    }
                }
                if(isDelete)ans++;
                else{
                    for (int j = 1; j < n; j++) {
                        if(A[j].charAt(i)>A[j-1].charAt(i)){
                            vis[j]=1;
                        }
                    }
                }
            }
            return ans;
        }
    }
    

    删列造序 II

    方法 1:贪心

    想法

    针对该问题,我们考虑保留哪些列去获得最终的有序结果,而不是删除哪些列。

    如果第一列不是字典序排列的,我们就必须删除它。

    否则,我们需要讨论是否保留第一列。会出现以下两种情况:

    • 如果我们不保留第一列,则最后答案的行需要保证有序;

    • 如果我们保留了第一列,那么最终答案的行(除去第一列)只需要在第一个字母相同的情况下需要保证有序。

      这个描述很难理解,看下面的例子:

      假设我们有 A = ["axx", "ayy", "baa", "bbb", "bcc"],当我们保留第一列之后,最终行变成 R = ["xx", "yy", "aa", "bb", "cc"],对于这些行,并不要求所有有序(R[0] <= R[1] <= R[2] <= R[3] <= R[4]),只需要达到一个较弱的要求:对于第一个字母相同的行保证有序(R[0] <= R[1]R[2] <= R[3] <= R[4])。

    现在,我们只将结论应用到第一列,但实际上这个结论对每列都合适。如果我们不能取用第一列,就删除它。否则,我们就取用第一列,因为无论如何都可以使要求更简单。

    算法

    首先没有任意列保留,对于每一列:如果保留后结果保持有序,就保留这一列;否则删除它。

    ###Java

    class Solution {
        public int minDeletionSize(String[] A) {
            int N = A.length;
            int W = A[0].length();
            int ans = 0;
    
            // cur : all rows we have written
            // For example, with A = ["abc","def","ghi"] we might have
            // cur = ["ab", "de", "gh"].
            String[] cur = new String[N];
            for (int j = 0; j < W; ++j) {
                // cur2 : What we potentially can write, including the
                //        newest column col = [A[i][j] for i]
                // Eg. if cur = ["ab","de","gh"] and col = ("c","f","i"),
                // then cur2 = ["abc","def","ghi"].
                String[] cur2 = Arrays.copyOf(cur, N);
                for (int i = 0; i < N; ++i)
                    cur2[i] += A[i].charAt(j);
    
                if (isSorted(cur2))
                    cur = cur2;
                else
                    ans++;
            }
    
            return ans;
        }
    
        public boolean isSorted(String[] A) {
            for (int i = 0; i < A.length - 1; ++i)
                if (A[i].compareTo(A[i+1]) > 0)
                    return false;
    
            return true;
        }
    }
    

    ###Python

    class Solution(object):
        def minDeletionSize(self, A):
            def is_sorted(A):
                return all(A[i] <= A[i+1] for i in xrange(len(A) - 1))
    
            ans = 0
            # cur : all rows we have written
            # For example, with A = ["abc","def","ghi"] we might have
            # cur = ["ab", "de", "gh"].
            cur = [""] * len(A)  
    
            for col in zip(*A):
                # cur2 : What we potentially can write, including the
                #        newest column 'col'.
                # Eg. if cur = ["ab","de","gh"] and col = ("c","f","i"),
                # then cur2 = ["abc","def","ghi"].
                cur2 = cur[:]
                for i, letter in enumerate(col):
                    cur2[i] = cur2[i] + letter
    
                if is_sorted(cur2):
                    cur = cur2
                else:
                    ans += 1
    
            return ans
    

    复杂度分析

    • 时间复杂度:$O(NW^2)$,其中 $N$ 是 A 的长度,$W$ 是 A[i] 的长度。
    • 空间复杂度:$O(NW)$。

    方法 2:优化贪心

    解释

    方法 1 可以用更少的空间和时间。

    核心思路是记录每一列的”割“信息。在第一个例子中,A = ["axx","ayy","baa","bbb","bcc"]R 也是相同的定义),第一列将条件 R[0] <= R[1] <= R[2] <= R[3] <= R[4] 切成了 R[0] <= R[1]R[2] <= R[3] <= R[4]。也就是说,"a" == column[1] != column[2] == "b" ”切割“了 R 中的一个条件。

    从更高层面上说,我们的算法只需要考虑新加进的列是否保证有序。通过维护”割“的信息,只需要比较新列的字符。

    ###Java

    class Solution {
        public int minDeletionSize(String[] A) {
            int N = A.length;
            int W = A[0].length();
            // cuts[j] is true : we don't need to check any new A[i][j] <= A[i][j+1]
            boolean[] cuts = new boolean[N-1];
    
            int ans = 0;
            search: for (int j = 0; j < W; ++j) {
                // Evaluate whether we can keep this column
                for (int i = 0; i < N-1; ++i)
                    if (!cuts[i] && A[i].charAt(j) > A[i+1].charAt(j)) {
                        // Can't keep the column - delete and continue
                        ans++;
                        continue search;
                    }
    
                // Update 'cuts' information
                for (int i = 0; i < N-1; ++i)
                    if (A[i].charAt(j) < A[i+1].charAt(j))
                        cuts[i] = true;
            }
    
            return ans;
        }
    }
    
    

    ###Python

    class Solution(object):
        def minDeletionSize(self, A):
            # cuts[i] is True : we don't need to check col[i] <= col[i+1]
            cuts = [False] * (len(A) - 1)
    
            ans = 0
            for col in zip(*A):
                if all(cuts[i] or col[i] <= col[i+1] for i in xrange(len(col) - 1)):
                    for i in xrange(len(col) - 1):
                        if col[i] < col[i+1]:
                            cuts[i] = True
                else:
                    ans += 1
            return ans
    

    复杂度分析

    • 时间复杂度:$O(NW)$,其中 $N$ 是 A 的长度,$W$ 是 A[i] 的长度。
    • 空间复杂度:额外空间开销 $O(N)$(在 Python 中,zip(*A) 需要 $O(NW)$ 的空间)。

    React Hooks 深度理解:useState / useEffect 如何管理副作用与内存

    🤯你以为 React Hooks 只是语法糖?

    不——它们是在帮你对抗「副作用」和「内存泄漏」

    如果你只把 Hooks 当成“不用 class 了”,
    那你可能只理解了 React 的 10%。


    🚀 一、一个“看起来毫无问题”的组件

    我们先从一个你我都写过无数次的组件开始:

    function App() {
      const [num, setNum] = useState(0)
    
      return (
        <div onClick={() => setNum(num + 1)}>
          {num}
        </div>
      )
    }
    

    看起来非常完美:

    • ✅ 没有 class
    • ✅ 没有 this
    • ✅ 就是一个普通函数

    但问题是:

    React 为什么要发明 Hooks?
    useState / useEffect 到底解决了什么“本质问题”?

    答案其实只有一个关键词👇


    💣 二、React 世界的终极敌人:副作用(Side Effect)

    React 背后有一个很少被明说,但极其重要的信仰

    组件 ≈ 纯函数

    🧠 什么是纯函数?

    • 相同输入 → 永远相同输出
    • 不依赖外部变量
    • 不产生额外影响(I/O、定时器、请求)
    function add(a, b) {
      return a + b
    }
    

    而理想中的 React 组件是:

    (props + state) → JSX
    

    React 希望你“只负责算 UI”,
    而不是在渲染时干别的事。


    ⚠️ 但现实是:你必须干“坏事”

    真实业务中,你不可避免要做这些事:

    • 🌐 请求接口
    • ⏱️ 设置定时器
    • 🎧 事件监听
    • 📦 订阅 / 取消订阅
    • 🧱 操作 DOM

    这些行为有一个共同点👇

    它们都不是纯函数行为
    它们都是副作用

    如果你直接把副作用写进组件函数,会发生什么?

    function App() {
      fetch('/api/data') // ❌
      return <div />
    }
    

    👉 每一次 render 都请求
    👉 状态更新 → 再 render → 再请求
    👉 组件直接失控


    🧯 三、useEffect:副作用的“隔离区”

    useEffect 的存在,本质只干一件事:

    把副作用从“渲染阶段”挪走

    useEffect(() => {
      // 副作用逻辑
    }, [])
    

    💡 一句话理解:

    render 阶段必须纯,
    effect 阶段允许脏。


    📦 四、依赖数组不是细节,而是“副作用边界”

    1️⃣ 只执行一次(挂载)

    useEffect(() => {
      console.log('mounted')
    }, [])
    
    • 只在组件挂载时执行
    • 类似 Vue 的 onMounted

    2️⃣ 依赖变化才执行

    useEffect(() => {
      console.log(num)
    }, [num])
    
    • num 变化 → 执行
    • 不变 → 不执行

    依赖数组的本质是:
    “这个副作用依赖谁?”


    3️⃣ 不写依赖项?

    useEffect(() => {
      console.log('every render')
    })
    

    👉 每次 render 都执行
    👉 99% 的时候是性能陷阱


    💥 五、90% 新手都会踩的坑:内存泄漏

    来看一个极其经典的 Hooks 错误写法👇

    useEffect(() => {
      const timer = setInterval(() => {
        console.log(num)
      }, 1000)
    }, [num])
    

    你觉得这段代码有问题吗?

    有,而且非常致命。


    ❌ 问题在哪里?

    • num 每变一次
    • effect 重新执行
    • 新建一个定时器
    • ❗旧定时器还活着

    结果就是:

    • ⏱️ 定时器越来越多
    • 📈 内存持续上涨
    • 💥 控制台疯狂打印
    • 🧠 内存泄漏

    🧹 六、useEffect return:副作用的“善终机制”

    React 给你准备了一个官方清理通道👇

    useEffect(() => {
      const timer = setInterval(() => {
        console.log(num)
      }, 1000)
    
      return () => {
        clearInterval(timer)
      }
    }, [num])
    

    ⚠️ 重点来了

    return 的函数不是“卸载时才执行”

    而是:

    下一次 effect 执行前,一定会先执行它

    React 内部顺序是这样的:

    1. 执行上一次 effect 的 cleanup
    2. 再执行新的 effect

    👉 这就是 Hooks 防内存泄漏的核心设计


    🧠 七、useState:为什么初始化不能异步?

    你在学习 Hooks 时,一定问过这个问题👇

    ❓ 我能不能在 useState 初始化时请求接口?

    useState(async () => {
      const data = await fetchData()
      return data
    })
    

    答案很干脆:

    不行


    🤔 为什么不行?

    因为 React 必须保证:

    • 首次 render 立即有确定的 state
    • 异步结果是不确定的
    • state 一旦初始化,必须是同步值

    React 允许的只有这种👇

    useState(() => {
      const a = 1 + 2
      const b = 2 + 3
      return a + b
    })
    

    💡 这叫 惰性初始化
    💡 但前提是:同步 + 纯函数


    🌐 八、那异步请求到底该写哪?

    答案只有一个地方:

    useEffect

    useEffect(() => {
      async function query() {
        const data = await queryData()
        setNum(data)
      }
      query()
    }, [])
    

    🎯 这是 React 官方推荐模式

    • state 初始化 → 确定
    • 异步请求 → 副作用
    • 数据回来 → 更新状态

    🔄 九、为什么 setState 可以传函数?

    setNum(prev => prev + 1)
    

    这不是“花里胡哨”,而是并发安全设计

    React 内部可能会:

    • 合并多次更新
    • 延迟执行 setState

    如果你直接用 num + 1,很可能拿到的是旧值。

    函数式 setState = 永远安全


    🏁 十、Hooks 的真正价值(总结)

    如果你只把 Hooks 当成:

    “不用写 class 了”

    那你只看到了表面。

    Hooks 真正解决的是:

    • 🧩 状态如何在函数中稳定存在
    • 🧯 副作用如何被精确控制
    • 🧠 生命周期如何显式建模
    • 🔒 内存泄漏如何被主动规避

    ✨ 最后的掘金金句

    useState 解决的是:数据如何“活着”
    useEffect 解决的是:副作用如何“善终”

    React Hooks 不只是语法升级,
    而是一场从“命令式生命周期”
    到“声明式副作用管理”的革命。

    AI Agent 介绍

    前言

    这周在组内做了一次关于 Agent 设计模式 的分享,主要介绍和讲解了 ReAct 模式P&A(Plan and Execute)模式。我计划将这次分享拆分为三篇文章,对我在组会中讲解的内容进行更加系统和细致的整理。

    在正式进入具体的 Agent 模式实现之前,有一个绕不开的问题需要先回答清楚:

    什么是 AI Agent?它解决了什么问题?以及在近几年 AI 技术与应用快速演进的过程中,AI 应用的开发范式经历了哪些关键变化?

    这一篇将不直接展开某一种 Agent 模式的实现细节,而是先回到更宏观的视角,从 AI 应用形态与工程范式的演进 入手,梳理 Agent 出现的技术背景与必然性。

    需要说明的是,下文对 AI 应用演进阶段的划分,是一种以“应用开发范式”为核心的抽象总结。真实的技术演进在时间上存在明显重叠,但这种阶段化的叙述有助于我们理解:为什么 Agent 会在当下成为主流方向

    AI 应用的发展历程

    第一阶段:提示词工程

    2022 年 11 月,GPT-3.5 发布后,大模型开始从研究领域进入大众视野。对开发者来说,这是第一次可以在实际产品中直接使用通用语言模型。

    这一阶段的 AI 应用形态非常简单,大多数产品本质上都是一个对话界面:用户输入问题模型生成回答结束

    很快,围绕 Prompt 的工程实践开始出现。由于模型对上下文非常敏感,系统提示词(System Prompt)成为当时最直接、也最有效的控制手段。常见的做法是通过提示词约束模型的角色、输出形式和关注重点,例如:

    “你是一个资深的前端开发工程师,请严格以 JSON 格式输出结果……”

    这类“身份面具”式的提示,本质上是通过上下文约束来减少模型输出的发散性,让结果更贴近预期。在这一阶段,也陆续出现了 Chain-of-Thought、Few-shot Prompting 等推理增强技巧,但它们依然属于单次生成模式:模型在一次调用中完成全部推理,过程中无法获得外部反馈,也无法根据中间结果调整策略。

    第二阶段:RAG

    当 AI 开始被用于真实业务场景时,很快暴露出两个问题:模型不了解私有知识,以及生成结果难以校验。以 GPT-3.5 为例,它的训练数据截止在 21 年左右,对于新技术以及企业内部文档、业务规则更是不了解,直接使用往往不可控。

    RAG(Retrieval-Augmented Generation)是在这种背景下被广泛采用的方案。它的核心做法是:

    • 将私有知识进行切分和向量化存储;
    • 用户提问时,先进行相似度检索;
    • 将命中的内容作为上下文提供给模型,再由模型完成生成。

    通过这种方式,模型不需要记住所有知识,而是在生成时按需获取参考信息。

    RAG 的价值不仅在于补充新知识,更重要的是带来了可控性和可追溯性:生成内容可以明确对应到原始文档,这一点在企业场景中尤为关键。

    第三阶段:Tool Calling

    如果说 RAG 让模型能够“查资料”,那么 Function / Tool Calling 则让模型开始能够“做事情”。

    在这一阶段,开发者会把可用能力(如查询数据库、调用接口、执行脚本)以结构化的方式提供给模型,包括函数名、参数说明和功能描述。模型在理解用户意图后,可以返回一个明确的工具调用请求,再由程序完成实际执行。

    这一能力的出现,标志着 AI 第一次在工程上具备了可靠调用外部系统的能力。它不再只是一个聊天机器人,而是一个可以触发真实世界动作的“控制器”,这也是后续 Agent 能够落地的关键技术支撑。

    第四阶段:AI Workflow

    当 RAG 能力和 Tool Calling 能力逐渐成熟后,开发者开始尝试把多个步骤组合起来,形成完整的业务流程。这催生了以 Dify、Coze 为代表的 AI Workflow 范式。

    在 Workflow 模式下,一个 AI 应用会被拆解为多个固定节点,并按照预设顺序执行,例如:检索 → 判断 → 工具调用 → 汇总输出。

    Workflow 的优势非常明显:

    • 流程清晰,行为可预期;
    • 易于测试和运营;
    • 对非工程人员友好。

    但问题也同样明显:流程完全由人设计,模型只是执行者。无论问题复杂与否,都必须走完整条路径。这种方式在应对高度动态或非标准任务时,灵活性有限。

    第五阶段:Agent

    在 Agent 出现之前,大多数 AI 应用仍然遵循一种典型模式:输入单次/编排好的推理输出

    而 Agent 的出现,本质上是将“任务编排”的控制权从人类手中交还给了 AI。在 Agent 架构下,AI 不再是被动执行一段代码,而是一个具备以下核心能力的闭环系统:

    • 将复杂目标拆解为多个可执行步骤;
    • 根据工具执行结果调整后续行动;
    • 在失败时尝试修正策略;
    • 在多步过程中维护上下文状态。

    这些能力并不是一次模型调用完成的,而是通过多轮推理与执行形成闭环。也正是在这一点上,Agent 与前面的应用形态拉开了差距。

    Agent 设计模式解决的问题

    当 Agent 开始承担更复杂的任务时,问题也随之出现:

    • 多步推理容易跑偏;
    • 执行失败后缺乏统一的修正策略;
    • 成本和稳定性难以控制。

    Agent 设计模式的作用,就是把这些反复出现的问题抽象成可复用的结构。

    无论是 ReAct,还是 Plan and Execute,它们关注的核心并不是“让模型更聪明”,而是:如何在工程上组织模型的推理、行动和反馈过程,使系统整体可控、可维护。

    理解这些模式,有助于我们在构建 Agent 系统时少走弯路,而不是每一次都从零开始设计整套交互与控制逻辑。

    结语

    从最初基于 Prompt 的简单对话,到如今具备一定自主能力的 Agent,我们看到的不只是模型能力的提升,更是 AI 在实际使用方式上的变化。

    回顾整个过程会发现,很多关键技术并不是最近才出现的。RAG 的核心思路早在几年前就已经被提出,ReAct 也并非新概念,只是在最近随着模型推理能力提升、工具链逐渐成熟,才真正具备了工程落地的条件。很多时候,并不是想法不存在,而是时机还没到。

    理解这些演进背景,有助于我们判断哪些能力是短期噱头,哪些是长期方向。下一篇文章将聚焦 Agent 设计模式中最常见、也最实用的 ReAct 模式,结合实际实现,看看它是如何让 AI 在执行任务的过程中逐步思考、不断调整策略的。

    参考资料

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

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

    线性颜色空间与伽马颜色空间基础概念

    在计算机图形学中,颜色空间的管理是渲染流程中至关重要的环节。理解线性颜色空间和伽马颜色空间的区别对于创建逼真的渲染效果至关重要。

    线性颜色空间指的是颜色数值与实际物理光强呈线性关系的颜色表示方式。在这种空间中,颜色值0.5表示的光强正好是颜色值1.0的一半。这种表示方式符合物理世界的真实光照行为,在渲染计算中能够产生准确的数学结果。

    伽马颜色空间则是为了适应传统显示设备的非线性特性而设计的颜色表示系统。由于CRT显示器以及其他显示设备对输入信号的响应不是线性的,实际上显示设备会对输入信号进行伽马变换。在伽马空间中,颜色数值与最终显示的亮度之间不是简单的线性关系,而是遵循一个幂函数关系。

    伽马校正的历史背景

    伽马校正的概念起源于早期的CRT显示器时代。CRT显示器具有固有的非线性响应特性,其亮度输出与输入电压之间的关系大致符合幂函数规律,指数约为2.2。这意味着即使输入线性的颜色信号,显示器也会自动应用一个近似的伽马变换。

    为了补偿这种非线性,图像和视频内容在创建时就会预先应用一个反向的伽马变换(指数约为1/2.2),这样当内容在显示器上显示时,两者的变换相互抵消,最终用户看到的就是正确的线性亮度关系。这种预处理过程就是所谓的伽马校正。

    现代渲染中的伽马处理

    在现代渲染管线中,虽然CRT显示器已逐渐被LCD、OLED等新技术取代,但伽马校正的概念仍然非常重要。主要原因包括:

    • 向后兼容性:大量现有的图像内容和标准都是基于伽马空间设计的
    • 感知均匀性:人类视觉系统对亮度的感知也是非线性的,伽马编码可以更有效地利用有限的比特深度
    • 行业标准:sRGB等现代颜色标准仍然建立在伽马编码的基础上

    LinearToGammaSpaceExact节点核心功能

    LinearToGammaSpaceExact节点是Unity URP Shader Graph中专门用于颜色空间转换的工具节点。该节点执行从线性颜色空间到伽马颜色空间的精确数学转换,确保颜色数据在不同空间之间的准确转换。

    数学原理

    LinearToGammaSpaceExact节点实现的数学转换基于标准的sRGB伽马校正公式。转换过程使用分段函数来精确处理整个数值范围:

    对于输入值小于或等于0.0031308的情况:

    伽马值 = 12.92 × 线性值
    

    对于输入值大于0.0031308的情况:

    伽马值 = 1.055 × 线性值^(1/2.4) - 0.055
    

    这种分段处理确保了在低亮度区域的线性关系和较高亮度区域的幂函数关系之间的平滑过渡,符合sRGB标准规范。

    与近似方法的区别

    Unity提供了两种伽马转换方法:LinearToGammaSpaceExact和LinearToGammaSpace。两者的主要区别在于精度和性能:

    • LinearToGammaSpaceExact:使用精确的sRGB转换公式,计算结果准确但计算量稍大
    • LinearToGammaSpace:使用近似的转换公式(通常为线性值^(1/2.2)),计算速度快但精度略低

    在大多数情况下,两种方法的视觉差异不大,但在需要严格颜色准确性的场景中(如专业图像处理、颜色分级等),应优先使用Exact版本。

    节点接口详解

    输入端口

    In输入端口接收Float类型的数值,代表线性颜色空间中的颜色分量。该端口可以接受以下类型的数值:

    • 单个浮点数值:表示单颜色通道的线性值
    • 二维向量:表示两个颜色通道的线性值
    • 三维向量:表示RGB颜色的线性值
    • 四维向量:表示RGBA颜色的线性值,包括透明度

    输入值的有效范围通常是[0,1],但节点也可以处理超出此范围的HDR值,转换时会保持数值的相对关系。

    输出端口

    Out输出端口返回转换后的Float类型数值,表示伽马颜色空间中的颜色分量。输出值的范围与输入相对应:

    • 对于标准范围[0,1]的输入,输出也在[0,1]范围内
    • 对于HDR值(大于1),输出会保持相应的相对亮度关系

    输出数据类型与输入保持一致,如果输入是向量类型,输出也是相应的向量类型,每个分量都独立进行伽马转换。

    实际应用场景

    后处理效果中的颜色校正

    在后处理渲染中,LinearToGammaSpaceExact节点常用于将线性空间的计算结果转换为适合显示的伽马空间。例如,在实现色彩分级、色调映射或Bloom效果时:

    • 在色调映射过程中,首先在线性空间中进行亮度压缩和颜色调整
    • 使用LinearToGammaSpaceExact将结果转换到伽马空间
    • 最终输出到屏幕缓冲区,确保显示设备能正确呈现

    这种工作流程保证了颜色处理的准确性,避免了因颜色空间不匹配导致的颜色失真。

    UI元素与渲染结果的混合

    当需要将3D渲染结果与UI元素结合时,正确管理颜色空间至关重要:

    • 3D渲染通常在线性空间中进行计算
    • UI元素和纹理通常存储在伽马空间中
    • 使用LinearToGammaSpaceExact可以将渲染结果转换到与UI一致的颜色空间
    • 确保混合后的视觉效果颜色一致,没有明显的界限或差异

    自定义光照模型开发

    在开发自定义光照模型时,正确管理颜色空间是保证光照计算准确性的关键:

    • 光照计算在线性空间中执行,符合物理规律
    • 使用LinearToGammaSpaceExact将最终光照结果转换到显示空间
    • 确保光照的亮度和颜色关系在显示时保持正确

    特别是在实现复杂的PBR材质时,颜色空间的正确转换对于金属度、粗糙度等参数的准确表现尤为重要。

    使用示例与案例分析

    基础颜色空间转换

    以下是一个简单的Shader Graph设置,演示如何使用LinearToGammaSpaceExact节点进行基本的颜色空间转换:

    • 创建Color节点作为线性空间的颜色输入
    • 将Color节点连接到LinearToGammaSpaceExact节点的In端口
    • 将LinearToGammaSpaceExact节点的Out端口连接到主节点的Base Color输入
    • 通过调节输入颜色,观察转换前后颜色的变化

    这种基础设置可以帮助理解线性空间与伽马空间之间颜色表现的差异,特别是在中等亮度区域,差异最为明显。

    HDR颜色处理案例

    在处理高动态范围颜色时,LinearToGammaSpaceExact节点的行为值得特别关注:

    // 假设在线性空间中有以下HDR颜色值
    float3 linearColor = float3(2.0, 1.0, 0.5);
    
    // 应用LinearToGammaSpaceExact转换
    float3 gammaColor = LinearToGammaSpaceExact(linearColor);
    
    // 结果会保持相对的亮度关系
    // 但数值可能超出标准[0,1]范围
    

    在实际应用中,通常会在伽马转换前先进行色调映射,将HDR值压缩到显示设备能够处理的范围内。

    自定义后处理效果实现

    下面是一个实现简单颜色分级效果的Shader Graph示例:

    • 使用Scene Color节点获取当前渲染的线性空间颜色
    • 应用颜色调整节点(如对比度、饱和度、色相调整)
    • 所有调整在线性空间中执行,保证计算准确性
    • 使用LinearToGammaSpaceExact节点将结果转换到伽马空间
    • 输出到Blit命令或后处理堆栈

    这种方法确保了颜色调整的物理准确性,避免了在伽马空间中进行调整可能引入的数学错误。

    性能考量与优化建议

    计算开销分析

    LinearToGammaSpaceExact节点的计算开销主要来自幂函数计算和条件判断。虽然单个节点的开销不大,但在像素着色器中大量使用时仍需注意:

    • 每个像素至少需要执行一次条件判断和一次幂运算
    • 在高分辨率渲染中,这些操作会累积成可观的计算量
    • 在移动平台或性能受限的环境中应谨慎使用

    优化策略

    针对性能敏感的场景,可以考虑以下优化策略:

    • 在顶点着色器中进行转换:如果颜色数据在顶点间变化不大,可以在顶点阶段进行转换
    • 使用近似版本:在视觉要求不高的场景中,使用LinearToGammaSpace替代Exact版本
    • 批量处理:将多个颜色通道的转换合并处理,减少条件判断次数
    • LUT优化:对于固定的颜色转换,可以使用查找表替代实时计算

    平台特异性考虑

    不同硬件平台对超越函数(如幂运算)的支持程度不同:

    • 现代桌面GPU通常有专门的硬件单元处理这类运算,效率较高
    • 移动GPU可能通过软件模拟,效率相对较低
    • 在针对多平台开发时,应测试目标平台的性能表现

    常见问题与解决方案

    颜色显示不一致问题

    在使用LinearToGammaSpaceExact节点时,可能会遇到颜色显示不一致的问题:

    • 问题表现:在不同设备或不同查看条件下颜色显示有差异
    • 可能原因:颜色空间配置错误、显示器校准不一致、图像格式不匹配
    • 解决方案:确保整个渲染管线颜色空间设置一致,使用标准颜色配置文件,定期校准显示设备

    性能瓶颈识别与解决

    如果发现使用LinearToGammaSpaceExact节点后性能下降:

    • 使用Unity Profiler分析着色器执行时间
    • 检查是否在不需要精确转换的地方使用了Exact版本
    • 考虑将转换移到渲染管线的后期阶段,减少重复计算
    • 评估是否可以使用更简化的颜色空间处理方案

    HDR与LDR工作流切换

    在HDR和LDR渲染管线之间切换时,颜色空间处理需要特别注意:

    • HDR管线通常在线性空间中处理更多计算
    • LDR管线可能混合使用线性和伽马空间
    • 使用LinearToGammaSpaceExact节点时应明确当前的颜色空间状态
    • 建立统一的颜色空间管理策略,确保在不同管线间的一致性

    最佳实践总结

    正确使用LinearToGammaSpaceExact节点需要遵循一系列最佳实践:

    • 始终了解数据当前所处的颜色空间,在线性空间中进行光照和颜色计算
    • 仅在最终输出到屏幕或非浮点格式纹理时进行伽马转换
    • 在需要最高颜色准确性的场景中使用Exact版本,其他情况可考虑使用近似版本
    • 建立项目统一的颜色空间管理规范,避免混乱的颜色空间使用
    • 定期测试在不同显示设备上的颜色表现,确保一致性
    • 文档化颜色空间决策,便于团队协作和后续维护

    通过遵循这些实践原则,可以确保渲染结果的视觉准确性,同时在性能和画质之间取得良好平衡。LinearToGammaSpaceExact节点作为颜色管理工具箱中的重要组件,在正确的使用场景下能够显著提升渲染质量。


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

    【Gemini简直无敌了】掌间星河:通过MediaPipe实现手势控制粒子

    受掘金大佬“不如摸鱼去”的启发,我也试试 Gemini 3做一下手势+粒子交互, 确实学到了不少东西,在这里简单的分享下。 github地址:掌间星河:github.com/huijieya/Ge…

    效果展示

    在这里插入图片描述

    基于原生h5、浏览器、PC摄像头实现手势控制粒子特效交互的逻辑,粒子默认离散,类似银河系分布缓慢移动,同时有5种手势:
    
    手势1: 握拳,握拳后粒子聚拢显示爱心的形状
    
    手势2: 展开手掌并挥手,展开手掌挥手后粒子从当前状态恢复到离散状态
    
    手势3: 👆 比 1 :只有食指伸直,其他 3 根弯曲,此时粒子聚拢显示第一句话:春来夏往
    
    手势4: ✌ 比 2 :只有食指和中指伸直,其他 2 根弯曲手此时粒子聚拢显示第二句话: 秋收冬藏
    
    手势5: 👌 比 3 :只有中指、无名指、小指伸直,食指弯曲,此时粒子聚拢显示第三句话:我们来日方长
    

    源码地址

    掌间星河:github.com/huijieya/Ge…

    源码分析

    手势识别流程

    1. 手部检测初始化

    // HandTracker.tsx 中初始化 MediaPipe Hands
    const hands = new (window as any).Hands({
      locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
    });
    
    hands.setOptions({
      maxNumHands: 1,           // 最多检测一只手
      modelComplexity: 1,       // 模型复杂度
      minDetectionConfidence: 0.7,  // 最小检测置信度
      minTrackingConfidence: 0.7    // 最小跟踪置信度
    });
    

    2. 手部关键点获取

    MediaPipe 会检测手部的21个关键点(landmarks),每个关键点包含 x, y, z 坐标:

    • 指尖: 4(拇指), 8(食指), 12(中指), 16(无名指), 20(小指)
    • 指关节: 用于判断手指是否伸直
    扩展:MediaPipe

    MediaPipe 是由 Google 开发并开源的实时机器学习框架,主要用于构建跨平台的多媒体处理应用,尤其擅长计算机视觉任务。它采用数据流图(Graph)的方式组织模块,支持在设备端(如手机、嵌入式设备)高效运行机器学习模型。

    该框架的核心功能包括:

    • 人脸检测‌:提供轻量级模型(如 BlazeFace),可在短距离(前置摄像头)或全范围(后置摄像头)图像中检测人脸,并返回边界框和关键点信息。‌

    • 手部识别‌:检测手部关键点(如21个坐标),支持手势控制、虚拟键盘等交互应用。‌

    • 人体姿态估计‌:识别人体33个关键点,用于动作分析(如健身、舞蹈)或AR滤镜。‌

    • 背景分割‌:实现人物与背景分离,支持虚化或替换背景功能。‌

    • 其他功能‌:还包括虹膜检测、目标追踪、3D物体检测等解决方案,覆盖从基础检测到高级分析的多种需求。‌

    3. 手势分类逻辑

    // gestureLogic.ts 中的 classifyGesture 函数
    const isExtended = (tipIdx: number, mcpIdx: number) => landmarks[tipIdx].y < landmarks[mcpIdx].y;
    
    // 判断各手指是否伸直
    const indexExt = isExtended(8, 5);   // 食指
    const middleExt = isExtended(12, 9); // 中指
    const ringExt = isExtended(16, 13);  // 无名指
    const pinkyExt = isExtended(20, 17); // 小指
    
    // 根据手指状态识别不同手势
    if (!indexExt && !middleExt && !ringExt && !pinkyExt) {
      return GestureType.HEART; // 握拳 - 显示爱心
    }
    if (indexExt && middleExt && ringExt && pinkyExt) {
      return GestureType.GALAXY; // 手掌展开 - 银河状态
    }
    // ... 其他手势判断
    

    粒子绘制机制

    1. 粒子系统初始化

    // ParticleCanvas.tsx 中初始化粒子
    useEffect(() => {
      const particles: Particle[] = [];
      for (let i = 0; i < PARTICLE_COUNT; i++) {
        particles.push({
          x: Math.random() * window.innerWidth,     // 随机初始位置
          y: Math.random() * window.innerHeight,
          targetX: Math.random() * window.innerWidth, // 目标位置
          targetY: Math.random() * window.innerHeight,
          vx: 0,                                    // 速度
          vy: 0,
          size: Math.random() * 1.5 + 0.5,          // 大小
          color: COLORS[Math.floor(Math.random() * COLORS.length)], // 颜色
          alpha: Math.random() * 0.4 + 0.4,         // 透明度
        });
      }
      particlesRef.current = particles;
    }, []);
    

    2. 形状生成算法

    const getShapePoints = (type: GestureType, width: number, height: number): Point[] => {
      const centerX = width / 2;
      const centerY = height / 2;
      
      switch (type) {
        case GestureType.HEART: 
          // 心形方程参数化生成点
          // x = 16sin³(t)
          // y = 13cos(t) - 5cos(2t) - 2cos(3t) - cos(4t)
          
        case GestureType.TEXT_1/2/3:
          // 使用 Canvas 绘制文字并提取像素点
          
        case GestureType.GALAXY:
        default:
          // 螺旋银河形状
          const angle = Math.random() * Math.PI * 2;
          const r = Math.pow(Math.random(), 0.7) * maxRadius;
          const spiralFactor = 2.0;
          const offset = r * (spiralFactor / maxRadius) * 5;
          points.push({ 
            x: centerX + Math.cos(angle + offset) * r, 
            y: centerY + Math.sin(angle + offset) * r 
          });
      }
    }
    

    3. 粒子动画更新

    // 粒子运动和渲染循环
    const render = () => {
      // 半透明背景覆盖产生拖尾效果
      ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      
      particlesRef.current.forEach((p) => {
        // 平滑插值移动到目标位置
        p.x += (tx - p.x) * LERP_FACTOR;
        p.y += (ty - p.y) * LERP_FACTOR;
        
        // 手势交互影响粒子位置
        if (hPos && canInteract) {
          const dx = p.x - hPos.x;
          const dy = p.y - hPos.y;
          const distSq = dx * dx + dy * dy;
          if (distSq < INTERACTION_RADIUS * INTERACTION_RADIUS) {
            // 排斥力计算
            const dist = Math.sqrt(distSq);
            const force = (1 - dist / INTERACTION_RADIUS) * INTERACTION_STRENGTH;
            p.x += dx * force;
            p.y += dy * force;
          }
        }
        
        // 添加随机扰动使粒子更生动
        p.x += (Math.random() - 0.5) * 0.6;
        p.y += (Math.random() - 0.5) * 0.6;
        
        // 绘制粒子
        ctx.fillStyle = p.color;
        ctx.globalAlpha = p.alpha;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fill();
      });
      
      animationRef.current = requestAnimationFrame(render);
    };
    

    4. 银河旋转效果

    // 银河状态下的旋转动画
    galaxyAngleRef.current += GALAXY_ROTATION_SPEED;
    const cosA = Math.cos(galaxyAngleRef.current);
    const sinA = Math.sin(galaxyAngleRef.current);
    
    // 对每个粒子应用旋转变换
    const dx = p.targetX - cx;
    const dy = p.targetY - cy;
    tx = cx + dx * cosA - dy * sinA;
    ty = cy + dx * sinA + dy * cosA;
    

    在这里插入图片描述

    脚手架开发工具——dotenv

    简介

    dotenv 是一个轻量级的 Node.js 环境变量管理工具,其核心作用是:从项目根目录的 .env 文件中加载自定义的环境变量,并将它们注入到 Node.js 的 process.env 对象中,使得我们可以在项目代码中统一通过 process.env.XXX 的方式获取这些环境配置,无需手动在系统环境中配置临时变量或永久变量。

    核心工作原理

    1. 当在项目中引入并执行 dotenv 时,它会自动查找项目根目录下的 .env 文件(该文件为纯文本格式,采用键值对配置);
    2. 它会解析 .env 文件中的每一行配置(格式通常为 KEY=VALUE);
    3. 将解析后的键值对逐一挂载到 Node.js 内置的 process.env 对象上(process.env 原本用于存储系统级环境变量,dotenv 为其扩展了项目自定义环境变量);
    4. 之后在项目的任意代码文件中,都可以通过 process.env.KEY 的形式获取对应的值。

    使用示例

    npm install dotenv --save
    
    # .env 文件内容 当前用户路径下创建 `.env` 文件
    PORT=3000
    DB_HOST=localhost
    DB_USER=root
    DB_PASSWORD=123456
    API_KEY=abcdefg123456
    
        const dotenv = require('dotenv');
        const dotenvPath = path.resolve(userHome, '.env'); // /Users/***/.env
        if (pathExists(dotenvPath)) {
            dotenv.config({
                path: dotenvPath,
            });
        }
    

    vscode没有js提示:配置jsconfig配置

    jsconfig.json是一个配置文件,它的核心作用是告诉 Visual Studio Code(VSCode)当前目录是一个 JavaScript 项目的根目录,从而为你的代码提供更强大的智能感知(IntelliSense)和语言支持。

    下面这个表格概括了它的主要作用:

    核心作用 解决的问题 简单示例
    定义项目上下文 没有它时,VSCode 将每个 JS 文件视为独立单元,文件间缺乏关联性。有了它,VSCode 能将整个项目作为一个整体理解。 {}(一个空文件即可定义项目)
    配置路径别名映射 当项目使用像 @这样的别名来代表 src目录时,VSCode 默认无法识别。配置后,可以实现路径的自动补全和点击跳转 "paths": { "@/*": ["src/*"] }
    提升 IDE 性能 通过排除不必要的文件(如 node_modules, dist),让语言服务专注于源代码,避免 IntelliSense 变慢 "exclude": ["node_modules", "dist"]
    调整语言服务选项 配置 JavaScript 的语言检查标准,例如启用实验性语法支持(如装饰器)或指定 ECMAScript 目标版本。 "experimentalDecorators": true

    💡 详细解读与配置

    • 定义项目上下文:在没有 jsconfig.json的“文件范围(File Scope)”模式下,VSCode 虽然能为单个文件提供基础语法高亮,但难以准确分析文件之间的模块引用关系。创建 jsconfig.json后,项目进入“显式项目(Explicit Project)”模式,VSCode 的语言服务能理解项目的整体结构,从而提供更精确的代码补全、类型推断和错误检查。

    • 配置路径映射(Paths Mapping) :这是在前端项目中非常实用的功能。许多项目使用 Webpack 或 Vite 等构建工具配置了路径别名,但在代码编辑器中,这些别名默认无法被识别。通过在 jsconfig.json中配置 paths,即可让 VSCode 理解这些别名。

      {
        "compilerOptions": {
          "baseUrl": "./", // 设置基础目录
          "paths": {
            "@/*": ["src/*"],    // 将 @ 映射到 src 目录
            "components/*": ["src/components/*"] // 配置其他别名
          }
        }
      }
      

      配置后,当你输入 import App from '@/App',VSCode 就能知道 @指向 src目录,并提供自动补全和跳转功能。

    • 优化性能(Exclude) :JavaScript 语言服务会分析项目中的文件来提供 IntelliSense。如果它去解析庞大的 node_modules或构建输出的 dist目录,会严重拖慢速度。使用 exclude属性可以告诉语言服务忽略这些目录。

      {
        "exclude": ["node_modules", "dist", "build", "*.min.js"]
      }
      

    🛠️ 创建与配置示例

    你可以在项目的根目录下创建一个名为 jsconfig.json的文件。一个适用于现代前端项目(如 Vue、React)的常见配置如下:

    {
      "compilerOptions": {
        "target": "ESNext",
        "module": "ESNext",
        "moduleResolution": "node",
        "baseUrl": "./",
        "paths": {
          "@/*": ["src/*"]
        },
        "allowSyntheticDefaultImports": true,
        "experimentalDecorators": true,
        "lib": ["esnext", "dom", "dom.iterable"]
      },
      "include": ["src/**/*"],
      "exclude": ["node_modules", "dist", "build"]
    }
    

    配置项说明

    • compilerOptions:虽然名字叫“编译选项”,但它主要用于配置 VSCode 的 JavaScript 语言服务行为,因为 jsconfig.json源自 TypeScript 的 tsconfig.json
    • include:明确指定哪些文件属于项目。如果未设置,则默认包含所有子目录下的文件。
    • exclude:指定要排除的文件和文件夹。

    💎 总结

    总而言之,jsconfig.json就像是你在 VSCode 中的项目地图和说明书。它通过定义项目根目录、映射路径别名、排除无关文件等方式,显著提升了代码编辑的智能体验、导航效率和整体性能。对于任何有一定规模的 JavaScript/TypeScript 项目,配置一个 jsconfig.json都是非常值得的。

    希望这些信息能帮助你更好地理解和使用 jsconfig.json。如果你在配置过程中遇到具体问题,例如如何为特定框架进行优化,我很乐意提供进一步的帮助。

    Koa 源码深度解析:带你理解 Koa 的设计哲学和核心实现原理

    Koa 源码深度解析:带你理解 Koa 的设计哲学和核心实现原理

    注:本文只讲koa源码与核心实现,无应用层相关知识

    一、Koa 的设计哲学

    1.1 什么是koa

    Koa 是由 Express 原班人马打造的下一代 Node.js Web 框架。相比于 Express,Koa 利用 ES2017 的 async/await 特性,让异步代码的编写变得更加优雅和可维护。本文将深入解析 Koa 的核心源码,帮助你理解其设计哲学和实现原理。

    1.2 核心设计理念

    Koa 的设计理念可以概括为:

    // Koa 应用本质上是一个包含中间件函数数组的对象
    // 这些中间件以类似栈的方式组合和执行
    const Koa = require('koa');
    const app = new Koa();
    
    // 中间件以"洋葱模型"方式执行
    app.use(async (ctx, next) => {
      // 请求阶段(向下)
      await next();
      // 响应阶段(向上)
    });
    

    官方文档这样描述:

    A Koa application is an object containing an array of middleware functions which are composed and executed in a stack-like manner upon request.

    二、源码核心流程解析

    在深入源码之前,我们先理解一下 Koa 应用从创建到处理请求的完整生命周期。一个典型的 Koa 应用会经历以下几个阶段:

    1. 创建应用实例 - new Koa() 初始化应用对象
    2. 注册中间件 - app.use() 将中间件函数添加到数组
    3. 启动监听 - app.listen() 创建 HTTP 服务并开始监听
    4. 处理请求 - 当请求到来时,组合中间件并执行

    接下来我们逐步剖析每个阶段的源码实现。

    2.1 创建Application

    当我们执行 const app = new Koa() 时,Koa 内部做了哪些初始化工作呢?让我们看看 Application 类的构造函数:

    class Application {
    
      constructor (options) {
        ......
        options = options || {}
        this.compose = options.compose || compose // 组合中间件的函数,这是实现洋葱模型的关键
        this.middleware = []                      // 中间件数组,所有通过 use() 注册的中间件都会存储在这里
        this.context = Object.create(context)     // 上下文对象的原型,每个请求会基于它创建独立的 ctx
        this.request = Object.create(request)     // 请求对象的原型,封装了对 Node.js 原生 req 的访问
        this.response = Object.create(response);  // 响应对象的原型,封装了对 Node.js 原生 res 的访问
        ......
       }
    

    为什么使用 Object.create() 而不是直接赋值?

    这是一个非常巧妙的设计。使用 Object.create() 创建原型链,意味着:

    • 每个应用实例都有自己独立的 contextrequestresponse 对象
    • 这些对象继承自共享的原型,既节省内存又保证了隔离性
    • 可以在不同应用实例上挂载不同的扩展属性,互不影响

    2.2 注册中间件

    中间件是 Koa 的核心概念。通过 app.use() 方法,我们可以注册各种中间件来处理请求。让我们看一个实际例子:

    // 请求日志中间件:记录请求的 URL 和响应时间
    const logMiddleware = async (ctx, next) => {
      const start = Date.now();  // 记录开始时间
      await next();              // 等待后续中间件执行完毕
      const end = Date.now();    // 记录结束时间
      console.log(`${ctx.method} ${ctx.url} - ${end - start}ms`);
    };
    
    app.use(logMiddleware);
    
    class Application {
      use(fn) {
        // 注册中间件:将中间件函数添加到数组末尾
        this.middleware.push(fn);
        return this; // 返回 this 支持链式调用
      }
    }
    

    use() 方法的设计亮点:

    1. 简单直接 - 只是将中间件函数 push 到数组,没有复杂的逻辑
    2. 链式调用 - 返回 this 使得可以连续调用 app.use().use().use()
    3. 顺序敏感 - 中间件的执行顺序取决于注册顺序,这对理解洋葱模型很重要

    注意上面的日志中间件示例:await next() 是一个分水岭,它将中间件分为"请求阶段"和"响应阶段"。这正是洋葱模型的精髓所在。

    2.3 创建context

    每当有新的 HTTP 请求到来时,Koa 都会为这个请求创建一个全新的 context 对象。这个对象是 Koa 最重要的创新之一,它封装了 Node.js 原生的 reqres,提供了更加便捷的 API。

    createContext(req, res) {
      // 基于应用的 context 原型创建新的 context 实例
      const context = Object.create(this.context);
      // 基于应用的 request 和 response 原型创建新的实例
      const request = context.request = Object.create(this.request);
      const response = context.response = Object.create(this.response);
    
      // 建立各对象之间的引用关系
      context.app = request.app = response.app = this;        // 都持有 app 实例的引用
      context.req = request.req = response.req = req;         // 都持有 Node.js 原生 req 的引用
      context.res = request.res = response.res = res;         // 都持有 Node.js 原生 res 的引用
    
      // 建立 context、request、response 之间的相互引用
      request.ctx = response.ctx = context;                   // request 和 response 都能访问 context
      request.response = response;                            // request 能访问 response
      response.request = request;                             // response 能访问 request
    
      return context;
    }
    
    

    这个方法的精妙之处:

    1. 原型继承 - 使用 Object.create() 确保每个请求都有独立的 context,但共享原型上的方法
    2. 四层封装 - contextrequest/responsereq/res,逐层抽象,提供更优雅的 API
    3. 相互引用 - 建立了复杂但合理的引用关系,使得在任何层级都能方便地访问其他对象
    4. 内存优化 - 通过原型链共享方法,避免每个请求都创建重复的方法副本

    这样设计的好处是,在中间件中我们可以灵活地访问:

    • ctx.req / ctx.res - 访问 Node.js 原生对象
    • ctx.request / ctx.response - 访问 Koa 封装的对象
    • ctx.body / ctx.status - 使用 Koa 的便捷属性(代理到 response)

    2.4 启动监听服务

    当所有中间件注册完成后,我们需要启动 HTTP 服务器开始监听请求。

    // 用户代码:启动服务器
    app.listen(3000, () => {
      console.log('Server is running on port 3000');
    });
    
    // Koa 内部实现
    class Application {
      listen (...args) {
        debug('listen')
        // 创建 Node.js HTTP 服务器,传入 callback 作为请求处理函数
        const server = http.createServer(this.callback())
        return server.listen(...args) // 返回 Node.js 的 http.Server 实例
      }
    
      // callback 方法返回一个符合 Node.js http.createServer 要求的请求处理函数
      callback() {
        // ⭐️ 核心:使用 compose 将所有中间件组合成一个函数
        // 这就是洋葱模型的实现入口!
        const fn = compose(this.middleware);
    
        // 如果没有监听 error 事件,添加默认的错误处理器
        if (!this.listenerCount('error')) this.on('error', this.onerror);
    
        // 返回请求处理函数,Node.js 会在每次请求到来时调用它
        return (req, res) => {
          // 为这个请求创建独立的 context
          const ctx = this.createContext(req, res);
          // 执行组合后的中间件函数,传入 context
          return this.handleRequest(ctx, fn);
        };
      }
    }
    

    流程分解:

    1. app.listen() - 这只是对 Node.js 原生 API 的薄封装
    2. this.callback() - 这里是魔法发生的地方:
      • 调用 compose(this.middleware) 将所有中间件组合成一个函数
      • 返回一个闭包函数,每次请求时被调用
    3. 请求处理 - 当请求到来时:
      • 创建本次请求专属的 ctx 对象
      • 执行组合后的中间件函数 fn(ctx)
      • 所有中间件共享同一个 ctx

    关键点:compose(this.middleware)

    这行代码是理解 Koa 的关键。它将一个中间件数组:

    [middleware1, middleware2, middleware3]
    

    转换成一个嵌套的调用链:

    middleware1(ctx, () => {
      middleware2(ctx, () => {
        middleware3(ctx, () => {
          // 最内层
        })
      })
    })
    

    这就是著名的"洋葱模型"的实现基础。接下来我们将深入剖析 compose 函数的源码。

    三、洋葱模型:中间件的优雅编排

    3.1 什么是洋葱模型?

    Koa 的中间件执行机制被形象地称为"洋葱模型"。中间件的执行过程类似于剥洋葱:

    1. 请求阶段(外层到内层):从第一个中间件开始,遇到 await next() 就进入下一个中间件
    2. 响应阶段(内层到外层):最内层中间件执行完毕后,依次返回到外层中间件

    3.2 compose 源码解析与实现

    compose 函数是 koa-compose 包提供的,它是实现洋葱模型的核心。让我们先看看官方源码:

    function compose(middleware) {
      // compose 返回一个函数,这个函数接收 context 和一个可选的 next
      return function (context, next) {
        let index = -1;  // 用于记录当前执行到第几个中间件
    
        // dispatch 函数负责执行第 i 个中间件
        function dispatch(i) {
          // 防止在同一个中间件中多次调用 next()
          // 如果 i <= index,说明 next() 被调用了多次
          if (i <= index) {
            return Promise.reject(new Error('next() 被多次调用'));
          }
    
          index = i; // 更新当前中间件索引,用于防止 next 被多次调用
          let fn = middleware[i];  // 获取当前要执行的中间件
    
          // 如果已经是最后一个中间件,fn 设为传入的 next(通常为 undefined)
          if (i === middleware.length) fn = next;
          // 如果 fn 不存在,说明已经到达末尾,返回一个 resolved 的 Promise
          if (!fn) return Promise.resolve();
    
          try {
            // ⭐️ 核心逻辑:执行当前中间件,并将 dispatch(i + 1) 作为 next 参数传入
            // 这样当中间件调用 await next() 时,实际上是在调用 dispatch(i + 1)
            // 从而递归地执行下一个中间件
            return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
          } catch (err) {
            return Promise.reject(err);
          }
        }
    
        // 从第一个中间件开始执行
        return dispatch(0);
      };
    }
    

    这个函数的精妙之处:

    1. 闭包保存状态 - 通过闭包保存 index,防止 next() 被重复调用,这是一个重要的安全检查
    2. 递归调用链 - dispatch(i) 执行当前中间件,并将 dispatch(i + 1) 作为 next 传入
    3. Promise 包装 - 所有中间件都被包装成 Promise,支持 async/await 语法
    4. 懒执行 - 只有当中间件调用 await next() 时,下一个中间件才会执行

    执行流程可视化:

    假设有三个中间件 [m1, m2, m3]

    dispatch(0) 执行 m1(ctx, dispatch(1))
      ↓
      m1 执行到 await next()
      ↓
      dispatch(1) 执行 m2(ctx, dispatch(2))
        ↓
        m2 执行到 await next()
        ↓
        dispatch(2) 执行 m3(ctx, dispatch(3))
          ↓
          m3 执行完毕
        ↓
        m2 的 next() 后的代码执行
      ↓
      m1 的 next() 后的代码执行
    

    源码使用递归实现,初看可能有些难懂。没关系,下面我们来实现一个简化版本,帮助理解核心思想。

    3.3 手写简易版 compose

    核心思想:在当前中间件执行过程中,让 next() 函数能够自动执行下一个中间件,直到最后一个。

    const compose = (middleware) => {
      const ctx = {};  // 创建一个上下文对象
    
      if (middleware.length === 0) {
        return;
      }
    
      let index = 0;
      const fn = middleware[index]; // 获取第一个中间件
      fn(ctx, next);                // 手动执行第一个中间件
    
      // 实现 next() 函数
      // 核心是在当前中间件执行过程中,获取下一个中间件函数并自动执行,直到最后一个
      async function next() {
        index++;  // 移动到下一个中间件
    
        // 如果已经是最后一个中间件,直接返回
        if (index >= middleware.length) {
          return;
        }
    
        const fn = middleware[index];  // 获取下一个中间件
        return await fn(ctx, next);    // 执行下一个中间件,并传入 next
      }
    };
    
    // 定义三个测试中间件
    const middleware1 = (ctx, next) => {
      console.log(">> one");
      next();                 // 调用 next(),执行 middleware2
      console.log("<< one");
    };
    
    const middleware2 = (ctx, next) => {
      console.log(">> two");
      next();                 // 调用 next(),执行 middleware3
      console.log("<< two");
    };
    
    const middleware3 = (ctx, next) => {
      console.log(">> three");
      next();                 // 已经是最后一个,next() 直接返回
      console.log("<< three");
    };
    
    // 执行组合后的中间件
    compose([middleware1, middleware2, middleware3]);
    
    // 输出:
    // >> one
    // >> two
    // >> three
    // << three
    // << two
    // << one
    

    关键理解点:

    1. 同步执行 - 当 middleware1 调用 next() 时,middleware2 会立即开始执行
    2. 栈式回溯 - 当 middleware3 执行完毕后,控制权会依次返回到 middleware2middleware1next() 之后
    3. 洋葱结构 - 这就形成了"进入"和"退出"两个阶段,像剥洋葱一样

    执行顺序详解:

    1. middleware1 开始执行 → 打印 ">> one"
    2. middleware1 调用 next() → 暂停,进入 middleware2
    3. middleware2 开始执行 → 打印 ">> two"
    4. middleware2 调用 next() → 暂停,进入 middleware3
    5. middleware3 开始执行 → 打印 ">> three"
    6. middleware3 调用 next() → 返回(已是最后一个)
    7. middleware3 继续执行 → 打印 "<< three"
    8. middleware3 执行完毕 → 返回到 middleware2
    9. middleware2 继续执行 → 打印 "<< two"
    10. middleware2 执行完毕 → 返回到 middleware1
    11. middleware1 继续执行 → 打印 "<< one"
    

    建议: 使用 VSCode 的断点调试功能,在每个中间件的 next() 前后打断点,单步执行体会代码的具体执行过程。这样能够更直观地理解洋葱模型的运作机制。

    image.png

    四、总结

    通过对 Koa 源码的深入分析,我们可以看到它的设计哲学:极简、优雅、灵活

    参考资源


    如果觉得有帮助,欢迎点赞收藏,也欢迎在评论区交流讨论!

    Promise :从基础原理到高级实践

    Promise 是 JavaScript 中处理异步操作的核心机制,它解决了传统回调函数(Callback)带来的“回调地狱”(Callback Hell)问题,使异步代码更清晰、可读、可维护。自 ES6(ECMAScript 2015)正式引入以来,Promise 已成为现代前端开发的基石,并为 async/await 语法提供了底层支持。


    一、为什么需要 Promise?

    1.1 回调函数的局限性

    在 Promise 出现之前,异步操作主要通过回调函数实现:

    // 嵌套回调(回调地狱)
    getData(function(a) {
      getMoreData(a, function(b) {
        getEvenMoreData(b, function(c) {
          console.log(c);
        });
      });
    });
    

    问题

    • 代码横向扩展,难以阅读和维护
    • 错误处理分散,需在每个回调中重复写 try/catch
    • 无法使用 returnthrow 控制流程
    • 多个异步操作的组合(如并行、竞态)实现复杂

    1.2 Promise 的优势

    • 链式调用:通过 .then() 实现线性流程
    • 统一错误处理:通过 .catch() 捕获整个链中的错误
    • 组合能力:支持 Promise.allPromise.race 等高级模式
    • 与 async/await 无缝集成

    二、Promise 基础概念

    2.1 什么是 Promise?

    Promise 是一个表示异步操作最终完成或失败的对象。

    它有三种状态(State):

    • pending(待定) :初始状态,既不是成功也不是失败
    • fulfilled(已成功) :操作成功完成
    • rejected(已失败) :操作失败

    ⚠️ 状态不可逆
    一旦 Promise 从 pending 变为 fulfilledrejected,状态将永久固定,不能再改变。

    2.2 创建 Promise

    使用 new Promise(executor) 构造函数:

    const promise = new Promise((resolve, reject) => {
      // 异步操作
      setTimeout(() => {
        const success = Math.random() > 0.5;
        if (success) {
          resolve('操作成功!'); // 将状态变为 fulfilled
        } else {
          reject(new Error('操作失败!')); // 将状态变为 rejected
        }
      }, 1000);
    });
    
    • resolve(value):标记 Promise 成功,传递结果值
    • reject(reason):标记 Promise 失败,传递错误原因(通常为 Error 对象)

    三、Promise 的基本用法

    3.1 链式调用(Chaining)

    通过 .then(onFulfilled, onRejected) 处理结果:

    promise
      .then(
        result => {
          console.log('成功:', result); // '操作成功!'
          return result.toUpperCase(); // 返回新值,传递给下一个 then
        },
        error => {
          console.error('失败:', error); // 不会执行(除非上一步 reject)
        }
      )
      .then(transformedResult => {
        console.log('转换后:', transformedResult); // '操作成功!'
      })
      .catch(error => {
        // 捕获链中任何未处理的 reject
        console.error('捕获错误:', error);
      });
    

    关键规则

    • .then() 总是返回一个新的 Promise
    • onFulfilled 返回普通值 → 新 Promise 状态为 fulfilled
    • onFulfilled 抛出异常 → 新 Promise 状态为 rejected
    • onFulfilled 返回另一个 Promise → 新 Promise 跟随该 Promise 的状态

    3.2 错误处理:.catch()

    .catch(onRejected).then(null, onRejected) 的语法糖:

    fetchUserData()
      .then(user => processUser(user))
      .then(data => saveToCache(data))
      .catch(error => {
        // 捕获 fetchUserData、processUser 或 saveToCache 中的任何错误
        console.error('操作失败:', error.message);
        showErrorMessage();
      });
    

    📌 最佳实践
    在链的末尾使用 .catch() 统一处理错误,避免在每个 .then() 中写错误回调。


    四、Promise 的高级特性

    4.1 静态方法

    Promise.resolve(value)

    将值转为已成功的 Promise:

    Promise.resolve(42).then(v => console.log(v)); // 42
    Promise.resolve(Promise.resolve('hello')).then(v => console.log(v)); // 'hello'
    

    Promise.reject(reason)

    创建一个已失败的 Promise:

    Promise.reject(new Error('Oops!')).catch(e => console.error(e.message));
    

    Promise.all(iterable)

    并行执行多个 Promise,全部成功才成功

    const promises = [
      fetch('/api/users'),
      fetch('/api/posts'),
      fetch('/api/comments')
    ];
    
    Promise.all(promises)
      .then(results => {
        const [users, posts, comments] = results;
        renderPage(users, posts, comments);
      })
      .catch(error => {
        // 任一请求失败,立即 reject
        console.error('加载失败:', error);
      });
    

    ⚠️ 注意:若任一 Promise reject,all 立即 reject,其余 Promise 仍会执行但结果被忽略。

    Promise.allSettled(iterable)

    等待所有 Promise 完成(无论成功或失败):

    Promise.allSettled(promises)
      .then(results => {
        results.forEach((result, i) => {
          if (result.status === 'fulfilled') {
            console.log(`请求 ${i} 成功:`, result.value);
          } else {
            console.error(`请求 ${i} 失败:`, result.reason);
          }
        });
      });
    

    Promise.race(iterable)

    返回第一个完成的 Promise(无论成功或失败)

    const timeout = new Promise((_, reject) =>
      setTimeout(() => reject(new Error('超时')), 5000)
    );
    
    Promise.race([fetch('/api/data'), timeout])
      .then(data => console.log('数据:', data))
      .catch(error => console.error('失败或超时:', error));
    

    Promise.any(iterable)(ES2021)

    返回第一个成功的 Promise(忽略失败):

    Promise.any([
      Promise.reject('A 失败'),
      Promise.resolve('B 成功'),
      Promise.reject('C 失败')
    ]).then(value => console.log(value)); // 'B 成功'
    

    ❗ 若全部失败,则 reject 一个 AggregateError


    五、Promise 与 async/await

    async/await 是 Promise 的语法糖,使异步代码看起来像同步代码。

    5.1 基本用法

    async function fetchData() {
      try {
        const response = await fetch('/api/data');
        if (!response.ok) throw new Error('请求失败');
        const data = await response.json();
        return data;
      } catch (error) {
        console.error('错误:', error);
        throw error; // 可选择重新抛出
      }
    }
    
    // 调用
    fetchData().then(data => console.log(data));
    

    5.2 关键规则

    • async 函数总是返回 Promise
    • await 只能在 async 函数内使用
    • await 后可跟 Promise 或普通值
    • 错误可通过 try/catch 捕获

    5.3 并行 vs 串行

    // ❌ 串行(慢)
    async function slow() {
      const a = await fetch('/a');
      const b = await fetch('/b');
      const c = await fetch('/c');
    }
    
    // ✅ 并行(快)
    async function fast() {
      const [a, b, c] = await Promise.all([
        fetch('/a'),
        fetch('/b'),
        fetch('/c')
      ]);
    }
    

    六、常见陷阱与最佳实践

    6.1 陷阱 1:忘记返回 Promise

    // ❌ 错误:第二个 then 无法获取数据
    fetch('/api')
      .then(res => res.json())
      .then(data => {
        processData(data); // 忘记 return
      })
      .then(result => {
        console.log(result); // undefined!
      });
    
    // ✅ 正确
    fetch('/api')
      .then(res => res.json())
      .then(data => {
        return processData(data); // 显式 return
      });
    

    6.2 陷阱 2:未处理拒绝(Uncaught Rejection)

    // ❌ 危险:可能被忽略,导致静默失败
    somePromise.then(result => {
      // ...
    });
    
    // ✅ 安全:始终处理错误
    somePromise
      .then(result => { /* ... */ })
      .catch(error => { /* 处理错误 */ });
    

    🔔 Node.js 提示:未处理的 Promise rejection 会导致进程警告(未来可能终止进程)。

    6.3 陷阱 3:在循环中使用 await(串行而非并行)

    // ❌ 串行执行(总耗时 = 所有请求时间之和)
    for (const url of urls) {
      const data = await fetch(url);
      results.push(data);
    }
    
    // ✅ 并行执行(总耗时 ≈ 最长请求时间)
    const promises = urls.map(url => fetch(url));
    const results = await Promise.all(promises);
    

    6.4 最佳实践

    1. 始终处理错误:使用 .catch()try/catch
    2. 避免嵌套 Promise:使用链式调用或 async/await
    3. 明确返回值:在 .then() 中显式 return
    4. 合理使用组合方法allraceallSettled
    5. 不要混合回调与 Promise:统一异步风格

    七、Promise 的内部原理(简要)

    虽然开发者通常无需实现 Promise,但理解其机制有助于调试:

    // 极简 Promise 实现(仅演示思路)
    class SimplePromise {
      constructor(executor) {
        this.state = 'pending';
        this.value = undefined;
        this.callbacks = [];
    
        const resolve = value => {
          if (this.state !== 'pending') return;
          this.state = 'fulfilled';
          this.value = value;
          this.callbacks.forEach(cb => cb());
        };
    
        const reject = reason => {
          if (this.state !== 'pending') return;
          this.state = 'rejected';
          this.value = reason;
          this.callbacks.forEach(cb => cb());
        };
    
        executor(resolve, reject);
      }
    
      then(onFulfilled) {
        return new SimplePromise((resolve) => {
          const callback = () => {
            if (this.state === 'fulfilled') {
              const result = onFulfilled(this.value);
              resolve(result);
            }
          };
          if (this.state === 'pending') {
            this.callbacks.push(callback);
          } else {
            callback();
          }
        });
      }
    }
    

    📚 真实 Promise 更复杂:需处理微任务队列(Microtask Queue)、thenable 对象、递归解析等。


    八、在 Vue 3 中的实践

    Vue 3 的组合式 API 与 Promise 天然契合:

    // composables/useApi.js
    import { ref } from 'vue';
    
    export function useApi(url) {
      const data = ref(null);
      const loading = ref(false);
      const error = ref(null);
    
      const execute = async () => {
        loading.value = true;
        error.value = null;
        try {
          const res = await fetch(url);
          if (!res.ok) throw new Error(res.statusText);
          data.value = await res.json();
        } catch (err) {
          error.value = err;
        } finally {
          loading.value = false;
        }
      };
    
      return { data, loading, error, execute };
    }
    
    <script setup>
    import { useApi } from '@/composables/useApi';
    
    const { data, loading, error, execute } = useApi('/api/users');
    execute();
    </script>
    
    <template>
      <div v-if="loading">加载中...</div>
      <div v-else-if="error">错误: {{ error.message }}</div>
      <ul v-else>
        <li v-for="user in data" :key="user.id">{{ user.name }}</li>
      </ul>
    </template>
    

    优势:逻辑复用、状态管理、错误处理一体化。


    结语

    Promise 是 JavaScript 异步编程的里程碑,它不仅解决了回调地狱问题,还为现代异步语法(async/await)奠定了基础。掌握 Promise 的核心概念、链式调用、错误处理和组合方法,是成为高效前端开发者的必经之路。

    记住:

    • Promise 是状态机:pending → fulfilled/rejected
    • 链式调用是核心:每个 .then() 返回新 Promise
    • 错误必须处理:避免静默失败
    • 组合优于嵌套:善用 Promise.all 等静态方法

    随着 Web 应用日益复杂,异步操作无处不在。

    脚手架开发工具——判断文件是否存在 path-exists

    简介

    path-exists 是一个轻量级的 Node.js npm 包,其核心作用是简便、高效地检查文件系统中指定的路径(文件或目录)是否存在,无需开发者手动封装原生文件操作的回调逻辑或错误处理,简化了 Node.js 中的路径存在性校验场景。

    核心用法

    npm install path-exists --save
    
    • 异步用法(推荐,非阻塞 I/O)
    const pathExists = require('path-exists');
    
    // 异步检查文件路径是否存在
    async function checkFilePath() {
      // 传入要检查的文件/目录路径(相对路径或绝对路径均可)
      const isExist = await pathExists('./test.txt');
      console.log('文件是否存在:', isExist); // 返回 true 或 false
    }
    
    checkFilePath();
    
    // 也可使用 Promise 链式调用(兼容旧版语法)
    pathExists('./dist/').then((exists) => {
      console.log('目录是否存在:', exists);
    });
    
    • 同步用法(适用于简单脚本,阻塞 I/O)
    const pathExists = require('path-exists');
    
    // 同步检查目录路径是否存在
    const dirExists = pathExists.sync('./node_modules/');
    console.log('node_modules 目录是否存在:', dirExists); // 返回 true 或 false
    

    XMLHttpRequest、AJAX、Fetch 与 Axios

    在现代 Web 开发中,前端与后端的数据交互是构建动态应用的核心。围绕这一需求,诞生了多个关键技术与工具:XMLHttpRequest(XHR)AJAXAxiosFetch API。它们之间既有历史演进关系,也有功能重叠与互补。本文将系统梳理四者的关系,深入剖析 XHR 的工作机制与 Fetch 的底层原理,并结合 Vue 3 开发实践,提供一套完整的前端网络通信知识体系。


    一、核心概念与层级关系

    1.1 AJAX:一种编程范式(不是技术)

    • 全称:Asynchronous JavaScript and XML
    • 本质一种开发模式,指在不刷新页面的情况下,通过 JavaScript 异步与服务器交换数据并更新部分网页内容。
    • 核心思想:解耦 UI 更新与数据获取,提升用户体验。

    关键点
    AJAX 不是某个具体 API,而是一种使用现有技术实现异步通信的策略
    实现 AJAX 的核心技术就是 XMLHttpRequest

    1.2 XMLHttpRequest(XHR):浏览器原生 API

    • 角色实现 AJAX 的底层工具

    • 功能:提供浏览器与服务器进行 HTTP 通信的能力

    • 特点

      • 基于回调(事件驱动)
      • 支持进度监控、取消请求、上传/下载
      • 兼容性极好(IE7+)

    📌 关系
    XHR 是 AJAX 的“引擎” 。没有 XHR,就没有现代意义上的 AJAX。

    1.3 Axios:基于 Promise 的 HTTP 客户端库

    • 定位对 XHR 的封装与增强

    • 核心特性

      • 返回 Promise,支持 async/await
      • 自动转换 JSON 数据
      • 拦截器(请求/响应)
      • 客户端支持 XSRF 防护
      • 浏览器 + Node.js 双端支持
    • 底层实现:在浏览器中默认使用 XHR,在 Node.js 中使用 http 模块

    // Axios 内部简化逻辑
    function axios(config) {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(config.method, config.url);
        xhr.send(config.data);
        xhr.onload = () => resolve(xhr.response);
        xhr.onerror = () => reject(xhr.statusText);
      });
    }
    

    关系
    Axios 是 XHR 的现代化封装,让开发者用更简洁的语法享受 XHR 的全部能力。

    1.4 Fetch API:浏览器新一代原生 API

    • 定位XHR 的官方继任者

    • 设计目标

      • 基于 Promise,符合现代 JS 编程习惯
      • 更简洁的 API 设计
      • 更好的流(Stream)支持
      • 统一请求/响应模型(Request/Response 对象)
    • 底层实现并非基于 XHR,而是直接调用浏览器的网络层(如 Chromium 的 blink::WebURLLoader

    ⚠️ 重要区别
    Fetch 不是 XHR 的封装,而是全新的底层实现


    二、四者关系图谱

                              ┌──────────────┐
                              │    AJAX      │ ←── 编程范式(异步通信思想)
                              └──────┬───────┘
                                     │
             ┌───────────────────────┼───────────────────────┐
             │                       │                       │
    ┌────────▼────────┐   ┌──────────▼──────────┐   ┌────────▼────────┐
    │ XMLHttpRequest  │   │       Fetch API     │   │      Axios      │
    │ (原生, 回调式)   │   │ (原生, Promise式)   │   │ (第三方库, Promise)│
    └────────┬────────┘   └─────────────────────┘   └────────┬────────┘
             │                                               │
             └───────────────────────┬───────────────────────┘
                                     │
                       ┌─────────────▼─────────────┐
                       │   现代 Web 应用数据通信    │
                       └───────────────────────────┘
    

    🔑 总结关系

    • AJAX 是思想,XHR/Fetch 是实现该思想的原生工具
    • Axios 是对 XHR(浏览器端)的高级封装
    • Fetch 是浏览器提供的、与 XHR 并列的新一代原生 API

    三、XMLHttpRequest 详解

    3.1 基本使用流程

    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/api/users', true);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.onreadystatechange = function() {
      if (xhr.readyState === 4 && xhr.status === 200) {
        console.log(xhr.responseText);
      }
    };
    xhr.send();
    

    3.2 核心属性

    属性 说明
    readyState 请求状态(0–4)
    status / statusText HTTP 状态码与描述
    responseText 字符串响应体
    response 根据 responseType 解析后的数据
    responseType 响应类型(jsonblobarraybuffer 等)

    3.3 事件模型

    • 传统方式onreadystatechange(需手动判断 readyState

    • 现代方式(推荐):

      • onload:请求完成
      • onerror:网络错误
      • ontimeout:超时
      • onabort:被中止

    3.4 高级功能

    • 超时控制xhr.timeout = 5000
    • 跨域凭据xhr.withCredentials = true
    • 上传进度xhr.upload.onprogress
    • 中止请求xhr.abort()

    3.5 实际应用场景

    文件上传(带进度)

    function uploadFile(file) {
      const formData = new FormData();
      formData.append('file', file);
      
      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/upload');
      
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
          const percent = (e.loaded / e.total) * 100;
          updateProgress(percent);
        }
      };
      
      xhr.onload = () => {
        if (xhr.status === 200) showSuccess();
      };
      
      xhr.send(formData);
    }
    

    四、Fetch API 原理深度解析

    4.1 核心设计:基于 Stream 的请求/响应模型

    Fetch 的核心是两个构造函数:

    • Request:表示 HTTP 请求
    • Response:表示 HTTP 响应

    两者都实现了 Body mixin,包含可读流(ReadableStream):

    fetch('/api/data')
      .then(response => {
        console.log(response.body instanceof ReadableStream); // true
        return response.json(); // 内部读取 body 流并解析
      });
    

    💡 关键机制
    Fetch 将响应体视为流(Stream) ,支持边下载边处理,适合大文件或实时数据。

    4.2 执行流程(浏览器内部)

    以 Chromium 为例:

    1. 调用 fetch(url) → 创建 Request 对象
    2. 浏览器主线程 → 网络服务线程(Network Service)
    3. 网络线程发起 HTTP 请求(复用连接池、DNS 缓存等)
    4. 收到响应头 → 立即 resolve Promise(返回 Response 对象)
    5. 响应体通过 ReadableStream 逐步传输到 JS 主线程
    6. 调用 .json() / .text() 等方法 → 消费流并解析

    4.3 与 XHR 的关键差异

    特性 XHR Fetch
    错误处理 网络错误 → onerror;HTTP 错误(404/500)→ onload 仅网络错误 reject;HTTP 错误仍 resolve(需手动检查 response.ok
    Cookie 发送 同域自动发送 需显式设置 credentials: 'same-origin'
    取消请求 xhr.abort() AbortController
    上传进度 原生 upload.onprogress 不支持(需自定义 ReadableStream,复杂)
    超时控制 xhr.timeout 需配合 AbortController + setTimeout

    错误处理对比示例:

    // Fetch:HTTP 404 仍 resolve
    fetch('/not-found')
      .then(res => {
        if (!res.ok) { // 必须手动检查
          throw new Error(`HTTP ${res.status}`);
        }
      })
      .catch(err => {
        // 只有网络断开才会进入这里
      });
    

    4.4 Fetch 的局限性与解决方案

    问题 1:无法监控下载进度

    解决方案:手动读取流并计算进度:

    const response = await fetch('/large-file');
    const contentLength = +response.headers.get('Content-Length');
    let loaded = 0;
    
    const reader = response.body.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      loaded += value.length;
      const progress = (loaded / contentLength) * 100;
      updateProgress(progress);
    }
    

    问题 2:无内置超时

    解决方案:结合 AbortController

    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 5000);
    
    fetch('/api/data', { signal: controller.signal })
      .finally(() => clearTimeout(timeoutId));
    

    五、Vue 3 中的网络通信实践

    虽然 Vue 本身不强制使用特定 HTTP 客户端,但其组合式 API 与现代请求库天然契合。

    5.1 使用 Axios(推荐用于复杂项目)

    // composables/useApi.js
    import axios from 'axios';
    
    const api = axios.create({
      baseURL: '/api',
      timeout: 10000,
      withCredentials: true
    });
    
    // 请求拦截器
    api.interceptors.request.use(config => {
      const token = localStorage.getItem('token');
      if (token) config.headers.Authorization = `Bearer ${token}`;
      return config;
    });
    
    // 响应拦截器
    api.interceptors.response.use(
      response => response.data,
      error => {
        if (error.response?.status === 401) {
          // 处理未授权
          router.push('/login');
        }
        return Promise.reject(error);
      }
    );
    
    export default api;
    
    <!-- 在组件中使用 -->
    <script setup>
    import { ref } from 'vue';
    import api from '@/composables/useApi';
    
    const users = ref([]);
    const loading = ref(false);
    
    const fetchUsers = async () => {
      loading.value = true;
      try {
        users.value = await api.get('/users');
      } finally {
        loading.value = false;
      }
    };
    
    fetchUsers();
    </script>
    

    5.2 使用 Fetch(轻量级项目)

    // utils/request.js
    async function request(url, options = {}) {
      const config = {
        credentials: 'include',
        ...options,
        headers: {
          'Content-Type': 'application/json',
          ...options.headers
        }
      };
    
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 10000);
      
      try {
        const response = await fetch(url, {
          ...config,
          signal: controller.signal
        });
        
        clearTimeout(timeoutId);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        
        return await response.json();
      } catch (error) {
        clearTimeout(timeoutId);
        throw error;
      }
    }
    
    export { request };
    

    5.3 封装为 Composable(最佳实践)

    // composables/useFetch.js
    import { ref } from 'vue';
    
    export function useFetch(url) {
      const data = ref(null);
      const loading = ref(false);
      const error = ref(null);
    
      const execute = async () => {
        loading.value = true;
        error.value = null;
        
        try {
          const res = await fetch(url);
          if (!res.ok) throw new Error(res.statusText);
          data.value = await res.json();
        } catch (err) {
          error.value = err;
        } finally {
          loading.value = false;
        }
      };
    
      return { data, loading, error, execute };
    }
    
    <script setup>
    import { useFetch } from '@/composables/useFetch';
    
    const { data: users, loading, execute } = useFetch('/api/users');
    execute();
    </script>
    

    六、如何选择?—— 使用场景建议

    场景 推荐方案 理由
    新项目(现代浏览器) fetch() + 工具函数封装 原生支持,无依赖,符合标准
    需要上传/下载进度 XMLHttpRequest 或 Axios 原生支持 onprogress,简单可靠
    复杂拦截、转换、兼容 Node.js Axios 功能全面,生态成熟
    维护旧项目(IE11+) XMLHttpRequest 或 Axios(带 polyfill) 最大兼容性
    轻量级应用,避免打包体积 fetch() 无需引入第三方库
    Vue 3 项目 Axios(复杂)Fetch + Composable(简单) 与组合式 API 完美契合

    📌 现代最佳实践

    • 优先使用 fetch() 或 Axios
    • 将网络逻辑封装为 Composable,实现逻辑复用
    • 避免直接使用裸 XHR(除非特殊需求)

    七、安全与性能注意事项

    7.1 安全

    • XSS 防护:永远不要将响应直接插入 innerHTML
    • CSRF 防护:使用 anti-CSRF token,重要操作用非 GET 方法
    • CORS 策略:服务器严格限制 Access-Control-Allow-Origin
    • 敏感数据:使用 HTTPS,避免客户端存储密码/token

    7.2 性能

    • 缓存策略:合理设置 Cache-Control
    • 请求合并:避免频繁小请求
    • 懒加载:非关键数据延迟请求
    • 取消冗余请求:组件销毁时中止未完成的请求
    // Vue 3 中取消请求
    import { onUnmounted } from 'vue';
    
    export function useFetch(url) {
      const controller = new AbortController();
      
      onUnmounted(() => {
        controller.abort(); // 组件卸载时取消请求
      });
      
      const execute = () => {
        return fetch(url, { signal: controller.signal });
      };
      
      return { execute };
    }
    

    结语

    理解 XHR、AJAX、Axios 与 Fetch 的关系,本质上是理解 Web 异步通信技术的演进史:

    • AJAX 提出了“异步更新”的思想
    • XHR 提供了首个标准化实现
    • Axios 在 XHR 基础上构建了开发者友好的抽象
    • Fetch 则代表了浏览器厂商对下一代网络 API 的重新设计

    作为开发者,我们不必拘泥于某一种工具,而应根据项目需求、浏览器支持和功能复杂度做出合理选择。但无论使用哪种方式,其背后的核心原理——HTTP 协议、CORS 安全模型、异步编程范式——始终不变。

    在 Vue 3 的组合式 API 时代,将网络逻辑封装为可复用的 Composable,不仅能提升代码可维护性,更能充分发挥现代 JavaScript 的表达力。掌握这些底层逻辑,才能在技术变迁中游刃有余,构建出高性能、高安全性的现代 Web 应用。

    2025-12-20 vue3中 eslint9+和prettier配置

    eslint 9+相较8版本使用eslint.config.js和扁平化配置方式。

    1.在项目根目录下安装所需的开发依赖包

    # 核心代码检查与格式化工具
    pnpm add -D eslint prettier
    
    # Vue.js 语法支持
    pnpm add -D eslint-plugin-vue
    
    # Prettier 与 ESLint 集成
    pnpm add -D eslint-config-prettier eslint-plugin-prettier
    

    💅 2. prettier配置

    vscode中安装插件

    image.png

    根目录下新建配置文件 .prettierrc

    {
      "semi": true,
      "singleQuote": true,
      "tabWidth": 2,
      "trailingComma": "es5",
      "printWidth": 100,
      "arrowParens": "avoid",
      "htmlWhitespaceSensitivity": "ignore"
    }
    

    3.eslint配置并将prettier规则作为eslint一部分,对不符合要求的报错

    // eslint.config.js
    import eslintPluginVue from 'eslint-plugin-vue'
    import vueEslintParser from 'vue-eslint-parser'
    import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
    // console.log(eslintPluginPrettierRecommended)
    export default [
      // 全局配置:指定环境、解析器选项
      {
        files: ['**/*.js', '**/*.vue'],
        ignores: ['vite.config.js', 'node_modules/', 'dist/', 'public/'],
        languageOptions: {
          ecmaVersion: 'latest',
          sourceType: 'module',
          globals: {
            browser: true, // 浏览器环境全局变量
            node: true, // Node.js 环境全局变量
            es2021: true // ES2021 语法支持
          }
        },
        rules: {
          'no-console': 'warn',
          'no-debugger': 'error',
          'no-unused-vars': ['warn', { varsIgnorePattern: '^_' }]
        }
      },
    
      // Vue 单文件组件专属配置
      {
        files: ['**/*.vue'],
        // 使用 vue-eslint-parser 解析 .vue 文件
        languageOptions: {
          parser: vueEslintParser,
          parserOptions: {
            parser: 'espree', // 解析 <script> 块内的 JavaScript
            ecmaVersion: 'latest',
            sourceType: 'module'
          }
        },
        plugins: {
          vue: eslintPluginVue
        },
        rules: {
          // 启用 Vue 官方推荐规则
          ...eslintPluginVue.configs['flat/recommended'].rules,
          // 自定义 Vue 规则
          'vue/multi-word-component-names': 'off' // 关闭组件名必须多单词的要求
        }
      },
      //prettier配置
      eslintPluginPrettierRecommended
    ]
    

    此时运行

    npm run lint
    

    可以对不符合规则的代码检查,包含不符合eslint规则的

    image.png

    4.配置vscode插件eslintPrettier ESlint,设置默认格式化工具为Prettier ESlint,让eslint直接报错prettier中的配置,有时修改了.prettierrc中的配置需要重启Prettier ESlint插件才能生效。

    image.png

    image.png

    对于.eslintignore文件缺失时eslint会使用.gitignore文件

    Vue3条件渲染中v-if系列指令如何合理使用与规避错误?

    一、Vue3条件渲染的核心概念

    在Vue3中,条件渲染是指根据响应式数据的真假,决定是否在页面上渲染某个元素或组件。而v-ifv-elsev-else-if 这组指令,就是实现条件渲染的“核心工具”——它们像一套“逻辑开关”,帮你精准控制DOM的显示与隐藏。

    二、v-if系列指令的语法与用法

    我们先逐个拆解每个指令的作用,再通过实际案例串起来用。

    2.1 v-if:基础条件判断

    v-if是最基础的条件指令,它的语法很简单:

    
    <元素 v-if="条件表达式">要渲染的内容</元素>
    
    • 条件表达式:可以是任何返回truefalse的JavaScript表达式(比如isLoginscore >= 90)。
    • 惰性渲染v-if是“懒”的——如果初始条件不满足(比如isLogin = false),元素不会被渲染到DOM中(连DOM节点都不会生成);只有当条件变为 true时,才会“从零开始”创建元素及其子组件。

    举个例子:判断用户是否登录,显示不同的内容:

    
    <script setup>
      import {ref} from 'vue'
    
      const isLogin = ref(false) // 初始未登录
    </script>
    
    <template>
      <div>
        <!-- 登录后显示 -->
        <p v-if="isLogin">欢迎回来,用户!</p>
        <!-- 未登录显示 -->
        <button v-if="!isLogin" @click="isLogin = true">点击登录</button>
      </div>
    </template>
    

    当点击按钮时,isLogin变为true,未登录的按钮会被销毁,同时渲染“欢迎”文本——这就是v-if的“销毁/重建”逻辑。

    往期文章归档
    免费好用的热门在线工具

    2.2 v-else:补充默认分支

    如果v-if的条件不满足,你可以用v-else添加一个“默认选项”。但要注意:
    v-else必须紧跟在v-ifv-else-if的后面,中间不能有其他兄弟元素(否则Vue无法识别它属于哪个条件)。

    修改上面的例子,用v-else简化未登录的情况:

    
    <template>
        <div>
            <p v-if="isLogin">欢迎回来,用户!</p>
            <!-- 直接跟在v-if后面,无需写条件 -->
            <button v-else @click="isLogin = true">点击登录</button>
        </div>
    </template>
    

    2.3 v-else-if:多分支条件判断

    当需要判断多个条件时,用v-else-if连接。它的语法是:

    
    <元素 v-if="条件1">内容1</元素>
    <元素 v-else-if="条件2">内容2</元素>
    <元素 v-else-if="条件3">内容3</元素>
    <元素 v-else>默认内容</元素>
    

    关键规则:Vue会按指令的顺序依次判断,满足第一个条件就停止(所以条件的顺序很重要!)。

    比如根据分数显示等级(最常见的多分支场景):

    
    <script setup>
      import {ref} from 'vue'
    
      const score = ref(85) // 响应式分数,初始85分
    </script>
    
    <template>
      <div class="score-level">
        <h3>你的分数:{{ score }}</h3>
        <!-- 顺序:从高到低 -->
        <p v-if="score >= 90" class="excellent">等级:优秀(≥90)</p>
        <p v-else-if="score >= 80" class="good">等级:良好(80-89)</p>
        <p v-else-if="score >= 60" class="pass">等级:及格(60-79)</p>
        <p v-else class="fail">等级:不及格(<60)</p>
      </div>
    </template>
    
    <style scoped>
      .excellent {
        color: #4CAF50;
      }
    
      .good {
        color: #2196F3;
      }
    
      .pass {
        color: #FFC107;
      }
    
      .fail {
        color: #F44336;
      }
    </style>
    

    运行这个组件,修改score的值(比如改成9550),会看到等级自动切换——这就是多分支条件渲染的实际效果。

    三、条件渲染的流程逻辑(附流程图)

    为了更直观理解v-if系列的执行顺序,我们画一个判断流程图

    flowchart TD
        A[开始] --> B{检查v-if条件}
        B -->|满足| C[渲染v-if内容,结束]
        B -->|不满足| D{检查下一个v-else-if条件}
        D -->|满足| E[渲染对应内容,结束]
        D -->|不满足| F{还有v-else-if吗?}
        F -->|是| D
        F -->|否| G[渲染v-else内容,结束]
    

    简单来说:按顺序“闯关”,满足条件就“通关”,否则继续,直到最后一个v-else

    四、课后Quiz:巩固你的理解

    Quiz 1:v-if和v-show有什么区别?

    问题:同样是“隐藏元素”,v-ifv-show的核心差异是什么?分别适合什么场景?

    答案解析(参考Vue官网):

    • v-if销毁/重建DOM——条件不满足时,元素从DOM中消失;条件满足时,重新创建。适合条件很少变化的场景(比如权限判断),因为初始渲染更省性能。
    • v-show修改CSS显示——无论条件如何,元素都会渲染到DOM,只是用display: none隐藏。适合条件频繁切换的场景(比如 tabs 切换),因为切换时无需销毁重建,更高效。

    Quiz 2:为什么v-else必须紧跟v-if?

    问题:如果写v-if之后隔了一个div再写v-else,会报错吗?为什么?

    答案解析
    会报错!错误信息是“v-else has no adjacent v-if”。
    原因:Vue需要明确v-else对应的“上级条件”——它必须与最近的v-if/v-else-if直接相邻(中间不能有其他兄弟元素)。如果隔开,Vue无法识别两者的关联。

    五、常见报错与解决方案

    在使用v-if系列时,新手常遇到以下问题,我们逐一解决:

    1. 报错:“v-else/v-else-if has no adjacent v-if”

    • 原因v-elsev-else-if没有紧跟对应的v-if(中间有其他元素)。
      比如:
      <div v-if="isShow"></div>
      <p>无关内容</p> <!-- 中间的p元素导致错误 -->
      <div v-else></div>
      
    • 解决:删除中间的无关元素,让v-else直接紧跟v-if
    • 预防:写v-else前,先检查前面的元素是否是v-ifv-else-if

    2. 报错:“条件变化时,v-if内容不更新”

    • 原因:条件变量不是响应式的(比如用let isShow = false而不是ref(false)),Vue无法追踪其变化。
    • 解决:用ref(基本类型)或reactive(对象/数组)包裹条件变量:
      // 错误写法
      let isShow = false 
      // 正确写法
      const isShow = ref(false)
      
    • 预防:所有需要“随数据变化而更新”的变量,都用Vue的响应式API(ref/reactive)定义。

    3. 逻辑错误:v-else-if顺序导致条件失效

    • 例子:先写v-else-if="score >= 60",再写v-else-if="score >= 80"——此时80分会被第一个条件拦截,永远到不了第二个。
    • 原因:Vue按指令顺序判断,满足第一个条件就停止。
    • 解决:将更严格的条件放在前面(比如先>=90,再>=80,最后>=60)。

    参考链接

    Vue官网条件渲染文档:vuejs.org/guide/essen…

    脚手架开发工具——root-check

    简介

    root-check 是一个 Node.js 工具包,核心作用是检测当前 Node.js 进程是否以 root(超级管理员)权限运行,并在检测到 root 权限时,自动降级为指定的普通用户权限运行,以此提升应用的安全性。

    检测 root 权限

    它会判断当前进程的 uid(用户 ID)是否为 0(Unix/Linux 系统中 root 用户的 uid 固定为 0),以此识别是否为 root 权限运行。

    自动降级权限

    如果检测到当前是 root 权限,它会尝试切换到一个非 root 普通用户的权限来运行后续代码。

    • 默认会尝试切换到 nobody 用户(Unix/Linux 系统中内置的无特权用户)。
    • 也可以手动指定要切换的用户(通过用户名或 uid)。

    解决的核心问题

    在 Unix/Linux 系统中,以 root 权限运行 Node.js 应用存在极高的安全风险:一旦应用存在漏洞被攻击,攻击者将直接获得系统的最高权限,可能导致服务器被完全控制。root-check 的存在就是为了避免这种风险,强制应用以低权限运行,即使被攻击,影响范围也会被大幅限制。

    适用场景

    • 命令行工具(CLI)开发:很多 Node.js 命令行工具(如前端的构建工具、脚手架)会用到这个包,防止用户以 root 权限执行命令带来风险。
    • 服务端应用:Node.js 编写的后端服务,部署时需要避免 root 权限运行,可通过此包自动降级。

    基本使用示例

    npm install root-check --save
    
    const rootCheck = require('root-check').default;
    // 自动降级为 nobody 用户(如果当前是 root 权限)
    rootCheck();
    // 或者手动指定要切换的用户
    // rootCheck('www-data'); // 切换到 www-data 用户
    

    注意事项

    • 仅支持 Unix/Linux 系统:Windows 系统没有 root/uid 的概念,该包在 Windows 上会直接失效(无副作用)。
    • 降级失败会抛出错误:如果当前是 root 权限,但无法切换到目标用户(比如目标用户不存在),rootCheck 会抛出异常,需要手动捕获处理。

    解决Tailwind任意值滥用:规范化CSS开发体验

    背景 eslint-plugin-tailwindcss插件的no-unnecessary-arbitrary-value无法对所有的任意值进行校验,比如h-[48px]text-[#f5f5f5]无法校验出来。但tailwindcss的预设值太多了,一个不小心可能就又写了一个没有必要的任意值。为了避免这种情况,我们需要自己实现一个检测任意值的eslint插件。

    插件地址:eslint-plugin-tailwind-no-preset-class

    首先来看下效果

    no-unnecessary-arbitrary-value 无法检测的情况

    image.png

    使用自定义的:eslint-plugin-tailwind-no-preset-class插件,完美完成了校验

    image.png

    创建eslint插件标准目录结构

    • 安装Yeoman
    npm install -g yo
    
    • 安装Yeoman generator-eslint
    npm install -g generator-eslint
    
    • 创建项目
    mkdir eslint-plugin-my-plugin
    yo eslint:plugin
    

    生成目录结构如下:

    eslint-plugin-my-plugin/
    ├── lib/                    # 核心源代码目录
    │   ├── index.js           # 插件的入口文件,在这里导出所有规则
    │   └── rules/             # 存放所有自定义规则的目录
    │       └── my-rule.js     # 生成器为你创建的一条示例规则文件
    ├── tests/                 # 测试文件目录
    │   └── lib/
    │       └── rules/
    │           └── my-rule.js # 示例规则对应的测试文件
    ├── package.json           # 项目的 npm 配置文件,依赖和元信息都在这里
    └── README.md              # 项目说明文档
    

    根据实际项目的tailwindcss配置文件和tailwindcss默认配置生成全量定制化配置,用于后续eslint插件的校验依据

    实现配置文件生成并加载方法:

    // lib/tailwind-config-loader.js
    // 配置文件生成
    ...
    ...
    // 动态加载 Tailwind 预设配置
    let tailwindPresetConfig = null;
    ...
    async function generateTailwindConfig(projectRootPath) {
      try {
        // 动态导入tailwindcss
        const resolveConfigModule = await import('tailwindcss/lib/public/resolve-config.js');
        const resolveConfig = resolveConfigModule.default.default
        // 尝试加载项目配置
        let projectConfig = {};
        try {
          const projectConfigPath = join(projectRootPath||process.cwd(), 'tailwind.config.js');
          const projectConfigModule = await import(projectConfigPath);
          projectConfig = projectConfigModule.default || projectConfigModule;
        } catch (error) {
          console.log('⚠️ 未找到项目 tailwind.config.js,使用默认配置');
          throw error;
        }
    
        // 使用tailwindcss的resolveConfig函数
        const finalConfig = resolveConfig(projectConfig);
    
        console.log('✅ Tailwind preset config generated successfully!');
        
        return finalConfig;
      } catch (error) {
        console.error('❌ 生成Tailwind配置失败:', error.message);
        throw error;
      }
    }
    
    
    // 加载配置到内存中
    async function loadTailwindPresetConfig(projectRootPath) {
      if (configLoading) {
        console.log('⏳ 配置正在加载中,跳过重复请求');
        return;
      }
    
      configLoading = true;
    
      try {
        // 直接动态生成配置
        tailwindPresetConfig = await generateTailwindConfig(projectRootPath);
        console.log('✅ Tailwind 预设配置已动态生成并加载');
        onConfigLoaded();
      } catch (error) {
        console.error('❌ 动态生成 Tailwind 预设配置失败:', error.message);
        onConfigLoadFailed(error);
        throw error;
      }
    }
    
    
    ...
    // 导出配置
    export const TailwindConfigLoader = {
      getConfig: () => tailwindPresetConfig,
      isLoaded: () => configLoaded,
      ensureLoaded: ensureConfigLoaded,
      reload: loadTailwindPresetConfig,
      generateConfig: generateTailwindConfig
    };
    ...
    ...
    
    

    创建校验规则函数

    • 实现校验规则函数checkAndReport
    ...
    // 使用 WeakMap 来跟踪每个文件的已报告类名,避免重复报告
    const reportedClassesMap = new WeakMap();
    ...
    // 检查并报告
    async function checkAndReport(context, node, className) {
      // 如果配置尚未加载,尝试等待加载
      if (!TailwindConfigLoader.isLoaded()) {
        try {
            const projectRootPath = context.getCwd();
            console.log(`正在等待加载配置文件 ${projectRootPath}...`);
          const loaded = await TailwindConfigLoader.ensureLoaded(projectRootPath);
          if (!loaded) {
            console.warn('⚠️ Tailwind 预设配置尚未加载,跳过检查');
            return;
          }
        } catch (error) {
          console.warn('⚠️ 配置加载失败,跳过检查');
          return;
        }
      }
    
      const filePath = context.getFilename();
      const filePathWrapper = new FilePathWrapper(filePath);
    
      if (!reportedClassesMap.has(filePathWrapper)) {
        reportedClassesMap.set(filePathWrapper, new Set());
      }
      const reportedClasses = reportedClassesMap.get(filePathWrapper);
    
      if (reportedClasses.has(className)) {
        return;
      }
    
      const propertyInfo = extractProperty(className);
      if (!propertyInfo) {
        return;
      }
    
      const { property, value, originalPrefix } = propertyInfo;
    
      // 只检查任意值
      if (isArbitraryValue(value)) {
        const arbitraryValue = value.slice(1, -1);
        const presetClass = findPresetClass(property, arbitraryValue);
    
        if (presetClass) {
          reportedClasses.add(className);
          // 使用原始前缀显示正确的类名格式(如 h-14 而不是 height-14)
          const suggestedClass = `${originalPrefix}${presetClass}`;
          context.report({
            node,
            message: `类名 "${className}" 使用了任意值,但存在对应的预设类名 "${suggestedClass}"。请使用预设类名替代。`,
          });
        }
      }
    }
    
    
    • 实现属性提取,将classname解析为tailwindcss的property和value
    // 提取属性值
    function extractProperty(className) {
      // 处理响应式前缀(如 max-md:, md:, lg: 等)
      const responsivePrefixes = [
        'max-sm:',
        'max-md:',
        'max-lg:',
        'max-xl:',
        'max-2xl:',
        'max-',
        'min-',
        'sm:',
        'md:',
        'lg:',
        'xl:',
        '2xl:',
      ];
    
      // 移除响应式前缀,保留核心类名
      let coreClassName = className;
      let responsivePrefix = '';
    
      for (const prefix of responsivePrefixes) {
        if (className.startsWith(prefix)) {
          responsivePrefix = prefix;
          coreClassName = className.slice(prefix.length);
          break;
        }
      }
    
      // 按前缀长度降序排序,优先匹配更长的前缀
      const sortedPrefixes = Object.keys(prefixToProperty).sort(
        (a, b) => b.length - a.length
      );
    
      for (const prefix of sortedPrefixes) {
        if (coreClassName.startsWith(prefix)) {
          return {
            property: prefixToProperty[prefix],
            value: coreClassName.slice(prefix.length),
            originalPrefix: responsivePrefix + prefix, // 包含响应式前缀
          };
        }
      }
    
      return null;
    }
    
    • 将提取的property和前面生成的全量的tailwindcss进行映射
    // 简化属性映射,只保留常用的属性
    const prefixToProperty = {
      // 尺寸相关
      "w-": "width",
      "h-": "height",
      "min-w-": "minWidth",
      "min-h-": "minHeight",
      "max-w-": "maxWidth",
      "max-h-": "maxHeight",
    
      // 间距相关
      "m-": "margin",
      "mt-": "marginTop",
      "mr-": "marginRight",
      "mb-": "marginBottom",
      "ml-": "marginLeft",
      "mx-": "margin",
      "my-": "margin",
      "p-": "padding",
      "pt-": "paddingTop",
      "pr-": "paddingRight",
      "pb-": "paddingBottom",
      "pl-": "paddingLeft",
      "px-": "padding",
      "py-": "padding",
    
      // 边框相关(新增)
      "border-": "borderWidth;borderColor",
      "border-t-": "borderWidth;borderColor",
      "border-r-": "borderWidth;borderColor",
      "border-b-": "borderWidth;borderColor",
      "border-l-": "borderWidth;borderColor",
      "border-x-": "borderWidth;borderColor",
      "border-y-": "borderWidth;borderColor",
    
      // 圆角相关(新增)
      "rounded-": "borderRadius",
      "rounded-t-": "borderRadius",
      "rounded-r-": "borderRadius",
      "rounded-b-": "borderRadius",
      "rounded-l-": "borderRadius",
      "rounded-tl-": "borderRadius",
      "rounded-tr-": "borderRadius",
      "rounded-br-": "borderRadius",
      "rounded-bl-": "borderRadius",
    
      // 文字相关
      "text-": "fontSize;color",
      "leading-": "lineHeight",
      "tracking-": "letterSpacing",
      "font-": "fontWeight",
    
      // 背景相关
      "bg-": "backgroundColor",
    
      // SVG相关
      "fill-": "fill",
      "stroke-": "stroke",
      "stroke-w-": "strokeWidth",
    
      // 定位相关
      "z-": "zIndex",
      "inset-": "inset",
      "top-": "top",
      "right-": "right",
      "bottom-": "bottom",
      "left-": "left",
    
      // 布局相关(新增)
      "gap-": "gap",
      "gap-x-": "gap",
      "gap-y-": "gap",
      "space-x-": "gap",
      "space-y-": "gap",
    
      // 透明度
      "opacity-": "opacity",
    
      // 变换相关(新增)
      "scale-": "scale",
      "scale-x-": "scale",
      "scale-y-": "scale",
      "rotate-": "rotate",
      "translate-x-": "translate",
      "translate-y-": "translate",
      "skew-x-": "skew",
      "skew-y-": "skew",
    
      // 阴影相关(新增)
      "shadow-": "boxShadow",
    
      // 网格相关(新增)
      "grid-cols-": "gridTemplateColumns",
      "grid-rows-": "gridTemplateRows",
      "col-": "gridColumn",
      "row-": "gridRow",
      "col-start-": "gridColumnStart",
      "col-end-": "gridColumnEnd",
      "row-start-": "gridRowStart",
      "row-end-": "gridRowEnd",
    
      // Flexbox相关(新增)
      "flex-": "flex",
      "basis-": "flexBasis",
      "grow-": "flexGrow",
      "shrink-": "flexShrink",
      "order-": "order",
    
      // 动画相关(新增)
      "duration-": "transitionDuration",
      "delay-": "transitionDelay",
      "ease-": "transitionTimingFunction",
    
      // 其他(新增)
      "aspect-": "aspectRatio",
      "cursor-": "cursor",
    };
    
    // 动态构建支持的 Tailwind 属性映射
    function getSupportedProperties() {
      const config = TailwindConfigLoader.getConfig();
      if (!config) {
        return {};
      }
    
      return {
        width: config.theme.width,
        height: config.theme.height,
        minWidth: config.theme.minWidth,
        minHeight: config.theme.minHeight,
        maxWidth: config.theme.maxWidth,
        maxHeight: config.theme.maxHeight,
        margin: config.theme.margin,
        marginTop: config.theme.margin,
        marginRight: config.theme.margin,
        marginBottom: config.theme.margin,
        marginLeft: config.theme.margin,
        padding: config.theme.padding,
        paddingTop: config.theme.padding,
        paddingRight: config.theme.padding,
        paddingBottom: config.theme.padding,
        paddingLeft: config.theme.padding,
        fontSize: config.theme.fontSize,
        lineHeight: config.theme.lineHeight,
        borderRadius: config.theme.borderRadius,
        color: config.theme.colors,
        backgroundColor: config.theme.backgroundColor,
        borderColor: config.theme.borderColor,
        fill: config.theme.fill,
        stroke: config.theme.stroke,
        borderWidth: config.theme.borderWidth,
        zIndex: config.theme.zIndex,
        gap: config.theme.gap,
        inset: config.theme.inset,
        top: config.theme.spacing,
        right: config.theme.spacing,
        bottom: config.theme.spacing,
        left: config.theme.spacing,
        opacity: config.theme.opacity,
      };
    }
    

    整体实现流程

    graph TD
        A[ESLint 执行插件] --> B[遍历代码中的类名]
        B --> C{是否为 Tailwind 类名?}
        C -->|否| D[跳过检查]
        C -->|是| E{是否包含任意值?}
        E -->|否| F[使用预设值 通过检查]
        E -->|是| G[提取类名前缀和任意值]
        
        G --> H[通过 prefixToProperty 映射到CSS属性]
        H --> I[检查Tailwind配置是否已加载]
        I -->|已加载| J[获取支持的属性预设值]
        I -->|未加载| K[加载项目Tailwind配置]
        
        K --> L[读取项目tailwind.config.js]
        L --> M{配置是否存在?}
        M -->|不存在| N[使用Tailwind默认配置]
        M -->|存在| O[解析项目配置]
        
        O --> P[合并默认配置和项目配置]
        N --> P
        
        P --> Q[生成全量Tailwind配置]
        Q --> R[缓存配置到内存]
        R --> J
        
        J --> S{判断属性类型}
        S -->|颜色相关| T[调用 findColorPreset]
        S -->|数值相关| U[调用 findNumericPreset]
        
        T --> V{是否匹配预设?}
        U --> V
        
        V -->|是| W[找到对应预设类名]
        V -->|否| X[未找到预设类名]
        
        W --> Y[生成建议消息]
        X --> Z[通过检查 无匹配预设]
        
        Y --> AA[报告建议]
        Z --> BB[检查完成]
        
        AA --> BB
    

    告别代码屎山!UniApp + Vue3 自动化规范:ESLint 9+ 扁平化配置全指南

    配置初衷是为了保证,团队开发中的代码规范问题。 以下是全部配置过程,我的项目是npm创建的,并非hbuilder创建的。如果你的项目的hbuilder创建的,需要执行下 npm init -y

    配置后想要达到的效果:

    • 保证缩进统一性
    • vue组件多个属性可以自动换行。
    • 在代码里用了 uni.showToast,ESLint 却疯狂报错 uni is not defined

    2025 年,ESLint 迎来了史上最大变革——Flat Config(扁平化配置)  时代。今天,我们就用一套最硬核的方案,把 Vue3、TypeScript、SCSS 和 Git 自动化 全部打通!

    一、 为什么 2025 年要用 ESLint 9+ ?

    传统的 .eslintrc.js 采用的是“层级继承”逻辑,配置多了就像迷宫。而 ESLint 9+ 的 Flat Config (eslint.config.mjs)  采用纯 JavaScript 数组对象,逻辑更扁平、加载更快速、对 ESM 原生支持更好。

    二、 核心依赖安装:一步到位

    首先,清理掉项目里的旧配置文件,然后在根目录执行这行“全家桶”安装命令:

    npm install eslint @eslint/js typescript-eslint eslint-plugin-vue globals eslint-config-prettier eslint-plugin-prettier prettier husky lint-staged --save-dev
    

    三、 配置实战:三剑客齐聚

    1. 魔法启动:生成 ESLint 9 配置文件

    新版 ESLint 推荐通过交互式命令生成基础框架,但针对 UniApp,我们建议直接创建 eslint.config.mjs 以获得极致控制力。

    核心逻辑:

    • 2 空格缩进:强迫症的福音。
    • 属性换行:组件属性 > 3 个自动起新行。
    • 多语言全开:JS / TS / Vue / CSS / SCSS 完美兼容。
    /* eslint.config.mjs */
    import js from '@eslint/js';
    import tseslint from 'typescript-eslint';
    import pluginVue from 'eslint-plugin-vue';
    import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
    import globals from 'globals';
    
    export default tseslint.config(
      // 【1】配置忽略名单:不检查编译后的代码
      { ignores: ['dist/**', 'unpackage/**', 'node_modules/**', 'static/**'] },
    
      // 【2】JS 基础规则 & UniApp 全局变量支持
      js.configs.recommended,
      {
        languageOptions: {
          ecmaVersion: 'latest',
          sourceType: 'module',
          globals: {
            ...globals.browser, ...globals.node,
            uni: 'readonly', wx: 'readonly', plus: 'readonly' // 解决 uni 报错
          },
        },
      },
    
      // 【3】TypeScript 强类型支持
      ...tseslint.configs.recommended,
    
      // 【4】Vue 3 核心规范(属性换行策略)
      ...pluginVue.configs['flat/recommended'],
      {
        files: ['**/*.vue'],
        languageOptions: {
          parserOptions: { parser: tseslint.parser } // Vue 模板内支持 TS
        },
        rules: {
          'vue/multi-word-component-names': 'off', // 适配 UniApp 页面名
          'vue/html-indent': ['error', 2],         // 模板强制 2 空格
          'vue/max-attributes-per-line': ['error', {
            singleline: { max: 3 }, // 超过 3 个属性就换行
            multiline: { max: 1 }   // 多行模式下每行只能有一个属性
          }],
          'vue/first-attribute-linebreak': ['error', {
            singleline: 'beside', multiline: 'below'
          }]
        }
      },
    
      // 【5】Prettier 冲突处理:必须放在数组最后一行!
      pluginPrettierRecommended,
    );
    
    1. 视觉统领:.prettierrc
    {
      "semi": false,
      "singleQuote": true,
      "tabWidth": 2,
      "useTabs": false,
      "printWidth": 100,
      "trailingComma": "all",
      "endOfLine": "auto"
    }
    
    1. 编辑器底层逻辑:.editorconfig

    让 IDEA 和 VS Code 在你打字的第一秒就明白:缩进只要两个空格。

    root = true
    [*]
    indent_style = space
    indent_size = 2
    end_of_line = lf
    insert_final_newline = true
    

    四、 极速体验:让工具为你打工

    1. IDEA / WebStorm 深度联动

    别再手动敲 Ctrl+Alt+L 了!

    • 进入 Settings -> ESLint,勾选 Run eslint --fix on save
    • 进入 Settings -> Prettier,勾选 Run on save
      IDEA 2024+ 会完美识别你的 eslint.config.mjs
    1. Git 提交自动“洗地” (Husky + lint-staged)

    想要代码仓库永远干干净净?在 package.json 中加入这道闸门:

    json

    "lint-staged": {
      "*.{js,ts,vue}": ["eslint --fix", "prettier --write"],
      "*.{css,scss,json,md}": ["prettier --write"]
    }
    

    执行 npx husky init 并将 .husky/pre-commit 改为 npx lint-staged。现在,任何不符合规则的代码都别想溜进 Git 仓库!

    五、 结语

    规范不是为了限制自由,而是为了让开发者在 2025 年繁重的业务中,能拥有一份优雅的代码底座。

    ❌