写点什么

React 18:新玩具、新陷阱以及新可能性

作者:Prithwish Nath

  • 2023-01-16
    北京
  • 本文字数:7119 字

    阅读完需:约 23 分钟

React 18:新玩具、新陷阱以及新可能性


 图源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 UrgentsetSearchFieldValue(input);
// State updates are TransitionsstartTransition(() => { 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 中新增了对其的原生支持。那对开发者体验来说意味着什么呢?可以分为两部分:

  1. 服务器组件不能也不需要使用 hook,因为它们是无状态的,async/await 可以使用任何 Promise。

  2. 客户端组件却不是异步的,并且不能用 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 17import { render } from 'react-dom';const container = document.getElementById('app');render(<App tab="home" />, container);
// React 18import { createRoot } from 'react-dom/client';const container = document.getElementById('app');const root = createRoot(container); // createRoot(container!) if you use TypeScriptroot.render(<App tab="home" />);
复制代码

 

2. 替换为 hydrateRoot

 

同样,对于 SSR 来说,ReactDOM.hydrate 也没有了,取而代之的是 hydrateRoot。如果你不想换,那 React 18 会和 React 17 的行为一样:

 

// React 17import { hydrate } from 'react-dom';const container = document.getElementById('app');hydrate(<App tab="home" />, container);
// React 18import { hydrateRoot } from 'react-dom/client';const container = document.getElementById('app');const root = hydrateRoot(container, <App tab="home" />);
复制代码

 

3. 没有渲染回调了

如果你的应用程序在用回调(callback)作为渲染函数的第三个参数,并且还想保留的话,就必须用 useEffect 替代,旧方法会破坏悬停。

 

// React 17const container = document.getElementById('app');render(<App tab="home" />, container, () => {  console.log('rendered');});
// React 18function 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 的下一步是什么?以目前来看,我觉得会是自动记忆的编译器,激动人心的时刻即将到来!

 

原文链接:

https://blog.bitsrc.io/whats-new-in-react-js-v18-new-toys-new-footguns-new-possibilities-baa0bb6ee863

相关阅读:

React 源码分析 1-jsx 转换及 React.createElement


手写一个 react,看透 react 运行机制


看透 react 源码之感受 react 的进化


深度分析 React 源码中的合成事件

2023-01-16 15:277545

评论

发布
暂无评论
发现更多内容

主流框架都用SPI机制,看一下他们的区别和原理

Java你猿哥

ssm 框架 JavaSPI Spring SPI Dubbo SPI

MySQL 并行复制方案演进历史及原理分析

Java你猿哥

Java MySQL ssm 并行复制 主从延迟

数说热点 | 跟着《长月烬明》起飞,今年各地文旅主打的就是一个听劝

MobTech袤博科技

Wallys AP controllers devices/PQ4019 and IPQ4029 chipsets support 20 km remote transmission

Cindy-wallys

IPQ4019 ipq4029

实战解读:隐钥科技数据库加密解决方案及场景化解析

Lily

从0到100:小区物业报修小程序开发笔记

CC同学

基于 Rainbond 的混合云管理解决方案

北京好雨科技有限公司

Kubernetes 云原生 rainbond 混合云架构

分享:集群吞吐量以1抵5,车企MySQL八大痛点的解决方案

OceanBase 数据库

数据库 oceanbase

一条SQL如何被MySQL架构中的各个组件操作执行的

华为云开发者联盟

sql 开发 华为云 华为云开发者联盟 企业号 5 月 PK 榜

工业互联网:加速从“中国制造”迈向“中国智造”

华为云开发者联盟

云计算 工业互联网 华为云 华为云开发者联盟 企业号 5 月 PK 榜

刘强:作业帮给OceanBase提了九条意见

OceanBase 数据库

数据库 oceanbase

CH32V307V-EVT-R1 简单上手入门

繁依Fanyi

嵌入式

SAPUI5 本地工程中的键值对 sapux - true 的作用

汪子熙

前端开发 SAP Fiori SAP UI5 三周年连更

未来市场主流的五大LED显示屏

Dylan

技术 方案 LED显示屏

字节首次公开!23年Java后端面试上岸手册 ,竟含全套后端面试考点

Java你猿哥

Java 算法 JVM 多线程 java面试

利用Python分析快手APP全国大学生用户数据(2022 年初赛第四题 )

繁依Fanyi

大数据

chatGPT是割韭菜的镰刀还是创业的新风口? | 社区征文

迷彩

AIGC 生成式人工智能 三周年征文 三周年连更

阿里巴巴官方上线!号称国内Java八股文天花板(终极版)首次开源

Java你猿哥

Java 微服务 算法 JVM 多线程

SPFA 算法:实现原理及其应用

繁依Fanyi

算法 SPFA

神秘的IP地址8.8.8.8地址到底是什么?为什么会被用作DNS服务器地址呢?

wljslmz

DNS 三周年连更

阿里P8撰写1500页程序性能调优笔记:GitHub标星79k

程序知音

Java 性能优化 JVM java架构 Java进阶

【OpenAI】私有框架代码生成实践 | 京东云技术团队

京东科技开发者

openai ChatGPT ChatGPT4 企业号 5 月 PK 榜 私有框架

从0开始:活动打卡小程序开发笔记

CC同学

Github高赞!Alibaba最新亿级并发系统架构(2023 版全彩小册)

Java你猿哥

Java 架构 分布式 高并发 架构设计

GPIO实验-主芯片GPIO输出实验

鸿蒙之旅

OpenHarmony 三周年连更

Python网络爬虫原理及实践 | 京东云技术团队

京东科技开发者

Python 爬虫 python 爬虫 爬虫入门 企业号 5 月 PK 榜

Zero-ETL、大模型和数据工程的未来

Baihai IDP

人工智能 大模型 数据工程 企业号 5 月 PK 榜 LLMs

多种文件清理:Disk Cleanup Pro 激活版

真大的脸盆

Mac Mac 软件 磁盘清理 清理工具

架构师必备!阿里P8耗时6个月手码架构师进阶笔记真的香

Java你猿哥

架构 前端架构 架构设计 架构师 后端架构

你想要的【微前端】都在这里了! | 京东云技术团队

京东科技开发者

前端 微前端 微前端框架 企业号 5 月 PK 榜 mirco

React 18:新玩具、新陷阱以及新可能性_大前端_InfoQ精选文章