React Redux Router4 Koa 服务端渲染,惰性加载,热更新教程

在实际项目中,大多数都需要服务端渲染。

服务端渲染的优势:

  • 1.首屏性能好,不需要等待 js 加载完成才能看到页面

  • 2.有利于SEO

网上很多服务端渲染的教程,但是碎片化很严重,或者版本太低。一个好的例子能为你节省很多时间!


演示

手机预览

点击预览

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


项目目录

[图片上传失败...(image-b337e7-1514650285812)]

  • server为服务端目录。因为这是最基础的服务端渲染,为了代码清晰和学习,所以服务端只共用了前端组件。
  • server/index.js为服务端入口文件
  • static存放静态文件

教程源码

Github地址: https://github.com/tzuser/ssr_base


教程开始 Webpack配置

首先区分生产环境和开发环境。 开发环境使用webpack-dev-server做服务器

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

在开发环境时需要热更新方便开发,而发布环境则不需要!

在生产环境中需要react-loadable来做分模块加载,提高用户访问速度,而开发时则不需要。

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文件,后台需要用到

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添加到底部

注意

  • 模板文件只给前端开发或打包用,后端读取的是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方法,方便调用。

暴露了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;

参考

这是我同事写的一篇服务器渲染的教程,也非常不错

https://juejin.im/post/5a392018f265da431b6d5501

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容

  • 在实现 egg + vue 服务端渲染工程化实现之前,我们先来看看前面两篇关于Webpack构建和Egg的文章: ...
    hubcarl阅读 5,990评论 0 19
  • 无意中看到zhangwnag大佬分享的webpack教程感觉受益匪浅,特此分享以备自己日后查看,也希望更多的人看到...
    小小字符阅读 8,121评论 7 35
  • 前两部分我们已经完成了博客页面的展示和后台页面的展示: React技术栈+Express+Mongodb实现个人博...
    SamDing阅读 5,444评论 1 12
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,051评论 25 707
  • react+redux+webpack+babel+npm+shell+git这方面的内容我会随时更新,更新内容放...
    liangklfang阅读 643评论 0 1