一、koa+React服务端渲染:Hello World

目标

以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后的文件)

项目设置

  1. 创建项目目录
makedir react-isomorphic
  1. 进入目录
cd react-isomorphic
  1. 初始化
npm init

这一步会问你一些问题,全部按Enter就好

  1. 安装react和koa相关的包
npm install koa koa-router koa-static react react-dom --save
  1. 安装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 用于编译前,清空编译目录

  1. 配置babel
    在项目根目录下创建文件.babelrc,并填入内容:
{
  "presets": ["env", "react"]
}
  1. 配置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页面,还缺少路由跳转,样式的引入,热更新,和部署流程,后面后持续加入。

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

推荐阅读更多精彩内容