普通视图
GDAL 实现数据属性查询
2025,菜鸟的「Vibe Coding」时刻
🔥Angular高效开发秘籍:掌握这些新特性,项目交付速度翻倍
如何在前端编辑器中实现像 Ctrl + Z 一样的撤销和重做
【AI 编程实战】第 6 篇:告别复制粘贴 - 设计一个优雅的 HTTP 模块
初来乍到公司,git不会用,怎么在团队里写代码?
轻松上手Bootstrap框架
如何取消Vue Watch监听
微信小程序前端扫码动画效果绿色光效移动,四角椭圆
避开 React 性能陷阱:useMemo 与 useCallback 的正确打开方式
用 React + TailwindCSS 打造高体验登录页面
权限封装不是写个指令那么简单:一次真实项目的反思
跨域 Iframe 嵌套:调整内部 Iframe 高度的终极指南 (以及无解的真相)
如何在Vue中传递函数作为Prop
告别 CJS 库加载兼容坑
一杯茶时间带你基于 Yjs 和 reactflow 构建协同流程图编辑器 😍😍😍
前端性能革命:200 行 JavaScript 代码实现 Streaming JSON
1. 前言
5 月的时候,React 的核心开发者 Dan 发表了一篇名为《Progressive JSON》 的文章,介绍了一种将 JSON 数据从服务器流式传输到客户端的技术,允许客户端在接收到全部数据之前就开始渲染部分数据。
这可以显著提升用户体验,尤其是处理大型数据集时。
让我们以“获取用户文章”这个场景为例。
这是一个完整的数据结构:
{
"user": {
"id": 1,
"name": "John Doe",
"posts": [
{ "id": 101, "title": "First Post", "content": "..." },
{ "id": 102, "title": "Second Post", "content": "..." }
]
}
}
假设我们能够很快获取用户信息,但文章数据还需要一段时间从数据库获取。
与其等待数据完全加载完毕,不如先发送一个占位符表示文章字段:
{
"user": {
"id": 1,
"name": "John Doe",
"posts": "_$1"
}
}
客户端收到数据后,先将用户信息渲染出来。
然后,当文章数据准备完毕后,我们将文章数据作为一个单独的 chunk 发送:
{
"_$1": [
{ "id": 101, "title": "First Post", "content": "..." },
{ "id": 102, "title": "Second Post", "content": "..." }
]
}
客户端收到数据后,最后将文章数据渲染出来。
要实现这样一个功能,客户端需要具备处理这些占位符的能力,并在最终数据到达时替换为实际数据。
如果要实现这样一个单独的功能需要多少代码呢?
200 行就可以!
本篇文章和大家介绍下实现思路,供大家学习和思考使用。
2. 服务端实现
让我们来看下服务器端实现。
首先是服务端函数。
function serve(res, data) {
res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
res.setHeader("Transfer-Encoding", "chunked");
// 向客户端发送 chunks
res.write(JSON.stringify(...) + "\n");
res.write(JSON.stringify(...) + "\n");
// 当完成的时候
res.end();
}
这里有 2 点值得注意:
- 我们使用了
application/x-ndjson内容类型。
NDJSON,全拼 Newline Delimited JSON,其实就是一种换行符分割的 JSON,其中每一行都是一个有效的 JSON 对象。这允许我们在单个响应中发送多个 JSON 对象,并以换行符分隔。
- 我们使用了
Transfer-Encoding: chunked响应头。
使用该响应头,可以通知客户端,响应将分块发送。在调用 res.end()之前,请保持连接活跃状态。
其次,我们需要对数据进行分块。
实现方式也很简单,遍历数据对象,并用占位符替代那些暂时没有准备好的部分。
当遇到需要稍后发送的部分(一个 Promise)时,我们将其存储到队列中,并在准备就绪后,将其作为单独的数据块发送。
函数如下:
function normalize(value) {
function walk(node) {
if (isPromise(node)) {
const id = getId();
registerPromise(node, id);
return id;
}
if (Array.isArray(node)) {
return node.map((item) => walk(item));
}
if (node && typeof node === "object") {
const out = {};
for (const [key, val] of Object.entries(node)) {
out[key] = walk(val);
}
return out;
}
return node;
}
return walk(value);
}
函数递归遍历数据对象。
当遇到 Promise 时,它会生成一个唯一的占位符 ID,注册该 Promise 以便稍后解析,并返回该占位符。
对于数组和对象,它会递归处理它们的元素或属性。原始值将按原样返回。
这是注册 Promise 的代码:
let promises = [];
function registerPromise(promise, id) {
promises.push({ promise, id });
promise.then((value) => {
send(id, value);
}).catch((err) => {
console.error("Error resolving promise for path", err);
send(id, { error: "promise error", timeoutMs: TIMEOUT });
});
这是 send 的代码,send函数负责将解析后的数据发送给客户端:
function send(id, value) {
res.write(JSON.stringify({ i: id, c: normalize(value) }) + "\n");
promises = promises.filter((p) => p.id !== id);
if (promises.length === 0) res.end();
}
该 send 函数会向响应中写入一个新的数据块,其中包括占位符 ID 和 normalize 后的值。然后它会从队列中移除已经 resolve 的 Promise。如果没有其他要处理的 Promise,它就会结束响应,从而关闭与客户端的连接。
完整的实现代码点击这里。
最后,我们举一个从服务端发送的对象示例:
const data = {
user: {
id: 1,
name: "John Doe",
posts: fetchPostsFromDatabase(), // 返回一个 promise
},
};
async function fetchPostsFromDatabase() {
const posts = await database.query("SELECT * FROM posts WHERE userId = 1");
return posts.map((post) => ({
id: post.id,
title: post.title,
content: post.content,
comments: fetchCommentsForPost(post.id), // 返回一个 promise
}));
}
每篇文章还有一个评论字段(comments),该字段是一个 Promise 对象。意味着评论数据将在文章数据发送后,作为单独的片段发送。
3. 客户端实现
那客户端该如何实现呢?
在客户端,我们处理传入的数据块,并将占位符替换为实际数据。
我们可以使用 Fetch API 向服务器发送请求,并将响应读取为流。每当遇到占位符时,我们都会将其替换为一个 Promise,该 Promise 将在实际数据到达时解析。
核心逻辑如下:
try {
const res = await fetch(endpoint);
const reader = res.body.getReader();
const decoder = new TextDecoder();
async function process() {
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
try {
const chunk = JSON.parse(decoder.decode(value, { stream: true }));
chunk.c = walk(chunk.c);
if (promises.has(chunk.i)) {
promises.get(chunk.i)(chunk.c);
promises.delete(chunk.i);
}
} catch (e) {
console.error(`Error parsing chunk.`, e);
}
}
}
}
process();
} catch (e) {
console.error(e);
throw new Error(`Failed to fetch data from Streamson endpoint ${endpoint}`);
}
}
对流的处理,你可能感到陌生,可以拓展阅读我的这篇文章:《如何用 Next.js v14 实现一个 Streaming 接口?》
process 函数逐块读取响应流。每个数据块都被解析为 JSON,并调用 walk 函数将占位符替换为 Promise。
如果数据块包含先前注册的占位符 ID ,则相应的 Promise 会被解析为接收到的数据。关键在于 await reader.read(),它允许我们等待新数据到来。
walk函数用于将占位符替换为 Promise:
function walk(node) {
if (isPromisePlaceholder(node)) {
return new Promise((done) => {
promises.set(node, done);
});
}
if (Array.isArray(node)) {
return node.map((item) => walk(item));
}
if (node && typeof node === "object") {
const out = {};
for (const [key, val] of Object.entries(node)) {
out[key] = walk(val);
}
return out;
}
return node;
}
function isPromisePlaceholder(val) {
return typeof val === "string" && val.match(/^_\$(\d)/);
}
类似于服务端的 normalize 函数。当遇到占位符的时候,它会返回一个新的 Promise,该 Promise 将在实际数据到达时解析。对于数组和对象,它会递归处理它们的元素或属性。原始值则直接返回。当然,ID 必须与服务器端生成的 ID 匹配。
完整的实现代码点击这里。两个文件加起来一共 155 行代码。
4. NPM 包
本篇文章整理翻译自 Streaming JSON in just 200 lines of JavaScript。
作者还将代码整理成了一个 NPM 包:Streamson。
通过 npm 安装:npm intall streamson
服务端上使用:
import { serve } from "streamson";
import express from "express";
const app = express();
const port = 5009;
app.get("/data", async (req, res) => {
const myData = {
title: "My Blog",
description: "A simple blog example using Streamson",
posts: getBlogPosts(), // this returns a Promise
};
serve(res, myData);
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
客户端是一个 1KB 的 JavaScript 文件,地址:unpkg.com/streamson@l…
客户端使用如下:
const request = Streamson("/data");
const data = await request.get();
console.log(data.title); // "My Blog"
const posts = await request.get("posts");
console.log(posts); // Array of blog posts
5. 最后
作为准前端开发专家的你,第一时间获取前端资讯、技术干货、AI 课程,那不得关注下我的公众号「冴羽」。
流式传输 JSON 数据是一种提升 Web 应用感知性能的有效方法,尤其适用于处理大型数据集或动态生成数据。
通过在数据可用时立即发送部分数据,我们可以让客户端更早地开始渲染内容,从而带来更佳的用户体验。
React 19 修复了一个遗留多年的类型乌龙,过程竞如此曲折
前言
在 React 19 的更新列表中,useRef 的变化看起来极不起眼:
-
useRef()不再允许无参调用 -
RefObject.current不再是readonly -
MutableRefObject被删除
乍一看,这似乎只是一次 TypeScript 类型整理。
但如果你回顾这个改动的来龙去脉,就会发现:
它实际上是 React 对“引用”这个概念的一次根本性修正。
回顾:React 18 中 useRef 的类型魔法
在 React 18 + TypeScript 开发中,你肯定认为下面的代码是很正常的:
const ref = useRef<HTMLInputElement>(null);
return (
<input ref={ref} />
)
这里必须传 null,否则 TypeScript 会报错。
背后的原因是:useRef 在 React 18 中有一组非常特殊的重载定义:
/**
* React 18 中 useRef 的 3 种 overload
**/
// ① 传入非 null
function useRef<T>(initialValue: T): MutableRefObject<T>
// ② 传入 T | null
function useRef<T>(initialValue: T | null): RefObject<T>
// ③ 什么都不传
function useRef<T = undefined>(): MutableRefObject<T | undefined>
/**
* 两种 ref 类型
**/
interface RefObject<T> {
readonly current: T | null
}
interface MutableRefObject<T> {
current: T
}
TypeScript 会根据你传入的初始值来决定:
-
useRef(null)→ 你在创建一个 JSX ref(只读) -
useRef(0)→ 你在创建一个实例变量(可写)
也就是说:
React 用 null 这个值,去推断一个 ref 的语义。
为什么需要 readonly ?
这个设计早在 2018 年 12 月就被 一篇 issue 质疑:
现在可以给 ref.current 赋值了,示例参见:reactjs.org/docs/hooks-…
尝试给它赋值时会出现错误:无法赋值给 'current',因为它是常量或只读属性。
我尝试使用
@types/react包,但遇到了问题。
一开始有人指出了是 readonly 导致的只读问题,并考虑是否将其删除。但一名仓库成员 支持了 readonly 的设计,并做出如下解释:
他认为
readonly的设计是在确保正确使用 ref,尽管确实 ref 是可写的(没有被冻结)。当 Hooks 刚出现时,
useRef必须同时承担两种历史角色:
在 class 中 在 Hooks 中 this.input = createRef()useRef(null)this.timer = 0useRef(0)他认为应当在类型系统中保留这层区分:
JSX ref 是 React 拥有的,理应只读 普通 ref 是用户拥有的,所以可写
这是一个 Rust / C++ 风格的“指针所有权”模型,通过这种方式来明确
ref的所有权,防止意外行为。
另外他也给出了当时 想要一个可写 ref 的解决方案(因为确实有需要初始值为 null 的普通 ref 的情况),那就是在泛型中添加 | null,此时 TypeScript 会根据 “最具匹配原则”,命中 mutableRefObject 给你。例如:
const value = useRef<number | null>(null) // MutableRefObject
const mutableDom = useRef<HTMLDivElement | null>(null) // MutableRefObject
如果你还没理解这个原理,可以通过下面的表格思考:
overload 是否匹配 useRef<T>(T)→MutableRefObject<T>null∈HTMLDivElement✅useRef<T>(T | null)→RefObject<T>null∈HTMLDivElement | null✅因为此时
T包含了| null,所以null初始值命中了两个重载, 但由于T比T | null更具体,所以命中了第一个,返回MutableRefObject。通过这样的方式,可以稳定获得可写的ref,不管其是 JSX ref 还是 普通 ref 。
又有人提出,为何不设置成当 useRef() 的参数缺省时当做是对 JSX 的引用(由 React 管理),如果有初始值则被当做是一个实例变量(由用户管理):
![]()
这样设计看似语义上更加符合直觉,因为当是个实例变量时,用户一般都会将其进行初始化(提供初始值)。反而不提供意味着可能是 JSX 引用。但这个方案很快也被否定:
![]()
![]()
![]()
总而言之分为以下几点:
-
ref在被当成引用时理应被初始化为null:因为 React 在释放 ref 时(例如,当卸载一个有条件挂载的组件时)会为 ref 设置为null。如果 useRef 没有传递初始值,它会以undefined开始,这样一来,你就不得不给原本只需要考虑| null的东西加上| undefined,不适合类型推导。 -
useRef 始终是可变的且不可为
null:前文提到,如果你需要一个可变的 ref,就一定要给一个含| null的联合类型作为泛型。如果你同时给它一个不包含| null的泛型参数和一个null的初始值才会引发不可用的问题,而这个情况也是不符合常理的,因为你自己就违背了自己设置的泛型 —— 它不应该为null。 -
useRef 应当必填初始值:早期 React 文档有使用不带初始值的
useRef,这虽然可以工作但完全不利于类型推导。-
如果这个 ref 作为 JSX 引用,那么上面提到它可能为
null。而缺省的含义为undefined,所以你还要手动添加一个联合类型| null在泛型中。 - 如果这个 ref 作为实例变量,它的初始值就更加重要。
-
如果这个 ref 作为 JSX 引用,那么上面提到它可能为
另外他也给了一些代码示例来佐证这些观点:
// 你应该永远提供初始值,尽管有时文档没有这么做
// ref1:这应该被视作一个错误
const ref1 = useRef() // $ExpectError
// ref2:这是一个可写的 ref,但它只能被赋值为 null(你没有指定泛型,类型推导为 null)
const ref2 = useRef(null)
// ref3:这依然是一个可写的 ref,但它只能被赋值为 undefined,原理同上
const ref3 = useRef(undefined)
// ref4:这是一个可写的 ref,类型为 number
const ref4 = useRef(0)
// ref5:这是一个可写的 ref,类型为 number | null
const ref5 = useRef<number | null>(null)
// ref6:这是一个可写的 ref,类型是一个对象
const ref6 = useRef<React.CSSProperties>({})
// ref7:这是一个可写的 ref,类型为一个 JSX 引用或 null
const ref7 = useRef<HTMLElement | null>(null)
// ref8:这是唯一一个 ref 只读的例子
// 你没有在泛型参数中说明你希望能够向其中写入 null,却还是传入了一个 null。
// 我认为这表明该引用旨在用作元素引用(即由 React 拥有,你只是共享它)
const ref8 = useRef<HTMLElement>(null)
// ref9:这也不应该被允许,因为你没有在泛型里写到它可能是一个 undefined
// 这本质上就是如果我们允许不带参数的 useRef 会发生的情况
// 更糟糕的是,你不能把它用作元素的 ref,因为 React 可能无论如何都会往里面写入一个 null。
const ref9 = useRef<HTMLElement>(undefined) // $ExpectError
其实到这里我已经被说服了,这个 readonly 设计可谓非常优雅且理性。因此在 2019 年 5 月,大家决定不对这个类型定义做出修改。
转折:readonly 真的守护了我们吗?
依旧是上面谈论的那篇 issue。尽管人们终止了讨论,但有一位开发者提出了他的场景:
export const useCombinedRefs = <T>(...refs: Ref<T>[]) =>
useCallback(
(element: T) =>
refs.forEach(ref => {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element; // this line produces error
}
}),
refs,
);
这个函数的作用是将多个 ref 合并成一个 ref callback,从而让一个 组件/JSX 同时写入多个 ref。
这个场景是有价值的,因为在一些场景中,你可能需要使用 forwardRef 将一个 JSX 暴露给父组件,但自己也想拥有这个 JSX 的 ref:
const ref1 = useRef<HTMLInputElement>(null);
const ref2 = someForwardedRef;
<input ref={useCombinedRefs(ref1, ref2)} />
这段代码的逻辑在很多组件库(Radix UI、MUI ...)都是核心基础设施。但很遗憾,就如此常用的用法,在上面看似为你好的 readonly 方案中会报错。因为你无法保证外部传来的 ref 是可写的,而且你也不能要求所有调用方都写成 useRef<HTMLElement | null>(null)。
人们被逼无奈要写一个类型断言来规避问题:
(ref as React.MutableRefObject<T | null>).current = element;
![]()
但仅因发现这一个问题,人们依旧没有意识到这个设计有问题,所以选择自适应。
直到 2023 年 5 月,一篇推文又把这个议题拉入大众视野:
![]()
随之当天创建了 Pull Request,并开启了二度讨论。
Matt 的那条推文并没有提出一个新 bug。
它真正做的事情是把一个已经被生态长期用类型断言绕过的问题,公开化、显性化。
在那条推文下,最典型的反应不是“这是 bug”,而是:
“这不是设计如此吗?你可以用
| null解决。”
这正是 2018 年那套 ownership 模型的遗产。
但这一次不同的地方在于:
React 自己已经进入了一个“ref 会被反复重写”的时代。
- StrictMode 下的 mount → unmount → mount
- Suspense / Offscreen 的 detach → attach
- Streaming hydration
- Server Components 的 DOM 复用
- forwardRef 与 imperativeHandle 的广泛使用
- ...
而这些写入,一半来自 React,一半来自于开发者。所以这更加意味着 JSX ref 不应是 React 独占管理的内容。
正是因为这样,“让所有 ref 可写” 变成了 RFC 项目。始于 18 年的话题终在 23 年再次深入讨论并计划于 React 19 上线。
在另一篇讨论 到底是删除 RefObject 还是删除 MutableRefObject 的 Pull Request 中,对这个历史问题留下了总结:
RefObject这个类型来自createRef(class 时代),
但我们错误地把它套在了useRef上。
function 组件里的 ref 从来就不是 React 独占的。
最后他们选择了删除 MutableRefObject,并为 RefObject 正名 —— ref 就是可写的。
-interface RefObject<T> {
- readonly current: T | null
-}
+interface RefObject<T> {
+ current: T
+}
ref 类型纠正背后的意义
这一改动不仅是减少了几行类型定义代码,它标志着 React 团队在API 设计哲学和开发体验之间做出的重要权衡。
1. 心智模型的回归:从“所有权”到“容器”
React 18 的类型系统试图通过 readonly 强加一种Rust/C++ 风格的“指针所有权”模型:
- 只读 Ref:意味着“React 拥有这个 DOM,你只能看”。
- 可变 Ref:意味着“这是你的变量,你可以随便改”。
但这违背了 JavaScript 开发者的直觉。在 JS 中,对象默认就是可变的。useRef 在本质上只是一个在重渲染之间保持引用的容器 。
React 19 放弃了这种人为的“类型洁癖”,承认了 useRef 的本质:它就是一个 { current: ... } 对象。无论里面放的是 DOM 节点还是普通数据,它在物理上都是可写的。
变化前:开发者需要根据“是否传入 null”来揣摩类型推导的规则。
变化后:
useRef就是一个储物柜,钥匙一直在你手里。
2. 承认现实:DOM 引用是“共享状态”而非“独占状态”
正如前文提到的 useCombinedRefs 案例,以及 React 生态中大量存在的 forwardRef 场景,现实中的 DOM 引用往往是多方协作的结果:
- React 在 commit 阶段写入 DOM。
- 父组件可能需要通过 callback ref 劫持这个 DOM。
- 第三方库(如动画库、拖拽库)可能直接操作这个 DOM。
旧的 RefObject 试图假装 DOM 引用是静态的、由 React 独占的,这是一种 “虚假的安全感”。React 19 的改动承认了 Ref 是组件与其外部环境(DOM、Web API、第三方库)之间的共享可变状态。
3. 给 React 19 的其它新特性铺路
如果把 React 19 的 ref 相关变化放在一起看,你会发现 useRef 的类型更新并不是孤立事件,而是一个“让所有 ref 行为在类型层面自洽”的基础设施升级。
1)ref as a prop:ref 开始变成“组件 API”的一部分
React 19 引入了 ref 作为函数组件的 prop 的能力:你可以在组件参数里直接拿到 ref,因此新函数组件不再需要 forwardRef 这层包装,官方也明确表示未来会逐步弃用并移除 forwardRef。 React
一旦 ref 变成普通 prop,生态里最常见的模式会变得更“显性”:
- 组件库 / 业务组件都会更频繁地 透传 ref
- 也会更频繁地 合并多个 ref(内部自己要用一个 ref,外部也传了 ref)
而这恰恰就是 React 18 时代 readonly RefObject 最尴尬的点:
你写一个通用的 mergeRefs/useCombinedRefs 工具函数时,根本无法保证外部传入的是“可写 ref” ,最终只能靠断言/any 解决。
React 19 把所有 RefObject.current 统一为可写,本质上是在为“ref 作为组件 API”扫清障碍:
ref 不再被类型系统暗示成“只属于 React 的只读视图”,而是一个可以被 React 与用户共同写入的槽位。 React
你可以在文章里点明这一句:
ref as a prop让 ref 的流动更频繁,而“所有 ref 可写”让这种流动在类型层面不需要额外的 hack。
2)Ref callback 支持 cleanup:ref 的“释放语义”正在变化,TS 必须更严格
React 19 还引入了一个很容易被忽略但很关键的 ref 行为变化:ref callback 可以返回 cleanup 函数,React 会在元素从 DOM 移除时调用这个 cleanup。
并且官方明确说明:如果你返回了 cleanup,React 将不再走“卸载时用 null 再调用一次 ref callback”的旧路径,并且未来会逐步弃用“卸载时传 null”这个行为。
这带来一个直接后果:TypeScript 必须能区分你返回的到底是 cleanup 还是“你不小心写了隐式返回值” 。因此升级指南里专门强调:由于 cleanup 的引入,ref callback 返回其它值会被 TS 拒绝,并提供 codemod 来迁移隐式返回。
你会发现这里的主题和 useRef 类型更新是一致的:
- ref 的能力变强了(callback cleanup)
- TS 必须更准确地反映真实行为(避免“看起来能写、其实类型不让写 / 看起来返回了个值、其实不是 cleanup”)
而“所有 ref 可写”的统一模型,使 ref 相关 API 在类型层面更一致:
你不需要再用 “readonly 代表 React 所有” 的旧抽象去解释复杂生命周期。
3)useRef 必须传初始值:不是“强迫症”,是为了让类型推导停止猜测
React 19 升级指南里对这一点讲得非常直白:
-
useRef现在必须传入一个初始值 - 这会显著简化类型签名,让它行为更像
createContext - 同时,这也意味着 所有 ref 都可变,不会再出现“因为用 null 初始化导致 current 只读”的问题 React
这里你可以把它写成一个“价值主张”:
React 18 的类型系统在尝试从
null/非null/无参推断你的语义(JSX ref vs 实例变量)。
React 19 选择停止猜测:你必须明确给初始值,类型也就能更稳定、更一致。
并且官方还给出了迁移策略:React 19 codemod 里就包含 refobject-defaults,专门处理 useRef() 无参的迁移。 React
4)把这三件事连起来:React 19 的 ref 观念正在从“所有权”走向“共享槽位”
把 React 19 的三条 ref 相关变化并排看:
- ref as a prop:ref 是组件 API 的一部分(减少 forwardRef 依赖)
- ref callback cleanup:ref 生命周期语义更完善,更像 effect cleanup
- useRef 类型统一 + 必填初值:停止用 overload 进行语义猜测,统一为可写 RefObject
你会得到一个非常清晰的结论:
React 19 想要的 ref,不再是“React 拥有 / 用户旁观”的所有权模型,
而是一个在运行时会被多方写入、需要明确生命周期语义的共享槽位(mutable identity)。
这也解释了为什么 readonly RefObject 最终必须被移除:
它不是“语义标注”,而是一种会在 ref 合并、透传、工具函数中不断制造摩擦的类型幻觉。
结语
现在是 2026 年,React 19 也早就不是新闻了。
但我还是想写这篇整理,是因为这种“悄无声息的架构修正”往往最容易被忽略。大家会记得 Server Components、Compiler、Actions,却很少有人去回头看:这些东西成立的前提,是 ref 这种最底层的概念被重新定义过。
useRef 变成“所有 ref 都可写”,表面上看是去掉了一个 readonly。
实际上,它是 React 放弃了一种曾经非常优雅、但已经不再真实的抽象。
我觉得这件事本身,就值得被记住。