1. 程式人生 > >React server rendering —— 網易美學主站同構實錄

React server rendering —— 網易美學主站同構實錄

此文已由作者張碩授權網易雲社群釋出。

歡迎訪問網易雲社群,瞭解更多網易技術產品運營經驗。


網易美學主站在最初開發時,因為各種歷史原因,引入了例如JQuery,Bootstrop,Angular, React等框架,程式碼結構比較混亂,給後續的開發和維護帶來了很大的不便。所以對它進行了重構。下面,我會從以下三個方面對主站的重構方案進行介紹:

  • 我們為什麼進行重構?

  • 如何使用React進行同構

  • 同構過程中遇到的問題以及解決方案

我們為什麼要進行重構?

Before

對於同一個元件,需要分別使用模板和React元件實現兩次

早期的主站使用Express作為Node層路由的同時,使用了類似於Jinja的Nunjucks作為javascript 模板引擎,進行HTML檔案的渲染,也就是說,我們的網站是一個多頁應用,Nunjucks渲染滿足了SEO的需求。之後出於封裝和元件的管理引入了Reactjs,對於一個頁面的開發,往往需要兩步:

  1. 使用Nujucks書寫template以及對應的css樣式;

  2. 頁面載入後,對某些需要元件化的DOM 節點進行React元件的替換

images

對於每個頁面,在引入的js檔案中,對DOM節點進行替換,以CommentBox元件為例:

((window, document) => {
  ReactDOM.render(
    <CommentBox limit={20} type={3} id={id} initalLogin={initalLogin}/>,      document.querySelector("#comments")
  )
})(window, document)

對於頁面的開發,造成了額外的工作量。

一些React元件初始化的資料,獲取不易

React元件初始化時,需要把一些資料作為props傳遞進去。例如isLogin屬性,對於一個有登入功能的網站,是否處於登入狀態,影響了元件的展示。但是isLogin這個狀態如何拿到呢,我們只能在Nunjucks模板中進行書寫:

// repo.njkvar initalData = (function(){  var data = {
    id: "{{id}}",
    initalLogin: {{"true" if currentUser.userId else "false"}}
  }  return function(){    return data
  }
})()

通過initialData這個全域性變數獲取React元件初始化所需要的props。

不同元件之間的狀態互相影響

我們的應用中,有一些狀態需要在不同元件間共享。比如登陸狀態isLogin,一些應用的做法是彈窗登陸後,強制重新整理頁面,使各個元件重新整理狀態。但是強制重新整理頁面會影響使用者體驗,這裡,產品的需求是這樣的:

images

點選點贊按鈕,彈出登入框,進行登陸後,進行主動點贊,其他與登入狀態有關的元件,檢測到登入狀態改變後,進行資料獲取和顯示重新整理。

由於我們的元件,是根據id直接掛在在DOM節點上的,這些元件之間沒有巢狀關係,不能通過props去傳遞狀態。只能通過基於釋出-訂閱者模式的全域性事件處理。在每個元件進行登入狀態的trigger和監聽。元件間需要共享的狀態不僅僅只有isLogin,這樣可以預見,我們需要在React元件的事件上,繫結大量的全域性監聽和觸發事件。這樣增加了元件之間的耦合,不利於程式碼的維護。

出於上述的考慮,我們選擇了使用React進行前後端同構。

什麼是同構

同構(Isomorphic)並不是一個新鮮的概念。一些團隊已經基於他們的業務實現了同構直出(參考[1])。

這裡再簡單介紹一下,根據自己理解,同構可以看成,只需要維護一份程式碼,client side(Browser端)和server side(Nodejs端)都可以共用。

這樣,在獲取資料後,server side可以返回已經渲染好的html檔案,滿足SEO需要的同時,相比純client rendering,也減少了響應時間,對於使用者來說,就是減少了白屏這樣不好的體驗。

之後,前端拿到後端返回的HTML和資料,使用同一份程式碼,再次進行渲染。

image (圖片來自網路)

同構方案的選擇

有Next.js這樣的服務端渲染框架,提供了腳手架,生成同構網站。我們沒有直接採用Next.js,主要是出於以下幾方面的考慮:

  • 對於已有專案來說,使用Next.js重寫成本過高;

  • 自己書寫重構方案,更容易定製;

  • Next.js的replaceState不支援IE9;

如何進行同構

這裡,先列出我們使用的工具以及版本:

  • node層框架 —— express(當然也可以用koa)

  • react 15

  • react-router v3 —— react路由的不二選擇

  • react-redux —— 思前想後最後引入的Redux

  • axios —— nodejs和browser通用的http框架,基於Promise

之後,會在後續的《React server rendering —— 網易美學主站同構實錄(二)》中,討論如何引入react16和react-router v4版本,進行同構。

Server Render和Client Render

React提供了在server side進行渲染的方法: renderToString 方法可以將React 元素渲染成HTML字串,並且返回這個字串。

這樣,以Express為例,對於一個請求,server side可以這樣返回:

// app.jsvar handleRender= require('./serverEntry')
app.get('*', handleRender)
// serverEntry.jsimport ReactDOMServer from 'react-dom/server'import App from './App'const handleRender = (req, res) => {  const reactString = ReactDOMServer.renderToString(<App />)
  res.send('<html><div id="app">'+ reactString + '</div></html>')
})module.exports = handleRender

對於client rendering,可以仍然使用ReactDOM提供的render方法。(React 16提供了hydrate方法,用來合併渲染server side渲染過的HTML)

// client.jsximport ReactDOM from 'react-dom'import App from './App'ReactDOM.render(<App />, docoment.getElementById('app'))

在React 16之前,由renderToString生成的HTML的各個DOM會帶有額外屬性:data-react-id,此外,第一個DOM會有data-checksum屬性。在client side 進行渲染時,會檢查HTML DOM是否存在相同的data-react-checksum,如果一致,則client side可以直接使用server side生成的DOM樹。如果不一致,則client side會重新渲染整個HTML,DevTools也會出如下圖的不一致警告:

image

React 16中,去掉了data-react-id和data-checksum屬性,它採用了不同的演算法來檢測client side和server side是否一致。如果不一致的話,會修正這些不一致,而不是在client side 重新生成整個HTML。

不得不說的路由

拋棄了Nunjucks後,重構後的主站是一個單頁應用,從index.html渲染所需要的頁面。路由的引入是不可缺少的,這裡使用了react-router, 對於4.x以前的版本,通過配置巢狀的, 很容易實現一個單頁應用的路由

// routes.jsconst routes = {  path: '/',  component: require('./App').default,  childRoutes: [
    { path: 'about', component: About },
    { path: 'login', component: Login }
  ]
}

有了路由配置之後,client side可以寫成以下:

// client.jsximport routes from './routes'import { browserHistory } from 'react-router' // 在生產環境中使用browserHistory而不是hashHistoryReactDOM.render(<Router routes={{ ...routes }} history={browserHistory} />, docoment.getElementById('app'))

而server side,在獲取請求後,react-router提供了:

  • match方法,可以對req.url進行匹配;

  • RouterContext 用來同步渲染route 元件。

    // serverEntry.jsimport routes from './routes'const handleRender = (req, res) => {
    match({ routes, location: req.url), (err, reactLocation, renderProps) => {  if(error) {
        res.status(500).send(error.message)
      } else if (renderProps) {
        res.status(200).send(ReactDOMServer.renderToString(<RouterContext {...renderProps} />))
      } else {
        res.status(404).send('Not found')
      }
    })
    })

資料獲取

前後端通用的資料獲取

對於一個不需要同構的React 應用來說,我們通常選擇把獲取資料這一步放在componentDidMount方法中,在獲取資料後,使用getState觸發render。但是對於server rendering,並不會執行到componentDidMount這個方法。所以,我們需要在呼叫renderToString前,進行資料的獲取,並將獲取後的資料放置在元件可以訪問到的store中,供元件渲染。

server side進行資料獲取的方法很多,比如說通過代理轉發請求。此外,已經有各種第三方庫,提供了在server side和client side 傳送請求的通用方法。isomorphic-fetch和axios都可以滿足我們的需求。通過封裝第三方庫,我們抹平了在前後端傳送請求書寫上的不同。對於某一個頁面來說,不管是server side還是client side,可以通過同一個fetchData方法獲取初始資料。

fetchData放在哪裡?

下面的問題,就是這個fetchData方法放在哪兒。可以選擇一個檔案,集中管理所有頁面的fetchData方法。一些參考資料中,會選擇把fetchData放置在頁面元件的靜態方法上:ES6中,提供了class中static方法,我們都知道class只是ES6提供的以一個語法糖,並沒有改變JS基於原型的本質。class中定義的static方法,並沒有放置在原型鏈上,可以直接通過類名進行呼叫。

我們的專案也選擇把fetchData放置在頁面static 方法中,主要是考慮到fetchData和業務邏輯放置在一起,維護起來更加方便和直觀。 如此,About,用虛擬碼可以這樣書寫:

// About.jsximport Fetch from './fetch' // 將axios進行封裝後的獲取資料方法import Store from './store' //一個全域性的Storeconst URL = '/api/about'class About extends React.Component {
  constructor(props) {    super(props)
  }  static fetchData() {    return Fetch(URL).then(data => {
      Store.set('about', data)
    })
  }  render() {    const data = Store.get('about')    // 後續的資料處理
    ...
  }
}

static方法fetchData並不是在元件About例項的生命週期裡面,所以對於fetchData中獲取的方法,我們需要先構建一個全域性的Store單例,用來set獲取的資料。在About元件的初始化render中,則可以使用Store.get方法獲取這些資料進行渲染。

server side呼叫fetchData

之前提到了,server side 需要在renderToString之前,就進行資料的獲取。對於頁面元件上的靜態方法fetchData,如何進行呼叫呢?

// serverEntry.js
match({ routes, location: req.url), (err, reactLocation, renderProps) => {  const { params, components, location } = renderProps  const taskList = []
  components.forEach((component) => {
    component && component.fetchData && taskList.push(component.fetchData())
  })
  Promise.all(taskList).then((data) => {    // 呼叫renderToString
  })
})

react-router 提供的match方法的回撥中,renderProps.components即為對應頁面的元件。可以直接呼叫這些元件的fetchData方法。client side 在獲取到server side響應後,要進行渲染,也需要兩部分:使用React框架的App程式碼;從後臺伺服器獲取的請求資料。程式碼部分,可以打包成js檔案引入到返回的html中,而請求資料,可以轉化為字串寫入全域性物件window上:

// serverEntry.js
Promise.all(taskList).then(() => {  const filepath = path.resolve(process.cwd(), 'dist/pages/index.html')
  fs.readfile(filepath, 'utf8', (err, file) => {    const data = Store.get()    const footString = `<script>(function(){window.__INITIAL_STATE__=${JSON.stringify(data)}})()</script>`    const reactString = ReactDOMServer.renderToString(<RouterContext {...renderProps} />)    const result = reactString.replace(/<div id="app"><\/div>/, `<div id="app">${reactString}</div>${footString}`)
    res.send(result)
  })
})

client side呼叫fetchData

對於單頁應用來說,開啟頁面後,頁面的跳轉時在client side完成,並不需要訪問伺服器獲取HTML。所以在進行頁面跳轉時,也需要進行fetchData,然後再掛載頁面元件。react-router 3.x版本中,提供了一個onEnter的hook:onEnter(nextState, replace, callback?)。如果使用了第三個引數,則頁面跳轉會被block,直到呼叫callback。有了onEnter,我們可以這樣進行client side資料獲取:

// routes.jsconst onEnter = (nextState, replace, callback) => {  if (!__BROWSER__) return callback() // 服務端直接返回
  if (window.__INITIAL_STATE__ !== null) {
    window.__INITIAL_STATE__ = null
    return callback()
  }  const { routes } = nextState  const defaultDataHandler = () => Promise.resolve()  const matchedRoute = routes[routes.length - 1]  const fetchDataHandler = matchedRoute.component
    && matchedRoute.component.fetchData || defaultDataHandler  fetchDataHandler().then(data => {
    ... // 一些業務處理
    callback()
  }).catch(err => {
    ... // 錯誤處理
    callback()
  })
}

狀態管理——Redux

之前提到,所以對於fetchData中獲取的方法,我們需要先構建一個全域性的Store單例,用來set獲取的資料。在元件的初始化render中,則可以使用Store.get方法獲取這些資料進行渲染。聽起來很熟悉是不是,Redux中的Store可以完全滿足我們的需求,而不用自己構建一個全域性的Store單例。但是對於大部分工程來說,Redux並不是非用不可,Redux的引入在使資料流更加清晰的同時,也會使元件的結構更加複雜,增加開發的工作量,對於一個setState操作,需要

  • 定義一個actiontype

  • 定義一個action函式

  • 定義一個reducer函式

  • 觸發action

"如果你不知道是否需要 Redux,那就是不需要它。"

但是出於以下的考慮,我們最後決定引入了Redux:

  • Redux提供了方便的通過初始state構建Store的方法,通過dispatch改變state,並可以通過getState獲取狀態;

  • React-Redux 提供Provider元件,將store放在上下文物件context中,子元件可以從context中拿到store,而不用經過層層props傳遞;

  • 我們的應用中,有一些元件的狀態需要共享。比如isLogin狀態,這個狀態改變,會許多元件的狀態

引入了Redux,在一次請求中,我們需要做

  • 建立一個Redux store例項;

  • 對於這個請求,fetchData,並在fetchData中dispatch一些action,獲取到的資料存入store;

  • 從store中獲取改變後的state;

  • 將state放在返回client的HTML字串中,供client端初始化store;

image

在client side,可以對window.__INITIAL_STATE__進行解析,並將解析後的物件作為初始狀態構建Store。

// client.jsximport configStore from './configStore'import { browserHistory } from 'react-router'const store = configStore(window.__INITIAL_STATE__)
ReactDOM.render(
  <Provider store={store}>
    <Router routes={{ ...routes }} history={browserHistory} />
  <Provider>,
  docoment.getElementById('app')
)

如果需要更詳細的介紹,可以參考Reactjs github上對於使用Redux進行server rendering的內容(參考[2])。此外,可以使用第三方庫react-router-redux,它提供了syncHistoryWithStore函式,可以將react-router的history與store互相同步。如果需要記錄、重複使用者行為,或者分析導航事件,則可以引入這個庫。

工程化——webpack

建立開發環境和上線環境,實現模組的打包,前端常用的工具有很多: 例如webpack,gulp, grunt, browerify等。具體的打包方法就不在這裡贅述。

與client rendering的單頁應用不同的是, 也需要對server side進行打包。以webpack為例,就是需要進行兩次打包,入口檔案分別是client.jsx和serverEntry.js。對於serverEntry生成的檔案bundle.server.js,需要在app.js中進行引入:

// app.jsvar handleRender= require('./dist/bundle.server')
app.get('*', handleRender)

遇到的問題以及解決方法

之前參考的資料中,已經有了比較完備的server rendering方案。但是具體的專案實踐中,也遇到了一些問題,在解決這些問題的時候,積累了寫經驗,希望能給之後也有需要進行React 前後端同構的專案一些參考。

HTTP 請求頭處理

通過封裝第三方庫,我們抹平了在前後端傳送請求書寫上的不同。對於某一個頁面來說,不管是server side還是client side,可以通過同一個fetchData方法獲取初始資料。fetchData是頁面元素的一個static方法。 fetchData中,基於業務需求,可能不僅僅有一個獲取資料的方法。比如/about請求,react-router路由匹配到了About元件, 在這個元件中,需要獲取兩部分資料:

  • /api/content : 獲取改頁面的展示內容;

  • /api/user: 獲取當前使用者登入資訊;

    // About.jsximport Fetch from './fetch' // 將axios進行封裝後的獲取資料方法import Store from './store' //一個全域性的Storeclass About extends React.Component {
    constructor(props) {  super(props)
    }static fetchData(store) {  const fetchContent = Fetch('/api/content')  const fetchUser = Fetch('/api/user')  return Promise.all([fetchContent, fetchUser]).then(datas => {
        ...
      })
    }
    render() {   // 後續的render操作
      ...
    }
    }

在 client side, 這樣fetchData沒有問題,因為瀏覽器傳送的請求(/api/content, /api/user),有完備的請求頭。在server side, 收到的/about請求,有完整的請求頭,但是從Node層發出的/api/content, /api/user則缺少了對應的請求頭資訊,例如cookie, 這就導致了/api/user這個介面,是不能獲取登陸資訊的。此外,還缺少referer,origin, userAgent一些對服務端比較重要的請求頭。

那怎麼辦呢?一個比較容易想到的辦法是,在server side,將/about請求的請求頭取出來,然後放到/api/content, /api/user這兩個請求頭上。

這裡,我們是這樣操作的,利用Redux,

  1. 在serverEntry.js中,將請求頭資訊從req.headers中讀出, 然後放在Redux store中;

  2. 在每個元件的static方法fetchData(store)中,在使用store中讀出,將其作為Fetch方法的一個引數;

  3. 對封裝了axios庫的Fetch方法進行改寫,讀取請求頭資訊,並且傳送。

這樣做的好處是,每個server side的請求,都有對應的請求頭,並且與瀏覽器傳送的請求頭一致。但是,也帶來了一些不便:每個頁面的fetchData中,都要重複從store中獲取請求頭-->將請求頭放在Fetch方法引數這個操作,處理上有一些冗餘。這裡,如果大家有什麼更好的解決方法,歡迎聯絡我~

XSS風險

React 會將所有要顯示到 DOM 的字串轉義,避免出現XSS的風險。

// serverEntry.js
const footString = `<script>(function(){window.__INITIAL_STATE__=${JSON.stringify(store.getState()}})()</script>`
// client.jsxconst initialState = window.__INITIAL_STATE__

上述的程式碼,大家應該已經察覺到問題了。對於store中的state,我們使用了JSON.stringify進行序列化, 它將一個Javascript value轉化成一個JSON字串,這樣就出現了XSS的風險。試想,如果store.getState()是下列的結果:

{
  "user": {
    "id": "1",
    "comment": "<script>alert('XSS!')</script>",
    "avatar": "https://beauty.nosdn.127.net/beauty/img/1.png"
  }}

我們的頁面上就會彈出 image

問了避免這樣的問題,我們需要對state其中的特殊html標籤進行轉義。 Git上有許多第三方庫可以幫助我們解決這個問題。例如serialize-javascript。它也是一個序列化的工具,提供了serialize API,可以自動地對HTML字元進行轉義:

serialize({    haxorXSS: '</script>'});

執行結果為:

{"haxorXSS":"\\u003C\\u002Fscript\\u003E"}

在server side,我們將JSON.stringify替換為serialize即可:

// serverEntry.js
const footString = `<script>(function(){window.__INITIAL_STATE__=${serialize(store.getState()}})()</script>`

登入檢測

在Redux store中,我們維護了一個isLogin狀態,對於某些頁面,只有在登入狀態才可見,如果沒有登入,直接在位址列中輸入對應的url,則會跳轉至其他頁面;如果在這些頁面中點選退出登入,也會跳轉至其他頁面。

為了減少程式碼的複用,我們設了一個高階元件CheckLoginEnhance, 它直接於Redux進行通訊,監聽isLogin的狀態。在componentDidMount, componentWillReceiveProps這兩個hook上,去檢測isLogin狀態,如果沒有登入,則進行頁面的跳轉。

高階元件的本質是生成元件的函式,使用起來也非常簡單,只需要在需要登入檢測的頁面元件上,用@CheckLoginEnhance進行包裹即可。

我們這裡的登陸檢測,都是在client side進行的,如果能在server side進行檢測,直接進行跳轉。對於使用者來說,體驗更加友好。

為了實現這個需求,我們可以在serverEntry.js中獲取isLogin,然後使用res.redirect進行跳轉。此外react-router v4採用了動態路由,不需要額外的配置,很容易地能夠實現這個功能,我們在後續的文章中會進行講解。

頁面哪些state要使用Redux進行管理?

對於一個較為複雜的應用,在使用Redux時,都需要進行Reducer的拆分,拆分後的每個Reducer函式獨立負責該特定切片state的更新。Redux提供了combineReducer函式,將拆分後的Reducer函式合併成一個Reducer函式,最後使用這個Reducer進行store的建立。

我們專案中,對於每一個頁面,拆分一個單獨的Reducer,對應單獨的state。對於一些公共的state,比如說使用者資訊,錯誤處理,導航資訊,則從各個頁面的state中抽離出來,統一處理。

與此同時,我們面臨了一個問題,這個問題也是剛接觸Redux進行專案開發時,經常會遇到的,在單個頁面中,哪些元件要使用Redux進行管理state,哪些使用setState進行維護?

之前提到,引入Redux的原因,就是它提供了一個上下文都可以訪問的store,儲存的資料既可以用於server rendering也可以用於client rendering。所以對於server rendering所需要的初始化的資料,需要使用Redux進行管理。此外,那些與server rendering無關的狀態呢?比如說,某個Button的顯示和隱藏。如果由Redux進行管理,固然資料流向更加清晰,但是也可以預見我們需要維護巨大的reduce方法和複雜的state結構,但是如果不由Redux進行管理,則是否會出現React state和Redux共存,導致資料流混亂的問題。

對於Redux的store和React的state,Redux的作者是這樣回答的:

Use React for ephemeral state that doesn't matter to the app globally and doesn't mutate in complex ways. For example, a toggle in some UI element, a form input state. Use Redux for state that matters globally or is mutated in complex ways. For example, cached users, or a post draft.

Sometimes you'll want to move from Redux state to React state (when storing something in Redux gets awkward) or the other way around (when more components need to have access to some state that used to be local).

The rule of thumb is: do whatever is less awkward.

對於應用中所使用的元件,可以簡單分為三類:

  • 頁面元件

  • 頁面的子元件,處理展示邏輯

  • 一些公共元件(如LoginModal),這些元件的state和Redux維護的state緊密相關;

image

對於這三類元件。按照容器元件和展示元件相分離的思想,我們使用高階函式connect將頁面元件進行包裹,形成容器元件。容器元件監聽Redux state,並且向Redux派發actions。對於從Redux中獲取的state,通過props向子元件傳遞。而子元件,通過props獲取資料外,自身可以維護與展示相關的state。

對於某些公共元件,當然也可以像普通的子元件一樣,獲取頁面元件的props。但是這樣一來,一則巢狀太深,二則與頁面程式碼耦合性太高,不利於元件的複用,也違背了我們使用Redux管理狀態的初衷。所以這裡也允許這些元件通過connect生成容器元件,直接與Redux通訊。

還沒有解決的問題:

網易美學主站上線已經四個多月了。在這個過程中,我們一直在持續維護周邊的構建,使整個網站架構更加完備和和合理。但是一直有一個問題沒有得到解決,那就是Code-splitting,目前client side所有的程式碼都打成一個包,沒有實現程式碼分隔和按需載入。在使用react-router同時進行程式碼分隔和server rendering時,遇到了一些問題。react-router是這樣解釋的:

We’ve tried and failed a couple of times. What we learned:

  1. You need synchronous module resolution on the server so you can get those bundles in the initial render.

  2. You need to load all the bundles in the client that were involved in the server render before rendering so that the client rendering is the same as the server render. (The trickiest part, I think its possible but this is where I gave up.)

  3. You need asynchronous resolution for the rest of the client app’s life. We determined that google was indexing our sites well enough for our needs without server rendering, so we dropped it in favor of code-splitting + service worker caching. Godspeed those who attempt the server-rendered, code-split apps.


延伸閱讀:

[1] ReactJS 服務端同構實踐【QQ音樂web團隊】

[2] Server Rendering

[3] Question: How to choose between Redux's store and React's state?

[4] Redux-Server Rendering


相關文章:
【推薦】 讓你知曉內容安全的邊界:盤點2017、2018這兩年的內容監管