本文做者 Bermudarathtml
頭圖來自 Level up your React architecture with MVVM, 做者 Danijel Vincijanovic前端
在開始正文前,先介紹幾個概念(已經瞭解的朋友能夠跳過):react
Server Side Rendering(SSR):服務端渲染,簡而言之就是後臺語言經過模版引擎生成 HTML 。實現方式依賴於後臺語言,例如 Python Flask 的 Jinja、Django 框架、Java 的 VM、Node.js 的 Jade 等。webpack
Client Side Rendering(CSR):客戶端渲染,服務器只提供接口,路由以及渲染都丟給前端。git
同構:先後端共用一套代碼邏輯,全部渲染功能均由前端實現。在服務端輸出含最基本的 HTML 文件;在客戶端進一步渲染時,判斷已有的 DOM 節點和即將渲染出的節點是否相同。如不一樣,從新渲染 DOM 節點,如相同,則只需綁定事件便可(這個過程,在 React 中稱之爲 注水)。同構是實現 SSR 的一種方式,側重點在於代碼複用。github
靜態路由:靜態路由須要在頁面渲染前聲明好 URL 到頁面的映射關係。如 Angular、Ember 中的路由,React Router v4 以前版本也採用此種路由。web
動態路由:動態路由拋開了靜態路由在渲染前定義映射關係的作法,在渲染過程當中動態生成映射。React Router v4 版本提供了對動態路由的支持。json
Code Splitting:也就是代碼分割,是由諸如 Webpack,Rollup 和 Browserify(factor-bundle)這類打包器支持的一項技術,可以在打包文件時建立多個包並在運行時動態加載。後端
Next.js、 Nuxt.js 是目前成熟的同構框架,前者基於 React,後者基於 Vue。有了這些框架,開發者能夠方便地搭建一個同構應用:只對首屏同構直出,知足 SEO 需求,減小白屏時間;使用前端路由進行頁面跳轉,實現局部渲染。這些同構框架,已經在工程中獲得了普遍應用。然而知其然也要知其因此然,對於一個功能完善的同構應用,須要解決如下幾個方面的問題:api
上述問題的解決過程當中,有不少坑會踩,本文主要討論第一點。此外,提出一種解決方案,在服務端不使用中心化的路由配置,結合 Code Splitting ,經過一次預渲染,獲取當前 URL 對應的模塊名和數據獲取方法。
React 提供了 四個方法 用來在服務端渲染 React 組件。其中,renderToStaticMarkup
、renderToStaticNodeStream
不會在 React 內部建立額外的 DOM 屬性,一般用於生成靜態頁面。同構中經常使用的是 renderToString
、 renderToNodeStream
這兩個方法:前者將應用渲染成字符串;後者將應用渲染爲 Stream 流,能夠顯著下降首字節響應時間(TTFB)。
實現一個同構的 React 應用,須要如下幾個步驟(下文均以字符串渲染爲例):
renderToString
方法,將應用渲染成字符串;這是實現同構的通用思路,Next.js 框架也是這種思路。 以上步驟的第一步,是獲取匹配當前 URL 的路由。不一樣的路由對應不一樣的數據獲取方法,這是後續步驟的前提。
React Router v4 提供了 React Router Config 實現中心化的靜態路由配置,用於獲取 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 文件,是接下來要解決的問題。
Webpack 根據 ECMAScript 提案實現了用於動態加載模塊的 import
方法。React v16.6 版本提供了 React.lazy
和 Suspend
,用於動態加載組件。然而 React.lazy
和 Suspend
並不適用於 SSR,咱們仍須要引入第三方的動態加載庫:
React.lazy and Suspense are not yet available for server-side rendering. If you want to do code-splitting in a server rendered app, we recommend Loadable Components. It has a nice guide for bundle splitting with server-side rendering.
目前已有不少成熟的第三方的動態加載庫: 早期的 React 官方文檔中推薦的 react-loadable,最新推薦的 @loadable/component,以及 react-universal-component 等等,他們提出這樣一種解決方案:
webpack --profile --json > compilation-stats.json
。除了命令行的方式,配置文件也能夠經過 webpack-stats-plugin 插件生成。此外,一些第三方動態加載庫也提供了插件生成這些配置(例如 react-loadable 提供的 ReactLoadablePlugin
);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 進行服務端渲染,須要執行如下步驟:
上述過程,流程以下:
上述討論中,在進行 URL 匹配時,咱們使用了中心化的靜態路由配置。React Router v4 版本的最大改進,就是提出了動態路由。Route
做爲一種真正的 React 組件,與 UI 展現緊密結合,而不是以前版本中的僞組件。有了動態路由組件,咱們再也不須要中心化的路由配置。
與靜態路由相比,動態路由在設計上有不少 改進之處。此外,動態路由在深層路由的書寫上,也比中心化的靜態路由要方便。 使用 React Router Config 進行中心化的靜態路由配置須要提供以下的路由配置文件:
const routes = [
{
component: Root,
routes: [
{
path: "/",
exact: true,
component: Home
},
{
path: "/child/:id",
component: Child,
routes: [
{
path: "/child/:id/grand-child",
component: GrandChild
}
]
}
]
}
];
複製代碼
採用動態路由,則徹底不須要上述配置文件。 以 Child
組件爲例, 能夠在組件中配置子路由。
function Child() {
// 使用 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>
);
複製代碼
完整流程以下:
經過一次預渲染,獲取對應當前 URL 的模塊名和數據獲取方法,適用於大部分動態路由的場景。可是若是動態加載組件自己是否渲染依賴於數據,那麼在預渲染時,這個組件的模塊名和靜態方法不能正常獲取。以下:
const PageA = AsyncComponent(import('./PageA'));
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 模式,只需在第一次請求時進行一次預渲染。以後再次請求,使用緩存數據便可。
均之外鍊形式列出
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!