此文已由做者張碩受權網易雲社區發佈。javascript
歡迎訪問網易雲社區,瞭解更多網易技術產品運營經驗。css
網易美學主站在最初開發時,由於各類歷史緣由,引入了例如JQuery,Bootstrop,Angular, React等框架,代碼結構比較混亂,給後續的開發和維護帶來了很大的不便。因此對它進行了重構。下面,我會從如下三個方面對主站的重構方案進行介紹:html
咱們爲何進行重構?前端
如何使用React進行同構java
同構過程當中遇到的問題以及解決方案node
早期的主站使用Express做爲Node層路由的同時,使用了相似於Jinja的Nunjucks做爲javascript 模板引擎,進行HTML文件的渲染,也就是說,咱們的網站是一個多頁應用,Nunjucks渲染知足了SEO的需求。以後出於封裝和組件的管理引入了Reactjs,對於一個頁面的開發,每每須要兩步:react
使用Nujucks書寫template以及對應的css樣式;webpack
頁面加載後,對某些須要組件化的DOM 節點進行React組件的替換ios
對於每一個頁面,在引入的js文件中,對DOM節點進行替換,以CommentBox組件爲例:git
((window, document) => { ReactDOM.render( <CommentBox limit={20} type={3} id={id} initalLogin={initalLogin}/>, document.querySelector("#comments") ) })(window, document)
對於頁面的開發,形成了額外的工做量。
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,一些應用的作法是彈窗登錄後,強制刷新頁面,使各個組件刷新狀態。可是強制刷新頁面會影響用戶體驗,這裏,產品的需求是這樣的:
點擊點贊按鈕,彈出登陸框,進行登錄後,進行主動點贊,其餘與登陸狀態有關的組件,檢測到登陸狀態改變後,進行數據獲取和顯示刷新。
因爲咱們的組件,是根據id直接掛在在DOM節點上的,這些組件之間沒有嵌套關係,不能經過props去傳遞狀態。只能經過基於發佈-訂閱者模式的全局事件處理。在每一個組件進行登陸狀態的trigger和監聽。組件間須要共享的狀態不只僅只有isLogin,這樣能夠預見,咱們須要在React組件的事件上,綁定大量的全局監聽和觸發事件。這樣增長了組件之間的耦合,不利於代碼的維護。
出於上述的考慮,咱們選擇了使用React進行先後端同構。
同構(Isomorphic)並非一個新鮮的概念。一些團隊已經基於他們的業務實現了同構直出(參考[1])。
這裏再簡單介紹一下,根據本身理解,同構能夠當作,只須要維護一份代碼,client side(Browser端)和server side(Nodejs端)均可以共用。
這樣,在獲取數據後,server side能夠返回已經渲染好的html文件,知足SEO須要的同時,相比純client rendering,也減小了響應時間,對於用戶來講,就是減小了白屏這樣很差的體驗。
以後,前端拿到後端返回的HTML和數據,使用同一份代碼,再次進行渲染。
(圖片來自網絡)
有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版本,進行同構。
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也會出以下圖的不一致警告:
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放置在頁面組件的靜態方法上: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 須要在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完成,並不須要訪問服務器獲取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() }) }
以前提到,因此對於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;
在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,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 先後端同構的項目一些參考。
經過封裝第三方庫,咱們抹平了在先後端發送請求書寫上的不一樣。對於某一個頁面來講,無論是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,
在serverEntry.js中,將請求頭信息從req.headers中讀出, 而後放在Redux store中;
在每一個組件的static方法fetchData(store)中,在使用store中讀出,將其做爲Fetch方法的一個參數;
對封裝了axios庫的Fetch方法進行改寫,讀取請求頭信息,而且發送。
這樣作的好處是,每一個server side的請求,都有對應的請求頭,而且與瀏覽器發送的請求頭一致。可是,也帶來了一些不便:每一個頁面的fetchData中,都要重複從store中獲取請求頭-->將請求頭放在Fetch方法參數這個操做,處理上有一些冗餘。這裏,若是你們有什麼更好的解決方法,歡迎聯繫我~
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" }}
咱們的頁面上就會彈出
問了避免這樣的問題,咱們須要對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採用了動態路由,不須要額外的配置,很容易地可以實現這個功能,咱們在後續的文章中會進行講解。
對於一個較爲複雜的應用,在使用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緊密相關;
對於這三類組件。按照容器組件和展現組件相分離的思想,咱們使用高階函數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:
You need synchronous module resolution on the server so you can get those bundles in the initial render.
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.)
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?
相關文章:
【推薦】 如何避免程序員和產品經理打架?「微服務」或將成終極解決方案