普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月4日首页

五月,适合想清楚一件事|幕启

2026年5月4日 20:41

图源:公众号「有三思  U  Sense」

5月4日,一个被默认为“青年专属”的日期。

但在我看来,“青年”一词从来不只和年龄挂钩,每一个还在试探人生的节拍与正轨的人,身体里都住着一位渴望力量的青年。

[1]

敲定新品牌命名的那一刻其实很轻。在讨论方案的脑暴会上,我夸了实习生一句“有Sense”,他眼神一亮:“这个词好诶。”

许多年前,我还是一个初入职场的新人,在猎头行业学习如何阅读简历、判断人、理解组织。那时候,我的老板总时不时夸团队里的同学“有Sense”。

“有Sense”是咨询行业爱用的职场黑话,也是一句高度浓缩的赞美。努力是苦劳,聪明是天赋,而Sense,是前辈不必手把手教你,你也能和他们同频思考的那种东西。

很多年过去,当我转行做科技新闻,开始用另一种方式阅读人和组织,我发现自己依然在寻找那种“有Sense”的状态——并不是指作为老板对下属的期待,而是关于自我要求。

它至少有三重意思。

Information Sense,在信息过载的噪音中抓住重要信号和异常值。某个人的离职时间恰好对应前公司某次架构调整,一个行业薪资数据出现了反常波动。遇到问题,不只看表面,而是追问终极目的和WHY。

Social Sense,在进退之间掌握分寸。知道什么时候推进、什么时候退让,能判断对方的情绪状态和真实动机,而不是急于达到自己的目的。

Direction Sense,是在他人指令之外,眼里有活,心里有方向。上司布置了A,便能联想到B和C;市场一旦出现新动向,会主动去做mapping,而不是等KPI压下来。

三个维度,合起来才是完整的“有Sense”。而“三”这个数字,本身就有一种奇妙的叙事完整性。

哈利波特里有三个主角。三个臭皮匠,顶个诸葛亮。那个72小时杀入GitHub全球榜的文科生,背后有三个AI agent在协同工作。戏剧理论里,三幕剧是最为经典的结构——亚里士多德在《诗学》第七章中提出“头、身、尾”的行动整一律,后世将其提炼为“开端、冲突、解决”的三段式叙事逻辑。

我们的职业生涯,也像一部三幕剧——

入局之思,是初心,是找到兴趣、热爱与发力感的时刻。你不知道自己能否胜任,但你感觉到了某种召唤。

纷争之思,是面对竞争环境与行业趋势,从擅长之处起手,在碰撞中培养优势的时刻。你不再只是凭直觉行事,你开始理解规则,甚至质疑规则。

和解之思,是找到独特卡位,产生稀缺价值,踩准红利的时刻。比起妥协,这更像是斗争后的清醒——你知道自己是谁,也知道世界需要什么。

三是叙事的最小完整单元。一个人从“找感觉”到“有Sense”,往往也要走过这三幕。它并不是线性的。有人反复入局,有人长期纷争,有人在和解后又重新进入了冲突。

而在每一“幕”之间起决定作用的,是选择。

[2]

在电影《猜火车》的那段著名开场独白里,Mark Renton用戏谑而粗粝的念白,拆解了主流社会为青年们规划好的“标准人生轨道”:选择工作,选择家庭,选择房贷和分期付款,选择洗衣机、汽车和电动开罐器,选择低胆固醇和牙科保险。

然后Mark说:“但我是谁啊?我会想去做那些事情?我选择不选择生活:我选择一些别的什么东西。理由?没有理由。有了海洛因,谁还需要理由呢?”

这段1996年问世的台词,精准戳中了每一代年轻人的困境:当主流社会给你画好了“正确”的人生路线,人潜意识里总会有“不想选”的叛逆。但反叛本身如果只是一种逃避,就会像Mark一样坠入更虚无的泥潭。

二十年后,博伊尔在《猜火车2》里让中年Mark再次面对这段台词。这一次,他离开了海洛因,选择回归主流,完成了对“选择”的闭环回答——无论选择哪条路,直面选择、承担结果本身,就是生活的意义。

熟悉我们的读者知道,《职场Bonus》创办以来,我们写行业、写公司、写商业环境的潮起潮落。但始终有一类内容让我个人更着迷:那些具体的、在“三幕剧”中跋涉过的人,他们在关键节点做出的选择,他们与风口之间的关联,他们如何在个体的局限中寻找突破口。

因此,我们把《职场Bonus》里的人物向栏目「红利人物」,关注职业生存状况的报道,以及各类有助于青年自我成长的内容——无论是对AI、科技、出海的探索,还是关于认知方法论的拆解——迁移到这里,《有三思》。一条边界线是:我们不打算鼓吹什么新的“正确人生路线”。

两个账号,两种观察风口和红利的方式。《职场Bonus》将继续聚焦行业环境、公司与人才流动。

报道和写作,一直是一个让我能安静下来学习思考、抵抗熵增、专注于研究某项重要事情的契机。对于编辑部来说,我们希望以做选题的方式和读者们一起成长——把生活、工作中一切可能对自我成长有帮助的细节,解构变成有价值的课题,这是《有三思》内容的隐性目标。

[3]

最后,关于这个名字。

有三思,是“有Sense”的谐音,也是“三思而后行”的古训新说。我们相信,每位青年身上,都已具备某种原始的Sense,只是需要被唤醒、被校准、被验证。

1919年5月4日,一群青年走上街头,选择用呐喊对抗一个他们不认同的世界。一百零七年后的今天,我们选择用记录与思考,对抗另一种形式的麻木——那种在信息洪流中放弃筛选、在标准轨道中放弃追问、在重复劳作中放弃方向的麻木。

青年节从来不只是纪念。它是一个提醒:每一代人都有权利重新选择自己的活法。

五月,适合想清楚一件事,然后去做。三幕剧开启了,这里有我们相伴成长的约定,欢迎你来。

◎ 他们的回忆,选择,与寄语

(按姓氏首字母排序)

邓江

● 缘启智慧 | 创始人

2015年离开银行,是我人生最实际的一个决定。那份稳定很好,但它给不了我“从无到有创造一个东西”的体验。

创业之后,每个决策都直接关联着公司的生死和团队的饭碗。路是自己选的,所以每一步都得踩实。回头看,这个选择没给我“退路”,但给了我“全部的路”——现在的业务、团队,还有这十年的所有体验,都从这里长出来。

图源:公众号「有三思  U  Sense」

Davin YC Dong

● Cookiy AI | Founder & CEO

我人生中最重要的选择之一,是从一条看起来足够确定的大厂高管路径里走出来创业。

过去我管业务更关注规模和效率;后来我越来越相信,AI时代真正稀缺的,不只是更强的模型,而是对真实人类的深入理解。

所以我创立Cookiy AI,希望成为AI与真实人类之间的连接桥梁:让机器更懂人的声音、判断、感受和选择,也让每一个普通人的观点被听见。

祝每一个还在选择中的年轻人,都能听见内心真正的声音。

(上图:与团队合影;下图:与Y Combinator的CEO合影。)

图源:公众号「有三思  U  Sense」

图源:公众号「有三思  U  Sense」

杜雨

● 未可知AI | 创始人

刚毕业那会儿,手里攥着好几个offer,纠结了好久没跟风选薪资最高的。

记得当时站在宿舍楼道跟我妈打电话商量,她跟我说了句实在话:跟着兴趣走才走得长远。

行业总有起起落落,应届生起薪差距,大多是行业风口造成的,跟个人真正能力关系不大。只有真心喜欢一件事,才愿意沉下心扎根深耕,慢慢磨出自己的核心价值,路才能走得稳、走得久。

图源:公众号「有三思  U  Sense」

高学亨

● FUNDel 汎迪 | 創始人及首席执行官

「終身學習」是恩師在最後一課留下的錦囊,自此成為我的座右銘。

多年來堅持「日間工作、夜間進修」,從EMBA攻讀至法律博士,在校園結識了一群跨行業的共成長共欢樂的夥伴。

如今即便工作繁忙,仍堅持走進校園,身分雖從學生轉為教授,但初心未改。透過教學相長與學生亦師亦友、互相啟發,以另一種身份繼續學習旅程。

图源:公众号「有三思  U  Sense」

郭人杰

● 乐享科技 | 创始人、CEO

几年前我发现,中国已经有全世界最好的技术、最好的产品,但是很多人还没意识到,或者不愿意相信。

所以我们想,不仅自己要做出来全球最好的技术跟产品,还要让所有人知道这个事实。不光做到了,也要说出来。中国有最聪明、最高速迭代的技术人员,这件事还会继续下去。

图源:公众号「有三思  U  Sense」

 

郭晓力 

● 原则科技 | CEO 

2021年9月,我离开字节,再次创业。不是因为看到了确定的机会,而是因为我只有在做自己真正相信的事时,才是真实的自己。我想做自己的产品,做芦笋录屏、芦笋提词器,每一天都比上班快乐。 希望每一个年轻人都能早一点找到那件让自己心动的事——不一定是创业,可以是任何形式——然后义无反顾地靠近它。

图源:公众号「有三思  U  Sense」

何方舟

● NEED! | 联合创始人

2021年五月去阿那亚参加“招待会音乐节”, 舞台演出结束后我们用音响放着歌,步行去海边看日出:这群人有招待会刚认识的朋友,也有路边跟着我们的音响一起走向阿那亚礼堂的陌生人。 月亮还很亮,而海岸线已经破晓。

那年“招待会”,我们认识了很多后来的好朋友, 其中也有一起做 NEED! 品牌的合伙人,在阿那亚礼堂看日出的时刻成为了这些友谊的开始,那些美好回忆也鼓舞着我们。

快五年了,虽然这群朋友们已经不在同一个城市,但我们仍然保持联系,见面和一起去海边旅游,每次见到她们就觉得自己还很年轻,we are infinite!

图源:公众号「有三思  U  Sense」

刘晓宇

● 少年纵横 | 创始人

选“关键选择”这个方向:

我做过最关键的几次选择,都是在一切“看起来还不错”的时候离开。不是因为走投无路,而是因为不甘心只走一条被安排好的路。

后来才明白,人生里很多真正重要的选择,都不是在“确定”里发生的,而是在隐约觉得“这件事我不做会后悔”。

选择本身未必让你赢,但它会决定你成为谁。

图源:公众号「有三思  U  Sense」

李盛祥

● 六度智囊 | CEO

特地去看了下对于青年的年龄定义,刚过24岁,25岁那年的时候选择的创业(正好脱离青年的稚嫩了?),其实算挺早的。

从上海的高档写字楼到蜗居借住在杭州别人的办公室里,草根创业,无知无畏,期待用专家知识能让商业决策变得更好一些,期待让来自于产业的人通过我们再度服务于产业——创立“六度智囊”。我信奉创业就是披巾斩棘,打怪升级。

(图片是创业第一个月被第一个招募加入的员工偷拍的。)

图源:公众号「有三思  U  Sense」

Cathy Mao

● ASML China | HR Head

2003年的五月,非典碎了留学梦。看着身边同学纷纷加入车企和四大,踌躇满志的我面临就业即失业的窘境。父亲说:“撞了墙就转弯,人生全是活法。”我先是加入了一家小公司,走进职场的浩瀚海洋,也找到奋斗至今的职业领域。

找到热爱,也懂得放下,学会坚持,更感恩遇见。祝年轻的你们直面选择,勇毅前行!

图源:公众号「有三思  U  Sense」

沈斐

● 蔚来 | 高级副总裁,兼乐道总裁

11年前,我在电力行业,从没想过要跨界。

遇见蔚来时,我好奇:电动车和能源互联网,能碰撞出什么?跨界能学到什么?

好奇心赢了。

入职后第一个周末,我和新同事一起跑了上海马拉松。那是我加入蔚来的第一场马拉松。

11年过去,这场马拉松还在继续。我很庆幸,当时选择了好奇,而不是沿着惯性往前走。

最好的成长,往往在你不熟悉的赛道上。

PS. 当时蔚来汽车的英文还不叫NIO。

图源:公众号「有三思  U  Sense」

孙辰昕 Kelvin

● DINQ | 联合创始人

2019 年 3 月,我开始了第一次创业。第一天入驻办公室,桌上只有电脑、笔记本、乱七八糟的线,和一堆不确定。

那一刻我想到Jeff Bezos创立Amazon早期的照片:伟大的事业,起点也可以很普通。创业就是在没人看见、什么都还没有的时候,依然选择相信并继续往前推。

Always Day One,把这个游戏一直玩下去。

图源:公众号「有三思  U  Sense」

图源:公众号「有三思  U  Sense」

配图 | 寄语者提供

封面图 | Xinyi Wen(Unsplash)

让 AI 从称手到称心 - 肘子的 Swift 周报 #134

作者 Fatbobman
2026年5月4日 22:00

从开始深度使用 AI 工具至今已有三年。三年间,我亲历了 AI 能力的飞跃,也越来越清晰地触摸到它的边界。截至目前,AI 早已是非常出色的效率工具,但如何让它写出真正“称心”——符合我个人风格、想法与设计哲学——的代码,仍是一个不小的挑战。

用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. 难以做后端校验

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

豆包将在免费模式外新增付费订阅,推出三档月包/年包价格|最前线

2026年5月4日 17:49

近日,豆包App Store页面出现付费版本服务声明。声明称,为更好地服务专业用户,豆包将在免费版的基础上,推出包含更多增值服务的付费版本。同时,该页面还披露了三档订阅价格:标准版连续包月每月68元(连续包年688元)、加强版连续包月每月200元(连续包年2048元)、专业版连续包月每月500元(连续包年5088元)。

 

豆包

 

目前,尚未在豆包产品中看到相关的付费选项和功能。豆包官方回应称,“豆包始终提供免费服务,在免费服务的基础上,豆包也在探索推出更多增值服务,以满足不同用户的差异化需求。相关方案细节目前还在测试阶段,正式上线时会通过官方渠道发布完整信息。”

据接近豆包的人士透露,付费功能将主要专注在复杂任务和生产力场景,如PPT生成、数据分析、影视制作等。随着模型能力持续升级,产品已经能满足越来越多的复杂高价值任务。但此类任务需消耗更多算力与推理时间,因此豆包计划上线付费服务,满足好这部分复杂场景需求。免费版本则继续面向用户的日常使用。

 

全球最大汽车滚装船首靠上海港

2026年5月4日 17:45
5月4日13时30分,全球首艘万辆级汽车滚装船——巴拿马籍“格罗领航”轮缓缓靠泊上海南港码头。这是该轮4月28日下水运营后首次挂靠上海港,本航次将在南港码头装载3700余辆各类乘用车、商用车后驶离出境。(澎湃)

陈发树一季度新进伊利股份,持股市值近16亿元

2026年5月4日 17:27
随着上市公司一季报披露结束,陈发树一季度末的持仓数据也随之出炉。一季度,陈发树新进伊利股份前十大股东,继续持有云南白药等个股。具体来看,陈发树新进伊利股份前十大股东名单,持有约6044万股,持股比例0.96%,以伊利股份3月31日收盘价计算(下同),持股市值近16亿元。另外,陈发树继续持有雷电微力,持股市值约10亿元;继续持有云南白药,持股市值逾6亿元。从行业分布来看,陈发树持股较为分散,按申万一级行业分类,涉及食品饮料、国防军工、医药生物、传媒、机械设备等。(证券时报)

港交所拟重启黄金期货交易

2026年5月4日 16:41
据香港电台网站消息,港交所正准备未来数月内重启黄金期货交易。港交所市场主管余学勤出席立法会一个委员会会议时表示,会征询市场参与者及持份者的意见,优化合约设计和完善机制。他表示,因地缘政治影响,投资者就黄金买卖和储备时要分散风险,因此认为黄金期货的设计和香港实体黄金生态圈的连动绝对有必要。港交所会继续研究,迎合投资者需求,让新的期货产品交易有更显著增长。(财联社)

恒指收涨1.24%,恒生科技指数涨2.16%

2026年5月4日 16:11
36氪获悉,恒指收涨1.24%,恒生科技指数涨2.16%;半导体、硬件设备、机械板块领涨,富通科技涨超63%,潍柴动力涨超12%,华虹半导体涨超6%;石油石化、电信服务、煤炭板块跌幅居前,中港石油跌超6%,中国电信、蒙古能源跌超1%。

“五一”假期第四天,全社会跨区域人员流动量预计超2.9亿人次

2026年5月4日 15:35
记者从交通运输部了解到,今天(4日)全社会跨区域人员流动量预计29379.4万人次,同比增长4.2%。其中:铁路客运量预计2030万人次,同比增长2.6%;公路人员流动量预计26971万人次,同比增长4.4%;水路客运量预计162.4万人次;民航客运量预计216万人次。(央视新闻)
❌
❌