Screenshot of two apps, one with loading spinners
2021 年 12 月 7 日

React 伺服器組件與 Remix

Ryan Florence
共同創辦人

在 Remix v1 發布後,有一個很大的問題不斷出現

React 伺服器組件呢?

好問題!和你們許多人一樣,我們自 2018 年首次發布以來,一直在嘗試 React Suspense。事實上,Remix 的早期版本就使用了它。意識到它可能在我們準備好之前不會發布,我們將 Remix 的異步部分構建到框架中,並且對結果非常滿意。

現在 React 伺服器組件 (RSC) 似乎越來越受到關注,我們重新審視並進行了一些測試,試圖找出在 Remix 中利用它們的最佳方法。我們很清楚 RSC 仍處於實驗階段,因此我們不打算將此研究作為我們對 RSC 和 Remix 的最後聲明。我們只是認為,為了每個正在使用 Remix (和 React Router) 並且對使用 RSC 也感到好奇的人,分享我們的觀點會很有用。

但首先,一點背景知識:什麼是 React 伺服器組件?

對使用者體驗的執著

在 Remix,我們絕對執著於使用者體驗 (UX)。我們密切關注的一個主要事項是瀏覽器中的網路標籤頁。如果網路標籤頁一團糟,UX 可能也很糟糕:跳動的載入指示器、緩慢的載入時間等等。如果網路標籤頁乾淨,您的 UX 可能會快速而響應迅速。您的應用程式如何載入數據會影響您的網路標籤頁的形狀。

在今天的 React 生態系統中,有三種方法可以將數據載入到您的應用程式中

  1. 渲染-擷取瀑布 (也稱為「隨渲染擷取」):這指的是在組件內部、從瀏覽器中、在 JavaScript 套件載入和渲染後擷取數據。我們稱之為「瀑布」,因為在一個套件載入、渲染並啟動數據擷取後,它會渲染執行相同操作的子組件。載入模組 → 渲染 (載入指示器) → 擷取 → 渲染子元件 (更多載入指示器) → 在子元件中擷取 → 等等。每次您渲染並顯示載入指示器都是瀑布中的另一步。

    如果您還沒有,請向下捲動我們的首頁,看看這種載入數據的方法如何影響 UI。它透過將這些資源耦合到 UI 階層,來建立人工的數據和模組階層。在渲染之前,您不知道要擷取什麼,而且在擷取父級數據之前,您無法渲染!這往往會在 UI 中產生「卡頓」,並導致累計版面配置位移 (CLS),因為子視圖會在父視圖已渲染後才彈出到頁面上。

  2. 擷取,然後渲染:在渲染頁面之前,擷取您的所有數據,然後一次渲染整個頁面。這是 Remix 中的預設行為。這也是大多數網站幾十年來的工作方式。由於巢狀路由,Remix 從 URL 就能知道頁面的所有依賴項 (JS 模組、數據,甚至 CSS),因此它可以並行運行所有查詢並載入資源。當您認為使用者即將訪問頁面時,它甚至可以預先擷取這些資源。正如您將在本篇文章中看到的,這對初始頁面載入和後續導航有正面的影響。

  3. 隨擷取渲染:像擷取,然後渲染一樣,您並行啟動所有載入,但您不會等待所有資源。相反,您會渲染任何在準備好時準備好的部分。除非您已經能夠擷取,然後渲染,否則這是無法實現的。這是一種優化,可以盡快讓使用者看到有用的東西 (請不要是空的 div!)。

今天 React 生態系統中幾乎每個應用程式都使用渲染-擷取瀑布。這是任何在 useEffect() hook 內運行的數據擷取的預設行為,包括像 react-queryuseSWR、Apollo Client 和許多其他函式庫。

開箱即用,React 伺服器組件渲染-擷取瀑布。由於擷取是在組件內部完成的,您的應用程式在組件渲染之前不知道要擷取什麼。

問題是,在這三種方法中,渲染-擷取瀑布提供最差的 UX。讓我們運行一些測試來看看為什麼。

React 團隊的示範

我從 Facebook 的核心 React 團隊獲取了 React 伺服器組件示範,將程式碼改組到 Remix 的路由慣例中,然後將兩個版本部署到澳洲的伺服器,以便我們可以在美國真正感受到它 (我現在非常想吃叻沙王。如果你知道,你就知道)。

Remix 版本沒有使用 React 伺服器組件,它只是 React 17 上的普通 Remix。我的目標是看看我從每個版本中開箱即用的效能,並看看 Remix 可以從 RSC 中受益的地方。

雖然 RSC 仍然只是一個實驗,而且這只是一個玩具應用程式,但老實說,我驚訝地發現 Remix 在初始頁面載入時的速度是 RSC 的兩倍以上。(我上次在雪梨時應該吃兩倍的叻沙王。)

如果您查看網路標籤頁,您可以看到 Remix 如何並行載入資源,而 RSC 會導致請求的級聯瀑布。擷取程式碼,渲染,擷取伺服器組件,渲染。這個 UI 也沒有任何巢狀結構,這就是為什麼我很驚訝地看到 Remix 的效能遠遠超過 RSC。載入巢狀 UI 是 Remix 真正超越其他替代方案的地方。

然而,我也意識到 React 團隊的示範並沒有真正利用 React 伺服器組件的殺手級功能,也就是在初始伺服器渲染期間串流響應的能力。如果沒有串流渲染,RSC 實際上只是另一種在組件內部擷取的方式。

SSR 串流,Next.js 示範

我決定使用一個還結合了串流伺服器渲染的 React 伺服器組件示範來進行衡量。我對這個功能感到興奮 (我想) 已經好幾年了。我抓取了 Next.js Hacker News 複製品,並將程式碼改組到 Remix 的數據載入慣例中,看看兩者並排起來是什麼感覺。然後我將兩個應用程式都部署到 Vercel,以便它們在相同的伺服器上運行。

這次我完全預期 Remix 會輸。

再次,Remix 的速度比 RSC 和 Next.js 快兩倍以上,即使沒有 HTTP 快取 (真正的 HN 中有使用者數據,所以這行不通)。此外,Remix 版本沒有顯示任何載入指示器,也沒有任何內容版面配置位移。

此外,這裡也沒有任何巢狀 UI,但 Remix 的載入速度仍然比 Next.js + RSC + SSR 串流快 2 倍 (使用過時重新驗證快取時為 5 倍)。

Remix 可以充分利用 RSC

RSC 的構建策略是隨擷取渲染。僅 RSC 不足以隨擷取渲染。它需要在上面有一個框架,以便在渲染之前啟動資源的並行載入。這兩個示範都沒有任何巢狀結構,這點至關重要。

Facebook 有 Relay、一個花哨的編譯器、後端基礎架構,以及多個團隊的工程師,他們的薪水比你我高得多,他們知道在渲染之前要擷取什麼。

但您有 Remix 🤗

在今天的環境中,Remix 的定位獨一無二,可以充分利用 Suspense、RSC 和 SSR 串流:它從 URL 就已經知道關於頁面的所有資訊,這正是 React 隨擷取渲染所需要的。

此外,Remix 已經具有共置您的伺服器和客戶端程式碼的優勢,包括 {name}.client.js{name}.server.js 檔案慣例 (通常不需要,但它會向編譯器提示哪些檔案應該只在一個地方運行)。

對於開發人員體驗,Remix 路由模組已經是「伺服器組件」。使用 RSC 只是 Remix 本身的一個實作細節。

當 RSC 準備好在 Remix 中採用時,遷移可能就像重新命名您的其中一個路由檔案一樣簡單

git mv routes/posts.tsx routes/posts.server.tsx

但是,我們將等到 RSC 穩定,並且沒有我們在這裡看到的效能和 UX 問題,然後再將其整合到 Remix 中。

真正的測試將是 Remix + RSC 與單獨的 Remix。如果 Remix + RSC 提供更好的使用者體驗,我們就會全力以赴。但是,當我們已經比目前的示範快 2 倍或更多時,很難證明投入這種努力是合理的。

再次,這些是虛擬應用程式的示範,因此我們不會太過重視它們。但是,我們對 RSC 在網路標籤頁上做出的權衡有一個相當大的擔憂。

零套件,還是無限套件?

似乎今天的網路開發精神是一種對初始頁面載入和首次位元組時間 (TTFB) 的痴迷。但這裡有一個同樣重要的問題:在您將使用者帶到頁面後會發生什麼?

只痴迷於 TTFB 就像試圖透過鍛鍊來變得精壯,卻忽略了您的飲食 (糟糕,這就是我所做的!... 不過叻沙王聽起來真不錯)。

關閉您的 M1X MacBook Pro,從學校抓取我孩子的 Chromebook,拔掉 CAT-6 纜線,然後跳上我岳父岳母的 WiFi。您會看到您的網站展現出完全不同的個性。

在低功耗裝置上使用不穩定的網路花費一小時閱讀狀態更新、更新記錄、建立貼文和傳送訊息的使用者體驗,與 Remix 上任何網路的任何裝置上的初始頁面載入一樣重要。那麼這與 RSC 有什麼關係呢?

我「快速瀏覽」了 React 團隊的示範,看看讓應用程式顯示每個頁面需要多長時間。這是一個愚蠢的指標,但是當我使用一個緩慢的網站時,那種遲鈍的感覺會隨著時間的推移而加劇,並讓我產生「這個網站不是那麼好」的普遍感覺。我認為這捕捉到了那種感覺。

正如您在此示範中看到的,RSC 版本在網路傳輸的 JavaScript 比 Remix 版本多 34 倍 (!)。

不,在啟動瀏覽器快取後是 16 倍

喔,對了。多 16 倍 JavaScript?!這到底怎麼回事?

除了串流渲染之外,React 伺服器組件的另一個主要功能是「零套件」。這個想法是在初始頁面載入時傳送更少的內容以加快速度 (我們稍早看到,在這個示範中它沒有)。這個想法是

  1. 瀏覽器永遠不需要載入包含渲染伺服器組件的範本的 JavaScript 套件
  2. 它還消除了對典型的 React SSR 行內 hydration <script> 的需求,該標籤充滿了已經在標記中重複的 JSON (開啟此網站上的開發人員工具,您會注意到此貼文在標記和底部的行內腳本標籤中重複... 此外,所有這些錯誤都是 YouTube 造成的,不是我們 😰)

這表面上聽起來很棒,但現在每次使用者與網站互動時,伺服器元件的 payload 中都會重複出現模板。換句話說,每次你從伺服器獲取資料時,你都會得到完整的渲染標記,而不僅僅是資料。

點擊伺服器元件示範中的單個項目會導致請求這個(請看:「伺服器元件」)。

M1:{"id":22,"chunks":[2],"name":""}
M2:{"id":20,"chunks":[0],"name":""}
S3:"react.suspense"
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","3",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":"@5"}]}]]}]
M6:{"id":21,"chunks":[3],"name":""}
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@6",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@6",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@6",null,{"id":3,"title":"I wrote this note toda","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note toda"}],["$","small",null,{"children":"5:59 PM"}]]}]}]}]]}]
J5:["$","div",null,{"className":"note","children":[["$","div",null,{"className":"note-header","children":[["$","h1",null,{"className":"note-title","children":"I wrote this note toda"}],["$","div",null,{"className":"note-menu","role":"menubar","children":[["$","small",null,{"className":"note-updated-at","role":"status","children":["Last updated on ","3 Dec 2021 at 5:59 PM"]}],["$","@2",null,{"noteId":3,"children":"Edit"}]]}]]}],["$","div",null,{"className":"note-preview","children":["$","div",null,{"className":"text-with-markdown","dangerouslySetInnerHTML":{"__html":"<p>It was an excellent note.</p>\n"}}]}]]}]

那超過 4 kB。如果你點擊所有三個項目,你會得到每個項目都像這樣的回應。

讓我們與 Remix 做個比較。第一次點擊連結時,你必須下載項目檢視的程式碼分割 JavaScript 模板。

import{a as i,c as a,d as p}from"/build/_shared/chunk-CDZR6LSD.js";import{a as m}from"/build/_shared/chunk-DQ7ZO7ZN.js";import"/build/_shared/chunk-XXRJHXMM.js";import{i as d}from"/build/_shared/chunk-2FSL4QX2.js";import{b as l,e as t,f as e}from"/build/_shared/chunk-AKSB5QXU.js";e();e();e();var f=l(p());function r(){let{id:n,title:s,body:u,updatedAt:o}=d();return o=new Date(o),t.createElement("div",{className:"note"},t.createElement("div",{className:"note-header"},t.createElement("h1",{className:"note-title"},s),t.createElement("div",{className:"note-menu",role:"menubar"},t.createElement("small",{className:"note-updated-at",role:"status"},"Last updated on ",i(o,"d MMM yyyy 'at' h:mm bb")),t.createElement(a,{noteId:n},"Edit"))),t.createElement(m,{body:u}))}export{r as default};

import{a as d}from"/build/_shared/chunk-XXRJHXMM.js";import{b as i,e as t,f as e}from"/build/_shared/chunk-AKSB5QXU.js";e();e();var n=i(d());function o({text:r}){return t.createElement("div",{className:"text-with-markdown",dangerouslySetInnerHTML:{__html:(0,n.default)(r)}})}function a({body:r}){return t.createElement("div",{className:"note-preview"},t.createElement(o,{text:r}))}export{a};

但從現在開始,每次點擊項目只會傳輸這個小東西。

{"id": 1, "createdAt": "2020-12-30T10:13:29.023Z", "updatedAt":
"2020-12-30T10:13:29.023Z", "title": "Meeting Notes", "body": "This is an
example note. It contains **Markdown**!"}

由於伺服器元件將你的資料與你的模板耦合在一起,你的使用者必須在每次與該元件相關的互動中下載模板。雖然對於你的 JavaScript 來說是「零捆綁」,但對於後續導航來說卻是「無限捆綁」😟。

當然,這是一個小玩具示範應用程式,而現實世界中的情況總是會有所不同,但從邏輯上來說,這些模板永遠會比資料大。以我的經驗,每一盎司的資料就有一磅的標記(天啊,我現在好想吃一磅叻沙)。

我們的看法

我玩了這些兩個示範好幾天:在高速公路上從手機載入它們(當然不是在開車,但希望我可以開車去叻沙王),在手機訊號不穩定的山丘上等我的孩子們放學,甚至在幾乎沒有任何手機訊號的教堂裡。

在每種情況下,毫無例外地,Remix 都比 React 伺服器元件快。而且快很多。

我不清楚 RSC 是為哪些網路、裝置和伺服器條件而建構的。無論網路速度快還是慢,從 Remix 發送整個文件總是比從 RSC 發送的第一個區塊快。

這並不是說 RSC「不好」。它仍然是實驗性的!我只是說它們目前對 Remix 沒有吸引力。當 RSC 穩定後,我們將應用我們在此展示的相同嚴格測試,如果它們能提供更好的 UX,我們會向 Remix 使用者推薦它們。

我的直覺是,當使用者的網路速度很快,但伺服器的資料載入速度很慢時,RSC 有機會提供更好的 UX。這將是我接下來的研究重點(這些示範的伺服器資料載入速度都很快)。我預期第一個區塊會比你慢速的伺服器透過 Remix 將完整的文件傳送給使用者更快地對使用者有用。

但如果你的慢速伺服器是問題所在,你可以解決這個問題。你可以讓你的伺服器變快,你對使用者的網路無能為力。而這就是 Remix 的重點,利用現代基礎設施,並在網路上傳輸更少的東西。

後端基礎設施變得非常好。Remix 可以在邊緣(靠近你的使用者)執行你的整個應用程式,在像 Cloudflare Workers(請參閱我們的示範)和(即將推出的)Deno Deploy等平台上。你不僅可以在邊緣執行你的應用程式伺服器,你也可以透過像 Fly.io Postgres Read ReplicasCloudflare KVDurable ObjectsFaunaDB 等技術,將你的資料也傳輸到邊緣。這些技術使你能夠在短短幾毫秒內渲染完整頁面,即使是使用者資料也行!

如果你可以在 500 毫秒,甚至 50 毫秒內用 Remix 渲染包含使用者資料的完整文件,你可能會問自己為什麼要用旋轉的載入動畫來串流資料(即使它現在快兩倍而不是慢兩倍)。

我們很期待聽到 React 團隊在本週的 React Conf 上對 React 伺服器元件和串流渲染有何看法。目前,我們很高興能夠為核心團隊提供我們的回饋,我們希望我們的研究有助於推動這項技術的發展。敬請期待我們在這個領域的更多消息!

線上示範和原始碼

Hacker News 示範

Hacker News 原始碼

筆記應用程式示範

筆記應用程式原始碼


取得最新的 Remix 新聞

搶先了解新的 Remix 功能、社群活動和教學課程。