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

作者 | Prithwish Nath譯者 | 馬可薇策劃 | 張衛濱  坦白地說,我最近也沒怎麼用過 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,等等。每次這些新功能都是併發式的,用戶不用具體瞭解其背後的機制原理。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>這樣能夠提升用戶體驗的方式的原因有:用戶不用等待 所有數據獲取完畢後 才能看到東西;用戶會看到一個加載按鈕,動態骨架,或者僅僅是一個<p>加載中</p>之類的即時反饋,告訴用戶程序正在運行,應用程序並沒有崩潰;用戶不用等待所有交互元素或組件完成水合(hydration),就能開始交互。<Comments>還沒加載完?沒問題,用戶完全可以先點點<LatestArticles>、<Navbar>,或者<Post>裏的數據。與此同時,開發者體驗也得到了改善。在構建應用程序或是在使用 Next.js 和 Hydrogen 類似的元框架時,開發者們可以參考 React 新定義的,規範的“加載狀態”。另外,如果你已經知道要怎麼在 Vanilla JavaScript 中寫 try-catch 模塊,那你應該如何使用懸停邊界。懸停<Suspense>會捕捉“懸停狀態”的組件,而不是錯誤。比如在數據、代碼缺失之類的情況中,給出“嘿我還沒準備好所有東西”的信息。拋出的錯誤會觸發最近的 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)實際只是在服務器上渲染的組件,而不是客戶端。那又有什麼影響呢?很多,這裏給出一個太長不看版:在使用 RSC 時,完全不會向客戶端發送任何 JavaScript。光是考慮這點就很強了,你再也不用擔心發送龐大的客戶端庫(比如 GraphQL 客戶端就是個常見的例子),影響產品的程序包大小及首字節時間(Time-to-First-Byte)。你可以直接在其中運行數據獲取操作,如數據庫查詢、API、微服務交互等,隨後直接通過 props 將結果數據返回給客戶端組件(如傳統 React 組件)。這些查詢的速度會是倍數級增長,因爲通常來說服務器都會比客戶端快上非常多,客戶端與服務器之間的通信一般也只用於 UI,而不是數據。RSC 和懸停相輔相成。我們可以在服務器上獲取數據,並將渲染好的 UI 單元流式遞增地傳遞到客戶側。同時,RSC 也不會在重新加載或獲取時丟失客戶端的狀態,確保用戶體驗和開發者體驗的一致性。你不能像是用 useState/useEffect 一樣用 hook,就像不能像 onClick() 一樣用事件監聽器,訪問畫布或剪貼板的瀏覽器 API,或者像 CSS-in-JS 的引擎一樣用 emotion 或 styled-components。你可以在服務器和客戶端之間共享代碼,從而更容易確保類型安全。現在,網頁開發變得更加容易,可以混搭服務器和客戶端組件,根據是否需要在較小的軟件包上運行,或需要更豐富的用戶互動性,有選擇地在二者之間跳轉。幫你構建靈活且多功能混合的應用程序,適應不斷變化的技術或業務需求。自動批處理:看不見的性能優化

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 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 的開發者,目前的工作流程應該大致是這樣的:默認情況下,數據操作、鑑權、以及任何後端代碼等組件渲染都是在服務器上進行的。在需要互動性時,選擇性地添加客戶端組件(useState/useEffect/DOM API),流式傳輸結果。更快的頁面加載速度,更小的 JavaScript 程序包,更短的可交互時間(TTI),全是爲了更好的用戶體驗和開發體驗。React 的下一步是什麼?以目前來看,我覺得會是自動記憶的編譯器,激動人心的時刻即將到來!原文鏈接:https://blog.bitsrc.io/whats-new-in-react-js-v18-new-toys-new-footguns-new-possibilities-baa0bb6ee863聲明:本文爲 InfoQ 翻譯,未經許可禁止轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章