不只是同構應用(isomorphic 工程化你所忽略的細節)

不管是服務端渲染還是服務端渲染衍生出的同構應用,現在來看已經並不新鮮了,實現起來也並不困難。但是社區上相關文章質量良莠不齊,很多隻是“紙上談兵”,甚至有的開發者認爲:同構應用不就是調用一個 renderToString(React 中)類似的 API 嗎?

講道理確實是這樣的,但是講道理你也許並沒有真正在實戰中領會同構應用的精髓。

同構應用能夠實現的本質條件是虛擬 DOM,基於虛擬 DOM 我們可以生成真實的 DOM,並由瀏覽器渲染;也可以調用不同框架的不同 APIs,將虛擬 DOM 生成字符串,由服務端傳輸給客戶端。

但是同構應用也不只是這麼簡單,它涉及到 NodeJS 層構建應用的方方面面。拿面試來說,同構應用的考察點不是“紙上談兵”的理論,而是實際實施時的細節。今天我們就來聊一聊“同構應用工程中往往被忽略的細節”,需要讀者提前瞭解服務端渲染和同構應用的概念。

相關知識點如下:

clipboard.png

打包環境區分

第一個細節:我們知道同構應用實現了客戶端代碼和服務端代碼的基本統一,我們只需要編寫一種組件,就能生成適用於服務端和客戶端的組件案例。可是你是否知道,服務端代碼和客戶端代碼大多數情況下還是需要單獨處理?比如:

  • 路由代碼差別:服務端需要根據請求路徑,匹配頁面組件;客戶端需要通過瀏覽器中的地址,匹配頁面組件。

來看一個例子,客戶端代碼:

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 的,服務端代碼如下:

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 中。

  • 打包差別:服務端運行的代碼如果需要依賴 Node 核心模塊或者第三方模塊,就不再需要把這些模塊代碼打包到最終代碼中了。因爲環境已經安裝這些依賴,可以直接引用。這樣一來,就需要我們在 webpack 中配置:target:node,並藉助 webpack-node-externals 插件,解決第三方依賴打包的問題。
  • 對於圖片等靜態資源,url-loader 會在服務端代碼和客戶端代碼打包過程中分別被引用,因此會在資源目錄中生成了重複的文件。當然後打包出來的因爲重名,會覆蓋前一次打包出來的結果,並不影響使用,但是整個構建過程並不優雅。
  • 由於路由在服務端和客戶端的差別,因此 webpack 配置文件的 entry 會不相同:
{
    entry: './src/client/index.js',
}

{
    entry: './src/server/index.js',
}

注水和脫水

第二個細節非常重要,涉及到數據的預獲取。也是服務端渲染的真正意義。

什麼叫做注水和脫水呢?這個和同構應用中數據的獲取有關:在服務器端渲染時,首先服務端請求接口拿到數據,並處理準備好數據狀態(如果使用 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,保障數據全部已經預先加載了呢?

一般有兩種方法:

  • react-router 的解決方案是配置路由 route-config,結合 matchRoutes,找到頁面上相關組件所需的請求接口的方法並執行請求。這就要求開發者通過路由配置信息,顯式地告知服務端請求內容。

我們首先配置路由:

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)
})
  • 另外一種思路類似 Next.js,我們需要在 React 組件上定義靜態方法。

比如定義靜態 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 過程可以簡化爲:

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 開發者閱讀,相信一定收穫很多!

meta tags 渲染

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 處理

當服務端渲染時,我們還需要留心對 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 是怎麼處理這個安全隱患的呢?

性能優化

我們將數據請求移到了服務端,但是依然要格外重視性能優化。目前針對於此,業界普遍做法包括以下幾點。

  • 使用緩存:服務端優化一個最重要的手段就是緩存,不同於傳統服務端緩存措施,我們甚至可以實現組件級緩存,業界 walmartlabs 在這方面的實踐非常多,且收穫了較大的性能提升。感興趣的讀者可以找到相關技術信息。
  • 採用 HSF 代替 HTTP,HSF 是 High-Speed Service Framework 的縮寫,譯爲分佈式的遠程服務調用框架,對外提供服務上,HSF 性能遠超過 HTTP。
  • 對於服務端壓力過大的場景,動態切換爲客戶端渲染。
  • NodeJS 升級。
  • React 升級。

如圖所示,React 16 在服務端渲染上的性能對比提升:

enter image description here

Beyond isomorphic

短短篇幅其實仍然無法說清楚同構應用的方方面面,如何優雅地設計一個 isomorphic 應用,將是開發者設計功力的體現。

在普通的 renderToString 調用之上,更“強大”、更“牛”的設計,比如我們需要關心以下問題:

  • 如何在服務端獲取數據,包含獲取深層組件跨層級的數據和攜帶鑑權信息的數據
  • 服務端渲染和客戶端渲染的一致性
  • SPA 服務端渲染的一致性問題
  • 同構項目中,JS 和 CSS 內聯和外聯設計
  • 真正意義的流式渲染(區分假 renderToNodeStream 和 FaceBook 的 BigPipe)
  • Node 端請求的 timeout 時間設計,結合客戶端動態“接力”渲染,服務端先返回帶有 script 標籤的(帶有空數據指明信息)的 html 內容

最後一點我稍微提一下,我設計的理想同構應用的輪子啓動時,獲取一個 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 層面的設計也沒有設計,歡迎大家和我討論,保持聯繫,我也會貢獻更多內容和資源。

分享交流

本篇文章主要內容出自我的課程:前端開發核心知識進階

感興趣的讀者可以:

PC 端點擊瞭解更多《前端開發核心知識進階》

移動端點擊瞭解更多:

移動端點擊瞭解更多《前端開發核心知識進階

大綱內容:

image

Happy coding!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章