本文做者 Bermudarat頭圖來自 Level up your React architecture with MVVM, 做者 Danijel Vincijanovichtml
在開始正文前,先介紹幾個概念(已經瞭解的朋友能夠跳過):前端
Server Side Rendering(SSR):服務端渲染,簡而言之就是後臺語言經過模版引擎生成 HTML 。實現方式依賴於後臺語言,例如 Python Flask 的 Jinja、Django 框架、Java 的 VM、Node.js 的 Jade 等。react
Client Side Rendering(CSR):客戶端渲染,服務器只提供接口,路由以及渲染都丟給前端。webpack
同構:先後端共用一套代碼邏輯,全部渲染功能均由前端實現。在服務端輸出含最基本的 HTML 文件;在客戶端進一步渲染時,判斷已有的 DOM 節點和即將渲染出的節點是否相同。如不一樣,從新渲染 DOM 節點,如相同,則只需綁定事件便可(這個過程,在 React 中稱之爲 注水)。同構是實現 SSR 的一種方式,側重點在於代碼複用。git
靜態路由:靜態路由須要在頁面渲染前聲明好 URL 到頁面的映射關係。如 Angular、Ember 中的路由,React Router v4 以前版本也採用此種路由。github
動態路由:動態路由拋開了靜態路由在渲染前定義映射關係的作法,在渲染過程當中動態生成映射。React Router v4 版本提供了對動態路由的支持。web
Code Splitting:也就是代碼分割,是由諸如 Webpack,Rollup 和 Browserify(factor-bundle)這類打包器支持的一項技術,可以在打包文件時建立多個包並在運行時動態加載。json
Next.js、 Nuxt.js 是目前成熟的同構框架,前者基於 React,後者基於 Vue。有了這些框架,開發者能夠方便地搭建一個同構應用:只對首屏同構直出,知足 SEO 需求,減小白屏時間;使用前端路由進行頁面跳轉,實現局部渲染。這些同構框架,已經在工程中獲得了普遍應用。然而知其然也要知其因此然,對於一個功能完善的同構應用,須要解決如下幾個方面的問題:後端
上述問題的解決過程當中,有不少坑會踩,本文主要討論第一點。此外,提出一種解決方案,在服務端不使用中心化的路由配置,結合 Code Splitting ,經過一次預渲染,獲取當前 URL 對應的模塊名和數據獲取方法。api
React 提供了 四個方法 用來在服務端渲染 React 組件。其中,renderToStaticMarkup
、renderToStaticNodeStream
不會在 React 內部建立額外的 DOM 屬性,一般用於生成靜態頁面。同構中經常使用的是 renderToString
、 renderToNodeStream
這兩個方法:前者將應用渲染成字符串;後者將應用渲染爲 Stream 流,能夠顯著下降首字節響應時間(TTFB)。
實現一個同構的 React 應用,須要如下幾個步驟(下文均以字符串渲染爲例):
renderToString
方法,將應用渲染成字符串;這是實現同構的通用思路,Next.js 框架也是這種思路。
以上步驟的第一步,是獲取匹配當前 URL 的路由。不一樣的路由對應不一樣的數據獲取方法,這是後續步驟的前提。
React Router v4 提供了 [React Router Config](
https://github.com/ReactTrain... 實現中心化的靜態路由配置,用於獲取 React 應用的路由信息,方便在服務端渲染時獲取數據:
With the introduction of React Router v4, there is no longer a centralized route configuration. There are some use-cases where it is valuable to know about all the app's potential routes such as:
- Loading data on the server or in the lifecycle before rendering the next screen
- Linking to routes by name
- Static analysis
React Router Config 提供了 matchRoutes
方法實現路由匹配。如何使用,在 文檔 中有詳細的說明:
// routes 爲中心化的路由配置文件 const routes = [ { path: "/", component: Root, loadData: () => getSomeData() } ]; const loadBranchData = location => { const branch = matchRoutes(routes, location.pathname); // 調用 route 上定義的數據獲取方法 const promises = branch.map(({route, match}) => { return route.loadData ? route.loadData(match): Promise.resolve(null); }); return Promise.all(promises); }; // 預獲取數據,並在 HTML 文件中寫入數據 loadBranchData(req.URL).then(data => { putTheDataSomewhereTheClientCanFindIt(data); });
loadData
方法除了做爲路由的屬性外,也能夠在 Root
的靜態方法中定義。
// Root 組件 const Root = () => { ... }; Root.loadData = () => getSomeData(); // 路由配置 const routes = [ { path: "/", component: Root } ]; // 頁面匹配 const loadBranchData = location => { // routes 爲中心化的路由配置文件 const branch = matchRoutes(routes, location.pathname); // 調用 component 上的靜態數據獲取方法 const promises = branch.map(({route, match}) => { return route.component.loadData ? route.component.loadData(match): Promise.resolve(null); }); return Promise.all(promises); };
接下就可使用預獲取的數據進行渲染。
HTML 字符串中須要包含客戶端渲染所需的 JS/CSS 標籤。對於沒有 Code Splitting 的應用,很容易定位這些資源文件。然而對於一個複雜的單頁應用,不進行 Code Splitting 會致使 JS 文件體積過大,增長了傳輸時間和瀏覽器解析時間,從而致使頁面性能降低。在 SSR 時,如何篩選出當前 URL 對應的 JS/CSS 文件,是接下來要解決的問題。
;
chunkNames
;chunkNames
對應的分塊代碼信息,並組裝成 JS/CSS 標籤。以 react-universal-component 爲例,代碼實現以下:
import {ReportChunks} from 'react-universal-component' import flushChunks from 'webpack-flush-chunks' import ReactDOM from 'react-dom/server' // webpackStats 中包含了應用中全部模塊的數據信息,能夠經過 webpack 打包得到 import webpackStats from './dist/webpackstats.json'; function renderToHtml () => { // 保存匹配當前 URL 的組件 chunk let chunkNames = []; const appHtml = ReactDOM.renderToString( // ReportChunks 經過 React Context 將 report 方法傳遞至每一個動態加載組件上。組件在加載時,執行 report 方法,從而將組件的模塊名傳遞至外部。 <ReportChunks report={chunkName => chunkNames.push(chunkName)}> <App /> </ReportChunks> ); // 提取 webpacStats 中 chunkNames 的信息,並組裝爲標籤; const {scripts} = flushChunks(webpackStats, { chunkNames, }); // 後續省略 }
綜上,使用 React Router 進行服務端渲染,須要執行如下步驟:
上述過程,流程以下:
 { // 使用 match.path,能夠避免前置路徑的重複書寫 let match = useRouteMatch(); return ( <div> <h>Child</h> <Route path={`${match.path}/grand-child`} /> </div> ) }
可是若是使用動態路由的話,該如何與當前 URL 匹配呢?
前面介紹了,react-universal-component 等動態加載組件, 能夠經過一次渲染,獲取對應當前 URL 的模塊名。
let chunkNames = []; const appHtml = ReactDOM.renderToString( <ReportChunks report={chunkName => chunkNames.push(chunkName)}> <App /> </ReportChunks> );
咱們是否可使用相似的方式,經過一次渲染,將定義在組件上的數據獲取方法傳遞至外部呢?好比下面的書寫方式:
let chunkNames = []; let loadDataMethods = []; const appHtml = ReactDOM.renderToString( <ReportChunks report={(chunkName, loadData) => { chunkNames.push(chunkName); loadDataMethods.push(loadData); }}> <App /> </ReportChunks> );
react-universal-component 中, ReportChunks
組件使用 React Context 將 report
方法傳遞至每一個動態加載組件上。組件在加載時,執行 report
方法,將組件的模塊名傳遞至外部。
所以,咱們只須要修改動態加載方法,使其在執行 report
方法時,同時將模塊名 chunkName
和組件上的靜態方法返回便可:
// AsyncComponent 提供在服務端同步加載組件的功能 class AsyncComponent extends Component { constructor(props) { super(props); const {report} = props; // syncModule 爲內置函數,不對用戶暴露,主要功能是使用 webpack 提供的 require.resolveWeak 方法實現模塊的同步加載; const comp = syncModule(resolveWeak, load); if (report && comp) { const exportStatic = {}; // 將 comp 的靜態方法複製至 exportStatic hoistNonReactStatics(exportStatic, comp); exportStatic.chunkName = chunkName; // 將 chunkName 和靜態方法傳遞給外部 report(exportStatic); } } // ... }
完整的實現能夠參考 react-asyncmodule。react-asyncmodule 提供了 AsyncChunk
組件,與 react-universal-component 提供的 ReportChunks
組件類似,做用是將 report
方法傳遞至每一個動態加載組件上。使用方法以下:
let modules = []; const saveModule = (m) => { // m 中包含 chunkName 和靜態數據獲取方法; const { chunkName } = m; // 過濾重複的 chunkName if (modules.filter(e => e.chunkName === chunkName).length) return; modules.push(m); }; const appHtml = ReactDOM.renderToString( <AsyncChunk report={saveModule}> <App /> </AsyncChunk> );
完整流程以下:
); const BasicExample = (props) => { const {canRender} = props; return ( <Router> <Route exact path="/"> { canRender ? <PageA /> : <div>Render Nothing!</div> } </Route> </Router> ); }; BasicExample.getInitialProps = () => { // 此處獲取 canRender,用於肯定 PageA 組件是否渲染 };
預渲染時 canRender
爲 undefined
, 不會渲染 PageA
,因此也不能獲取到 PageA
對應的模塊名和靜態方法。正式渲染時,服務端渲染出的頁面中會缺乏 PageA
中的數據信息。爲了解決這個問題,業務代碼須要在 PageA
的 componentDidMount
生命週期中,進行數據的獲取,以正確展現頁面。
此外,預渲染可使用 renderToStaticMarkup
方法,相比 renderToString
,renderToStaticMarkup
不會生成額外的 React 屬性,所以減小了 HTML 字符串的大小。可是預渲染自己增長了服務端的計算壓力,因此能夠考慮緩存預渲染結果,實現思路以下:
moduleCache
;matchPath
方法,在 moduleCache
中查找是否有此 path string 模式(例如 /user/:name
)的緩存,若是有,則使用緩存的方法進行數據獲取;使用這種方法,對於不一樣的 path string 模式,只需在第一次請求時進行一次預渲染。以後再次請求,使用緩存數據便可。
均之外鍊形式列出
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!