目标
以koa为后端服务器,实现react的服务端渲染。最终的目的是想要实现一个admin的后台单页面应用和一个移动端得单页面。这里先从admin开始。当用户访问 /admin 这个地址的时候,在服务端渲染好页面,然后返回。
项目目录
|-- app
|-- controller
|-- admin.js (在这里面调用ctx.render('admin')实现页面渲染)
|-- middleware
|-- react_view.js (在这里给koa的context添加render方法,已确保在controller里面可以调用ctx.render)
|- view
|-- admin.js(这个是编译后的,可以直接用于服务端渲染的文件)
|-- web
|-- component(存放react组件)
|-- page
|-- browser
|-- admin.js
|-- server
|-- admin.js
|-- build (存放build后的文件)
项目设置
- 创建项目目录
makedir react-isomorphic
- 进入目录
cd react-isomorphic
- 初始化
npm init
这一步会问你一些问题,全部按Enter就好
- 安装react和koa相关的包
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语法
babel-loader 用webpack和babel编译js
clean-webpack-plugin 用于编译前,清空编译目录
- 配置babel
在项目根目录下创建文件.babelrc,并填入内容:
{
"presets": ["env", "react"]
}
- 配置webpack
在项目根目录下创建 webpack.config.js
const path = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
// 客户端 react 应用的入口文件
const adminBrowserFilePath = path.resolve(__dirname, './app/web/page/browser/admin');
// 服务端 react 应用的入口文件
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'])
]
}
];
编写应用
./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
// 这个是页面的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
这个是服务段渲染的入口文件,我门将通过后台直接给react传入初始属性(即context,一个普通的对象)。与客户段渲染不同的是,客户端通常是执行完js后,通过ajax向服务器请求初始状态相关的数据。比如:一个用于展示个人信息的页面,服务端渲染的话,出来的结果直接是一个带有个人信息的html文本,而客户端则需要发送一次请求到后端获取,然后再渲染。
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;
目前为止一个最简单的React页面就完成了,但是为了和koa整合起来,还需要实现一个为koa对象实现一个render方法。这里我把实现代码放到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是一个函数,调用后返回一个react组件,然后把react组件渲染成html字符串
this.body = ReactDOMServer.renderToString(view(context));
this.type = 'html';
} catch (err) {
err.code = 'REACT';
throw err;
}
}
};
然后需要做的是,实现一个controller用于返回页面给前端
./app/controller/admin.js
exports.admin = ctx => {
ctx.render('admin', { msg: 'World' });
};
controller写好了以后现在需要配置路由
./app/router.js
const admin = require('./controller/admin');
module.exports = app => {
const { router } = app;
router.get('/admin', admin.admin);
};
然后实例化一个koa对象
./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对象增加一个router属性
Object.defineProperties(app, {
router: {
get() {
return router;
}
}
});
// 给koa的上下文ctx对象增加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;
以上所有的代码已经完成,现在就是设置启动端口
./index.js
require('./app/app')
.listen(process.env.PORT || 3000, () => {
console.log('Server is running on 3000');
});
现在我们添加两个命令到package.json,用于编译react和启动应用。
{
...
"scripts": {
"build": "webpack",
"start": "node index.js"
}
...
}
到现在应用就可以运行了,在当前项目根目录下执行命令
npm run build && npm run start
打开浏览器,输入http://localhost:3000/admin,如果没有错误的话,你应该能看到
项目地址:https://github.com/leitc/isomorphic-react/tree/0.1
总结
目前只实现了基本的hello world页面,还缺少路由跳转,样式的引入,热更新,和部署流程,后面后持续加入。