React Redux Router4 Koa 服務(wù)端渲染,惰性加載香府,熱更新教程

在實(shí)際項(xiàng)目中董栽,大多數(shù)都需要服務(wù)端渲染。

服務(wù)端渲染的優(yōu)勢(shì):

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

  • 2.有利于SEO

網(wǎng)上很多服務(wù)端渲染的教程锭碳,但是碎片化很嚴(yán)重,或者版本太低勿璃。一個(gè)好的例子能為你節(jié)省很多時(shí)間擒抛!


演示

手機(jī)預(yù)覽

點(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;

參考

這是我同事寫的一篇服務(wù)器渲染的教程圆裕,也非常不錯(cuò)

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末广鳍,一起剝皮案震驚了整個(gè)濱河市荆几,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌赊时,老刑警劉巖吨铸,帶你破解...
    沈念sama閱讀 212,029評(píng)論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異祖秒,居然都是意外死亡诞吱,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,395評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門竭缝,熙熙樓的掌柜王于貴愁眉苦臉地迎上來房维,“玉大人,你說我怎么就攤上這事抬纸×” “怎么了?”我有些...
    開封第一講書人閱讀 157,570評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵湿故,是天一觀的道長(zhǎng)阿趁。 經(jīng)常有香客問我,道長(zhǎng)坛猪,這世上最難降的妖魔是什么脖阵? 我笑而不...
    開封第一講書人閱讀 56,535評(píng)論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮砚哆,結(jié)果婚禮上独撇,老公的妹妹穿的比我還像新娘。我一直安慰自己躁锁,他們只是感情好纷铣,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,650評(píng)論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著战转,像睡著了一般搜立。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上槐秧,一...
    開封第一講書人閱讀 49,850評(píng)論 1 290
  • 那天啄踊,我揣著相機(jī)與錄音,去河邊找鬼刁标。 笑死颠通,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的膀懈。 我是一名探鬼主播顿锰,決...
    沈念sama閱讀 39,006評(píng)論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了硼控?” 一聲冷哼從身側(cè)響起刘陶,我...
    開封第一講書人閱讀 37,747評(píng)論 0 268
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎牢撼,沒想到半個(gè)月后匙隔,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,207評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡熏版,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,536評(píng)論 2 327
  • 正文 我和宋清朗相戀三年纷责,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片撼短。...
    茶點(diǎn)故事閱讀 38,683評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡碰逸,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出阔加,到底是詐尸還是另有隱情,我是刑警寧澤满钟,帶...
    沈念sama閱讀 34,342評(píng)論 4 330
  • 正文 年R本政府宣布胜榔,位于F島的核電站,受9級(jí)特大地震影響湃番,放射性物質(zhì)發(fā)生泄漏夭织。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,964評(píng)論 3 315
  • 文/蒙蒙 一吠撮、第九天 我趴在偏房一處隱蔽的房頂上張望尊惰。 院中可真熱鬧,春花似錦泥兰、人聲如沸弄屡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,772評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽膀捷。三九已至,卻和暖如春削彬,著一層夾襖步出監(jiān)牢的瞬間全庸,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,004評(píng)論 1 266
  • 我被黑心中介騙來泰國打工融痛, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留壶笼,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,401評(píng)論 2 360
  • 正文 我出身青樓雁刷,卻偏偏與公主長(zhǎng)得像覆劈,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,566評(píng)論 2 349

推薦閱讀更多精彩內(nèi)容

  • 在實(shí)現(xiàn) egg + vue 服務(wù)端渲染工程化實(shí)現(xiàn)之前墩崩,我們先來看看前面兩篇關(guān)于Webpack構(gòu)建和Egg的文章: ...
    hubcarl閱讀 6,004評(píng)論 0 19
  • 無意中看到zhangwnag大佬分享的webpack教程感覺受益匪淺氓英,特此分享以備自己日后查看,也希望更多的人看到...
    小小字符閱讀 8,147評(píng)論 7 35
  • 前兩部分我們已經(jīng)完成了博客頁面的展示和后臺(tái)頁面的展示: React技術(shù)棧+Express+Mongodb實(shí)現(xiàn)個(gè)人博...
    SamDing閱讀 5,450評(píng)論 1 12
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 171,799評(píng)論 25 707
  • react+redux+webpack+babel+npm+shell+git這方面的內(nèi)容我會(huì)隨時(shí)更新鹦筹,更新內(nèi)容放...
    liangklfang閱讀 649評(píng)論 0 1