React Labs:视图过渡、Activity 等功能
2025 年 4 月 23 日,作者:Ricky Hanlon
在 React Labs 系列文章中,我们会介绍正在积极研究和开发的项目。本文将分享两个已经可供测试的新实验性功能,以及其他正在开发中功能的最新进展。
今天,我们很高兴发布两个可供测试的新实验性功能的文档:
我们还分享了目前正在开发的新功能的最新进展:
新的实验性功能
视图过渡和 Activity 现在已经可以在 react@experimental
中进行测试。这些功能已经在生产环境中经过测试并且稳定,但最终的 API 可能会随着我们采纳反馈而发生变化。
你可以通过将 React 包升级到最新的实验版本来尝试它们:
react@experimental
react-dom@experimental
继续阅读以了解如何在你的应用中使用这些功能,或者查看新发布的文档:
<ViewTransition>
:一个让你为 Transition 激活动画的组件。addTransitionType
:一个允许你指定 Transition 原因的函数。<Activity>
:一个让你隐藏和显示 UI 部分的组件。
视图过渡
React 视图过渡是一个新的实验性功能,它让你能更轻松地为应用中的 UI 过渡添加动画效果。在底层,这些动画使用了大多数现代浏览器提供的新 startViewTransition
API。
要选择为元素添加动画,请将其包装在新的 <ViewTransition>
组件中:
// "要"动画的内容
<ViewTransition>
<div>为我添加动画</div>
</ViewTransition>
这个新组件让你可以声明式地定义”要”在动画激活时进行动画处理的内容。
你可以通过使用以下三种触发器之一来定义”何时”进行动画:
// "何时"进行动画
// Transitions
startTransition(() => setState(...));
// 延迟值
const deferred = useDeferredValue(value);
// Suspense
<Suspense fallback={<Fallback />}>
<div>加载中...</div>
</Suspense>
默认情况下,这些动画使用视图过渡的默认 CSS 动画(通常是平滑的交叉淡入淡出)。你可以使用视图过渡伪选择器来定义动画”如何”运行。例如,你可以使用 *
来更改所有过渡的默认动画:
// "如何"进行动画
::view-transition-old(*) {
animation: 300ms ease-out fade-out;
}
::view-transition-new(*) {
animation: 300ms ease-in fade-in;
}
当 DOM 因动画触发器(如 startTransition
、useDeferredValue
或 Suspense
后备方案切换到内容)而更新时,React 将使用声明式启发法自动确定要为动画激活哪些 <ViewTransition>
组件。然后浏览器将运行在 CSS 中定义的动画。
如果你熟悉浏览器的视图过渡 API 并想了解 React 如何支持它,请查看文档中的<ViewTransition>
如何工作。
在本文中,让我们看几个使用视图过渡的例子。
我们将从这个应用开始,它不会为以下任何交互添加动画:
- 点击视频查看详情。
- 点击”返回”回到信息流。
- 在列表中输入以筛选视频。
import TalkDetails from './Details'; import Home from './Home'; import {useRouter} from './router'; export default function App() { const {url} = useRouter(); // 🚩这个版本还没有包含任何动画 return url === '/' ? <Home /> : <TalkDetails />; }
为导航添加动画
我们的应用包含一个支持 Suspense 的路由器,其中页面过渡已标记为 Transitions,这意味着导航是通过 startTransition
执行的:
function navigate(url) {
startTransition(() => {
go(url);
});
}
startTransition
是一个视图过渡触发器,因此我们可以添加 <ViewTransition>
来为页面之间的切换添加动画:
// "要"动画的内容
<ViewTransition key={url}>
{url === '/' ? <Home /> : <TalkDetails />}
</ViewTransition>
当 url
改变时,<ViewTransition>
和新路由会被渲染。由于 <ViewTransition>
是在 startTransition
内部更新的,因此 <ViewTransition>
会被激活以进行动画处理。
默认情况下,视图过渡包含浏览器默认的交叉淡入淡出动画。将其添加到我们的示例中,现在每当我们在页面之间导航时都会有交叉淡入淡出效果:
import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; export default function App() { const {url} = useRouter(); // Use ViewTransition to animate between pages. // No additional CSS needed by default. return ( <ViewTransition> {url === '/' ? <Home /> : <Details />} </ViewTransition> ); }
由于我们的路由器已经使用 startTransition
更新路由,添加 <ViewTransition>
的这一行更改就会激活默认的交叉淡入淡出动画。
如果你好奇这是如何工作的,请查看文档中的 <ViewTransition>
如何工作?
自定义动画
默认情况下,<ViewTransition>
包含浏览器默认的交叉淡入淡出效果。
要自定义动画,你可以根据 <ViewTransition>
的激活方式,为 <ViewTransition>
组件提供属性来指定要使用的动画。
例如,我们可以减慢 default
交叉淡入淡出动画:
<ViewTransition default="slow-fade">
<Home />
</ViewTransition>
并使用视图过渡类在 CSS 中定义 slow-fade
:
::view-transition-old(.slow-fade) {
animation-duration: 500ms;
}
::view-transition-new(.slow-fade) {
animation-duration: 500ms;
}
现在,交叉淡入淡出更慢了:
import { unstable_ViewTransition as ViewTransition } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); // Define a default animation of .slow-fade. // See animations.css for the animation definiton. return ( <ViewTransition default="slow-fade"> {url === '/' ? <Home /> : <Details />} </ViewTransition> ); }
有关样式化 <ViewTransition>
的完整指南,请参阅样式化视图过渡。
共享元素过渡
当两个页面包含相同的元素时,你通常希望将其从一个页面动画到下一个页面。
要做到这一点,你可以为 <ViewTransition>
添加一个唯一的 name
:
<ViewTransition name={`video-${video.id}`}>
<Thumbnail video={video} />
</ViewTransition>
现在视频缩略图在两个页面之间有动画效果:
import { useState, unstable_ViewTransition as ViewTransition } from "react"; import LikeButton from "./LikeButton"; import { useRouter } from "./router"; import { PauseIcon, PlayIcon } from "./Icons"; import { startTransition } from "react"; export function Thumbnail({ video, children }) { // Add a name to animate with a shared element transition. // This uses the default animation, no additional css needed. return ( <ViewTransition name={`video-${video.id}`}> <div aria-hidden="true" tabIndex={-1} className={`thumbnail ${video.image}`} > {children} </div> </ViewTransition> ); } export function VideoControls() { const [isPlaying, setIsPlaying] = useState(false); return ( <span className="controls" onClick={() => startTransition(() => { setIsPlaying((p) => !p); }) } > {isPlaying ? <PauseIcon /> : <PlayIcon />} </span> ); } export function Video({ video }) { const { navigate } = useRouter(); return ( <div className="video"> <div className="link" onClick={(e) => { e.preventDefault(); navigate(`/video/${video.id}`); }} > <Thumbnail video={video}></Thumbnail> <div className="info"> <div className="video-title">{video.title}</div> <div className="video-description">{video.description}</div> </div> </div> <LikeButton video={video} /> </div> ); }
默认情况下,React 会为每个激活过渡的元素自动生成一个唯一的 name
(参见 <ViewTransition>
如何工作)。当 React 看到一个带有 name
的 <ViewTransition>
被移除,而一个带有相同 name
的新 <ViewTransition>
被添加时,它将激活共享元素过渡。
有关更多信息,请参阅文档中的为共享元素添加动画。
基于原因的动画
有时,你可能希望元素根据触发方式以不同方式进行动画。对于这种用例,我们添加了一个名为 addTransitionType
的新 API 来指定过渡的原因:
function navigate(url) {
startTransition(() => {
// 原因为"向前导航"的过渡类型
addTransitionType('nav-forward');
go(url);
});
}
function navigateBack(url) {
startTransition(() => {
// 原因为"向后导航"的过渡类型
addTransitionType('nav-back');
go(url);
});
}
通过过渡类型,你可以通过 <ViewTransition>
的属性提供自定义动画。让我们为”6 个视频”和”返回”的标题添加共享元素过渡:
<ViewTransition
name="nav"
share={{
'nav-forward': 'slide-forward',
'nav-back': 'slide-back',
}}>
{heading}
</ViewTransition>
Here we pass a share
prop to define how to animate based on the transition type. When the share transition activates from nav-forward
, the view transition class slide-forward
is applied. When it’s from nav-back
, the slide-back
animation is activated. Let’s define these animations in CSS:
::view-transition-old(.slide-forward) {
/* when sliding forward, the "old" page should slide out to left. */
animation: ...
}
::view-transition-new(.slide-forward) {
/* when sliding forward, the "new" page should slide in from right. */
animation: ...
}
::view-transition-old(.slide-back) {
/* when sliding back, the "old" page should slide out to right. */
animation: ...
}
::view-transition-new(.slide-back) {
/* when sliding back, the "new" page should slide in from left. */
animation: ...
}
Now we can animate the header along with thumbnail based on navigation type:
import {unstable_ViewTransition as ViewTransition} from 'react'; import { useIsNavPending } from "./router"; export default function Page({ heading, children }) { const isPending = useIsNavPending(); return ( <div className="page"> <div className="top"> <div className="top-nav"> {/* Custom classes based on transition type. */} <ViewTransition name="nav" share={{ 'nav-forward': 'slide-forward', 'nav-back': 'slide-back', }}> {heading} </ViewTransition> {isPending && <span className="loader"></span>} </div> </div> {/* Opt-out of ViewTransition for the content. */} {/* Content can define it's own ViewTransition. */} <ViewTransition default="none"> <div className="bottom"> <div className="content">{children}</div> </div> </ViewTransition> </div> ); }
为 Suspense 边界添加动画
Suspense 也会激活视图过渡。
要为后备方案到内容的过渡添加动画,我们可以用 <ViewTransition>
包装 Suspense
:
<ViewTransition>
<Suspense fallback={<VideoInfoFallback />}>
<VideoInfo />
</Suspense>
</ViewTransition>
通过添加这个,后备方案将交叉淡入淡出到内容。点击一个视频,看看视频信息如何动画显示:
import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; function VideoDetails({ id }) { // Cross-fade the fallback to content. return ( <ViewTransition default="slow-fade"> <Suspense fallback={<VideoInfoFallback />}> <VideoInfo id={id} /> </Suspense> </ViewTransition> ); } function VideoInfoFallback() { return ( <div> <div className="fit fallback title"></div> <div className="fit fallback description"></div> </div> ); } export default function Details() { const { url, navigateBack } = useRouter(); const videoId = url.split("/").pop(); const video = use(fetchVideo(videoId)); return ( <Layout heading={ <div className="fit back" onClick={() => { navigateBack("/"); }} > <ChevronLeft /> Back </div> } > <div className="details"> <Thumbnail video={video} large> <VideoControls /> </Thumbnail> <VideoDetails id={video.id} /> </div> </Layout> ); } function VideoInfo({ id }) { const details = use(fetchVideoDetails(id)); return ( <div> <p className="fit info-title">{details.title}</p> <p className="fit info-description">{details.description}</p> </div> ); }
我们还可以使用后备方案上的 exit
和内容上的 enter
提供自定义动画:
<Suspense
fallback={
<ViewTransition exit="slide-down">
<VideoInfoFallback />
</ViewTransition>
}
>
<ViewTransition enter="slide-up">
<VideoInfo id={id} />
</ViewTransition>
</Suspense>
以下是我们如何在 CSS 中定义 slide-down
和 slide-up
:
::view-transition-old(.slide-down) {
/* Slide the fallback down */
animation: ...;
}
::view-transition-new(.slide-up) {
/* Slide the content up */
animation: ...;
}
Now, the Suspense content replaces the fallback with a sliding animation:
import { use, Suspense, unstable_ViewTransition as ViewTransition } from "react"; import { fetchVideo, fetchVideoDetails } from "./data"; import { Thumbnail, VideoControls } from "./Videos"; import { useRouter } from "./router"; import Layout from "./Layout"; import { ChevronLeft } from "./Icons"; function VideoDetails({ id }) { return ( <Suspense fallback={ // Animate the fallback down. <ViewTransition exit="slide-down"> <VideoInfoFallback /> </ViewTransition> } > {/* Animate the content up */} <ViewTransition enter="slide-up"> <VideoInfo id={id} /> </ViewTransition> </Suspense> ); } function VideoInfoFallback() { return ( <> <div className="fallback title"></div> <div className="fallback description"></div> </> ); } export default function Details() { const { url, navigateBack } = useRouter(); const videoId = url.split("/").pop(); const video = use(fetchVideo(videoId)); return ( <Layout heading={ <div className="fit back" onClick={() => { navigateBack("/"); }} > <ChevronLeft /> Back </div> } > <div className="details"> <Thumbnail video={video} large> <VideoControls /> </Thumbnail> <VideoDetails id={video.id} /> </div> </Layout> ); } function VideoInfo({ id }) { const details = use(fetchVideoDetails(id)); return ( <> <p className="info-title">{details.title}</p> <p className="info-description">{details.description}</p> </> ); }
为列表添加动画
你还可以使用 <ViewTransition>
为重新排序的项目列表添加动画,例如在可搜索的项目列表中:
<div className="videos">
{filteredVideos.map((video) => (
<ViewTransition key={video.id}>
<Video video={video} />
</ViewTransition>
))}
</div>
要激活 ViewTransition,我们可以使用 useDeferredValue
:
const [searchText, setSearchText] = useState('');
const deferredSearchText = useDeferredValue(searchText);
const filteredVideos = filterVideos(videos, deferredSearchText);
Now the items animate as you type in the search bar:
import { useId, useState, use, useDeferredValue, unstable_ViewTransition as ViewTransition } from "react";import { Video } from "./Videos";import Layout from "./Layout";import { fetchVideos } from "./data";import { IconSearch } from "./Icons"; function SearchList({searchText, videos}) { // Activate with useDeferredValue ("when") const deferredSearchText = useDeferredValue(searchText); const filteredVideos = filterVideos(videos, deferredSearchText); return ( <div className="video-list"> <div className="videos"> {filteredVideos.map((video) => ( // Animate each item in list ("what") <ViewTransition key={video.id}> <Video video={video} /> </ViewTransition> ))} </div> {filteredVideos.length === 0 && ( <div className="no-results">No results</div> )} </div> ); } export default function Home() { const videos = use(fetchVideos()); const count = videos.length; const [searchText, setSearchText] = useState(''); return ( <Layout heading={<div className="fit">{count} Videos</div>}> <SearchInput value={searchText} onChange={setSearchText} /> <SearchList videos={videos} searchText={searchText} /> </Layout> ); } function SearchInput({ value, onChange }) { const id = useId(); return ( <form className="search" onSubmit={(e) => e.preventDefault()}> <label htmlFor={id} className="sr-only"> Search </label> <div className="search-input"> <div className="search-icon"> <IconSearch /> </div> <input type="text" id={id} placeholder="Search" value={value} onChange={(e) => onChange(e.target.value)} /> </div> </form> ); } function filterVideos(videos, query) { const keywords = query .toLowerCase() .split(" ") .filter((s) => s !== ""); if (keywords.length === 0) { return videos; } return videos.filter((video) => { const words = (video.title + " " + video.description) .toLowerCase() .split(" "); return keywords.every((kw) => words.some((w) => w.includes(kw))); }); }
Final result
By adding a few <ViewTransition>
components and a few lines of CSS, we were able to add all the animations above into the final result.
We’re excited about View Transitions and think they will level up the apps you’re able to build. They’re ready to start trying today in the experimental channel of React releases.
Let’s remove the slow fade, and take a look at the final result:
import {unstable_ViewTransition as ViewTransition} from 'react'; import Details from './Details'; import Home from './Home'; import {useRouter} from './router'; export default function App() { const {url} = useRouter(); // Animate with a cross fade between pages. return ( <ViewTransition key={url}> {url === '/' ? <Home /> : <Details />} </ViewTransition> ); }
如果你想了解更多关于它们如何工作的信息,请查看文档中的<ViewTransition>
如何工作。
关于我们如何构建视图过渡的更多背景信息,请参阅:#31975、#32105、#32041、#32734、#32797、#31999、#32031、#32050、#32820、#32029、#32028 和 #32038,由 @sebmarkbage 完成(感谢 Seb!)。
Activity
在过去的更新中,我们分享了我们正在研究一个 API,允许组件在视觉上被隐藏并降低优先级,相比卸载或使用 CSS 隐藏,这种方式能以更低的性能成本保留 UI 状态。
现在我们准备分享这个 API 及其工作原理,这样你就可以开始在实验性 React 版本中测试它了。
<Activity>
是一个用于隐藏和显示 UI 部分的新组件:
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Page />
</Activity>
当 Activity 处于 visible(可见)状态时,它会正常渲染。当 Activity 处于 hidden(隐藏)状态时,它会被卸载,但会保存其状态并继续以低于屏幕上任何可见内容的优先级进行渲染。
你可以使用 Activity
来保存用户当前未使用的 UI 部分的状态,或预渲染用户可能接下来会使用的部分。
让我们看一些改进上面视图过渡示例的例子。
使用 Activity 恢复状态
当用户离开一个页面时,通常会停止渲染旧页面:
function App() {
const { url } = useRouter();
return (
<>
{url === '/' && <Home />}
{url !== '/' && <Details />}
</>
);
}
然而,这意味着如果用户返回到旧页面,所有之前的状态都会丢失。例如,如果 <Home />
页面有一个 <input>
字段,当用户离开页面时,<input>
会被卸载,他们输入的所有文本都会丢失。
Activity 允许你在用户切换页面时保留状态,这样当他们返回时可以从离开的地方继续。这是通过将树的一部分包装在 <Activity>
中并切换 mode
来实现的:
function App() {
const { url } = useRouter();
return (
<>
<Activity mode={url === '/' ? 'visible' : 'hidden'}>
<Home />
</Activity>
{url !== '/' && <Details />}
</>
);
}
通过这个更改,我们可以改进上面的视图过渡示例。之前,当你搜索视频、选择一个视频并返回时,你的搜索过滤器会丢失。使用 Activity,你的搜索过滤器会被恢复,你可以从离开的地方继续。
尝试搜索一个视频,选择它,然后点击”返回”:
import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; export default function App() { const { url } = useRouter(); return ( // View Transitions know about Activity <ViewTransition> {/* Render Home in Activity so we don't lose state */} <Activity mode={url === '/' ? 'visible' : 'hidden'}> <Home /> </Activity> {url !== '/' && <Details />} </ViewTransition> ); }
Pre-rendering with Activity
Sometimes, you may want to prepare the next part of the UI a user is likely to use ahead of time, so it’s ready by the time they are ready to use it. This is especially useful if the next route needs to suspend on data it needs to render, because you can help ensure the data is already fetched before the user navigates.
For example, our app currently needs to suspend to load the data for each video when you select one. We can improve this by rendering all of the pages in a hidden <Activity>
until the user navigates:
<ViewTransition>
<Activity mode={url === '/' ? 'visible' : 'hidden'}>
<Home />
</Activity>
<Activity mode={url === '/details/1' ? 'visible' : 'hidden'}>
<Details id={id} />
</Activity>
<Activity mode={url === '/details/1' ? 'visible' : 'hidden'}>
<Details id={id} />
</Activity>
<ViewTransition>
With this update, if the content on the next page has time to pre-render, it will animate in without the Suspense fallback. Click a video, and notice that the video title and description on the Details page render immediately, without a fallback:
import { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, use } from "react"; import Details from "./Details"; import Home from "./Home"; import { useRouter } from "./router"; import {fetchVideos} from './data' export default function App() { const { url } = useRouter(); const videoId = url.split("/").pop(); const videos = use(fetchVideos()); return ( <ViewTransition> {/* Render videos in Activity to pre-render them */} {videos.map(({id}) => ( <Activity key={id} mode={videoId === id ? 'visible' : 'hidden'}> <Details id={id}/> </Activity> ))} <Activity mode={url === '/' ? 'visible' : 'hidden'}> <Home /> </Activity> </ViewTransition> ); }
使用 Activity 进行服务端渲染
在使用服务端渲染(SSR)的页面上使用 Activity 时,有额外的优化。
如果页面的一部分使用 mode="hidden"
渲染,那么它将不会包含在 SSR 响应中。相反,React 会在页面其余部分进行水合(hydrate)的同时,为 Activity 内部的内容安排客户端渲染,优先处理屏幕上可见的内容。
对于使用 mode="visible"
渲染的 UI 部分,React 会降低 Activity 内容的水合优先级,类似于 Suspense 内容以较低优先级进行水合的方式。如果用户与页面交互,我们会在需要时优先处理边界内的水合。
这些是高级用例,但它们展示了 Activity 考虑的额外好处。
Activity 的未来模式
未来,我们可能会为 Activity 添加更多模式。
例如,一个常见的用例是渲染模态框,其中之前的”非活动”页面在”活动”模态视图后面可见。“hidden”模式不适用于这种情况,因为它不可见且不包含在 SSR 中。
相反,我们正在考虑一种新模式,它会保持内容可见——并包含在 SSR 中——但保持它卸载并降低更新优先级。这种模式可能还需要”暂停”DOM 更新,因为在模态框打开时看到背景内容更新可能会分散注意力。
我们正在考虑的 Activity 的另一种模式是,如果使用了太多内存,能够自动销毁隐藏的 Activity 的状态。由于组件已经卸载,与消耗过多资源相比,销毁应用中最近最少使用的隐藏部分的状态可能更可取。
这些是我们仍在探索的领域,随着进展我们会分享更多信息。有关 Activity 当前包含的功能的更多信息,请查看文档。
正在开发的功能
我们还在开发功能来帮助解决以下常见问题。
在我们迭代可能的解决方案时,你可能会看到我们正在测试的一些潜在 API,这些 API 基于我们正在合并的 PR 进行分享。请记住,当我们尝试不同的想法时,我们通常会在尝试后更改或删除不同的解决方案。
当我们正在开发的解决方案过早分享时,可能会在社区中造成混乱和困惑。为了平衡透明度和减少困惑,我们分享了我们当前正在为其开发解决方案的问题,而不分享我们心中的特定解决方案。
随着这些功能的进展,我们将在博客上宣布它们,并附上文档,以便你可以尝试它们。
React 性能追踪
我们正在开发一套新的自定义性能分析器追踪功能,使用浏览器 API 允许添加自定义追踪,以提供有关 React 应用性能的更多信息。
这个功能仍在开发中,因此我们还没有准备好发布文档将其作为实验性功能完全发布。当使用 React 的实验版本时,你可以获得一个预览,它会自动将性能追踪添加到分析中:


我们计划解决一些已知问题,如性能问题,以及调度器追踪不总是能在 Suspended 树之间”连接”工作,所以它还不完全可用。我们还在收集早期采用者的反馈,以改进追踪的设计和可用性。
一旦我们解决了这些问题,我们将发布实验性文档并分享它已准备好供尝试。
自动 Effect 依赖
当我们发布 hooks 时,我们有三个动机:
- 在组件之间共享代码:hooks 替代了像 render props 和高阶组件这样的模式,允许你在不改变组件层次结构的情况下重用有状态逻辑。
- 以函数而非生命周期的方式思考:hooks 让你可以根据相关的部分(如设置订阅或获取数据)将一个组件拆分成更小的函数,而不是强制基于生命周期方法进行拆分。
- 支持提前编译:hooks 的设计支持提前编译,减少了由生命周期方法和类的限制导致的无意去优化的陷阱。
自发布以来,hooks 在在组件之间共享代码方面取得了成功。Hooks 现在是在组件之间共享逻辑的首选方式,render props 和高阶组件的使用场景减少了。Hooks 还成功支持了像 Fast Refresh 这样的功能,这在类组件中是不可能实现的。
Effects 可能很难理解
不幸的是,有些 hooks 仍然很难以函数而非生命周期的方式思考。特别是 Effects 仍然很难理解,是我们从开发者那里听到的最常见的痛点。去年,我们花了大量时间研究 Effects 的使用方式,以及如何简化这些用例并使其更容易理解。
我们发现,困惑通常来自于在不需要的情况下使用 Effect。你可能不需要 Effect 指南涵盖了许多 Effects 不是正确解决方案的情况。然而,即使 Effect 是解决问题的正确选择,Effects 仍然可能比类组件生命周期更难理解。
我们认为造成困惑的原因之一是开发者从组件的角度(像生命周期一样)思考 Effects,而不是从 Effects 的角度(Effect 做什么)思考。
让我们看一个文档中的例子:
useEffect(() => {
// 你的 Effect 连接到由 roomId 指定的房间...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...直到它断开连接
connection.disconnect();
};
}, [roomId]);
许多用户会将这段代码理解为”在挂载时,连接到 roomId。每当 roomId
改变时,断开与旧房间的连接并重新创建连接”。然而,这是从组件的生命周期角度思考,这意味着你需要考虑每个组件生命周期状态才能正确编写 Effect。这可能很困难,所以当使用组件角度时,Effects 看起来比类生命周期更难理解是可以理解的。
没有依赖的 Effects
相反,最好从 Effect 的角度思考。Effect 不了解组件的生命周期。它只描述如何开始同步和如何停止同步。当用户以这种方式思考 Effects 时,他们的 Effects 往往更容易编写,并且更能适应根据需要多次启动和停止。
我们花了一些时间研究为什么 Effects 会从组件角度思考,我们认为原因之一是依赖数组。由于你必须编写它,它就在那里,提醒你你在”响应”什么,并引导你进入”当这些值改变时做这件事”的心智模型。
当我们发布 hooks 时,我们知道可以通过提前编译使它们更易于使用。使用 React 编译器,你现在可以在大多数情况下避免自己编写 useCallback
和 useMemo
。对于 Effects,编译器可以为你插入依赖项:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}); // 编译器插入的依赖项。
使用这段代码,React 编译器可以为你推断依赖项并自动插入它们,这样你就不需要看到或编写它们。通过像IDE 扩展和useEffectEvent
这样的功能,我们可以提供一个 CodeLens 来显示编译器在你需要调试时插入的内容,或通过移除依赖项来优化。这有助于强化编写 Effects 的正确心智模型,即 Effects 可以在任何时候运行,以将你的组件或 hook 的状态与其他内容同步。
我们希望自动插入依赖项不仅更容易编写,而且通过迫使你从 Effect 的作用角度思考,而不是从组件生命周期角度思考,使它们更容易理解。
编译器 IDE 扩展
本周早些时候我们分享了 React 编译器的候选发布版本,我们正在努力在未来几个月内发布编译器的第一个语义化版本稳定版。
我们还开始探索使用 React 编译器提供信息的方法,以改进对代码的理解和调试。我们开始探索的一个想法是一个由 React 编译器驱动的基于 LSP 的新实验性 React IDE 扩展,类似于 Lauren Tan 在 React Conf 演讲中使用的扩展。
我们的想法是,我们可以使用编译器的静态分析直接在你的 IDE 中提供更多信息、建议和优化机会。例如,我们可以为违反 React 规则的代码提供诊断,悬停显示组件和 hooks 是否被编译器优化,或者提供 CodeLens 来查看自动插入的 Effect 依赖项。
IDE 扩展仍处于早期探索阶段,但我们将在未来的更新中分享我们的进展。
Fragment Refs
许多 DOM API,如事件管理、定位和焦点等,在使用 React 编写时很难组合。这通常导致开发者求助于 Effects,管理多个 Refs,或使用像 findDOMNode
(在 React 19 中已移除)这样的 API。
我们正在探索向 Fragment 添加 refs,这些 refs 将指向一组 DOM 元素,而不仅仅是单个元素。我们希望这将简化管理多个子元素,并在调用 DOM API 时使编写可组合的 React 代码变得更容易。
Fragment refs 仍在研究中。当我们接近完成最终 API 时,我们将分享更多信息。
手势动画
我们还在研究增强视图过渡以支持手势动画的方法,例如滑动打开菜单或滚动浏览照片轮播。
手势因以下几个原因带来新的挑战:
- 手势是连续的:当你滑动时,动画与你的手指放置时间相关联,而不是触发并运行到完成。
- 手势不一定完成:当你释放手指时,手势动画可以运行到完成,或者根据你滑动的距离恢复到原始状态(比如当你只是部分打开菜单时)。
- 手势颠倒了旧状态和新状态:在动画过程中,你希望你正在从中进行动画的页面保持”活跃”和可交互。这颠倒了浏览器视图过渡模型,在该模型中,“旧”状态是快照,而”新”状态是实时 DOM。
我们相信已经找到了一种行之有效的方法,并可能引入一个新的 API 来触发手势过渡。目前,我们专注于发布 <ViewTransition>
,之后再重新审视手势相关功能。
并发存储
当我们发布带有并发渲染的 React 18 时,我们还发布了 useSyncExternalStore
,这样不使用 React 状态或上下文的外部存储库可以通过在存储更新时强制同步渲染来支持并发渲染。
使用 useSyncExternalStore
是有代价的,因为它会强制退出过渡等并发功能,并强制现有内容显示 Suspense 后备方案。
现在 React 19 已经发布,我们正在重新审视这个问题领域,创建一个原语,通过 use
API 完全支持并发外部存储:
const value = use(store);
我们的目标是允许在渲染期间读取外部状态而不会撕裂,并与 React 提供的所有并发功能无缝协作。
这项研究仍处于早期阶段。当我们进一步推进时,我们将分享更多信息,以及新 API 的样子。
感谢 Aurora Scharff、Dan Abramov、Eli White、Lauren Tan、Luna Wei、Matt Carroll、Jack Pope、Jason Bonta、Jordan Brown、Jordan Eldredge、Mofei Zhang、Sebastien Lorber、Sebastian Markbåge 和 Tim Yung 审阅本文。