原本在上週就想寫下這篇文章,可是在學習的過程當中,愈來愈以爲以前的不少思路須要修改,因此就下定決心,等我重構完這個項目以後再寫第二篇教程。css
先上代碼倉庫githubhtml
看過我第一篇文章的朋友們應該已經大體瞭解了 react ssr 的基本思路了,若是沒有第一篇文章的同窗建議先看教程一,可是隻是掌握這些仍是遠遠不夠的。前端
首先梳理下上篇教程所帶來的問題node
很是幸運,以上的問題在 v2 中都已解決。下面就跟着我依次解決上述問題,因爲考慮文章篇幅,此次我不會貼出太多的源碼,只敘述個人思路以及部分核心代碼,強烈建議掘友們本身動手 碼一碼。react
在上次的文章中我分別採用了 react-router 和 koa-router 來構建項目的路由,而且手動保持兩端路由的一致性,這樣的好處是更加的靈活以及解耦,但缺點是是編寫不少重複的代碼,考慮咱們實際開發中,對於輸出 html 的路由先後端基本是一致的,而且數據處理出入不大,則咱們在 koa-router 的 html 路由部分能夠徹底採用 react-router 的配置。webpack
首先咱們npm i react-router-config -S
,這個包在後面會發揮相當重要的做用。git
重構路由配置以下github
import React from 'react';
import Home from './pages/home'
import Detail from './pages/detail'
export default [
{
path: '/',
component: Home,
exact: true,
},
{
path: '/detail/:id',
component: Detail,
exact: true,
},
]
複製代碼
koa-router 修改以下web
router.get('/api/flash', HomeControl.flash);
router.get('/api/column', HomeControl.column);
router.get('/api/detail', DetailControl.detail);
router.get('*', async (ctx, next) => {
await render(ctx, template);
next();
})
複製代碼
這樣咱們全部直出html 的路由部分走同一個控制器,想知道render 幹了什麼事?npm
其實和以前同樣,經過 renderToString 輸出對應路由的html,而後填充數據,返回最終的html,簡單看下
import { renderRoutes } from 'react-router-config';
function templating(template) {
return props => template.replace(/<!--([\s\S]*?)-->/g, (_, key) => props[key.trim()]);
}
function(ctx, template) {
try {
const render = templating(template);
const html = renderToString(
<Provider store={store}> <StaticRouter location={ctx.url} context={ctx}> { renderRoutes(routerConfig) } // 這裏的routerConfig就是上面配置的路由信息 </StaticRouter> </Provider>
);
const body = render({
html,
store: `<script>window.__STORE__ = ${JSON.stringify(ctx.store.getState())}</script>`,
});
ctx.body = body;
ctx.type = 'text/html';
}
catch (err) {
console.error(err.message);
ctx.body = err.message;
ctx.type = 'text/html';
}
}
複製代碼
在模板中使用註釋當作佔位符,拋棄了花括號,這樣先後端就能夠共用一個模板了。
可是上面的store 部分咱們怎麼去獲取呢?在以前咱們是在每一個路由渲染以前請求數據而後將數據傳遞給render 函數,如今咱們路由走的是同一個控制器,應該如何處理store ?
下面咱們就來重構下store
首先在每個路由組件上面編寫一個靜態方法 asyncData
function mapDispatchToProps(dispatch) {
return {
fetchHome: (id) => dispatch(homeActions.fetchHome(id)),
fetchColumn: (page) => dispatch(homeActions.fetchColumn(page)),
}
}
class Home extends React.Component {
state = {
tabs: [
{ title: '科技新聞', index: 0 },
{ title: '24h快訊', index: 1 }
],
columnPage: this.props.column.length > 0 ? 1 : 0,
}
static asyncData(store) {
const { fetchHome, fetchColumn } = mapDispatchToProps(store.dispatch);
// 這裏必須return Promise 而且這裏發起請求走的是node環境,api路徑必須寫絕對路徑。
return Promise.all([
fetchHome(),
fetchColumn(),
])
}
}
複製代碼
而後在咱們的 render 函數中去調用對應組件的 asyncData 去初始化 store
import { renderRoutes, matchRoutes } from 'react-router-config';
import createStore from '../createStore.js'
function templating(template) {
return props => template.replace(/<!--([\s\S]*?)-->/g, (_, key) => props[key.trim()]);
}
function(ctx, template) {
try {
// 初始化store
const store = createStore();
// 先獲取全部匹配上的路由信息
const routes = matchRoutes(routerConfig, ctx.url);
// 若是沒有匹配上路由則返回404
if (routes.length <= 0) {
return reject({ code: 404, message: 'Not Page' });
}
// 等全部數據請求回來以後在render, 注意這裏不能用ctx上的路由信息,要使用前端的路由信息
const promises = routes
.filter(item => item.route.component.asyncData) // 過濾掉沒有asyncData的組件
.map(item => item.route.component.asyncData(store, item.match)); // 調用組件內部的asyncData,這裏就修改了store
Promise.all(promises).then(() => {
....同上
})
}
catch (err) {
....同上
}
}
複製代碼
如今 store 的初始化徹底都由 action 控制,不須要咱們手動的經過初始值去初始化 store。不懂的看下圖
好的,到這裏咱們路由和數據處理以及重構完成。
在上篇教程中,因爲咱們的服務端代碼中充斥着 jsx 代碼,因此咱們在運行以前須要使用 babel 編譯下源代碼,但是 jsx 代碼就那麼一小部分,爲了這一小部分,並且編譯整個服務端代碼,這是很是錯誤的決定,因此如今咱們來重構下 koa 的代碼
既想不編譯 koa 代碼,又想讓 node 識別 jsx,那咱們應該怎麼處理呢?很是的簡單,只要咱們把包含 jsx 代碼的這部分抽取到一個單獨的文件,而後咱們只編譯這個文件,這樣不就好了?
其實上面的思路就是編寫一個服務端入口文件。如今咱們既有客戶端入口,也有服務端入口,而且他們都依賴 React React-router Redux,則咱們先編寫一個公共文件,導出這部分的代碼。
// createApp.js
import routerConfig from './router';
import createStore from './redux/store/createStore';
import { renderRoutes } from 'react-router-config';
export default function(store = {}) {
return {
router: renderRoutes(routerConfig),
store: createStore(store),
routerConfig,
}
}
複製代碼
而後編寫 server-entry.js 返回一個 controller
import ReactDom from 'react-dom';
import { StaticRouter } from 'react-router-dom';
import React from 'react';
import { Provider } from 'react-redux';
import { matchRoutes } from 'react-router-config';
import createApp from './createApp';
export default ctx => {
return new Promise((resolve, reject) => {
const { router, store, routerConfig } = createApp();
const routes = matchRoutes(routerConfig, ctx.url);
// 若是沒有匹配上路由則返回404
if (routes.length <= 0) {
return reject({ code: 404, message: 'Not Page' });
}
// 等全部數據請求回來以後在render, 注意這裏不能用ctx上的路由信息,要使用前端的路由信息
const promises = routes
.filter(item => item.route.component.asyncData)
.map(item => item.route.component.asyncData(store, item.match));
Promise.all(promises).then(() => {
ctx.store = store; // 掛載到ctx上,方便渲染到頁面上
resolve(
<Provider store={store}> <StaticRouter location={ctx.url} context={ctx}> { router } </StaticRouter> </Provider>
)
}).catch(reject);
})
}
複製代碼
如今咱們只須要編寫一個服務端打包的 webpack 配置文件, 將服務端入口打包成 node 能夠識別的文件,而後在node端引入這個編譯後的 controller 便可。
const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.base.config');
const config = require('./config')[process.env.NODE_ENV];
const nodeExternals = require('webpack-node-externals');
const { resolve } = require('./utils');
module.exports = merge(baseConfig(config), {
target: 'node',
devtool: config.devtool,
entry: resolve('app/server-entry.js'),
output: {
filename: 'js/server-bundle.js',
libraryTarget: 'commonjs2' // 使用commonjs模塊化
},
// 服務端打包的時候忽略外部的npm包
externals: nodeExternals({
// 固然外部的css仍是能夠打進來的
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(config.env),
'process.env.VUE_ENV': '"server"'
}),
]
})
複製代碼
具體的請看 github,值得說明的是,千萬不要吧 css 打進這個包,node 是不識別 css 的,因此須要抽離 css 代碼。
如今咱們在服務端能夠舒舒服服的寫代碼了,無需編譯便可運行,而且咱們不在依賴前端的源代碼,也能夠開心的使用 css module 了
開啓 css module 很簡單,css-loader 就自帶這個功能。
{
loader: 'css-loader',
options: {
modules: true, // 開啓css module
localIdentName: '[path][local]-[hash:base64:5]' // css module 命名規則
},
},
複製代碼
最後咱們只須要npm build
打包客戶端資源和服務端資源,就能夠直接 npm start
啓動服務了。
因爲咱們啓動的服務須要依賴打包後的文件,生產環境沒問題,可是開發環境我總不能每次修改了代碼就要從新打包一次吧,這樣會嚴重影響效率。下面咱們來講下開發環境如何處理這個問題呢?
起初我準備和上次同樣,開啓兩個服務,客戶端使用 webpack-dev-server 服務端作一層轉發,將靜態資源轉發到 dev-server 服務,可是這樣作在開發環境就不能實現 ssr,因此我決定合併這兩個服務,由 koa 實現 dev-server 的功能。
編寫 dev-server.js
const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
const readFile = (fs, file) => {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
}
module.exports = function(app, templatePath) {
let bundle
let template
let clientHtml
// 這裏其實就是吧resolve單獨拿出來了,其實你也能夠直接吧下面的代碼寫在promise裏面,這樣的好處就是減小代碼嵌套。
let ready
const readyPromise = new Promise(r => {
ready = r
})
// 更新觸發的函數
const update = () => {
if (bundle && clientHtml) {
ready({ bundle, clientHtml });
}
}
// 監聽模版文件
template = fs.readFileSync(templatePath, 'utf-8')
chokidar.watch(templatePath).on('change', () => {
template = fs.readFileSync(templatePath, 'utf-8')
console.log('index.html template updated.')
update()
})
// 添加熱更新的入口
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)
// 建立dev服務
const clientCompiler = webpack(clientConfig)
const devMiddleware = require('koa-webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
});
app.use(devMiddleware)
clientCompiler.hooks.done.tap('DevPlugin', stats => {
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
if (stats.errors.length) return
// 獲取dev內存中入口html
clientHtml = readFile(
devMiddleware.fileSystem,
'server.tpl.html',
)
update()
})
// 開啓熱更新
app.use(require('koa-webpack-hot-middleware')(clientCompiler))
// 監聽而且更新server入口文件
const serverCompiler = webpack(serverConfig)
// 建立一個內存文件系統
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return
// 獲取內存中的server-bundle,並用eval函數執行,返回controller
bundle = eval(readFile(mfs, 'js/server-bundle.js')).default;
update()
})
return readyPromise
}
複製代碼
最後在 koa 中區分下兩個環境
if (isPro) {
// 生成環境直接使用打包好的資源
serverBundle = require('../dist/js/server-bundle').default;
template = fs.readFileSync(resolve('../dist/server.tpl.html'), 'utf-8');
} else {
// 開發環境建立一個服務
readyPromise = require('../build/dev-server')(app, resolve('../app/index.html'));
}
router.get('*', async (ctx, next) => {
if (isPro) {
await render(ctx, serverBundle, template);
} else {
// 等待內存中文件獲取到以後再渲染。
const { bundle, clientHtml } = await readyPromise;
await render(ctx, bundle, clientHtml);
}
next();
})
複製代碼
好了,本篇教程到這裏就結束了,若是幫助到你了,那麼請不要吝嗇你的贊和 start 有問題能夠在下面評論或者在 github 上留言。最後各位看官給個人 github 點個 start,小編感激涕零啊。