不論是服務端渲染仍是服務端渲染衍生出的同構應用,如今來看已經並不新鮮了,實現起來也並不困難。可是社區上相關文章質量參差不齊,不少只是「紙上談兵」,甚至有的開發者認爲:同構應用不就是調用一個 renderToString(React 中)相似的 API 嗎?javascript
講道理確實是這樣的,可是講道理你也許並無真正在實戰中領會同構應用的精髓。css
同構應用可以實現的本質條件是虛擬 DOM,基於虛擬 DOM 咱們能夠生成真實的 DOM,並由瀏覽器渲染;也能夠調用不一樣框架的不一樣 APIs,將虛擬 DOM 生成字符串,由服務端傳輸給客戶端。html
可是同構應用也不僅是這麼簡單,它涉及到 NodeJS 層構建應用的方方面面。拿面試來講,同構應用的考察點不是「紙上談兵」的理論,而是實際實施時的細節。今天咱們就來聊一聊「同構應用工程中每每被忽略的細節」,須要讀者提早了解服務端渲染和同構應用的概念。前端
相關知識點以下:java
第一個細節:咱們知道同構應用實現了客戶端代碼和服務端代碼的基本統一,咱們只須要編寫一種組件,就能生成適用於服務端和客戶端的組件案例。但是你是否知道,服務端代碼和客戶端代碼大多數狀況下仍是須要單獨處理?好比:node
來看一個例子,客戶端代碼:react
const App = () => { return ( <Provider store={store}> <BrowserRouter> <div> <Route path='/' component={Home}> <Route path='/product' component={Product}> </div> </BrowserRouter> </Provider> ) } ReactDom.render(<App/>, document.querySelector('#root'))
BrowserRouter 組件根據 window.location 以及 history API 實現頁面切換,而服務端確定是沒法獲取 window.location 的,服務端代碼以下:webpack
const App = () => { return <Provider store={store}> <StaticRouter location={req.path} context={context}> <div> <Route path='/' component={Home}> </div> </StaticRouter> </Provider> } Return ReactDom.renderToString(<App/>)
須要使用 StaticRouter 組件,並將請求地址和上下文信息做爲 location 和 context 這兩個 props 傳入 StaticRouter 中。git
{ entry: './src/client/index.js', } { entry: './src/server/index.js', }
第二個細節很是重要,涉及到數據的預獲取。也是服務端渲染的真正意義。web
什麼叫作注水和脫水呢?這個和同構應用中數據的獲取有關:在服務器端渲染時,首先服務端請求接口拿到數據,並處理準備好數據狀態(若是使用 Redux,就是進行 store 的更新),爲了減小客戶端的請求,咱們須要保留住這個狀態。通常作法是在服務器端返回 HTML 字符串的時候,將數據 JSON.stringify 一併返回,這個過程,叫作脫水(dehydrate);在客戶端,就再也不須要進行數據的請求了,能夠直接使用服務端下發下來的數據,這個過程叫注水(hydrate)。用代碼來表示:
服務端:
ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <script> window.context = { initialState: ${JSON.stringify(store.getState())} } </script> <div id="app"> // ... </div> </body> </html> `
客戶端:
export const getClientStore = () => { const defaultState = JSON.parse(window.context.state) return createStore(reducer, defaultState, applyMiddleware(thunk)) }
這一系列過程很是典型,可是也會有幾個細節值得探討:在服務端渲染時,服務端如何可以請求全部的數據請求 APIs,保障數據所有已經預先加載了呢?
通常有兩種方法:
咱們首先配置路由:
const routes = [ { path: "/", component: Root, loadData: () => getSomeData() } // etc. ] import { routes } from "./routes" function App() { return ( <Switch> {routes.map(route => ( <Route {...route} /> ))} </Switch> ) }
在服務端代碼中:
import { matchPath } from "react-router-dom" const promises = [] routes.some(route => { const match = matchPath(req.path, route) if (match) promises.push(route.loadData(match)) return match }) Promise.all(promises).then(data => { putTheDataSomewhereTheClientCanFindIt(data) })
好比定義靜態 loadData 方法,在服務端渲染時,咱們能夠遍歷全部組件的 loadData,獲取須要請求的接口。這樣的方式借鑑了早期 React-apollo 的解決方案,我我的很喜歡這種設計。這裏貼出我爲 Facebook 團隊著名的 react-graphQl-apollo 開源項目貢獻的改動代碼,其目的就是遍歷組件,獲取請求接口:
function getPromisesFromTree({ rootElement, rootContext = {}, }: PromiseTreeArgument): PromiseTreeResult[] { const promises: PromiseTreeResult[] = []; walkTree(rootElement, rootContext, (_, instance, context, childContext) => { if (instance && hasFetchDataFunction(instance)) { const promise = instance.fetchData(); if (isPromise<Object>(promise)) { promises.push({ promise, context: childContext || context, instance }); return false; } } }); return promises; } // Recurse a React Element tree, running visitor on each element. // If visitor returns `false`, don't call the element's render function // or recurse into its child elements. export function walkTree( element: React.ReactNode, context: Context, visitor: ( element: React.ReactNode, instance: React.Component<any> | null, context: Context, childContext?: Context, ) => boolean | void, ) { if (Array.isArray(element)) { element.forEach(item => walkTree(item, context, visitor)); return; } if (!element) { return; } // A stateless functional component or a class if (isReactElement(element)) { if (typeof element.type === 'function') { const Comp = element.type; const props = Object.assign({}, Comp.defaultProps, getProps(element)); let childContext = context; let child; // Are we are a react class? if (isComponentClass(Comp)) { const instance = new Comp(props, context); // In case the user doesn't pass these to super in the constructor. // Note: `Component.props` are now readonly in `@types/react`, so // we're using `defineProperty` as a workaround (for now). Object.defineProperty(instance, 'props', { value: instance.props || props, }); instance.context = instance.context || context; // Set the instance state to null (not undefined) if not set, to match React behaviour instance.state = instance.state || null; // Override setState to just change the state, not queue up an update // (we can't do the default React thing as we aren't mounted // "properly", however we don't need to re-render as we only support // setState in componentWillMount, which happens *before* render). instance.setState = newState => { if (typeof newState === 'function') { // React's TS type definitions don't contain context as a third parameter for // setState's updater function. // Remove this cast to `any` when that is fixed. newState = (newState as any)(instance.state, instance.props, instance.context); } instance.state = Object.assign({}, instance.state, newState); }; if (Comp.getDerivedStateFromProps) { const result = Comp.getDerivedStateFromProps(instance.props, instance.state); if (result !== null) { instance.state = Object.assign({}, instance.state, result); } } else if (instance.UNSAFE_componentWillMount) { instance.UNSAFE_componentWillMount(); } else if (instance.componentWillMount) { instance.componentWillMount(); } if (providesChildContext(instance)) { childContext = Object.assign({}, context, instance.getChildContext()); } if (visitor(element, instance, context, childContext) === false) { return; } child = instance.render(); } else { // Just a stateless functional if (visitor(element, null, context) === false) { return; } child = Comp(props, context); } if (child) { if (Array.isArray(child)) { child.forEach(item => walkTree(item, childContext, visitor)); } else { walkTree(child, childContext, visitor); } } } else if ((element.type as any)._context || (element.type as any).Consumer) { // A React context provider or consumer if (visitor(element, null, context) === false) { return; } let child; if ((element.type as any)._context) { // A provider - sets the context value before rendering children ((element.type as any)._context as any)._currentValue = element.props.value; child = element.props.children; } else { // A consumer child = element.props.children((element.type as any)._currentValue); } if (child) { if (Array.isArray(child)) { child.forEach(item => walkTree(item, context, visitor)); } else { walkTree(child, context, visitor); } } } else { // A basic string or dom element, just get children if (visitor(element, null, context) === false) { return; } if (element.props && element.props.children) { React.Children.forEach(element.props.children, (child: any) => { if (child) { walkTree(child, context, visitor); } }); } } } else if (typeof element === 'string' || typeof element === 'number') { // Just visit these, they are leaves so we don't keep traversing. visitor(element, null, context); } }
可是一個重要細節是:以 Next.js 爲例,getInitialData 的方法必需要註冊在根組件 App 當中。這樣作的目的在於減小子孫組件的渲染。由於若是子孫組件也注入了 getInitialData 方法,那麼若是不進行渲染,天然也就沒法收集到該子孫組件 getInitialData 方法。
也就是說,基於 walkTree 的方案或者其餘非配置化方案,咱們都須要在服務端渲染兩次。第一次的目的在於收集請求,第二次纔是 renderToString 獲得真正的渲染結果。
咱們項目中的整個 isomorphic 過程能夠簡化爲:
更多內容因爲敏感性,再也不展開。
使人期待的 React.suspense 能夠解決 double rendering 的問題,但你知道原理是什麼嗎?後續我會寫文章分析,歡迎關注~
注水和脫水,是同構應用最爲核心和關鍵的細節點。
上面講到服務端預先請求數據,那麼思考這樣的場景:某個請求依賴 cookie 代表的用戶信息,好比請求「個人學習計劃列表」。這種狀況下服務端請求是不一樣於客戶端的,不會有瀏覽器添加 cookie 以及不含郵其餘相關的 header 信息。這個請求在服務端發送時,必定不會拿到預期的結果。
爲了解決這個問題,咱們來看看 React-apollo 的解決方法:
import { ApolloProvider } from 'react-apollo' import { ApolloClient } from 'apollo-client' import { createHttpLink } from 'apollo-link-http' import Express from 'express' import { StaticRouter } from 'react-router' import { InMemoryCache } from "apollo-cache-inmemory" import Layout from './routes/Layout' // Note you don't have to use any particular http server, but // we're using Express in this example const app = new Express(); app.use((req, res) => { const client = new ApolloClient({ ssrMode: true, // Remember that this is the interface the SSR server will use to connect to the // API server, so we need to ensure it isn't firewalled, etc link: createHttpLink({ uri: 'http://localhost:3010', credentials: 'same-origin', headers: { cookie: req.header('Cookie'), }, }), cache: new InMemoryCache(), }); const context = {} // The client-side App will instead use <BrowserRouter> const App = ( <ApolloProvider client={client}> <StaticRouter location={req.url} context={context}> <Layout /> </StaticRouter> </ApolloProvider> ); // rendering code (see below) })
這個作法也很是簡單,原理是:服務端請求時須要保留客戶端頁面請求的信息,並在 API 請求時攜帶並透傳這個信息。上述代碼中,createHttpLink 方法調用時:
headers: { cookie: req.header('Cookie'), },
這個配置項就是關鍵,它使得服務端的請求完整地還原了客戶端信息,所以驗證類接口也再也不會有問題。
事實上,不少早期 React 完成服務端渲染的輪子好比 React-universally 都借鑑了 React-apollo 衆多優秀思想,對這個話題感興趣的讀者能夠抽空去了解 React-apollo。
同構應用的樣式處理容易被開發者所忽視,而一旦忽略,就會掉到坑裏。好比,正常的服務端渲染只是返回了 HTML 字符串,樣式須要瀏覽器加載完 CSS 後纔會加上,這個樣式添加的過程就會形成頁面的閃動。
再好比,咱們不能再使用 style-loader 了,由於這個 webpack loader 會在編譯時將樣式模塊載入到 HTML header 中。可是在服務端渲染環境下,沒有 window 對象,style-loader 進而會報錯。通常咱們換用 isomorphic-style-loader 來實現:
{ test: /\.css$/, use: [ 'isomorphic-style-loader', 'css-loader', 'postcss-loader' ], }
同時 isomorphic-style-loader 也會解決頁面樣式閃動的問題。它的原理也不難理解:在服務器端輸出 html 字符串的同時,也將樣式插入到 html 字符串當中,將結果一同傳送到客戶端。
isomorphic-style-loader 具體作了什麼呢,他是如何實現的?
咱們知道對於 webpack 來講,全部的資源都是模塊,webpack loader 在編譯過程當中能夠將導入的 CSS 文件轉換成對象,拿到樣式信息。所以 isomorphic-style-loader 能夠獲取頁面中全部組件樣式。爲了實現的更加通用化,isomorphic-style-loader 利用 context API,在渲染頁面組件時獲取全部 React 組件的樣式信息,最終插入到 HTML 字符串中。
在服務端渲染時,咱們須要加入這樣的邏輯:
import express from 'express' import React from 'react' import ReactDOM from 'react-dom' import StyleContext from 'isomorphic-style-loader/StyleContext' import App from './App.js' const server = express() const port = process.env.PORT || 3000 // Server-side rendering of the React app server.get('*', (req, res, next) => { const css = new Set() // CSS for all rendered React components const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss())) const body = ReactDOM.renderToString( <StyleContext.Provider value={{ insertCss }}> <App /> </StyleContext.Provider> ) const html = `<!doctype html> <html> <head> <script src="client.js" defer></script> <style>${[...css].join('')}</style> </head> <body> <div id="root">${body}</div> </body> </html>` res.status(200).send(html) }) server.listen(port, () => { console.log(`Node.js app is running at http://localhost:${port}/`) })
咱們定義了 css Set 類型來存儲頁面全部的樣式,並定義了 insertCss 方法,該方法經過 context 傳給每一個 React 組件,這樣每一個組件在服務端渲染階段就能夠調用 insertCss 方法。該方法調用時,會將組件樣式加入到 css Set 當中。
最後咱們用 [...css].join('') 就能夠獲取頁面的全部樣式字符串。
強調一下,isomorphic-style-loader 的源碼目前已經更新,採用了最新的 React hooks API,我推薦給 React 開發者閱讀,相信必定收穫不少!
React 應用中,骨架每每相似:
const App = () => { return ( <div> <Component1 /> <Component2 /> </div> ) } ReactDom.render(<App/>, document.querySelector('#root'))
App 組件嵌入到 document.querySelector('#root') 節點當中,通常是不包含 head 標籤的。 可是單頁應用在切換路由時,可能也會須要動態修改 head 標籤信息,好比 title 內容。也就是說:在單頁面應用切換頁面,不會通過服務端渲染,可是咱們仍然須要更改 document 的 title 內容。
那麼服務端如何渲染 meta tags head 標籤就是一個常被忽略可是相當重要的話題,咱們每每使用 React-helmet 庫來解決問題。
Home 組件:
import Helmet from "react-helmet"; <div> <Helmet> <title>Home page</title> <meta name="description" content="Home page description" /> </Helmet> <h1>Home component</h1>
Users 組件:
<Helmet> <title>Users page</title> <meta name="description" content="Users page description" /> </Helmet>
React-helmet 這個庫會在 Home 組件和 Users 組件渲染時,檢測到 Helmet,並自動執行反作用邏輯。執行反作用的過程:React-helmet 依賴了 react-side-effect 庫,該庫做者就是大名鼎鼎的 Dan abramov,也推薦給你們學習。
當服務端渲染時,咱們還須要留心對 404 的狀況進行處理,有 layout.js 文件以下:
<Switch> <Route path="/" exact component={Home} /> <Route path="/users" exact component={Users} /> </Switch>
當訪問:/home
時,會獲得一個空白頁面,瀏覽器也沒有獲得 404 的狀態碼。爲了處理這種狀況,咱們加入:
<Switch> <Route path="/" exact component={Home} /> <Route path="/users" exact component={Users} /> <Route component={NotFound} /> </Switch>
並建立 NotFound.js 文件:
import React from 'react' export default function NotFound({ staticContext }) { if (staticContext) { staticContext.notFound = true } return ( <div>Not found</div> ) }
注意,在訪問一個不存在的地址時,咱們要返回 404 狀態碼。通常 React router 類庫已經幫咱們進行了較好的封裝,Static Router 會注入一個 context prop,並將 context.notFound 賦值爲 true,在 server/index.js 加入:
const context = {} const html = renderer(data, req.path, context); if (context.notFound) { res.status(404) } res.send(html)
便可。這一系列處理過程沒有什麼難點,可是這種處理意識,仍是須要具有的。
安全問題很是關鍵,尤爲是涉及到服務端渲染,開發者要格外當心。這裏提出一個點:咱們前面提到了注水和脫水過程,其中的代碼:
ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <script> window.context = { initialState: ${JSON.stringify(store.getState())} } </script> <div id="app"> // ... </div> </body> </html> `
很是容易遭受 XSS 攻擊,JSON.stringify 可能會形成 script 注入。所以,咱們須要嚴格清洗 JSON 字符串中的 HTML 標籤和其餘危險的字符。我習慣使用 serialize-javascript 庫進行處理,這也是同構應用中最容易被忽視的細節。
另外一個規避這種 XSS 風險的作法是:將數據傳遞個頁面中一個隱藏的 textarea 的 value 中,textarea 的 value 天然就不怕 XSS 風險了。
這裏給你們留一個思考題,React dangerouslySetInnerHTML API 也有相似風險,React 是怎麼處理這個安全隱患的呢?
咱們將數據請求移到了服務端,可是依然要格外重視性能優化。目前針對於此,業界廣泛作法包括如下幾點。
如圖所示,React 16 在服務端渲染上的性能對比提高:
短短篇幅其實仍然沒法說清楚同構應用的方方面面,如何優雅地設計一個 isomorphic 應用,將是開發者設計功力的體現。
在普通的 renderToString 調用之上,更「強大」、更「牛」的設計,好比咱們須要關心如下問題:
最後一點我稍微提一下,我設計的理想同構應用的輪子啓動時,獲取一個 timeout 參數。服務端渲染真正在於服務端請求數據。在實際應用中好比,當前應用須要在服務端請求 6 組 RPC,在請求過程當中超時(這個 timeout 由業務方設置),只拉取了 4 個接口,注水 4 組數據源。爲了縮短 TTFB 的時間,服務端優先返回,剩下的未請求到的 2 個接口數據經過 script 標籤注入頁面,並進行返回,這樣客戶端超時前便可渲染頁面。
開源的 react-server.io 也實現了相似功能,同時它經過指令化的組件,來作到服務端渲染時,數據的順序可控性:
getElements() { return <RootContainer> <RootElement when={headerPromise}> <Header /> </RootElement> <RootContainer listen={bodyEmitter}> <MainContent /> <RootElement when={sidebarPromise}> <Sidebar /> </RootElement> </RootContainer> <TheFold /> <Footer /> </RootContainer> }
注意 RootElement 的 when props,以及 RootContainer 的 listen props,顧名思義,這些都實現漸進式渲染和服務端控制。
與此相關的其餘概念以及上述技術細節的實現,因爲篇幅緣由,這裏再也不展開,將來我講針對更高階的同構應用設計產出更多文章。
最後,服務端渲染和目前革命性趨勢 serverless 的結合也很值得期待,前一段在和狼叔聊天時得知阿里在積極嘗試同構應用在 serverless 環境下的架構設計,我我的將來長期看好,也會在這個主題上分享更多內容。
本講沒有「手把手」教你實現服務端渲染的同構應用,由於這些知識並不困難,社區上資料也不少。咱們從更高的角度出發,剖析同構應用中那些關鍵的細節點和疑難問題的解決方案,這些經驗來源於真刀真槍的線上案例,若是讀者沒有開發過同構應用,也能從中全方位地瞭解關鍵信息,一旦掌握了這些細節,同構應用的實現就會更穩、更可靠。
同構應用其實遠比理論複雜,絕對不是幾個 APIs 和幾臺服務器就能完成的,但願你們多思考、多動手,必定會更有體會。
另外,同構應用各類細節也不止於此,坑也不止於此,還有更多 NodeJS 層面的設計也沒有設計,歡迎你們和我討論,保持聯繫,我也會貢獻更多內容和資源。
本篇文章主要內容出自個人課程:前端開發核心知識進階
感興趣的讀者能夠:
移動端點擊瞭解更多:
大綱內容:
Happy coding!