每日一题-旋转链表🟡
给你一个链表的头节点 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 <= 1000 <= k <= 2 * 109
给你一个链表的头节点 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 <= 1000 <= k <= 2 * 109![]()
示例 1 的链表长为 $5$,$k=2$。旋转后,原链表的倒数第 $k$ 个节点,成为新链表的头节点。
把 $1\to 2\to 3\to 4\to 5$ 变成 $4\to 5\to 1\to 2\to 3$,我们需要:
本题 $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
}
}
欢迎关注 B站@灵茶山艾府
(模拟) $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 = head,head = p->next,p->next = nullptr,将链表的后k个节点和前 n - k个节点拼接到一块,并让head指向新的头节点(p->next),新的尾节点即p节点的next指针指向null。
{:width="70%"}
4、最后返回链表的新的头节点head。
时间复杂度分析: 链表一共被遍历两次,因此总的时间复杂度为$O(n)$,$n$是链表的长度。
###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; //返回新的头节点
}
};
###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; //返回新的头节点
}
}
![]()
思路及算法
记给定链表的长度为 $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)$,我们只需要常数的空间存储若干变量。
我在开发一个跨链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坑了一次,不如彻底解决。
我用的React框架,之前用@web3-react/core配合ethers.js。Viem官方提供了wagmi(一个React Hooks库),但我不想引入太多依赖,所以直接用Viem的createWalletClient和createPublicClient自己封装。
这里有个坑: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)
});
注意:createWalletClient的transport参数必须用custom(window.ethereum),不能用http()。我当时用http()测试,结果报TransportError: The transport does not support signing。
这是我最头疼的部分。Viem的signTypedData方法要求传入的参数类型非常严格——domain里的chainId必须是number,不能是bigint或string。而且它不会像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,签名完全匹配。
替换合约调用时,我遇到了第二个坑:Viem的writeContract和estimateGas是分离的,不像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;
v值冲突:ethers.js的splitSignature会调整v值到27/28,但Viem不会。如果你在同一个项目里混用两个库,签名验证会失败。我的解决方案是:完全切换到Viem,统一签名处理逻辑。
Gas参数命名:Viem用gas而不是gasLimit,这个命名差异让我排查了半小时。Viem的文档里写的是gas,但很多教程示例用的还是gasLimit,容易混淆。
createPublicClient实例管理:每次切换链都重新创建publicClient,导致多个组件引用了不同的实例。我把客户端放到了React Context里,用useMemo缓存实例,只在链切换时更新。
watchContractEvent回调频率:在Polygon上监听事件时,回调被频繁触发。我加了一个throttle函数,每500ms只处理一次日志。
迁移到Viem后,签名问题彻底解决了,而且类型安全让我少了很多运行时错误。核心收获是:对于跨链场景,Viem的原始签名处理更可靠。如果你也在被ethers.js的签名兼容性折磨,可以试试Viem。下一步我打算研究Viem的account abstraction支持,看看能不能进一步简化钱包集成。
系列第二篇:用最时髦的工具链,三十分钟搭好企业级前端项目基底
上一篇文章我们定下了“从零到开源”的总体规划。现在,是时候把手弄脏,真正开始敲命令了。
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 官方脚手架已经支持 React 19(需手动指定版本)。我们分三步走。
打开终端,执行:
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
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"
}
打开 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。
pnpm run dev
浏览器打开 http://localhost:3000,看到 Vite + React 的默认页面即成功。
Tailwind CSS v4 最大的变化是不再需要 tailwind.config.js,而是通过 CSS 中的 @import 和 @theme 进行配置,原生支持 light/dark 模式切换,编译速度也大幅提升。
官方包名已从 tailwindcss 升级,并需要配合 @tailwindcss/vite 插件(Vite 专用)。
pnpm add tailwindcss@next @tailwindcss/vite
@next标签目前对应 v4.0.0-beta。生产环境稳定后直接用tailwindcss@^4即可。
修改 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,
},
})
删除 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 对象。
在 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 9 开始默认使用扁平配置(Flat Config),.eslintrc.js 已成为历史。我们需要创建 eslint.config.js 并集成 React 19 和 Tailwind 的规则。
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 类名。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'],
},
]
在 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 init
创建 .gitignore 文件(Vite 官方模板已带,确保包含以下内容):
node_modules
dist
dist-ssr
*.local
.env
.DS_Store
git add .
git commit -m "chore: initial commit with React 19, Vite 8, Tailwind v4"
登录 GitHub,点击右上角 “+” → “New repository”。
react19-starter
复制仓库地址(HTTPS 或 SSH),本例用 SSH:
git remote add origin git@github.com:你的用户名/react19-starter.git
git branch -M main
git push -u origin main
在项目根目录创建 .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 项目的完整环境搭建:
你的项目基底已经具备代码规范、样式工具、自动化检查等企业级要素。接下来可以愉快地编写业务代码了。
下一篇预告:《第 3 篇:路由与状态管理 —— React Router v7 + Zustand 最佳实践》。我们将引入新版本路由和轻量状态管理,实现多页面和全局数据流。敬请期待!
本文所有代码已上传至 GitHub:react19-starter(记得把链接替换成你自己的仓库哦)
如果你在配置中遇到任何问题,欢迎在评论区留言,我会第一时间解答。下期见~
// ❌ 错误理解sadasda'张三',
this: '???', // 这只是一个普通属性,不是 this
thisValue: this // 这里的 this 是全局对象,不是 obj
};
// ✅ 正确理解:this 是在函数执行时确定的
const obj2 = {
name: '李四',
sayName() {
console.log(this.name); // this 在执行时才绑定到 obj2
}
};
obj2.sayName(); // '李四' - this 指向 obj2
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 (同一个函数)
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 API 实现图片懒加载,核心优势在于进入视口才加载图片,可显著提升首屏加载速度、节省带宽资源、避免页面卡顿,适合多图列表场景
图片懒加载的核心思想是:图片进入用户可视区域时才加载真实图片,未进入时显示占位图。
Vue3 中实现懒加载最优雅的方式是使用 IntersectionObserver API,相比传统的 scroll 事件监听,它具备以下优势:
懒加载实现流程:
src 使用占位图,真实地址存在 data-src 属性中IntersectionObserver 实例,监听所有图片元素data-src 的值赋给 src
<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>
<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(),防止内存泄漏 |
该方案在多图列表场景下效果显著,可直接应用于商品列表、朋友圈图片流、相册等业务场景。
虚拟 DOM 本质就是一个 JS 对象,用来描述真实 DOM 结构。
例如 JSX:
<div className="box">
<span>Hello</span>
</div>
会被转换成类似:
{
type: 'div',
props: {
className: 'box',
children: [
{
type: 'span',
props: {
children: 'Hello'
}
}
]
}
}
核心目的:减少真实 DOM 操作
因为:
👉 所以 React 做了一层“中间层”:
状态变化 → 生成新的虚拟DOM → Diff → 最小化更新真实DOM
React 的 Diff 不是传统树算法(O(n³)),而是做了优化 → O(n)
核心基于 3 个假设:
👉 React 只比较同一层节点,不会跨层移动
例如:
A
├─ B
└─ C
如果变成:
A
└─ B
└─ C
React 会认为:
❗不会复用
👉 这是用空间换时间
<div />
→
<span />
👉 直接销毁旧节点,创建新节点
👉 这是重点(和你下面的问题强相关)
👉 标识节点的唯一身份
让 React 在 Diff 时可以:
✔ 复用节点
✔ 只更新变化的部分
✔ 避免错误复用
旧列表:
[{id:1}, {id:2}, {id:3}]
新列表:
[{id:3}, {id:1}, {id:2}]
React 会按位置比较:
旧: 1 2 3
新: 3 1 2
👉 结果:
key: 1 2 3
→
key: 3 1 2
React 会:
👉 发现只是“顺序变了”
👉 复用节点,只移动 DOM
很多人背这个结论,但不理解原因。
旧:
[A, B, C]
key: 0 1 2
新(头部插入 D):
[D, A, B, C]
key: 0 1 2 3
旧 0 → 新 0 (A → D ❌)
旧 1 → 新 1 (B → A ❌)
旧 2 → 新 2 (C → B ❌)
👉 全错位
[A, B, C]
→
[A, C]
index 变化:
B 被删 → C 的 index 从 2 → 1
👉 React 误以为:
<input value="A" />
<input value="B" />
删除第一个后:
👉 B 会变成 A(错位)
不是绝对不能用,而是有条件:
👉 满足以下条件可以用:
例如:
[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:
精确性 ↓
性能 ↑
在医疗预约、订单通知、物流提醒等场景中,消息通知是提升用户体验的重要手段。微信小程序提供了订阅消息能力,允许开发者向用户发送订阅消息。本文将结合医疗预约场景,详细介绍订阅消息的完整使用流程。
订阅消息是微信小程序提供的消息推送能力,分为两种类型:
| 类型 | 说明 | 适用场景 |
|---|---|---|
| 一次性订阅 | 用户授权一次,可发送一条消息 | 订单通知、预约提醒等 |
| 长期订阅 | 用户授权一次,可发送多条消息 | 仅限特定类目(如政务、医疗等) |
⚠️ 大部分类目只能申请一次性订阅消息,每次发送前都需要用户主动授权。
[申请模板] → [前端发起授权] → [用户允许] → [后端发送消息] → [用户收到通知]
每个模板由多个关键词组成,每个关键词有固定的类型和格式要求:
| 字段类型 | 说明 | 格式要求 |
|---|---|---|
name |
姓名 | 最多10个字符,仅支持文字 |
time |
时间 | 格式:YYYY-MM-DD HH:MM
|
thing |
事项 | 最多20个字符 |
character_string |
字符值 | 用于编号、单号等 |
📌 关键点:字段类型决定了值的格式,错误的格式会导致发送失败(错误码 47003)。
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'
});
}
});
}
用户点击"允许" → res[templateId] = 'accept' → 可以发送消息
用户点击"拒绝" → res[templateId] = 'reject' → 本次不能发送
用户曾拒绝且勾选"不再询问" → 需引导至设置页开启
引导用户开启权限:
// 当用户拒绝授权时,引导至设置页
wx.showModal({
title: '开启通知',
content: '需要开启通知权限才能接收预约提醒',
success: (res) => {
if (res.confirm) {
wx.openSetting(); // 打开设置页
}
}
});
订阅消息的数据是一个对象,键名为 {{name1.DATA}} 中的 name1 部分:
const templateData = {
name1: { value: '张三' },
time2: { value: '2026-05-04 14:00' },
thing3: { value: '北京协和医院' }
};
假设你的模板字段如下:
就诊人:{{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) }
};
}
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 47003 错误 | 字段值包含特殊字符 | 使用正则过滤非法字符 |
| 47003 错误 | 字段值为空 | 设置默认值(如"未填写") |
| 47003 错误 | 字段值超长 | 截断到规定长度 |
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
};
}
};
// 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' });
}
}
errCode: 43101
errMsg: user refuse to accept the msg
含义:用户未授权订阅消息。
解决方案:
wx.requestSubscribeMessage 获取用户授权
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;
}
| 错误码 | 说明 | 解决方案 |
|---|---|---|
| 40003 | touser 不合法 | 检查 openid 是否正确 |
| 40037 | 模板ID不正确 | 检查模板ID是否填写正确 |
| 43100 | 请在小程序中体验订阅消息 | 需在真机上测试 |
┌─────────────────────────────────────────────────────────────┐
│ 订阅消息完整流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ [1. 公众平台申请模板] │
│ ↓ │
│ [2. 获取模板ID] │
│ ↓ │
│ [3. 前端调用 wx.requestSubscribeMessage] │
│ ↓ │
│ [4. 用户点击"允许"] │
│ ↓ │
│ [5. 构建模板数据(注意格式校验)] │
│ ↓ │
│ [6. 调用云函数发送消息] │
│ ↓ │
│ [7. 用户收到订阅消息] │
│ │
└─────────────────────────────────────────────────────────────┘
// 建议:封装订阅消息工具类
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 }
});
}
}
订阅消息是微信小程序重要的用户触达手段,正确使用需要注意:
wx.requestSubscribeMessage 获取用户授权subscribeMessage.send 发送消息希望本文能帮助你快速上手微信小程序订阅消息功能。如果有任何问题,欢迎在评论区交流!
参考资料:
周五下午 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_context 和 load_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 子类(ConversationBufferMemory、ConversationSummaryMemory),再通过 helper 函数模拟多轮对话写入,最后断言 load_memory_variables 出来的历史消息既完整又没串味儿。
这段代码把 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
这段用例模拟两次对话输入,验证 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"
这是线上血案的高发区。下面这个测试模拟两个用户同时对话,验证各自的记忆完全隔离,不会出现“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 如何借助 Suspense 边界对流与渲染 UI 进行乱序处理,同时仍保持最终呈现顺序。
早在 Server Components 出现之前,React 就已经支持流式渲染。React 18 提供了 renderToPipeableStream() 与 renderToReadableStream()。而在浏览器侧,这也并不是什么新鲜事:浏览器原生支持流式 HTML,会在收到数据块时就开始渲染。
可以看一个简单的演示。
![]()
大多数流式传输会遵循一种顺序:你会依次看到 chunk(1)、chunk(2) … chunk(N-1)、chunk(N)。
但 React Server Components 与 Suspense 的有趣之处在于:它并不遵循这种顺序。你可以按任意顺序流式输出组件,例如 component(2)、component(N) … component(1)。本文要讨论的就是这件事。
目标读者
本文面向已经熟悉 Suspense 与 Server Components 等基础概念的 React 开发者,重点解释 React 在内部如何处理流式渲染,以及「乱序流式」与常规流式有何不同。
先来看这个例子:
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 />
</>
);
}
![]()
你可能会说: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 />
</>
);
}
![]()
现在三者会同时发起。太好了……但我们仍然有一个问题。
页面会等到三者全部结束,才会发送第一字节的 HTML。即便 Footer 与 Navbar 与数据拉取无关,它们也会被卡住。整个页面会一直等到 getRecommendations() 结束,才会开始发送任何内容。
如果我们能让用户立刻看到那些暂时不需要数据的组件,那就太好了。
好吧,我们可以通过引入流式渲染来解决。
![]()
但「只有流式」仍然有其局限,你发现了吗?
即便启用了流式渲染,用户不必为了看到 Navbar 而等待 ProductDetails,Footer 仍可能因为 Recommendations 还在加载而被阻塞。这叫做顺序流式(in-order streaming):每个组件按其在 HTML 中出现的顺序依次到来。
说明
这是为了在 Next.js 里刻意演示顺序流式而写的例子;你并不能直接「手动」做到完全相同的形态。
顺序流式解决了一部分问题,但并没有把问题彻底解决。
如果我们能立刻发送 Navbar 与 Footer,在慢组件将要出现的位置先放下占位符(标记),等数据就绪后再把这些占位符替换成真实内容呢?互不等待、互不阻塞、彼此独立。
这就是乱序流式(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>
);
}
![]()
说明
为了方便你跟上节奏,我把演示 GIF 里的延迟调大了(1s、2s、3s)。
挺酷的对吧?接下来我们深入看看 React 到底是怎么做到的。
把 React 用的技巧用大白话说出来其实很简单:立刻发送已经有的内容;对还没有的内容留下带标记的占位符;等服务器把数据解析完后,再用 JavaScript 完成替换。
就是这样。下文都只是这个思路的具体实现。
如果你观察服务器实际吐出来的 HTML 流,大致会看到类似下面这样的结构:
![]()
<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>
Navbar 与 Footer 已经在那儿了。慢组件各自处在 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 正在展示,我们还没收到真实数据。
![]()
到这一步,我强烈建议你打开一个 Next.js 项目,打开 DevTools 看 Network:亲眼看到隐藏的 div 与 script 标签随着流式数据一点点进来,往往比光看文字更快「开窍」。
当数据在服务器端解析完成后,React 会把组件继续以流的方式推回客户端。看起来像这样:
<div hidden id="S:0">
<section>ProductDetails</section>
</div>
注意这是一个 hidden 的 div。React 不会把它直接插到「正确的位置」,而是先把它暂存到屏幕外,并用 id="S:0" 标记。紧接着,它会再流式输出一小段 <script>:
<script>
$RC("B:0", "S:0");
</script>
![]()
替换就发生在这里。$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 行
![]()
你需要重点关注三件事:$RB 队列、$RC 函数、以及 $RV 函数。
$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 只是一个充当队列的数组。React 会把 [template, resolved] 这样的成对元素推进去。真正的交换并不会在每一次 $RC 调用时立刻发生:它会等到至少有一对元素,并把 $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)
既然 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 的流式渲染与「单纯把 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 |
ES Module(ESM)和 CommonJS(CJS)是 JavaScript 中两种主流的模块化规范。ESM 是 ES6 推出的官方标准,而 CommonJS 则是 Node.js 早期采用的模块化方案。
以下从几个核心角度为你详细拆解:
| 对比角度 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 基本语法 |
require() 导入,module.exports 导出 |
import 导入,export 导出 |
| 加载时机 | 运行时加载(动态) | 编译时加载(静态) |
| 加载方式 | 同步加载 | 异步加载(浏览器端) |
| 导出本质 | 值的拷贝(浅拷贝) | 值的引用(Live Binding) |
| 代码优化 | 不支持 Tree Shaking | 支持 Tree Shaking |
顶层 this |
指向 module.exports
|
undefined(严格模式) |
语法与规范来源
require() 来引入模块,使用 module.exports 或 exports 来向外暴露功能。import 和 export 关键字,语法更加语义化,支持命名导出和默认导出。加载时机与方式(最核心的区别)
require() 这一行时,才会去加载并执行对应的模块文件。这种方式在服务端(读取本地硬盘文件)非常高效,但在浏览器端会因为网络请求阻塞页面渲染,所以浏览器不原生支持。import 和 export 语句,提前确定好模块之间的依赖关系。在浏览器中,ESM 默认是异步加载的,不会阻塞 HTML 的解析。导出的本质:值拷贝 vs 值的引用 这是两者在实际开发中最容易产生 Bug 的差异点:
// 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,因为是拷贝的旧值
// 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)
require() 可以在代码运行时动态执行(比如写在 if 判断里),打包工具很难在编译阶段确定到底引用了哪些模块,因此无法有效支持 Tree Shaking。运行环境与兼容性
<script type="module">),也是现代前端框架(Vue3, React)和构建工具(Vite)的首选。Node.js 从 v12 版本后也开始支持 ESM,但需要在 package.json 中配置 "type": "module" 或使用 .mjs 后缀。总结建议: 在现代前端开发和新的 Node.js 项目中,优先推荐使用 ES Module,因为它更标准、性能更好且支持代码优化。但在维护一些老旧的 Node.js 项目或依赖某些仅支持 CJS 的第三方库时,你依然会频繁接触到 CommonJS。
低代码指的是一种通过可视化拖拽、组件复用,并结合少量代码配置,来快速构建应用程序的开发模式。其核心并非完全消除代码,而是将开发者从重复、底层的“手工劳动”中解放出来,转向“装配式开发”。换句话说,开发者从“开发一些页面”变成了“开发一个工具”,使用这个工具的人不仅仅是开发人员,不了解技术的运营人员也可以使用,根据自己的需要生成一个页面。
更准确地说,低代码开发平台是将底层架构、基础设施和通用能力抽象为图形化界面,以可视化设计为主、少量代码为辅,覆盖应用从设计、开发、测试、部署到运维全生命周期的一站式工具集。
下面是一个简单的问卷低代码平台,以此为例简单介绍一下技术重点。
![]()
代码地址: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";
目前的结构里,每一道题目都有一个id,数据给服务端的时候,服务端很难将其转成有意义的字段。
改进方向:允许编辑id,使其变成服务端可识别的字段。
问卷的结构目前是前端定义,前端解析,后端只是做了一个存和取的过程,因此到实际的问卷填报时,都是前端去做校验。如果后端做校验,就需要前端告知数据的结构,然后后端再把校验的逻辑写一遍。
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 |
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 |
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
|
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 |
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 |
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 -
|
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 |
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
|
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
|
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 |
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.
| 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 |
Before starting, make sure you have:
Keep your original root SSH session open until you have tested the new user login and the hardened SSH configuration.
Open a terminal on your local machine and connect to the server using the public IP address from your hosting provider:
ssh root@server_ip_addressAccept 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.
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:
adduser usernameThe 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:
usermod -aG sudo usernameThe account can now run administrative commands with sudo.
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:
rsync --archive --chown=username:username /root/.ssh /home/usernameIf you need to copy a key from your local workstation, run this command from the local machine:
ssh-copy-id username@server_ip_addressOpen a new terminal window and test the login before changing the SSH server configuration:
ssh username@server_ip_addressThe connection should succeed as the new user. Keep both the root session and the new user session open while you continue.
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:
sudo nano /etc/ssh/sshd_config.d/99-hardening.confAdd the following lines:
PermitRootLogin no
PasswordAuthentication noSave the file and test the SSH configuration syntax:
sudo sshd -tIf the command prints no output, the configuration is valid. Reload SSH to apply the change:
sudo systemctl reload sshOpen another terminal and confirm that you can still log in as the regular user:
ssh username@server_ip_addressDo not close your existing sessions until this test succeeds.
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:
sudo ufw allow OpenSSHEnable the firewall:
sudo ufw enableConfirm the prompt with y, then check the active rules:
sudo ufw statusThe output should show that OpenSSH is allowed:
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:
sudo ufw allow 'Nginx Full'For more examples, see how to set up a firewall with UFW .
A descriptive hostname makes logs, shell prompts, monitoring alerts, and dashboards easier to read. Set the hostname with hostnamectl:
sudo hostnamectl set-hostname server-nameReplace server-name with a short name that matches the server role, such as web-01 or db-01.
Check the result:
hostnamectlYou can update DNS records or your local SSH config separately if you want to connect by name instead of IP address.
Set the server timezone so logs, cron jobs, and timestamps match the region you use for operations:
sudo timedatectl set-timezone Europe/BerlinList available zones if you are unsure of the exact name:
timedatectl list-timezonesSee how to set or change the timezone on Ubuntu for a deeper explanation.
Refresh the package index and install pending updates:
sudo apt update
sudo apt upgradeIf the upgrade installed a new kernel or core system libraries, reboot the server:
sudo rebootAfter the reboot, reconnect as the regular sudo user:
ssh username@server_ip_addressLocked 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.
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.
![]()
给定一个 n × 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].length1 <= n <= 20-1000 <= matrix[i][j] <= 1000
前端圈一直流传着一个经典段子:Java和JavaScript是什么关系?就是雷峰和雷峰塔的关系。听过后令人会心一笑。但静下来想想🤔,真是这样吗?
雷峰(人)和雷峰塔(建筑)的关系非常明确:除了名字读音相似之外,两者在血缘、历史、物理构成等任何维度上,都百分之百毫无关联。
那么,Java和Javascript是否只是名字有点相似,实则毫无关系呢?
如果抛开段子,翻开真实的计算机史,你会发现Java和JavaScript不仅不是“毫无关系”,反而有着千丝万缕的渊源。
时间回到1995年,网景公司(Netscape)为了在浏览器里加入交互能力,搞出了一门脚本语言(最初叫Mocha,后改LiveScript)。当时Sun公司推出的Java语言正如日中天,被媒体炒作战无不胜的“神器”。网景为了蹭上这波热度,与Sun公司达成了战略合作,将这门语言正式更名为JavaScript。
更硬核的事实是:直到今天,JavaScript的商标权依然掌握在Sun的继承者甲骨文(Oracle)手里。如果是毫无关系的两者,怎么可能共用一个具有法律效力的名字?
网景公司在给语言改名的同时,也给开发者(Brendan Eich)提出了一个明确的需求:“让它的语法看起来像Java”。因此,JavaScript在诞生之初,大量借鉴了Java的基础语法结构。它的 if/else 分支、for 循环结构、try/catch 异常处理机制,甚至是 new 关键字的使用,看起来和Java几乎如出一辙。因此,JavaScript 不是巧合像,是故意设计成像 Java
从JDK 6引入Rhino引擎,到JDK 8内置Nashorn引擎(后在JDK 15中移除),再到如今通过GraalVM JS等现代方案实现深度互操作,Java官方生态长期保持着对JavaScript运行时的支持。这意味着,你完全可以在Java程序里直接调用JavaScript代码,把它们当作业务中的“动态脚本层”。这绝不是两座毫无交集的孤岛,两者在运行层面长期深度集成。
如果说设计一门Java的脚本语言,要类似Java的语法,但是要脚本语言的特性,要能解释执行、方便灵活、宽松,还要高扩展性。那么设计出来就是JavaScript这个样子。
这是一个非常有趣的逻辑推导。假设1995年你需要为Java生态设计一门“附属脚本语言”,你的需求清单是这样的:
当你按照这份需求文档写出一门语言时,恭喜你,你重新发明了JavaScript。它从一出生,就是带着“ Java的轻量化脚本兄弟 ”这个定位来的。
有些人觉得JavaScript还是不够 “像” Java,比如JavaScript的类的实现是基于原型链的,和Java类有本质不同。对此我想说,脚本语言和编译型的语言本来就是为不同场景设计的。JavaScript和Java的差异确实足够大,大到应当作为两门不同语言分别学习,但是不能否认它们的历史渊源。JavaScript和Java的差异更多的可以用不同场景设计来解释。比如原型链问题,在Java中,你的API更新了,你只要升级JDK;而浏览器环境上应当让内置类有较高的扩展性,原型链无疑是最优解,让你重新设计一遍Javascript你也会设计成这样。
Java 和 JavaScript 并非毫无关系,把它俩比作雷锋和雷峰塔,实在是冤枉了这两门语言。它俩的关系更接近于 VB 和 VBScript 的关系:VB 是微软推出的完整版编译型语言,适合开发大型桌面应用,VBScript 则是基于 VB 语法设计的轻量级脚本语言,灵活简洁,用于自动化、网页脚本,二者语法同源、定位互补,是同体系下不同分工的语言。
故事的走向在浏览器大战时期变得更加复杂。微软为了让自家的Internet Explorer浏览器兼容已有网站,迅速搞出了一个 JScript。JScript虽然名字刻意避开了“Java”字眼,但就是为了兼容JavaScript而生的,可以看作微软的JavaScript。但由于JavaScript并非开放标准,微软是照着Netscape的JavaScript行为猜着做的,只能仿个大概,对边界场景可能存在不一致。
为了推动Web标准化,1996年,网景将JavaScript提交给了欧洲计算机制造商协会(ECMA)进行标准化。第二年,ECMA出台了ECMA-262标准,这便是大名鼎鼎的 ECMAScript(简称ES)。
从此,技术界有了一个清晰的共识:JScript和JavaScript,本质上都只是ECMAScript标准的不同实现。 这个标准的诞生,把JavaScript从网景和微软的商业战中抽离出来,成为了一门真正开放的语言。
随着Web标准化,ECMAScript已经不是Sun或Oracle可以控制的。如今ECMAScript的更新由TC39委员会主导,采用五阶段提案流程。如今ECMAScript的发展已经偏离的像Java的目的,比如Map/Set的API刻意避开了Java的命名规范。ES刻意都划清了界限。这导致JavaScript作为ES最核心的实现,如今越来越不像Java。
我知道有些人极度反感Java,甚至否定Javascript和Java的关系。对于这种掩耳盗铃的行为,我倒有个建议:既然这么嫌弃,不如彻底抛弃“JavaScript”这个带Java基因的名字,以后只准叫它ECMAScript。叫它ES,确实是对它如今独立设计哲学的最好宣告,证明它不再是任何人的附庸。也顺理成章地把“JavaScript”这个名字,留给那些真正需要“Java脚本化”的人。
在 Web 开发中,复制文本到剪贴板是一个常见需求,比如:
现代浏览器提供了 navigator.clipboard API,但存在兼容性和安全上下文的限制;传统的 document.execCommand('copy') 虽然兼容性更好,但使用方式较为繁琐。本质上,我们需要一个统一的工具函数来屏蔽这些差异。
const textarea = document.createElement('textarea')
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
优点:兼容性好,支持所有主流浏览器 缺点:需要创建临时 DOM 元素,代码冗长
await navigator.clipboard.writeText(content)
优点:简洁直观,直接操作剪贴板 缺点:需要安全上下文(HTTPS),部分浏览器支持受限
export interface CopyTextOptions {
/** 是否允许复制空白内容(空字符串或纯空格),默认 false */
allowWhitespace?: boolean
/** 是否使用旧版复制方法(不支持空白内容复制),默认 false */
legacy?: boolean
}
export interface CopyTextReturn {
success: boolean
message: string
}
export async function copyText(content: string, options: CopyTextOptions = {}): Promise<CopyTextReturn> {
try {
const { allowWhitespace = false, legacy = false } = options
if (!allowWhitespace && (!content || content.trim() === '')) {
return { success: false, message: '复制内容不能为空' }
} else if (navigator.clipboard && window.isSecureContext && !legacy) {
await navigator.clipboard.writeText(content)
} else {
const textarea = document.createElement('textarea')
textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange?.(0, content.length)
const copied = document.execCommand('copy')
document.body.removeChild(textarea)
if (!copied) throw new Error('浏览器限制或无法复制')
}
return { success: true, message: '复制成功' }
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : '未知错误'
return { success: false, message: `${errMsg}` }
}
}
参数一:allowWhitespace
控制是否允许复制空白内容。默认 false 会过滤空字符串和纯空格内容,避免用户误操作。
参数二:legacy
强制使用传统 execCommand 方案。某些场景下(如在 iframe 内)可能需要降级处理。
优先级判断
navigator.clipboard 可用?
└─ 是 → 判断 isSecureContext(安全上下文)
└─ 是 → 使用现代 API
└─ 否 → 降级到 execCommand
└─ 否 → 降级到 execCommand
| 方案 | 兼容性 | 安全要求 | 代码复杂度 |
|---|---|---|---|
| navigator.clipboard | 现代浏览器 | 必须 HTTPS | 简洁 |
| execCommand | 所有浏览器 | 无 | 较繁琐 |
// 降级逻辑核心代码
const textarea = document.createElement('textarea')
textarea.style.cssText = 'position:fixed; opacity:0; z-index:-9999; left:-9999px; top:-9999px;'
textarea.value = content
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange?.(0, content.length) // 兼容 iOS Safari
const copied = document.execCommand('copy')
document.body.removeChild(textarea)
iOS Safari 兼容要点:setSelectionRange 在 iOS 设备上需要显式调用才能正确选中文本。
navigator.clipboard 要求页面必须处于安全上下文:
开发环境下通常没问题,但部署到生产环境务必确保使用 HTTPS,否则会自动降级到传统方案。
const result = await copyText('hello world')
if (result.success) {
console.log('复制成功')
} else {
console.error(result.message)
}
// 复制可能为空的文本时
const result = await copyText(userInput, { allowWhitespace: true })
// 在特殊场景下强制降级
const result = await copyText(content, { legacy: true })
注释掉的 TipModal 部分可根据项目实际使用的 UI 库进行适配:
// Element Plus 示例
import { ElMessage } from 'element-plus'
if (!allowWhitespace && (!content || content.trim() === '')) {
ElMessage.error('复制内容不能为空')
return { success: false, message: '复制内容不能为空' }
}
// 复制成功后
ElMessage.success('复制成功')
copyText 函数的核心设计要点:
navigator.clipboard,不支持时自动降级到 execCommand
isSecureContext 确保在安全环境下使用现代 APIallowWhitespace 和 legacy 参数适配不同业务场景{ success, message } 结构化结果,便于调用方处理这个不到 50 行的工具函数覆盖了浏览器复制场景的绝大多数需求,可直接集成到项目中。