阅读视图

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

每日一题-旋转数字🟡

我们称一个数 X 为好数, 如果它的每位数字逐个地被旋转 180 度后,我们仍可以得到一个有效的,且和 X 不同的数。要求每位数字都要被旋转。

如果一个数的每位数字被旋转以后仍然还是一个数字, 则这个数是有效的。0, 1, 和 8 被旋转后仍然是它们自己;2 和 5 可以互相旋转成对方(在这种情况下,它们以不同的方向旋转,换句话说,2 和 5 互为镜像);6 和 9 同理,除了这些以外其他的数字旋转以后都不再是有效的数字。

现在我们有一个正整数 N, 计算从 1 到 N 中有多少个数 X 是好数?

 

示例:

输入: 10
输出: 4
解释: 
在[1, 10]中有四个好数: 2, 5, 6, 9。
注意 1 和 10 不是好数, 因为他们在旋转之后不变。

 

提示:

  • N 的取值范围是 [1, 10000]

【宫水三叶】简单模拟题

模拟

利用 $n$ 的范围为 $1e4$,我们可以直接检查 $[1, n]$ 的每个数。

由于每一个位数都需要翻转,因此如果当前枚举到的数值 x 中包含非有效翻转数字(非 0125689)则必然不是好数;而在每一位均为有效数字的前提下,若当前枚举到的数值 x 中包含翻转后能够发生数值上变化的数值(2569),则为好数。

代码:

###Java

class Solution {
    public int rotatedDigits(int n) {
        int ans = 0;
        out:for (int i = 1; i <= n; i++) {
            boolean ok = false;
            int x = i;
            while (x != 0) {
                int t = x % 10;
                x /= 10;
                if (t == 2 || t == 5 || t == 6 || t == 9) ok = true;
                else if (t != 0 && t != 1 && t != 8) continue out;
            }
            if (ok) ans++;
        }
        return ans;
    }
}

###TypeScript

function rotatedDigits(n: number): number {
    let ans = 0
    out:for (let i = 1; i <= n; i++) {
        let ok = false
        let x = i
        while (x != 0) {
            const t = x % 10
            x = Math.floor(x / 10)
            if (t == 2 || t == 5 || t == 6 || t == 9) ok = true
            else if (t != 0 && t != 1 && t != 8) continue out
        }
        if (ok) ans++
    }
    return ans
};

###Python3

class Solution:
    def rotatedDigits(self, n: int) -> int:
        ans = 0
        for i in range(1, n + 1):
            ok, x = False, i
            while x != 0:
                t = x % 10
                x = x // 10
                if t == 2 or t == 5 or t == 6 or t == 9:
                    ok = True
                elif t != 0 and t != 1 and t != 8:
                    ok = False
                    break
            ans = ans + 1 if ok else ans
        return ans
  • 时间复杂度:共有 $n$ 个数需要枚举,检查一个数需要遍历其每个数字,复杂度为 $O(\log{n})$。整体复杂度为 $O(n\log{n})$
  • 空间复杂度:$O(1)$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

数位 DP 通用模板(Python/Java/C++/Go)

视频讲解,从 19:30 开始(基于题目 2376. 统计特殊整数)。
讲了数位 DP 的通用模板,以及如何使用该模板秒杀相关困难题目。
讲完题目后还讲了一些上分的训练技巧。


根据题意,好数中不能有 $3,4,7$,且至少包含 $2,5,6,9$ 中的一个。

将 $n$ 转换成字符串 $s$,定义 $f(i,\textit{hasDiff}, \textit{isLimit}, \textit{isNum})$ 表示构造从左往右第 $i$ 位及其之后数位的合法方案数,其余参数的含义为:

  • $\textit{hasDiff}$ 表示前面填的数字是否包含 $2,5,6,9$(至少一个)。
  • $\textit{isLimit}$ 表示当前是否受到了 $n$ 的约束。若为真,则第 $i$ 位填入的数字至多为 $s[i]$,否则可以是 $9$。如果在受到约束的情况下填了 $s[i]$,那么后续填入的数字仍会受到 $n$ 的约束。
  • $\textit{isNum}$ 表示 $i$ 前面的数位是否填了数字。若为假,则当前位可以跳过(不填数字),或者要填入的数字至少为 $1$;若为真,则要填入的数字可以从 $0$ 开始。

后面两个参数可适用于其它数位 DP 题目。

枚举要填入的数字,具体实现逻辑见代码。对于本题来说,由于前导零对答案无影响,$\textit{isNum}$ 可以省略。

下面代码中 Java/C++/Go 只需要记忆化 $(i,\textit{hasDiff})$ 这个状态,因为:

  1. 对于一个固定的 $(i,\textit{hasDiff})$,这个状态受到 $\textit{isLimit}$ 的约束在整个递归过程中至多会出现一次,没必要记忆化。比如 $n=1234$,当 $i=2$ 的时候,前面可以填 $10,11,12$ 等等,如果受到 $\textit{isLimit}$ 的约束,就说明前面填的是 $12$。「当 $i=2$ 的时候,前面填的是 $12$」这件事情,在整个递归中只会发生一次。
  2. 另外,如果只记忆化 $(i,\textit{hasDiff})$,$\textit{dp}$ 数组的含义就变成在不受到约束时的合法方案数,所以要在 !isLimit 成立时才去记忆化。接着上面的例子,在前面填 $12$ 的时候,下一位填的数字不能超过 $3$,因此算出来的结果是不能套用到前面填的是 $10,11$ 这些数字上面的。
DIFFS = (0, 0, 1, -1, -1, 1, 1, -1, 0, 1)

class Solution:
    def rotatedDigits(self, n: int) -> int:
        s = str(n)
        @cache
        def f(i: int, has_diff: bool, is_limit: bool) -> int:
            if i == len(s):
                return has_diff  # 只有包含 2/5/6/9 才算一个好数
            res = 0
            up = int(s[i]) if is_limit else 9
            for d in range(0, up + 1):  # 枚举要填入的数字 d
                if DIFFS[d] != -1:  # d 不是 3/4/7
                    res += f(i + 1, has_diff or DIFFS[d], is_limit and d == up)
            return res
        return f(0, False, True)
class Solution {
    private static int[] DIFFS = {0, 0, 1, -1, -1, 1, 1, -1, 0, 1};

    public int rotatedDigits(int n) {
        char[] s = Integer.toString(n).toCharArray();
        int[][] memo = new int[s.length][2];
        for (int[] row : memo) {
            Arrays.fill(row, -1);
        }
        return dfs(0, 0, true, s, memo);
    }

    private int dfs(int i, int hasDiff, boolean isLimit, char[] s, int[][] memo) {
        if (i == s.length) {
            return hasDiff; // 只有包含 2/5/6/9 才算一个好数
        }
        if (!isLimit && memo[i][hasDiff] >= 0) {
            return memo[i][hasDiff];
        }
        int res = 0;
        int up = isLimit ? s[i] - '0' : 9;
        for (int d = 0; d <= up; d++) { // 枚举要填入的数字 d
            if (DIFFS[d] != -1) { // d 不是 3/4/7
                res += dfs(i + 1, hasDiff | DIFFS[d], isLimit && d == up, s, memo);
            }
        }
        if (!isLimit) {
            memo[i][hasDiff] = res;
        }
        return res;
    }
}
int diffs[10] = {0, 0, 1, -1, -1, 1, 1, -1, 0, 1};

class Solution {
public:
    int rotatedDigits(int n) {
        string s = to_string(n);
        int m = s.size();
        vector<array<int, 2>> memo(m, {-1, -1});

        auto dfs = [&](this auto&& dfs, int i, bool has_diff, bool is_limit) -> int {
            if (i == m) {
                return has_diff; // 只有包含 2/5/6/9 才算一个好数
            }
            if (!is_limit && memo[i][has_diff] >= 0) {
                return memo[i][has_diff];
            }
            int res = 0;
            int up = is_limit ? s[i] - '0' : 9;
            for (int d = 0; d <= up; d++) { // 枚举要填入的数字 d
                if (diffs[d] != -1) { // d 不是 3/4/7
                    res += dfs(i + 1, has_diff || diffs[d], is_limit && d == up);
                }
            }
            if (!is_limit) {
                memo[i][has_diff] = res;
            }
            return res;
        };

        return dfs(0, false, true);
    }
};
var diffs = [10]int{0, 0, 1, -1, -1, 1, 1, -1, 0, 1}

func rotatedDigits(n int) int {
s := strconv.Itoa(n)
m := len(s)
memo := make([][2]int, m)
for i := range memo {
memo[i] = [2]int{-1, -1}
}
var dfs func(int, int, bool) int
dfs = func(i, isDiff int, isLimit bool) (res int) {
if i == m {
return isDiff // 只有包含 2/5/6/9 才算一个好数
}
if !isLimit {
p := &memo[i][isDiff]
if *p >= 0 {
return *p
}
defer func() { *p = res }()
}
up := 9
if isLimit {
up = int(s[i] - '0')
}
for d := 0; d <= up; d++ { // 枚举要填入的数字 d
if diffs[d] != -1 { // d 不是 3/4/7
res += dfs(i+1, isDiff|diffs[d], isLimit && d == up)
}
}
return
}
return dfs(0, 0, true)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mD)$,其中 $m=\mathcal{O}(\log n),\ D=10$。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(m)$,单个状态的计算时间为 $\mathcal{O}(D)$,所以动态规划的时间复杂度为 $\mathcal{O}(mD)$。
  • 空间复杂度:$\mathcal{O}(m)$。

专题训练

见下面动态规划题单的「十、数位 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站@灵茶山艾府

788. 旋转数字:动态规划

动归数组d,d[i]表示数字i的状态。

d[i]对应有三个值,1是好数,0是普数,-1是坏数。

好数是旋转后不同的数比如(2,5,6,9),普数是旋转以后相同的数如(0,1,8),坏数是旋转后不能成立的数如(3,4,7),这里前十个数的好坏情况通过切片给预处理了。

某数字i末位或末位以外的数字为坏数,数字i就肯定是坏数,例如(897,389,941)等等。

如果数字i不是坏数,那只要末位或末位以外的数字存在好数,那数字i就肯定是好数,如(120,886,509)等等就是好数,如(808,880)就是普数,程序这里计算用了按位或"|"。

如果数字i的结果d[i]是1是好数,那总答案就加1。

主要是末位以外的数字在计算该数字前肯定计算过了,所以可以动归。

class Solution:
    def rotatedDigits(self, N: int) -> int:
        ans = 0
        d = [0, 0, 1, -1, -1, 1, 1, -1, 0, 1] + [0] * (N - 9)
        for i in range(N + 1):
            if d[i // 10] == -1 or d[i % 10] == -1:
                d[i] = -1
            elif d[i // 10] == 1 or d[i % 10] == 1:
                d[i] = 1
                ans += 1
        return ans
class Solution:
    def rotatedDigits(self, N: int) -> int:
        ans, d = 0, [0, 0, 1, -1, -1, 1, 1, -1, 0, 1] + [0] * (N - 9)
        for i in range(N + 1):
            d[i] = -1 in (d[i // 10], d[i % 10]) and -1 or d[i // 10] | d[i % 10]
            ans += d[i] == 1
        return ans
func rotatedDigits(N int) int {
    ans, d := 0, make([]int, N + 1)
    copy(d, []int{0, 0, 1, -1, -1, 1, 1, -1, 0, 1})
    for i := 0; i <= N; i++ {
        if d[i / 10] == -1 || d[i % 10] == -1 {
            d[i] = -1
        } else if d[i] = d[i / 10] | d[i % 10]; d[i] == 1 {
            ans++
        }
    }
    return ans
}
impl Solution {
    pub fn rotated_digits(n: i32) -> i32 {
        let mut d: Vec<i32> = vec![0, 0, 1, -1, -1, 1, 1, -1, 0, 1];
        d.extend(vec![0; (n - 9).max(0) as usize]);
        let mut ans = 0;
        for i in 0..=n as usize {
            d[i] = if d[i / 10] == -1 || d[i % 10] == -1 {-1} else {d[i / 10] | d[i % 10]};
            ans += if d[i] == 1 {1} else {0};
        }
        ans
    }
}
var rotatedDigits = function(N) {
    let ans = 0
    const d = [0, 0, 1, -1, -1, 1, 1, -1, 0, 1].concat(Array(Math.max(0, N - 9)).fill(0))
    for (let i = 0; i <= N; ++i) {
        if (d[Math.floor(i / 10)] == -1 || d[i % 10] == -1) {
            d[i] = -1
        } else if (d[Math.floor(i / 10)] == 1 || d[i % 10] == 1) {
            d[i] = 1
            ++ans
        }
    }
    return ans
};
function rotatedDigits(N: number): number {
    let ans: number = 0;
    let d: number[] = [0, 0, 1, -1, -1, 1, 1, -1, 0, 1, ...Array(Math.max(N - 9, 0)).fill(0)];
    for (let i of Array.from({length: N + 1}, (_, k) => k)) {
        let [j, k] = [Math.trunc(i / 10), i % 10];
        d[i] = (d[j] == -1 || d[k] == -1) && -1 || d[j] | d[k];
        ans += Number(d[i] == 1);
    }
    return ans
};

pyimage.png
goimage.png
rsimage.png
jsimage.png
tsimage.png

Netcat Cheatsheet

Basic Syntax

Core nc command forms.

Command Description
nc host port Open a TCP connection
nc -u host port Open a UDP connection
nc -l port Listen on a local port
nc -h Show help and options
man nc Read the local Netcat manual

Connect and Listen

Create simple client and server connections.

Command Description
nc example.com 80 Connect to TCP port 80
nc -l 5555 Listen on port 5555
nc server.example.com 5555 Connect to a listening host
nc -v host 22 Connect with verbose output
nc -n 192.168.1.10 22 Skip DNS lookups

Port Checks

Check whether TCP ports are open.

Command Description
nc -z -v host 22 Check one TCP port
nc -z -v host 20-80 Check a port range
nc -z -v host 80 443 Check selected ports
nc -z -w 3 host 443 Check with a timeout
nmap host Use Nmap for deeper scans

UDP

Use UDP instead of TCP.

Command Description
nc -u host 53 Connect to a UDP service
nc -u -l 5555 Listen for UDP datagrams
nc -z -v -u host 53 Check a UDP port
nc -u -w 3 host 123 UDP check with timeout
nc -u 192.168.1.10 5555 Send text to a UDP listener

File Transfers

Send files between two hosts.

Command Description
nc -l 5555 > file Receive a file
nc host 5555 < file Send a file
nc -l 5555 | tar xzvf - Receive and extract a directory
tar czvf - dir | nc host 5555 Archive and send a directory
nc -w 5 host 5555 < file Send with a timeout

HTTP and Raw Requests

Send plain text requests to network services.

Command Description
printf "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n" | nc example.com 80 Send an HTTP request
echo "QUIT" | nc mail.example.com 25 Send a simple SMTP command
echo "PING" | nc host 5555 Send text to a listener
nc host 80 Type a request manually
curl http://example.com Use curl for HTTP work

Timeouts and Persistent Listeners

Control how long Netcat waits and whether it keeps listening.

Command Description
nc -w 5 host 80 Timeout after 5 seconds
nc -k -l 5555 Keep listening after disconnect
nc -v -w 3 host 22 Verbose connection with timeout
nc -l 8080 < index.html Serve one file on port 8080
while true; do nc -l 8080 < index.html; done Serve the file repeatedly

Troubleshooting

Quick checks for common Netcat issues.

Issue Check
nc: command not found Install netcat-openbsd, netcat-traditional, or nmap-ncat
Connection refused Confirm the remote service is running and listening on that port
Command hangs Add -w to set a timeout
UDP result is unclear UDP has no TCP-style handshake, use Nmap for reliable scans
Option behaves differently Run man nc and check the Netcat implementation on that system

Related Guides

Use these guides for broader networking workflows.

Guide Description
Netcat Command in Linux Full Netcat guide with examples
Nmap Command in Linux Scan hosts and ports in more detail
curl cheatsheet Work with HTTP requests and APIs
tcpdump cheatsheet Capture and inspect network packets
ss Command in Linux Inspect sockets and listening services

React 性能优化精讲

在日常 React 项目开发中,绝大多数开发者都会陷入一个核心误区:默认 React 框架本身高性能,业务项目就一定流畅无卡顿。但在真实企业级项目落地中,我们频繁遇到各类性能问题:首屏白屏耗时久、页面滚动帧率暴跌、表单输入响应延迟、应用长期运行越用越卡、偶发全局白屏崩溃等。

究其本质:React 仅封装了高效的底层视图更新机制,并不会自动优化业务代码。框架解决了原生 JS 频繁操作 DOM 的低效问题,但项目中出现的无效重渲染、重复计算、资源冗余、主线程阻塞、内存泄漏等核心性能问题,全部源于业务代码不规范、状态设计不合理、工程配置不完善。

本文将从浏览器底层渲染原理、React 核心更新机制、组件级精准优化、大数据场景专项优化、首屏全链路工程优化、应用稳定性治理、React18 高阶并发调度、状态架构源头优化八个核心维度,由浅入深、层层递进,结合通俗解读与专业原理,搭建一套闭环、可落地、成体系的 React 性能优化方案。全文逻辑严谨、流程清晰、案例完整可复用,既适合开发者深度学习沉淀技术笔记,也可直接用于团队技术分享、项目性能复盘与架构优化落地。

一、底层基石:前端性能优化的本质逻辑

所有前端页面的性能问题,最终都指向浏览器主线程。浏览器的 JS 解析执行、DOM 节点操作、CSS 样式计算、页面布局绘制、交互事件响应全部依赖主线程,且主线程为单线程串行执行,同一时间仅能处理一项任务。一旦主线程被耗时任务长时间阻塞,页面就会出现卡顿、输入延迟、点击无响应、卡死、白屏等问题。

因此,前端性能优化的终极本质可归纳为四条核心准则,所有 React 优化方案均围绕这四点展开:

  1. 减少无效 JS 计算:规避重复执行、冗余计算、无意义逻辑执行,降低 JS 执行耗时;

  2. 减少冗余 DOM 更新:最小化真实 DOM 操作频次,减少浏览器重排、重绘开销;

  3. 精简网络资源:压缩资源体积、减少请求次数、优化加载策略,极速首屏渲染;

  4. 规避主线程阻塞:拆分耗时任务、优先级调度任务,保障用户交互高优先级执行。

想要做好 React 性能优化,不能只靠 API 堆砌,必须先吃透浏览器渲染底层逻辑与 React 视图更新机制,从根源理解性能瓶颈的产生原因。

1.1 浏览器完整渲染流水线(核心性能理论)

浏览器从接收前端代码到最终页面可视化展示,遵循一套固定、不可逆的渲染流水线,任意环节耗时过长都会直接影响用户体验,完整流程如下:

解析HTML生成DOM树 → 解析CSS生成CSSOM树 → 合成渲染树 → 布局(重排)计算元素尺寸位置 → 绘制(重绘)像素填充 → 图层合成 → 页面最终展示

在整条流水线中,**重排(Reflow)重绘(Repaint)**是影响页面性能的两大核心概念,必须精准区分:

  • 重排(Reflow,回流):当元素的布局尺寸、位置、层级、盒模型属性发生变更时,浏览器需要重新计算页面所有相关元素的布局信息,触发完整渲染流水线。重排开销极高,是页面卡顿的核心元凶

  • 重绘(Repaint):仅元素颜色、背景色、透明度、阴影等纯样式属性变更,不改变页面布局结构,无需重新计算元素位置尺寸。开销远低于重排,但高频、大批量重绘依然会造成页面掉帧卡顿。

而 React 虚拟 DOM + Diff 算法的核心价值,正是精准对比视图差异,只推送最小粒度的 DOM 更新补丁,最大限度减少真实 DOM 操作,从源头降低浏览器重排、重绘的性能开销。

1.2 React 视图更新完整链路

React 采用经典的数据驱动视图设计思想,摒弃原生手动操作 DOM 的模式,通过状态变更自动触发视图更新。其完整更新流程分为协调阶段提交阶段两大核心阶段,两个阶段的执行特性完全不同,也是性能优化的关键切入点。

完整更新链路流程

State / Props / Context 状态变更 → 对应组件标记为待更新状态 → 进入协调阶段(生成新虚拟DOM、新旧虚拟DOM Diff 比对、计算最小更新补丁) → 进入提交阶段(批量操作真实 DOM、触发浏览器渲染流水线) → 完成页面视图更新

两大阶段核心差异

  • 协调阶段(内存计算):纯 JS 内存运算,不涉及任何真实 DOM 操作,运行开销极低。React18 及以上版本支持任务暂停、中断、优先级插队,调度灵活性大幅提升。

  • 提交阶段(DOM 操作):执行真实 DOM 增删改操作,触发浏览器重排重绘,开销极大。该阶段为同步执行、不可中断,是 React 项目绝大多数性能瓶颈的核心场景

1.3 React 原生机制的四大天然性能缺陷

很多人误以为 React 框架自带极致性能,实则不然。React 为了兼顾通用性、灵活性与开发体验,底层设计天然存在性能冗余,这也是我们需要手动做业务层优化的根本原因:

  1. 无条件递归更新:父组件触发重渲染时,默认会递归触发整棵子组件树重渲染,与子组件自身数据是否变更无关,产生大量无效渲染。

  2. 无自动缓存机制:函数组件每次重渲染都是一次全新的函数执行,内部定义的函数、对象、数组都会生成全新内存引用,极易触发不必要的更新。

  3. 浅比较局限性:Diff 算法、所有 React 缓存 API 均采用浅比较策略,无法识别嵌套对象、深层数组的属性变更,容易出现更新失效或过度更新问题。

  4. 同步阻塞渲染(React18 前):旧版本 React 渲染任务一旦启动必须执行完毕,无法中断,大数据渲染、复杂视图更新会直接阻塞主线程,造成交互卡顿。

二、组件层核心优化:彻底解决无效重渲染问题

无效重渲染是 React 项目最普遍、性价比最高、优先级最靠前的优化点。所谓无效重渲染,即组件的状态、props、依赖无任何有效变更,但组件依然重复执行渲染逻辑、参与 Diff 比对,白白消耗主线程资源,长期累积就会造成页面卡顿。

2.1 无效重渲染三大核心根源

经过大量企业项目复盘,99% 的组件无效重渲染均来自以下三种场景,其中后两种为高频隐形坑:

  1. 自身状态更新:组件内部 state、context 发生有效变更触发渲染,属于合理渲染,无需优化;

  2. 父组件渲染传导:父组件任意状态更新,无论子组件 props、数据是否变动,子组件都会无条件跟随重渲染,是最核心的无效渲染场景

  3. 引用地址陷阱:组件内联定义函数、对象、数组,每次组件渲染都会生成全新内存引用,浅比较机制会判定数据更新,强制触发子组件重渲染,隐蔽性极强。

2.2 优化前置原则:先测速,后优化

性能优化最大的误区是盲目堆砌 memo、useMemo、useCallback。所有缓存 API 都存在内存开销和代码复杂度成本,滥用、错用不仅无法提升性能,反而会造成内存冗余、代码可读性下降,引发负优化

企业级标准优化流程:先定位瓶颈、再精准优化、最后验证效果,杜绝无意义优化。

React DevTools Profiler 性能排查完整流程

  1. 安装官方 React 开发者工具插件,切换至 Profiler 性能面板;

  2. 开启录制按钮,复现页面卡顿、频繁更新、输入延迟等问题场景;

  3. 停止录制,查看组件渲染耗时、渲染次数、更新链路;

  4. 通过 Why did this render 功能精准定位更新诱因:自身状态更新/父级传导/Props 引用变更;

  5. 根据定位结果做靶向优化,实现精准降本提效。

2.3 三大记忆化 API 深度实战(完整缓存体系)

React 提供三套互补的记忆化 API,形成「组件渲染+计算逻辑+函数引用」的完整缓存体系,核心原理统一:依赖不变,复用上次执行结果,跳过无效计算与渲染

2.3.1 React.memo|组件级渲染缓存

React.memo 是官方高阶组件(HOC),专门用于缓存函数组件渲染结果。它会对组件 Props 执行浅比较,若 Props 无任何变更,直接复用上次渲染结果,跳过本次重渲染和 Diff 比对,从根源拦截无效渲染。

适用场景:纯展示组件、无内部状态组件、被父组件高频带动更新的通用 UI 组件、列表项组件;

不适用场景:高频动态变更组件、渲染耗时极短的小型组件(缓存开销大于优化收益)。

// 基础用法:纯组件浅比较缓存
const UserCard = React.memo(({ name, avatar }) => {
  return (
    <div className="card">
      <img src={avatar} alt="用户头像" />
      <p className="name">{name}</p>
    </div>
  )
})

// 进阶用法:复杂嵌套Props自定义比对,解决浅比较失效问题
// 仅核心业务ID一致,判定组件无需更新,精准规避无效渲染
const CustomMemoComp = React.memo(Component, (prevProps, nextProps) => {
  return prevProps.info.id === nextProps.info.id
})

2.3.2 useMemo|计算值与引用数据缓存

useMemo 用于缓存组件内部的耗时计算逻辑对象、数组等引用类型数据。当依赖项不变时,不会重复执行计算逻辑,同时稳定数据的内存引用地址,配合 React.memo 可彻底杜绝因引用变更导致的无效重渲染。

核心两大作用:1. 避免耗时筛选、计算、遍历逻辑重复执行;2. 稳定引用类型数据地址,补齐 memo 缓存能力。

const ListPage = ({ originList = [] }) => {
  // 仅原始列表数据变化时,重新执行筛选计算,否则复用缓存结果
  const validList = useMemo(() => {
    return originList.filter(item => item.status === 1 && item.isValid)
  }, [originList])

  // 稳定数据引用,子组件memo生效,杜绝无效渲染
  return <List data={validList} />
}

2.3.3 useCallback|函数引用固化

函数组件每次重渲染,内联函数都会被重新创建,生成全新内存引用。即便函数逻辑完全不变,引用地址变更也会让 memo 缓存失效,触发子组件重渲染。

useCallback 的核心作用是固化函数引用地址,依赖不变时,函数地址永久不变,完美配合 memo 实现组件缓存。

关键注意点:useCallback 必须配合 React.memo 使用,单独使用无任何渲染优化效果。

const ListPage = () => {
  const [count, setCount] = useState(0)

  // 依赖为空数组,组件生命周期内函数引用永久固定
  const handleItemClick = useCallback((id) => {
    console.log('点击列表条目:', id)
  }, [])

  // 子组件ListItem搭配memo即可实现缓存生效
  return <ListItem onClick={handleItemClick} />
}

2.4 高频优化误区深度解析(避坑核心)

大量开发者优化无效、越优化越卡,本质是踩中了缓存 API 的使用误区,四大高频坑点务必规避:

  1. 过度缓存:对简单组件、极简计算逻辑使用 memo、useMemo,缓存的内存开销、代码维护成本大于优化收益,造成负优化;

  2. API 单独使用:仅写 useCallback/useMemo 但不配合 memo,无法拦截组件重渲染,优化完全失效;

  3. 浅比较局限忽略:嵌套对象、深层数组的属性变更,浅比较无法识别,会出现「数据更新、视图不更新」的隐性 bug;

  4. 依赖项不规范:随意省略、篡改 Hook 依赖项,导致缓存数据陈旧,出现视图与数据不一致的业务问题。

三、场景化实战优化:大数据长列表卡顿终极解决方案

在后台管理系统、内容信息流、数据大屏、日志列表等业务场景中,长列表滚动卡顿是最典型的性能瓶颈。当单页数据量超过 500 条时,全量 DOM 渲染会直接导致首屏加载缓慢、滚动帧率暴跌、页面卡死,传统分页、懒加载仅能缓解问题,无法根治。

3.1 长列表卡顿底层核心原理

  1. DOM 节点过载:DOM 节点的解析、挂载、样式计算、渲染成本极高,上千个 DOM 节点会瞬间耗尽主线程资源,造成首屏渲染阻塞;

  2. 高频重排重绘:滚动过程中,海量列表项持续更新位置、样式,高频触发浏览器渲染流水线,持续阻塞主线程,导致滚动掉帧;

  3. 内存持续累积:非可视区域的列表项常驻 DOM 树,不会自动销毁,长期滚动会持续累积内存,出现「页面越滑越卡」的现象。

3.2 终极方案:虚拟滚动原理详解

虚拟滚动是解决长列表卡顿的行业最优方案,核心思想:放弃全量 DOM 渲染,仅渲染用户可视区域内的 DOM 节点,让页面常驻 DOM 数量始终维持在 20-50 个,从根源解决 DOM 过载、渲染卡顿问题。

虚拟滚动完整执行流程(文字流程图解):

  1. 定义外层固定高度容器,锁定列表可视区域范围;

  2. 设定单条列表项固定/动态尺寸,计算可视区域可容纳的最大条目数;

  3. 监听页面滚动事件,实时获取滚动偏移量;

  4. 根据偏移量、单条尺寸、可视高度,精准计算当前需要渲染的数据区间;

  5. 仅渲染区间内的少量 DOM 节点,通过 transform 位移模拟完整列表的滚动高度;

  6. 滚动过程中实时更新渲染区间,复用 DOM 节点,实现无缝滚动。

3.3 技术选型与完整实战代码

业界主流两大成熟方案,可根据业务场景选型:

  • react-window:轻量、高性能、体积小,适配绝大多数常规长列表场景,优先推荐;

  • react-virtualized:功能全面,支持复杂表头、不定高、分组列表,适配重度复杂业务。

import { FixedSizeList } from 'react-window'

// 定高虚拟列表完整实战Demo,适配绝大多数大数据列表场景
const BigDataList = ({ dataList = [] }) => {
  return (
    <FixedSizeList
      height={500}     // 列表可视区域高度
      width="100%"     // 列表自适应宽度
      itemCount={dataList.length} // 数据源总条数
      itemSize={50}    // 单条列表固定高度
    >
      {({ index, style }) => {
        // 实时获取当前渲染条目数据
        const item = dataList[index] || {}
        return (
          <div style={style} className="list-item">
            {item.title}
          </div>
        )
      }}
    </FixedSizeList>
  )
}

3.4 组合优化与避坑细则

虚拟滚动可解决滚动卡顿,搭配以下优化可实现极致体验:

  1. 分页+虚拟滚动组合:接口分页控制单次加载数据量,减少首屏渲染压力,滚动触底懒加载增量数据,适配无限滚动场景;

  2. 不定高列表适配:不规则列表使用 VariableSizeList 动态计算条目高度,避免滚动错位、空白问题;

  3. 简化列表节点:列表条目避免嵌套重型组件、复杂计算、高频动画,降低单条 DOM 渲染耗时;

  4. 关闭滚动监听冗余逻辑:滚动过程中禁止执行耗时计算、接口请求,仅保留视图更新逻辑。

四、首屏性能优化:从打包到传输全链路提速

首屏加载速度直接决定用户留存率与产品体验核心指标。React 项目默认会将所有业务代码、第三方依赖打包为单一 bundle 文件,随着项目迭代,代码量和依赖持续膨胀,会出现首屏白屏时间长、资源加载慢、LCP(最大内容绘制)指标不达标、首屏交互延迟等问题。

首屏优化核心思路:拆包减量、按需加载、资源压缩、加速传输,确保首屏仅加载核心必需资源,非核心资源延迟加载。

4.1 代码分割与懒加载(最高收益优化)

基于 ES6 动态 import 语法,Webpack 可自动实现代码块分割,搭配 React 官方的 lazy + Suspense 实现路由级、组件级按需加载,是中大型 React 项目首屏优化的必备方案,优化收益最高。

4.1.1 路由级懒加载(核心优化)

路由页面是天然的按需加载单元,非当前路由无需在首屏加载,可最大程度压缩首屏包体积。

import { lazy, Suspense } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'

// 核心首页、高频页面常驻首屏,保证基础体验
import Home from './pages/Home'
// 非核心路由、低频页面懒加载,首屏不加载
const About = lazy(() => import('./pages/About'))
const UserCenter = lazy(() => import('./pages/UserCenter'))

// 优雅加载兜底,避免首屏白屏,提升用户感知
const PageLoading = () => <div className="loading">页面加载中,请稍候...</div>

const App = () => {
  return (
    <Router>
      {/* 懒加载页面统一兜底 */}
      <Suspense fallback={<PageLoading />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/user" element={<UserCenter />} />
        </Routes>
      </Suspense>
    </Router>
  )
}

4.1.2 组件级懒加载(精细化优化)

针对弹窗、抽屉、富文本编辑器、数据图表、Excel 导出等非首屏、重型、触发式使用的组件,实现按需加载,进一步精简首屏资源体积。

4.2 工程化打包深度优化

基于 Webpack/Vite 工程配置,从打包层面全方位精简代码、优化资源:

  1. 开启 Tree-Shaking 摇树优化:项目统一使用 ES6 Module 模块化规范,生产环境自动剔除未引用的死代码、冗余依赖、无效逻辑;

  2. 资源压缩处理:生产环境开启 JS、CSS、HTML 代码压缩,去除注释、空格、冗余代码,关闭 sourceMap 减少打包体积;

  3. 第三方依赖拆分:将 React、ReactDOM、UI 组件库、Axios 等稳定不常更新的依赖单独拆包,利用浏览器长效缓存,避免每次迭代重复加载;

  4. 服务端传输压缩:服务器开启 Gzip、Brotli 压缩,资源传输体积可缩减 60% 以上,大幅提升加载速度;

  5. 静态资源 CDN 托管:图片、静态资源、第三方库全部托管至 CDN,利用 CDN 就近加速能力,规避服务器带宽限制。

4.3 静态资源精细化优化

  1. 图片懒加载:非可视区域图片统一开启 loading="lazy",延迟加载,减少首屏资源请求量;

  2. 图片格式升级:使用 WebP、AVIF 高压缩率格式替代 PNG、JPG,同等清晰度下体积减半;

  3. 图标轻量化:小尺寸图标统一使用 IconFont 字体图标或 SVG 图标,替代图片图标,减少网络请求次数与资源体积。

五、稳定性优化:异常容错与内存泄漏治理

真正高性能的企业级应用,不仅要加载快、交互流畅,更要长期稳定运行。很多项目短期使用流畅,长时间运行后出现内存飙升、页面卡顿、偶发白屏、崩溃等问题,核心原因是缺少异常容错兜底和内存泄漏治理。

5.1 错误边界:隔离局部异常,杜绝整页白屏

React 中任意子组件出现渲染报错、生命周期报错,错误会逐层向上冒泡,最终导致整个应用白屏崩溃。**错误边界(Error Boundary)**可捕获子组件渲染异常,隔离错误范围,展示降级 UI,保障应用主体可用。

注意:错误边界仅类组件支持,可捕获渲染、生命周期、构造函数错误,无法捕获异步请求、定时器、事件回调中的错误

import React from 'react'

class ErrorBoundary extends React.Component {
  state = { hasError: false, errorMsg: '' }

  // 捕获错误,更新状态触发降级渲染
  static getDerivedStateFromError(error) {
    return { hasError: true, errorMsg: error.message }
  }

  // 收集错误信息,用于日志上报
  componentDidCatch(error, errorInfo) {
    console.error('组件渲染异常:', error, errorInfo)
    // 可对接前端监控平台,实现异常自动上报
  }

  render() {
    // 异常降级展示
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h3>模块加载异常</h3>
          <p>{this.state.errorMsg}</p>
          <button onClick={() => window.location.reload()}>刷新重试</button>
        </div>
      )
    }
    // 无异常则正常渲染子组件
    return this.props.children
  }
}

5.2 内存泄漏根治方案

页面长期运行卡顿、内存占用持续升高、页面越用越卡,核心原因是:组件卸载后,副作用逻辑未彻底销毁。前端高频内存泄漏场景:定时器、全局事件监听、未取消的异步请求、WebSocket 订阅、全局变量挂载。

统一解决方案:在 useEffect 清理函数中,批量销毁所有副作用,彻底杜绝内存泄漏。

import { useEffect, useState } from 'react'

const DemoComponent = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    // 开启定时器
    const timer = setInterval(() => setCount(prev => prev + 1), 1000)
    // 绑定全局滚动监听
    const handleScroll = () => console.log('滚动监听')
    window.addEventListener('scroll', handleScroll)
    // 异步请求中断控制器
    const abortController = new AbortController()

    // 组件卸载时统一清理所有副作用
    return () => {
      clearInterval(timer) // 清空定时器
      window.removeEventListener('scroll', handleScroll) // 移除事件监听
      abortController.abort() // 取消未完成请求
    }
  }, [])

  return <div>计数器:{count}</div>
}

六、React18高阶优化:并发渲染与任务优先级调度

React18 版本最核心的底层升级就是并发渲染模式(Concurrent Mode),彻底解决了旧版本同步阻塞渲染的痛点。通过任务优先级分级调度,让高优先级的用户交互任务优先执行,低优先级的视图更新任务可中断、可插队,极致提升用户交互流畅度。

6.1 并发模式核心原理

React18 之前,所有渲染任务均为同步、不可中断,一旦渲染任务开始,必须执行完毕才能响应其他交互。遇到大数据渲染、复杂视图更新时,主线程被长时间阻塞,直接造成输入延迟、点击卡顿、页面无响应。

React18 并发模式将任务分为两大优先级:

  • 高优先级任务:用户输入、按钮点击、弹窗开关、手势交互等即时用户操作,优先执行,绝不阻塞;

  • 低优先级任务:数据筛选、列表渲染、视图更新、状态同步等非即时操作,支持暂停、中断、恢复、插队。

6.2 useTransition 实战落地

useTransition 是 React18 核心高阶 Hook,用于手动标记非紧急低优先级任务,避免繁重的数据处理、视图更新阻塞用户实时交互,完美解决搜索输入、筛选、排序等场景的卡顿问题。

import { useState, useTransition } from 'react'

const SearchPage = () => {
  const [keyword, setKeyword] = useState('')
  const [list, setList] = useState([])
  // 开启过渡任务,isPending标记低优先级任务执行状态
  const [isPending, startTransition] = useTransition()

  const handleInputChange = (e) => {
    const value = e.target.value
    // 高优先级:实时更新输入框内容,保证输入丝滑无延迟
    setKeyword(value)
    // 低优先级:将数据筛选、列表更新纳入过渡任务,可被中断
    startTransition(() => {
      // 模拟大数据筛选、复杂计算逻辑
      const filterResult = mockFilterData(value)
      setList(filterResult)
    })
  }

  return (
    <>
      <input value={keyword} onChange={handleInputChange} placeholder="关键词搜索" />
      {/* 低优先级任务执行中展示加载状态,优化用户感知 */}
      {isPending ? <div>数据筛选中...</div> : <List data={list} />}
    </>
  )
}

七、源头架构优化:状态设计决定性能上限

经过大量项目复盘得出结论:80% 的 React 性能问题,根源不是不会用缓存 API,而是状态架构设计不合理。混乱的状态定义、冗余的状态存储、不合理的状态层级,会从源头产生大量无效渲染和重复计算。优秀的状态设计,可以无需堆砌优化代码,从根源规避性能问题,是性价比最高的底层优化。

7.1 状态设计四大黄金原则

  1. 状态最小化:不存储可通过现有数据计算的冗余状态,仅存储核心原始数据,减少状态更新频次;

  2. 状态下沉:局部状态定义在最小使用单元组件中,避免顶层状态更新带动整棵组件树联动渲染;

  3. 状态扁平化:摒弃嵌套对象式 State,扁平化存储状态,减少无效深层属性变更导致的引用变化;

  4. 高低频拆分:高频更新状态(输入框、滚动位置)与低频更新状态(用户信息、配置数据)拆分管理,避免高频状态带动低频视图更新。

7.2 正反案例对比(规范落地)

// ❌ 错误示范:嵌套冗余、顶层聚合、包含静态数据、高低频混杂
const [userInfo, setUserInfo] = useState({ name: '', age: 0, token: '' })

// ✅ 正确示范:扁平化、按需拆分、剥离静态数据、高低频分离
const [name, setName] = useState('')
const [age, setAge] = useState(0)
// 静态/全局数据抽离至状态管理库,不占用组件本地state
const { token } = useUserStore()

八、工程落地规范与全文总结

8.1 企业项目优化优先级(收益从高到低)

在实际项目优化中,无需盲目全量优化,可按照以下优先级落地,低成本获取最高性能收益:

  1. 代码分割+工程打包优化:首屏加载速度提升最明显,用户感知最强;

  2. 状态架构优化+无效重渲染优化:根治日常交互卡顿,从源头减少性能消耗;

  3. 大数据列表虚拟滚动优化:解决特定场景重度卡顿问题,刚需优化;

  4. 内存泄漏治理+错误边界兜底:保障应用长期稳定运行,规避远期性能劣化;

  5. React18 并发渲染优化:极致优化交互体验,解决输入、筛选等高频场景卡顿;

  6. 静态资源精细化优化:低成本、高收益,全方位辅助提效。

8.2 核心优化准则

所有 React 性能优化必须遵循核心准则:先测速、后优化,先源头、后补丁,按需优化、拒绝过度。绝不以牺牲代码可读性、可维护性、可扩展性为代价,换取微小的性能提升,避免过度优化导致的工程负债。

8.3 全文总结

本文从浏览器底层渲染原理出发,完整覆盖了 React 视图更新机制、组件级缓存优化、大数据场景专项优化、首屏全链路工程优化、应用稳定性治理、React18 高阶并发调度、状态架构源头优化八大模块,搭建了一套从底层原理到业务落地、从短期提速到长期稳定的闭环性能优化体系。

React 性能优化的本质可概括为四句话:减少无效渲染、减少重复计算、减少资源体积、减少主线程阻塞

真正的高阶性能优化,不是熟练堆砌各类缓存 API,而是吃透底层运行机制,在项目开发初期就规避性能隐患,让 React 应用实现高速加载、流畅交互、长期稳定的极致体验。

GitHeron:把网页标注写到 GitHub

最近写了一个 Chrome 插件,叫 GitHeron。它想解决的问题很简单:

Web highlights and clippings, synced to GitHub as Markdown.

我一直使用 Hypothesis 来同步冲浪记录,然后使用一个 Obsidian 插件来同步到我的知识库。但 hypothesis 的浏览器插件体验不佳,时不时需要登录,选中文字打算做备注时又偶尔无法激活,我就想自己写个插件来解决这个问题。

GitHeron 的思路是直接使用 Github token 访问私有 repo,通过 API 把数据写入仓库。GitHub 当然不是传统意义上的数据库。但对个人工具来说,它已经提供了很多“数据库”才有的能力:同步、历史、权限、备份、API、跨设备访问。更重要的是,这些都完全是由自己控制的。

网页备注和高亮

GitHeron 最核心的功能是网页标注。

在网页上选中一段文字,按下快捷键 (默认 Ctrl+E),就可以打开 note 编辑框。写完之后,这段文字会在页面上变成高亮。下次再打开同一个页面,GitHeron 会自动把之前的高亮恢复出来。

这件事听起来不复杂,但体验上很重要。很多阅读笔记工具只能把内容保存走,却不能在原网页上重新建立上下文。GitHeron 更在意的是“回到现场”:当你再次打开这篇文章时,能立刻看到自己上次为什么停在这里。

写 note 时也可以加 tags,最近使用过的 tags 会出现在输入框附近:

保存整篇网页

除了高亮,GitHeron 还可以保存当前网页的主要内容。

按下快捷键 (默认 Ctrl + O) 后,它会提取页面正文,转换成 Markdown,然后保存到仓库中的 Clippings 目录。这里保存的是 main content,不是整个网页 HTML,所以导航栏、广告、推荐列表这些内容会尽量被过滤掉。

网页剪藏部分使用 Defuddle 来提取 main content,再转换成 Markdown。它不能保证所有网页都完美,但比直接保存整个 DOM 更接近“我真正想留下来的文章内容”。

这个功能更接近 Obsidian Web Clipper:遇到一篇值得完整保存的文章,不需要复制粘贴,也不需要手工整理格式,直接让它进入自己的 Markdown 仓库。

使用体验

在 Settings 填入一个 Github repo 地址,私有的或者公开的都行,然后去 Github token 页面生成一个 token 含有写入这个 repo 权限的 token,填入插件的配置里即可。

默认有两个快捷键:

  • Ctrl+E:给当前选中文字添加 note;
  • Ctrl+O:保存当前网页正文。

如果喜欢鼠标操作,也可以开启选中文字后的悬浮按钮;如果不喜欢它打扰阅读,可以在 settings 里关掉,只使用快捷键。

同步 GitHub 时也有两种模式。普通模式会等 GitHub 写入完成再结束;后台同步模式则会先更新页面状态,把任务放到后台慢慢同步。网络失败或 GitHub 返回错误时,可以在 settings 的 tasks 里看到最近任务,并进行 retry。

写 note 应该是一个很轻的动作,不应该因为网络慢而打断阅读节奏。

技术方案

GitHeron 是一个 Chrome MV3 插件,主要由 content script 和 service worker 组成。

content script 负责页面里的交互:选区、高亮、快捷键、弹框和正文提取。为了避免被网页自身样式影响,弹框和面板都放在 Shadow DOM 里。

service worker 负责设置、后台任务和 GitHub API。写入仓库时使用 GitHub 的 Git Data API 来生成 commit,这样一次保存可以同时更新 Markdown 内容和用于恢复高亮的辅助数据。

这里有一个取舍:Markdown 文件应该尽量保持可读,不应该塞进大段元数据。所以 GitHeron 会把可读内容写进 .md,把用于定位高亮的 selector 信息放到旁边的 JSON 文件里。这样仓库里既有人能直接读的笔记,也有插件重新打开网页时需要的结构化数据。

小结

GitHeron 是一个很个人化的工具,它的目标不是做一个复杂的标注系统,而是让“读到有用内容”到“进入自己的知识库”之间少一点摩擦。

对了,我最近还 Vibe coding 了另一个小插件 auto tabs,也是解决我日常的具体问题的。在 AI 时代,稍微有点编程经验的人都会把自己的工作流优化到极致。

一天上线 + 零返工:我如何给复杂前端需求建立“安全感”

一天上线一个高不确定性需求:状态矩阵 + E2E 让前端交付不返工

最近用一个工作日上线了一个"容易反复改"的前端需求,过程几乎没有返工。

说真的,这次上线给我一种很少见的感觉:我对这段逻辑有安全感。不是那种"大概没问题"的心虚。

需求本身不复杂,但很典型:AI 流式回答过程中,根据"思考步骤"和"正文"的返回情况动态切换 UI。

难点不在 UI,在时序不确定性。


一个看着简单、写起来容易错的需求

场景:

某个 AI 对话模式下,如果没有"思考步骤",先展示等待态;有了就切换。

但实际跑起来:

  • 正文可能先到,步骤后到
  • 步骤可能先到,正文后到
  • 中间几秒到十几秒的空窗
  • 不同对话模式的逻辑还不一样

写个简单的 if 会怎样?

一边还在显示"等待灵感",另一边正文已经开始滚动了。这种 UI 上线后就是反复改的开始。


先让 AI 找现状,不要直接改

这个需求一开始容易误判。

我最初以为是:没有思考步骤时显示金句,有了之后金句和步骤都显示。后来跟产品确认才知道正确逻辑是:没步骤时显示金句,有步骤后只显示步骤。再往后又发现一个遗漏:如果已经有正文了,即使还没步骤,也不能继续显示金句。

三轮理解修正,才算把需求搞清楚。

这里 AI 的价值不是"直接给答案",而是快速把相关文件串起来。它帮我定位到几个关键文件:展示思考状态的组件、消息列表的渲染入口、全局 UI 状态管理、聊天服务和流式处理逻辑。

最关键的发现是,等待态组件和 Markdown 正文是并列渲染的:

{showProgress && <MessageProgress2 />}
{showMD && <MdRender text={handledContent} />}

只看等待态组件本身,很容易漏掉"金句 + 正文同屏"的问题。得从渲染入口一层层往下看才能发现。

到这一步我意识到:直接写代码大概率改了又改。它不是 UI 问题,是状态问题。


用状态矩阵把需求说清楚

我没有继续讨论"什么时候显示等待态",而是把所有状态列出来:

场景 chatType 是否有步骤 是否有正文 期望
1 agent 显示等待态(gif + 金句)
2 agent 显示正文,隐藏金句
3 agent 无/有 显示步骤,不显示金句
4 非 agent 任意 任意 保持原逻辑

这一步把讨论从"感觉对不对"变成了"每个状态怎么渲染"。

而且我们确实在这里抓到了一个错误:我一开始把正文判断放在了外层条件上,导致非 agent 场景被误伤。后来改成只作用在 agent 分支里。


用 E2E 锁住最容易出错的状态

没写很多测试,只覆盖了三个关键场景:

  1. agent + 无正文 + 无步骤 → 金句出现
  2. agent + 有正文 + 无步骤 → 金句消失
  3. agent + 有步骤 → 步骤树出现,金句消失

测试重点不是 UI 细节,而是:状态有没有切换正确。

为了让测试稳定,我加了几个选择器:

data-testid="progress-agent-quote"   // 金句容器
data-testid="progress-quote-text"    // 金句文本
data-testid="progress-analyzing"     // 分析中状态
data-testid="progress-tree"          // 步骤树

这些不是在测实现细节,而是稳定定位几个用户可见状态。

跑完之后我就知道了一件事:以后谁改这段逻辑,这几个状态不会被改坏。第一层安全感就是这样来的。


做 Demo,把时序问题变成可见的

E2E 能证明逻辑,但不适合肉眼看过程。尤其这个需求的重点是"数据从没有到有"的动态变化。

所以我做了一个 Demo 模式,按 375px 移动端视口打开浏览器,演示状态变化:

  1. 正文先到 → 金句消失 → 再到步骤
  2. 步骤先到 → 金句消失 → 再到正文
  3. 两者交错 → 步骤 → 正文 → 子步骤 → 完成态

页面会自动推进状态,每个 case 停留十秒左右,底部有倒计时。这个比截图有用,因为它能暴露"切换瞬间"有没有怪异 UI。

所有人可以"看到"状态变化,不用靠想象。而且是在后端还没准备好之前就把交互问题确认掉了——正文先到怎么办?步骤先到怎么办?loading 什么时候消失?这些如果等到联调才讨论,基本必返工。


一个取舍:不用 mock 网络,直接驱动 Store

一开始考虑过 mock SSE、模拟流式接口。但成本高,而且这次的核心不是网络层,是 UI 状态。

所以我选了一个更直接的方式:直接用脚本驱动 Store 状态。组件完全不变,只是数据来源变了。

这个方案的好处:不依赖后端、状态完全可控、每次演示一致、各种顺序都能模拟。本质是把"时间问题"转成"状态问题"。


测试 hook 的取舍

为了快速做 E2E 和 demo,我在开发模式下加了一个 hook,让 Playwright 可以直接 dispatch Redux 状态。优点是快、稳定、可控。缺点也明显:即使只在 dev 生效,它还是侵入了主入口。

后来讨论了三个方案:

  1. Playwright route mock SSE —— 最接近真实链路,但动态演示要处理本地 mock server、HTTPS、CORS 等问题,太重
  2. 单独 debug page —— 干净,但会新增一套页面
  3. 把 hook 抽到独立 dev-only 文件 —— 保留可控性,主入口侵入降到最低

最后选了方案 3。hook 逻辑放在独立的 dev 文件里,主入口只保留一行动态 import。方便以后整体删掉或替换。


结果

时间线:

  • 前一天下班:需求下达
  • 晚上(1~2 小时):完成状态建模 + 测试 + demo
  • 第二天 10 点:用 demo 和产品确认所有交互
  • 下午 4 点:联调完成
  • 下午 6 点前:上线

这次真正节省时间的不是写代码快,而是避免了后面的返工。状态在一开始就说清楚了,交互在 demo 阶段就确认了,测试锁住了关键逻辑。联调之后,前端几乎不需要再改。


代价

写 demo 需要额外时间,加了测试需要维护,测试 hook 有一定侵入性。

但跟"上线前反复改 UI + 心里不踏实"比,我觉得值得。


最大的收获

这次让我确认了一件事:在需求模糊、状态复杂、时序不确定的情况下,先确认状态和行为再写代码,其实是更快的路径。

AI 在这里面最有用的地方不是"替我写代码",而是帮我压缩探索时间。一个需求如果直接改,很容易只改一个组件,漏掉渲染入口里并列显示的问题。

而把需求变成状态表之后,几个关键问题自然就浮出来了:正文来了怎么办?不同对话模式是否一样?loading 结束后怎么办?simple 模式要不要动?

这些问题一列出来,代码就好写很多。


最后

这次需求很小,但很典型:状态多、时序乱、容易误解、容易反复改。

用的方法也不复杂:先让 AI 搜,不要先让 AI 改;用状态矩阵说清需求;用 E2E 锁关键状态,不覆盖所有细节;用 Demo 提前确认交互,有争议就跑一遍。

结果:一天上线,几乎零返工。最重要的是,有安全感。


异步 UI 的问题,本质是状态问题。先把状态说清楚,再写代码,才是最快的方式。

vfojs:Vue 超集架构,外壳React灵魂Vue

vfojs

npm version npm 项目地址 node version license vue version

vfojs

  • React 体验:使用 TSX/JSX 构建 UI,支持同文件多组件组合。
  • Vue 性能:逻辑层直接复用 Vue 3 Composition API 响应式系统。
  • 非侵入式:通过 Vite 插件精准拦截 .vfo 文件,不干扰现有 Vue 代码,完美兼容所有 Vue 插件与 UI 库。

主要特性

  • Vue 超集架构:完全支持 Vue 生态(Router, Pinia, Element Plus),.vfo 组件可直接在 .vue 中引用,反之亦然。
  • Scoped CSS/SCSS/Less:支持在 .vfo 中直接声明样式变量,编译期自动实现作用域隔离。
  • 智能属性透传class/style/id 等 attrs 自动合并至根节点,保持与 Vue 一致的行为。
  • 响应式解构 (Writeable Ref)const { count } = props 自动转换为 toRef,支持跨组件双向绑定。
  • 指令语法糖<input $value={state.name} /> 自动展开为高性能的双向绑定逻辑。
  • 内置轻量状态管理useFoStore(key, init) 实现跨组件、跨文件的状态共享。

在 Vue 项目中使用

vfojs 的设计初衷是非侵入式。你可以在现有的 Vue 项目中开启“魔法模式”。


安装 vfojs

npm install @fo4/vfojs

1).vfo 组件的基本写法

.vfo 的默认导出是一个函数。你可以把它理解成 Vue 组件的 setup():写逻辑、返回 JSX 作为渲染内容。

export default () => {
  const count = ref(0)
  const inc = () => count.value++

  return (
    <div>
      <h2>计数</h2>
      <p>count:{count.value}</p>
      <button onClick={inc}>加 1</button>
    </div>
  )
}

2)自动注入的 API(无需 import)

.vfo 里可以直接使用(编译时自动注入):

  • Vue:ref/reactive/computed/watch/watchEffect/onMounted/onUnmounted/onUpdated/defineComponent/h/Fragment/Transition/useAttrs/useSlots/toRef
  • vfojs:useFoStore/useFoEffect/useVModel

3)子组件写法(同文件组件 / 组合组件)

你可以在同一个 .vfo 文件里用函数声明子组件,然后像 React 一样在 JSX 里使用:

const myComponent = (props) => {
  return <div>你好,{props.name}</div>
}

export default () => {
  return (
    <div>
      <myComponent name="vfojs" />
    </div>
  )
}

说明:

  • 只要某个函数变量被当成 <myComponent name="vfojs" /> 使用,vfojs 会把它自动包装成真正的 Vue 组件实例(支持生命周期)
  • props 里能直接拿到传入的属性(包含常规 props 和 attrs)
  • 也支持第二个参数 ctx,用于 ctx.slots(slot)等能力

4)插槽(slots)

const myCard = (props, ctx) => {
  const body = ctx?.slots?.default ? ctx.slots.default() : null
  return (
    <div style="border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px;">
      <h3>{props.title}</h3>
      <div>{body}</div>
    </div>
  )
}

export default () => {
  return (
    <myCard title="标题">
      <div>这里是 slot 内容</div>
    </myCard>
  )
}

5)Scoped CSS / SCSS / Less

三种写法都支持:

  • CSS:export const css = \...``
  • SCSS:export const scss = \...``
  • Less:export const less = \...``

也支持在 .vfo 中直接引入样式文件:

import './app.scss'
import './app.less'

6)属性透传(Attribute Fallthrough)

像 Vue 一样,传给组件的 class/style/id 等 attrs 会自动合并到根节点:

const myComponent = () => <div class="box">子组件</div>

export default () => {
  return <myComponent class="外部class" style="background: #f8fafc;" />
}

7)响应式解构(可写 ref)与跨组件双向绑定

子组件:

const myComponent = (props) => {
  const { count } = props
  return <button onClick={() => (count.value = count.value + 1)}>count:{count.value}</button>
}

父组件用 onUpdate:count 接收回写:

export default () => {
  const state = reactive({ count: 1 })
  return (
    <div>
      <p>父:{state.count}</p>
      <myComponent count={state.count} onUpdate:count={(v) => (state.count = v)} />
    </div>
  )
}

7.1)编译期宏:$ref(极致“去 .value”)

你可以写:

export default () => {
  let count = $ref(0)
  const inc = () => count++
  return <button onClick={inc}>count:{count}</button>
}

vfojs 会在编译时自动把它变成 ref(...),并在使用 count 的地方自动补全 .value

如果你需要拿到“原始 ref 对象”(例如传给子组件做双向绑定),可以写 $$(count),它会在编译时被还原成 count(不会自动解包)。

8)指令语法糖:$value

你可以写:

<input $value={state.name} />

vfojs 会自动把它展开为双向绑定:

  • 原生表单元素(input/textarea/select):value/checked + onInput/onChange
  • 自定义组件:modelValue + onUpdate:modelValue

9)内置全局状态:useFoStore

同一个 key 在多个组件里拿到的是同一份状态(基于 reactive):

const A = () => {
  const store = useFoStore('demo', () => ({ count: 0 }))
  return <button onClick={() => store.count++}>A:{store.count}</button>
}

const B = () => {
  const store = useFoStore('demo', () => ({ count: 0 }))
  return <button onClick={() => store.count--}>B:{store.count}</button>
}

10)便捷 Hook:useFoEffect / useVModel

useFoEffect:更接近 React effect 的心智,组件卸载时自动停止监听并清理副作用:

useFoEffect(() => {
  console.log(count.value)
  return () => console.log('cleanup')
}, [count])

useVModel:复杂组件里快速创建一个双向绑定 ref(修改会触发 onUpdate:name):

const name = useVModel(props, 'name')
name.value = 'next'

11)显式 Props/Emits:defineProps / defineEmits

definePropsdefineEmits 由编译器自动注入(无需手动 import),用于在 .vfo 中显式声明组件的 props 与事件。

// defineProps 和 defineEmits 将由编译器自动注入,无需手动 import
export default (context) => {
  // 1. 定义 Props(带类型和默认值)
  const props = defineProps<{
    title: string;
    count?: number;
  }>({
    count: 0, // 默认值
  });

  // 2. 定义 Emits
  const emit = defineEmits<{
    (e: 'change', value: number): void;
    (e: 'update:count', value: number): void;
  }>();

  return (
    <div onClick={() => emit('change', props.count)}>
      {props.title}: {props.count}
    </div>
  );
}

事件映射规则:

  • emit('change', x) 会尝试调用 props.onChange(x)
  • emit('update:count', x) 会尝试调用 props['onUpdate:count'](x)

安装 vfojs

npm install @fo4/vfojs

1. 配置 Vite

vite.config.ts 中,将 vfojs 插件置于 vue 插件之前:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vfojs from '@fo4/vfojs'

export default defineConfig({
  plugins: [
    vfojs(), // 拦截并处理 .vfo 文件
    vue(),  // 处理标准 .vue 文件
  ],
})

2. 混合开发模式

App.vue 中调用 .vfo 组件:

<script setup>
import MyFoCard from './components/Card.vfo'
</script>
<template>
  <MyFoCard title="来自 vfojs 的组件" class="custom-style" />
</template>


快速上手 (CLI)

npx create-vfojs@latest my-app

创建完成后,你可以立即体验。

cd my-app
npm i
npm run dev

工具链

模块 说明
create-vfojs 快速创建项目的 CLI 脚手架
@fo4/vfojs-language-plugin 提供 IDE 类型检查与 JSX 属性提示
vscode-vfo 提供 IDE 插件,支持 vfojs 语法 (暂未上架)
fo-ui 基于 vfojs 构建的组件库(开发中)

Webpack vs Vite:一个是“老黄牛”,一个是“猎豹”,你选谁?

你准备搭一个新项目,打开搜索引擎:“Webpack还是Vite?” 答案一半一半,你更懵了。今天我们就来场正面PK:Webpack像头任劳任怨的老黄牛,啥都能干,但起步慢;Vite像只猎豹,瞬间冲刺,但偶尔挑食。看完你就能拍板:我的项目,就该用那个!

前言

前端工具链的“内卷”从未停止。Webpack多年霸主,几乎成了“打包”的代名词。但Vite横空出世,以“快”为刀,砍向Webpack的软肋:开发服务器启动慢、热更新慢。

两者没有绝对好坏,只有合不合适。今天我们从开发体验、生产构建、生态、配置复杂度四个维度,来场硬核对比。

一、核心原理:一个全量打包,一个按需编译

  • Webpack:开发时,从入口开始,递归分析所有模块依赖,打包成一个或多个bundle(哪怕你只用了一个组件,它也把你整个项目打包一遍)。启动慢,但随着项目变大越来越慢。热更新时,需要重新打包变更的模块及其依赖,可能还是慢。

  • Vite:利用浏览器原生ESM(<script type="module">),开发时不打包,只启动一个静态服务器。浏览器请求哪个文件,Vite实时编译哪个文件(比如把JSX转成JS,把TS转成JS)。启动极快(毫秒级),热更新也只更新被改的模块,速度飞快。

比喻:Webpack像搬家公司的卡车,把整个房子家具先打包再运;Vite像快递员,你点一个包裹,他送一个。

二、速度实测:秒开 vs 等咖啡

操作 Webpack Vite
冷启动(大型项目) 10~30秒 <1秒
热更新(改一行代码) 200~500ms(可能更多) <50ms
生产构建 中等(但可优化) 稍慢(用Rollup,但整体可接受)

Vite在开发体验上完胜。尤其大型项目,Webpack启动一次够你刷几条短视频,Vite眨眨眼就好了。

三、生产构建:Webpack还是稳

Vite开发时用ESM,但生产打包不用ESM(会产生太多请求),它底层用的是Rollup。Rollup对tree-shaking、代码分割也很强,但某些复杂场景(比如需要自定义打包逻辑的库)不如Webpack灵活。

Webpack经过多年打磨,插件生态极其丰富,任何你能想到的打包需求(比如特殊文件处理、自定义chunk分割、微前端),Webpack几乎都能找到现成方案。

结论:普通应用项目,Vite的生产构建够用;搞复杂库或需要精细控制打包,Webpack更成熟。

四、配置复杂度:Webpack劝退新手,Vite开箱即用

Webpack配置堪称“噩梦”。从零配置一个支持TypeScript、React、CSS Modules、热更新的项目,要写几十行甚至上百行。虽然官方有create-react-app等脚手架掩盖了配置,但一旦需要 eject 或自定义,头就大了。

Vite默认支持TS、JSX、CSS预处理器、热更新,配置文件极简。你需要做的只是:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

Vite还提供了create-vite脚手架,选择模板一键生成。

五、生态与兼容性:Webpack的护城河

Webpack的插件/loader生态是它的最大优势。比如:

  • file-loader/url-loader 处理静态资源。
  • raw-loader 导入文本。
  • html-webpack-plugin 生成HTML。
  • mini-css-extract-plugin 抽离CSS。
  • webpack-manifest-plugin 生成资源清单。

Vite虽然也支持大多数常见需求(通过插件),但一些老旧的、小众的loader可能没有直接替代。不过对于绝大多数项目,Vite的插件生态已经足够。

另外,Vite要求浏览器支持ESM(现代浏览器都支持),但如果你需要兼容IE11,那不好意思,Vite官方不支持(需要额外插件且很麻烦),这时候Webpack是唯一选择。

六、实战选择:到底用哪个?

用Vite,如果:

  • 新项目,没有历史包袱。
  • 追求极致的开发体验(快!)。
  • 不需要兼容IE11。
  • 项目是常规SPA或静态站点。

用Webpack,如果:

  • 项目已经用Webpack,迁移成本高。
  • 需要兼容IE11。
  • 用了大量Webpack专属插件或自定义loader。
  • 项目是非常复杂的库,需要精细化控制打包。

七、未来趋势:Vite会取代Webpack吗?

短期不会。Webpack在大型企业级项目、复杂构建场景仍有优势。但Vite作为“下一代前端工具链”,已经被Vue、React等官方推荐。尤其在Vue生态,Vite已经是默认配置。

长期看,Vite会逐渐蚕食Webpack在新项目中的份额。但Webpack也不会坐以待毙,Webpack 5 已经改进了缓存和模块联邦,但启动速度这个底层设计问题很难根治。

八、迁移指南:从Webpack到Vite

如果你决定尝鲜,步骤很简单:

  1. create-vite新建一个空项目,复制源码。
  2. require改成import(如果之前用CommonJS)。
  3. 把环境变量从process.env改成import.meta.env
  4. 找对应的Vite插件替代webpack loader。
  5. 测试。

对于中小项目,半天就能完成迁移。

九、总结:没有最好,只有最合适

  • Webpack:老黄牛,稳重、能干、啥都有,但动作慢、配置复杂。
  • Vite:猎豹,快、轻盈、开箱即用,但偶尔挑食(生态稍弱、不支持IE)。

新个人项目、创业项目,无脑上Vite,享受飞一般的开发体验。大厂遗留项目、需要IE兼容,继续Webpack。两者可以共存,甚至可以在一个项目里用Vite开发,Webpack打包(不常见)。

选工具就像选对象,适合的才是最好的。现在你知道该怎么选了。

LeetCode 5. 最长回文子串:DP + 中心扩展

在LeetCode字符串类题目中,「最长回文子串」是入门级经典题,也是动态规划、中心扩展法的典型应用场景。本文将从题目解析出发,详细讲解两种主流解法(动态规划+中心扩展),拆解思路、代码逻辑、避坑要点,兼顾新手理解与实战应用,帮助大家举一反三解决同类问题。

一、题目核心解析

1. 题目描述

给你一个字符串 s,找到 s 中最长的回文子串。

2. 关键概念区分

  • 回文子串:正读和反读完全相同的连续子串(如 "bab"、"bb")。

  • 回文子序列:正读和反读完全相同的非连续子序列(如 "babad" 的子序列 "bab",可跳过中间字符),本文重点聚焦「子串」。

3. 边界与示例

  • 边界情况:空字符串返回 "";单个字符返回其本身(如 "a" → "a")。

  • 示例1:输入 "babad" → 输出 "bab" 或 "aba"(两种均为最长回文子串)。

  • 示例2:输入 "cbbd" → 输出 "bb"(唯一最长回文子串)。

二、解法一:动态规划法(易懂通用版)

动态规划(DP)的核心思路是「复用子串状态,避免重复计算」,适合新手入门,思路可迁移到同类子串问题(如最长回文子序列)。

1. 核心思路拆解

(1)DP数组定义

定义 dp[i][j] 表示:字符串 s 中,从索引 i 到索引 j(闭区间)的子串 s[i..j] 是否是回文子串(true 为是,false 为否)。

(2)状态转移方程

判断 s[i..j] 是否为回文,核心依赖两个条件,分3种情况推导:

  • 子串长度为 1(i = j):单个字符必然是回文,故 dp[i][i] = true。

  • 子串长度为 2(j = i+1):首尾字符相等则为回文,即 s[i] === s[j] 时,dp[i][j] = true。

  • 子串长度 > 2(j > i+1):首尾字符相等 内部子串 s[i+1..j-1] 是回文,即 s[i] === s[j] && dp[i+1][j-1] = true 时,dp[i][j] = true。

(3)遍历顺序

由于 dp[i][j] 依赖 dp[i+1][j-1](内部子串状态),若按 i 或 j 直接遍历,会导致内部子串未计算就先判断外部,因此需按「子串长度」从小到大遍历:

  1. 先初始化所有长度为 1 的子串(dp[i][i] = true)。

  2. 再依次处理长度为 2 到 n 的子串,遍历所有可能的左边界 left,计算右边界 right = left + len - 1,判断是否为回文。

(4)结果记录

用两个变量记录最长回文子串的信息,避免遍历结束后再查找:

  • maxLen:最长回文子串的长度(初始为 1,覆盖单个字符的默认情况)。

  • start:最长回文子串的起始索引(初始为 0)。

2. 完整代码(TypeScript)

function longestPalindrome(s: string): string {
  const n = s.length;
  // 边界处理:空字符串或单个字符直接返回
  if (n <= 1) return s;

  // 初始化DP数组:n行n列,默认值为false
  const dp = Array.from({ length: n }, () => new Array(n).fill(false));

  let maxLen = 1;
  let start = 0;

  // 初始化长度为1的子串(所有单个字符都是回文)
  for (let i = 0; i < n; i++) {
    dp[i][i] = true;
  }

  // 遍历长度为2到n的子串
  for (let len = 2; len <= n; len++) {
    for (let left = 0; left < n; left++) {
      const right = left + len - 1;
      // 右边界超出字符串长度,终止当前循环
      if (right >= n) break;

      // 核心判断:首尾字符相等
      if (s[left] === s[right]) {
        // 长度为2直接是回文,长度>2依赖内部子串
        if (len === 2) {
          dp[left][right] = true;
        } else {
          dp[left][right] = dp[left + 1][right - 1];
        }
      }

      // 更新最长回文子串信息
      if (dp[left][right] && len > maxLen) {
        maxLen = len;
        start = left;
      }
    }
  }

  // 截取最长回文子串(substring左闭右开)
  return s.substring(start, start + maxLen);
};

3. 逐行解析与避坑要点

避坑核心:

  1. 边界处理:先判断 n ≤ 1 的情况,避免后续 DP 数组初始化报错(如 n=0 时无法创建 n×n 数组)。

  2. 右边界判断:left 遍历中,right = left + len - 1 可能超出 n-1(字符串最大索引),需及时 break,避免数组越界。

  3. 状态转移:长度 >2 时,必须依赖 dp[left+1][right-1],不可直接设为 true(如 "abcba",需判断中间 "bcb" 是回文)。

  • DP数组初始化:用 Array.from 创建 n 行 n 列的二维数组,默认填充 false,确保初始状态统一。

  • 长度为1的子串初始化:循环赋值 dp[i][i] = true,覆盖所有单个字符的情况。

  • 子串遍历:外层循环控制长度 len,内层循环控制左边界 left,计算右边界后判断首尾字符,再根据长度更新 dp 状态。

  • 结果截取:substring 方法是左闭右开区间,因此 end 为 start + maxLen,无需减1。

4. 复杂度分析

  • 时间复杂度:O(n^2),两层循环(len 从 2 到 n,left 从 0 到 n-len),每次判断为 O(1)。

  • 空间复杂度:O(n^2),需开辟 n×n 的 DP 数组存储子串回文状态。

三、解法二:中心扩展法(空间优化版)

动态规划法的空间复杂度较高,中心扩展法利用「回文子串中心对称」的特点,将空间优化至 O(1),执行效率更优,适合实战中追求空间性能的场景。

1. 核心思路

回文子串的本质是「中心对称」,因此可围绕两种中心向两边扩散,判断扩散后的子串是否为回文,同时记录最长回文信息:

  • 奇数长度回文:中心为单个字符(如 "aba",中心是 "b")。

  • 偶数长度回文:中心为两个相邻字符(如 "bb",中心是 "b" 和 "b")。

  • 辅助函数复用:定义 expandAroundCenter 函数,接收左右边界,返回该中心对应的最长回文子串的「起始索引」和「长度」,简化代码。

2. 完整代码(TypeScript)

// 中心扩展法实现(空间优化版)
function longestPalindromeCenterExpand(s: string): string {
  const n = s.length;
  // 边界处理:空字符串或单个字符直接返回
  if (n <= 1) return s;

  let maxLen = 1;
  let start = 0;

  // 辅助函数:从left和right向两边扩散,返回[起始索引, 回文长度]
  const expandAroundCenter = (left: number, right: number): [number, number] => {
    // 左右边界不越界,且首尾字符相等,继续扩散
    while (left >= 0 && right < n && s[left] === s[right]) {
      left--;
      right++;
    }
    // 扩散结束后,有效回文边界为[left+1, right-1],计算长度和起始索引
    const length = right - left - 1;
    const startIdx = left + 1;
    return [startIdx, length];
  };

  // 遍历所有可能的中心(奇数+偶数)
  for (let i = 0; i < n; i++) {
    // 奇数长度回文:中心为i(单个字符)
    const [start1, len1] = expandAroundCenter(i, i);
    // 偶数长度回文:中心为i和i+1(两个相邻字符)
    const [start2, len2] = expandAroundCenter(i, i + 1);

    // 更新最长回文子串信息
    const currentMaxLen = Math.max(len1, len2);
    if (currentMaxLen > maxLen) {
      maxLen = currentMaxLen;
      // 确定当前最长回文的起始索引
      start = currentMaxLen === len1 ? start1 : start2;
    }
  }

  // 截取并返回最长回文子串
  return s.substring(start, start + maxLen);
};

3. 逐行解析与避坑要点

避坑核心:

  1. 辅助函数边界回退:扩散结束后,left 和 right 已超出有效回文边界,需回退一位(left+1、right-1),因此长度为 right - left - 1。

  2. 中心不遗漏:需遍历所有奇数和偶数中心,共 2n-1 个(n 个奇数中心 + n-1 个偶数中心),避免遗漏最长回文子串。

  3. 边界处理:与 DP 法一致,先判断 n ≤ 1 的情况,避免后续扩散时越界。

  • 辅助函数设计:将扩散逻辑封装,避免重复代码,提高可读性和可维护性。

  • 中心遍历:循环变量 i 覆盖所有奇数中心,i 和 i+1 覆盖所有偶数中心,确保无遗漏。

  • 结果更新:每次扩散后对比长度,及时更新 maxLen 和 start,避免遍历结束后再查找,提升效率。

4. 复杂度分析

  • 时间复杂度:O(n^2),每个中心最多扩散 n 次,共 2n-1 个中心,整体为 O(n×n)。

  • 空间复杂度:O(1),仅使用常数个变量存储回文信息,无需额外开辟数组。

四、两种解法测试用例验证

为确保两种解法的正确性,以下测试用例分别验证两种方法,覆盖边界、常规、特殊场景:

1. 动态规划法测试

  • 测试用例1:s = "babad" → 输出 "bab"(start=0,maxLen=3,截取 s[0,3))。

  • 测试用例2:s = "cbbd" → 输出 "bb"(len=2,left=1,right=2,dp[1][2]=true)。

  • 测试用例3:s = "" → 输出 ""(边界处理生效)。

  • 测试用例4:s = "a" → 输出 "a"(初始 maxLen=1)。

2. 中心扩展法测试

  • 测试用例1:s = "babad" → 输出 "bab" 或 "aba"(奇数中心 i=1 扩散得到)。

  • 测试用例2:s = "cbbd" → 输出 "bb"(偶数中心 i=1、i+1=2 扩散得到)。

  • 测试用例3:s = "ac" → 输出 "a" 或 "c"(最长回文长度为1)。

  • 测试用例4:s = "ccc" → 输出 "ccc"(奇数中心 i=1 扩散得到,长度3)。

五、两种解法对比总结

对比维度 动态规划法 中心扩展法
核心思路 复用子串回文状态,避免重复计算 利用中心对称,向两边扩散判断
时间复杂度 O(n^2) O(n^2)
空间复杂度 O(n^2)(需n×n DP数组) O(1)(仅用常数变量)
优势 思路易懂,可迁移到同类子串/子序列问题 空间最优,执行效率更高,适合实战
适用场景 新手入门、同类问题迁移(如最长回文子序列) 实战优化、空间受限场景

六、总结与实战建议

LeetCode 5. 最长回文子串的核心是「回文子串的对称性」和「子问题复用」,两种解法各有侧重:

  • 若你是新手,优先掌握「动态规划法」,理解状态定义和转移逻辑,打好子问题复用的基础,后续可轻松迁移到 LeetCode 516. 最长回文子序列等题目。

  • 若你追求实战效率,优先使用「中心扩展法」,空间优化至 O(1),在面试中更易体现代码功底。

补充技巧:解题时可先判断边界情况(n ≤ 1),再执行核心逻辑,避免不必要的计算;同时可通过调试工具查看 DP 数组状态、中心扩散过程,加深对思路的理解。

用TS无法实盘量化? - 实盘均线策略

从零开始:用 DTrader TS SDK 写一个长期运行的均线买卖策略

本文会使用 DTrader 连接实盘账户和行情数据,完成一个从读 K 线到自动下单的小策略。DTrader 的接入说明和 API 文档可以查看:DTrader 文档

本文从零开始,只用 TypeScript 和 DTrader v3-api 的 TS SDK,写一个可以长期运行的均线买卖策略。

这次的小目标很简单:

脚本长期运行
每天 14:55 到点执行一次
读取日 K 线
计算短均线和长均线
读取当前持仓
短均线上穿长均线:没有持仓就买入
短均线下穿长均线:有持仓就卖出
用状态文件保证同一天只执行一次

它不讨论复杂量化理论,也不搭建庞大的策略框架。先把一条主线跑顺:读取行情、生成信号、查看持仓、执行交易。

1. 创建 TypeScript 项目

先创建一个目录:

mkdir dtrader-ma-strategy
cd dtrader-ma-strategy
npm init -y

安装 DTrader v3-api 的 TypeScript SDK,以及运行 TypeScript 需要的工具:

npm install @dtrader/v3-sdk
npm install -D typescript tsx @types/node

package.json 改成 ESM 项目:

{
  "name": "dtrader-ma-strategy",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "start": "tsx moving-average-live.ts"
  },
  "dependencies": {
    "@dtrader/v3-sdk": "^0.1.0"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}

如果需要在本机 v3-api 仓库里调试 SDK,也可以把依赖临时指向本地路径:

npm install /Users/regan/work/go/src/github.com/DTrader-store/v3-api/sdk/ts

正式项目里,使用 npm install @dtrader/v3-sdk 会更清爽,后续升级也方便。

2. 配置环境变量

策略会连接 DTrader 服务,并在信号触发时调用买卖接口。先把这些环境变量准备好:

export DTRADER_BASE_URL="https://your-endpoint"
export DTRADER_AUTH="your-key"
export DTRADER_CODE="600519"
export DTRADER_SHORT_WINDOW="5"
export DTRADER_LONG_WINDOW="20"
export DTRADER_ORDER_VOLUME="100"
export DTRADER_ORDER_PRICE_OFFSET="0"
export DTRADER_POLL_INTERVAL_MS="30000"
export DTRADER_EXECUTE_AT="14:55"
export DTRADER_TIMEZONE="Asia/Shanghai"
export DTRADER_STATE_FILE=".dtrader-ma-state.json"

这些变量分别表示:

  • DTRADER_BASE_URL:DTrader v3-api 地址。
  • DTRADER_AUTH:认证 key。
  • DTRADER_CODE:策略交易的股票代码。
  • DTRADER_SHORT_WINDOW:短均线窗口,默认 5。
  • DTRADER_LONG_WINDOW:长均线窗口,默认 20。
  • DTRADER_ORDER_VOLUME:每次买入或卖出的数量。
  • DTRADER_ORDER_PRICE_OFFSET:下单价格偏移,默认 0。比如想比当前收盘价高 0.02 买入,可以设成 0.02
  • DTRADER_POLL_INTERVAL_MS:轮询间隔,默认 30 秒。
  • DTRADER_EXECUTE_AT:每天执行策略的时间,默认 14:55
  • DTRADER_TIMEZONE:时间判断使用的时区,默认 Asia/Shanghai
  • DTRADER_STATE_FILE:本地状态文件,用来记录当天是否已经执行过。

示例里的下单代码会调用真实交易接口。连接实盘环境前,先确认账户、标的、价格和数量都符合预期。

3. 先理解 DTrader TS SDK 的基本用法

DTrader TS SDK 的入口是 createClient

import { createClient } from "@dtrader/v3-sdk";

const client = createClient({
  baseUrl: process.env.DTRADER_BASE_URL!,
  auth: process.env.DTRADER_AUTH!,
});

读取 K 线:

const kline = await client.kline("600519", { period: "day" });

读取持仓:

const positions = await client.positions();

买入:

await client.buy([{ code: "600519", price: "1500", volume: "100" }]);

卖出:

await client.sell([{ code: "600519", price: "1500", volume: "100" }]);

后面的完整策略,就是把这些 API 按顺序串起来:读 K 线、算信号、看持仓、决定要不要交易。

4. 策略规则

策略规则先用最常见的双均线交叉:

金叉:
上一根 K 线短均线 <= 长均线
当前 K 线短均线 > 长均线
动作:如果当前没有持仓,则买入

死叉:
上一根 K 线短均线 >= 长均线
当前 K 线短均线 < 长均线
动作:如果当前有持仓,则卖出

为什么要看“上一根”和“当前”两组均线?

因为只看当前短均线大于长均线,只能说明现在偏强,不能说明刚刚发生了上穿。策略关心的是“穿越”这个动作,而不是每天看到短均线在长均线上方就重复买入。

5. 为什么每天 14:55 执行一次

长期运行不等于每隔几秒就认真思考一次。这个策略每天做一次决策就够了:

脚本可以从早上就挂着
每 30 秒醒来检查一次时间
没到 14:55:只等待
到了 14:55 且今天还没执行:读取 K 线、算信号、读持仓、决定买卖
今天已经执行过:继续等待明天

这样写会轻松很多:

  • 逻辑短,执行路径清楚。
  • 轮询可以很勤快,交易不会跟着重复。
  • 信号、持仓和下单都在同一轮完成。
  • 脚本重启后,也能知道今天已经处理到哪一步。

示例代码先用简化工作日判断:周一到周五执行,周末跳过。真实使用时,可以再接入交易日历,处理节假日、临时休市等情况。

6. 为什么长期运行需要状态文件

脚本长期运行时,14:55 之后还会继续轮询。如果没有状态文件,14:55:00 执行了一次,14:55:30 又可能执行第二次。

所以完整代码会在本地放一个小状态文件:

{
  "lastExecutedDate": "2026-04-30",
  "lastAction": "buy"
}

每轮策略都会检查:

  • 今天是不是工作日?
  • 现在是否已经到 DTRADER_EXECUTE_AT
  • 状态文件里是否已经记录今天执行过?

只要 lastExecutedDate 等于今天日期,就直接跳过。这个小文件不复杂,但很管用。

7. 完整代码

把下面代码保存为 moving-average-live.ts

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { createClient } from "@dtrader/v3-sdk";

type KlineRow = {
  close?: number | string;
  date?: string;
  day?: string;
  time?: string;
  datetime?: string;
  [key: string]: unknown;
};

type StrategyState = {
  lastExecutedDate?: string;
  lastAction?: "buy" | "sell" | "hold";
};

type Signal = {
  barKey: string;
  currentPrice: number;
  previousShortMa: number;
  previousLongMa: number;
  currentShortMa: number;
  currentLongMa: number;
  goldenCross: boolean;
  deathCross: boolean;
};

type Clock = {
  dateKey: string;
  weekday: number;
  minutes: number;
  label: string;
};

type Candle = {
  row: KlineRow;
  close: number;
};

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Set ${name} before running this strategy.`);
  }
  return value;
}

function envInt(name: string, fallback: number): number {
  const raw = process.env[name];
  if (!raw) return fallback;

  const value = Number.parseInt(raw, 10);
  if (!Number.isFinite(value)) {
    throw new Error(`${name} must be an integer.`);
  }

  return value;
}

function envNumber(name: string, fallback: number): number {
  const raw = process.env[name];
  if (!raw) return fallback;

  const value = Number(raw);
  if (!Number.isFinite(value)) {
    throw new Error(`${name} must be a number.`);
  }

  return value;
}

function parseHHMM(value: string, name: string): number {
  const match = /^(\d{2}):(\d{2})$/.exec(value);
  if (!match) {
    throw new Error(`${name} must use HH:mm format.`);
  }

  const hours = Number(match[1]);
  const minutes = Number(match[2]);

  if (hours > 23 || minutes > 59) {
    throw new Error(`${name} is out of range.`);
  }

  return hours * 60 + minutes;
}

function currentClock(timeZone: string): Clock {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    weekday: "short",
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
    hourCycle: "h23",
  }).formatToParts(new Date());

  const get = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
  const weekdayMap: Record<string, number> = {
    Mon: 1,
    Tue: 2,
    Wed: 3,
    Thu: 4,
    Fri: 5,
    Sat: 6,
    Sun: 7,
  };

  const weekday = weekdayMap[get("weekday")] ?? 0;
  const hour = Number(get("hour"));
  const minute = Number(get("minute"));
  const dateKey = `${get("year")}-${get("month")}-${get("day")}`;

  return {
    dateKey,
    weekday,
    minutes: hour * 60 + minute,
    label: `${dateKey} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`,
  };
}

function isWeekday(clock: Clock): boolean {
  return clock.weekday >= 1 && clock.weekday <= 5;
}

function hasReachedExecuteTime(clock: Clock, executeAtMinutes: number): boolean {
  return clock.minutes >= executeAtMinutes;
}

function movingAverage(values: number[]): number {
  return values.reduce((sum, value) => sum + value, 0) / values.length;
}

function barKey(row: KlineRow, index: number): string {
  const value = row.date ?? row.day ?? row.datetime ?? row.time;
  return value ? String(value) : `index:${index}`;
}

function loadState(path: string): StrategyState {
  if (!existsSync(path)) return {};
  return JSON.parse(readFileSync(path, "utf8")) as StrategyState;
}

function saveState(path: string, state: StrategyState): void {
  writeFileSync(path, JSON.stringify(state, null, 2));
}

function extractRows(data: unknown): KlineRow[] {
  if (Array.isArray(data)) return data as KlineRow[];
  if (!data || typeof data !== "object") return [];

  const payload = data as {
    klines?: unknown;
    list?: unknown;
    data?: unknown;
  };

  if (Array.isArray(payload.klines)) return payload.klines as KlineRow[];
  if (Array.isArray(payload.list)) return payload.list as KlineRow[];
  if (Array.isArray(payload.data)) return payload.data as KlineRow[];

  return [];
}

function extractCandles(rows: KlineRow[]): Candle[] {
  const candles: Candle[] = [];

  for (const row of rows) {
    if (row.close === undefined || row.close === null) continue;

    const close = Number(row.close);
    if (Number.isFinite(close)) {
      candles.push({ row, close });
    }
  }

  return candles;
}

function buildSignal(rows: KlineRow[], shortWindow: number, longWindow: number): Signal | null {
  const candles = extractCandles(rows);
  const closes = candles.map((item) => item.close);
  const requiredCount = longWindow + 1;

  if (closes.length < requiredCount) {
    console.log(
      JSON.stringify(
        {
          event: "not_enough_kline_data",
          required: requiredCount,
          actual: closes.length,
        },
        null,
        2,
      ),
    );
    return null;
  }

  const previousShortMa = movingAverage(closes.slice(-shortWindow - 1, -1));
  const previousLongMa = movingAverage(closes.slice(-longWindow - 1, -1));
  const currentShortMa = movingAverage(closes.slice(-shortWindow));
  const currentLongMa = movingAverage(closes.slice(-longWindow));
  const currentPrice = closes[closes.length - 1];
  const latestCandle = candles[candles.length - 1]!;

  return {
    barKey: barKey(latestCandle.row, candles.length - 1),
    currentPrice,
    previousShortMa,
    previousLongMa,
    currentShortMa,
    currentLongMa,
    goldenCross: previousShortMa <= previousLongMa && currentShortMa > currentLongMa,
    deathCross: previousShortMa >= previousLongMa && currentShortMa < currentLongMa,
  };
}

function hasPosition(positions: unknown, code: string): boolean {
  if (!Array.isArray(positions)) return false;

  return positions.some((item) => {
    if (!item || typeof item !== "object") return false;

    const row = item as {
      stock_code?: string;
      vol_hold?: number;
      vol_actual?: number;
      vol_remain?: number;
    };

    if (row.stock_code !== code) return false;

    const volume = Number(row.vol_hold ?? row.vol_actual ?? row.vol_remain ?? 0);
    return volume > 0;
  });
}

function orderPriceFrom(currentPrice: number, offset: number): string {
  const price = currentPrice + offset;
  if (price <= 0) {
    throw new Error("Order price must be positive.");
  }

  return price.toFixed(2);
}

const baseUrl = requiredEnv("DTRADER_BASE_URL");
const auth = requiredEnv("DTRADER_AUTH");
const code = process.env.DTRADER_CODE ?? "600519";
const shortWindow = envInt("DTRADER_SHORT_WINDOW", 5);
const longWindow = envInt("DTRADER_LONG_WINDOW", 20);
const orderVolume = String(envInt("DTRADER_ORDER_VOLUME", 100));
const orderPriceOffset = envNumber("DTRADER_ORDER_PRICE_OFFSET", 0);
const pollIntervalMs = envInt("DTRADER_POLL_INTERVAL_MS", 30_000);
const executeAt = process.env.DTRADER_EXECUTE_AT ?? "14:55";
const executeAtMinutes = parseHHMM(executeAt, "DTRADER_EXECUTE_AT");
const timeZone = process.env.DTRADER_TIMEZONE ?? "Asia/Shanghai";
const stateFile = process.env.DTRADER_STATE_FILE ?? ".dtrader-ma-state.json";

if (shortWindow <= 0 || longWindow <= 0) {
  throw new Error("DTRADER_SHORT_WINDOW and DTRADER_LONG_WINDOW must be positive.");
}

if (shortWindow >= longWindow) {
  throw new Error("DTRADER_SHORT_WINDOW should be smaller than DTRADER_LONG_WINDOW.");
}

const client = createClient({
  baseUrl,
  auth,
  timeoutMs: envInt("DTRADER_TIMEOUT_MS", 30_000),
});

let stopping = false;

process.on("SIGINT", () => {
  stopping = true;
  console.log("received SIGINT, stopping after current iteration");
});

process.on("SIGTERM", () => {
  stopping = true;
  console.log("received SIGTERM, stopping after current iteration");
});

async function runOnce(): Promise<void> {
  const clock = currentClock(timeZone);

  if (!isWeekday(clock)) {
    console.log(`skip ${clock.label}: not a weekday`);
    return;
  }

  if (!hasReachedExecuteTime(clock, executeAtMinutes)) {
    console.log(`skip ${clock.label}: wait until ${executeAt}`);
    return;
  }

  const state = loadState(stateFile);

  if (state.lastExecutedDate === clock.dateKey) {
    console.log(`skip ${clock.label}: already executed today with action ${state.lastAction ?? "unknown"}`);
    return;
  }

  const kline = await client.kline(code, { period: "day" });
  const rows = extractRows(kline.data);
  const signal = buildSignal(rows, shortWindow, longWindow);

  if (!signal) return;

  console.log(
    JSON.stringify(
      {
        event: "moving_average_signal",
        date: clock.dateKey,
        code,
        shortWindow,
        longWindow,
        ...signal,
      },
      null,
      2,
    ),
  );

  const positions = await client.positions();
  const holding = hasPosition(positions.data, code);
  const orderPrice = orderPriceFrom(signal.currentPrice, orderPriceOffset);
  const order = [{ code, price: orderPrice, volume: orderVolume }];

  if (signal.goldenCross && !holding) {
    console.log(JSON.stringify({ event: "buy_order", order }, null, 2));
    const response = await client.buy(order);
    console.log(JSON.stringify({ event: "buy_response", response }, null, 2));
    saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });
    return;
  }

  if (signal.deathCross && holding) {
    console.log(JSON.stringify({ event: "sell_order", order }, null, 2));
    const response = await client.sell(order);
    console.log(JSON.stringify({ event: "sell_response", response }, null, 2));
    saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "sell" });
    return;
  }

  console.log(
    JSON.stringify(
      {
        event: "hold",
        holding,
        reason: holding
          ? "holding position but no death cross"
          : "no position and no golden cross",
      },
      null,
      2,
    ),
  );
  saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "hold" });
}

async function main(): Promise<void> {
  console.log(
    JSON.stringify(
      {
        event: "strategy_started",
        code,
        shortWindow,
        longWindow,
        orderVolume,
        orderPriceOffset,
        pollIntervalMs,
        executeAt,
        timeZone,
        stateFile,
      },
      null,
      2,
    ),
  );

  while (!stopping) {
    try {
      await runOnce();
    } catch (error) {
      console.error("strategy iteration failed");
      console.error(error);
    }

    if (!stopping) {
      await sleep(pollIntervalMs);
    }
  }

  console.log("strategy stopped");
}

await main();

8. 运行策略

启动前确认环境变量已经设置:

export DTRADER_BASE_URL="https://your-endpoint"
export DTRADER_AUTH="your-key"
export DTRADER_CODE="600519"
export DTRADER_SHORT_WINDOW="5"
export DTRADER_LONG_WINDOW="20"
export DTRADER_ORDER_VOLUME="100"
export DTRADER_ORDER_PRICE_OFFSET="0"
export DTRADER_POLL_INTERVAL_MS="30000"
export DTRADER_EXECUTE_AT="14:55"
export DTRADER_TIMEZONE="Asia/Shanghai"
export DTRADER_STATE_FILE=".dtrader-ma-state.json"

启动:

npm start

脚本会一直运行。每轮都会:

  1. 判断今天是否是工作日。
  2. 判断当前时间是否已经到 14:55
  3. 读取状态文件,判断今天是否已经执行过。
  4. 如果今天还没执行,就读取日 K。
  5. 计算均线信号。
  6. 读取当前持仓。
  7. 金叉且没有持仓时,直接买入。
  8. 死叉且有持仓时,直接卖出。
  9. 没有动作时记录 hold,当天不再重复判断。

停止时按 Ctrl+C。脚本会在当前轮结束后退出。

9. 代码解读

9.1 SDK 初始化

const client = createClient({
  baseUrl,
  auth,
  timeoutMs: envInt("DTRADER_TIMEOUT_MS", 30_000),
});

createClient 来自 @dtrader/v3-sdk。这一步只做三件事:

  • 指定 v3-api 地址。
  • 带上认证 key。
  • 设置请求超时时间。

后面所有交易和行情能力都从 client 发起。

9.2 周期控制

const clock = currentClock(timeZone);

if (!hasReachedExecuteTime(clock, executeAtMinutes)) {
  console.log(`skip ${clock.label}: wait until ${executeAt}`);
  return;
}

if (state.lastExecutedDate === clock.dateKey) {
  console.log(`skip ${clock.label}: already executed today`);
  return;
}

这是长期运行策略里最值得留意的部分。

脚本可以全天挂着,但只有到了 DTRADER_EXECUTE_AT=14:55 之后,当天第一次轮询才会进入交易逻辑。执行完成后,代码写入 lastExecutedDate。后面即使脚本继续轮询,也会因为“今天已经执行过”而安静跳过。

这里用 >= 14:55,不是只认 14:55:00 那一秒。脚本是轮询运行的,网络、机器调度和接口耗时都可能让它错过精确秒点。用“14:55 之后当天第一次执行”更顺手。

9.3 读取 K 线

const kline = await client.kline(code, { period: "day" });
const rows = extractRows(kline.data);

这里读取日 K。选择日 K 是为了让策略保持简单:每天只判断一次,不处理分钟级噪音。

如果行情源在 14:55 时还没有把当日数据合入日 K,可以把执行时间调晚,或者把 period 改成 v3-api 支持的更短周期。这个示例先固定采用“14:55 执行一次”的模型。

9.4 计算均线

const previousShortMa = movingAverage(closes.slice(-shortWindow - 1, -1));
const previousLongMa = movingAverage(closes.slice(-longWindow - 1, -1));
const currentShortMa = movingAverage(closes.slice(-shortWindow));
const currentLongMa = movingAverage(closes.slice(-longWindow));

这里同时计算上一根 K 线和当前 K 线对应的短均线、长均线。

如果只计算当前均线,只能知道当前短均线是否大于长均线;但无法知道它是不是刚刚上穿。交易信号关心的是“变化”,所以要比较前后两组均线。

9.5 判断金叉和死叉

goldenCross: previousShortMa <= previousLongMa && currentShortMa > currentLongMa,
deathCross: previousShortMa >= previousLongMa && currentShortMa < currentLongMa,

金叉用于买入,死叉用于卖出。

  • 金叉:短均线从弱转强。
  • 死叉:短均线从强转弱。

这两个条件只是策略信号,不是收益保证。它们的好处是简单、可解释,很适合作为理解 DTrader 自动交易流程的第一个例子。

9.6 查询持仓

const positions = await client.positions();
const holding = hasPosition(positions.data, code);

策略不只看信号,还会顺手看一下当前是否已经持仓。

  • 金叉但已经持仓:不重复买。
  • 死叉但没有持仓:不卖不存在的仓位。

这一步之后,脚本就不只是会喊“有信号了”,而是能结合账户状态做决定。

9.7 直接下单

14:55 到点后,代码会在同一轮里完成信号计算、持仓确认和交易执行。

买入:

const order = [{ code, price: orderPrice, volume: orderVolume }];
const response = await client.buy(order);

卖出:

const order = [{ code, price: orderPrice, volume: orderVolume }];
const response = await client.sell(order);

订单格式是:

[{ code, price: orderPrice, volume: orderVolume }]

pricevolume 都按字符串传入,和 v3-api 的 TS SDK 示例保持一致。

这里直接展示 DTrader API 的使用方式:信号满足时,就调用 client.buy()client.sell()

9.8 状态文件

saveState(stateFile, { lastExecutedDate: clock.dateKey, lastAction: "buy" });

状态文件很小,但对长期运行很有用。没有它,脚本在 14:55 之后每次轮询都可能重复交易。

这份代码只记录两件事:

  • lastExecutedDate:最近执行过的日期。
  • lastAction:最近动作,可能是 buysellhold

如果今天没有金叉或死叉,也会写入 hold。这表示“今天已经看过了,没动作”,后面不再重复判断。

9.9 错误处理和持续运行

while (!stopping) {
  try {
    await runOnce();
  } catch (error) {
    console.error("strategy iteration failed");
    console.error(error);
  }

  if (!stopping) {
    await sleep(pollIntervalMs);
  }
}

长期运行时,请求失败、网络抖动、接口临时错误都可能发生。这里先打印错误,然后等下一轮继续。

如果下单接口抛错,代码不会写入 lastExecutedDate,下一轮还会再试。只有买入、卖出或明确 hold 成功走完之后,才会记录“今天已经执行”。

10. 真实使用前要补的东西

这个例子已经能从零跑起一个长期运行的均线买卖策略。真实使用前,可以继续补这些能力:

  • 真实交易日历:替换掉示例里的简化工作日判断,处理节假日和临时休市。
  • 订单状态确认:下单后读取 client.orders()client.order(id) 确认成交状态。
  • 仓位比例控制:不要只按固定数量交易。
  • 失败告警:连续失败时推送到飞书、邮件或短信。
  • 日志持久化:把每次信号和订单写入文件或数据库。
  • 多标的支持:把 DTRADER_CODE 扩展成股票列表。

到这里,主线就跑通了:用 DTrader TS SDK 读取 K 线、生成均线信号、读取持仓,并在每天 14:55 执行一次买卖决策。

解决不同项目需要不同 Node.js 版本的问题

告别“这是在我电脑上能跑”的魔咒:Node.js 多版本管理终极指南

你是否遇到过这样的场景:接手一个老项目,运行时疯狂报错;切回自己的新项目,又提示语法不支持。  根源往往只有一个——Node.js 版本不匹配。

本文将彻底解决这个困扰无数开发者的问题,教你一套优雅的 Node.js 多版本管理方案,让你在不同项目间自由切换,再无环境烦恼。


一、症状:你的Node.js版本管理出问题了

典型“病状”自查:

  • 启动项目时,控制台输出 SyntaxError: Unexpected token '??='(常见于 Node.js 版本过低,不识别新语法)
  • 运行npm install后,依赖死活装不上,或者启动就报错
  • 团队中有人跑得好好的,你拉下来却各种异常
  • 你电脑里明明装了新版Node,老项目却要求你必须降级

如果你中了一条以上,恭喜你,需要开始管理 Node.js 版本了。


二、根本原因:Node.js 版本更新太快,生态碎片化

Node.js 版本 发布时间 主要特性
v12 2019 相对稳定,但较老
v14 2020 LTS(长期支持版,很多老项目仍用)
v16 2021 支持 ??=&&= 等逻辑赋值运算符
v18 2022 支持原生 Fetch、Node.js 测试运行器
v20 2023 稳定版,性能提升
v22+ 2024+ 最新特性,需主动升级

核心矛盾:老项目不敢轻易升(怕 breaking changes),新项目又享受不到新特性。❌ 全局只有一个 Node 版本的模式,必然死路一条。

image.png


三、解决方案核心:nvm(Node Version Manager)

nvm 是什么?
一个让你在电脑上同时安装、共存多个 Node.js 版本,并能在终端里一键切换的工具。

🪟 Windows 用户指南:nvm-windows

1️⃣ 安装前的准备工作(非常重要!)

安装 nvm-windows 之前,务必彻底卸载电脑上原有的 Node.js,避免冲突:

  • “控制面板” -> “程序和功能” -> 卸载 Node.js

  • 手动删除以下残留文件夹(如存在):

    text

    C:\Program Files\nodejs
    C:\Program Files (x86)\nodejs
    C:\Users<你的用户名>\AppData\Roaming\npm
    C:\Users<你的用户名>\AppData\Roaming\npm-cache
    
  • 检查系统的 PATH 环境变量,删除所有与 Node.js 或 npm 相关的路径

2️⃣ 安装 nvm-windows
  1. 访问 nvm-windows 发布页,下载最新版 nvm-setup.zip
  2. 解压后,以管理员身份运行 nvm-setup.exe
  3. 按向导安装,路径建议保持默认(避免权限问题)。
  4. 安装完成后,重启命令行工具(CMD 或 PowerShell)。
3️⃣ 下载加速(国内用户强烈推荐)

在 nvm 安装目录(默认 C:\Users<你的用户名>\AppData\Roaming\nvm)下,找到 settings.txt,末尾添加:

text

node_mirror: https://npmmirror.com/mirrors/node/
npm_mirror: https://npmmirror.com/mirrors/npm/

这样可以大幅提升国内下载 Node.js 的速度。

🍎 macOS / Linux 用户指南:标准版 nvm

在终端中执行:

bash

# 使用 curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

# 或使用 wget
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

安装脚本会自动将 nvm 加入到你的 shell 配置文件(~/.bashrc~/.zshrc 等)。安装完成后,重启终端或运行 source ~/.zshrc(根据你的 shell 选择)使其生效。


四、一图看懂 nvm 核心操作

我要做什么 命令示例
查看能装哪些 Node 版本 Windows: nvm list available Mac/Linux: nvm ls-remote
安装某个具体版本 nvm install 16.20.0
安装最新的 LTS 版本 nvm install --lts
看我电脑里已有哪些版本 nvm list
在当前终端切换到某个版本 nvm use 16.20.0
设置默认(新打开终端)版本 nvm alias default 16.20.0
删除某个版本 nvm uninstall 16.20.0
查看当前使用版本 node -v

⚠️ Windows 用户特别注意:执行 nvm use 切换版本时,建议以管理员身份打开命令行,否则可能因权限不足而切换失败。


五、终极奥义:自动化项目版本切换(.nvmrc)

再也不用手动记住每个项目用的 Node 版本。

操作步骤

  1. 在项目根目录下,创建一个名叫  .nvmrc 的文件(注意开头有个点)。

  2. 文件内容只需一行,比如:16.20.0(或者 lts/gallium,等别名)。

  3. 当你要进入该项目工作时,在项目根目录执行:

    bash

    nvm use
    

    nvm 会自动读取 .nvmrc 中指定的版本并切换过去。

更高级:自动切换(可选)

如果你希望每次 cd 进项目目录时自动切换,可以借助 avn 或 zsh-nvm 插件。但个人建议:手动执行 nvm use 已经足够简洁,且避免了误切换。


前端开发者做 Agent:别写成一次请求,用 5 步受控循环防止 AI 乱跑

作者:前端转 AI 深度实践者

【省流助手/核心观点】:Agent 不是“调用一次大模型”就结束,也不是让模型无限自由发挥。一个最小 Agent Loop 至少要经历 5 步:接收用户输入、模型决定是否调用工具、程序执行工具、工具结果写回上下文、模型生成最终回答。真正可靠的 Agent Loop 必须受控:有 maxSteps、有工具边界、有结构化输出、有错误处理、有 trace 记录。对前端开发者来说,它很像一个带状态、带副作用、带退出条件的事件循环。


很多人第一次做 Agent,会把它想得过于神秘。

仿佛只要 Prompt 写成:

你是一个自主智能体,请一步一步完成用户任务。

模型就会自己查资料、自己调接口、自己整理结论、自己处理异常。

这个想象很美,但工程系统不能靠想象跑。

真正的 Agent,不是让模型无限自由发挥。
真正的 Agent,是让模型和工具在程序控制下协作。

这一层协作,叫 Agent Loop

1. 痛点:一次调用只能聊天,不能稳定办事

前两篇我们讲了 Tool Calling 和工具 Schema。

模型不应该编真实世界的信息,而应该提出工具调用意图。

比如用户问:

帮我查一下订单 A1001 到哪了,并告诉我什么时候能收到。

模型可以输出:

{
  "type": "tool_call",
  "toolName": "getOrderStatus",
  "args": {
    "orderId": "A1001"
  }
}

程序执行工具,得到:

{
  "status": "shipping",
  "eta": "2026-05-03"
}

问题来了:这就结束了吗?

对程序来说,工具结果已经拿到了。
但对用户来说,他想看的不是 JSON,而是一句能读懂的话:

订单 A1001 当前运输中,预计 2026-05-03 送达。

所以完整流程应该是:

用户提问
-> 模型提出工具调用
-> 程序执行工具
-> 工具结果写回上下文
-> 模型组织最终回答

这就是最小 Agent Loop。

2. 错误做法:把 Agent 写成一次模型请求

很多早期实现会这样写:

async function askAgent(question: string) {
  const prompt = `
你是一个智能助手,请完成用户任务。

用户问题:${question}
`;

  return llm.chat(prompt);
}

这段代码的问题是:它没有工具执行,也没有状态回写。

模型只能生成一段回答。
如果答案涉及订单、库存、权限、合同、退款金额,它很可能是在“猜”。

另一种常见错误,是只执行一次工具,然后直接把工具 JSON 返回给用户:

async function askOrderOnce(orderId: string) {
  const result = await getOrderStatus({ orderId });
  return JSON.stringify(result);
}

这看起来接了工具,但还不是 Agent Loop。

因为它缺了最后一步:让模型基于工具结果组织面向用户的回答。
也缺了更重要的一点:当模型还需要第二个工具时,系统没有继续循环的能力。

3. 正确做法:把模型输出设计成两种类型

在普通聊天里,模型输出通常就是一段文本。

但在 Agent 里,模型输出至少有两种可能:

type ModelOutput =
  | {
      type: "tool_call";
      toolName: string;
      args: Record<string, unknown>;
    }
  | {
      type: "final_answer";
      content: string;
    };

第一种是工具调用:

{
  "type": "tool_call",
  "toolName": "getOrderStatus",
  "args": {
    "orderId": "A1001"
  }
}

意思是:现在信息不够,需要程序帮我调用工具。

第二种是最终回答:

{
  "type": "final_answer",
  "content": "订单 A1001 当前运输中,预计 2026-05-03 送达。"
}

意思是:我已经可以回答用户了。

Agent Loop 的核心判断就一句话:

如果是 tool_call,就执行工具,然后继续。
如果是 final_answer,就返回答案,然后结束。

复杂 Agent 会加入规划、记忆、多工具、多轮任务,但地基仍然是这个判断。

4. 先用 mockModel 练骨架,别一上来接真实 API

很多人一上来就接真实模型,结果调试时一团乱:

  • 是 Prompt 不清楚?
  • 是模型没有按格式返回?
  • 是工具调度器有 bug?
  • 是参数校验拦错了?
  • 是 API 调用失败?
  • 是网络或环境变量问题?

更稳的做法是:先用 mockModel

就像前端开发时,后端接口没好,你会先 mock 数据,把页面状态和交互跑通。

Agent 也一样。

type Message =
  | {
      role: "user";
      content: string;
    }
  | {
      role: "tool";
      toolName: string;
      content: unknown;
    };

function mockModel(messages: Message[]): ModelOutput {
  const lastMessage = messages[messages.length - 1];

  if (lastMessage.role === "user") {
    if (lastMessage.content.includes("A1001")) {
      return {
        type: "tool_call",
        toolName: "getOrderStatus",
        args: { orderId: "A1001" }
      };
    }

    return {
      type: "final_answer",
      content: "我暂时只能处理订单 A1001 的查询。"
    };
  }

  if (lastMessage.role === "tool") {
    const result = lastMessage.content as {
      status?: string;
      eta?: string;
    };

    return {
      type: "final_answer",
      content: `订单当前状态为 ${result.status},预计 ${result.eta} 送达。`
    };
  }

  return {
    type: "final_answer",
    content: "无法处理当前请求。"
  };
}

它不聪明,但它稳定。

稳定的好处是,你可以先验证 Agent Loop 的工程结构:

  • messages 是否维护正确。
  • 工具调用是否被执行。
  • 工具结果是否写回上下文。
  • 最终回答是否能结束循环。
  • 错误路径是否会返回。

等骨架跑稳了,再接真实模型,问题会清楚很多。

5. messages 是 Agent 的短期记忆

Agent Loop 里最重要的数据结构之一是 messages

它记录了整个运行过程:

const messages: Message[] = [
  {
    role: "user",
    content: "帮我查订单 A1001"
  },
  {
    role: "tool",
    toolName: "getOrderStatus",
    content: {
      status: "shipping",
      eta: "2026-05-03"
    }
  }
];

为什么工具结果要写回 messages

因为模型下一轮需要看到它。

如果工具执行完了,但你没有把结果放回上下文,模型就像刚查完资料却失忆:

工具查到了,但模型不知道查到了什么。

这类 bug 在 Agent 开发里非常常见。

记住一个规则:

工具执行不是终点,工具结果必须回到上下文。

6. 一个最小 Agent Loop 长这样

先准备一个工具和调度器:

type ToolResult =
  | {
      ok: true;
      toolName: string;
      data: unknown;
    }
  | {
      ok: false;
      toolName?: string;
      error: string;
    };

async function getOrderStatus(args: Record<string, unknown>) {
  if (args.orderId !== "A1001") {
    return { status: "not_found" };
  }

  return {
    status: "shipping",
    eta: "2026-05-03"
  };
}

async function runTool(output: Extract<ModelOutput, { type: "tool_call" }>) {
  if (output.toolName !== "getOrderStatus") {
    return {
      ok: false,
      toolName: output.toolName,
      error: `未知工具:${output.toolName}`
    } satisfies ToolResult;
  }

  const data = await getOrderStatus(output.args);

  return {
    ok: true,
    toolName: output.toolName,
    data
  } satisfies ToolResult;
}

然后写 Agent Loop:

type AgentRunResult =
  | {
      ok: true;
      answer: string;
      steps: number;
      messages: Message[];
    }
  | {
      ok: false;
      error: string;
      steps: number;
      messages: Message[];
    };

async function runAgentLoop(
  userInput: string,
  maxSteps = 3
): Promise<AgentRunResult> {
  const messages: Message[] = [
    {
      role: "user",
      content: userInput
    }
  ];

  for (let step = 1; step <= maxSteps; step++) {
    const output = mockModel(messages);

    if (output.type === "final_answer") {
      return {
        ok: true,
        answer: output.content,
        steps: step,
        messages
      };
    }

    if (output.type === "tool_call") {
      const toolResult = await runTool(output);

      messages.push({
        role: "tool",
        toolName: output.toolName,
        content: toolResult.ok ? toolResult.data : toolResult
      });

      continue;
    }

    return {
      ok: false,
      error: "模型输出了未知类型",
      steps: step,
      messages
    };
  }

  return {
    ok: false,
    error: "超过最大步骤数,Agent 已停止",
    steps: maxSteps,
    messages
  };
}

这段代码已经包含 Agent 的核心骨架:

  • 有用户输入。
  • 有模型决策。
  • 有工具执行。
  • 有上下文更新。
  • 有最终回答。
  • 有最大步数。
  • 有异常退出。

复杂 Agent 往后扩展,基本都是在这个骨架上继续加能力。

7. maxSteps 是 Agent 的安全绳

为什么一定要有 maxSteps

因为模型可能会反复调用工具。

比如一个异常模型每次都返回:

{
  "type": "tool_call",
  "toolName": "searchPolicy",
  "args": {
    "keyword": "报销"
  }
}

如果你没有最大步数,Agent 就会一直查,一直查,一直查。

这不是智能,这是迷路。

maxSteps 就像安全绳:

最多跑 3 步,跑不完就停下来,把错误返回。

玩具 demo 假设模型总是乖。

工程系统默认任何环节都可能出错。

8. 前端页面怎么展示 Agent Loop?

如果你只展示最终回答,排查 Agent 问题会很痛苦。

建议至少在开发环境或内部后台展示步骤 trace。

type AgentTraceStep = {
  step: number;
  modelOutput: ModelOutput;
  toolResult?: ToolResult;
};

Agent Loop 运行时记录 trace:

async function runAgentLoopWithTrace(userInput: string, maxSteps = 3) {
  const messages: Message[] = [{ role: "user", content: userInput }];
  const trace: AgentTraceStep[] = [];

  for (let step = 1; step <= maxSteps; step++) {
    const output = mockModel(messages);
    const traceStep: AgentTraceStep = {
      step,
      modelOutput: output
    };

    if (output.type === "final_answer") {
      trace.push(traceStep);
      return {
        ok: true,
        answer: output.content,
        messages,
        trace
      };
    }

    const toolResult = await runTool(output);
    traceStep.toolResult = toolResult;
    trace.push(traceStep);

    messages.push({
      role: "tool",
      toolName: output.toolName,
      content: toolResult.ok ? toolResult.data : toolResult
    });
  }

  return {
    ok: false,
    error: "超过最大步骤数,Agent 已停止",
    messages,
    trace
  };
}

前端可以把 trace 做成折叠面板:

function AgentTracePanel({ trace }: { trace: AgentTraceStep[] }) {
  return (
    <details>
      <summary>Agent 执行过程</summary>
      {trace.map((item) => (
        <section key={item.step}>
          <h4>Step {item.step}</h4>
          <pre>{JSON.stringify(item.modelOutput, null, 2)}</pre>
          {item.toolResult && (
            <pre>{JSON.stringify(item.toolResult, null, 2)}</pre>
          )}
        </section>
      ))}
    </details>
  );
}

这不是炫技,而是为了让 Agent 出问题时能定位:

  • 模型哪一步选错了工具?
  • 参数是哪一步变错的?
  • 工具有没有执行成功?
  • 工具结果有没有回到上下文?
  • 为什么没有生成最终回答?

9. Agent Loop 不是无限自治,而是受控协作

很多人喜欢把 Agent 描述成“自主智能体”。

这个词没问题,但容易让初学者误会:好像越自主越高级。

工程里不是这样。

真正可靠的 Agent,不是无限自治,而是受控协作。

它应该有:

  • 明确的工具范围。
  • 明确的参数 Schema。
  • 明确的最大步数。
  • 明确的错误处理。
  • 明确的风险拦截。
  • 明确的日志和 traceId。

模型可以决定下一步,但只能在程序允许的范围内决定。

对前端开发者来说,可以这样类比:

用户输入 = 用户事件
messages = 状态容器
modelOutput = 状态决策
tool_call = effect 描述
runTool = effect 执行器
final_answer = 渲染结果
maxSteps = 防止无限循环的保护

Agent 不是凭空多出来的外星架构。

它只是把语言模型放进了你熟悉的状态与副作用系统里。

10. 生产环境避坑指南

1. 不要让 Agent 无限循环

必须设置 maxSteps
超过步数就停止,并返回结构化错误。

2. 不要把 tool error 当正常工具结果

工具失败时,要让模型知道这是失败,而不是把错误文本当成正常数据继续编。

建议在 tool message 里保留 okerrorTypemessage 等字段。

3. 高风险工具不要自动执行

取消订单、发送邮件、修改权限、扣款、删除数据,都不应该在 Agent Loop 中无确认执行。

这类工具应该返回 confirmation_required,交给用户或业务规则确认。

4. 每一步都要记录 traceId

一次 Agent 请求可能包含多次模型调用和多次工具调用。

没有统一 traceId,排查时很难把它们串起来。

5. 步数越多,不代表能力越强

步数越多,成本、延迟和错误概率都会上升。

大多数业务型 Agent,先把 2 到 4 步跑稳定,比追求长链路自主规划更重要。

11. 常见误区

误区 1:一次大模型调用就是 Agent

不是。一次模型调用只是聊天或生成。Agent 至少要有决策、工具、状态和循环。

误区 2:工具执行完就结束

不一定。工具结果通常还要回到模型,让模型生成用户能读懂的最终回答。

误区 3:Agent 应该尽可能多跑几步

不是。步数越多,成本、延迟和错误概率都会上升。够用就好。

误区 4:先接真实模型才能学 Agent

不需要。先用 mock 模型跑通控制流,反而更适合学习和调试。

12. 给前端开发者的落地清单

如果你要在团队里实现一个最小 Agent,可以从这份清单开始:

  1. 明确 Agent 支持哪些任务。
  2. 定义可用工具和工具 Schema。
  3. 实现工具调度器。
  4. 设计模型输出类型:tool_callfinal_answer
  5. 维护 messages
  6. 工具结果写回上下文。
  7. 设置 maxSteps
  8. 记录每一步 trace。
  9. 处理未知工具、参数错误和工具失败。
  10. 高风险工具必须走确认。

这份清单能帮你避免把 Agent 做成一个“看起来会自己想办法,实际上出了事没人知道在哪里”的黑盒。

结语

Agent 不是一次调用。

Agent 是一轮受控循环。

模型负责判断下一步,工具负责获取真实信息或执行动作,程序负责维护状态、控制边界、处理错误和决定什么时候停止。

对前端开发者来说,这件事并不陌生。你早就熟悉事件、状态、副作用和渲染。现在只是多了一个语言模型,帮助系统理解自然语言里的意图。

当你能写出一个最小 Agent Loop,能看懂每一步发生了什么,能让它在错误时停下来而不是乱跑,你就已经跨过了 Agent 工程最重要的一道门槛。

Vue前端SEO优化全攻略(实操落地版,新手也能上手)

Vue作为主流前端框架,其默认的客户端渲染(CSR)模式存在天然SEO短板——SPA页面初始加载仅返回空骨架HTML,核心内容通过JavaScript动态渲染,搜索引擎爬虫可能无法等待JS执行完毕,导致页面内容无法被正常抓取、索引,最终影响网站曝光和排名。

Vue前端SEO优化的核心逻辑的是:让搜索引擎爬虫能轻松抓取页面核心内容、识别页面层级、明确页面价值,本质是解决“爬虫可见性”和“内容可识别性”两大问题。以下方案从基础到进阶,覆盖所有高频优化场景,附具体代码和避坑细节,Vue2/Vue3通用,可直接复制落地。

一、核心优化:解决SPA渲染短板(爬虫抓取核心)

Vue SEO的最大痛点的是“动态内容无法被爬虫抓取”,核心解决方案有3种,根据项目规模和需求选择,优先推荐“预渲染”(低成本、易落地),动态内容多的场景选择“SSR”,快速落地可选择“静态站点生成”。

1. 预渲染(Prerendering):低成本首选,适配静态内容场景

核心逻辑:在项目构建阶段,提前渲染指定路由的静态HTML文件(包含完整内容),部署后用户和爬虫访问时,直接返回渲染好的静态页面,无需等待客户端JS执行,完美解决SPA初始内容为空的问题。

适配场景:内容相对固定的页面(官网、博客详情、产品介绍页),无需服务器额外部署,静态托管即可,开发成本最低。

实操步骤(Vue3+Vite适配):

  1. 安装预渲染插件:pnpm add -D @prerenderer/rollup-plugin(Vite项目);Vue2+Webpack项目可使用prerender-spa-plugin
  2. 配置vite.config.js,指定需要预渲染的路由: import { defineConfig } from 'vite' `` import vue from '@vitejs/plugin-vue' `` import prerender from '@prerenderer/rollup-plugin' ```` export default defineConfig({ `` plugins: [ `` vue(), `` // 预渲染配置 `` prerender({ `` routes: ['/', '/about', '/product', '/contact'], // 需要预渲染的路由(必填) `` renderer: '@prerenderer/renderer-puppeteer' // 渲染器,无需额外配置 `` }) `` ] ``})
  3. 执行npm run build,构建后dist目录会生成每个路由对应的静态HTML文件(如/about/index.html),直接部署即可;
  4. 避坑点:预渲染仅适用于内容固定的页面,动态内容(如实时数据、用户中心)无法预渲染,需结合其他方案;路由较多时,会增加构建时间。

2. 服务端渲染(SSR):动态内容首选,适配高需求场景

核心逻辑:用户/爬虫发起请求时,服务器先执行Vue代码,渲染出完整的HTML(包含动态内容),再将HTML返回给客户端,爬虫可直接抓取完整内容,同时能提升首屏加载速度,是动态内容(电商商品页、资讯列表)的最优解。

适配场景:动态内容多、对SEO和首屏速度要求高的项目(电商、资讯平台),需额外部署Node.js服务器,开发和运维成本较高。

实操方案(两种选择,优先推荐Nuxt.js):

  • 方案1:使用Nuxt.js(Vue官方推荐,简化SSR配置)

    • 创建Nuxt项目(Vue3):npx nuxi init my-nuxt-seo
    • Nuxt自动实现SSR,页面组件中可通过asyncDatafetch获取服务端数据,确保渲染的HTML包含动态内容: <script setup> `` // 服务端获取数据,渲染到HTML中,爬虫可直接抓取 `` const { data } = await useAsyncData('productList', () => { `` return fetch('/api/product').then(res => res.json()) `` }) ``</script>
    • 部署:需部署到支持Node.js的服务器(如阿里云ECS、Vercel),Nuxt提供一键部署方案,降低运维成本。
  • 方案2:自定义SSR(Vue2/Vue3通用,灵活度高)

    • 基于Express+vue-server-renderer实现,核心是创建服务端渲染入口,将Vue组件渲染为HTML字符串,返回给客户端;
    • 注意:需区分客户端和服务端环境,避免在服务端使用window、document等浏览器API,否则会报错。

补充:SSR的核心优势是支持动态内容抓取,但需注意服务器负载,可通过CDN缓存优化,减少服务器压力。

3. 静态站点生成(SSG):折中方案,兼顾成本和动态性

核心逻辑:在构建阶段生成所有页面的静态HTML(类似预渲染),但支持动态数据注入,构建后可静态托管,同时能通过增量构建更新内容,适配内容更新频率较低的动态场景(如每周更新的资讯、商品页)。

实操方案(Vue3+ViteSSG):

  1. 安装插件:pnpm add -D vite-ssg
  2. 改造入口文件main.ts(替换createApp,交给ViteSSG接管): import { ViteSSG } from 'vite-ssg' `` import App from './App.vue' `` import { routes } from './router' // 导出路由数组,而非router实例 ```` // 核心改造:ViteSSG生成静态站点 `` export const createApp = ViteSSG( `` App, `` { routes, base: import.meta.env.BASE_URL }, `` ({ app, router }) => { `` // 注册插件(如Pinia、VueMeta) `` } ``)
  3. 路由配置改造:需导出routes数组,且必须使用History模式,避免Hash模式破坏静态页面结构;
  4. 优势:无需部署Node.js服务器,静态托管即可,支持动态数据注入,构建后页面加载速度快,爬虫抓取友好。

二、基础优化:元信息(Meta)配置(爬虫识别核心)

搜索引擎爬虫抓取页面时,首先读取页面的元信息(Title、Description、Keywords等),用于判断页面主题和价值,是SEO优化的基础,必须每个页面配置独立的元信息,避免全局统一配置导致的权重分散。

1. 核心插件:vue-meta(Vue2/Vue3通用)

用于在组件级别管理元信息,支持动态设置Title、Meta标签、OG标签(用于社交媒体分享),无需手动操作DOM,适配SPA、SSR、SSG所有场景。

实操步骤:

  1. 安装插件:npm install vue-meta --save
  2. 全局注册(main.ts): import { createApp } from 'vue' `` import App from './App.vue' `` import VueMeta from 'vue-meta' ```` const app = createApp(App) `` app.use(VueMeta, { `` refreshOnceOnNavigation: true // 路由切换时刷新元信息 `` }) ``app.mount('#app')
  3. 组件中配置(每个页面独立配置): <script setup> `` // Vue3组合式API配置 `` useMeta({ `` title: 'Vue SEO优化指南 | 新手也能落地的实操方案', // 页面标题(核心,包含关键词) `` htmlAttrs: { lang: 'zh-CN' }, // 页面语言,帮助爬虫识别 `` meta: [ `` { name: 'description', content: '本文详细讲解Vue前端SEO优化方法,包含预渲染、SSR、元信息配置等实操技巧,适合新手学习,可直接复制落地。' }, // 页面描述(吸引点击,包含核心关键词) `` { name: 'keywords', content: 'Vue SEO, Vue前端SEO, Vue预渲染, Vue SSR' }, // 核心关键词(3-5个为宜,避免堆砌) `` // OG标签(优化社交媒体分享,提升曝光) `` { property: 'og:title', content: 'Vue SEO优化指南' }, `` { property: 'og:description', content: '新手也能落地的Vue前端SEO实操方案' }, `` { property: 'og:type', content: 'article' } `` ] `` }) ``</script>

2. 路由级元信息配置(统一管理,避免遗漏)

通过Vue Router的meta配置,统一管理所有页面的元信息,结合全局导航守卫,实现路由切换时自动更新元信息,适合页面较多的项目。

// router/index.ts(Vue3)
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('../views/Home.vue'),
    meta: {
      title: '首页 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '首页:专注Vue前端SEO优化,分享可落地的实操技巧' },
        { name: 'keywords', content: 'Vue SEO, 前端SEO, Vue优化' }
      ]
    }
  },
  {
    path: '/product/:id',
    component: () => import('../views/Product.vue'),
    meta: {
      title: '产品详情 | Vue SEO优化实战',
      metaTags: [
        { name: 'description', content: '产品详情页,展示Vue SEO相关工具和方案' },
        { name: 'keywords', content: 'Vue产品, SEO工具, Vue优化方案' }
      ]
    }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 全局导航守卫:路由切换时更新元信息
router.beforeEach((to, from, next) => {
  // 更新页面标题
  document.title = to.meta.title || 'Vue SEO优化指南'
  
  // 移除已存在的meta标签,避免重复
  const existingTags = document.querySelectorAll('meta[name^="vue-meta-"]')
  existingTags.forEach(tag => tag.parentNode.removeChild(tag))
  
  // 添加新的meta标签
  if (to.meta.metaTags) {
    to.meta.metaTags.forEach(tag => {
      const metaTag = document.createElement('meta')
      metaTag.setAttribute('name', tag.name)
      metaTag.setAttribute('content', tag.content)
      metaTag.setAttribute('vue-meta', '1')
      document.head.appendChild(metaTag)
    })
  }
  
  next()
})

export default router

3. 避坑点

  • Title:每个页面独立,包含1-2个核心关键词,长度控制在30字以内,避免堆砌关键词;
  • Description:简洁明了,包含核心关键词,长度控制在120字以内,吸引用户点击,避免和其他页面重复;
  • Keywords:3-5个为宜,贴合页面内容,避免堆砌(如“Vue,SEO,VueSEO,前端优化,SEO优化”);
  • OG标签:必须配置,优化微信、微博等社交媒体分享时的预览效果,提升页面曝光率。

三、内容优化:让爬虫“读懂”页面内容

即使解决了渲染问题,若页面内容杂乱、结构不清晰,爬虫仍无法识别核心价值,需优化内容结构和标签使用,提升页面权重。

1. 语义化标签使用(核心)

Vue模板中优先使用语义化标签,替代div嵌套,帮助爬虫识别页面层级和内容类型,提升页面可读性。

<!-- 推荐:语义化标签,清晰区分页面结构 --&gt;
&lt;header&gt;
  &lt;h1&gt;Vue SEO优化指南&lt;/h1&gt; <!-- 每个页面只有1个h1,作为页面核心标题 -->
  <nav><!-- 导航栏 -->
    <a href="/" rel="canonical">首页</a>
    <a href="/about">关于我们</a>
  </nav>
</header&gt;
&lt;main&gt;<!-- 页面核心内容 -->
  <section><!-- 内容区块 -->
    <h2>一、核心优化方案</h2><!-- h2-h6层级递减,不跳级 -->
    <p>Vue SEO的核心是解决爬虫抓取问题,主要有3种方案...</p>
  </section&gt;
&lt;/main&gt;
&lt;footer&gt;<!-- 页脚 -->
  <p>© 2026 Vue SEO优化指南 版权所有</p>
</footer>

关键要点:

  • 每个页面只有1个h1标签,作为页面核心标题,包含核心关键词;
  • h2-h6标签层级递减,不跳级(如h1之后是h2,h2之后是h3),清晰区分内容层级;
  • 使用header、main、nav、section、footer等语义化标签,替代div,帮助爬虫识别页面结构。

2. 动态内容优化(爬虫可识别)

对于SPA中的动态内容(如列表、详情),除了使用SSR/SSG/预渲染,还需注意:

  • 避免使用v-if隐藏核心内容:爬虫可能无法识别v-if控制的内容,若必须隐藏,可使用v-show(通过CSS隐藏,内容仍在HTML中);
  • 图片、视频添加alt属性:图片需添加alt属性(描述图片内容,包含关键词),视频添加title属性,帮助爬虫识别多媒体内容; <!-- 正确示例:图片添加alt属性 --> ``<img src="/vue-seo.jpg" alt="Vue前端SEO优化实操步骤" />
  • 结构化数据标记(Schema.org):给核心内容(如文章、产品、资讯)添加结构化数据,帮助搜索引擎理解内容类型,提升搜索排名(如电商商品可标记价格、评分,文章可标记作者、发布时间): <script setup> `` useMeta({ `` script: [ `` { `` type: 'application/ld+json', `` json: { `` "@context": "https://schema.org", `` "@type": "Article", `` "name": "Vue前端SEO优化全攻略", `` "description": "新手也能落地的Vue SEO实操方案", `` "author": { "@type": "Person", "name": "前端开发者" }, `` "datePublished": "2026-04-23" `` } `` } `` ] `` }) ``</script>

3. 内部链接优化

  • 页面之间添加合理的内部链接(如首页链接到产品页、文章页),帮助爬虫抓取更多页面,提升网站整体权重;
  • 避免使用空链接、死链接,链接文本需贴合目标页面内容(如“查看Vue预渲染教程”,而非“点击这里”);
  • 使用rel="canonical"标签,避免页面重复(如同一内容有多个URL,指定规范URL),防止权重分散: <a href="/product" rel="canonical">产品列表</a>

四、性能优化:提升页面加载速度(辅助SEO)

搜索引擎优先收录加载速度快的页面,Vue项目的性能优化不仅能提升用户体验,还能间接提升SEO排名,核心优化点如下:

1. 资源优化

  • 图片优化:压缩图片(使用tinypng等工具),使用WebP格式,懒加载(避免首屏加载过多图片),Vue中可使用vue-lazyload插件: // 安装:npm install vue-lazyload --save `` // 全局注册 `` import VueLazyload from 'vue-lazyload' `` app.use(VueLazyload, { `` loading: '/loading.png', // 加载中占位图 `` error: '/error.png' // 加载失败占位图 `` }) `` // 页面使用 ``<img v-lazy="imgSrc" alt="Vue SEO优化" />
  • JS/CSS优化:开启Gzip压缩(需服务器配置),拆分代码(路由懒加载),减少首屏加载体积: // 路由懒加载(Vue Router) `` const routes = [ `` { `` path: '/about', `` component: () => import('../views/About.vue') // 懒加载,按需加载组件 `` } ``]
  • 静态资源CDN托管:将图片、JS、CSS等静态资源部署到CDN(如阿里云CDN),提升资源加载速度,减轻服务器压力。

2. 首屏加载优化

  • 减少首屏JS体积:移除无用代码,按需引入第三方插件(如Element Plus可按需引入组件);
  • 预加载核心资源:使用<link rel="preload">预加载首屏必需的资源(如核心JS、CSS);
  • 优化webpack/vite配置:压缩代码、移除注释,减少构建后文件体积: // vue.config.js(Vue2+Webpack) `` module.exports = { `` configureWebpack: config => { `` config.plugin('html').tap(args => { `` args[0].minify = { `` removeComments: true, `` collapseWhitespace: true, // 压缩HTML `` removeAttributeQuotes: true `` } `` return args `` }) `` } ``}

五、其他关键优化(避坑必看)

1. 路由优化(History模式)

Vue Router默认使用Hash模式(URL带#),#后面的内容无法被爬虫识别,需切换为History模式,并配置服务器,避免404错误。

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
  history: createWebHistory(), // 切换为History模式
  routes
})

服务器配置(以Nginx为例):

server {
  listen 80;
  server_name your-domain.com;
  root /usr/share/nginx/html; # 部署目录
  
  # 解决History模式404问题
  location / {
    try_files $uri $uri/ /index.html;
  }
}

2. 避免SEO黑名单操作

  • 禁止隐藏关键词(如文字颜色和背景色一致)、堆砌关键词,会被搜索引擎判定为作弊,降低排名;
  • 禁止使用iframe嵌套核心内容,爬虫可能无法抓取iframe内的内容;
  • 禁止动态生成的内容完全依赖JS(如无SSR/预渲染,仅通过JS渲染核心内容),会导致爬虫无法抓取。

3. 配置robots.txt和sitemap.xml

  • robots.txt:放在网站根目录,指定爬虫可抓取和不可抓取的页面,避免爬虫抓取无用页面(如后台管理页): # robots.txt `` User-agent: * # 所有爬虫 `` Allow: / # 允许抓取所有页面 `` Disallow: /admin/ # 禁止抓取后台页面 ``Disallow: /api/ # 禁止抓取接口页面
  • sitemap.xml:生成网站地图,列出所有需要被抓取的页面,提交给百度、谷歌等搜索引擎,帮助爬虫快速抓取所有页面,提升收录效率。

六、优化效果验证(必做步骤)

优化完成后,需验证优化效果,确保爬虫能正常抓取页面内容,核心验证工具和步骤:

  1. 查看页面源码:右键“查看页面源代码”,确认核心内容、元信息、语义化标签是否存在(非空骨架);
  2. 百度搜索资源平台:提交网站、sitemap.xml,使用“URL提交”功能,验证页面是否能被收录;
  3. Google Search Console:验证页面收录情况,查看爬虫抓取错误,及时调整优化方案;
  4. SEO检测工具:使用爱站、站长工具等,检测页面元信息、关键词密度、加载速度等,优化不足的地方。

七、总结(实操优先级)

Vue前端SEO优化的实操优先级:渲染方式优化(预渲染/SSR/SSG)→ 元信息配置 → 内容语义化 → 性能优化 → 路由/robots配置

新手建议:先从预渲染+元信息配置入手(低成本、易落地),解决核心的爬虫抓取问题;若项目有动态内容,再升级为SSR/SSG;最后优化内容和性能,提升页面权重和排名。

核心原则:SEO优化是长期过程,需持续更新内容、监控抓取情况、调整优化方案,才能逐步提升网站曝光和排名。

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

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

Random Range 节点是 Unity URP Shader Graph 中一个功能强大的工具节点,它能够根据输入的种子值生成指定范围内的伪随机数值。在着色器编程中,随机性是一个非常重要的概念,它可以用于创建各种自然效果,如噪波纹理、星空分布、磨损效果等,使渲染结果更加真实和自然。

该节点的核心机制是基于确定性算法生成伪随机数,这意味着对于相同的输入种子值,它总是会产生相同的输出结果。这种特性在着色器编程中非常有用,因为它保证了渲染结果的一致性,避免了帧与帧之间的闪烁问题。同时,由于算法设计的复杂性,输出的数值序列在统计上表现出良好的随机特性,足以满足大多数图形效果的需求。

输入参数中的 Seed 采用 Vector 2 类型,这种设计主要是为了便于与 UV 坐标系统集成。在纹理采样和基于屏幕空间的效果中,UV 坐标自然地提供了二维的输入空间,使得可以基于像素位置生成随机值。不过在实际使用中,如果不需要基于空间位置的随机性,使用 Float 类型的输入也是完全可以的,系统会自动进行类型转换和处理。

MinMax 参数定义了输出值的范围边界,生成的随机数将均匀分布在这个区间内。需要注意的是,虽然节点名称中包含"Range",但实际输出是连续分布的,可以产生任意精度的浮点数值,而不仅仅是整数。

技术原理

伪随机数生成算法

Random Range 节点内部使用的随机数生成算法基于经典的伪随机数生成方法。从生成的代码示例可以看出,其核心是一个哈希函数,通过对种子值进行数学变换来产生看似随机的数值。

算法的数学表达式为:

randomno = frac(sin(dot(Seed, float2(12.9898, 78.233))) * 43758.5453)

这个算法的工作原理可以分解为几个步骤:

  • 首先计算种子向量与固定向量 (12.9898, 78.233) 的点积
  • 然后对点积结果取正弦函数,将结果映射到 [-1, 1] 范围
  • 接着乘以一个大数 43758.5453,扩大数值范围
  • 最后使用 frac 函数取小数部分,确保结果在 [0, 1) 范围内

这种方法的优势在于计算效率高,适合在着色器中实时计算,同时产生的数值序列具有良好的统计分布特性。

确定性特性分析

Random Range 节点的确定性是其最重要的特性之一。在实时图形渲染中,保持帧间一致性至关重要,特别是在以下场景中:

  • 动态模糊和运动模糊效果需要稳定的随机采样
  • 蒙特卡洛积分在实时全局光照中的应用
  • 程序化内容生成需要可重现的结果

确定性的实现依赖于算法中使用的所有参数都是固定的,包括点积计算中的固定向量和缩放系数。这意味着只要输入相同的种子值,无论在什么硬件上运行,无论在哪个帧调用,都会得到完全相同的输出结果。

端口详解

输入端口

Seed(种子值)

  • 类型:Vector 2
  • 描述:用于生成随机数的起始值。虽然定义为 Vector 2 类型,但实际上可以接受多种输入形式:
    • 直接的 Vector 2 常量,如 (0.5, 0.5)
    • UV 坐标,用于基于空间位置的随机效果
    • 时间变量,用于生成随时间变化的随机序列
    • 其他计算得到的二维向量

Seed 端口的设计特别考虑了与纹理坐标系统的兼容性。在实际应用中,常见的 Seed 输入模式包括:

  • 使用物体空间的 UV 坐标,为每个物体表面生成固定的随机模式
  • 使用世界空间位置,创建基于场景位置的随机分布
  • 结合时间变量,产生动态变化的随机效果

Min(最小值)

  • 类型:Float
  • 描述:定义输出随机数范围的下界。这个值可以是常数,也可以来自其他节点的动态计算结果。在实际应用中,Min 值可以用于:
    • 控制随机效果的强度下限
    • 定义颜色通道的最小值
    • 设置粒子大小的最小尺度

Max(最大值)

  • 类型:Float
  • 描述:定义输出随机数范围的上界。与 Min 配合使用,定义了完整的输出范围。Max 值的应用场景包括:
    • 限制随机效果的最大强度
    • 定义颜色通道的最大值
    • 控制随机分布的上限边界

输出端口

Out(输出值)

  • 类型:Float
  • 描述:在 [Min, Max] 范围内均匀分布的伪随机数。输出值的分布特性:
    • 在大量样本下呈现均匀分布
    • 单个输出值无法预测,但序列可重现
    • 数值精度为浮点数精度,适合大多数图形应用

实际应用示例

基础随机颜色生成

创建一个简单的随机颜色生成器,可以为物体表面添加自然的颜色变化:

  • 使用物体UV坐标作为Seed输入
  • 设置Min值为0.0,Max值为1.0
  • 将输出连接到Base Color端口
  • 通过调整Min/Max控制颜色范围

这种技术特别适合用于:

  • 自然材质的颜色变化,如树叶、石材
  • 人群模拟中的服装颜色差异
  • 建筑表面的材质变化

程序化噪波纹理

结合多个Random Range节点创建复杂的噪波模式:

  • 使用不同缩放系数的UV坐标作为各个节点的Seed
  • 为每个节点设置不同的Min/Max范围
  • 使用数学运算组合多个随机输出
  • 应用对比度调整增强视觉效果

进阶应用技巧:

  • 使用分形噪声技术组合多个频率的随机数
  • 应用域扭曲创造更有机的图案
  • 结合曲线调整控制噪波分布

动态粒子效果

在粒子着色器中应用随机性:

  • 使用粒子ID或发射时间作为Seed
  • 为大小、旋转、寿命等属性添加随机变化
  • 创建更自然的粒子系统行为

具体实现方法:

  • 使用Custom Vertex Streams传递随机种子
  • 在片段着色器中基于位置添加次级随机效果
  • 结合噪声纹理增强细节层次

表面磨损效果

模拟自然磨损和老化效果:

  • 基于世界空间位置生成随机分布
  • 控制磨损区域的密度和强度
  • 混合不同材质表现磨损层次

技术细节:

  • 使用世界空间坐标避免纹理拉伸问题
  • 结合距离函数控制磨损分布
  • 应用高度混合实现立体磨损效果

高级技巧与最佳实践

种子值选择策略

选择合适的种子值对于获得理想的随机效果至关重要:

  • 空间一致性:使用位置相关的种子值确保空间上的一致性
  • 时间动画:引入时间变量创建动态随机效果
  • 对象差异化:使用对象ID确保不同对象的随机模式不同

具体实施建议:

  • 对于表面效果,优先使用UV坐标作为种子
  • 对于体积效果,使用三维位置坐标
  • 对于动画效果,谨慎控制时间变量的影响范围

性能优化考虑

Random Range 节点的性能特征和优化方法:

  • 计算复杂度相对较低,适合实时使用
  • 避免在片段着色器中过度使用,特别是全屏效果
  • 考虑使用预计算的噪声纹理替代复杂实时计算

优化策略:

  • 在顶点着色器计算随机值,通过插值传递到片段着色器
  • 使用LOD技术,在远距离使用简化的随机计算
  • 利用计算着色器批量生成随机数序列

与其他节点配合使用

Random Range 节点与其他Shader Graph节点的协同工作:

  • 与数学节点结合:通过数学运算变换随机分布
  • 与纹理节点结合:增强或调制纹理效果
  • 与控制流节点结合:创建条件随机行为

典型组合模式:

  • 使用Multiply和Add节点调整输出范围
  • 通过Condition节点创建阈值化的随机效果
  • 结合Gradient节点将随机值映射到颜色渐变

常见问题与解决方案

随机模式重复问题

当使用不合适的种子值时可能出现明显的重复模式:

  • 问题表现:随机分布中出现可见的重复图案
  • 原因分析:种子值变化范围过小或存在周期性
  • 解决方案:使用更高维度的种子值或引入随机偏移

具体解决方法:

  • 在种子值中添加高频噪声成分
  • 使用旋转或扭曲变换打破周期性
  • 组合多个不同尺度的随机函数

性能瓶颈识别

识别和解决Random Range节点引起的性能问题:

  • 使用Frame Debugger分析着色器执行时间
  • 检查Random Range节点的调用频率
  • 评估是否可以用更简单的方法达到类似效果

性能优化步骤:

  • 分析节点在着色器阶段中的分布
  • 测试不同精度设置的影响
  • 考虑使用近似计算替代精确随机

跨平台一致性

确保随机结果在不同硬件平台上的一致性:

  • 测试不同GPU架构下的输出结果
  • 验证移动设备与桌面设备的一致性
  • 检查精度差异对最终效果的影响

一致性保证措施:

  • 避免依赖特定硬件的浮点数行为
  • 使用标准化数学函数确保一致性
  • 在不同设备上进行全面的视觉测试

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

从零实现 GIF 制作工具:LZW 压缩与 Median Cut 色彩量化

GIF 制作工具,原本以为直接用现成的库就完事了,结果发现纯前端实现更有意思。这篇文章聊聊 GIF 格式的核心技术:LZW 压缩和 Median Cut 色彩量化。
在这里插入图片描述

为什么 GIF 这么难搞?

GIF 格式诞生于 1987 年,那时候的设计理念跟现在完全不同。最大的坑在于:GIF 只支持 256 色。现在的图片动辄几百万色,要塞进 256 色的框框里,还得保持画质,这就是色彩量化的难题。

另一个坑是 LZW 压缩。GIF 用的 LZW 算法是专利保护的(2003 年过期),但更重要的是,LZW 的实现细节很容易踩坑。比如码表溢出怎么办?Clear Code 什么时候发?这些细节文档里写得含糊,得靠实验摸索。

Median Cut:把几百万色砍到 256 色

Median Cut 是经典的色彩量化算法,核心思想很简单:把颜色空间切成 256 个方块,每个方块取中心点作为代表色。

算法步骤

function medianCut(pixels, maxColors) {
  // 1. 统计所有颜色及其出现频率
  const colorMap = new Map()
  for (let i = 0; i < pixels.length; i += 4) {
    const key = `${pixels[i]},${pixels[i+1]},${pixels[i+2]}`
    colorMap.set(key, (colorMap.get(key) || 0) + 1)
  }
  
  // 2. 初始化:所有颜色放进一个桶
  const buckets = [{
    entries: Array.from(colorMap.entries()),
    rMin: 0, rMax: 255,
    gMin: 0, gMax: 255,
    bMin: 0, bMax: 255
  }]
  
  // 3. 反复切分,直到桶数达到 maxColors
  while (buckets.length < maxColors) {
    // 找范围最大的桶
    const bucket = findWidestBucket(buckets)
    if (!bucket) break
    
    // 沿最长边切分
    const [left, right] = splitBucket(bucket)
    buckets.splice(buckets.indexOf(bucket), 1, left, right)
  }
  
  // 4. 每个桶取加权平均色作为调色板
  return buckets.map(b => computeAverage(b))
}

关键细节

为什么要按出现频率加权? 因为颜色出现的次数越多,对视觉影响越大。如果一个像素只出现一次,它被量化错了也无所谓;但背景色要是偏了,整张图都难看。

切分策略的选择:标准 Median Cut 按颜色数量均分,但更好的做法是按像素数量均分。这样可以避免一个桶里塞了几百万像素,另一个桶只有几个像素的情况。

LZW 压缩:GIF 的灵魂

LZW 是一种字典编码算法,核心思想是用短编码代替重复出现的字符串。GIF 的 LZW 有几个特殊点:

1. 变长编码

LZW 的编码长度是动态增长的。初始码长是 minCodeSize + 1,随着字典增大,码长逐步增加到 12 位。一旦字典满了(4096 项),就发 Clear Code 重置字典。

function packBits(codes, minCodeSize) {
  const clearCode = 1 << minCodeSize
  const endCode = clearCode + 1
  
  let codeSize = minCodeSize + 1
  let nextCode = endCode + 1
  
  for (const code of codes) {
    // 把编码塞进位缓冲区
    bitBuffer |= (code << bitCount)
    bitCount += codeSize
    
    // 每凑够 8 位就输出一个字节
    while (bitCount >= 8) {
      bytes.push(bitBuffer & 0xFF)
      bitBuffer >>>= 8
      bitCount -= 8
    }
    
    // 字典满了,发 Clear Code
    if (nextCode >= 4096) {
      codes.push(clearCode)
      nextCode = endCode + 1
      codeSize = minCodeSize + 1
    }
    // 码长增长
    else if (nextCode >= (1 << codeSize) && codeSize < 12) {
      codeSize++
    }
  }
}

2. 字典构建

LZW 的字典是边压缩边构建的。每次输出一个编码,就把"当前编码 + 下一个像素"加入字典。

function lzwEncode(indexedPixels, minCodeSize) {
  const dict = new Map()
  let w = indexedPixels[0]
  
  for (let i = 1; i < indexedPixels.length; i++) {
    const k = indexedPixels[i]
    
    // w+k 在字典里,继续扩展
    if (dict.get(w)?.has(k)) {
      w = dict.get(w).get(k)
    }
    // w+k 不在字典里,输出 w,把 w+k 加入字典
    else {
      codes.push(w)
      if (!dict.has(w)) dict.set(w, new Map())
      dict.get(w).set(k, nextCode++)
      w = k
    }
  }
  
  codes.push(w)
  return codes
}

3. 性能优化

原始 LZW 实现用 w+k 作为字典键,字符串拼接很慢。优化方案是用嵌套 Map:第一层 Map 的键是前缀编码,第二层 Map 的键是后缀像素值。这样查找从 O(n) 降到 O(1)。

GIF 文件结构

GIF 文件是按块组织的,主要包含:

GIF89a Header
Logical Screen Descriptor
Netscape Application Extension (循环播放)
┌─ Graphics Control Extension (延迟、透明)
│  Image Descriptor
│  Local Color Table
│  LZW Image Data
└─ (重复每一帧)
GIF Trailer

延迟时间的坑

GIF 的延迟单位是 10 毫秒,不是 1 毫秒。而且很多播放器会强制最小延迟为 20ms(2 个单位),所以你设 10ms 实际播放可能是 20ms。

循环播放

GIF89a 标准本身不支持循环播放,是 Netscape 加的扩展。循环次数 0 表示无限循环,1 表示播放 1 次(总共播放 2 次)。这个坑我踩了好久。

实战经验

做 JsonKit 的 GIF 制作工具时,遇到几个性能问题:

色彩量化太慢:原始实现遍历所有像素找最近色,O(width × height × paletteSize)。优化方案是用 KD-Tree 或预先建立颜色查找表。

LZW 压缩太慢:字典查找是瓶颈。用嵌套 Map 替代字符串拼接后,速度提升了 10 倍。

内存爆炸:处理大图时,Canvas 的 getImageData 会返回巨大的数组。解决方案是分块处理,或者用 Web Worker 避免阻塞主线程。

最终效果

在这里插入图片描述

相关工具

总结

GIF 格式虽然古老,但背后的技术很有意思。LZW 压缩是早期无损压缩的经典算法,Median Cut 是色彩量化的基石。理解这些原理,不仅能写出更好的 GIF 工具,对理解现代图片格式(WebP、AVIF)也有帮助。

完整代码在 JsonKit 的 GIF Maker 工具里,欢迎试用。

在企业搭建一套完整的AI Agent系统

在企业搭建一套完整的AI Agent系统,主要涉及三个核心部分的协同:知识库(是AI的大脑,负责提供业务知识)、Agent(是执行者,负责理解、规划和调用工具)和Skill(是AI的手脚,封装具体操作与经验)。

在你的场景下,我建议采用从“高起点、快见效”到“深度定制、全面铺开” 的两阶段方案。LangChain及其企业级产品LangSmith Fleet是目前实现这一目标最完整、最主流的路径。

🎯 方案蓝图:三驾马车与演进路径

一个企业级AI Agent系统的本质是“知识库 + Agent + Skill/工具”三位一体的协同。它的能力会随着建设深入,从简单的问答演进到复杂的自动化问题解决

image.png

graph TD
Start --> Stop

🚀 第一阶段:基础搭建与快速启动

这个阶段的目标是跑通流程,快速验证价值,让第一个能解决实际问题的Agent上线。

1. 知识库搭建:从“文档”到“知识”

这是基础。你需要将分散在企业各处(如Confluence、本地文档、Wiki)的信息,转化为AI能理解的形式。

  • 数据接入与清洗:首先,将各类文档(Word、PDF、Markdown等)接入系统。关键在于对文档进行智能分块(Chunking) ,比如按语义切分为500-800字的小段落,并标注元数据(如“售后政策-2026版”、“产品A-操作手册”)。这能极大提升后续检索的准确率
  • 存储与检索:处理后的知识片段会被存入向量数据库。当用户提问时,系统会先在知识库中检索最相关的片段,然后将这些片段作为“参考资料”连同问题一起提交给大模型,让它基于这些资料生成答案。这就是著名的RAG(检索增强生成)  技术,能有效避免AI“胡说八道”
  • 与Agent结合:将知识库封装成一个“工具(Tool)”挂载给Agent。当Agent需要回答专业问题时,它会主动“调用”知识库工具去查询

2. 核心Agent选型与开发:选择你的起点

这里有两种方案,可以根据你的技术实力和需求灵活选择。

方案 核心路径 优点 适合场景
方案一:无代码快速搭建 (推荐) 直接使用LangSmith Fleet。它是一个企业级平台,允许通过自然语言提示词来创建和管理Agent 上手极快,无需编写复杂代码;内置了权限管理、身份认证、可观测性等企业级功能,省去了大量的开发工作 绝大多数企业,尤其是希望快速验证、让业务团队也能参与的团队。
方案二:开源框架深度定制 基于 LangChain 或 LangGraph 框架用Python/TypeScript进行代码开发。 灵活性极高,可以精细控制Agent的每个环节,构建自定义的复杂逻辑。 拥有强大AI研发团队,且业务场景极其特殊、需要深度定制的公司。

3. Skill(工具)的构建:让Agent会“做事”

Skill是Agent执行任务的手段。一个Agent的能力边界,取决于它拥有多少Skill。

  • 内置标准Skill:Fleet本身就集成了大量常用工具,如搜索引擎、计算器,以及通过OAuth接入的Gmail、Slack、Google Calendar、GitHub
  • 封装API为Skill:将企业内部系统的API(如CRM查询客户、ERP创建订单、HR系统请假)封装成Skill。用户只需描述“帮我查一下客户A的订单”,Agent便会自动调用对应API完成操作
  • 固化经验为Skill (高级) :这是将企业“老师傅”的经验转化为资产的关键。例如,将“处理客户投诉”的流程(安抚→记录→查因→反馈→跟进)固化为一个Skill。当Agent识别到相关意图时,会严格按此标准流程执行,确保服务质量的高度一致性。通过AgentRAG的经验库问答模式,甚至可以实现经验的精准触发和自动执行

🏢 第二阶段:规模化治理与深度集成

当你的“Agent舰队”初具规模时,管理的复杂度会直线上升。这个阶段的目标是实现有序、安全、高效的治理

1. 权限与身份:让Agent合规地工作

Fleet提供了完善的治理模型

  • 精细化权限:可以控制谁使用(Run) 、谁编辑(Edit) 、谁复制(Clone)  一个Agent。

  • 双模式身份 (Identity)

    • Claw(共享爪牙) :使用固定的服务账号,适合团队共享的工具,如一个全公司可用的“周报生成器”。
    • Assistant(个人助理) :以调用者本人的身份操作,OAuth认证后,Agent拥有的权限就是你的权限,天然实现数据隔离,适合处理部门或个人的私密数据。

2. 动态知识与SOP:让系统灵活且强大

初期把所有规则写在静态提示词里会难以维护。更优雅的方案是采用“Supervisor + 动态Skill”架构

  • 分离配置与知识:将SOP(标准作业程序)、冲突消解规则等“软知识”从代码中抽离,写成结构化的Markdown文件
  • 动态加载:通过一个SkillsManager模块,在系统启动或运行时动态加载这些文件,并注入到Supervisor Agent的系统提示词中。
  • 热更新:修改SOP文件即可实现业务逻辑的热更新,无需重启服务。这种方式不仅让意图识别和路由决策更精准,还能通过[GLOBAL_RULES][CONFLICT_RESOLUTION]等区块,优雅地解决不同Agent间的“打架”问题

3. 可观测性与审计:让一切有迹可循

这是企业级应用不可或缺的一环。LangSmith提供了强大的Tracing(追踪)  功能

  • 白盒化调试:能可视化地看到Agent的每一个“思考-行动-观察”循环。
  • 审计日志:结合身份信息,你能完整回溯“谁(哪个用户)  在 什么时间通过哪个Agent,以 什么身份,执行了 哪些操作,调用了 哪些工具,结果是什么”。这对于合规和安全至关重要。

💎 总结与建议

推荐路线图

  1. 快速验证

    • 学习:参考腾讯乐享的“知识库+大模型+Agent”三位一体架构理念
    • 实践:从 LangSmith Fleet 开始。用无代码方式创建第一个连接到团队Slack的企业知识问答Agent(作为Assistant)。
    • 目标:在1-2周内看到实际业务价值,让团队建立信心。
  2. 构建与治理

    • 充实知识库:持续接入更多内部文档,并测试分块、元数据等策略。
    • 扩展Skill:将高频、重复的日常任务(如数据拉取、周报草拟)封装成Skill。
    • 建立治理:为不同角色的Agent配置合适的权限身份模式
  3. 深化与创新

    • 固化经验:与业务专家合作,将核心业务流程和决策逻辑固化为Skill和SOP知识库
    • 构建舰队:创建多个专业化Agent(如“客服助手”、“销售分析师”),通过Supervisor Agent统一调度,形成强大的自动化网络。
    • 高级开发:当无代码平台无法满足超个性化需求时,再考虑引入 LangGraph 进行代码级深度定制。

在初期,无需在自建 vs. 采购上过度纠结。LangSmith Fleet本身就是LangChain官方提供的企业级解决方案,它与开源框架一脉相承。你可以从Fleet的无代码功能快速起步,当业务场景需要深度定制时,再平滑地利用其底层基于LangGraph的开放性进行扩展。

如果想深入了解某个部分,比如如何将具体的API封装成Skill,或者Fleet的定价和部署细节,随时告诉我,我们可以继续深挖。

从零构建 Web 端视频剪辑器:技术实践与思考

前言

短视频时代,视频剪辑已经成为一项基础技能。传统桌面剪辑软件(如 Premiere、Final Cut Pro)功能强大但门槛较高,而在线剪辑工具(如剪映网页版)则提供了更轻量的替代方案。本文将介绍如何使用 Web 技术栈构建一个轻量级在线视频剪辑器,涵盖多轨道编辑、实时预览、视频导出等核心功能的实现思路。

项目地址:gitee.com/hpysirius/c…

一、技术选型

前端

技术 选型 理由
框架 React 19 + TypeScript 组件化开发,类型安全
状态管理 Redux Toolkit 复杂时间线状态需要集中管理
UI 组件库 Ant Design 6 丰富的表单、弹窗组件
构建工具 Vite 极速的 HMR 开发体验

后端

技术 选型 理由
运行时 Node.js + Express 前后端统一语言
视频处理 fluent-ffmpeg Node.js 的 ffmpeg 封装,API 友好
数据库 MySQL 8.0 存储素材和项目数据
文件上传 multer 成熟的 multipart 处理方案

核心依赖

视频剪辑的核心是 ffmpeg——一个几乎无所不能的多媒体处理工具。fluent-ffmpeg 是它的 Node.js 封装,提供了链式 API:

ffmpeg(inputPath)
  .outputOptions(['-c:v libx264', '-crf 23'])
  .output(outputPath)
  .on('end', () => console.log('完成'))
  .run();

二、系统架构

┌─────────────────────────────────────────────────┐
│                    前端 (React)                   │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│  │ Material │ │ Preview  │ │     Timeline     │ │
│  │   Lib    │ │          │ │ (video/audio/sub)│ │
│  └──────────┘ └──────────┘ └──────────────────┘ │
│                     │ Redux                      │
│                     ▼                            │
│              ┌────────────┐                      │
│              │ editorSlice│                      │
│              └────────────┘                      │
└────────────────────────┬────────────────────────┘
                         │ HTTP API
┌────────────────────────▼────────────────────────┐
│                   后端 (Express)                  │
│  ┌──────────────┐  ┌──────────────────────────┐ │
│  │   Material   │  │       Export Engine       │ │
│  │   Routes     │  │  (fluent-ffmpeg + concat) │ │
│  └──────────────┘  └──────────────────────────┘ │
│                     │                            │
│              ┌──────┴──────┐                     │
│              │    MySQL    │                     │
│              └─────────────┘                     │
└─────────────────────────────────────────────────┘

前端负责 UI 交互和状态管理,后端负责文件存储和视频处理。两者通过 RESTful API 通信。

三、核心模块实现

3.1 时间线数据模型

时间线是剪辑器的核心。我们用 Redux 管理整个编辑状态:

// editorSlice.ts
interface TimelineClip {
  id: string;
  materialId: string;      // 关联素材 ID
  trackIndex: number;       // 所在轨道索引
  startTime: number;        // 在时间线上的起始时间(秒)
  endTime: number;          // 结束时间
  duration: number;         // 持续时长
  trimStart: number;        // 素材裁剪起点
  trimEnd: number;          // 素材裁剪终点
  muted?: boolean;          // 是否静音(视频轨道)
  text?: string;            // 文本内容(字幕/文本轨道)
  textStyle?: TextStyle;    // 文本样式
}

interface Track {
  id: string;
  type: 'video' | 'audio' | 'subtitle' | 'text';
  name: string;
  clips: TimelineClip[];
}

// 初始状态包含 4 条轨道
const initialState = {
  tracks: [
    { id: 'video-1', type: 'video', name: '视频轨道', clips: [] },
    { id: 'text-1', type: 'text', name: '文本轨道', clips: [] },
    { id: 'audio-1', type: 'audio', name: '音频轨道', clips: [] },
    { id: 'subtitle-1', type: 'subtitle', name: '字幕轨道', clips: [] },
  ],
  currentTime: 0,
  duration: 0,
  isPlaying: false,
};

这种设计的优势:

  • 数据驱动 UI:时间线、预览、导出都从同一份状态派生
  • 撤销/重做:Redux 天然支持时间旅行调试
  • 序列化:状态可以直接 JSON 序列化保存到数据库

3.2 时间线渲染与交互

时间线的核心是将时间映射为像素位置:

const PIXELS_PER_SECOND = 50;
const zoom = 1; // 缩放比例

// 时间 → 像素
const left = clip.startTime * PIXELS_PER_SECOND * zoom;
const width = clip.duration * PIXELS_PER_SECOND * zoom;

// 像素 → 时间(点击定位)
const time = clickX / (PIXELS_PER_SECOND * zoom);

拖拽移动和边缘裁剪通过监听 mousemove 实现:

const handleMouseMove = (e: MouseEvent) => {
  if (isDragging && dragData) {
    const dx = e.clientX - dragData.startX;
    const dt = dx / (PIXELS_PER_SECOND * zoom);
    const newStartTime = Math.max(0, dragData.startTime + dt);
    
    dispatch(updateClip({
      trackId: dragData.trackId,
      clipId: dragData.clipId,
      updates: { startTime: newStartTime, endTime: newStartTime + clip.duration }
    }));
  }
};

3.3 实时预览引擎

预览是最具挑战性的部分之一。浏览器原生的 <video> 元素只能播放单个文件,但我们需要播放时间线上拼接的多个片段。

方案:时钟驱动 + 视频同步

// 用系统时钟驱动时间线推进
const playbackStartRef = useRef<{ time: number; wallClock: number }>();

const updateTime = () => {
  if (!isPlaying || !playbackStartRef.current) return;
  
  const elapsed = (Date.now() - playbackStartRef.current.wallClock) / 1000;
  const newTime = playbackStartRef.current.time + elapsed;
  
  if (newTime <= duration) {
    dispatch(setCurrentTime(newTime));
  } else {
    dispatch(setIsPlaying(false));
  }
  
  requestAnimationFrame(updateTime);
};

currentTime 变化时,查找当前活跃的片段并切换视频源:

useEffect(() => {
  const videoTrack = tracks.find(t => t.type === 'video');
  const activeClip = videoTrack?.clips.find(
    clip => currentTime >= clip.startTime && currentTime < clip.endTime
  );
  
  if (activeClip) {
    const material = materials.find(m => m.id === activeClip.materialId);
    setActiveMedia({ url: material.url, type: material.type });
  }
}, [currentTime, tracks, materials]);

关键细节:用 key 属性强制 React 在切换片段时重建 <video> 元素,避免残留上一段视频的画面:

<video key={activeMedia.url} src={activeMedia.url} />

3.4 视频导出流水线

导出是后端的核心功能。整个流程分为 3 个阶段:

阶段 1:视频轨道处理

每个片段单独处理,然后用 ffmpeg concat 拼接:

// 处理单个片段
async function processClip(clip, material, outputPath, resolution, crf, fps) {
  const isImage = material.type === 'image';
  
  if (isImage) {
    // 图片转视频:循环指定时长
    ffmpeg()
      .input(sourcePath)
      .input('anullsrc=channel_layout=stereo:sample_rate=44100') // 静音音频流
      .loop(clipDuration)
      .outputOptions(['-c:v libx264', '-pix_fmt yuv420p', '-t', clipDuration]);
  } else {
    // 视频裁剪 + 缩放
    ffmpeg(sourcePath)
      .outputOptions([
        `-vf scale=${width}:${height}:force_original_aspect_ratio=decrease`,
        '-c:v libx264', '-crf', crf, '-r', fps
      ]);
  }
}

多片段拼接使用 ffmpeg 的 concat demuxer:

// 生成 concat 列表文件
const concatList = segmentPaths.map(p => `file '${p}'`).join('\n');
fs.writeFileSync(concatListPath, concatList);

// 拼接
ffmpeg()
  .input(concatListPath)
  .inputOptions(['-f', 'concat', '-safe', '0'])
  .outputOptions(['-c', 'copy'])
  .output(finalPath);

阶段 2:音频混合

将音频轨道的片段按时间位置混合到视频中:

// 提取音频片段
ffmpeg(sourcePath)
  .outputOptions(['-c:a aac', '-vn'])
  .setStartTime(clip.trimStart)
  .duration(clip.duration);

// 用 adelay 滤镜定位,amix 混合
const filter = `[1:a]adelay=${delayMs}|${delayMs}[a1];[a1]amix=inputs=1:duration=first[aout]`;

ffmpeg()
  .input(videoPath)
  .input(audioPath)
  .complexFilter(filter)
  .outputOptions(['-map', '0:v', '-map', '[aout]', '-c:v copy']);

阶段 3:字幕烧录

使用 ffmpeg 的 drawtext 滤镜将字幕烧录到视频:

const filter = `drawtext=text='${text}':fontsize=${size}:fontcolor=${color}` +
  `:box=1:boxcolor=${bgColor}:boxborderw=8` +
  `:x=(w-tw-${boxBorderw * 2})/2:y=${y}` +
  `:enable='between(t\\,${start}\\,${end})'`;

3.5 文本编辑与定位

文本轨道支持在预览中直接拖拽定位和双击编辑:

// 拖拽移动
const handleMouseMove = (e: MouseEvent) => {
  const dx = ((e.clientX - startX) / containerWidth) * 100;
  const dy = ((e.clientY - startY) / containerHeight) * 100;
  
  dispatch(updateClip({
    trackId, clipId,
    updates: { textStyle: { ...style, x: origX + dx, y: origY + dy } }
  }));
};

中文输入需要处理 IME 组合事件,否则拼音会被当作最终值:

<textarea
  defaultValue={text}
  onCompositionStart={() => { isComposing = true; }}
  onCompositionEnd={(e) => {
    isComposing = false;
    updateText(e.target.value);
  }}
  onChange={(e) => {
    if (!isComposing) updateText(e.target.value);
  }}
/>

四、踩过的坑

4.1 nodemon 误重启

exports/ 目录下创建临时片段文件时,nodemon 检测到文件变化自动重启服务,导致导出中断。

解决方案:将临时文件放到系统临时目录 os.tmpdir(),并配置 nodemon 只监听 src/ 目录。

4.2 ffmpeg concat 音频丢失

图片片段和静音视频片段没有音频流,与有音频的片段拼接时,整个视频的音频丢失。

解决方案:为所有片段统一生成音频流,无音频的片段用 anullsrc 生成静音轨道。

4.3 播放到片段边界卡住

最初用 <video> 元素的 currentTime 驱动时间线,视频播放结束时时间线停止推进。

解决方案:改用系统时钟(Date.now())驱动时间线,视频元素只负责渲染,两者通过 currentTime 单向同步。

五、性能优化

  1. 片段懒加载:只加载当前播放位置附近的视频资源
  2. 预览降质:预览时使用较低分辨率,导出时使用原始质量
  3. Web Worker:将耗时的状态计算放到 Worker 线程(待实现)
  4. 虚拟列表:素材列表使用虚拟滚动,支持大量素材

六、未来规划

  • 撤销/重做(基于 Redux 中间件)
  • 转场效果(交叉溶解、淡入淡出)
  • 滤镜效果(亮度、对比度、饱和度)
  • 关键帧动画
  • 项目保存与加载
  • WebSocket 实时导出进度
  • WebAssembly 版 ffmpeg(客户端导出)

总结

构建一个 Web 视频剪辑器涉及前端状态管理、多媒体处理、实时渲染等多个技术领域。核心挑战在于:

  1. 时间线模型设计:需要足够灵活以支持各种编辑操作
  2. 预览性能:浏览器的 <video> 元素能力有限,需要巧妙的架构设计
  3. 导出流水线:ffmpeg 功能强大但 API 复杂,需要仔细处理各种边界情况

这个项目还有很多可以改进的地方,但已经验证了 Web 技术栈构建视频剪辑器的可行性。希望本文能为有类似需求的开发者提供一些参考。


技术栈总结:React 19 + TypeScript + Redux Toolkit + Ant Design + Express + fluent-ffmpeg + MySQL

❌