在實(shí)際項(xiàng)目中董栽,大多數(shù)都需要服務(wù)端渲染。
服務(wù)端渲染的優(yōu)勢(shì):
1.首屏性能好企孩,不需要等待 js 加載完成才能看到頁面
2.有利于SEO
網(wǎng)上很多服務(wù)端渲染的教程锭碳,但是碎片化很嚴(yán)重,或者版本太低勿璃。一個(gè)好的例子能為你節(jié)省很多時(shí)間擒抛!
演示
點(diǎn)擊預(yù)覽
演示版 Github地址: https://github.com/tzuser/ssr
項(xiàng)目目錄
[圖片上傳失敗...(image-b337e7-1514650285812)]
- server為服務(wù)端目錄。因?yàn)檫@是最基礎(chǔ)的服務(wù)端渲染补疑,為了代碼清晰和學(xué)習(xí)歧沪,所以服務(wù)端只共用了前端組件。
- server/index.js為服務(wù)端入口文件
- static存放靜態(tài)文件
教程源碼
Github地址: https://github.com/tzuser/ssr_base
教程開始 Webpack配置
首先區(qū)分生產(chǎn)環(huán)境和開發(fā)環(huán)境莲组。 開發(fā)環(huán)境使用webpack-dev-server做服務(wù)器
webpack.config.js 基礎(chǔ)配置文件
const path=require('path');
const webpack=require('webpack');
const HTMLWebpackPlugin = require('html-webpack-plugin');//html生成
module.exports={
entry: {
main:path.join(__dirname,'./src/index.js'),
vendors:['react','react-redux']//組件分離
},
output:{
path: path.resolve(__dirname,'build'),
publicPath: '/',
filename:'[name].js',
chunkFilename:'[name].[id].js'
},
context:path.resolve(__dirname,'src'),
module:{
rules:[
{
test:/\.(js|jsx)$/,
use:[{
loader:'babel-loader',
options:{
presets:['env','react','stage-0'],
},
}]
}
]
},
resolve:{extensions:['.js','.jsx','.less','.scss','.css']},
plugins:[
new HTMLWebpackPlugin({//根據(jù)index.ejs 生成index.html文件
title:'Webpack配置',
inject: true,
filename: 'index.html',
template: path.join(__dirname,'./index.ejs')
}),
new webpack.optimize.CommonsChunkPlugin({//公共組件分離
names: ['vendors', 'manifest']
}),
],
}
開發(fā)環(huán)境 webpack.dev.js
在開發(fā)環(huán)境時(shí)需要熱更新方便開發(fā)诊胞,而發(fā)布環(huán)境則不需要!
在生產(chǎn)環(huán)境中需要react-loadable來做分模塊加載锹杈,提高用戶訪問速度撵孤,而開發(fā)時(shí)則不需要迈着。
const path=require('path');
const webpack=require('webpack');
const config=require('./webpack.config.js');//加載基礎(chǔ)配置
config.plugins.push(//添加插件
new webpack.HotModuleReplacementPlugin()//熱加載
)
let devConfig={
context:path.resolve(__dirname,'src'),
devtool: 'eval-source-map',
devServer: {//dev-server參數(shù)
contentBase: path.join(__dirname,'./build'),
inline:true,
hot:true,//啟動(dòng)熱加載
open : true,//運(yùn)行打開瀏覽器
port: 8900,
historyApiFallback:true,
watchOptions: {//監(jiān)聽配置變化
aggregateTimeout: 300,
poll: 1000
},
}
}
module.exports=Object.assign({},config,devConfig)
生產(chǎn)環(huán)境 webpack.build.js
在打包前使用clean-webpack-plugin插件刪除之前打包文件。
使用react-loadable/webpack處理惰性加載
ReactLoadablePlugin會(huì)生成一個(gè)react-loadable.json文件,后臺(tái)需要用到
const config=require('./webpack.config.js');
const path=require('path');
const {ReactLoadablePlugin}=require('react-loadable/webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');//復(fù)制文件
const CleanWebpackPlugin = require("clean-webpack-plugin");//刪除文件
let buildConfig={
}
let newPlugins=[
new CleanWebpackPlugin(['./build']),
//文件復(fù)制
new CopyWebpackPlugin([
{from:path.join(__dirname,'./static'),to:'static'}
]),
//惰性加載
new ReactLoadablePlugin({
filename: './build/react-loadable.json',
})
]
config.plugins=config.plugins.concat(newPlugins);
module.exports=Object.assign({},config,buildConfig)
模板文件 index.ejs
在基礎(chǔ)配置webpack.config.js里 HTMLWebpackPlugin插件就是根據(jù)這個(gè)模板文件生成index.html 并且會(huì)把需要js添加到底部
注意
- 模板文件只給前端開發(fā)或打包用邪码,后端讀取的是HTMLWebpackPlugin插件生成后的index.html寥假。
- body下有個(gè)window.main() 這是用來確保所有js加載完成后再調(diào)用react渲染,window.main方法是src/index.js暴露的霞扬,如果對(duì)這個(gè)感到疑惑,沒關(guān)系在后面后詳解枫振。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="icon" href="/static/favicon.ico" mce_href="/static/favicon.ico" type="image/x-icon">
<link rel="manifest" href="/static/manifest.json">
<meta name="viewport" content="width=device-width,user-scalable=no" >
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="root"></div>
</body>
<script>window.main();</script>
</html>
入口文件 src/index.js
和傳統(tǒng)寫法不同的是App.jsx采用require動(dòng)態(tài)引入,因?yàn)閙odule.hot.accept會(huì)監(jiān)聽App.jsx文件及App中引用的文件是否改變喻圃,
改變后需要重新加載并且渲染。
所以把渲染封裝成render方法粪滤,方便調(diào)用斧拍。
暴露了main方法給window 并且確保Loadable.preloadReady預(yù)加載完成再執(zhí)行渲染
import React,{Component} from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
import {createStore,applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
//瀏覽器開發(fā)工具
import {composeWithDevTools} from 'redux-devtools-extension/developmentOnly';
import reducers from './reducers/index';
import createHistory from 'history/createBrowserHistory';
import {ConnectedRouter,routerMiddleware} from 'react-router-redux';
import { Router } from 'react-router-dom';
import Loadable from 'react-loadable';
const history = createHistory()
const middleware=[thunk,routerMiddleware(history)];
const store=createStore(
reducers,
composeWithDevTools(applyMiddleware(...middleware))
)
if(module.hot) {//判斷是否啟用熱加載
module.hot.accept('./reducers/index.js', () => {//偵聽reducers文件
import('./reducers/index.js').then(({default:nextRootReducer})=>{
store.replaceReducer(nextRootReducer);
});
});
module.hot.accept('./Containers/App.jsx', () => {//偵聽App.jsx文件
render(store)
});
}
const render=()=>{
const App = require("./Containers/App.jsx").default;
ReactDOM.hydrate(
<Provider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</Provider>,
document.getElementById('root'))
}
window.main = () => {//暴露main方法給window
Loadable.preloadReady().then(() => {
render()
});
};
APP.jsx 容器
import React,{Component} from 'react';
import {Route,Link} from 'react-router-dom';
import Loadable from 'react-loadable';
const loading=()=><div>Loading...</div>;
const LoadableHome=Loadable({
loader:()=> import(/* webpackChunkName: 'Home' */ './Home'),
loading
});
const LoadableUser = Loadable({
loader: () => import(/* webpackChunkName: 'User' */ './User'),
loading
});
const LoadableList = Loadable({
loader: () => import(/* webpackChunkName: 'List' */ './List'),
loading
});
class App extends Component{
render(){
return(
<div>
<Route exact path="/" component={LoadableHome}/>
<Route path="/user" component={LoadableUser}/>
<Route path="/list" component={LoadableList}/>
<Link to="/user">user</Link>
<Link to="/list">list</Link>
</div>
)
}
};
export default App
注意這里引用Home、User杖小、List頁面時(shí)都用了
const LoadableHome=Loadable({
loader:()=> import(/* webpackChunkName: 'Home' */ './Home'),
loading
});
這種方式惰性加載文件肆汹,而不是import Home from './Home'。
/* webpackChunkName: 'Home' */ 的作用是打包時(shí)指定chunk文件名
Home.jsx 容器
home只是一個(gè)普通容器 并不需要其它特殊處理
import React,{Component} from 'react';
const Home=()=><div>首頁更改</div>
export default Home
接下來-服務(wù)端
server/index.js
加載了一大堆插件用來支持es6語法及前端組件
require('babel-polyfill')
require('babel-register')({
ignore: /\/(build|node_modules)\//,
presets: ['env', 'babel-preset-react', 'stage-0'],
plugins: ['add-module-exports','syntax-dynamic-import',"dynamic-import-node","react-loadable/babel"]
});
require('./server');
server/server.js
注意 路由首先匹配路由予权,再匹配靜態(tài)文件昂勉,最后app.use(render)再指向render。為什么要這么做扫腺?
比如用戶訪問根路徑/ 路由匹配成功渲染首頁岗照。緊跟著渲染完成后需要加載/main.js,這次路由匹配失敗,再匹配靜態(tài)文件笆环,文件匹配成功返回main.js攒至。
如果用戶訪問的網(wǎng)址是/user路由和靜態(tài)文件都不匹配,這時(shí)候再去跑渲染躁劣,就可以成功渲染user頁面迫吐。
const Loadable=require('react-loadable');
const Router = require('koa-router');
const router = new Router();
const path= require('path')
const staticServer =require('koa-static')
const Koa = require('koa')
const app = new Koa()
const render = require('./render.js')
router.get('/', render);
app.use(router.routes())
.use(router.allowedMethods())
.use(staticServer(path.resolve(__dirname, '../build')));
app.use(render);
Loadable.preloadAll().then(() => {
app.listen(3000, () => {
console.log('Running on http://localhost:3000/');
});
});
最重要的 server/render.js
寫了prepHTML方法,方便對(duì)index.html處理账忘。
render首先加載index.html
通過createServerStore傳入路由獲取store和history志膀。
在外面包裹了Loadable.Capture高階組件,用來獲取前端需要加載路由地址列表闪萄,
[ './Tab', './Home' ]
通過getBundles(stats, modules)方法取到組件真實(shí)路徑梧却。
stats是webpack打包時(shí)生成的react-loadable.json
[ { id: 1050,
name: '../node_modules/.1.0.0-beta.25@material-ui/Tabs/Tab.js',
file: 'User.3.js' },
{ id: 1029, name: './Containers/Tab.jsx', file: 'Tab.6.js' },
{ id: 1036, name: './Containers/Home.jsx', file: 'Home.5.js' } ]
使用bundles.filter區(qū)分css和js文件,取到首屏加載的文件后都塞入html里败去。
import React from 'react'
import Loadable from 'react-loadable';
import { renderToString } from 'react-dom/server';
import App from '../src/Containers/App.jsx';
import {ConnectedRouter,routerMiddleware} from 'react-router-redux';
import { StaticRouter } from 'react-router-dom'
import createServerStore from './store';
import {Provider} from 'react-redux';
import path from 'path';
import fs from 'fs';
import Helmet from 'react-helmet';
import { getBundles } from 'react-loadable/webpack'
import stats from '../build/react-loadable.json';
//html處理
const prepHTML=(data,{html,head,style,body,script})=>{
data=data.replace('<html',`<html ${html}`);
data=data.replace('</head>',`${head}${style}</head>`);
data=data.replace('<div id="root"></div>',`<div id="root">${body}</div>`);
data=data.replace('</body>',`${script}</body>`);
return data;
}
const render=async (ctx,next)=>{
const filePath=path.resolve(__dirname,'../build/index.html')
let html=await new Promise((resolve,reject)=>{
fs.readFile(filePath,'utf8',(err,htmlData)=>{//讀取index.html文件
if(err){
console.error('讀取文件錯(cuò)誤!',err);
return res.status(404).end()
}
//獲取store
const { store, history } = createServerStore(ctx.req.url);
let modules=[];
let routeMarkup =renderToString(
<Loadable.Capture report={moduleName => modules.push(moduleName)}>
<Provider store={store}>
<ConnectedRouter history={history}>
<App/>
</ConnectedRouter>
</Provider>
</Loadable.Capture>
)
let bundles = getBundles(stats, modules);
let styles = bundles.filter(bundle => bundle.file.endsWith('.css'));
let scripts = bundles.filter(bundle => bundle.file.endsWith('.js'));
let styleStr=styles.map(style => {
return `<link href="/dist/${style.file}" rel="stylesheet"/>`
}).join('\n')
let scriptStr=scripts.map(bundle => {
return `<script src="/${bundle.file}"></script>`
}).join('\n')
const helmet=Helmet.renderStatic();
const html=prepHTML(htmlData,{
html:helmet.htmlAttributes.toString(),
head:helmet.title.toString()+helmet.meta.toString()+helmet.link.toString(),
style:styleStr,
body:routeMarkup,
script:scriptStr,
})
resolve(html)
})
})
ctx.body=html;//返回
}
export default render;
server/store.js
創(chuàng)建store和history和前端差不多放航,createHistory({ initialEntries: [path] }),path為路由地址
import { createStore, applyMiddleware, compose } from 'redux';
import { routerMiddleware } from 'react-router-redux';
import thunk from 'redux-thunk';
import createHistory from 'history/createMemoryHistory';
import rootReducer from '../src/reducers/index';
// Create a store and history based on a path
const createServerStore = (path = '/') => {
const initialState = {};
// We don't have a DOM, so let's create some fake history and push the current path
let history = createHistory({ initialEntries: [path] });
// All the middlewares
const middleware = [thunk, routerMiddleware(history)];
const composedEnhancers = compose(applyMiddleware(...middleware));
// Store it all
const store = createStore(rootReducer, initialState, composedEnhancers);
// Return all that I need
return {
history,
store
};
};
export default createServerStore;