React Redux Router4 Koa 服務端渲染,惰性加載,熱更新教程

在實際項目中,大多數都須要服務端渲染。javascript

服務端渲染的優點:

  • 1.首屏性能好,不須要等待 js 加載完成才能看到頁面css

  • 2.有利於SEOhtml

網上不少服務端渲染的教程,可是碎片化很嚴重,或者版本過低。一個好的例子能爲你節省不少時間!前端


演示

手機預覽
點擊預覽

演示版 Github地址: github.com/tzuser/ssr


項目目錄

  • server爲服務端目錄。由於這是最基礎的服務端渲染,爲了代碼清晰和學習,因此服務端只共用了前端組件。
  • server/index.js爲服務端入口文件
  • static存放靜態文件

教程源碼

Github地址: github.com/tzuser/ssr_…


教程開始 Webpack配置

首先區分生產環境和開發環境。 開發環境使用webpack-dev-server作服務器java

webpack.config.js 基礎配置文件

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({//根據index.ejs 生成index.html文件
			title:'Webpack配置',
			inject: true,
			filename: 'index.html',
			template: path.join(__dirname,'./index.ejs')
		}),
		new webpack.optimize.CommonsChunkPlugin({//公共組件分離
			  names: ['vendors', 'manifest']
		}),
	],
}

複製代碼

開發環境 webpack.dev.js

在開發環境時須要熱更新方便開發,而發佈環境則不須要!node

在生產環境中須要react-loadable來作分模塊加載,提升用戶訪問速度,而開發時則不須要。react

const path=require('path');
const webpack=require('webpack');
const config=require('./webpack.config.js');//加載基礎配置

config.plugins.push(//添加插件
	new webpack.HotModuleReplacementPlugin()//熱加載
)

let devConfig={
	context:path.resolve(__dirname,'src'),
	devtool: 'eval-source-map',
	devServer: {//dev-server參數
		contentBase: path.join(__dirname,'./build'),
		inline:true,
		hot:true,//啓動熱加載
		open : true,//運行打開瀏覽器
		port: 8900,
		historyApiFallback:true,
		watchOptions: {//監聽配置變化
			aggregateTimeout: 300,
			poll: 1000
		},
   	}
}

module.exports=Object.assign({},config,devConfig)
複製代碼

生產環境 webpack.build.js

在打包前使用clean-webpack-plugin插件刪除以前打包文件。 使用react-loadable/webpack處理惰性加載 ReactLoadablePlugin會生成一個react-loadable.json文件,後臺須要用到webpack

const config=require('./webpack.config.js');
const path=require('path');
const {ReactLoadablePlugin}=require('react-loadable/webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');//複製文件
const CleanWebpackPlugin = require("clean-webpack-plugin");//刪除文件

let buildConfig={

}
let newPlugins=[
    new CleanWebpackPlugin(['./build']),
    //文件複製
    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

在基礎配置webpack.config.js裏 HTMLWebpackPlugin插件就是根據這個模板文件生成index.html 而且會把須要js添加到底部git

注意

  • 模板文件只給前端開發或打包用,後端讀取的是HTMLWebpackPlugin插件生成後的index.html。
  • body下有個window.main() 這是用來確保全部js加載完成後再調用react渲染,window.main方法是src/index.js暴露的,若是對這個感到疑惑,不要緊在後面後詳解。
<!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

和傳統寫法不一樣的是App.jsx採用require動態引入,由於module.hot.accept會監聽App.jsx文件及App中引用的文件是否改變, 改變後須要從新加載而且渲染。 因此把渲染封裝成render方法,方便調用。es6

暴露了main方法給window 而且確保Loadable.preloadReady預加載完成再執行渲染

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';
//瀏覽器開發工具
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頁面時都用了

const LoadableHome=Loadable({
	loader:()=> import(/* webpackChunkName: 'Home' */ './Home'),
	loading
});
複製代碼

這種方式惰性加載文件,而不是import Home from './Home'。

/* webpackChunkName: 'Home' */ 的做用是打包時指定chunk文件名

Home.jsx 容器

home只是一個普通容器 並不須要其它特殊處理

import React,{Component} from 'react';
const Home=()=><div>首頁更改</div>
export default Home
複製代碼

接下來-服務端

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

注意 路由首先匹配路由,再匹配靜態文件,最後app.use(render)再指向render。爲何要這麼作?

好比用戶訪問根路徑/ 路由匹配成功渲染首頁。緊跟着渲染完成後須要加載/main.js,此次路由匹配失敗,再匹配靜態文件,文件匹配成功返回main.js。

若是用戶訪問的網址是/user路由和靜態文件都不匹配,這時候再去跑渲染,就能夠成功渲染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方法,方便對index.html處理。 render首先加載index.html 經過createServerStore傳入路由獲取store和history。

在外面包裹了Loadable.Capture高階組件,用來獲取前端須要加載路由地址列表, [ './Tab', './Home' ]

經過getBundles(stats, modules)方法取到組件真實路徑。 stats是webpack打包時生成的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區分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('讀取文件錯誤!',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

建立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;
複製代碼

參考

這是我同事寫的一篇服務器渲染的教程,也很是不錯

juejin.im/post/5a3920…

相關文章
相關標籤/搜索