普通视图

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

TCP/IP 与前端性能:从数据包到首次渲染的底层逻辑

2026年5月4日 21:23

TCP/IP 与前端性能:从数据包到首次渲染的底层逻辑

这是“从 URL 到页面展示”系列第三篇。前面两篇我们聊完了浏览器导航DNS 与传输层细节,今天我们钻进 TCP/IP 协议栈的核心,看看一个数据包究竟怎么跑完全程,以及它为什么直接影响前端最关心的性能指标 FP(首次渲染时间)


一、前端性能的起点:FP 与 TTFB

面试官问“怎么优化页面加载速度”,你可以先说两个关键时间点:

  • TTFB(Time To First Byte):从发起请求到收到服务器第一个字节的耗时 = DNS 解析 + TCP/TLS 连接 + 服务器处理 + 响应传输的第一个字节到达。
  • FP(First Paint):从页面加载到浏览器首次绘制出像素的耗时 = TTFB + HTML 解析 + CSSOM 构建 + 渲染树构建 + 布局 + 首次绘制。

从这个公式可以看出:TTFB 里有一大块时间花在网络传输上,而网络传输的根基就是 TCP/IP。前端做性能优化,不能只盯着 JS 和 CSS,还得懂底层。


二、数据包的旅程:互联网的“快递系统”

互联网本质是一套理念和协议组成的体系架构,数据不是一整块丢进网线的,而是拆成一个一个数据包传输。

为什么拆包?

  • 单个文件可能几十 MB,一次性发送会长时间占用整条链路,其它请求就得排队。
  • 拆成小包后,可以利用带宽并发传输,提升传输效率和容错率。某个包丢了,只需要重发这一个,不用重发全部。

这些数据包最终会变成二进制数据帧,在物理介质上流动。


三、IP 层:只负责“送到”,不负责“送到位”

IP(Internet Protocol)是网络层的协议,职责非常单纯:根据 IP 地址,把数据包从源主机送到目标主机

它做的是“尽力而为”的服务:

  • 可能丢包
  • 可能出错
  • 可能不按顺序到达
  • 不提供任何纠正机制

所以 IP 本身是一个“不可靠”的协议。前端请求的 HTML 文件结构严密,一个字节错位都可能导致渲染异常,怎么办?

答案就在传输层。


四、UDP vs TCP:两种“快递模式”

传输层运行在 IP 之上,负责将数据包交付到目标主机上的具体应用(通过端口号)。主要有两位选手:

UDP(User Datagram Protocol):只管快

  • 不建立连接,直接发包。
  • 不保证顺序,不重传丢失的包。
  • 头部开销小,速度快。

适用场景:对实时性要求极高、能容忍少量数据丢失的音视频直播、视频通话、在线游戏。

TCP(Transmission Control Protocol):保证到位

对于 HTML、CSS、JS、图片这类 Web 资源,哪怕一个包出错都可能导致页面渲染异常。TCP 专门解决两个核心问题:

问题 TCP 的解法
数据包在传输过程中丢失 超时重传机制 — 每发一个包,启动一个计时器,过期未收到确认就重发
数据包到达接收端顺序错乱 序号机制 — 每个包都带有序号,接收端按序号重新组装

因为要保证可靠性,TCP 比 UDP 慢 —— 但这恰恰是 Web 页面需要的可靠传输


五、三次握手:建立可靠连接

在真正发送 HTTP 请求之前,TCP 需要通过三次握手建立连接。核心目的:同步初始序号,验证双方收发能力

简化过程:

  1. 客户端 → 服务器SYN,带上初始序号 J
    “我想和你建立连接,我发送的包从 J 开始编号,你听得到吗?”

  2. 服务器 → 客户端SYN + ACK,确认号 J+1,同时带上自己的初始序号 K
    “听到了,你下一个从 J+1 开始发。我也想和你建立连接,我的包从 K 开始编号,你听得到吗?”

  3. 客户端 → 服务器ACK,确认号 K+1
    “听到了,你下一个从 K+1 开始发。咱们可以正式开始传数据了。”

高频追问:为什么是三次,不是两次或四次?

  • 两次不够:服务器无法确认客户端能收到自己的消息(无法确认客户端有接收能力)。
  • 四次没必要:第二次握手时,服务器把 “响应客户端的 SYN”“发出自己的 SYN” 合并成一条消息,效率最大化。
    原则是:每一方的发送和接收能力都需要两次验证,但因为服务器把两个动作合并了,总共只需三次。

过程图

image.png

六、四次挥手:优雅地断开连接

数据传输完毕(比如 HTML 下载完成、图片加载结束),需要断开 TCP 连接释放资源。这个过程需要四次挥手

  1. A → BFIN,带上序号 M
    “我没有数据要发了,想断开连接。”

  2. B → AACK,确认号 M+1
    “知道你没数据了,但我可能还有数据没发完,你再等等。”

  3. B → AFIN,带上序号 N
    “我的数据也发完了,可以断开了。”

  4. A → BACK,确认号 N+1
    “好的,我知道你也没数据了。再见。”

为什么比握手多一次?因为 TCP 是全双工的,双方都可以独立发送和接收数据。一端说“我发完了”,另一端可能还有数据要传,所以 FIN 和 ACK 不能合并,必须分开发送,正好四次。

过程图

image.png


七、回到前端:这些对性能优化意味着什么?

理解了 TCP,就能看懂很多性能优化的底层逻辑:

优化手段 背后的 TCP 原理
减少 HTTP 请求数(雪碧图、合并文件) 每个 TCP 连接都有三次握手开销,请求数越少,握手成本越低
使用 HTTP/2 多路复用 单个 TCP 连接上并发传输多个请求和响应,避免重复握手
启用 TCP Fast Open 在握手阶段就开始传数据,将握手和数据传输部分重叠,降低 TTFB
使用 CDN 缩短物理距离 → 减少 RTT(往返时延)→ 丢包概率降低 → 重传少 → 更快
资源预连接(preconnect) 提前完成 DNS + TCP + TLS 握手,请求时直接使用已建立的连接

八、总结:一条链路串起知识体系

至此,我们串联起了整个前端性能链条的网络部分:

DNS 解析(IP 找到主机)
  → TCP 三次握手(建立可靠连接)
  → TLS 握手(加密安全)
  → HTTP 请求/响应(应用层数据)
  → TCP 四次挥手(断开连接)

每一个环节的耗时,都叠加进了 TTFB,进而影响 FP。下次面试问到性能优化,你完全可以从这个底层视角切入,展示你对网络协议栈的真懂,而不是只背“减少请求数”的表面答案。


代码写成一锅粥?3个设计模式让你的项目“起死回生”

作者 kyriewen
2026年5月4日 21:01

你的组件里是不是全是if-else?改一个地方,崩三个地方?新来的同事改完你的代码,你看着他,他看你,两人都沉默了。今天我们不背理论,直接用3个前端最常用的设计模式——单例、观察者、策略,把业务从“屎山”变成“积木”。学完你就能拍着胸脯说:“我的代码,谁都敢动。”

前言

设计模式不是“面试八股文”,而是前辈们踩过的坑总结成的“套路”。就像做饭有菜谱,写代码也有标准解法。今天我们把场景摆出来:弹窗多次打开、购物车更新通知到处写、表单校验if-else十几层……然后一个个用设计模式把它们治好。

一、单例模式:全局只有一个的“独生子”

场景:你写了个全局弹窗(Modal),用户点按钮就打开。结果用户连续点三次,页面上冒出三个弹窗叠在一起,像俄罗斯方块。

问题代码

function showModal() {
  const div = document.createElement('div');
  div.className = 'modal';
  div.innerHTML = '我是弹窗';
  document.body.appendChild(div);
}
// 点三次,三个弹窗

单例模式解决

确保无论调用多少次,只创建同一个实例。

class GlobalModal {
  constructor() {
    if (!GlobalModal.instance) {
      this.element = null;
      GlobalModal.instance = this;
    }
    return GlobalModal.instance;
  }
  show() {
    if (!this.element) {
      this.element = document.createElement('div');
      this.element.className = 'modal';
      this.element.innerHTML = '我是弹窗';
      document.body.appendChild(this.element);
    }
    this.element.style.display = 'block';
  }
  hide() {
    if (this.element) this.element.style.display = 'none';
  }
}
const modal1 = new GlobalModal();
const modal2 = new GlobalModal();
console.log(modal1 === modal2); // true

真实项目更简单的写法:直接导出实例对象。

// modal.js
export const globalModal = {
  element: null,
  show() { /* ... */ },
  hide() { /* ... */ }
};

应用:全局Store(Pinia/Vuex就是单例)、全局轮询管理器、WebSocket连接。

二、观察者模式:让不相干的组件“悄悄对话”

场景:用户点击“添加购物车”,需要同时做三件事:更新购物车角标、弹出“添加成功”提示、发送埋点数据。如果直接在购物车里调用其他模块的方法,代码会变成:

function addToCart(item) {
  // 添加逻辑...
  header.updateBadge(count);
  toast.show('添加成功');
  analytics.track('add_to_cart', item);
}

每加一个功能,addToCart就要改一次,耦合得像麻花。

观察者模式解决(事件总线)

// eventBus.js
class EventBus {
  constructor() {
    this.events = {};
  }
  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
  }
  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
}
export const bus = new EventBus();
// 购物车模块
import { bus } from './eventBus';
function addToCart(item) {
  // 添加逻辑...
  bus.emit('cartUpdated', { count: newCount, item });
}
// 头部模块
import { bus } from './eventBus';
bus.on('cartUpdated', (data) => {
  updateBadge(data.count);
});
// 埋点模块
bus.on('cartUpdated', (data) => {
  analytics.track('add_to_cart', data.item);
});

现在,要加新功能只管bus.on,不用改购物车代码。Vue的emitter、React的useContext+useReducer其实都用了这个思想。

三、策略模式:消灭if-else毒瘤

场景:用户等级不同,商品折扣不同。你写了一个函数:

function getDiscount(level, price) {
  if (level === 'normal') return price * 0.95;
  else if (level === 'gold') return price * 0.9;
  else if (level === 'platinum') return price * 0.8;
  else return price;
}

这还好。但当你需要增加“钻石会员”、“企业会员”、“节日特惠”……函数越来越大,改一次心惊胆战。

策略模式解决:把算法抽成独立对象

const discountStrategies = {
  normal: (price) => price * 0.95,
  gold: (price) => price * 0.9,
  platinum: (price) => price * 0.8,
};
function getDiscount(level, price) {
  const strategy = discountStrategies[level];
  return strategy ? strategy(price) : price;
}

新增会员等级,只需要加一个策略,不用改getDiscount

更复杂的例子:表单验证

const validators = {
  required: (val) => val && val.trim() !== '',
  minLength: (val, len) => val.length >= len,
  email: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
};
function validateField(value, rules) {
  for (let rule of rules) {
    const [name, param] = rule.split(':');
    const validator = validators[name];
    if (validator && !validator(value, param)) {
      return false;
    }
  }
  return true;
}
// 使用
const isValid = validateField('abc@test.com', ['required', 'email']);

以后增加“手机号验证”,加一个mobile策略即可,完全符合开闭原则(对扩展开放,对修改封闭)。

四、组合实战:一个购物车结算页面

  • 单例:全局唯一的购物车实例(存储商品列表)。
  • 观察者:商品数量变化时,触发价格重算、优惠券校验、埋点。
  • 策略:根据用户等级计算折扣;根据优惠券类型(满减、打折)计算优惠。
// 购物车单例
class Cart {
  static instance = null;
  static getInstance() {
    if (!Cart.instance) Cart.instance = new Cart();
    return Cart.instance;
  }
  items = [];
  addItem(item) {
    // 添加逻辑
    bus.emit('cartChanged', this.items);
  }
}
// 价格计算模块监听变化并应用折扣策略
bus.on('cartChanged', (items) => {
  const total = items.reduce((sum, item) => sum + item.price * item.count, 0);
  const discount = discountStrategies[user.level](total);
  renderTotal(discount);
});

各模块独立,改折扣策略不影响购物车;加埋点不影响价格计算。

五、总结:模式是工具,不是教条

  • 单例:保证全局唯一,适合共享资源。
  • 观察者:解耦事件发布和订阅,适合跨组件通信。
  • 策略:消除if-else,算法可互换,适合规则多变场景。

不要为了用模式而用模式。当你的代码出现重复、难维护、改一处动全身时,想想哪种模式能帮你“抽出来”。写代码就像搭积木,模式就是那些标准接口的积木块,让你搭得又快又稳。

每日一题-旋转链表🟡

2026年5月5日 00:00

给你一个链表的头节点 head ,旋转链表,将链表每个节点向右移动 k 个位置。

 

示例 1:

输入:head = [1,2,3,4,5], k = 2
输出:[4,5,1,2,3]

示例 2:

输入:head = [0,1,2], k = 4
输出:[2,0,1]

 

提示:

  • 链表中节点的数目在范围 [0, 500]
  • -100 <= Node.val <= 100
  • 0 <= k <= 2 * 109

首尾相连再断开(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2026年5月4日 09:30

lc61.jpg

示例 1 的链表长为 $5$,$k=2$。旋转后,原链表的倒数第 $k$ 个节点,成为新链表的头节点。

把 $1\to 2\to 3\to 4\to 5$ 变成 $4\to 5\to 1\to 2\to 3$,我们需要:

  1. 首尾相连,把 $5$ 和 $1$ 连起来。遍历链表即可找到尾节点。
  2. 断开倒数第 $k+1$ 个节点和倒数第 $k$ 个节点,即断开 $3\to 4$。

本题 $k$ 可能很大,我们需要先求出链表的长度 $n$,然后把 $k$ 更新为 $k\bmod n$。这是因为链表旋转 $n$ 次没变,旋转 $n+1$ 次等同于旋转 $1$ 次,依此类推,旋转 $k$ 次等价于旋转 $k\bmod n$ 次。

倒数第 $k+1$ 个节点即正数第 $n-k$ 个节点。从头节点开始,向后移动 $n-k-1$ 次,即为正数第 $n-k$ 个节点。

###py

class Solution:
    def rotateRight(self, head: Optional[ListNode], k: int) -> Optional[ListNode]:
        if head is None:
            return None

        # 1. 计算链表长度,并找到尾节点
        length = 1
        tail = head
        while tail.next:
            length += 1
            tail = tail.next
        k %= length

        # 2. 首尾相连
        tail.next = head

        # 3. 找倒数第 k+1 个节点,作为新链表的尾节点
        new_tail = head
        for _ in range(length - k - 1):
            new_tail = new_tail.next

        # 4. 断开倒数第 k+1 个节点(new_tail)和倒数第 k 个节点(new_head)
        new_head = new_tail.next
        new_tail.next = None
        return new_head

###java

class Solution {
    public ListNode rotateRight(ListNode head, int k) {
        if (head == null) {
            return null;
        }

        // 1. 计算链表长度,并找到尾节点
        int length = 1;
        ListNode tail = head;
        while (tail.next != null) {
            length++;
            tail = tail.next;
        }
        k %= length;

        // 2. 首尾相连
        tail.next = head;

        // 3. 找倒数第 k+1 个节点,作为新链表的尾节点
        ListNode newTail = head;
        for (int i = 0; i < length - k - 1; i++) {
            newTail = newTail.next;
        }

        // 4. 断开倒数第 k+1 个节点(newTail)和倒数第 k 个节点(newHead)
        ListNode newHead = newTail.next;
        newTail.next = null;
        return newHead;
    }
}

###cpp

class Solution {
public:
    ListNode* rotateRight(ListNode* head, int k) {
        if (head == nullptr) {
            return nullptr;
        }

        // 1. 计算链表长度,并找到尾节点
        int length = 1;
        ListNode* tail = head;
        while (tail->next) {
            length++;
            tail = tail->next;
        }
        k %= length;

        // 2. 首尾相连
        tail->next = head;

        // 3. 找倒数第 k+1 个节点,作为新链表的尾节点
        ListNode* new_tail = head;
        for (int i = 0; i < length - k - 1; i++) {
            new_tail = new_tail->next;
        }

        // 4. 断开倒数第 k+1 个节点(new_tail)和倒数第 k 个节点(new_head)
        ListNode* new_head = new_tail->next;
        new_tail->next = nullptr;
        return new_head;
    }
};

###c

struct ListNode* rotateRight(struct ListNode* head, int k) {
    if (head == NULL) {
        return NULL;
    }

    // 1. 计算链表长度,并找到尾节点
    int length = 1;
    struct ListNode* tail = head;
    while (tail->next) {
        length++;
        tail = tail->next;
    }
    k %= length;

    // 2. 首尾相连
    tail->next = head;

    // 3. 找倒数第 k+1 个节点,作为新链表的尾节点
    struct ListNode* new_tail = head;
    for (int i = 0; i < length - k - 1; i++) {
        new_tail = new_tail->next;
    }

    // 4. 断开倒数第 k+1 个节点(new_tail)和倒数第 k 个节点(new_head)
    struct ListNode* new_head = new_tail->next;
    new_tail->next = NULL;
    return new_head;
}

###go

func rotateRight(head *ListNode, k int) *ListNode {
if head == nil {
return nil
}

// 1. 计算链表长度,并找到尾节点
length := 1
tail := head
for tail.Next != nil {
length++
tail = tail.Next
}
k %= length

// 2. 首尾相连
tail.Next = head

// 3. 找倒数第 k+1 个节点,作为新链表的尾节点
newTail := head
for range length - k - 1 {
newTail = newTail.Next
}

// 4. 断开倒数第 k+1 个节点(newTail)和倒数第 k 个节点(newHead)
newHead := newTail.Next
newTail.Next = nil
return newHead
}

###js

var rotateRight = function(head, k) {
    if (head === null) {
        return null;
    }

    // 1. 计算链表长度,并找到尾节点
    let length = 1;
    let tail = head;
    while (tail.next !== null) {
        length++;
        tail = tail.next;
    }
    k %= length;

    // 2. 首尾相连
    tail.next = head;

    // 3. 找倒数第 k+1 个节点,作为新链表的尾节点
    let newTail = head;
    for (let i = 0; i < length - k - 1; i++) {
        newTail = newTail.next;
    }

    // 4. 断开倒数第 k+1 个节点(newTail)和倒数第 k 个节点(newHead)
    const newHead = newTail.next;
    newTail.next = null;
    return newHead;
};

###rust

impl Solution {
    pub fn rotate_right(mut head: Option<Box<ListNode>>, mut k: i32) -> Option<Box<ListNode>> {
        if head.is_none() {
            return head;
        }

        // 1. 计算链表长度
        let mut length = 0;
        let mut cur = &head;
        while let Some(node) = cur {
            length += 1;
            cur = &node.next;
        }

        k %= length;
        if k == 0 { // 链表不变
            return head;
        }

        // 2. 找倒数第 k 个节点
        let mut cur = &mut head;
        for _ in 0..length - k {
            cur = &mut cur.as_mut()?.next;
        }

        // 3. 断开倒数第 k+1 个节点和倒数第 k 个节点(new_head)
        let mut new_head = cur.take();

        // 4. 首尾相连
        let mut tail = &mut new_head;
        while !tail.as_mut()?.next.is_none() {
            tail = &mut tail.as_mut()?.next;
        }
        tail.as_mut()?.next = head;

        new_head
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是链表的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

旋转链表 | 图解链表 | 最清晰易懂的题解 【c++/java版本】

1、思路

(模拟) $O(n)$

给你一个链表的头节点 head ,然后将链表每个节点向右移动 k 个位置。

样例:

{:width="70%"}

如样例所示,head = [1,2,3,4,5]k = 2,我们输出[4,5,1,2,3]。下面来讲解模拟的做法。

假设链表的长度为n,为了将链表每个节点向右移动 k 个位置,我们只需要将链表的后 k % n个节点移动到链表的最前面,然后将链表的后k % n个节点和前 n - k个节点连接到一块即可。

具体过程如下:

1、首先遍历整个链表,求出链表的长度n,并找出链表的尾节点tail

{:width="70%"}

2、由于k可能很大,所以我们令 k = k % n,然后再次从头节点head开始遍历,找到第n - k个节点p,那么1 ~ p是链表的前 n - k个节点,p+1 ~ n是链表的后k个节点。

{:width="70%"}

3、接下来就是依次执行 tail->next = headhead = p->nextp->next = nullptr,将链表的后k个节点和前 n - k个节点拼接到一块,并让head指向新的头节点(p->next),新的尾节点即p节点的next指针指向null

{:width="70%"}

4、最后返回链表的新的头节点head

时间复杂度分析: 链表一共被遍历两次,因此总的时间复杂度为$O(n)$,$n$是链表的长度。

2、c++代码

###c

class Solution {
public:
    ListNode* rotateRight(ListNode* head, int k) {
        if(!head || !k)  return head;
        int n = 0;        //链表的长度
        ListNode* tail;   //尾节点
        for(ListNode* p = head; p ; p = p->next){
            tail = p;
            n++;
        }
        k %= n;  
        ListNode* p = head;
        for(int i = 0; i < n - k - 1; i++)   p = p->next;  //找到链表的第n-k个节点
        tail->next = head;
        head = p->next;
        p->next = nullptr;
        return head;     //返回新的头节点
    }
};

3、java代码

###javascript

class Solution {
    public ListNode rotateRight(ListNode head, int k) {
        if(head == null|| k == 0)  return head;
        int n = 0;   //链表的长度
        ListNode tail = null;  //尾节点
        for(ListNode p = head; p != null ; p = p.next){
            tail = p;
            n++;
        }
        k %= n;
        ListNode p = head;
        for(int i = 0; i < n - k - 1; i++)  p = p.next;   //找到链表的第n-k个节点
        tail.next = head;
        head = p.next;
        p.next = null;
        return head;  //返回新的头节点
    }
}

在这里插入图片描述

旋转链表

2021年3月26日 19:03

方法一:闭合为环

思路及算法

记给定链表的长度为 $n$,注意到当向右移动的次数 $k \geq n$ 时,我们仅需要向右移动 $k \bmod n$ 次即可。因为每 $n$ 次移动都会让链表变为原状。这样我们可以知道,新链表的最后一个节点为原链表的第 $(n - 1) - (k \bmod n)$ 个节点(从 $0$ 开始计数)。

这样,我们可以先将给定的链表连接成环,然后将指定位置断开。

具体代码中,我们首先计算出链表的长度 $n$,并找到该链表的末尾节点,将其与头节点相连。这样就得到了闭合为环的链表。然后我们找到新链表的最后一个节点(即原链表的第 $(n - 1) - (k \bmod n)$ 个节点),将当前闭合为环的链表断开,即可得到我们所需要的结果。

特别地,当链表长度不大于 $1$,或者 $k$ 为 $n$ 的倍数时,新链表将与原链表相同,我们无需进行任何处理。

代码

###C++

class Solution {
public:
    ListNode* rotateRight(ListNode* head, int k) {
        if (k == 0 || head == nullptr || head->next == nullptr) {
            return head;
        }
        int n = 1;
        ListNode* iter = head;
        while (iter->next != nullptr) {
            iter = iter->next;
            n++;
        }
        int add = n - k % n;
        if (add == n) {
            return head;
        }
        iter->next = head;
        while (add--) {
            iter = iter->next;
        }
        ListNode* ret = iter->next;
        iter->next = nullptr;
        return ret;
    }
};

###Java

class Solution {
    public ListNode rotateRight(ListNode head, int k) {
        if (k == 0 || head == null || head.next == null) {
            return head;
        }
        int n = 1;
        ListNode iter = head;
        while (iter.next != null) {
            iter = iter.next;
            n++;
        }
        int add = n - k % n;
        if (add == n) {
            return head;
        }
        iter.next = head;
        while (add-- > 0) {
            iter = iter.next;
        }
        ListNode ret = iter.next;
        iter.next = null;
        return ret;
    }
}

###Python

class Solution:
    def rotateRight(self, head: ListNode, k: int) -> ListNode:
        if k == 0 or not head or not head.next:
            return head
        
        n = 1
        cur = head
        while cur.next:
            cur = cur.next
            n += 1
        
        if (add := n - k % n) == n:
            return head
        
        cur.next = head
        while add:
            cur = cur.next
            add -= 1
        
        ret = cur.next
        cur.next = None
        return ret

###JavaScript

var rotateRight = function(head, k) {
    if (k === 0 || !head || !head.next) {
        return head;
    }
    let n = 1;
    let cur = head;
    while (cur.next) {
        cur = cur.next;
        n++;
    }

    let add = n - k % n;
    if (add === n) {
        return head;
    }

    cur.next = head;
    while (add) {
        cur = cur.next;
        add--;
    }

    const ret = cur.next;
    cur.next = null;
    return ret;
};

###go

func rotateRight(head *ListNode, k int) *ListNode {
    if k == 0 || head == nil || head.Next == nil {
        return head
    }
    n := 1
    iter := head
    for iter.Next != nil {
        iter = iter.Next
        n++
    }
    add := n - k%n
    if add == n {
        return head
    }
    iter.Next = head
    for add > 0 {
        iter = iter.Next
        add--
    }
    ret := iter.Next
    iter.Next = nil
    return ret
}

###C

struct ListNode* rotateRight(struct ListNode* head, int k) {
    if (k == 0 || head == NULL || head->next == NULL) {
        return head;
    }
    int n = 1;
    struct ListNode* iter = head;
    while (iter->next != NULL) {
        iter = iter->next;
        n++;
    }
    int add = n - k % n;
    if (add == n) {
        return head;
    }
    iter->next = head;
    while (add--) {
        iter = iter->next;
    }
    struct ListNode* ret = iter->next;
    iter->next = NULL;
    return ret;
}

复杂度分析

  • 时间复杂度:$O(n)$,最坏情况下,我们需要遍历该链表两次。

  • 空间复杂度:$O(1)$,我们只需要常数的空间存储若干变量。

昨天 — 2026年5月4日技术

用Viem替代ethers.js:从一次签名失败到完整迁移的实战记录

作者 竹林818
2026年5月4日 18:01

用Viem替代ethers.js:从一次签名失败到完整迁移的实战记录

摘要

我在开发一个跨链DeFi聚合器时,被ethers.js的签名兼容性和类型错误折磨了两天,最终决定迁移到Viem。这篇文章记录了我从踩坑、分析到用Viem重写合约交互的全过程,包括签名验证、事件监听和Gas估算等核心场景的代码实现。

背景

上个月接了一个跨链DeFi聚合器的前端开发,核心功能是让用户在一条链上签名交易,然后在另一条链上执行。项目用了ethers.js v5,配合MetaMask做钱包连接。本来一切顺利,直到我需要在Polygon上签名一个EIP-712结构化数据,然后在Optimism上验证并执行。结果签名总是对不上,不是报invalid signature就是recovered address mismatch。我排查了两天,发现ethers.js在处理某些链的签名格式时,会有奇怪的行为——尤其是当签名中的v值不是27或28时,它会自动调整,导致跨链验证失败。当时我就想,这库用了几年了,怎么还有这种坑?

问题分析

我的最初思路是:既然ethers.js对签名做了"友好"处理,那我手动把v值标准化成27/28不就行了?于是我写了段代码:

const signature = await signer._signTypedData(domain, types, value);
// 手动解析并调整v值
const { v, r, s } = ethers.utils.splitSignature(signature);
const adjustedV = v === 0 ? 27 : v === 1 ? 28 : v;

结果更糟了。因为splitSignature内部已经把v调整了一次,我再调整一次,签名直接废了。而且ethers.js v5的TypeScript类型定义不够严谨,signer._signTypedData返回的是Promise<string>,但你传进去的domain类型是TypedDataDomain,它和EIP-712规范里的字段名有细微差异(比如chainId在ethers里是number,但规范里是uint256),导致某些链(比如Arbitrum)直接报invalid argument

我后来查了GitHub issues,发现ethers.js团队在v6里改进了签名处理,但v6的API变化太大,迁移成本高。这时我想到了Viem——一个更轻量、类型更严格的Web3库。当时我犹豫了一下:换库意味着重写所有合约交互代码,但既然已经被ethers.js坑了一次,不如彻底解决。

核心实现

第一步:搭建Viem环境,替换钱包连接

我用的React框架,之前用@web3-react/core配合ethers.js。Viem官方提供了wagmi(一个React Hooks库),但我不想引入太多依赖,所以直接用Viem的createWalletClientcreatePublicClient自己封装。

这里有个坑:Viem的createWalletClient默认不包含window.ethereum,需要手动传入transport。我一开始忘了传,结果walletClient.getAddresses()一直返回空数组。

import { createWalletClient, createPublicClient, custom, http } from 'viem';
import { mainnet, polygon, optimism } from 'viem/chains';

// 初始化公共客户端(用于读链上数据)
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http()
});

// 初始化钱包客户端(需要用户授权)
const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum)
});

注意:createWalletClienttransport参数必须用custom(window.ethereum),不能用http()。我当时用http()测试,结果报TransportError: The transport does not support signing

第二步:用Viem实现EIP-712签名

这是我最头疼的部分。Viem的signTypedData方法要求传入的参数类型非常严格——domain里的chainId必须是number,不能是bigintstring。而且它不会像ethers.js那样自动转换v值,签名结果就是原始格式。

import { signTypedData, recoverTypedDataAddress } from 'viem';

// 定义EIP-712类型
const domain = {
  name: 'CrossChainSwap',
  version: '1',
  chainId: 137, // Polygon的chainId,必须是number
  verifyingContract: '0x...' as `0x${string}`
};

const types = {
  Swap: [
    { name: 'fromToken', type: 'address' },
    { name: 'toToken', type: 'address' },
    { name: 'amount', type: 'uint256' },
    { name: 'nonce', type: 'uint256' }
  ]
};

const value = {
  fromToken: '0x...' as `0x${string}`,
  toToken: '0x...' as `0x${string}`,
  amount: BigInt('1000000000000000000'),
  nonce: BigInt(Date.now())
};

// 签名
const signature = await walletClient.signTypedData({
  account,
  domain,
  types,
  primaryType: 'Swap',
  message: value
});

// 验证签名(在另一条链上)
const recoveredAddress = await recoverTypedDataAddress({
  domain,
  types,
  primaryType: 'Swap',
  message: value,
  signature
});
console.log('Recovered:', recoveredAddress); // 应该等于account

这里有个关键细节:Viem的signTypedData返回的签名是0x开头的十六进制字符串,长度是132个字符(包含0x),这是标准的RSV格式。ethers.js返回的也是同样的格式,但它内部对v做了处理。Viem不会——所以跨链验证时,只要你在两条链上用相同的参数签名,结果就是一致的。我当时测试了Polygon和Optimism,签名完全匹配。

第三步:合约调用和Gas估算

替换合约调用时,我遇到了第二个坑:Viem的writeContractestimateGas是分离的,不像ethers.js那样直接在交易对象里传gasLimit。我需要先估算Gas,然后手动设置。

import { getContract } from 'viem';

// 创建合约实例
const contract = getContract({
  address: '0x...' as `0x${string}`,
  abi: swapAbi,
  client: { public: publicClient, wallet: walletClient }
});

// 估算Gas
const gasEstimate = await publicClient.estimateContractGas({
  address: contract.address,
  abi: contract.abi,
  functionName: 'swap',
  args: [value.fromToken, value.toToken, value.amount, signature],
  account
});

// 发送交易
const hash = await walletClient.writeContract({
  address: contract.address,
  abi: contract.abi,
  functionName: 'swap',
  args: [value.fromToken, value.toToken, value.amount, signature],
  account,
  gas: gasEstimate // 注意:Viem里gas参数是`gas`不是`gasLimit`
});

注意这个参数名:Viem用gas,ethers.js用gasLimit。我当时习惯性写了gasLimit,结果交易一直报错missing gas limit。排查了半小时才发现这个命名差异。

第四步:事件监听

事件监听是另一个让我头疼的地方。ethers.js的contract.on是回调式的,Viem用的是watchContractEvent,返回一个取消监听的函数。

// 监听Swap事件
const unwatch = publicClient.watchContractEvent({
  address: contract.address,
  abi: contract.abi,
  eventName: 'SwapExecuted',
  args: { user: account }, // 过滤条件
  onLogs: (logs) => {
    const [log] = logs;
    console.log('Swap executed:', log.args);
    // 更新UI
    setTxStatus('confirmed');
  }
});

// 组件卸载时取消监听
useEffect(() => {
  return () => unwatch();
}, []);

这里有个坑:Viem的watchContractEvent在监听时,如果链的区块时间很短(比如Polygon每2秒一个块),回调会被频繁触发。我一开始没做防抖,结果UI刷新了几百次。后来加了throttle才解决。

第五步:链切换

跨链聚合器需要频繁切换链。Viem的switchChain比ethers.js更直观,但要注意:walletClient.switchChain只切换钱包的链,不影响publicClient。我需要同时更新两个客户端。

import { polygon, optimism } from 'viem/chains';

async function switchChain(targetChain: typeof polygon | typeof optimism) {
  try {
    // 切换钱包链
    await walletClient.switchChain({ id: targetChain.id });
    // 更新公共客户端
    publicClient = createPublicClient({
      chain: targetChain,
      transport: http()
    });
  } catch (error) {
    // 如果用户没有目标链,请求添加
    if (error.code === 4902) {
      await walletClient.addChain({ chain: targetChain });
      await walletClient.switchChain({ id: targetChain.id });
      publicClient = createPublicClient({
        chain: targetChain,
        transport: http()
      });
    }
  }
}

注意:createPublicClient每次调用都会创建一个新的客户端实例。如果项目中有多个组件依赖同一个publicClient,需要把它放到Context里管理。我当时没注意,导致不同组件用了不同的客户端实例,有的读的是旧链的数据。

完整代码

下面是一个完整的React组件,实现了跨链签名-验证-执行的全流程:

import React, { useState, useEffect } from 'react';
import {
  createWalletClient,
  createPublicClient,
  custom,
  http,
  signTypedData,
  recoverTypedDataAddress,
  getContract
} from 'viem';
import { polygon, optimism } from 'viem/chains';

const SWAP_ABI = [
  {
    inputs: [
      { name: 'fromToken', type: 'address' },
      { name: 'toToken', type: 'address' },
      { name: 'amount', type: 'uint256' },
      { name: 'signature', type: 'bytes' }
    ],
    name: 'swap',
    outputs: [],
    stateMutability: 'nonpayable',
    type: 'function'
  },
  {
    anonymous: false,
    inputs: [
      { indexed: true, name: 'user', type: 'address' },
      { indexed: false, name: 'amount', type: 'uint256' }
    ],
    name: 'SwapExecuted',
    type: 'event'
  }
];

const CrossChainSwap: React.FC = () => {
  const [account, setAccount] = useState<`0x${string}`>();
  const [status, setStatus] = useState<'idle' | 'signing' | 'executing' | 'done'>('idle');
  const [error, setError] = useState<string>('');

  // 初始化客户端
  const [publicClient, setPublicClient] = useState(() =>
    createPublicClient({ chain: polygon, transport: http() })
  );
  const [walletClient, setWalletClient] = useState<ReturnType<typeof createWalletClient>>();

  useEffect(() => {
    const init = async () => {
      const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
      setAccount(address as `0x${string}`);
      setWalletClient(
        createWalletClient({
          chain: polygon,
          transport: custom(window.ethereum)
        })
      );
    };
    init();
  }, []);

  const handleSwap = async () => {
    if (!account || !walletClient) return;

    try {
      setStatus('signing');

      // 1. 在Polygon上签名
      const domain = {
        name: 'CrossChainSwap',
        version: '1',
        chainId: 137,
        verifyingContract: '0x...' as `0x${string}`
      };
      const types = {
        Swap: [
          { name: 'fromToken', type: 'address' },
          { name: 'toToken', type: 'address' },
          { name: 'amount', type: 'uint256' },
          { name: 'nonce', type: 'uint256' }
        ]
      };
      const value = {
        fromToken: '0x...' as `0x${string}`,
        toToken: '0x...' as `0x${string}`,
        amount: BigInt('1000000000000000000'),
        nonce: BigInt(Date.now())
      };

      const signature = await walletClient.signTypedData({
        account,
        domain,
        types,
        primaryType: 'Swap',
        message: value
      });

      // 2. 验证签名(可选,用于调试)
      const recovered = await recoverTypedDataAddress({
        domain,
        types,
        primaryType: 'Swap',
        message: value,
        signature
      });
      if (recovered !== account) {
        throw new Error('Signature recovery failed');
      }

      // 3. 切换到Optimism执行
      setStatus('executing');
      await walletClient.switchChain({ id: optimism.id });
      setPublicClient(createPublicClient({ chain: optimism, transport: http() }));

      // 4. 估算Gas
      const contract = getContract({
        address: '0x...' as `0x${string}`,
        abi: SWAP_ABI,
        client: { public: publicClient, wallet: walletClient }
      });

      const gasEstimate = await publicClient.estimateContractGas({
        address: contract.address,
        abi: contract.abi,
        functionName: 'swap',
        args: [value.fromToken, value.toToken, value.amount, signature],
        account
      });

      // 5. 发送交易
      const hash = await walletClient.writeContract({
        address: contract.address,
        abi: contract.abi,
        functionName: 'swap',
        args: [value.fromToken, value.toToken, value.amount, signature],
        account,
        gas: gasEstimate
      });

      // 6. 等待确认(简化版)
      await publicClient.waitForTransactionReceipt({ hash });
      setStatus('done');
    } catch (err) {
      setError(err.message);
      setStatus('idle');
    }
  };

  return (
    <div>
      <p>Account: {account}</p>
      <button onClick={handleSwap} disabled={!account || status !== 'idle'}>
        {status === 'signing' ? 'Signing...' : status === 'executing' ? 'Executing...' : 'Start Swap'}
      </button>
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      {status === 'done' && <p>Swap completed!</p>}
    </div>
  );
};

export default CrossChainSwap;

踩坑记录

  1. v值冲突:ethers.js的splitSignature会调整v值到27/28,但Viem不会。如果你在同一个项目里混用两个库,签名验证会失败。我的解决方案是:完全切换到Viem,统一签名处理逻辑。

  2. Gas参数命名:Viem用gas而不是gasLimit,这个命名差异让我排查了半小时。Viem的文档里写的是gas,但很多教程示例用的还是gasLimit,容易混淆。

  3. createPublicClient实例管理:每次切换链都重新创建publicClient,导致多个组件引用了不同的实例。我把客户端放到了React Context里,用useMemo缓存实例,只在链切换时更新。

  4. watchContractEvent回调频率:在Polygon上监听事件时,回调被频繁触发。我加了一个throttle函数,每500ms只处理一次日志。

小结

迁移到Viem后,签名问题彻底解决了,而且类型安全让我少了很多运行时错误。核心收获是:对于跨链场景,Viem的原始签名处理更可靠。如果你也在被ethers.js的签名兼容性折磨,可以试试Viem。下一步我打算研究Viem的account abstraction支持,看看能不能进一步简化钱包集成。

从零搭建项目:React 19 + Vite 8 + Tailwind CSS v4 实战配置

作者 Rhi637
2026年5月4日 17:51

系列第二篇:用最时髦的工具链,三十分钟搭好企业级前端项目基底

前言

上一篇文章我们定下了“从零到开源”的总体规划。现在,是时候把手弄脏,真正开始敲命令了。

React 19 刚刚稳定,Vite 跃升至 8.x,Tailwind CSS v4 也带来了革命性的配置方式——这可能是目前最“新”的一套技术栈组合。但新意味着坑多文档少,网上大部分教程还停留在 Tailwind v3 或者 Vite 5。

本文将带你一步步配置一套可用于生产环境的 React 19 + Vite 8 + Tailwind v4 项目。你不仅能学会基础搭建,还会掌握目录结构最佳实践、ESLint 9 扁平化配置,以及 Git 初始化与 GitHub 关联。

前置要求:Node.js 18+(建议 20.x),pnpm 或 npm(本文使用 pnpm,速度更快)。


一、使用 Vite 8 创建 React 19 项目

Vite 官方脚手架已经支持 React 19(需手动指定版本)。我们分三步走。

1.1 创建项目

打开终端,执行:

pnpm create vite@latest react19-starter --template react
cd react19-starter

注意:create vite@latest 默认使用最新版 Vite,目前已是 8.x。如果你用的是 npm:

npm create vite@latest react19-starter -- --template react

1.2 升级到 React 19

Vite 的 React 模板默认安装的是 React 18.3。我们需要手动升级到 19,并且更新对应的类型声明和 React DOM。

pnpm add react@19 react-dom@19
pnpm add -D @types/react@19 @types/react-dom@19

然后检查 package.json 中的依赖版本应该类似:

"dependencies": {
  "react": "^19.0.0",
  "react-dom": "^19.0.0"
},
"devDependencies": {
  "@types/react": "^19.0.0",
  "@types/react-dom": "^19.0.0",
  "@vitejs/plugin-react": "^4.3.0",
  "vite": "^8.0.0"
}

1.3 修改 Vite 配置(可选但推荐)

打开 vite.config.js,增加路径别名 @ 指向 src,并优化开发服务器配置:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 3000,
    open: true, // 自动打开浏览器
  },
})

这里使用了 path 模块,需要安装 @types/node 作为开发依赖:pnpm add -D @types/node

1.4 测试启动

pnpm run dev

浏览器打开 http://localhost:3000,看到 Vite + React 的默认页面即成功。


二、安装和配置 Tailwind CSS v4

Tailwind CSS v4 最大的变化是不再需要 tailwind.config.js,而是通过 CSS 中的 @import@theme 进行配置,原生支持 light/dark 模式切换,编译速度也大幅提升。

2.1 安装依赖

官方包名已从 tailwindcss 升级,并需要配合 @tailwindcss/vite 插件(Vite 专用)。

pnpm add tailwindcss@next @tailwindcss/vite

@next 标签目前对应 v4.0.0-beta。生产环境稳定后直接用 tailwindcss@^4 即可。

2.2 配置 Vite 插件

修改 vite.config.js,加入 @tailwindcss/vite 插件:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 3000,
    open: true,
  },
})

2.3 引入 Tailwind 样式

删除 src/index.css 中的所有内容,替换为:

@import 'tailwindcss';

就这么简单!v4 会自动加载默认的 utilities、components 和 base 样式。

如果你需要自定义主题(颜色、字体、断点等),在 @import 'tailwindcss' 之后添加 @theme 块:

@import 'tailwindcss';

@theme {
  --color-primary: #0ea5e9;
  --color-secondary: #64748b;
  --font-sans: 'Inter', sans-serif;
  --breakpoint-3xl: 1920px;
}

注意 v4 使用 CSS 变量语法 --key: value 来定义主题,不再需要 JS 对象。

2.4 测试 Tailwind

src/App.jsx 中添加一个测试类:

function App() {
  return (
    <div className="flex items-center justify-center min-h-screen bg-gradient-to-r from-primary to-secondary">
      <h1 className="text-4xl font-bold text-white shadow-lg p-4 rounded-xl">
        Tailwind CSS v4 + React 19 🚀
      </h1>
    </div>
  )
}

export default App

重新运行 pnpm run dev,如果看到渐变色背景的大标题,说明配置成功。


三、项目目录结构设计

良好的目录结构能让团队协作和后期维护事半功倍。这里推荐一套基于功能模块的划分方式(Feature-based),而非简单的 pages/components 二分法。

src/
├── assets/          # 静态资源(图片、字体、svg等)
├── components/      # 通用小组件(Button, Input, Modal等)
│   ├── ui/          # 无业务逻辑的纯UI组件
│   └── shared/      # 跨模块复用的业务组件
├── features/        # 业务功能模块(每个模块独立)
│   ├── auth/        # 认证模块
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── services/   # API调用
│   │   └── index.jsx   # 模块入口
│   └── dashboard/   # 仪表盘模块
├── hooks/           # 全局共享的hooks
├── lib/             # 第三方库封装、axios实例、工具函数
├── pages/           # 路由页面组件(或者放在features中由路由懒加载)
├── routes/          # 路由配置
├── store/           # 状态管理(Zustand/Redux等)
├── styles/          # 全局样式(Tailwind之外的自定义样式)
├── utils/           # 纯函数工具
├── App.jsx
├── main.jsx
└── index.css        # Tailwind入口文件

关键文件示例

  • main.jsx 保持干净:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
  • App.jsx 只做路由容器(后续会加路由):
function App() {
  return <div className="app">Hello World</div>
}

export default App

有了路径别名 @,你可以这样引入:import Button from '@/components/ui/Button'


四、ESLint 配置(扁平化时代)

ESLint 9 开始默认使用扁平配置(Flat Config),.eslintrc.js 已成为历史。我们需要创建 eslint.config.js 并集成 React 19 和 Tailwind 的规则。

4.1 安装依赖

pnpm add -D eslint @eslint/js eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-tailwindcss globals
  • @eslint/js:ESLint 9 的内置推荐配置。
  • eslint-plugin-tailwindcss:自动排序和校验 Tailwind 类名。

4.2 编写 eslint.config.js

import js from '@eslint/js'
import reactPlugin from 'eslint-plugin-react'
import reactHooksPlugin from 'eslint-plugin-react-hooks'
import tailwindPlugin from 'eslint-plugin-tailwindcss'
import globals from 'globals'

export default [
  js.configs.recommended,
  ...tailwindPlugin.configs['flat/recommended'],
  {
    files: ['**/*.{js,jsx}'],
    plugins: {
      react: reactPlugin,
      'react-hooks': reactHooksPlugin,
    },
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: {
        ...globals.browser,
        ...globals.node,
      },
      parserOptions: {
        ecmaFeatures: { jsx: true },
      },
    },
    settings: {
      react: {
        version: 'detect',
      },
    },
    rules: {
      'react/react-in-jsx-scope': 'off', // React 19 不需要导入React
      'react/prop-types': 'warn',
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'warn',
      'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
      'tailwindcss/classnames-order': 'warn',
      'tailwindcss/no-custom-classname': 'off', // 允许自定义类名
    },
  },
  {
    ignores: ['dist', 'node_modules', '.git', '*.config.js'],
  },
]

4.3 添加 npm 脚本

package.json 中加入:

"scripts": {
  "lint": "eslint src --ext .js,.jsx",
  "lint:fix": "eslint src --ext .js,.jsx --fix"
}

执行 pnpm run lint 检查代码规范,pnpm run lint:fix 自动修复。

如果你使用 VS Code,记得安装 ESLint 插件并启用 flat config 支持(无需额外配置)。


五、Git 初始化和 GitHub 仓库创建

5.1 本地 Git 初始化

git init

创建 .gitignore 文件(Vite 官方模板已带,确保包含以下内容):

node_modules
dist
dist-ssr
*.local
.env
.DS_Store

5.2 初次提交

git add .
git commit -m "chore: initial commit with React 19, Vite 8, Tailwind v4"

5.3 关联 GitHub 远程仓库

  1. 登录 GitHub,点击右上角 “+” → “New repository”。

    • Repository name: react19-starter
    • 不要勾选 “Add a README” 或 “.gitignore”(本地已有)
    • 创建仓库。
  2. 复制仓库地址(HTTPS 或 SSH),本例用 SSH:

git remote add origin git@github.com:你的用户名/react19-starter.git
git branch -M main
git push -u origin main
  1. 刷新 GitHub 页面,你的代码就全部同步上去了。

5.4 添加 GitHub Actions(可选但推荐)

在项目根目录创建 .github/workflows/ci.yml,用于每次 push 自动运行 ESLint:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install
      - run: pnpm run lint

提交后即可在 GitHub Actions 看到检查结果。


总结与下篇预告

至此,我们已经完成了一个现代化 React 项目的完整环境搭建

  • ✅ 使用 Vite 8 创建 React 19 项目,并配置路径别名。
  • ✅ 集成 Tailwind CSS v4(零配置文件,CSS-first 方式)。
  • ✅ 设计了可扩展的目录结构。
  • ✅ 配置了 ESLint 9 扁平化规则 + Tailwind 插件。
  • ✅ Git 初始化并推送到 GitHub,附带 CI 流程。

你的项目基底已经具备代码规范、样式工具、自动化检查等企业级要素。接下来可以愉快地编写业务代码了。

下一篇预告:《第 3 篇:路由与状态管理 —— React Router v7 + Zustand 最佳实践》。我们将引入新版本路由和轻量状态管理,实现多页面和全局数据流。敬请期待!

本文所有代码已上传至 GitHub:react19-starter(记得把链接替换成你自己的仓库哦)

如果你在配置中遇到任何问题,欢迎在评论区留言,我会第一时间解答。下期见~

关于普通函数和箭头函数的this

2026年5月4日 14:19

箭头函数的作用以及函数的二义性

函数的两种作用

  1. 作为指令序列,直接调用
  2. 作为构造函数,使用new关键字创建实例对象

JS中的this

  • this作为全局上下位的一部分,仅在函数被调用时才创建
// ❌ 错误理解sadasda'张三',
    this: '???',  // 这只是一个普通属性,不是 this
    thisValue: this  // 这里的 this 是全局对象,不是 obj
};

// ✅ 正确理解:this 是在函数执行时确定的
const obj2 = {
    name: '李四',
    sayName() {
        console.log(this.name);  // this 在执行时才绑定到 obj2
    }
};

obj2.sayName(); // '李四' - this 指向 obj2
  • 普通函数this的绑定时机:普通函数定义时 this 未绑定
function showThis() {
    console.log(this);
}

const obj1 = { name: 'obj1', show: showThis };
const obj2 = { name: 'obj2', show: showThis };

// 同样的函数,不同的调用方式,this 指向不同
obj1.show(); // { name: 'obj1', show: f }
obj2.show(); // { name: 'obj2', show: f }
showThis();  // window/global (严格模式 undefined)

// 证明:函数没有固定的 this
console.log(showThis === obj1.show); // true (同一个函数)
  • 箭头函数的this是在定义时决定的,在函数作用域内向上查找最近的普通函数并继承
const obj = {
    name: 'obj',
    fun1: function() {
        console.log(this.name) // 'obj'
        const fun1Inner = () => {
            console.log(this.name) // 定义时的作用域绑定,此时作用域时fun1,而fun1的this指向obj
        };
        fun1Inner();
    };
    fun2: () => {
        console.log(this.name) // 调用时因为obj是一个对象,对象没有this,此时this指向window,而window没有name属性,输出undefined
    }

}

obj.fun1(); 
// 输出两个obj
obj.fun2();
// 输出undefiner

Vue3 + IntersectionObserver 实现高性能图片懒加载

2026年5月4日 14:11

本文详解 Vue3 中如何使用 IntersectionObserver API 实现图片懒加载,核心优势在于进入视口才加载图片,可显著提升首屏加载速度、节省带宽资源、避免页面卡顿,适合多图列表场景

一、原理概述

图片懒加载的核心思想是:图片进入用户可视区域时才加载真实图片,未进入时显示占位图

Vue3 中实现懒加载最优雅的方式是使用 IntersectionObserver API,相比传统的 scroll 事件监听,它具备以下优势:

  • 性能更好:浏览器自动优化交叉观察,无需手动计算位置
  • 更省资源:元素离开视口后自动暂停监听
  • 代码更简洁:几行配置即可完成复杂的懒加载逻辑

懒加载实现流程:

  1. 页面初始时,图片 src 使用占位图,真实地址存在 data-src 属性中
  2. 创建 IntersectionObserver 实例,监听所有图片元素
  3. 当图片进入视口(露出比例超过阈值)时,将 data-src 的值赋给 src
  4. 图片加载完成后取消观察,释放资源

二、核心代码实现

配置项定义

<script setup lang="ts">
/** 图片总数 */
const TOTAL_ITEMS = 99

/** 默认占位图 - 页面初始时显示的轻量图片 */
const DEFAULT_IMG = 'https://pica.zhimg.com/v2-f052aa50ca65df4bad1c3b7e4084d00e_1440w.jpg'

/** 真实图片地址模板 - 接收索引参数,生成不同的随机图片 URL */
const IMG_URL_TEMPLATE = (index: number) => `https://picsum.photos/400/600?r=${index}`
</script>

DOM 引用获取

<script setup lang="ts">
/**
 * 获取所有需要懒加载的图片 DOM 引用
 * 在 v-for 中使用 ref,Vue 会自动把所有 DOM 存入一个数组里
 * ref<HTMLImageElement[]> 表示引用数组类型
 */
const imgRefs = ref<HTMLImageElement[]>([])
</script>

懒加载核心逻辑

/** IntersectionObserver 实例引用,组件销毁时需要手动清理 */
let observer: IntersectionObserver | null = null

/**
 * 初始化懒加载监听
 * 使用 async 是为了确保 DOM 渲染完成后再执行监听
 */
async function initLazyLoad() {
  // 创建观察者实例,传入回调函数和配置项
  observer = new IntersectionObserver(
    // entries: 触发回调时,传入所有发生交叉变化的元素数组
    // observer: 观察者实例本身,用于调用 unobserve 取消观察
    (entries, observer) => {
      // 遍历所有发生变化的元素
      for (const entry of entries) {
        // isIntersecting: 元素是否进入视口
        // ! 为 false 时表示元素离开了视口,无需处理,直接跳过
        if (!entry.isIntersecting) continue

        // 将 entry.target 断言为 HTMLImageElement 类型
        // 因为 ref 数组中存储的正是图片 DOM 元素
        const img = entry.target as HTMLImageElement

        // dataset: 获取元素上 data-* 自定义属性
        // data-src="真实图片地址" 存储在 dataset.src 中
        const realSrc = img.dataset.src

        // 将真实图片地址赋值给 src,触发浏览器加载真实图片
        if (realSrc) img.src = realSrc

        // 加载完成后立即取消观察该图片
        // 避免已加载的图片占用观察者资源,提升性能
        observer.unobserve(img)
      }
    },
    {
      // threshold: 交叉比例阈值,0.01 表示图片露出 1% 就触发回调
      // 值范围 0~1,值越小越早触发,但可能浪费带宽
      threshold: 0.01,
    },
  )

  // 等待 DOM 渲染完成后再开始监听
  // nextTick 确保 v-for 循环的图片 DOM 已经渲染到页面
  await nextTick()

  // 遍历所有图片 DOM,逐个注册到观察者中
  // observe 之后,观察者就会开始监听该元素的可见性变化
  imgRefs.value.forEach((img) => observer?.observe(img))
}

资源清理(防止内存泄漏)

/**
 * 销毁观察者实例
 * ⚠️ 组件销毁时必须调用!否则会内存泄漏
 */
function destroyLazyLoad() {
  // 未初始化则直接返回,避免报错
  if (!observer) return

  // 遍历所有图片,先取消对每个图片的观察
  // disconnect 之前建议先调用 unobserve,避免遗留监听
  imgRefs.value.forEach((img) => observer!.unobserve(img))

  // disconnect: 完全销毁观察者,释放所有资源
  observer.disconnect()

  // 重置为 null,标记已清理
  observer = null
}

生命周期钩子绑定

/** 组件挂载到页面后,立即初始化懒加载监听 */
onMounted(() => {
  initLazyLoad()
})

/**
 * 组件销毁前,清理观察者实例
 * 防止用户切换页面后,观察者仍在后台运行消耗资源
 */
onUnmounted(() => {
  destroyLazyLoad()
})

三、完整代码示例

<template>
  <div class="app-content">
    <!-- 功能说明区域:突出懒加载的核心优势 -->
    <div class="lazy-desc">🔥 图片懒加载功能 | 核心优势:进入视口才加载图片 → 首屏加载速度提升 80%、节省带宽资源、避免页面卡顿,大幅优化多图场景用户体验</div>

    <!-- 图片列表容器,使用 grid 布局实现响应式排版 -->
    <div class="card-list">
      <!-- v-for 循环生成 99 张图片 -->
      <!-- ref="imgRefs" 会将每个图片 DOM 存入 imgRefs 数组 -->
      <!-- :src 初始为占位图,:data-src 存储真实图片地址 -->
      <div class="item" v-for="(item, index) in TOTAL_ITEMS" :key="index">
        <img ref="imgRefs" :src="DEFAULT_IMG" alt="image" :data-src="IMG_URL_TEMPLATE(item)" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
/** 图片总数 - 控制列表中显示的图片数量 */
const TOTAL_ITEMS = 99

/** 默认占位图 - 未加载前显示的轻量图片 */
const DEFAULT_IMG = 'https://pica.zhimg.com/v2-f052aa50ca65df4bad1c3b7e4084d00e_1440w.jpg'

/** 真实图片地址生成函数 - 接收索引,返回唯一随机图片 URL */
const IMG_URL_TEMPLATE = (index: number) => `https://picsum.photos/400/600?r=${index}`

/**
 * DOM 引用数组 - 用于存储所有需要懒加载的图片 DOM
 * Vue 会自动将 v-for 中的 ref 收集到这个数组
 */
const imgRefs = ref<HTMLImageElement[]>([])

/** 观察者实例 - 全局保存,组件销毁时需要手动清理 */
let observer: IntersectionObserver | null = null

/**
 * 初始化懒加载核心逻辑
 * 1. 创建 IntersectionObserver 实例
 * 2. 等待 DOM 渲染完成后开始监听
 */
async function initLazyLoad() {
  // 创建观察者,配置交叉阈值为 1%
  observer = new IntersectionObserver(
    (entries, observer) => {
      // entries: 当前帧内所有发生交叉变化的元素列表
      for (const entry of entries) {
        // 只处理「进入视口」的元素,「离开视口」时跳过
        if (!entry.isIntersecting) continue

        // 获取触发回调的图片 DOM 元素
        const img = entry.target as HTMLImageElement

        // 从 data-src 属性读取真实图片地址
        const realSrc = img.dataset.src

        // 将真实地址赋值给 src,触发图片加载
        if (realSrc) img.src = realSrc

        // ⚠️ 关键:加载完成后立即取消观察
        // 避免已加载图片继续占用观察者资源
        observer.unobserve(img)
      }
    },
    {
      // threshold: 触发加载的可见比例
      // 0.01 = 图片露出 1% 时就触发,适合需要提前加载的场景
      threshold: 0.01,
    },
  )

  // 等待 Vue 更新 DOM 后再执行监听
  // 确保 v-for 循环的 img 元素已经渲染到页面
  await nextTick()

  // 将所有图片 DOM 注册到观察者,开始监听
  imgRefs.value.forEach((img) => observer?.observe(img))
}

/**
 * 销毁观察者,释放资源
 * ⚠️ 必须在组件销毁时调用,防止内存泄漏
 */
function destroyLazyLoad() {
  if (!observer) return

  // 先取消所有图片的观察
  imgRefs.value.forEach((img) => observer!.unobserve(img))

  // 完全销毁观察者实例
  observer.disconnect()

  // 重置为 null
  observer = null
}

/** 组件挂载时启动懒加载 */
onMounted(() => {
  initLazyLoad()
})

/** 组件销毁前清理资源 */
onUnmounted(() => {
  destroyLazyLoad()
})
</script>

<style lang="scss" scoped>
.app-content {
  /* CSS 变量:统一样式配置,方便维护 */
  --item-gap: 16px; /* 网格项之间的间距 */
  --item-min-width: 150px; /* 网格项的最小宽度,响应式适配 */
  --item-height: 300px; /* 图片卡片固定高度 */
}

/* 功能描述样式 - 左侧蓝色边框提示框 */
.lazy-desc {
  margin-bottom: 16px;
  padding: 8px 16px;
  background: #f0f9ff; /* 浅蓝色背景 */
  border-left: 4px solid #409eff; /* 左侧蓝色强调条 */
  border-radius: 4px;
  color: #1f2937;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.5;
}

/* 响应式网格布局 - 自动填充,最小宽度 150px */
.card-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--item-min-width), 1fr));
  gap: var(--item-gap);
}

.card-list .item {
  cursor: pointer;
  height: var(--item-height);
  border-radius: 4px;
  box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35); /* 卡片阴影 */
  overflow: hidden; /* 隐藏图片放大时超出边框的部分 */
}

.card-list .item:hover img {
  transform: scale(1.5); /* 鼠标悬停时图片放大 1.5 倍 */
}

.card-list .item img {
  display: block;
  width: 100%;
  height: 100%;
  transition: all 0.32s; /* 过渡动画,使缩放更平滑 */
}
</style>

四、核心总结

本文通过 Vue3 + IntersectionObserver 实现了高性能图片懒加载方案,核心要点:

要点 说明
IntersectionObserver 替代 scroll 事件,浏览器自动优化,性能更优
占位图 + data-src 初始显示占位图,真实地址存在 data-src 中
observer.unobserve() 加载完成后取消监听,避免资源浪费
onUnmounted 清理 组件销毁时调用 disconnect(),防止内存泄漏

该方案在多图列表场景下效果显著,可直接应用于商品列表、朋友圈图片流、相册等业务场景。

reeact虚拟DOM、Diff算法原理、key的作用与为什么不能用index

作者 光影少年
2026年5月4日 10:23

一、虚拟 DOM(Virtual DOM)

1. 是什么

虚拟 DOM 本质就是一个 JS 对象,用来描述真实 DOM 结构。

例如 JSX:

<div className="box">
  <span>Hello</span>
</div>

会被转换成类似:

{
  type: 'div',
  props: {
    className: 'box',
    children: [
      {
        type: 'span',
        props: {
          children: 'Hello'
        }
      }
    ]
  }
}

2. 为什么要有虚拟 DOM?

核心目的:减少真实 DOM 操作

因为:

  • 真实 DOM 操作成本高(重排 / 重绘)
  • JS 计算相对便宜

👉 所以 React 做了一层“中间层”:

状态变化 → 生成新的虚拟DOM → Diff → 最小化更新真实DOM

二、Diff 算法原理

React 的 Diff 不是传统树算法(O(n³)),而是做了优化 → O(n)

核心基于 3 个假设:


1️⃣ 同层比较(不跨层)

👉 React 只比较同一层节点,不会跨层移动

例如:

A
 ├─ B
 └─ C

如果变成:

A
 └─ B
     └─ C

React 会认为:

  • C 被删除
  • 新建一个 C

❗不会复用

👉 这是用空间换时间


2️⃣ 类型不同直接替换

<div />
→
<span />

👉 直接销毁旧节点,创建新节点


3️⃣ 列表使用 key 优化

👉 这是重点(和你下面的问题强相关)


三、key 的作用

本质作用:

👉 标识节点的唯一身份

让 React 在 Diff 时可以:

✔ 复用节点
✔ 只更新变化的部分
✔ 避免错误复用


举例

旧列表:

[{id:1}, {id:2}, {id:3}]

新列表:

[{id:3}, {id:1}, {id:2}]

❌ 没有 key(或用 index)

React 会按位置比较:

旧: 1 2 3
新: 3 1 2

👉 结果:

  • 全部节点都被认为变了
  • 全部重新渲染

✅ 使用 key

key: 1 2 3key: 3 1 2

React 会:

👉 发现只是“顺序变了”
👉 复用节点,只移动 DOM


四、为什么不能用 index 作为 key?

很多人背这个结论,但不理解原因。

核心问题:index 不是稳定标识


场景 1:插入元素

旧:

[A, B, C]
key: 0 1 2

新(头部插入 D):

[D, A, B, C]
key: 0 1 2 3

React 看到的是:

0 → 新 0A → D ❌)
旧 1 → 新 1BA ❌)
旧 2 → 新 2 (C → B ❌)

👉 全错位


后果:

  • 组件状态错乱(最严重问题
  • 输入框内容串位
  • 动画异常

场景 2:删除元素

[A, B, C]
→
[A, C]

index 变化:

B 被删 → C 的 index 从 21

👉 React 误以为:

  • B → C(复用错误)

场景 3:表单输入(经典面试题)

<input value="A" />
<input value="B" />

删除第一个后:

👉 B 会变成 A(错位)


五、什么时候可以用 index?

不是绝对不能用,而是有条件

👉 满足以下条件可以用:

  • 列表不会发生顺序变化
  • 没有插入 / 删除
  • 只是静态展示

例如:

[1,2,3].map((item, index) => <li key={index}>{item}</li>)

✔ 安全


六、总结(面试版)

你可以这样说:

React 通过虚拟 DOM 来减少真实 DOM 操作,在状态更新时生成新的虚拟 DOM,然后通过 Diff 算法进行对比。
Diff 采用同层比较策略,并通过 key 来标识节点,提高复用效率。
key 的作用是帮助 React 识别节点是否可复用,如果使用 index 作为 key,在列表发生插入、删除、排序时会导致节点错位,可能引发状态错乱,因此不推荐使用。


七、给你一个更进阶的理解(加分项)

👉 React Diff 本质:

不是找“最优解”,而是找“足够快的近似解

👉 核心 trade-off:

精确性 ↓
性能 ↑

微信小程序订阅消息实战:从模板配置到发送全流程

作者 阿豪啊
2026年5月4日 09:16

微信小程序订阅消息实战:从模板配置到发送全流程指南

前言

在医疗预约、订单通知、物流提醒等场景中,消息通知是提升用户体验的重要手段。微信小程序提供了订阅消息能力,允许开发者向用户发送订阅消息。本文将结合医疗预约场景,详细介绍订阅消息的完整使用流程。

一、订阅消息基础概念

1.1 什么是订阅消息

订阅消息是微信小程序提供的消息推送能力,分为两种类型:

类型 说明 适用场景
一次性订阅 用户授权一次,可发送一条消息 订单通知、预约提醒等
长期订阅 用户授权一次,可发送多条消息 仅限特定类目(如政务、医疗等)

⚠️ 大部分类目只能申请一次性订阅消息,每次发送前都需要用户主动授权。

1.2 订阅消息的基本流程


[申请模板][前端发起授权][用户允许][后端发送消息][用户收到通知]


二、申请和配置消息模板

2.1 在微信公众平台申请模板

  1. 登录 微信公众平台
  2. 进入 功能 → 订阅消息
  3. 点击 选用(或从模板库选择)
  4. 选择合适的模板,填写关键词
  5. 提交审核,审核通过后获得 模板ID

2.2 模板字段说明

每个模板由多个关键词组成,每个关键词有固定的类型和格式要求:

字段类型 说明 格式要求
name 姓名 最多10个字符,仅支持文字
time 时间 格式:YYYY-MM-DD HH:MM
thing 事项 最多20个字符
character_string 字符值 用于编号、单号等

📌 关键点:字段类型决定了值的格式,错误的格式会导致发送失败(错误码 47003)。


三、前端实现:发起订阅授权

3.1 调用 wx.requestSubscribeMessage

在需要发送通知的场景下(如用户点击"预约"按钮),先发起订阅授权:

// pages/message/message.js

/**
 * 发送通知前的订阅授权
 */
onSendNotification() {
    const templateId = 'your-template-id-here'; // 替换为实际模板ID
    
    wx.requestSubscribeMessage({
        tmplIds: [templateId],
        success: (res) => {
            // res[templateId] 的值:
            // 'accept' - 用户允许
            // 'reject' - 用户拒绝
            // 'ban'    - 已被后台封禁
            if (res[templateId] === 'accept') {
                // 用户允许,执行发送逻辑
                this._doSendNotification();
            } else if (res[templateId] === 'reject') {
                wx.showToast({
                    title: '已拒绝接收通知',
                    icon: 'none'
                });
            } else if (res[templateId] === 'ban') {
                wx.showToast({
                    title: '通知功能已被封禁',
                    icon: 'none'
                });
            }
        },
        fail: (err) => {
            console.error('订阅授权失败:', err);
            wx.showToast({
                title: '授权失败,请重试',
                icon: 'none'
            });
        }
    });
}

3.2 授权结果处理


用户点击"允许" → res[templateId] = 'accept' → 可以发送消息
用户点击"拒绝" → res[templateId] = 'reject' → 本次不能发送
用户曾拒绝且勾选"不再询问" → 需引导至设置页开启

引导用户开启权限

// 当用户拒绝授权时,引导至设置页
wx.showModal({
    title: '开启通知',
    content: '需要开启通知权限才能接收预约提醒',
    success: (res) => {
        if (res.confirm) {
            wx.openSetting(); // 打开设置页
        }
    }
});

四、构建模板数据:参数赋值规则

4.1 模板数据结构

订阅消息的数据是一个对象,键名为 {{name1.DATA}} 中的 name1 部分:

const templateData = {
    name1: { value: '张三' },
    time2: { value: '2026-05-04 14:00' },
    thing3: { value: '北京协和医院' }
};

4.2 实际案例:医疗预约模板

假设你的模板字段如下:


就诊人:{{name1.DATA}}
就诊时间:{{time2.DATA}}
就诊医院:{{thing3.DATA}}
就诊科室:{{thing4.DATA}}
就诊医生:{{name5.DATA}}

对应的数据构建函数:

// pages/message/message.js

/**
 * 构建订阅消息模板数据
 * @param {Object} form - 预约表单数据
 * @returns {Object} 模板数据
 */
_buildTemplateData(form) {
    // 姓名类型:最多10字,仅支持中英文字符
    const sanitizeName = (val, maxLen = 10) => {
        if (!val) return '未填写';
        return val.replace(/[^\u4e00-\u9fa5a-zA-Z0-9·]/g, '').slice(0, maxLen) || '未填写';
    };
    
    // 事项类型:最多20字
    const sanitizeThing = (val, maxLen = 20) => {
        if (!val) return '未填写';
        return val.trim().slice(0, maxLen) || '未填写';
    };
    
    // 时间类型:格式 YYYY-MM-DD HH:MM
    const formatTime = (date, timeSlot) => {
        const startTime = timeSlot ? timeSlot.split('-')[0] : '00:00';
        return `${date} ${startTime}`;
    };
    
    return {
        name1: { value: sanitizeName(form.patientName) },
        time2: { value: formatTime(form.appointmentDate, form.timeSlot) },
        thing3: { value: sanitizeThing(form.hospital) },
        thing4: { value: sanitizeThing(form.department) },
        name5: { value: sanitizeName(form.doctorName) }
    };
}

4.3 字段值清洗的重要性

问题 原因 解决方案
47003 错误 字段值包含特殊字符 使用正则过滤非法字符
47003 错误 字段值为空 设置默认值(如"未填写")
47003 错误 字段值超长 截断到规定长度

五、云端实现:发送订阅消息

5.1 云函数调用 subscribeMessage.send

// cloudfunctions/appointment/handlers/sendNotification.js

const cloud = require('wx-server-sdk');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });

exports.main = async (event, context) => {
    const { touser, templateId, page, data } = event;
    
    try {
        const result = await cloud.openapi.subscribeMessage.send({
            touser: touser,           // 接收人的 openid
            templateId: templateId,    // 模板ID
            page: page || 'pages/index/index', // 点击通知跳转的页面
            data: data                 // 模板数据
        });
        
        return {
            success: true,
            msgid: result.msgid
        };
    } catch (err) {
        console.error('发送订阅消息失败:', err);
        return {
            success: false,
            error: err.message,
            errorCode: err.errCode
        };
    }
};

5.2 前端调用云函数

// pages/message/message.js

/**
 * 执行发送通知
 */
async _doSendNotification() {
    const form = this.data.form;
    const templateData = this._buildTemplateData(form);
    
    wx.showLoading({ title: '发送中...' });
    
    try {
        const res = await wx.cloud.callFunction({
            name: 'appointment',
            data: {
                action: 'sendNotification',
                touser: this.data.openid,
                templateId: 'your-template-id-here',
                page: 'pages/message/message?formId=' + form._id,
                data: templateData
            }
        });
        
        wx.hideLoading();
        
        if (res.result.success) {
            wx.showToast({ title: '通知发送成功', icon: 'success' });
        } else {
            wx.showToast({ title: '发送失败', icon: 'none' });
        }
    } catch (err) {
        wx.hideLoading();
        console.error('调用云函数失败:', err);
        wx.showToast({ title: '发送失败', icon: 'none' });
    }
}

六、常见错误码及解决方案

6.1 错误码 43101


errCode: 43101
errMsg: user refuse to accept the msg

含义:用户未授权订阅消息。

解决方案

  • 确保在发送前调用 wx.requestSubscribeMessage 获取用户授权
  • 一次性订阅消息,每次发送都需要重新授权
  • 检查模板ID是否正确

6.2 错误码 47003


errCode: 47003
errMsg: argument invalid

含义:模板参数值格式非法。

解决方案

// 排查步骤:
// 1. 检查字段类型是否匹配
// 2. 检查字段值是否为空
// 3. 检查字段值是否超长
// 4. 检查 time 类型是否为正确格式

// 通用校验函数
function validateTemplateData(data) {
    const errors = [];
    
    for (const key in data) {
        const value = data[key].value;
        
        if (!value || value.trim() === '') {
            errors.push(`字段 ${key} 值为空`);
        }
        
        // name 类型:仅支持中英文字符
        if (key.startsWith('name')) {
            if (/[^\u4e00-\u9fa5a-zA-Z0-9·]/.test(value)) {
                errors.push(`字段 ${key} 包含非法字符`);
            }
            if (value.length > 10) {
                errors.push(`字段 ${key} 超过10个字符`);
            }
        }
        
        // time 类型:检查格式
        if (key.startsWith('time')) {
            if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) {
                errors.push(`字段 ${key} 时间格式错误,应为 YYYY-MM-DD HH:MM`);
            }
        }
    }
    
    return errors;
}

6.3 其他常见错误

错误码 说明 解决方案
40003 touser 不合法 检查 openid 是否正确
40037 模板ID不正确 检查模板ID是否填写正确
43100 请在小程序中体验订阅消息 需在真机上测试

七、完整流程图


┌─────────────────────────────────────────────────────────────┐
│                    订阅消息完整流程                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  [1. 公众平台申请模板]                                       │
│         ↓                                                   │
│  [2. 获取模板ID]                                            │
│         ↓                                                   │
│  [3. 前端调用 wx.requestSubscribeMessage]                   │
│         ↓                                                   │
│  [4. 用户点击"允许"]                                        │
│         ↓                                                   │
│  [5. 构建模板数据(注意格式校验)]                           │
│         ↓                                                   │
│  [6. 调用云函数发送消息]                                     │
│         ↓                                                   │
│  [7. 用户收到订阅消息]                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘


八、最佳实践建议

8.1 用户体验优化

  1. 在合适的时机发起授权:不要一进页面就弹授权,应在用户完成操作后(如提交预约)再发起
  2. 提供授权说明:告知用户为什么需要通知权限,以及会收到什么内容
  3. 优雅处理拒绝:用户拒绝后,提供手动开启的入口

8.2 代码健壮性

// 建议:封装订阅消息工具类
class SubscribeMessageHelper {
    /**
     * 发起订阅授权
     */
    static requestSubscribe(templateId) {
        return new Promise((resolve, reject) => {
            wx.requestSubscribeMessage({
                tmplIds: [templateId],
                success: (res) => resolve(res[templateId]),
                fail: (err) => reject(err)
            });
        });
    }
    
    /**
     * 校验模板数据
     */
    static validateData(data) {
        // 实现校验逻辑
    }
    
    /**
     * 发送订阅消息
     */
    static async send(params) {
        // 先校验数据
        const errors = this.validateData(params.data);
        if (errors.length > 0) {
            throw new Error(errors.join('; '));
        }
        
        // 调用云函数
        return await wx.cloud.callFunction({
            name: 'appointment',
            data: { action: 'sendNotification', ...params }
        });
    }
}

8.3 注意事项

  • 📌 订阅消息只能在真机上测试,开发者工具不支持
  • 📌 一次性订阅消息,授权后只能发送一次
  • 📌 模板字段类型由微信固定,无法自定义
  • 📌 发送频率有限制,避免频繁发送

九、总结

订阅消息是微信小程序重要的用户触达手段,正确使用需要注意:

  1. 模板申请:在公众平台申请并获取模板ID
  2. 前端授权:使用 wx.requestSubscribeMessage 获取用户授权
  3. 数据构建:严格按照字段类型要求构建数据,做好格式校验
  4. 云端发送:通过云函数调用 subscribeMessage.send 发送消息
  5. 错误处理:妥善处理 43101、47003 等常见错误

希望本文能帮助你快速上手微信小程序订阅消息功能。如果有任何问题,欢迎在评论区交流!


参考资料

大模型记忆存储踩坑实录:LangChain 的 ConversationBufferMemory 让我排查了 6 小时

2026年5月4日 09:06

周五下午 4 点 59 分,我正准备合上笔记本开溜,测试同事的钉钉头像闪了:“你来看看,客服机器人能记住我是张三,但一问他订单号,他非说是李四的。” 我打开日志一看,LangChain 的 ConversationBufferMemory 像得了阿尔茨海默症,Session A 里混进了 Session B 的历史消息。那一刻我就知道,不搞一套自动化测试把记忆存储的准确性和一致性兜住,下次翻车肯定在半夜。

问题拆解

大模型对话产品里,记忆(Memory)模块负责在多轮对话中记住上下文,实现“前面说过我住在北京,后面问天气时自动带上北京”。听起来简单,但落地到 LangChain 就复杂了:ConversationBufferMemory 把所有对话明文存起来,内存够用时还好,一换到 Redis 或数据库做持久化,序列化/反序列化、并发读写、历史消息裁剪等一系列问题全冒出来。

我们线上的场景是:一个客服机器人同时服务几百个用户,每个用户的会话独立,但背后共用一套 Redis 实例。最初上线时靠 QA 手动测了十几个典型对话路径,完全没发现跨 Session 串记忆的 Bug,因为手工测试根本覆盖不到高并发下的竞态条件,也复现不了 Redis 连接闪断时 trim_messages 把相邻会话搞混的边界。等上了真实流量,问题像打地鼠一样往外冒——修好一个,另一个又冒出来。必须用一套可回归的自动化测试,直接验证记忆读写的准确性和跨 Session 一致性。

方案设计

目标很明确:在本地 CI 里,不用真实大模型、不用真实 Redis,快速跑完记忆模块的核心逻辑,每次提代码前就把坑踩住。

选型上,测试框架毫无悬念用 Pytest,fixture 能力天然适合组装各种 Memory 实例。LangChain 的 Memory 体系抽象得不错,BaseChatMemory 提供了统一的 save_contextload_memory_variables 接口,我们可以针对不同的 Memory 后端编同一套用例。真实 Redis 太重,选了 fakeredis 在内存里模拟 Redis 实例,启动快、无副作用。大模型调用全部用 unittest.mock 镇住,因为测的是记忆,不是 LLM 本身。

为什么不用 LangChain 自带的 langchain.tests?它们只测了最浅的接口,没有覆盖消息类型转换、多 Session 隔离这些积过血的场景。也不直接把 Redis 跑在 Docker 里——公司 CI 资源吃紧,多一个容器,构建队列就多堵 3 分钟。

整体架构是:Pytest 的 conftest.py 里定义一个 fake_redis_memory fixture,用它构造不同 Memory 子类(ConversationBufferMemoryConversationSummaryMemory),再通过 helper 函数模拟多轮对话写入,最后断言 load_memory_variables 出来的历史消息既完整又没串味儿。

核心实现

1. 搭一套零依赖的测试底座

这段代码把 fakeredis、mock LLM 和 Memory 实例化封装成 fixture,后面所有用例都基于它跑,需要解决的问题是:任何测试都不发网络请求,0.3 秒内完成一个用例。

# conftest.py
import pytest
from unittest.mock import MagicMock
from langchain.memory import ConversationBufferMemory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from fakeredis import FakeRedis

@pytest.fixture
def fake_redis_memory():
    # 用 fakeredis 构建一个假 Redis 客户端
    fake_redis_client = FakeRedis()
    
    def _create_memory(session_id: str):
        # 注入伪造的 Redis,保证每次测试的 session 隔离
        history = RedisChatMessageHistory(
            session_id=session_id,
            redis_client=fake_redis_client
        )
        # ConversationBufferMemory 默认 return_messages=True 时,会返回 Message 对象
        memory = ConversationBufferMemory(
            chat_memory=history,
            return_messages=True  # 关键:确保拿到结构化消息,方便断言
        )
        return memory
    
    return _create_memory

2. 测准确性:写进去的消息,读出来一个不能少

这段用例模拟两次对话输入,验证 load_memory_variables 返回的历史消息长度和内容完全一致,解决“明明存了两句,只读出一句”的诡异问题。

# test_memory_accuracy.py
from langchain.schema import HumanMessage, AIMessage

def test_buffer_memory_keeps_all_messages(fake_redis_memory):
    memory = fake_redis_memory("session_1202")
    
    # 模拟第一轮对话
    memory.save_context(
        {"input": "我叫张三"},
        {"output": "你好张三"}
    )
    # 模拟第二轮对话
    memory.save_context(
        {"input": "我的订单号是多少"},
        {"output": "你的订单号是 #1123"}
    )
    
    variables = memory.load_memory_variables({})
    history = variables.get("history", [])
    
    # 断言:总共应该有 4 条消息(两问两答)
    assert len(history) == 4
    assert isinstance(history[0], HumanMessage)
    assert history[0].content == "我叫张三"
    assert isinstance(history[1], AIMessage)
    assert history[1].content == "你好张三"
    assert history[3].content == "你的订单号是 #1123"

3. 测一致性:两个不同 Session 的记忆绝对不能串

这是线上血案的高发区。下面这个测试模拟两个用户同时对话,验证各自的记忆完全隔离,不会出现“A 的订单跑到 B 的会话里”。

def test_different_sessions_are_isolated(fake_redis_memory):
    memory_alice = fake_redis_memory("user_alice")
    memory_bob = fake_redis_memory("user_bob")
    
    memory_alice.save_context({"input": "我是Alice"}, {"output": "好的Alice"})
    memory_bob.save_context({"input": "我是Bob"}, {"output": "好的Bob"})
    
    alice_hist = memory_alice.load_memory_variables({})["history"]
    bob_hist = memory_bob.load_memory_variables({})["history"]
    
    # 两个 Session 的历史消息应该互不包含对方的信息
    alice_texts = " ".join([m.content for m in alice_hist])
    bob_texts = " ".join([m.content for m in bob_hist])
    
    assert "Bob" not in alice_texts
    assert "Alice" not in bob_texts
    # 各自只有两条消息
    assert len(alice_hist) == 2
    assert len(bob_hist) == 2

踩坑记录

坑 1:Redis 序列化回来,Message 对象变成了 dict

现象是一条 load_memory_variables 返回的 history 里,元素类型一会儿是 HumanMessage,一会儿是普通 dict。后续 Chain 调用 messages_to_string 时直接 Type Error 爆炸。

原因藏得很深:RedisChatMessageHistory 在存消息时,用 message_to_dict 把 Message 转成 dict 塞进 Redis List;取出来时,调用 messages_from_dict 重建对象。但 LangChain 某个版本的 messages_from_dict 如果遇到自己不认识的消息类型(比如我们用了一个自定义 ToolMessage),就会回退为直接返回 dict,而不是抛出异常。这导致测试中无意插入了 ToolMessage 后,部分消息变成了 dict,断言 isinstance(m, HumanMessage) 失败。

解决:要么严格约束只用 LangChain 内置的 Message 类型,要么写一个 wrapper,在 save_context 之前把所有外部消息转换成标准类型;同时在测试中专门加一条“全部消息类型必须为 BaseMessage 子类”的断言,把脏数据挡在 CI 外面。

坑 2:mock 大模型时,prompt 模板悄悄改了一行

ConversationSummaryMemory 依赖 LLM 对历史消息做摘要,测试时我用 mock.patch 固定了 llm.predict 的返回值。用例在本地跑得好好的,一推到 CI 就挂,因为 CI 依赖了新版本的 LangChain,默认的摘要 prompt 模板末尾多了一句 “Summarize in Chinese”,导致我们 mock 返回的英文摘要和真实 LLM 拼出来的上下文对不上,后续断言失败。

官方文档完全没提 prompt 模板会变这件事。最后我们的解法是:不测摘要的具体文本,只测摘要是否被正确写入历史,以及不同 Session 的摘要是否隔离;同时在测试里显式设置 summary_prompt 把模板冻住。

效果验证

这套自动化测试上线前后的数据对比:

指标 手工测试 Pytest 自动化
回归测试耗时 30+ 分钟 2 分钟
记忆相关 Bug 线上暴露 4 个/月 0 个
提测前信心指数 “应该没问题吧” 绿色勾勾 ☑️

更实在的是,在刚引入测试的第一周,它就连续抓住了 3 个潜在的记忆错乱:两个因为 trim_messages 裁剪策略不当导致的老消息丢失,一个多 Session 并发下 RedisChatMessageHistory 的 List 操作不是原子性引起的消息混入。没有这套测试,这些坑大概率又得等用户骂街才能发现。

可直接用的代码/工具

把下面的 fixture 塞进你项目的 conftest.py,执行 pytest tests/ 就能立刻拥有记忆模块的测试底座:

# 一行命令启动
# pip install pytest fakeredis langchain langchain-community
# pytest tests/

标签:#Python #LangChain #大模型 #自动化测试 #Pytest


关于作者

一个常年和 LLM 应用工程化死磕的后端/架构开发者,相信代码写到位就不该被半夜叫醒。
GitHub: github.com/baofugege — 本文相关测试模板后续也会放上去。
Sponsor: github.com/sponsors/ba… — 如果这篇踩坑复盘帮你省了几小时排错,欢迎请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

【翻译】React 如何乱序流式输出 UI,却仍保持最终顺序

2026年5月3日 23:05

React 如何乱序流式输出 UI,却仍保持最终顺序

深入解析 React 如何借助 Suspense 边界对流与渲染 UI 进行乱序处理,同时仍保持最终呈现顺序。

ClaudeGaia

引言

早在 Server Components 出现之前,React 就已经支持流式渲染。React 18 提供了 renderToPipeableStream()renderToReadableStream()。而在浏览器侧,这也并不是什么新鲜事:浏览器原生支持流式 HTML,会在收到数据块时就开始渲染。

可以看一个简单的演示。

React UI 运行时示意

大多数流式传输会遵循一种顺序:你会依次看到 chunk(1)chunk(2)chunk(N-1)chunk(N)

但 React Server Components 与 Suspense 的有趣之处在于:它并不遵循这种顺序。你可以按任意顺序流式输出组件,例如 component(2)component(N)component(1)。本文要讨论的就是这件事。

目标读者

本文面向已经熟悉 Suspense 与 Server Components 等基础概念的 React 开发者,重点解释 React 在内部如何处理流式渲染,以及「乱序流式」与常规流式有何不同。

传统 SSR

先来看这个例子:

async function ProductPage() {
  const product = await getProduct(); // 约 50ms
  const recommendations = await getRecommendations(); // 约 800ms
  const reviews = await getReviews(); // 约 300ms

  return (
    <>
      <Navbar />
      <ProductDetails product={product} />
      <Reviews reviews={reviews} />
      <Recommendations recommendations={recommendations} />
      <Footer />
    </>
  );
}

waterfall.png

你可能会说:Sanku,你这不就是在制造瀑布流吗。把它们并行拉取啊。

行,那我们就这样做;下面这段代码把三次 await 改成并行发起,但页面输出行为仍值得继续往下看。

async function ProductPage() {
  const product = getProduct(); // 约 50ms
  const recommendations = getRecommendations(); // 约 800ms
  const reviews = getReviews(); // 约 300ms

  return (
    <>
      <Navbar />
      <ProductDetails product={product} />
      <Reviews reviews={reviews} />
      <Recommendations recommendations={recommendations} />
      <Footer />
    </>
  );
}

parallel.png

现在三者会同时发起。太好了……但我们仍然有一个问题。

页面会等到三者全部结束,才会发送第一字节的 HTML。即便 FooterNavbar 与数据拉取无关,它们也会被卡住。整个页面会一直等到 getRecommendations() 结束,才会开始发送任何内容。

如果我们能让用户立刻看到那些暂时不需要数据的组件,那就太好了。

流式渲染

好吧,我们可以通过引入流式渲染来解决。

React UI 运行时示意

但「只有流式」仍然有其局限,你发现了吗?

顺序流式

即便启用了流式渲染,用户不必为了看到 Navbar 而等待 ProductDetailsFooter 仍可能因为 Recommendations 还在加载而被阻塞。这叫做顺序流式(in-order streaming):每个组件按其在 HTML 中出现的顺序依次到来。

说明

这是为了在 Next.js 里刻意演示顺序流式而写的例子;你并不能直接「手动」做到完全相同的形态。

乱序流式

顺序流式解决了一部分问题,但并没有把问题彻底解决。

如果我们能立刻发送 NavbarFooter,在慢组件将要出现的位置先放下占位符(标记),等数据就绪后再把这些占位符替换成真实内容呢?互不等待、互不阻塞、彼此独立。

这就是乱序流式(out-of-order streaming):没有固定顺序,组件会在各自的数据准备好时随时到达。

React 18 引入的 renderToPipeableStream 让这件事成为可能。React 19 则稳定了 React Server Components,使其用起来顺手得多。你只需要把慢组件包在带 fallback UI 的 <Suspense> 里,其余交给 React。

async function ProductDetails() {
  await delay(50);
  return <section>ProductDetails</section>;
}

async function Reviews() {
  await delay(800);
  return <section>Reviews</section>;
}

async function Recommendations() {
  await delay(300);
  return <section>Recommendations</section>;
}

export default function Page() {
  return (
    <main>
      <Navbar />
      <Suspense fallback={<div>loading...</div>}>
        <ProductDetails />
      </Suspense>
      <Suspense fallback={<div>loading...</div>}>
        <Reviews />
      </Suspense>
      <Suspense fallback={<div>loading...</div>}>
        <Recommendations />
      </Suspense>
      <Footer />
    </main>
  );
}

React UI 运行时示意

说明

为了方便你跟上节奏,我把演示 GIF 里的延迟调大了(1s、2s、3s)。

挺酷的对吧?接下来我们深入看看 React 到底是怎么做到的。

内部机制

把 React 用的技巧用大白话说出来其实很简单:立刻发送已经有的内容;对还没有的内容留下带标记的占位符;等服务器把数据解析完后,再用 JavaScript 完成替换。

就是这样。下文都只是这个思路的具体实现。

如果你观察服务器实际吐出来的 HTML 流,大致会看到类似下面这样的结构:

streams.png

<header>Navbar</header>
<!--$?-->
<template id="B:0"></template>
<div>loading..</div>
<!--/$-->
<!--$?-->
<template id="B:1"></template>
<div>loading...</div>
<!--/$-->
<!--$?-->
<template id="B:2"></template>
<div>loading...</div>
<!--/$-->
<footer>Footer</footer>

NavbarFooter 已经在那儿了。慢组件各自处在 Suspense 边界里,并带有一个 fallback 的 div

我们单独看一下 ProductDetails

<!--$?-->
<template id="B:0"></template>
<div>loading..</div>
<!--/$-->

<!--$?--><!--/$--> 是 Suspense 边界的标记。<template> 标签是稍后会被替换掉的占位符。<div>loading..</div> 则是你的 fallback UI。

id="B:0" 让 React 知道当解析后的组件到达时,应该去替换哪一个占位符。

注释里的 $? 表示该 Suspense 边界仍处于 pending:fallback 正在展示,我们还没收到真实数据。

streamsummary.png

到这一步,我强烈建议你打开一个 Next.js 项目,打开 DevTools 看 Network:亲眼看到隐藏的 divscript 标签随着流式数据一点点进来,往往比光看文字更快「开窍」。

组件回推到客户端

当数据在服务器端解析完成后,React 会把组件继续以流的方式推回客户端。看起来像这样:

<div hidden id="S:0">
  <section>ProductDetails</section>
</div>

注意这是一个 hiddendiv。React 不会把它直接插到「正确的位置」,而是先把它暂存到屏幕外,并用 id="S:0" 标记。紧接着,它会再流式输出一小段 <script>

<script>
  $RC("B:0", "S:0");
</script>

stream2.png

替换就发生在这里。$RC 是 React 更早就在流里下发过的函数,因此客户端已经准备好了。我们再来看 React 为实现这件事会用到的三个函数。

<script>
  $RB = [];  
  $RV = function (a) {    
    $RT = performance.now();    
    for (var b = 0; b < a.length; b += 2) {      
      var c = a[b],        
        e = a[b + 1];      
      null !== e.parentNode && e.parentNode.removeChild(e);      
      var f = c.parentNode;      
        if (f) {// 出于可读性,此处折叠了 51 行

React UI 运行时示意

你需要重点关注三件事:$RB 队列、$RC 函数、以及 $RV 函数。

$RC

$RC = function(a, b) {
  if (b = document.getElementById(b))
    (a = document.getElementById(a))
      ? (/* 替换逻辑 */)
      : b.parentNode.removeChild(b)
}

$RC 接收两个参数。a 是类似 B:0 的 template id,b 则是类似 S:0 的已解析组件 id。

它首先尝试用 document.getElementById(b) 找到已解析组件对应的 div。如果找不到,就移除组件并不做任何事。如果找到了,再继续用 document.getElementById(a) 去找 template 元素。

如果找到了 template,它会把前一个兄弟注释节点上的边界标记从 $? 改成 $~,表示该 Suspense 边界已进入排队状态,然后把两个元素一起推进 $RB 队列:

a.previousSibling.data = "$~";
$RB.push(a, b);

一旦 $RC 凑齐了「template + 已解析内容」这一对,就会用 requestAnimationFrame 调用 $RV 去做真正的 DOM 交换。

$RB

$RB 只是一个充当队列的数组。React 会把 [template, resolved] 这样的成对元素推进去。真正的交换并不会在每一次 $RC 调用时立刻发生:它会等到至少有一对元素,并把 $RV 安排到下一帧执行。

$RV

这里才会发生真正的交换。

$RV = function(a) {
  for (var b = 0; b < a.length; b += 2) {
    var c = a[b],    // template 元素(B:0)
        e = a[b+1]; // 已解析组件(S:0)
    ...
  }
}

它会每次从 $RB 里取两个元素,因为我们总是成对 push。

首先把已解析组件从隐藏的 div 上拆下来,这样它就不再处于 hidden 状态。

然后它会遍历 Suspense 边界内的所有兄弟节点,并逐个移除它们。这就是如何清掉 fallback UI:你写的 loading 转圈?没了。

do {
  d = c.nextSibling;
  f.removeChild(c);
  c = d;
} while (c);

接着,它会把已解析组件的所有子节点,逐个插入到 Suspense 边界闭合注释之前。

for (; e.firstChild; ) f.insertBefore(e.firstChild, c);

最后,它会把边界注释从 $~ 更新为 $,表示 Suspense 已结束。如果边界节点上挂了 _reactRetry,它也会触发——这就是 React 处理并发模式重试的方式。

$?$~$ 这一串状态迁移,就是 Suspense 边界的完整生命周期:

$?  = pending  (fallback 正在展示)
$~  = queued   (已解析内容就绪,等待 RAF$   = complete(真实内容已进入 DOM

打破 Suspense

既然 React 只是在 DOM 里寻找 <template id="B:0">,那如果你手动塞一个进去会发生什么?

<main>
  --
  <div>
    hello
    <template id="B:0">hello testing</template>
  </div>
  --
  <Navbar />
  <Suspense fallback={<div>loading..</div>}>
    <ProductDetails />
  </Suspense>
  <Suspense fallback={<div>loading...</div>}>
    <Reviews />
  </Suspense>
  <Suspense fallback={<div>loading...</div>}>
    <Recommendations />
  </Suspense>
  <Footer />
</main>

我故意在一个随意的 div 里加了一个 <template id="B:0">。React 并不知道那是假的。当 $RC("B:0", "S:0") 运行时,它只会执行 document.getElementById("B:0"),于是先命中的是你那个。结果就是:它不会去替换真正的 ProductDetails 占位符,而是把你的随机 div 给换了。

React UI 运行时示意

小结

这正是 React 的流式渲染与「单纯把 HTML 分块」不同的地方:普通 HTML 流被迫按顺序解析,因为 HTML 解析本身就是顺序的。React 则把 DOM 当作暂存区:用隐藏 div 把组件先送过来,再用 JavaScript 在正确的时机把它们摆到正确的位置。

希望你喜欢这篇文章的阅读体验,也欢迎在社交平台上把本文转给同样需要搞懂流式细节的同学 ❤️

特别感谢 @render,帮我指出了几处我遗漏的问题。

术语表(本篇命中)

术语 英文 释义
乱序流式 out-of-order streaming 不依赖 DOM 出现顺序,先发送可渲染部分并以占位符延迟补齐
顺序流式 in-order streaming 流式片段大致按文档顺序依次到达,后续内容可能被前置的未完成异步阻塞
服务器组件 Server Components 在服务器上渲染/序列化的 React 组件形态,常与流式配合
流式渲染 streaming (SSR) 边生成边发送 HTML(或数据块),客户端可渐进展示
占位符 / 标记 placeholder / marker 流中预留位置,后续由脚本替换为真实 UI

ESModule和Commonjs模块的区别

作者 Rkgua
2026年5月3日 22:12

ES Module(ESM)和 CommonJS(CJS)是 JavaScript 中两种主流的模块化规范。ESM 是 ES6 推出的官方标准,而 CommonJS 则是 Node.js 早期采用的模块化方案。

以下从几个核心角度为你详细拆解:

1. 核心差异速览表

对比角度 CommonJS (CJS) ES Module (ESM)
基本语法 require() 导入,module.exports 导出 import 导入,export 导出
加载时机 运行时加载(动态) 编译时加载(静态)
加载方式 同步加载 异步加载(浏览器端)
导出本质 值的拷贝(浅拷贝) 值的引用(Live Binding)
代码优化 不支持 Tree Shaking 支持 Tree Shaking
顶层 this 指向 module.exports undefined(严格模式)

2. 深度解析各个角度

语法与规范来源

  • CommonJS:是社区提出的规范,主要用于 Node.js 服务端环境。它的语法非常直观,使用 require() 来引入模块,使用 module.exportsexports 来向外暴露功能。
  • ES Module:是 ECMAScript 2015 (ES6) 的官方语言标准,旨在统一浏览器和服务端的模块化。它使用 importexport 关键字,语法更加语义化,支持命名导出和默认导出。

加载时机与方式(最核心的区别)

  • CommonJS 是“运行时同步加载”:当你代码执行到 require() 这一行时,才会去加载并执行对应的模块文件。这种方式在服务端(读取本地硬盘文件)非常高效,但在浏览器端会因为网络请求阻塞页面渲染,所以浏览器不原生支持。
  • ES Module 是“编译时静态加载”:JS 引擎在解析代码的阶段(编译时),就会通过分析 importexport 语句,提前确定好模块之间的依赖关系。在浏览器中,ESM 默认是异步加载的,不会阻塞 HTML 的解析。

导出的本质:值拷贝 vs 值的引用 这是两者在实际开发中最容易产生 Bug 的差异点:

  • CommonJS(值拷贝):导出的是模块内部变量的一个副本。如果模块内部修改了这个变量,外部引入的地方是感知不到的。
    // CommonJS 示例
    // counter.js
    let count = 0;
    module.exports = { count };
    setTimeout(() => { count = 1; }, 1000); // 内部修改
    
    // main.js
    const { count } = require('./counter.js');
    console.log(count); // 0
    setTimeout(() => { console.log(count); }, 1100); // 依然是 0,因为是拷贝的旧值
    
  • ES Module(值的引用 / Live Binding):导出的是对模块内部变量的动态引用。当模块内部修改了变量,所有引入该变量的地方都会同步更新。
    // ESM 示例
    // counter.js
    export let count = 0;
    setTimeout(() => { count = 1; }, 1000); // 内部修改
    
    // main.js
    import { count } from './counter.js';
    console.log(count); // 0
    setTimeout(() => { console.log(count); }, 1100); // 1,实时同步了最新值
    

代码优化(Tree Shaking)

  • ES Module:由于它是静态的,打包工具(如 Webpack、Rollup、Vite)可以在打包阶段就分析出哪些代码被使用了,哪些没有。未被使用的代码(Dead Code)会被直接剔除,这个过程叫 Tree Shaking(摇树优化),能显著减小打包体积。
  • CommonJS:由于 require() 可以在代码运行时动态执行(比如写在 if 判断里),打包工具很难在编译阶段确定到底引用了哪些模块,因此无法有效支持 Tree Shaking。

运行环境与兼容性

  • CommonJS:Node.js 的默认模块规范,生态极其成熟。在浏览器中无法直接使用,必须通过 Webpack、Browserify 等工具打包转换。
  • ES Module:现代浏览器原生支持(通过 <script type="module">),也是现代前端框架(Vue3, React)和构建工具(Vite)的首选。Node.js 从 v12 版本后也开始支持 ESM,但需要在 package.json 中配置 "type": "module" 或使用 .mjs 后缀。

总结建议: 在现代前端开发和新的 Node.js 项目中,优先推荐使用 ES Module,因为它更标准、性能更好且支持代码优化。但在维护一些老旧的 Node.js 项目或依赖某些仅支持 CJS 的第三方库时,你依然会频繁接触到 CommonJS。

拖一拖控件,拖出个问卷(低代码平台)

2026年5月3日 01:00

什么是低代码平台

低代码指的是一种通过可视化拖拽、组件复用,并结合少量代码配置,来快速构建应用程序的开发模式。其核心并非完全消除代码,而是将开发者从重复、底层的“手工劳动”中解放出来,转向“装配式开发”。换句话说,开发者从“开发一些页面”变成了“开发一个工具”,使用这个工具的人不仅仅是开发人员,不了解技术的运营人员也可以使用,根据自己的需要生成一个页面。

更准确地说,低代码开发平台是将底层架构、基础设施和通用能力抽象为图形化界面,以可视化设计为主、少量代码为辅,覆盖应用从设计、开发、测试、部署到运维全生命周期的一站式工具集。

下面是一个简单的问卷低代码平台,以此为例简单介绍一下技术重点。

92c80ae8-bf26-4ddb-a697-25fb657ef428.png

代码地址:https://github.com/beat-the-buzzer/lowcode-survey.git

演示地址:https://beat-the-buzzer.github.io/lowcode-survey/#/design/xg

技术栈:vue3、element-plus、pinia、vuedraggable

页面结构和项目搭建

页面结构设计:

  • 左侧控件区域
  • 中部问卷展示区域
  • 右侧控件属性编辑区

数据结构设计:

数据结构其实并不复杂,问卷的主体就是一个list,list里面的对象就是题目,都是前端定义,前端使用,服务端只是存一下。

使用 pinia 创建问卷的数据:

{
  list: [], // 问卷内容 里面存的属性都是前端定义、前端使用,服务端只是存一下·
  config: {
    title: '问卷标题',
// 问卷其他涉及到的属性都可以放在这里
  },
}

核心技术点一:拖动控件,生成页面

import Draggable from "vuedraggable-es";

关键代码:

<draggable
  itemKey="key123"
  tag="ul"
  v-model="list.children"
  :group="{ name: 'form', pull: 'clone', put: false }"
  ghost-class="ghost"
  :sort="false"
  :clone="clone"
  :distance="1"
  :move="handleMove"
>
</draggable>

关键点:clone模式的用法,拖动的节点数据会被复制。

这里设置的 name 非常关键,在中间的问卷主体里,是这样写的:

<draggable
  itemKey="id"
  :list="data"
  name="fade"
  class="drag"
  v-bind="{
    group: 'form',
    ghostClass: 'ghost',
    animation: 200,
    handle: '.drag-move',
  }"
  @add="draggableAdd"
  @end="draggableEnd"
  :move="draggableMove"
>
</draggable>

group 的 name 对应上,才能拖到指定位置,释放时候触发了 add 方法,会把 clone 的数据带过去,我们在 draggableAdd 里修改 store 里的数据。

const draggableAdd = (evt: any) => {
  console.log(evt)
  const newIndex = evt.newIndex;
  const obj: any = data.value[newIndex];
  if (obj.type === "pagination") {
    handleAddPagination(data.value);
  } else {
    groupClick(data.value[newIndex], newIndex);
  }
};

核心技术点二:给控件添加定制化的属性

store 里面用 currentItem 去标识当前选中的数据,然后根据不同的type展示不同的定制化属性,最终这些定制化属性都会保存到store里。

<div
    v-if="
      showHide(['input', 'matrix_blanks_input'], true) ||
      (showHide(['table_column'], true) &&
        controlItem.attribute.dataType === 'text')
    "
    >
    <InputAttrs v-model:value="controlItem.attribute" />
    </div>
    <!-- 时间选择     -->
    <div v-if="showHide(['timepicker'], true)">
    <TimeAttrs v-model:value="controlItem.attribute" />
</div>

根据不同的类型展示不同的条件。

核心技术点三:成果页面的展示逻辑

本质上就是把配置好的问卷用表单的形式展示出来:

type 就是题目类型,根据这个 type 渲染不同的组件

<SingleChoice 
  :config="question"
  :read-only="readOnly"
  @trigger-skip="handleTriggerSkip"
  v-if="question.type === 'single_choice'"
></SingleChoice>
<MultChoice 
  :config="question"
  :read-only="readOnly"
  @trigger-skip="handleTriggerSkip"
  v-else-if="question.type === 'mult_choice'"
></MultChoice> 

可以使用 component is 属性,不在这里使用大量的v-if语句:

<component
  v-model:value="formModel[question.id]"
  :config="question"
  :read-only="readOnly"
  :is="dom[question.type]"
  @trigger-skip="handleTriggerSkip"
></component>
export { default as mult_text } from "./MultText.vue";
export { default as single_choice } from "./SingleChoice.vue";
export { default as mult_choice } from "./MultChoice.vue";

import * as Elements from "./export";

其他难点以及后续改良方向

  1. 数据如何回收

目前的结构里,每一道题目都有一个id,数据给服务端的时候,服务端很难将其转成有意义的字段。

改进方向:允许编辑id,使其变成服务端可识别的字段。

  1. 难以做后端校验

问卷的结构目前是前端定义,前端解析,后端只是做了一个存和取的过程,因此到实际的问卷填报时,都是前端去做校验。如果后端做校验,就需要前端告知数据的结构,然后后端再把校验的逻辑写一遍。

cd Cheatsheet

Basic Syntax

Core command forms for changing directories.

Command Description
cd [DIRECTORY] Change to a directory
cd Change to your home directory
cd -- DIRECTORY Change to a directory whose name may start with -
pwd Print the current working directory

Everyday Navigation

Common ways to move around the filesystem.

Command Description
cd /etc Change to an absolute path
cd Downloads Change to a relative path
cd .. Move up one directory
cd ../.. Move up two directories
cd ./scripts Change to a directory under the current directory

Home Directories

Use shell shortcuts for your home directory and other users’ homes.

Command Description
cd ~ Change to your home directory
cd ~/Downloads Change to Downloads inside your home directory
cd ~username Change to another user’s home directory
cd "$HOME" Change to the directory stored in $HOME

Relative Paths

Build paths from your current directory.

Command Description
cd . Stay in the current directory
cd .. Move to the parent directory
cd ../src Move up one level, then into src
cd ../../var Move up two levels, then into var
cd project/docs Move through nested directories

Previous Directory

Switch between recently used directories.

Command Description
cd - Change to the previous working directory
echo "$OLDPWD" Show the previous working directory
cd "$OLDPWD" Change to the previous directory without using cd -
pushd /path Change directory and save the old one on the stack
popd Return to a directory from the stack

Paths with Spaces

Quote or escape paths that contain spaces or shell metacharacters.

Command Description
cd "Project Files" Quote a directory name with spaces
cd 'Project Files' Use single quotes for a literal path
cd Project\ Files Escape the space with a backslash
cd -- "-reports" Enter a directory whose name starts with -

Symlinks and Physical Paths

Control whether cd follows logical or physical paths.

Command Description
cd -L linkdir Follow symbolic links (default in Bash)
cd -P linkdir Resolve to the physical directory path
pwd Show the shell’s logical current directory
pwd -P Show the physical current directory
cd -P .. Move using the physical directory structure

CDPATH

Search extra base directories when changing by name.

Command Description
export CDPATH=.:~/projects:/opt Search current directory, ~/projects, and /opt
cd myapp Try matching myapp in each CDPATH entry
unset CDPATH Disable CDPATH for the current shell
CDPATH= cd myapp Run one cd command without CDPATH

Troubleshooting

Quick checks for common directory-change errors.

Issue Check
No such file or directory Verify the path with ls -ld path
Permission denied Check execute permission on the directory
Path with spaces fails Quote the path or escape spaces
cd - fails $OLDPWD is not set yet
Unexpected target with CDPATH Run unset CDPATH or use an absolute path
Symlink path looks different Compare pwd and pwd -P

Related Guides

Use these guides for detailed directory navigation workflows.

Guide Description
cd Command in Linux: Change Directories Full cd guide with examples
How to Get the Current Working Directory in Linux Use pwd and understand the current directory
pushd and popd Commands in Linux Work with the directory stack
Linux Commands Cheatsheet General Linux command quick reference

Initial Server Setup on Ubuntu 26.04

A fresh Ubuntu 26.04 server ships with root SSH access, no regular user, and no firewall rules. That works for the first login, but it is not a safe state to leave running on a public VPS.

This guide walks through the first tasks to perform on a new Ubuntu 26.04 server: creating a sudo user, enabling SSH key authentication, locking down SSH, configuring UFW, setting the hostname and timezone, and applying package updates.

Quick Reference

Task Command or file
Log in as root ssh root@server_ip_address
Create a user adduser username
Grant sudo access usermod -aG sudo username
Copy root SSH keys rsync --archive --chown=username:username /root/.ssh /home/username
Add a local key ssh-copy-id username@server_ip_address
SSH hardening file /etc/ssh/sshd_config.d/99-hardening.conf
Test SSH config sudo sshd -t
Allow SSH in UFW sudo ufw allow OpenSSH
Set hostname sudo hostnamectl set-hostname server-name
Set timezone sudo timedatectl set-timezone Europe/Berlin

Prerequisites

Before starting, make sure you have:

  • A new Ubuntu 26.04 server with a public IP address.
  • Root access over SSH, either with a password or a provider-supplied key.
  • A local SSH key pair on your workstation. If you do not have one yet, see how to generate SSH keys on Linux .
  • Access to the provider web console as a backup path in case SSH access stops working.

Keep your original root SSH session open until you have tested the new user login and the hardened SSH configuration.

Log In as Root

Open a terminal on your local machine and connect to the server using the public IP address from your hosting provider:

Terminal
ssh root@server_ip_address

Accept the host key when prompted and enter the root password if password authentication is still enabled. If your provider created the server with an SSH key, the connection should use that key automatically.

Create a New Sudo User

Working as root for daily administration is risky because every command runs with full privileges. Create a regular user account and give it administrative access through the sudo group.

Replace username with the account name you want to use:

Terminal
adduser username

The command prompts for a password and optional user details. Enter a strong password, then press Enter to skip any fields you do not need.

Add the new user to the sudo group:

Terminal
usermod -aG sudo username

The account can now run administrative commands with sudo.

Set Up SSH Key Authentication

SSH keys are safer than password logins and are easier to use once configured. The exact command depends on where your public key is currently stored.

If your public key is already present under the root account, copy the root SSH directory to the new user:

Terminal
rsync --archive --chown=username:username /root/.ssh /home/username

If you need to copy a key from your local workstation, run this command from the local machine:

Terminal
ssh-copy-id username@server_ip_address

Open a new terminal window and test the login before changing the SSH server configuration:

Terminal
ssh username@server_ip_address

The connection should succeed as the new user. Keep both the root session and the new user session open while you continue.

Disable Root Login and Password Authentication

After key-based login works, configure OpenSSH to reject direct root logins and password authentication. Ubuntu includes files from /etc/ssh/sshd_config.d/, which keeps local changes separate from the main SSH configuration file.

Create a hardening snippet:

Terminal
sudo nano /etc/ssh/sshd_config.d/99-hardening.conf

Add the following lines:

/etc/ssh/sshd_config.d/99-hardening.conftxt
PermitRootLogin no
PasswordAuthentication no

Save the file and test the SSH configuration syntax:

Terminal
sudo sshd -t

If the command prints no output, the configuration is valid. Reload SSH to apply the change:

Terminal
sudo systemctl reload ssh

Open another terminal and confirm that you can still log in as the regular user:

Terminal
ssh username@server_ip_address

Do not close your existing sessions until this test succeeds.

Set Up the Firewall with UFW

Ubuntu uses UFW (Uncomplicated Firewall) as a simple front-end for managing host firewall rules. Start by allowing SSH so the firewall does not block your current access:

Terminal
sudo ufw allow OpenSSH

Enable the firewall:

Terminal
sudo ufw enable

Confirm the prompt with y, then check the active rules:

Terminal
sudo ufw status

The output should show that OpenSSH is allowed:

output
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
OpenSSH (v6) ALLOW Anywhere (v6)

When you install services such as Nginx or Apache, open their profiles before expecting traffic to reach them. For example, an Nginx server that should accept HTTP and HTTPS traffic needs:

Terminal
sudo ufw allow 'Nginx Full'

For more examples, see how to set up a firewall with UFW .

Set the Hostname

A descriptive hostname makes logs, shell prompts, monitoring alerts, and dashboards easier to read. Set the hostname with hostnamectl:

Terminal
sudo hostnamectl set-hostname server-name

Replace server-name with a short name that matches the server role, such as web-01 or db-01.

Check the result:

Terminal
hostnamectl

You can update DNS records or your local SSH config separately if you want to connect by name instead of IP address.

Set the Timezone

Set the server timezone so logs, cron jobs, and timestamps match the region you use for operations:

Terminal
sudo timedatectl set-timezone Europe/Berlin

List available zones if you are unsure of the exact name:

Terminal
timedatectl list-timezones

See how to set or change the timezone on Ubuntu for a deeper explanation.

Update the System

Refresh the package index and install pending updates:

Terminal
sudo apt update
sudo apt upgrade

If the upgrade installed a new kernel or core system libraries, reboot the server:

Terminal
sudo reboot

After the reboot, reconnect as the regular sudo user:

Terminal
ssh username@server_ip_address

Troubleshooting

Locked out after disabling password authentication
Use your provider web console or recovery mode to log in. Edit /etc/ssh/sshd_config.d/99-hardening.conf, temporarily set PasswordAuthentication yes, run sudo sshd -t, reload SSH, and test key login again before disabling passwords.

usermod: group 'sudo' does not exist
Some minimal images may not include the sudo package. Install it with apt install sudo, then rerun usermod -aG sudo username.

sshd -t reports an error
Read the line number in the error message, fix the snippet in /etc/ssh/sshd_config.d/99-hardening.conf, and run sudo sshd -t again. Do not reload SSH until the syntax test passes.

UFW blocks an expected service
Check the active rules with sudo ufw status. Allow the needed service profile or port, such as sudo ufw allow 'Nginx Full' for Nginx web traffic, then test the connection again.

Conclusion

You now have an Ubuntu 26.04 server with a sudo user, key-based SSH access, direct root logins disabled, a basic firewall, and current packages. A good next step is to enable automatic security updates before installing the rest of your stack.

每日一题-旋转图像🟡

2026年5月4日 00:00

给定一个 × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。

你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。

 

示例 1:

输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[[7,4,1],[8,5,2],[9,6,3]]

示例 2:

输入:matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
输出:[[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]

 

提示:

  • n == matrix.length == matrix[i].length
  • 1 <= n <= 20
  • -1000 <= matrix[i][j] <= 1000

 

❌
❌