Hiện tại thì project của mình cũng đang sử dụng React SSR vesion 17, và phải sử dụng thêm một thư viện khác support SSR + Spliting Code là loadable. Nên việc React 18 support Suspense phía SSR thì mình không cần thêm một thư viện khác bên ngoài nữa, và với việc kiến trúc mới có performance tốt hơn nên hiện tại mình khá là hào hứng để bắt đầu migrate.
NOTED
Nếu bạn còn đang lùng bùng về các khái niệm, hoặc chưa nắm rõ được cách hoạt động của SSR cũng như kiến trúc SSR mới của React 18 thì bạn có thể xem thêm ở link bên dưới, nội dung bài viết này chỉ đi qua quá trình mình migrate các api mới createRoot, hydrateRoot, renderToPipeableStream. Upgrade Guild React 18 architect
Xác định input, output trước khi tối ưu
Context: Sử dụng thư viện, công nghệ xung quanh hệ sinh thái React để tối ưu ứng dụng React Server Side Rendering. Thông thường để đánh giá được chi tiết việc tối ưu có hiệu quả hay không cần có những con số cụ thể ví dụ tổng app bundle size, thời gian process trên server, thời gian tải resource, time to interaction... Nếu không có những con số đo lường được thì việc tối ưu cũng không có ý nghĩa gì hết vì lấy gì để chứng minh được việc mình đang làm có ý nghĩa hay không? Đối với Nextlint, thì input để tối ưu khá rõ ràng đó là Nextlint Appliation(Kết hợp Server Side Rendering + Single Page Application). Và output sau khi tối ưu là các chỉ số như bundle size, thời gian để render 1 page trên server, các chỉ số về web performance (Benchmark metrics)
React version 17 + @loadable
React version 17 giới thiệu api React.lazy để chia nhỏ bundle size (code spliting), các bundle chỉ được tải về khi cần thiết cho việc render. Tuy nhiên API này không work trên phía server, nên mình phải sử dụng một thư viện thay thế đó là @loadable/component.
Các component được wrap lại với api loadable khi compile sẽ được tách ra một bundle riêng và chỉ được tải xuống khi component này được render:
import loadable from '@loadable/component'
const OtherComponent = loadable(() => import('./OtherComponent'))
function MyComponent() { return ( <div> <OtherComponent /> </div> )
}
Đối với bundle client thì việc này hoàn toàn tự động, khi React render ở phía client, @loadable runtime sẽ tự động inject script để tải bundle cần thiết dựa vào location của browser. Với Server Side Rendering thì lại khác, bạn render một page dựa vào location của một request. Bạn phải biết trước page này sẽ cần những script nào để inject vào file html, nếu bạn không làm như vậy, khi React hydrate ở phía client, @loadable api không detect được scripts ở phía client nó sẽ inject scripts để tải bundle này về, dẫn đến việc page sẽ bị re-render thêm một lần nữa => HTML được render sẵn trên server không còn giá trị gì nữa. Vì vậy nếu sử dụng SSR bạn cần phải inject tất cả các scripts/css cần thiết để render bằng cách sử dụng api ChunkExtractor:
import { renderToNodeStream } from 'react-dom/server'
import { ChunkExtractor } from '@loadable/server' // Stream trước page metadata
res.write('<html><head><title>Test</title>') const statsFile = path.resolve('../dist/loadable-stats.json')
const chunkExtractor = new ChunkExtractor({ statsFile })
const jsx = chunkExtractor.collectChunks(<YourApp />) // Stream css
res.write(`{chunkExtractor.getStyleTags()}</head><body><div id="root">`) const stream = renderToNodeStream(jsx)
stream.pipe(res, { end: false })
// Stream script tags
stream.on('end', () => res.end(`</div>${chunkExtractor.getScriptTags()}</body></html>`),
)
Như ở đoạn code trên, dựa vào một statsFile(sinh ra từ @loadable/webpack-plugin ) chứa thông tin của tất cả các bundle, ChunkExtractor sẽ sử dụng file này làm cơ sở để extract các scripts/css cần thiết để render page: loadable-stats.json:
Vấn đề của giải pháp này là phải cần thêm một thư viện bên ngoài, setup loằng ngoằng các thứ nữa để SSR + Code spliting work, giá như trong chính bản thân React support luôn SSR + Code spliting thì tốt biết mấy ?
React 18 + Suspense
Bùm, như nghe được lời thỉnh cầu của các thần dân, thì React 18 nay đã support React.lazy + Suspense trên phía server. Có nghĩa là không cần sử dụng thêm thư viện @loadable nữa, và theo như những gì React Team thông báo thì kiến trúc SSR trên React 18 có performance tốt hơn bao gồm việc stream render trên server và selection hydrate ở client. Về cách React 18 Code-Spliting cũng giống như React 17 + @loadable, nhưng React 18 apply kiến trúc server side rendering + hydrate mới.
Mình xin tóm tắt lại như sau:
React 17 + @loadable :
- Mọi thứ phải được render ở phía server trước khi response về client
- Tất cả các script phải được tải về trước khi việc hydrate bắt đầu
- Hydrate tất cả trước khi bạn có thể tương tác với DOM thật.
Tất cả các bước trên diễn ra tuần tự, có nghĩa là mỗi step trước đó hoàn thành mới đến được bước tiếp theo, nếu một trong các bước trên bị chậm, sẽ kéo theo việc render bị chậm trong toàn bộ quá trình.
React 18 + Suspense:
- Khi React render DOM ở trên server gặp component Suspense(CompA), React sẽ stream về placeholder của CompA và tiếp tục render những component khác, khi data của CompA sẵn sàng, React sẽ stream về data và một đoạn script để replace lại placeholder bằng data của CompA.
- Một component được wrap lại với Suspense sẽ không block quá trình rendering, và nó cũng work tương tự với quá trình hydrate, khi script của CompA tải xuống nó sẽ bắt đầu hydrate chính nó mà không cần phải đợi tất cả script phải được tải về để bắt đầu quá trình hydrate.
- Yup vì ở bước số 2 chúng ta đã chia nhỏ script ở cấp component, nên script tại mỗi component được tải về sẽ bắt đầu hydrate luôn mà không cần đợi dẫn đến việc interact với DOM sẽ sớm hơn. Mà còn xịn xò hơn nữa là trong quá trình 2 ComponentA và ComponentB cùng hydrate, nếu mình click vào ComponentA, quá trình hydrate ComponentB sẽ pending lại và ưu tiên hydrate ComponentA (Selection Hydrate) Quá đỉnh phải không, migrate ngay và luôn nè.
Với việc setup sẵn kiến trúc SSR + @loadable ở phiên bản 17, việc migrate lên 18 cũng không có gì khó khăn lắm, những công việc chính mình cần phải làm là:
- Remove @loadable lib, upgrade React lên version 18.
- Thay thế api hydrate, renderToNodeStream bằng hydrateRoot, renderToPipeableStream
- Thay thế @loabable api với React.lazy và Wrap component lại với Suspense. Tạm biệt @loadable, sau khi thay bằng lazy, mình cần phải wrap lại với Suspense. Việc migrate đã xong, có thêm một vài lỗi lặt vặt nữa nhưng cơ bản với project của mình thì chủ yếu những bước trên. Bây giờ sẽ đến phần mình mong chờ nhất là benchmark các chỉ số.
So sánh các chỉ số sau khi upgrade
Mình có tạo một branch khác để thực hiện việc migrate, nên giờ mình sẽ chạy cùng lúc 2 nhánh để analyze các chỉ số thêm thế nào.
1. Bundle analyzer
Yeap một metric khá bự ảnh hưởng đến performance của app. Về mặt lý thuyết thì sau khi remove @loadable thì tổng bundle nó sẽ giảm đi nhưng thực tế thì không, thậm chí còn lớn hơn, vì sao nhỉ ? Để xem thử bundle size của thằng loadable là bao nhiêu hén. why why and whyyyyyyyyyyyyy? Cảm thấy một chút hụt hẫng nha, à nãy có bump version của react + react-dom, để xem thử
Chẳng lẽ những gì mình optimize trước giờ đều là công cốc à ? Bình tĩnh bạn tôi ơi, nếu để ý thì size của script browser.xxxx.js đã giảm từ 640KB -> 327KB. Đây mới là cái tạo nên sự khác biệt, browser.xxx.js là file chưa tất cả các thư viện cần để boootstrap app, thông thường là các thư viện node_module sử dụng tại thời điểm runtime(react,react-dom,redux.....)
Khi bạn vào bất kì trang nào, file browser.xxx.js sẽ đều được tải xuống => script tải về ít hơn kéo theo thời gian compile+boootstrap app sẽ nhanh hơn. Hm....không trình bày, nếu xét về tổng bundle size thì vẫn thua nha. Đúng là học tài thi phận mà 😓😓😓
2. Benchmark phía Server
Mình sử dụng fastify ở phía server để handle request và stream HTML. Để benchmark được ơ server. Sau khi sử dụng api mới là mình sẽ Proxy lại function write để hook vào stream process để log ra xem performance của 2 api renderToPipeableStream(_@.com) và renderToNodeStream(_@.com) như thế nào.
const nodeStream = renderToPipeableStream( <StaticRouter location={url}> <Provider store={store}> <App /> </Provider> </StaticRouter>,{...})
// Proxy function write.
nodeStream.pipe( Object.assign(rep.raw, { write: new Proxy(rep.raw.write, { async apply(target, thisArgs, argsList) { const content = Utf8ArrayToStr(argsList[0]); console.log(`>>>>>>[React18] Stream at ${getTick()}::`, content, '<<<<<<<<<<<<<<'); return Reflect.apply(target, thisArgs, argsList); }, }), }) );
Okie, bây giờ mình sẽ tiến hành chạy 2 con server và test cùng một lúc: First Request: Hơi sai, vì theo như fb có để cập tới kiến trúc render mới có hiệu năng tốt hơn mà nhi?? Scroll lên xíu nữa xem sao.
HTML content rối quá nên mình tạm ẩn đi nha các bạn, mục đích mình log html content để các bạn thấy được React Stream từng chunk html như thế nào.
Sau khi remove hết HTML content thì console sạch bóng như này: Chà, _@.com mất 15ms để stream thôi nhưng mà cũng bắt đầu stream ở ms thứ 15. Còn _@.com chậm hơn nhưng stream ở ms thứ 5. Có nghĩa là _@.com khi renderToNodeStream đợi toàn bộ HTML render ra mới stream về, còn _@.com sẽ render theo từng chunk. Việc này cũng giải thích vì sao TTFB ở _@.com lại nhanh hơn _@.com Second Request: Ở lần request thứ 2 performance cải thiện rất nhiều, nhưng _@.com vẫn chỉ stream một lần duy nhất.
3. Script tải về mỗi trang
Bây giờ mình sẽ chạy cả 2 app để xem sao.
Bạn xem hình ở trên sẽ thấy browser.xxx.js + home.xxx.js ở _@.com là 88.9KB so với _@.com à 163KB.
Chưa dùng lại ở đó, việc các script đã được chia nhỏ ở mức tối đa chỉ để phục vụ cho từng trang, nên kéo theo toàn bộ scripts tải về cũng ít hơn nhiều so với version 17.
**_@.com **
Localhost: 220ms ( TTFB + Download HTML Content)
Request: 14
Transferred: 298kB
Finish: 739ms
DOMContentLoaded: 413ms
Load: 732ms
**_@.com **
Localhost: 176ms (TTFB + Download HTML Content)
Request: 5
Transferred: 117kB
Finish: 300ms
DOMContentLoaded: 235ms
Load: 270ms
4. Core Web Vitals
Tổng kết lại mình sẽ chạy test web vitals với lighthouse: _@.com:
lighthouse http://localhost:7171 --output html --output-path ./next_17.html
_@.com:
lighthouse http://localhost:8181 --output html --output-path ./next_18.html
Khác biệt lớn nhất là chỉ số Time To interaction, đúng như mình dự đoán, bởi vì _@.com stream từng chunk về, ngay tại thời điểm browser nhận được chunk, nó sẽ bắt đầu tải script để hydrate component luôn, nên TTI sẽ nhanh hơn rất nhiều do không phải đợi toàn bộ HTML phải được render -> Toàn bộ JS phải về -> hydrate.
Tổng kết.
Trên đây là tổng kết toàn bộ quá trình mình upgrade react lên version 18 và benchmark metrics để xem việc upgrade có thực sự tốt. Còn bạn thì sao đã upgrade lên react 18 chưa, hoặc có các chỉ số nào để cần test thêm cho mình biết với nhé, cảm ơn các bạn rất nhiều vì đã xem bài viết này, nhớ like, theo dõi để cho mình có động lực ra những bài tiếp theo nha.