自前端框架(React
,Vue
,Angelar
)出現以來,每一個框架攜帶不一樣理念,分爲三大陣營,之前使用JQuery
的時代已經成爲過去,之前每一個頁面就是一個HTML
,引入相對應的JS
、CSS
,同時在HTML
中書寫DOM
。正由於是這樣,每次用戶訪問進來,因爲HTML
中有DOM
的存在,給用戶的感受響應其實並非很慢。javascript
可是自從使用了框架以後,不管是多少個頁面,就是單獨一個單頁面,即SPA
。HTML
中全部的DOM
元素,必須在客戶端下載完js
以後,經過調用執行React.render()
纔可以進行渲染,因此就有了不少網站上,一進來很長時間的loading
動畫。css
爲了解決這一併非很友好的問題,社區上提出了不少方案,例如預渲染
、SSR
、同構
。html
固然這篇文章主要講述的是從零開始搭建一個React服務器渲染同構。前端
Next.js
是一個輕量級的 React
服務端渲染應用框架。有興趣的能夠去Next.js
官網學習下。java
關於同構有兩種方案:node
let app = express(); app.get('/todo', (req, res) => { let html = renderToString( <Route path="/" component={ IComponent } > <Route path="/todo" component={ AComponent }> </Route> </Route>) res.send( indexPage(html) ) } }) 複製代碼
在這裏有兩個問題須要處理:react
Node
不支持前端的import
語法,須要引入babel
支持。Node
不能解析標籤語法。因此執行Node
時,須要使用babel
來進行轉義,若是出現錯誤了,也無從查起,我的並不推薦這樣作。webpack
因此這裏採用第二種方案git
使用webpack
打包兩份代碼,一份用於Node
進行服務器渲染,一份用於瀏覽器進行渲染。github
下面具體詳細說明下。
因爲使用習慣,常用Egg
框架,而Koa
是Egg
的底層框架,所以,這裏咱們採用Koa
框架進行服務搭建。
搭建最基本的一個Node
服務。
const Koa = require('koa'); const app = new Koa(); app.listen(3000, () => { console.log("服務器已啓動,請訪問http://127.0.0.1:3000") }); 複製代碼
衆所周知,React
代碼須要通過打包編譯才能執行的,而服務端和客戶端運行的代碼只有一部分相同,甚至有些代碼根本不須要將代碼打包,這時就須要將客戶端代碼和服務端運行的代碼分開,也就有了兩份webpack
配置
webpack
將同一份代碼,經過不一樣的webpack
配置,分別爲serverConfig
和clientConfig
,打包爲兩份代碼。
經過webpack文檔咱們能夠知道,webpack不只能夠編譯web端代碼還能夠編譯其餘內容。
這裏咱們將target
設爲node
。
配置入口文件和出口位置:
const serverConfig = { target: 'node', entry: { page1: './web/render/serverRouter.js', }, resolve, output: { filename: '[name].js', path: path.resolve(__dirname, './app/build'), libraryTarget: 'commonjs' } } 複製代碼
注意⚠
服務端配置須要配置libraryTarget
,設置commonjs
或者umd
,用於服務端進行require
引用,否則require
值爲{}
。
在這裏客戶端和服務端配置沒有什麼區別,無需配置target
(默認web
環境),其餘入門文件和輸出文件不一致。
const clientConfig = { entry: { page1: './web/render/clientRouter.js' }, output: { filename: '[name].js', path: path.resolve(__dirname, './public') } } 複製代碼
因爲打包的是React
代碼,所以還須要配置babel
。
新建.babelrc
文件。
{ "presets": ["@babel/preset-react", ["@babel/preset-env",{ "targets": { "browsers": [ "ie >= 9", "ff >= 30", "chrome >= 34", "safari >= 7", "opera >= 23", "bb >= 10" ] } }] ], "plugins": [ [ "import", { "libraryName": "antd", "style": true } ] ] } 複製代碼
這份配置由服務端和客戶端共用,用來處理React
和轉義爲ES5
和瀏覽器兼容問題。
服務端使用CommonJS
規範,並且服務端代碼也並不須要構建,所以,對於node_modules中的依賴並不須要打包,因此藉助webpack
第三方模塊webpack-node-externals
來進行處理,通過這樣的處理,兩份構建過的文件大小已經相差甚遠了。
服務端和客戶端的區別,可能就在於一個默認處理,一個須要將CSS
單獨提取出爲一個文件,和處理CSS
前綴。
服務端配置
{ test: /\.(css|less)$/, use: [ { loader: 'css-loader', options: { importLoaders: 1 } }, { loader: 'less-loader', } ] } 複製代碼
客戶端配置
{ test: /\.(css|less)$/, use: [ { loader: MiniCssExtractPlugin.loader, }, { loader: 'css-loader' }, { loader: 'postcss-loader', options: { plugins: [ require('precss'), require('autoprefixer') ], } }, { loader: 'less-loader', options: { javascriptEnabled: true, // modifyVars: theme //antd默認主題樣式 } } ], } 複製代碼
實現 React
的 SSR
架構,咱們須要讓相同的代碼在客戶端和服務端各自執行一遍,可是這裏各自執行一遍,並不包括路由端的代碼,形成這種緣由主要是由於客戶端是經過地址欄來渲染不一樣的組件的,而服務端是經過請求路徑來進行組件渲染的。
所以,在客戶端咱們採用BrowserRouter
來配置路由,在服務端採用StaticRouter
來配置路由。
import React from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter } from "react-router-dom"; import Router from '../router'; function ClientRender() { return ( <BrowserRouter > <Router /> </BrowserRouter> ) } 複製代碼
import React from 'react'; import { StaticRouter } from 'react-router' import Router from '../router.js'; function ServerRender(req, initStore) { return (props, context) => { return ( <StaticRouter location={req.url} context={context} > <Router /> </StaticRouter> ) } } export default ServerRender; 複製代碼
上面配置的服務器,只是簡單啓動個服務,沒有深刻進行配置。
const Koa = require('koa'); const app = new Koa(); const path = require('path'); const React = require('react'); const ReactDOMServer = require('react-dom/server'); const koaStatic = require('koa-static'); const router = new KoaRouter(); const routerManagement = require('./app/router'); const manifest = require('./public/manifest.json'); /** * 處理連接 * @param {*要進行服務器渲染的文件名默認是build文件夾下的文件} fileName */ function handleLink(fileName, req, defineParams) { let obj = {}; fileName = fileName.indexOf('.') !== -1 ? fileName.split('.')[0] : fileName; try { obj.script = `<script src="${manifest[`${fileName}.js`]}"></script>`; } catch (error) { console.error(new Error(error)); } try { obj.link = `<link rel="stylesheet" href="${manifest[`${fileName}.css`]}"/>`; } catch (error) { console.error(new Error(error)); } //服務器渲染 const dom = require(path.join(process.cwd(),`app/build/${fileName}.js`)).default; let element = React.createElement(dom(req, defineParams)); obj.html = ReactDOMServer.renderToString(element); return obj; } /** * 設置靜態資源 */ app.use(koaStatic(path.resolve(__dirname, './public'), { maxage: 0, //瀏覽器緩存max-age(以毫秒爲單位) hidden: false, //容許傳輸隱藏文件 index: 'index.html', // 默認文件名,默認爲'index.html' defer: false, //若是爲true,則使用後return next(),容許任何下游中間件首先響應。 gzip: true, //當客戶端支持gzip時,若是存在擴展名爲.gz的請求文件,請嘗試自動提供文件的gzip壓縮版本。默認爲true。 })); /** * 處理響應 * * **/ app.use((ctx) => { let obj = handleLink('page1', ctx.req, {}); ctx.body = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>koa-React服務器渲染</title> ${obj.link} </head> <body> <div id='app'> ${obj.html} </div> </body> ${obj.script} </html> ` }) app.listen(3000, () => { console.log("服務器已啓動,請訪問http://127.0.0.1:3000") }); 複製代碼
這裏涉及一個manifest
文件,這個文件是webpack
插件webpack-manifest-plugin
生成的,裏面包含編譯後的地址和文件。大概結構是這樣:
{ "page1.css": "page1.css", "page1.js": "page1.js" } 複製代碼
咱們把他引入到clientConfig
中,添加以下配置:
... plugins: [ // 提取樣式,生成單獨文件 new MiniCssExtractPlugin({ filename: `[name].css`, chunkFilename: `[name].chunk.css` }), new ManifestPlugin() ] 複製代碼
在上述服務端代碼中,咱們對於ServerRender.js
進行了柯里化處理,這樣作的目的在於,咱們在ServerRender
中,使用了服務端能夠識別的StaticRouter
,並配置了location
參數,而location
須要參數URL
。
所以,咱們須要在renderToString
中傳遞req
,以讓服務端可以正確解析React組件。
let element = React.createElement(dom(req, defineParams)); obj.html = ReactDOMServer.renderToString(element); 複製代碼
經過handleLink
的解析,咱們能夠獲得一個obj
,包含三個參數,link
(css
連接),script
(JS
連接)和html
(生成Dom
元素)。
經過ctx.body
渲染html
。
將 React
元素渲染到其初始 HTML
中。 該函數應該只在服務器上使用。 React
將返回一個 HTML
字符串。 您可使用此方法在服務器上生成 HTML
,並在初始請求時發送標記,以加快網頁加載速度,並容許搜索引擎抓取你的網頁以實現 SEO
目的。
若是在已經具備此服務器渲染標記的節點上調用 ReactDOM.hydrate()
,React
將保留它,而且只附加事件處理程序,從而使您擁有很是高性能的第一次加載體驗。
相似於 renderToString
,除了這不會建立 React
在內部使用的額外DOM
屬性,如 data-reactroot
。 若是你想使用React
做爲一個簡單的靜態頁面生成器,這頗有用,由於剝離額外的屬性能夠節省一些字節。
可是若是這種方法是在瀏覽訪問以後,會所有替換掉服務端渲染的內容,所以會形成頁面閃爍,因此並不推薦使用該方法。
將 React
元素渲染到其最初的 HTML
中。返回一個 可讀的 流(stream
) ,即輸出 HTML
字符串。這個 流(stream
) 輸出的 HTML
徹底等同於 ReactDOMServer.renderToString
將返回的內容。
咱們也可使用上述renderToNodeSteam
將其改造下:
let element = React.createElement(dom(req, defineParams)); ctx.res.write(' <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>koa-React服務器渲染</title> </head><body><div id="app">'); // 把組件渲染成流,而且給Response const stream = ReactDOMServer.renderToNodeStream(element); stream.pipe(ctx.res, { end: 'false' }); // 當React渲染結束後,發送剩餘的HTML部分給瀏覽器 stream.on('end', () => { ctx.res.end('</div></body></html>'); }); 複製代碼
相似於 renderToNodeStream
,除了這不會建立 React
在內部使用的額外DOM
屬性,如 data-reactroot
。 若是你想使用 React
做爲一個簡單的靜態頁面生成器,這頗有用,由於剝離額外的屬性能夠節省一些字節。
這個 流(stream
) 輸出的 HTML
徹底等同於 ReactDOMServer.renderToStaticMarkup
將返回的內容。
以上開發一個靜態網站,或者一個相對於比較簡單的項目已經OK
了,可是對於複雜的項目,這些還遠遠不夠,這裏,咱們再給它加上全局狀態管理Redux
。
服務器渲染中其順序是同步的,所以,要想在渲染時出現首屏數據渲染,必須得提早準備好數據。
對於客戶端來講添加redux
和常規的redux
並沒有太大差異,只是對於store
添加了一個初始的window.__INIT_STORE__
。
let initStore = window.__INIT_STORE__; let store = configStore(initStore); function ClientRender() { return ( <Provider store={store}> <BrowserRouter > <Router /> </BrowserRouter> </Provider> ) } 複製代碼
而對於服務端來講在初始數據獲取完成以後,能夠採用Promise.all()
來進行併發請求,當請求結束時,將數據填充到script
標籤內,命名爲window.__INIT_STORE__
。
`<script>window.__INIT_STORE__ = ${JSON.stringify(initStore)}</script>` 複製代碼
而後將服務端的store
從新配置下。
function ServerRender(req, initStore) { let store = CreateStore(JSON.parse(initStore.store)); return (props, context) => { return ( <Provider store={store}> <StaticRouter location={req.url} context={context} > <Router /> </StaticRouter> </Provider> ) } } 複製代碼
考慮後面開發的便利性,添加以下功能:
/** * 註冊路由 */ const router = new KoaRouter(); const routerManagement = require('./app/router'); ... routerManagement(router); app.use(router.routes()).use(router.allowedMethods()); 複製代碼
爲了保證開發時,接口規整,這裏將全部的路由都提到一個新的文件中進行書寫。並保證如如下格式:
/** * * @param {router 實例化對象} router */ const home = require('./controller/home'); module.exports = (router) => { router.get('/',home.renderHtml); router.get('/page2',home.renderHtml); router.get('/favicon.ico',home.favicon); router.get('/test',home.test); } 複製代碼
將html
放入代碼中,給人感受並非很友好,所以,這裏一樣引入了服務模板koa-nunjucks-2
。
同時在其上在套一層中間件,以便傳遞參數和處理各類靜態資源連接。
... const koaNunjucks = require('koa-nunjucks-2'); ... /** * 服務器渲染,渲染HTML,渲染模板 * @param {*} ctx */ function renderServer(ctx) { return (fileName, defineParams) => { let obj = handleLink(fileName, ctx.req, defineParams); // 處理自定義參數 defineParams = String(defineParams) === "[object Object]" ? defineParams : {}; obj = Object.assign(obj, defineParams); ctx.render('index', obj); } } ... /** * 模板渲染 */ app.use(koaNunjucks({ ext: 'html', path: path.join(process.cwd(), 'app/view'), nunjucksConfig: { trimBlocks: true } })); /** * 渲染Html */ app.use(async (ctx, next) => { ctx.renderServer = renderServer(ctx); await next(); }); 複製代碼
在用戶訪問該服務器時,經過調用renderServer
函數,處理連接,執行到最後,調用ctx.render
完成渲染。
/** * 渲染react頁面 */ exports.renderHtml = async (ctx) => { let initState = ctx.query.state ? JSON.parse(ctx.query.state) : null; ctx.renderServer("page1", {store: JSON.stringify(initState ? initState : { counter: 1 }) }); } exports.favicon = (ctx) => { ctx.body = null; } exports.test = (ctx) => { ctx.body = { data: `測試數據` } } 複製代碼
關於koa-nunjucks-2
中,在渲染HTML
時,會將有< >
進行安全處理,所以,咱們還需對咱們傳入的數據進行過濾處理。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>koa-React服務器渲染</title> {{ link | safe }} </head> <body> <div id='app'> {{ html | safe }} </div> </body> <script> window.__INIT_STORE__ = {{ store | safe }} </script> {{ script | safe }} </html> 複製代碼
├── README.md ├── app //node端業務代碼 │ ├── build │ │ ├── page1.js │ │ └── page2.js │ ├── controller │ │ └── home.js │ ├── router.js │ └── view │ └── index.html ├── index.js ├── package.json ├── public //前端靜態資源 │ ├── manifest.json │ ├── page1.css │ ├── page1.js │ ├── page2.css │ └── page2.js ├── web //前端源碼 │ ├── action //redux -action │ │ └── count.js │ ├── components //組件 │ │ └── layout │ │ └── index.jsx │ ├── pages //主頁面 │ │ ├── page │ │ │ ├── index.jsx │ │ │ └── index.less │ │ └── page2 │ │ ├── index.jsx │ │ └── index.less │ ├── reducer //redux -reducer │ │ ├── counter.js │ │ └── index.js │ ├── render //webpack入口文件 │ │ ├── clientRouter.js │ │ └── serverRouter.js │ ├── router.js //前端路由 │ └── store //store │ └── index.js └── webpack.config.js 複製代碼
目前這個架構目前只能手動啓動Koa
服務和啓動webpack
。
若是須要將Koa和webpack跑在一塊,這裏就涉及另一個話題了,在這裏能夠查看我一開始寫的文章。
若是須要了解一個完整的服務器須要哪些功能,能夠了解我早期的文章。
最後GITHUB
地址以下:
參考資料: