阅读 6265

使用 React + Koa 从零开始一步一步的带你开发一个 36kr SSR 案例(一)

前言

本项目源码地址 github.com/zwmmm/react… 喜欢的给个star鼓励下作者,有问题可以提issue

也许你看过其他的ssr教程都会先说一说spa和ssr的区别以及优缺点,但是我相信能点进来看的小伙伴们肯定是对这两个概念有过了解的,也无需我在这里多费口舌。不懂的可以直接看这里

那么我们就直接进入正题了!!!

搭建目录结构

首先我们创建一个react-ssr文件夹, 执行git init初始化git仓库,添加如下目录和文件。

.
|-- app
|-- build
|-- server
|-- template
|-- package.json
|-- README.md
|-- .gitignore
复制代码

.gitignore忽略文件

node_modules
.cache
.idea
复制代码

webpack的配置

安装webpack

npm install --save-dev webpack webpack-cli
复制代码

推荐使用 --save-dev 安装,因为现在webpack版本很多,全局安装不利于各个项目管理。

配置react环境

首先我们明确下目标,要想运行react的代码,首先将react中的jsx编译成js代码。

先在app下创建入口文件main.js

|-- app
|   |-- main.js
复制代码

template下创建模板文件app.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>demo</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>
复制代码

build文件夹中创建utils.js文件。先写一些公共的方法。

const path = require('path');

exports.resolve = (...arg) => path.join(__dirname, '..', ...arg);
复制代码

build文件夹中创建webpack.base.config.js文件

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { resolve } = require('./utils');

module.exports = {
    entry: resolve('app/main.js'),
    output: {
        path: resolve('dist'),
        filename: 'index.js'
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                // 只编译app文件夹下的文件
                include: resolve('app'),
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            '@babel/preset-env',
                            '@babel/preset-react',
                        ],
                    }
                }
            },
        ]
    },
    resolve: {
        // 设置路径别名
        alias: {
            '@': resolve('app'),
        },
        // 文件后缀自动补全, 就是你import文件的时候如果没写后缀名就会优先找下面这几个
        extensions: [ '.js', '.jsx' ],
    },
    // 第三方依赖,可以写在这里,不打包
    externals: {},
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.html',
            template: resolve('template/app.html')
        })
    ]
}
复制代码

安装下上面用到的包

npm i -D @babel/cli @babel/core @babel/preset-env @babel/preset-react babel-loader html-webpack-plugin
复制代码

简单说下这几个配置的作用

  • entry 指定入口
  • output 设置出口并确定输出的文件名称
  • rules 配置loader
  • babel 编译代码,将代码转成浏览器可以运行的代码
  • HtmlWebpackPlugin 自动生成html的插件

如果不熟悉babel的同学可以看这篇文章,不过我使用了babel7 所以在包名上会有不同,新版的babel统一有@babel前缀

配置好了就需要我们写点react代码测试下啦

首先下载react相关的资源包

npm i --save react react-dom
复制代码

app/main.js编写如下代码

import React from 'react';
import { render } from 'react-dom';

function App() {
    return <div>Hello React</div>
}

render(<App/>, document.getElementById('app'));
复制代码

package.json中增加一条script命令

{
  "scripts": {
    "start": "webpack --config build/webpack.base.config.js"
  },
}
复制代码

执行npm start 打开dist/index.html就可以查看效果,正确情况下会显示Hello React

到此我们就已经完成我们的第一阶段,可以编写react代码

配置开发环境

上面我们说了如何编译react代码,但是在我们实际开发中不可能每次修改代码都要npm start,所以在上面的基础上配置一个dev环境

在配置dev环境之前先介绍下webpack-dev-server,这个插件可以在本地启动一个本地服务,并且提供了非常丰富的功能,例如热更新,接口代理。首先我们安装下

npm i -D webpack-dev-server
复制代码

build下新建webpack.dev.config.js

const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {
    // 用于调试, inline-source-map模式效率比较高, 所以在dev模式下推荐使用这个
    devtool: 'inline-source-map',
    mode: 'development',
    // 设置dev服务器
    devServer: {
        // 设置端口号,默认8080
        port: 8000,
    },
    plugins: [
        // 在js中注入全局变量process.env用来区分环境
        new webpack.DefinePlugin({
            'process.env': {
                NODE_ENV: JSON.stringify('development'),
            }
        }),
    ],
})
复制代码

安装下webpack-merge

npm i -D webpack-merge
复制代码

简单说下上面的配置

  • 使用webpack-merge复用之前的配置
  • 配置devServer
  • 注入process.env全局变量区分环境

最后我们在修改下启动命令

{
  "scripts": {
    "start": "webpack-dev-server --hot --config build/webpack.dev.config.js"
  },
}
复制代码

现在我们执行下npm start 浏览器打开localhost:8000访问,并尝试修改main.js中的react代码,不刷新浏览器是否会自动更新

现在我们的webpack已经可以支持简单的开发了,但是这还远远不够,在编写前端代码时,我们还会接触到cssimage、等其他文件的使用,所以需要加强下webpack的配置

    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                // 只编译app文件夹下的文件
                include: resolve('app'),
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            '@babel/preset-env',
                            '@babel/preset-react',
                        ],
                    }
                }
            },
+           {
+               test: /\.html$/,
+               include: resolve('app'),
+               loader: 'html-loader'
+           },
+           {
+               test: /\.less/,
+               include: resolve('app'),
+               use: [
+                   'style-loader',
+                   'css-loader',
+                   'less-loader'
+               ]
+           },
+           {
+               test: /\.(png|jpg|gif|svg)$/,
+               loader: `url-loader?limit=1000`
+           },
+           {
+               test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
+               loader: `file-loader`
+           },
+       ]
    },
复制代码

下载需要的loader以及less

npm i -D html-loader style-loader css-loader less-loader url-loader file-loader less
复制代码

经过下面的配置我们就可以在代码中做如下的操作

import img from './xxx.png'
import 'xxx.less'
import html from 'xxx.html'
复制代码

那么接下来我们就给我们的react丰富一下代码

首先在app文件夹下新建style static文件夹分别存放css文件和静态资源,

新增index.lesstimg.png

#app {
    text-align: center;
    color: deepskyblue;
}
.logo {
    width: 500px;
}
复制代码

然后修改main.js

import React from 'react';
import { render } from 'react-dom';
import './style/index.less';
import logo from './static/timg.jpg'

function App() {
    return <div>
        <h1>Hello React !!!</h1>
        <img src={ logo } className="logo"/>
    </div>
}

render(<App/>, document.getElementById('app'));
复制代码

最终的效果

这里可能会有同学会有一个疑问, 图片为什么直接使用<img src="./static/time.png" className="logo"/>这样引入?其实很好解释,我们的网站是访问的webpack-dev-server启动的服务,如果没有使用import引入图片,则在服务器中就不会存在这个图片。而import图片的时候 首先会找到对应的图片资源存到服务器上, 并且生成一个文件路径供我们访问。

使用Koa搭建Node服务

react的部分我们先告一段落,后面还会继续说到react-router redux,接下来我们说下服务端,也算是正式讲点ssr的东西

首先在这里提一嘴,ssr和普通的spa页面最大的区别在于,我们是直接将完整的html返回给浏览器的。

话不多说,直接开工!!!

先下载koa

npm i -S koa
复制代码

创建server/app.js文件

const Koa = require('koa');

const app = new Koa();

app.use(ctx => {
    ctx.body = '<div>Hello Koa<div/>'
})

app.listen(9000, () => {
    console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码

添加一条script命令

"server": "node server/app.js"
复制代码

运行npm run server并访问localhost:9000

这时候就可以看到Hello Koa,其实这就是一个最基本的直出服务,现在让我们想一想,如果代码可以写成这样

    app.use(ctx => {
-       ctx.body = '<div>Hello Koa<div/>'
+       ctx.body = <App/>
    })
复制代码

直接返回一个react组件,那不就是我们要的react ssr?

当然上面的代码直接这么执行肯定是会报错,不过react给我们提供了renderToString方法,将组件转成字符串。这样我们就可以实现渲染组件了!!!

来,我们改良下上面的代码,让node支持jsx语法

先创建server/index.js,使用@babel/register在node运行时候编译我们的jsx代码以及es6语法

安装@babel/register

npm i -S @babel/register
复制代码
require('@babel/register')({
    presets: [
        '@babel/preset-react',
        '@babel/preset-env'
    ],
});
require('./app.js');
复制代码

修改script命令

- "server": "node server/app.js"
+ "server": "node server/index.js"
复制代码

重构app.js

因为前面使用了babel编译了代码,所以可以使用es6的模块化

// jsx编译之后会用到React对象, 所以需要引入
import React from 'react';
import Koa from 'koa';
import { renderToString } from "react-dom/server";

const app = new Koa();

const App = () => <div>Hello Koa SSR</div>

app.use(ctx => {
    ctx.body = renderToString(<App/>);
})

app.listen(9000, () => {
    console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码

现在我们已经完成了最简单的react ssr,下一步我们将加上路由,实现对应的路由显示对应的组件

SSR下的路由

看完上面的章节,大伙是不是想说,ssr是实现了,但是好像和我得前端部分并没有关联起来啊,我在前端写的组件应该怎么在Node中去使用呢?下面我在路由这个篇章就会将前端和Node关联起来讲,让大家知道页面到底是怎么渲染出来的。

在开始讲之前我还是得先和大家说说传统的spa页面路由是怎么配置的,下面就以history模式为例

首先我们从浏览器输入url,不管你的url是匹配的哪个路由,后端统统都给你index.html,然后加载js匹配对应的路由组件,渲染对应的路由。

那我们的ssr路由是怎么样的模式呢?

首先我们从浏览器输入url,后端匹配对应的路由获取到对应的路由组件,获取对应的数据填充路由组件,将组件转成html返回给浏览器,浏览器直接渲染。当这个时候如果你在页面中点击跳转,我们依旧还是不会发送请求,由js匹配对应的路由渲染

文字看懵的我们直接看图

所以我们需要同时配置前端路由以及后端路由

那一步步来,我们先配置前端路由,前端路由使用react-router,如果不会使用react-router的同学可以看下我写的这篇入门文章

下载react-router

npm i -S react-router-dom
复制代码

新建app/router.js

import { Link, Switch, Route } from 'react-router-dom';
import React from 'react';

const Home = () => (
    <div>
        <h1>首页</h1>
        <Link to="/list">跳转列表页</Link>
    </div>
)

const list = [
    'react真好玩',
    'koa有点意思',
    'ssr更有意思'
]

const List = () => (
    <ul>
        { list.map((item, i) => <li key={ i }>{ item }</li>) }
    </ul>
)

export default () => (
    <Switch>
        <Route exact path="/" component={ Home }/>
        <Route exact path="/list" component={ List }/>
    </Switch>
)
复制代码

修改main.js

import React from 'react';
import { render } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Router from './router'

render(
    <BrowserRouter>
        <Router/>
    </BrowserRouter>,
    document.getElementById('app')
);
复制代码

执行npm start 访问localhost:8000

ok,前端路由就这么简单的配置好了,现在如果你跳转到列表页,然后刷新页面就会提示404这是因为我们的dev-server没有匹配上对应的路由,那么接下来我们就来配置服务端路由来解决这个问题,并且实现ssr

服务端路由我们使用koa-router

先下载 npm i -S koa-router

新建server/router/index.js

import Router from 'koa-router';
import RouterConfig from '../../app/router';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from "react-dom/server";
import React from 'react';

const routes = new Router();

routes.get('/', (ctx, next) => {
    ctx.body = renderToString(
        <StaticRouter location={ctx.url}>
            <RouterConfig/>
        </StaticRouter>
    )
    next();
})

routes.get('/list', (ctx, next) => {
    ctx.body = renderToString(
        <StaticRouter location={ctx.url}>
            <RouterConfig/>
        </StaticRouter>
    )
    next();
})

export default routes;
复制代码

一下看不懂没关系,听我来解释

首先我们用koa-router注册了/ /list 两个路由,并且使用renderToString将组件转成html

那这个StaticRouter是干嘛的呢?和BrowserRouter有什么区别?其实很简单,在浏览器上我们可以使用js获取到location,但是在node环境却获取不到,所以react-router提供了StaticRouter来让我们自己设置location

现在你也许会有另外一个疑问,这两个路由设置写的代码不是都一样的么,为什么还要去区分路由?这是应为在生成html之前我们还需要获取对应的数据,所以必须要分开。后面我会继续讲ssr如何处理数据

接下来我们改造下app.js

import Koa from 'koa';
import routes from './router';

const app = new Koa();

app.use(routes.routes(), routes.allowedMethods());

app.listen(9000, () => {
    console.log(`node服务已经启动, 请访问localhost:9000`)
})
复制代码

启动npm run server 访问localhost:9000

现在我们的localhost:9000 localhost:8000 都可以浏览了,正好你们可以对比下两种渲染方式。

ok,心细的朋友可能发现了localhost:9000下的页面点击跳转是刷新页面的,并不是单页面跳转。这是因为我们返回的html里面根本就没有携带js,所以跳转路由当然是直接发生跳转了啊,并且返回的html也是不完整的,现在我们就给我们的内容添加一个html模板

新建模板template/server.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>36氪_让一部分人先看到未来</title>
    <link href="//36kr.com/favicon.ico" rel="shortcut icon" type="image/vnd.microsoft.icon">
</head>
<body>
    <div id="app">{{ html }}</div>
    <script src="http://localhost:8000/index.js"></script>
</body>
</html>
复制代码

这里我们加载localhost:8000服务下的inedx.js,其实你可以吧webpack-dev-server想象成静态资源服务器了,这样我们的静态资源在你的开发阶段就可以实时更新。

然后我们给ctx对象扩展一个render方法,用来渲染html

import fs from 'fs';
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import RouterConfig from '../app/router'
import React from 'react';
import path from 'path';

// 匹配模板中的{{}}
function templating(props) {
    const template = fs.readFileSync(path.join(__dirname, '../template/server.html'), 'utf-8');
    return template.replace(/{{([\s\S]*?)}}/g, (_, key) => props[ key.trim() ]);
}

export default function(ctx, next) {
    try {
        ctx.render = () => {
            const html = renderToString(
                <StaticRouter location={ ctx.url }>
                    <RouterConfig/>
                </StaticRouter>
            );
            const body = templating({
                html
            });
            ctx.body = body;
        }
    }
    catch (err) {
        ctx.body = templating({ html: err.message });
    }
    ctx.type = 'text/html';
    // 这里必须是return next() 不然异步路由是404
    return next();
}
复制代码

然后在app.js中加载上面写的中间件

    import Koa from 'koa';
    import routes from './router';
+   import templating from './templating'
    
    const app = new Koa();
    
+   app.use(templating);
    app.use(routes.routes(), routes.allowedMethods());
    
    app.listen(9000, () => {
        console.log(`node服务已经启动, 请访问localhost:9000`)
    })
复制代码

最后我们来改造下路由

import Router from 'koa-router';
import React from 'react';

const routes = new Router();

routes.get('/', (ctx, next) => {
    ctx.render();
    next();
})

routes.get('/list', (ctx, next) => {
    ctx.render();
    next();
})

export default routes;
复制代码

重启你的localhost:9000看看现在跳转list是不是就不会再刷新页面了。

到这里我们的路由就算配置完成了。相信大家对ssr也有一定的了解了,但是还不够,目前我们渲染的都是静态页面,也就是写死的,而实际业务肯定是根据数据渲染出来的,之前的spa页面我们会在组件中去发送请求获取数据渲染,但我们的ssr肯定不能这样做,所以得在生成html这一步获取数据,那数据又该怎么传进组件内呢?以及前后端数据怎么做到同步呢?下一个章节我们就讲讲ssr的数据请求

SSR中的数据请求

react中操作数据无非两种方式stateprops,我们在node中肯定是没办法给组件设置state的,所以只能通过props传进去,并且我们的数据还要做到前后端同步,不然你就光渲染出了html,数据没给前端这样也不行啊。而redux刚好满足这两点需求。

既然要用redux那就得先从前端开始了啊,不熟悉redux的朋友建议先了解下基本概念

下载npm i redux react-redux -S

新建目录

|-- app
|   |-- redux
|   |   |-- reducers
|   |   |-- store
复制代码

先创建reducers

// reducers/home.js
const defaultState = {
    title: 'Hello Redux'
}

export default function(state  = defaultState , action) {
    switch (action.type) {
        default:
            return state
    }
}
复制代码
// reducers/list.js
const defaultState = {
    list: [
        'react真好玩',
        'koa有点意思',
        'ssr更有意思'
    ]
}

export default function(state  = defaultState , action) {
    switch (action.type) {
        default:
            return state
    }
}
复制代码

合并reducers

// reducers/index.js
import home from './home';
import list from './list';
import { combineReducers  } from 'redux';

// 其实就是把分散的reducers给合并了
export default combineReducers({
    home,
    list,
})
复制代码

接下来创建store

import { createStore } from 'redux';
import reducers from '../reducers';

/**
 * 为什么写成函数?
 * 因为我们在前端和后端都需要去进行初始化store所以这里封装一个工厂函数
 * @param data
 * @returns {*}
 */
export default data => createStore(reducers, data);
复制代码

然后将store注入到组件中

// main.js
+ import { Provider } from 'react-redux';
+ import createStore from './redux/store/create';

+ const store = createStore();

render(
+   <Provider store={store}>
        <BrowserRouter>
            <Router/>
        </BrowserRouter>
+   </Provider>,
    document.getElementById('app')
);
复制代码

page从路由中抽离出来

// pages/home.js
import { Link } from 'react-router-dom';
import React from 'react';
import { connect } from 'react-redux';

const Home = props => (
    <div>
        <h1>{ props.title }</h1>
        <Link to="/list">跳转列表页</Link>
    </div>
)

/**
 * 通过connect将redux中的数据传递进入组件
 */
function mapStateTpProps(state) {
    return { ...state.home };
}

export default connect(mapStateTpProps)(Home)
复制代码
// pages/list.js
import React from 'react';
import { connect } from 'react-redux';

const List = props => (
    <ul>
        { props.list.map((item, i) => <li key={ i }>{ item }</li>) }
    </ul>
)

/**
 * 通过connect将redux中的数据传递进入组件
 */
function mapStateTpProps(state) {
    return { ...state.list };
}

export default connect(mapStateTpProps)(List)
复制代码

最后修改下路由

import { Switch, Route } from 'react-router-dom';
import React from 'react';
import Home from './pages/home';
import List from './pages/list';

export default () => (
    <Switch>
        <Route exact path="/" component={ Home }/>
        <Route exact path="/list" component={ List }/>
    </Switch>
)
复制代码

好了,最基本的redux已经完成,现在我们已经将数据从组件内部提取到了redux来管理,接下来我们实现在node中填充数据。

其实这一步非常简单,只要修改下templating就可以,直接看代码

import fs from 'fs';
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import RouterConfig from '../app/router'
import React from 'react';
import path from 'path';
+ import { Provider } from 'react-redux';
+ import createStore from '../app/redux/store/create';

// 匹配模板中的{{}}
function templating(props) {
    const template = fs.readFileSync(path.join(__dirname, '../template/server.html'), 'utf-8');
    return template.replace(/{{([\s\S]*?)}}/g, (_, key) => props[ key.trim() ]);
}

export default function(ctx, next) {
    try {
+       ctx.render = (data = {}) => {
+           const store = createStore(data);
            const html = renderToString(
+               <Provider store={ store }>
                    <StaticRouter location={ ctx.url }>
                        <RouterConfig/>
                    </StaticRouter>
+               </Provider>
            );
            const body = templating({
                html
            });
            ctx.body = body;
        }
    }
    catch (err) {
        ctx.body = templating({ html: err.message });
    }
    ctx.type = 'text/html';
    // 这里必须是return next() 不然异步路由是404
    return next();
}
复制代码

然后我们在调用ctx.render的时候将数据当做参数传入就可以了

import Router from 'koa-router';
import React from 'react';

const routes = new Router();

routes.get('/', (ctx, next) => {
    ctx.render({
        home: {
            title: '我是从node中获取的数据'
        }
    });
    next();
})

routes.get('/list', (ctx, next) => {
    ctx.render({
        list: {
            list: [
                '我是从node中获取的数据',
                '感觉还不错',
                '测试成功',
            ]
        }
    });
    next();
})

export default routes;
复制代码

重启npm run server 刷新下localhost:9000看看效果

诶,不对啊,是不是看到了,页面一开始是正确的,然后又被重新覆盖了?这是因为我们加载了index.js他又重新初始化store,所以会产生这样的问题。

那怎么解决?还记得刚开始说的前后端数据同步么?只要我把node用到的数据传给前端,前端基于这个数据去初始化store这样不就可以了?

怎么把数据传给前端?很简单,直接把store注入到window上就行。

先修改下我们的模板server.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>36氪_让一部分人先看到未来</title>
    <link href="//36kr.com/favicon.ico" rel="shortcut icon" type="image/vnd.microsoft.icon">
</head>
<body>
<div id="app">{{ html }}</div>
+ <script>
+ window.__STORE__ = {{ store }}
+ </script>
<script src="http://localhost:8000/index.js"></script>
</body>
</html>
复制代码

改下templating

ctx.render = (data = {}) => {
    const store = createStore(data);
    const html = renderToString(
        <Provider store={ store }>
            <StaticRouter location={ ctx.url }>
                <RouterConfig/>
            </StaticRouter>
        </Provider>
    );
    const body = templating({
        html,
+       store: JSON.stringify(data, null, 4),
    });
    ctx.body = body;
}
复制代码

最后前端获取store

+ const defaultStore = window.__STORE__ || {}
- const store = createStore();
+ const store = createStore(defaultStore);

render(
    <Provider store={store}>
        <BrowserRouter>
            <Router/>
        </BrowserRouter>
    </Provider>,
    document.getElementById('app')
);
复制代码

重启npm run server 刷新下localhost:9000是不是完美了

最后补充一点关于api请求的点

因为一个页面可能是由node直出的,也有可能是js加载的,所以我们还需要在每个组件的componentDidMount中去分析有没有事先注入过store,来判断是否需要请求,如下面的伪代码。

componentDidMount() {
    const { news, fetchHome } = this.props;
    news.length || fetchHome();
}
复制代码

其实到这里我们的ssr实现原理已经讲完了,接下来的章节我会带大家完成一个36kr的案例,想自己动手直接开撸的同学也可以直接看我的react-ssr-36kr源码,那如果你对redux以及koa不是很熟悉的同学则可以继续看我的下篇文章,下篇文章会带大家进行实战开发以及build发布线上环境的配置。

点击进入下篇教程