一、koa+React服務(wù)端渲染:Hello World

目標(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è)置

  1. 創(chuàng)建項(xiàng)目目錄
makedir react-isomorphic
  1. 進(jìn)入目錄
cd react-isomorphic
  1. 初始化
npm init

這一步會(huì)問(wèn)你一些問(wèn)題,全部按Enter就好

  1. 安裝react和koa相關(guān)的包
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語(yǔ)法
babel-loader 用webpack和babel編譯js
clean-webpack-plugin 用于編譯前,清空編譯目錄

  1. 配置babel
    在項(xiàng)目根目錄下創(chuàng)建文件.babelrc,并填入內(nèi)容:
{
  "presets": ["env", "react"]
}
  1. 配置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'])
    ]
  }
];

編寫(xiě)應(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ù)段渲染的入口文件,我門(mén)將通過(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ě)好了以后現(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ù)加入。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容