衆所周知,目前的 WEB 應用,用戶體驗要求愈來愈高,WEB 交互變得愈來愈豐富!前端能夠作的事愈來愈多,去年 Node 引領了先後端分層的浪潮,而 React 的出現讓分層思想能夠更多完全的執行,尤爲是 React 同構 (Universal or Isomorphic) 這個黑科技究竟是怎麼實現的,咱們來一探究竟。css
若是熟悉 React 開發,那麼必定對 ReactDOM.render
方法不陌生,這是 React 渲染到 DOM 中的方法。html
現有的任何開發模式都離不開 DOM 樹,如圖:前端
服務端渲染就要稍做改動,如圖:react
比較兩張圖能夠看出,服務端渲染須要把 React 的初次渲染放到服務端,讓 React 幫咱們把業務 component 翻譯成 string 類型的 DOM 樹,再經過後端語言的 IO 流輸出至瀏覽器。webpack
咱們來看 React 官方給咱們提供的服務端渲染的API:git
React.renderToString
是把 React 元素轉成一個 HTML 字符串,由於服務端渲染已經標識了 reactid,因此在瀏覽器端再次渲染,React 只是作事件綁定,而不會將全部的 DOM 樹從新渲染,這樣能帶來高性能的頁面首次加載!同構黑魔法主要從這個 API 而來。github
React.renderToStaticMarkup
,這個 API 至關於一個簡化版的 renderToString,若是你的應用基本上是靜態文本,建議用這個方法,少了一大批的 reactid,DOM 樹天然精簡了,在 IO 流傳輸上節省一部分流量。web
配合 renderToString
和 renderToStaticMarkup
使用,createElement
返回的 ReactElement 做爲參數傳遞給前面兩個方法。ajax
有了解決方案,咱們就能夠動手在 Node 來作一些事了。後面會利用 KOA 這個 Node 框架來作實踐。數據庫
咱們新建應用,目錄結構以下,
react-server-koa-simple ├── app │ ├── assets │ │ ├── build │ │ ├── src │ │ │ ├── img │ │ │ ├── js │ │ │ └── css │ │ ├── package.json │ │ └── webpack.config.js │ ├── middleware │ │ └── static.js(前端靜態資源託管中間件) │ ├── plugin │ │ └── reactview(reactview 插件) │ └── views │ ├── layout │ │ └── Default.js │ ├── Device.js │ └── Home.js ├── .babelrc ├── .gitgnore ├── app.js ├── package.json └── README.md
首先,咱們須要實現一個 KOA 插件,用來實現 React 做爲服務端模板的渲染工做,方法是將 render
方法插入到 app 上下文中,目的是在 controller 層中調用,this.render(viewFileName, props, children)
並經過 this.body
輸出文檔流至瀏覽器端。
/* * koa-react-view.js * 提供 react server render 功能 * { * options : { * viewpath: viewpath, // the root directory of view files * doctype: '<!DOCTYPE html>', * extname: '.js', // view層直接渲染文件名後綴 * writeResp: true, // 是否須要在view層直接輸出 * } * } */ module.exports = function(app) { const opts = app.config.reactview || {}; assert(opts && opts.viewpath && util.isString(opts.viewpath), '[reactview] viewpath is required, please check config!'); const options = Object.assign({}, defaultOpts, opts); app.context.render = function(filename, _locals, children) { let filepath = path.join(options.viewpath, filename); let render = opts.internals ? ReactDOMServer.renderToString : ReactDOMServer.renderToStaticMarkup; // merge koa state let props = Object.assign({}, this.state, _locals); let markup = options.doctype || '<!DOCTYPE html>'; try { let component = require(filepath); // Transpiled ES6 may export components as { default: Component } component = component.default || component; markup += render(React.createElement(component, props, children)); } catch (err) { err.code = 'REACT'; throw err; } if (options.writeResp) { this.type = 'html'; this.body = markup; } return markup; }; };
而後,咱們來寫用 React 實現的服務端的 Components,
/* * react-server-koa-simple - app/views/Home.js * home模板 */ render() { let { microdata, mydata } = this.props; let homeJs = `${microdata.styleDomain}/build/${microdata.styleVersion}/js/home.js`; let scriptUrls = [homeJs]; return ( <Default microdata={microdata} scriptUrls={scriptUrls} title={"demo"}> <div id="demoApp" data-microdata={JSON.stringify(microdata)} data-mydata={JSON.stringify(mydata)}> <Content mydata={mydata} microdata={microdata} /> </div> </Default> ); }
這裏作了幾件事,初始化 DOM 樹,用 data 屬性做服務端數據埋點,渲染先後端公共 Content 模塊,引用前端模塊
而客戶端,咱們就能夠很方便地拿到了服務端的數據,能夠直接拿來使用,
import ReactDOM from 'react-dom'; import Content from './components/Content.js'; const microdata = JSON.parse(appEle.getAttribute('data-microdata')); const mydata = JSON.parse(appEle.getAttribute('data-mydata')); ReactDOM.render( <Content mydata={mydata} microdata={microdata} />, document.getElementById('demoApp') );
而後,到了啓動一個簡單的 koa 應用的時候,完善入口 app.js 來驗證咱們的想法,
const koa = require('koa'); const koaRouter = require('koa-router'); const path = require('path'); const reactview = require('./app/plugin/reactview/app.js'); const Static = require('./app/middleware/static.js'); const App = ()=> { let app = koa(); let router = koaRouter(); // 初始化 /home 路由 dispatch 的 generator router.get('/home', function*() { // 執行view插件 this.body = this.render('Home', { microdata: { domain: "//localhost:3000" }, mydata: { nick: 'server render body' } }); }); app.use(router.routes()).use(router.allowedMethods()); // 注入 reactview const viewpath = path.join(__dirname, 'app/views'); app.config = { reactview: { viewpath: viewpath, // the root directory of view files doctype: '<!DOCTYPE html>', extname: '.js', // view層直接渲染文件名後綴 beautify: true, // 是否須要對dom結構進行格式化 writeResp: false, // 是否須要在view層直接輸出 } } reactview(app); return app; }; const createApp = ()=> { const app = App(); // http服務端口監聽 app.listen(3000, ()=> { console.log('3000 is listening!'); }); return app; }; createApp();
如今,訪問上面預先設置好的路由,http://localhost:3000/home 來驗證 server render,
服務端:
瀏覽器端:
咱們已經創建了服務端渲染的基礎了,接着再考慮下如何把後端和前端的路由作統一。
假設咱們的路由設置成 /device/:deviceID
這種形式,
那麼服務端是這麼來實現的,
// 初始化 device/:deviceID 路由 dispatch 的 generator router.get('/device/:deviceID', function*() { // 執行view插件 let deviceID = this.params.deviceID; this.body = this.render('Device', { isServer: true, microdata: microdata, mydata: { path: this.path, deviceID: deviceID, } }); });
以及服務端 View 模板,
render() { const { microdata, mydata, isServer } = this.props; const deviceJs = `${microdata.styleDomain}/build/${microdata.styleVersion}/js/device.js`; const scriptUrls = [deviceJs]; return ( <Default microdata={microdata} scriptUrls={scriptUrls} title={"demo"}> <div id="demoApp" data-microdata={JSON.stringify(microdata)} data-mydata={JSON.stringify(mydata)}> <Iso microdata={microdata} mydata={mydata} isServer={isServer} /> </div> </Default> ); }
前端 app 入口:app.js
function getServerData(key) { return JSON.parse(appEle.getAttribute(`data-${key}`)); }; // 從服務端埋點處 <div id="demoApp"> 獲取 microdata, mydata let microdata = getServerData('microdata'); let mydata = getServerData('mydata'); ReactDOM.render( <Iso microdata={microdata} mydata={mydata} isServer={false} />, document.getElementById('demoApp'));
先後端公用的 Iso.js 模塊,前端路由一樣設置成 /device/:deviceID
:
class Iso extends Component { static propTypes = { // ... }; // 包裹 Route 的 Component,目的是注入服務端傳入的 props wrapComponent(Component) { const { microdata, mydata } = this.props; return React.createClass({ render() { return React.createElement(Component, { microdata: microdata, mydata: mydata }, this.props.children); } }); } // LayoutView 爲路由的佈局; DeviceView 爲參數處理模塊 render() { const { isServer, mydata } = this.props; return ( <Router history={isServer ? createMemoryHistory(mydata.path || '/') : browserHistory}> <Route path="/" component={this.wrapComponent(LayoutView)}> <IndexRoute component={this.wrapComponent(DeviceView)} /> <Route path="/device/:deviceID" component={DeviceView} /> </Route> </Router> ); } }
這樣我就實現了服務端和前端路由的同構!
不管你是初次訪問這些資源路徑: /device/all, /device/pc, /device/wireless
,仍是在頁面手動切換這些資源路徑效果都是同樣的,既保證了初次渲染有符合預期的 DOM 輸出的用戶體驗,又保證了代碼的簡潔性,最重要的是先後端代碼是一套,而且由一位工程師開發,有沒有以爲很棒?
其中注意幾點:
Iso 的 render 模塊須要判斷isServer,服務端用createMemoryHistory,前端用browserHistory;
react-router 的 component 若是須要注入 props 必須對其進行包裹 wrapComponent。由於服務端渲染的數據須要經過傳 props 的方式,而react-router-route 只提供了 component,並不支持繼續追加 props。截取 Route 的源碼,
propTypes: { path: string, component: _PropTypes.component, components: _PropTypes.components, getComponent: func, getComponents: func },
爲何服務端獲取數據不和前端保持一致,在 Component 裏做數據綁定,使用 fetchData 和數據綁定!只能說,你能夠大膽的假設。接下來就是咱們要繼續探討的同構model!
咱們都知道,瀏覽器端獲取數據須要發起 ajax 請求,實際上發起的請求 URL 就是對應服務端一個路由控制器。
React 是有生命週期的,官方給咱們指出的綁定 Model,fetchData 應該在 componentDidMount
裏來進行。在服務端,React 是不會去執行componentDidMount
方法的,由於,React 的 renderTranscation
分紅兩塊: ReactReconcileTransaction
和ReactServerRenderingTransaction
,其在服務端的實現移除掉了在瀏覽器端的一些特定方法。
而服務端處理數據是線性的,是不可逆的,發起請求 > 去數據庫獲取數據 > 業務邏輯處理 > 組裝成 html-> IO流輸出給瀏覽器。顯然,服務端和瀏覽器端是矛盾的!
你或許會想到利用 ReactClass
提供的 statics 來作點文章,React 確實提供了入口,不只能包裹靜態屬性,還能包裹靜態方法,而且能 DEFINE_MANY:
/** * An object containing properties and methods that should be defined on * the component's constructor instead of its prototype (static methods). * * @type {object} * @optional */ statics: SpecPolicy.DEFINE_MANY,
利用 statics 把咱們的組件擴展成這樣,
class ContentView extends Component { statics: { fetchData: function (callback) { ContentData.fetch().then((data)=> { callback(data); }); } }; // 瀏覽器端這樣獲取數據 componentDidMount() { this.constructor.fetchData((data)=> { this.setState({ data: data }); }); } ... });
ContentData.fetch() 須要實現兩套:
服務端:封裝服務端service層方法
瀏覽器端:封裝ajax或Fetch方法
服務端調用:
require('ContentView').fetchData((data)=> { this.body = this.render('Device', { isServer: true, microdata: microdata, mydata: data }); });
這樣能夠解決數據層的同構!但我並不認爲這是一個好的方法,好像回到 JSP 時代。
咱們團隊如今使用的方法:
本文完整運行的 例子