目標(biāo)
以koa為后端服務(wù)器,實(shí)現(xiàn)react的服務(wù)端渲染。最終的目的是想要實(shí)現(xiàn)一個(gè)admin的后臺(tái)單頁(yè)面應(yīng)用和一個(gè)移動(dòng)端得單頁(yè)面。這里先從admin開(kāi)始。當(dāng)用戶訪問(wèn) /admin 這個(gè)地址的時(shí)候,在服務(wù)端渲染好頁(yè)面,然后返回。
項(xiàng)目目錄
|-- app
??|-- controller
????|-- admin.js (在這里面調(diào)用ctx.render('admin')實(shí)現(xiàn)頁(yè)面渲染)
??|-- middleware
????|-- react_view.js (在這里給koa的context添加render方法,已確保在controller里面可以調(diào)用ctx.render)
??|- view
????|-- admin.js(這個(gè)是編譯后的,可以直接用于服務(wù)端渲染的文件)
??|-- web
????|-- component(存放react組件)
????|-- page
??????|-- browser
????????|-- admin.js
??????|-- server
????????|-- admin.js
|-- build (存放build后的文件)
項(xiàng)目設(shè)置
- 創(chuàng)建項(xiàng)目目錄
makedir react-isomorphic
- 進(jìn)入目錄
cd react-isomorphic
- 初始化
npm init
這一步會(huì)問(wèn)你一些問(wèn)題,全部按Enter就好
- 安裝react和koa相關(guān)的包
npm install koa koa-router koa-static react react-dom --save
- 安裝webpack和編譯所需要的包
npm install webpack webpack-cli babel-core babel-preset-env babel-preset-react babel-loader clean-webpack-plugin --save-dev
babel-core 是babel的核心包
babel-preset-env 用于將es2015+編譯成es5
babel-preset-react 用于編譯react的jsx語(yǔ)法
babel-loader 用webpack和babel編譯js
clean-webpack-plugin 用于編譯前,清空編譯目錄
- 配置babel
在項(xiàng)目根目錄下創(chuàng)建文件.babelrc,并填入內(nèi)容:
{
"presets": ["env", "react"]
}
- 配置webpack
在項(xiàng)目根目錄下創(chuàng)建 webpack.config.js
const path = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
// 客戶端 react 應(yīng)用的入口文件
const adminBrowserFilePath = path.resolve(__dirname, './app/web/page/browser/admin');
// 服務(wù)端 react 應(yīng)用的入口文件
const adminServerFilePath = path.resolve(__dirname, './app/web/page/server/admin');
const browserBuildPath = path.resolve(__dirname, './build');
const serverBuildPath = path.resolve(__dirname, './app/view');
module.exports = [
{
name: 'browser',
entry: {
admin: adminBrowserFilePath
},
output: {
path: browserBuildPath,
filename: 'static/js/[name].js',
chunkFilename: 'static/js/[name].chunk.js',
publicPath: '/'
},
target: 'web',
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules\/)/,
use: {
loader: 'babel-loader'
}
}
]
},
plugins: [
new CleanWebpackPlugin(['build'])
]
},
{
name: 'server',
entry: {
admin: adminServerFilePath
},
output: {
path: serverBuildPath,
filename: '[name].js',
publicPath: '/',
libraryTarget: 'commonjs'
},
target: 'node',
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules\/)/,
use: {
loader: 'babel-loader'
}
}
]
},
plugins: [
new CleanWebpackPlugin(['app/view'])
]
}
];
編寫應(yīng)用
./app/web/component/app/Admin.js
import React from 'react';
const App = ({ msg }) => {
return (
<div>Hello { msg }</div>
)
};
export default App;
./app/web/component/app/layout/AdminLayout.js
// 這個(gè)是頁(yè)面的layout文件
import React from 'react';
const Layout = ({state, children}) => {
return (
<html>
<head>
<title>Admin</title>
</head>
<body>
{ children }
<script dangerouslySetInnerHTML={{__html: `window.__STATE__ = ${JSON.stringify(state)}`}}/>
<script src="/static/js/admin.js"></script>
</body>
</html>
);
};
export default Layout;
./app/web/page/browser/admin.js
import React from 'react';
import ReactDOM from 'react-dom';
import AdminApp from '../../component/app/Admin';
ReactDOM.hydrate((<AdminApp {...window.__STATE__}/>), document.getElementById('root'));
./app/web/page/server/admin.js
這個(gè)是服務(wù)段渲染的入口文件,我門將通過(guò)后臺(tái)直接給react傳入初始屬性(即context,一個(gè)普通的對(duì)象)。與客戶段渲染不同的是,客戶端通常是執(zhí)行完js后,通過(guò)ajax向服務(wù)器請(qǐng)求初始狀態(tài)相關(guān)的數(shù)據(jù)。比如:一個(gè)用于展示個(gè)人信息的頁(yè)面,服務(wù)端渲染的話,出來(lái)的結(jié)果直接是一個(gè)帶有個(gè)人信息的html文本,而客戶端則需要發(fā)送一次請(qǐng)求到后端獲取,然后再渲染。
import React from 'react';
import AdminLayout from '../../component/layout/AdminLayout';
import AdminApp from '../../component/app/Admin';
const server = context => {
return (
<AdminLayout>
<AdminApp {...context}/>
</AdminLayout>
)
};
export default server;
目前為止一個(gè)最簡(jiǎn)單的React頁(yè)面就完成了,但是為了和koa整合起來(lái),還需要實(shí)現(xiàn)一個(gè)為koa對(duì)象實(shí)現(xiàn)一個(gè)render方法。這里我把實(shí)現(xiàn)代碼放到middleware目錄下。
./app/middleware/react_view.js
const assert = require('assert');
const path = require('path');
const fs = require('fs');
const ReactDOMServer = require('react-dom/server');
const defaults = {
view: path.resolve(process.cwd(), 'view'),
extname: 'js'
};
module.exports = (options, app) => {
options = options || {};
options = Object.assign(options, defaults);
assert(typeof options.view === 'string', 'options.view required, and must be a string');
assert(fs.existsSync(options.view), `Directory ${options.view} not exists`);
options.extname = options.extname.trim().replace(/^\.?/, '.');
app.context.render = function (filename, _context) {
if (!path.extname(filename)) {
filename += options.extname;
}
let filepath = path.isAbsolute(filename) ? filename : path.resolve(options.view, filename);
const context = Object.assign({}, this.state, _context);
try {
// 獲取server/admin.js編譯后的文件
let view = require(filepath);
view = view.default || view;
// view是一個(gè)函數(shù),調(diào)用后返回一個(gè)react組件,然后把react組件渲染成html字符串
this.body = ReactDOMServer.renderToString(view(context));
this.type = 'html';
} catch (err) {
err.code = 'REACT';
throw err;
}
}
};
然后需要做的是,實(shí)現(xiàn)一個(gè)controller用于返回頁(yè)面給前端
./app/controller/admin.js
exports.admin = ctx => {
ctx.render('admin', { msg: 'World' });
};
controller寫好了以后現(xiàn)在需要配置路由
./app/router.js
const admin = require('./controller/admin');
module.exports = app => {
const { router } = app;
router.get('/admin', admin.admin);
};
然后實(shí)例化一個(gè)koa對(duì)象
./app/app.js
const Koa = require('koa');
const serve = require('koa-static');
const Router = require('koa-router');
const path = require('path');
const router = new Router();
const routes = require('./router');
const reactView = require('./middleware/react_view');
const app = new Koa();
// 給koa對(duì)象增加一個(gè)router屬性
Object.defineProperties(app, {
router: {
get() {
return router;
}
}
});
// 給koa的上下文ctx對(duì)象增加render方法
reactView({
view: path.resolve(__dirname, './view')
}, app);
routes(app);
app.use(serve(path.resolve(__dirname, '../build')));
app.use(router.routes());
app.on('error', function(err, ctx){
log.error('server error', err, ctx);
});
module.exports = app;
以上所有的代碼已經(jīng)完成,現(xiàn)在就是設(shè)置啟動(dòng)端口
./index.js
require('./app/app')
.listen(process.env.PORT || 3000, () => {
console.log('Server is running on 3000');
});
現(xiàn)在我們添加兩個(gè)命令到package.json,用于編譯react和啟動(dòng)應(yīng)用。
{
...
"scripts": {
"build": "webpack",
"start": "node index.js"
}
...
}
到現(xiàn)在應(yīng)用就可以運(yùn)行了,在當(dāng)前項(xiàng)目根目錄下執(zhí)行命令
npm run build && npm run start
打開(kāi)瀏覽器,輸入http://localhost:3000/admin,如果沒(méi)有錯(cuò)誤的話,你應(yīng)該能看到
項(xiàng)目地址:https://github.com/leitc/isomorphic-react/tree/0.1
總結(jié)
目前只實(shí)現(xiàn)了基本的hello world頁(yè)面,還缺少路由跳轉(zhuǎn),樣式的引入,熱更新,和部署流程,后面后持續(xù)加入。