React19新特性源码剖析

1,036 阅读9分钟

2024年12月5日,React19终于出了~

图片

前言

本文章配置视频版~

本篇文章里会涉及三个简单项目。

一个DebugReact,大部分例子都在这个项目里。注意这个项目涉及源码包你可能跑不起来,可以使用wolai的DebugReact代码包。或者把这里的页面代码粘贴到自己启动的项目里启动。

一个yu-next,ssr相关代码在这里。

一个koa-lemon,一个后端服务。

基本概念补充

transition与state

React18加入了transition的概念,其实类似以前的state。

不同的是,React18区分transition为非紧急更新(过渡更新) ,而state为紧急更新

比如输入框和模糊查询框的例子,输入框与用户交互直接相关,因此是紧急更新。而下拉框由于非直接交互,则为非紧急更新。

Optimistic乐观

React19新加入了一个概念,叫做乐观

其实React18中已经应用乐观,比如过渡更新其实就涉及了乐观更新,参考苹果手机设置里页面翻页的延迟,就是一种乐观设计

所谓乐观,就是我觉得页面99%的几率下是会成功更新的,那么这个时候的API和交互写法都是优先考虑这种场景,至于1%失败的情况,不太参考,有点像target marketing。

React19种的乐观

React中加入了乐观的直接API,如useOptimistic

React19新特性总述

React19中,包含了新特性、破坏性改动(ref)、改进等。

Actions

React19在transition中添加了对异步函数的支持,即在状态变更中,React会帮你处理pending、error、result。看下面的例子:

以前

import { useState } from "react"; export default function ActionPage() { const [user, setUser] = useState(""); const [isPending, setPending] = useState(false); const handleSubmit = async () => { setPending(true); const res = await fetch("https://randomuser.me/api") .then((x) => x.json()) .then((x) => x.results[0]); setPending(false); setUser(res.name.first); }; return ( <div> <h3>ActionPage</h3> <p>userName: {user}</p> <button onClick={handleSubmit} disabled={isPending}> {isPending ? "Updating.." : "Update"} </button> </div> ); }

现在

这里使用useTransition,当然如果你不需要isPending,也可以使用startTransition。

import { useState, useTransition } from "react"; export default function ActionPage() { const [user, setUser] = useState(""); const [isPending, startTransition] = useTransition(); const handleSubmit = () => { startTransition(async () => { const res = await fetch("https://randomuser.me/api") .then((x) => x.json()) .then((x) => x.results[0]); setUser(res.name.first); }); }; return ( <div> <h3>ActionPage</h3> <p>userName: {user}</p> <button onClick={handleSubmit} disabled={isPending}> {isPending ? "Updating.." : "Update"} </button> </div> ); }

在异步transition中,isPending会先被设置为true,然后发起异步请求,随后在异步请求结束后,isPending再被设置为false。

这保证了状态变更的时候,UI的响应和连续性。

New hook:useActionState

在action的基础上,React19添加了两个hook API,useActionStateuseOptimistic,前者针对一般情况,后者针对乐观情况。

如下面的例子,简化了form中相关写法:

import { useActionState, useState } from "react"; export default function UseActionStatePage() { const [user, setUser] = useState(""); const [error, submitAction, isPending] = useActionState( async (previousState, formData) => { console.log(formData.get("name")); //sy-log const res = await fetch("https://randomuser.me/api") .then((x) => x.json()) .then((x) => x.results[0]); setUser(res.name.first); // window.location.href = "https://www.baidu.com"; }, null ); return ( <div> <h3>UseActionStatePage</h3> <form action={submitAction}> <input type="text" name="name" /> <button type="submit" disabled={isPending}> {isPending ? "Updating.." : "Update"} </button> {error && <p>{error}</p>} </form> <p>userName: {user}</p> </div> ); }

useFormStatus

React DOM的New hook。

import { useActionState, useState } from "react"; import { useFormStatus } from "react-dom"; export default function UseFormStatusPage() { const [user, setUser] = useState("default"); const [error, submitAction, isPending] = useActionState( async (previousState, formData) => { const res = await fetch("https://jsonplaceholder.typicode.com/posts", { method: "POST", body: JSON.stringify({ user: formData.get("name"), userId: 1, }), headers: { "Content-type": "application/json; charset=UTF-8", }, }).then((response) => response.json()); setUser(res.user); }, null ); return ( <div> <h3>UseFormStatusPage</h3> <form action={submitAction}> <input type="text" name="name" /> <DesignButton> {isPending ? "DesignButton Updating.." : "DesignButton Update"} </DesignButton> {error && <p>{error}</p>} </form> <p>userName: {user}</p> {/* 注意下面这个 DesignButton 不能通过 useFormStatus 获取pending,因为它不在form内 */} {/* <DesignButton>{isPending ? "2Updating.." : "2Update"}</DesignButton> */} </div> ); } function DesignButton(props) { const status = useFormStatus(); console.log( "%c [ pending ]-41", "font-size:13px; background:pink; color:#bf2c9f;", status, status.data?.get("name") ); return ( <> <button type="submit" disabled={status.pending} {...props} /> {/* <button onClick={() => { // omg console.log( "%c [ ]-49", "font-size:13px; background:pink; color:#bf2c9f;", status ); typeof status.action === "function" && status.action(); }} > test </button> */} </> ); }
useFormStatus具体用法

useFormStatus提供上一个form提交的状态信息。

const { pending, data, method, action } = useFormStatus();
  • pending: 布尔值. 如果为true, 意思是父<form> 在提交中. 否则是false
  • data: 基于FormData interface的一个对象,包含了父form提交的数据。如果没有提交或者没有父form,为null
  • method:'get' or'post'. 表示父form提交的方法。默认是GET
  • action:  如果用户在父form定义了action函数,这里就是<form>action函数引用。否则为null。比如 没有父<form>或者form的action是字符串, 那这里就是null
注意事项

useFormStatus必须用在渲染form的组件内部。

乐观 hookuseOptimistic

假如:从A页面切换到B页面,需要先经过一个异步函数,当异步函数完成之后,再进行B页面的渲染。这是正常操作。

乐观点想,大部分用户的网络、环境都是极好的,那在异步完成之前,先去切换A到B。这就是乐观。 useOptimistic就是一个让你实现乐观渲染的Hook。

运行下面的代码,观察optimisticState.count的更新与Button的pending和console.log的变更。

import { useOptimistic, useState, useRef } from "react"; import { updateSomething } from "../utils"; function Thread({ messages, sendMessage }) { const formRef = useRef(); async function formAction(formData) { addOptimisticMessage(formData.get("message")); formRef.current.reset(); await sendMessage(formData); } const [optimisticMessages, addOptimisticMessage] = useOptimistic( messages, (state, newMessage) => [ ...state, { text: newMessage, sending: true, }, ] ); console.log( "%c [ ]-24", "font-size:13px; background:pink; color:#bf2c9f;", optimisticMessages ); return ( <> {optimisticMessages.map((message, index) => ( <div key={index}> {message.text} {!!message.sending && <small> (Sending...)</small>} </div> ))} <form action={formAction} ref={formRef}> <input type="text" name="message" placeholder="Hello!" /> <button type="submit">Send</button> </form> </> ); } export default function UseOptimisticPage() { const [errorMsg, setErrorMsg] = useState(""); const [messages, setMessages] = useState([ { text: "Hello there!", sending: false, key: 0 }, ]); async function sendMessage(formData) { const msg = formData.get("message"); const res = await updateSomething({ msg }); if (res.error) { setErrorMsg(msg + "-" + res.error.msg); } else { setErrorMsg(""); setMessages((messages) => [ ...messages, { text: res.msg, key: messages.length }, ]); } } return ( <div> <h3>UseOptimisticPage</h3> <Thread messages={messages} sendMessage={sendMessage} /> <p className="red">{errorMsg}</p> </div> ); }
乐观的错误处理

如果错误发生,乐观值会自动回滚到上一次。

use

use可以读取promise和context。

不同于传统的hook规则,use可用在条件或者循环语句读取context中。

const value = use(resource);

例子:

import { useState, use, Suspense } from "react"; import { ErrorBoundary } from "react-error-boundary"; function fetchMessage() { return new Promise((resolve, reject) => setTimeout(reject, 1000)); } export default function UsePage() { const [messagePromise, setMessagePromise] = useState(null); const [show, setShow] = useState(false); function download() { setMessagePromise(fetchMessage()); setShow(true); } if (show) { return <MessageContainer messagePromise={messagePromise} />; } else { return <button onClick={download}>Download message</button>; } } function MessageContainer({ messagePromise }) { console.log( "%c [ MessageContainer ]-25", "font-size:13px; background:pink; color:#bf2c9f;" ); return ( <ErrorBoundary fallback={<p>⚠️Something went wrong</p>}> <Suspense fallback={<p>⌛Downloading message...</p>}> <Message messagePromise={messagePromise} /> </Suspense> </ErrorBoundary> ); } // use的父组件不要写太复杂 function Message({ messagePromise }) { console.log( "%c [ msg ]-26", "font-size:13px; background:pink; color:#bf2c9f;" ); const content = use(messagePromise); return <p>Here is the message: {content}</p>; }

支持异步

use可读取promise。

读取context

import {use} from 'react'; import ThemeContext from './ThemeContext' function Heading({children}) { if (children == null) { return null; } // This would not work with useContext // because of the early return. const theme = use(ThemeContext); return ( <h1 style={{color: theme.color}}> {children} </h1> );

支持条件式调用

这里原因主要在于context的存储不同于普通hooks。

普通hooks的值存储在hooks单链表中,即fiberNode.memoizedState中,但是context是存储在一个单独的stack中。

use源码

function use<T>(usable: Usable<T>): T { if (usable !== null && typeof usable === 'object') { // $FlowFixMe[method-unbinding] if (typeof usable.then === 'function') { // This is a thenable. const thenable: Thenable<T> = (usable: any); return useThenable(thenable); } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext<T> = (usable: any); return readContext(context); } } // eslint-disable-next-line react-internal/safe-string-coercion throw new Error('An unsupported type was passed to use(): ' + String(usable)); }

New React DOM Static APIs

简单来说,现在的服务端组件可以使用async/await函数。

以前的话,要使用useEffect,或者是使用next提供API,比如getServerSideProps或者getStaticProps函数。

React19新增了用于static site generation(SSG)的API,用于代替renderToString,原因在于renderToString不支持流式布局。

React Server Components

ssr代码在yu-next中。

服务端组件是一种新型组件,它让你的组件可以提前渲染,即在bundling之前渲染,或者实在ssr中渲染。

没有Server的Server组件

以前
// bundle.js import marked from 'marked'; // 35.9K (11.2K gzipped) import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped) function Page({page}) { const [content, setContent] = useState(''); // NOTE: loads *after* first page render. useEffect(() => { fetch(`/api/content/${page}`).then((data) => { setContent(data.content); }); }, [page]); return <div>{sanitizeHtml(marked(content))}</div>; }

服务

// api.js app.get(`/api/content/:page`, async (req, res) => { const page = req.params.page; const content = await file.readFile(`${page}.md`); res.send({content}); });
19
import marked from 'marked'; // Not included in bundle import sanitizeHtml from 'sanitize-html'; // Not included in bundle async function Page({page}) { // NOTE: loads *during* render, when the app is built. const content = await file.readFile(`${page}.md`); return <div>{sanitizeHtml(marked(content))}</div>; }

这里render出来的内容可以在SSR中转成HTML并上传到CDN。在应用程序加载的时候,客户端不需要加载到原先组件,也不用执行耗费时长的异步。客户端只会见到:

<div><!-- html for markdown --></div>
例子
export default async function ServerComponentWithoutServer() { const res = await fetch("https://fakestoreapi.com/products/1").then((res) => res.json() ); return ( <div> <h3>ServerComponentWithoutServer</h3> <p>{res.title}</p> </div> ); }

参考yu-next中的例子,在pnpm build之后,你会看到这样的代码。

图片

另一个例子

上面例子中用的是fake api,无法在服务端监听,也可以用自己本机的服务,比如这里我用了koa-lemon中的server.js。

export default async function ServerComponentWithoutServer() { // const res = await fetch("https://fakestoreapi.com/products/1").then((res) => // res.json() // ); const res = await fetch("http://localhost:3000").then((res) => res.json()); return ( <div> <h3>ServerComponentWithoutServer</h3> <p>{res.title}</p> </div> ); }

服务端监听,在yu-next中执行pnpm build,发现这个时候打印log。

图片

Server里的Server Components

服务端组件也可以运行在web服务中,在bundled之前渲染。只是启动的

之前
/ bundle.js function Note({id}) { const [note, setNote] = useState(''); // NOTE: loads *after* first render. useEffect(() => { fetch(`/api/notes/${id}`).then(data => { setNote(data.note); }); }, [id]); return ( <div> <Author id={note.authorId} /> <p>{note}</p> </div> ); } function Author({id}) { const [author, setAuthor] = useState(''); // NOTE: loads *after* Note renders. // Causing an expensive client-server waterfall. useEffect(() => { fetch(`/api/authors/${id}`).then(data => { setAuthor(data.author); }); }, [id]); return <span>By: {author.name}</span>; }
19
import db from './database'; async function Note({id}) { // NOTE: loads *during* render. const note = await db.notes.get(id); return ( <div> <Author id={note.authorId} /> <p>{note}</p> </div> ); } async function Author({id}) { // NOTE: loads *after* Note, // but is fast if data is co-located. const author = await db.authors.get(id); return <span>By: {author.name}</span>; }
yu-next中可运行的例子

这个页面会被输出为static html

import { NavLink } from "src/components/NavLink"; export default async function ServerComponentWithServer() { const { results } = await fetch("https://randomuser.me/api/").then((res) => res.json() ); const username = results[0].name.first; return ( <div> <h3>ServerComponentWithServer</h3> <p>{username}</p> <NavLink href={`with-server/${username}`}>Navigate to details</NavLink> </div> ); }

查看我部署到vercel上之后,yu-next-oi12ntxtk-bubucuos-projects.vercel.app/ssr/with-se…

图片

而下面这个页面,每次进入,内容都不同。

export default async function DynamicUserName(props) { const { username } = await props.params; const { results } = await fetch("https://randomuser.me/api/").then((res) => res.json() ); const newUserName = results[0].name.first; return ( <div className="box"> <h3>DynamicUserName</h3> <p>username from last page: {username}</p> <p>newUserName: {newUserName}</p> </div> ); }

线上地址是yu-next-oi12ntxtk-bubucuos-projects.vercel.app/ssr/with-se…

图片

可以反复刷新页面,观察下newUserName值的变化。

给服务端组件添加交互

服务端组件在服务端完成渲染,因此不能使用客户端交互使用的API,如useState。

如果涉及交互,可以抽离组件出来,比如Child。并在Child组件最上方添加'use client'标记其为客户端组件。

如:

import Refresh from "../../components/Refresh"; export default async function SSR() { const { results } = await fetch("https://randomuser.me/api/").then((res) => res.json() ); const username = results[0].name.first; return ( <div> <h3>SSR</h3> <p>ssr-{username}</p> <Refresh username={username} /> </div> ); }

添加交互:

"use client"; import { useState } from "react"; export default function Refresh({ username }) { const [user, setUser] = useState(username); const refresh = async () => { const { results } = await fetch("https://randomuser.me/api/").then((res) => res.json() ); const username = results[0].name.first; setUser(username); }; return ( <> <p>{user}</p> <button className="btn" onClick={refresh}> refresh </button> </> ); }

Improvements in React 19

ref as a prop

从19开始,ref可以作为prop在函数组件中使用了。

而以前函数组件想要转发ref,必须使用forwardRef

这也意味着从19开始,forwardRef以后要被弃用了。

这里影响比较大的是各大组件库,因为基本上每个组件都有ref,以前的写法都是调用了forwardRef

比如现在next最新版15默认支持React19,但是AntD还是18,所以两者就不兼容,就会报错。

Diffs for hydration errors

改善了DEV环境下ssr的错误提示;

以前:

图片

现在:

图片

Cleanup functions for refs

19给ref加上了清理函数,以前可以用useEffect清理,以后ref有自己的清理函数了。

当组件卸载的时候,React会执行这个cleanup函数。

<input ref={(ref) => { // ref created // NEW: return a cleanup function to reset // the ref when element is removed from DOM. return () => { // ref cleanup }; }} />
对DOM Ref的影响

图片

现在要用下面的这个用法。

<Context> as a provider

就像把大象装冰箱里需要几步,以前是三步,打开冰箱、装大象进去、关上冰箱。

Context使用三步走:创建Context、Provider传递value、后代组件消费value。

现在只需要两步了,可以直接使用Context代替Provider:

const ThemeContext = createContext(''); function App({children}) { return ( <ThemeContext value="dark"> {children} </ThemeContext> ); }

Context.Provider以后会被弃用。

好消息是,React说会推出一个自动工具,自动修改代码中的Context.Provider到Context。

吐槽一下:还是以前更好理解。

useDeferredValue初始值

function Search({deferredValue}) { // On initial render the value is ''. // Then a re-render is scheduled with the deferredValue. const value = useDeferredValue(deferredValue, ''); return ( <Results query={value} /> ); }

Support for Document Metadata

图片

Support for stylesheets

图片

Support for async scripts

图片

支持 preloading resources

图片

Better error reporting

图片

图片

支持自定义元素

以前,React会把不认识的props当做attributes。

在19中,React支持了。

  • 服务端渲染中: 如果传递给自定义元素的 props 类型是原始值(如string、number)或者值为 true,则它们将呈现为attributes。具有非原始type(例如object,symbol,function, 或者 value是false)的 props 将被忽略掉。
  • 客户端渲染中:与自定义元素实例上的属性匹配的 props 将被分配为properties,否则它们将被分配为attributes。

综述

经历两年的时间,React19的改版还是比较大的。

总体来看,这些新特性都围绕在用户体验、开发体验上,比如说用户体验上(乐观、form)、 开发体验上的改进(action)、流式布局的进一步支持(Server Component)、换个更人性化的写法(ref、Context)、更丰富的支持(自定义原生、preload等)。