React18:新玩具、新陷阱以及新可能性
作者 Prithwish Nath 译者 马可薇
图源Lautaro Andreani@ Unsplash
坦白地说,我最近也没怎么用过React,只用过Vanilla React(我在另一篇文章里总结过版本13的复杂性),以及Astro+Preact的组合工具。别误会,React依旧很赞,但多数情况下,你大概会觉得React可行性在很大程度上会取决于你愿意投入多少时间学习它的怪癖,以及你愿意写多少代码来对抗黑客。
但React 18(在我写这篇文章时是18.2.0)为弥补这一差距迈出了巨大一步,提供了许多开箱即用的新功能,如并发渲染、过渡(Transitions)和悬停(Suspense),以及一些锦上添花的变化。
那么代价是什么呢?更多“神奇”的抽象。并不是所有人都吃这一套,但就结果而言,我们或许可以考虑在下一个项目中跳过“功能齐全”框架,并用React 18取而代之,让react-query成为我们数据获取或缓存的解决方案。
那究竟是什么说服了我呢?容我慢慢道来。
并发渲染
突击问答:JavaScript是单线程的吗?
JavaScript本身是单线程的,初始代码不会等DOM树完成立刻执行,但其他基于浏览器Web接口的,如Ajax请求、渲染、事件触发等却不是单线程。React的开发者或许已经对这种独立地从不同组件中获取数据并遭遇竞赛条件的情况驾轻就熟了。
要想应对这种情况,我们需要求助并发。并发让React具备并行性,且有能力在响应性方面与本地设备UI相匹配。
怎么做到这一点?要回答这个问题,让我们先看看React幕后的工作原理。
React的核心设计是维护一个虚拟或影子DOM,渲染DOM树的副本,其中每一个独立的节点都代表一个React元素。在对UI做更新后,React都会递归更新两个树之间的差异,并将累计的变更传递到渲染通道。
在React 16中引入了一套新算法来完成这段流程,也就是React Fiber,取代了原先基于堆栈的算法。所有React元素或者说是组件都是一个Fiber,每个Fiber的子和兄弟都可以延迟渲染,React通过对这些Fiber的延迟渲染实现数量级更高、效果更好的UI响应。具体观感对比可见这里。
React 17以此为基础构建,而React 18则为这套流程带来了更多可控性。
React 18所做的是在所有子树被评估之前暂停DOM树之后的差异化渲染传递。最终结果?每个渲染通道现在都可以中断。React可以有选择地在后台更新部分UI,暂停、恢复或者放弃正在进行的渲染过程,同时保证UI不会崩溃,不会掉帧,或帧数时间一致(如,60 FPS的UI应该需要16.67毫秒来渲染每一帧)。
随着React 18加入React Native,移动设备的游戏规则将彻底改变。
React 18功能背后的核心概念是并发渲染,其中包括悬念、流式HTML、过渡API,等等。每次这些新功能都是并发式的,用户不用具体了解其背后的机制原理。
悬停
悬停(Suspense)最早出现在React 16.6.0中,但也只能用于动态导入React.lazy,如:
const CustomButton = React.lazy(() => import(‘./components/CustomButton’));
在React 18中,悬停有了新的扩展,应用也更加普遍。你是否有遇到过组件树还没有完成数据获取,什么都显示不出来的情况?在能够给出真正的数据之前,指定一个默认的、非阻塞的加载状态展示给用户。
<Suspense fallback={<Spinner />}> <Comments /> </Suspense>
这样能够提升用户体验的方式的原因有:
1.用户不用等待所有数据获取完毕后才能看到东西;
2.用户会看到一个加载按钮,动态骨架,或者仅仅是一个<p>加载中</p>之类的即时反馈,告诉用户程序正在运行,应用程序并没有崩溃;
3.用户不用等待所有交互元素或组件完成水合(hydration),就能开始交互。<Comments>还没加载完?没问题,用户完全可以先点点看<LatestArticles>、<Navbar>,或者<Post>里的数据。
与此同时,开发者体验也得到了改善。在构建应用程序或是在使用Next.js和Hydrogen类似的元框架时,开发者们可以参考React新定义的,规范的“加载状态”。另外,如果你已经知道要怎么在Vanilla JavaScript中写try-catch模块,那你应该如何使用悬停边界。
1.悬停<Suspense>会捕捉“悬停状态”的组件,而不是错误。比如在数据、代码缺失之类的情况中,给出“嘿我还没准备好所有东西”的信息。
2.抛出的错误会触发最近的catch模块,无论其中有多少组件,最邻近的<Suspense>都会捕获其下第一个暂停组件,并展示其回退UI。
悬停的边界再加上React编程模型中的“加载状态”概念,让UI的设计更加精细化。不过,当你将其与过渡API相结合,以指定组件渲染的“优先级”时,那么这一功能将会更加强大。
过渡API
我应该还没有提过我最喜欢的React自定义hook?
在多个产品的发行中,这个简单的hook都为我带来了非常好的服务体验,我认为它对于我写的任何<Search Field>用户输入组件来说都是无价的。
/* 只有在用户停止打字的几毫秒延迟后,才会设置变量 */ function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { /* 1. 延迟数毫秒后的新防抖值 */ const handler = setTimeout(() => { setDebouncedValue(value); }, delay); /* 2. 如果变量值在延迟的毫秒内有变动,则防抖值保持不变 */ return () => { clearTimeout(handler); }; },[value, delay]); return debouncedValue; }
功能背后的想法很简单,在用户搜索栏中输入或下拉列表选择过滤器时,你不会想在每次按键输入时都对下拉列表更新(甚至是调用API搜索)。这个hook可以节流调用或者说“防抖”,确保服务器不会崩溃。
但缺点也很明显,那就是感知滞后。本质上这个功能是引入任意延迟,以UI响应性为代价,确保应用程序的内部结构不被破坏。
在React 18中,并发性支持一种更直观的方法:接收新状态后可以自如地打断计算及其渲染,以提高响应性和稳定性。
新的过渡API支持进一步微调,将状态更新划分为像是前文中SearchField例子中的打字、点击、高亮和更新查询文本的紧急状态(Urgent),以及例子中更新实际展示列表的,可以暂缓直到数据准备好的过渡(Transition)更新。过渡是可以随时中断,且不会阻碍用户输入的,让应用程序保持更高的响应速度。
import { startTransition } from 'react'; // UI updates are Urgent setSearchFieldValue(input); // State updates are Transitions startTransition(() => { setSearchQuery(input); });
你可能也猜到了,这段代码在悬停边界上效果更好,也避免了明显的UI问题:如果你在过渡期间悬停,React实际只是在展示旧状态和旧数据,而不是用回退内容替代已经在界面上展示的内容。新的渲染将被延迟直到有数据加载完毕。
悬停、过渡以及流式SSR,并发React到底对用户体验和开发者体验有多少改善呢?
服务器组件
这是React 18中的又一个重要的新功能,能够让网页构建工作变得更简单,更容易。唯一的问题就是……它仍然不够稳定,只能通过Next.js 13等元框架使用。
React服务器组件(RSC)实际只是在服务器上渲染的组件,而不是客户端。
那又有什么影响呢?很多,这里给出一个太长不看版:
1.在使用RSC时,完全不会向客户端发送任何JavaScript。光是考虑这点就很强了,你再也不用担心发送庞大的客户端库(比如GraphQL客户端就是个常见的例子),影响产品的程序包大小及首字节时间(Time-to-First-Byte)。
2.你可以直接在其中运行数据获取操作,如数据库查询、API、微服务交互等,随后直接通过props将结果数据返回给客户端组件(如传统React组件)。这些查询的速度会是倍数级增长,因为通常来说服务器都会比客户端快上非常多,客户端与服务器之间的通信一般也只用于UI,而不是数据。
3.RSC和悬停相辅相成。我们可以在服务器上获取数据,并将渲染好的UI单元流式递增地传递到客户侧。同时,RSC也不会在重新加载或获取时丢失客户端的状态,确保用户体验和开发者体验的一致性。
4.你不能像是用useState/useEffect一样用hook,就像不能像onClick()一样用事件监听器,访问画布或剪贴板的浏览器API,或者像CSS-in-JS的引擎一样用emotion或styled-components。
5.你可以在服务器和客户端之间共享代码,从而更容易确保类型安全。
现在,网页开发变得更加容易,可以混搭服务器和客户端组件,根据是否需要在较小的软件包上运行,或需要更丰富的用户互动性,有选择地在二者之间跳转。帮你构建灵活且多功能混合的应用程序,适应不断变化的技术或业务需求。
自动批处理:看不见的性能优化
React在幕后的渲染流程就是:一次状态更新=一次新的渲染。你可能不知道的是,React如何通过将多个状态更新集中到一个渲染通道,以达到优化效果的。当然,既然状态更新=重新渲染,你会想尽量减少这种情况的。
在React 17以及更低的版本中,这种情况只会出现在事件监听器中。任何在React管理之外的事件处理程序都不会被批处理,当然也包括Promise.then()里的、await之后的,以及setTimeout之内的东西。因此,你大概会遇到多次意料之外的重新渲染,这是因为其背后的批处理是基于调用堆栈的,而Promise(或回调)=首次浏览器事件之外的多个新调用堆栈=多次批处理=多个渲染过程。
那有什么变化呢?好吧,React现在变聪明了,会将所有状态更新排序成一个事件循环,以确保尽量减少重新渲染。但这点你并不用去考虑或选择,因为这些在React 18中是自动发生的。
function App() { const [data, setData] = useState([]); const [isLoading, setIsLoading] = useState(true); function handleClick() { fetch('/api').then((response) => { setData(response.json()); // In React 17 this causes re-render #1 setIsLoading(false); // In React 17 this causes re-render #2 // In React 18 the first and only re-render is triggered here, AFTER the 2 state updates }); } return ( <div> <button onClick={handleClick}> Get Data </button> <div> {JSON.stringify(data)} </div> </div> ); }
对Async/Await的原生支持:usehook介绍
好消息!好消息!React终于接受了大部分数据操作都是异步的现实,并在React 18中新增了对其的原生支持。那对开发者体验来说意味着什么呢?可以分为两部分:
服务器组件不能也不需要使用hook,因为它们是无状态的,async/await可以使用任何Promise。
客户端组件却不是异步的,并且不能用await来解包Promise值。React 18为此提供了一个全新的usehook。
这个usehook(顺带一提,我不是很喜欢这个名字)是唯一可以被条件调用的React hook,而且是可以在任何地方调用的,即使是在循环之中。以后,React也将包含对Context等其他值的解包支持。
那要怎么用use呢?
import { experimental_use as use, Suspense } from 'react'; const getData = fetch("/api/posts").then((res) => res.json()); const Posts = () => { const data = use(getData); return <div> { JSON.stringify(data) } </div> }; function App() { return ( <div> <Suspense fallback={ <Spinner /> }> <Posts /> </Suspense> </div> ); }
是的,非常简单,但也非常容易翻车。举例来说,你可能会遇到这种情况:
import { experimental_use as use, Suspense } from 'react'; // 哈,你刚刚触发了一个无限加载 const PostsWhoops = () => { // 因为这个最后总是会回到一个新的引用 const data = use(fetch("/api/posts").then(res) => res.json())); return <div> { JSON.stringify(data) } </div> }; // 正确方法 const getData = fetch("/api/posts").then((res) => res.json()); const Posts = () => { const data = use(getData); return <div> { JSON.stringify(data) } </div> }; // ... }
为什么会这样?
假设一种情况,hook解包了一个出于各种原因(网络速度或数据错误)还没完成加载的Promise。那么,这种在悬停边界的使用将被悬停,但由于组件的工作方式和vanilla JS中的异步或等待不同,它不会在故障点恢复执行,而是会在问题解决后重新渲染组件,并在下一次渲染中解包Promise的真实值,也就是非未定义值。
然而,这也就意味着每次对Promise的引用都是全新的引用,这一过程会重复执行,也就是为什么会触发例子中的无限渲染循环。
为避免这种情况,我们应该把use和即将发布的Cache API一起使用,用于自动记忆打包好的函数结果。Next.js 13中实现了自动缓存和清理缓存,甚至可以按路由字段而不是像上面例子中一样按请求实现,以作为新的API扩展fetch。
这就是真相了。React目前对服务器和客户端的异步代码都有完全的原生支持,确保对其余JavaScript的完全兼容。
如何更新?
你可能已经用上React 18了!无论是CRA、Vite还是Next.js通过npx的启动模板,都已经在使用React 18.2.0了。
但如果你想把React 17及以下的版本升级,那还需要注意以下几点。
1.替换为createRoot
根管理换成了一个新的API,且不再支持ReactDOM.render,取而代之的是createRoot。随着createRoot而来的还有新的并发渲染器,以启动所有新奇的新功能。替换之前的应用不会中断,但会和React 17一样运行,无法获得React 18的任何优势。
// React 17 import { render } from 'react-dom'; const container = document.getElementById('app'); render(<App tab="home" />, container); // React 18 import { createRoot } from 'react-dom/client'; const container = document.getElementById('app'); const root = createRoot(container); // createRoot(container!) if you use TypeScript root.render(<App tab="home" />);
2.替换为hydrateRoot
同样,对于SSR来说,ReactDOM.hydrate也没有了,取而代之的是hydrateRoot。如果你不想换,那React 18会和React 17的行为一样:
// React 17 import { hydrate } from 'react-dom'; const container = document.getElementById('app'); hydrate(<App tab="home" />, container); // React 18 import { hydrateRoot } from 'react-dom/client'; const container = document.getElementById('app'); const root = hydrateRoot(container, <App tab="home" />);
3.没有渲染回调了
如果你的应用程序在用回调(callback)作为渲染函数的第三个参数,并且还想保留的话,就必须用useEffect替代,旧方法会破坏悬停。
// React 17 const container = document.getElementById('app'); render(<App tab="home" />, container, () => { console.log('rendered'); }); // React 18 function AppWithCallbackAfterRender() { useEffect(() => { console.log('rendered'); }); return <App tab="home" /> } const container = document.getElementById('app'); const root = createRoot(container); root.render(<AppWithCallbackAfterRender />);
4.严格模式
React 18中的一大性能提升就在于并发,但它也要求组件能与可复用的状态兼容。为了实现并发,我们需要能够中断正在进行的渲染,同时复用旧的状态以保持UI一致性。
为了消除反模式,React 18的严格模式将通过两次调用功能组件、初始化器以及更新器,模拟效果被多次加载和销毁,具体过程如下:
·第一步:安装组件(Layout影响代码运行,Effect影响代码运行)
·第二步:React模拟组件隐藏及卸除效果(Layout影响清理代码运行+Effect影响清理代码运行)
·第三步:React模拟组件以旧的状态重新安装(返回第一步)
为了展示React在保持纯组件理念中与并发相关的代码错误,可以参考这个例子:
setTodos(prevTodos => { prevTodos.push(createTodo()); });
例子中的函数直接修改了数据状态,因此是一个不纯的函数。在严格模式中,React会调用两次Updater函数,也就是说同一个Todo会被添加两次,可以非常明显地看到错误问题。
正确的解决方法是:替换数据,不要直接改变状态。
setTodos(prevTodos => { return […prevTodos, createTodo()]; });
如果你的组件、初始化器和更新器都是幂等的,那这种仅存在于开发模式,不上生产的双重渲染不会破坏代码。事件处理程序因为不是纯函数,所以不受新严格模式的影响。
5.关于TypeScript
如果你在用TypeScript(强烈推荐),那还需要更新类型定义(@types/react以及@types/react-dom)到最新版本。除此之外,新版本还要求明确列出children项:
interface MyButtonProps { color: string; children?: React.ReactNode; }
6.不再支持IE浏览器
虽然目前代码还在,估计直到React 19都不会删,但如果你必须要支持IE的话,建议保持React 17版本不要升级。
未来的日子
React 18是向着正确的前进方向迈出的一大步,是预示着更美好的webdev生态系统。但如果你对React的奇思妙想和抽象不太满意,那你大概是不会喜欢这个包含诸多超赞的新功能,但同时也有更多神奇抽象的版本。
React 18.2.01的开发者,目前的工作流程应该大致是这样的:
1.默认情况下,数据操作、鉴权、以及任何后端代码等组件渲染都是在服务器上进行的。
2.在需要互动性时,选择性地添加客户端组件(useState/useEffect/DOM API),流式传输结果。
更快的页面加载速度,更小的JavaScript程序包,更短的可交互时间(TTI),全是为了更好的用户体验和开发体验。React的下一步是什么?以目前来看,我觉得会是自动记忆的编译器,激动人心的时刻即将到来!
原文链接