React填坑記(四):render !== hydrate
上一篇講到了如何通過webpack插件來實現文案的按頁面和語言進行按需載入,如果頁面僅僅通過客戶端渲染,這種處理方式沒有太大問題,然而當面臨服務端渲染的時候,仍然會碰到這樣那樣的問題。
最近把項目里的React的版本升級到16,React的15到16的變動並不大,項目里主要需要處理如下幾方面的變動
- React16不再包含propTypes,propTypes,必須使用第三方的prop-types庫
ReactDOM.render()
和ReactDOM.unstable_renderIntoContainer()
不再返回組件實例,而是返回null,需要在callback里才能獲取組件實例,我們使用的react-portal組件有部分代碼依賴於返回值,改成回調即可,更好的方式是替換掉react-portal組件,使用最新的ReactDOM.createPortal。- React16使用了Map和Set以及requestAnimationFrame,在IE11上使用需要打polyfill。
處理完上面三個變動,項目平穩的升級到React16版本,可以盡情的使用最新的特性了。
後來看到文檔上如下的一句話:
哇,ReactDOM.render在下個大版本要廢棄了啊,乾脆一起升級了算了,於是乎把幾個服務端渲染的頁面的ReactDOM.render換成ReactDOM.hydrate算了。測了下好像沒問題,OK。
俗話說,不作就不會死,過了幾天接連發現各種詭異的問題。
- 頁面載入後,無端的滾動到頁面尾部
- 頁面載入完,莫名其妙有的地方被focus了
- tooltip有時莫名其妙失靈了
- 頁面有時會閃爍
- 服務端渲染的頁面出現了一些warning
都是什麼鬼,最後追查了半天才搞清楚,一切都是hydrate的鍋,hydrate !== render !!!
更準確的說是React16的hydrate不等於React15的render,因為React16的render和React15的render渲染結果也不一樣呢,而且沒寫在文檔中/(ㄒoㄒ)/~~。本文所說的render都是React15的render實現。
Document that you cant rely on React 16 SSR patching up differences · Issue #25 · reactjs/reactjs.org 實際上新文檔在todo中,但是距離完成似乎遙遙無期。
我們直接想用hydrate替換render,需要滿足一個十分重要的前提條件:
在服務端渲染和客戶端首次渲染完全一致的情況下,才能使用hydrate替換render,否則自求多福吧!!!
如果說在React15里客戶端渲染和服務端渲染不一致是warning的話,那麼在React16,如果你使用hydrate,那麼這些warning就不是warning而是error了 !!!
在react15中ReactDOM.render的使用分為三種場景,意義各不相同:
- 無服務端渲染情況下,首次調用,掛載組件到掛載點,是我們常見的使用ReactDOM.render的方式,在一個掛載點下初始化我們的應用其要完成所有的工作,包括創建dom節點,初始化節點屬性,綁定事件等,對於比較大型的應用其執行速度對首屏載入的速度影響較大。
- 服務端渲染情況下,進行hydrate,綁定事件到已存在的dom節點,相比於1其免去了創建dom節點的工作,但仍然需要完成dom diff,和dom patch的工作。
- 後續調用,更新組件,其使用場景較為有限,主要適用於與跨節點渲染如Modal/Tooltip等需要掛載在body下的組件更新上,其和父組件更新子組件方式類似,ReactDOM.createPortal的引入,可以減小此類場景的使用。
在服務端渲染的場景下,2的執行時間一定程度上影響了首屏的可交互時間。我們需要儘可能的減小2的執行時間。
render === hydrate?
在react15中,當服務端和客戶端渲染不一致時,render會做dom patch,使得最後的渲染內容和客戶端一致,否則這會使得客戶端代碼陷入混亂之中,如下的代碼就會掛掉。
import React from react;export default class Admin extends React.Component { componentDidMount() { const container = document.querySelector(.client); container.innerHTML = this is client; } render() { const content = __IS_CLIENT__ ? client : server; return ( <div className={content}> {content} </div> ); }}
render遵從客戶端渲染雖然保證了客戶端代碼的一致性,但是其需要對整個應用做dom diff和dom patch,其花銷仍然不小。在React16中,為了減小開銷,和區分render的各種場景,其引入了新的api,hydrate。
hydrate的策略與render的策略不一樣,其並不會對整個dom樹做dom patch,其只會對text Content內容做patch,對於屬性並不會做patch。上面的代碼在hydrate和render下會有兩種不同的結果。
hydrate(React16)
render(React15)
我們發現在render徹底拋棄了服務端的渲染結果採用客戶端的渲染結果,而hydrate則textContent使用了客戶端渲染結果,屬性仍然是服務端的結果(為啥這樣設計,只能等React那篇文檔了)。
不止如此,hydrate還有個副作用,就是當發現服務端和客戶端渲染結果不一致的時候,就會focus到不一致的節點上,這就導致了我們頁面載入完後,頁面自動滾動到了渲染不一致的節點上。
由此導致的結果就是,在React16中,我們必須保證服務端的渲染結果和客戶端渲染的結果一致。同構的需求迫在眉睫。
客戶端服務端同構
同構的最大難點在於服務端和客戶端的運行環境不一致,其主要區別如下:
- 服務端和客戶端的運行環境不一樣,所支持的語法也不一樣。
- 服務端無法支持圖片、css等資源文件。
- 服務端缺乏BOM和DOM環境,服務端下無法訪問window,navigator等對象。
- 服務端中所有用戶公用一個global環境,客戶端每個用戶都有自己的global環境。
對於1和2,客戶端通常使用webpack進行編譯,資源的載入通過各種loader進行處理,但這寫loader只是針對於客戶端環境的,編譯生成的代碼,無法應用於服務端。webpack自帶import實現不需要babel-loader處理,而node不支持import需要babel-loader進行處理。雖然有webpack-isomorphic-tools這樣的項目,但配置起來仍然較為麻煩。為此我們考慮使用babel-node進行語法的轉換支持es-next和jsx,對於圖片、css等資源文件,通過忽略進行處理。
require.extensions[.svg] = function() { return null;};
我們在node中雖然忽略了css資源,但是首屏載入如果沒有css文件,勢必影響效果,為此我們通過編寫webpack插件,將ExtractTextPlugin生成的css文件,內聯插入頁面的pug模板中,這樣服務端首屏渲染就可以支持樣式了。
對於3有兩種解決方式,1是fake window等對象如window等庫,2是延遲這些對象的調用,在didMount中才進行調用。
對於4,由於js是單線程,無法像flask一樣為每個請求構造出一個request對象,只能另尋他法。
客戶端無可避免的需要訪問服務端帶過來的一些屬性,例如用戶信息,伺服器信息等。在組件內如何訪問這些信息就成了問題了。
server.js
const Koa = require(koa);const Util = require(./util);const app = new Koa();...app.use(async (ctx,next) => { const userInfo = Util.getUserInfo(); const serverInfo = Util.getServerInfo(); ctx.body = ` <html> ... window.userInfo = ${userInfo} window.serverInfo = ${serverInfo} ... </html> `});
client.js
// feedPage.jsrender(<FeedContaienr />,root);// feedContainer.jsexport () => <FeedList />// feedList.jsexport () => <FeedCard />// feedCardexport () => { const userInfo = window.userInfo; return (<div className="feed-card-container">{userInfo} />)}
上面是一個簡單的服務端渲染例子,在FeedCard里我們通過window.userInfo直接取出userInfo信息進行渲染,然而這是無法通過服務端渲染的。
方案1: props drilling
我們可以把屬性從根組件一層層的傳遞到子組件,對於一個大型應用組件樹可能達到十幾層,這樣傳下去太噁心了。
方案2:old context
優點是,不用一層層傳遞,缺點是會被shouldComponentUpdate阻止更新
方案3:new context
解決了shouldComponentUpdate阻止更新的問題了,但還未正式發布
方案4:redux connect
導致組件依賴於redux,不能用於無redux的頁面了。
方案5:服務端臨時構造window對象
前面提到過服務端是單線程的,無法為每個請求構造一個window對象,但是由於服務端的render是同步的,我們可以在渲染前借用window對象,渲染後返還window對象。如下所示:
const feed = { *index() { const originWindow = global.window; global.window = createWindow(this.userInfo}) try{ const htmlContent = renderToString(<feedContainer />); console.log(html:, htmlContent); this.render(admin, { html: htmlContent }); }catch(err){ } global.window = originWindow; }}
這樣上面的客戶端代碼就能夠通過服務端渲染了。這個方法著實有點hack了。
推薦閱讀:
※瀏覽器新生態(技術周刊 2018-02-12)
※ajax post 415 Unsupported Media Type 錯誤
※探秘 React Hot Loader
※七層網路協議--tcp/ip協議
※前端日刊-2018.02.14