一、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'])
    ]
  }
];

編寫應(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ù)加入。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,071評(píng)論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書人閱讀 175,409評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書人閱讀 62,569評(píng)論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,360評(píng)論 6 404
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書人閱讀 54,895評(píng)論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評(píng)論 3 440
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 42,123評(píng)論 0 286
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,643評(píng)論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,559評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,742評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評(píng)論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,981評(píng)論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 34,363評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 35,622評(píng)論 1 280
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,354評(píng)論 3 390
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,707評(píng)論 2 370

推薦閱讀更多精彩內(nèi)容