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

4,132 阅读5分钟

前言

本来在上周就想写下这篇文章,但是在学习的过程中,越来越觉得之前的很多思路需要修改,所以就下定决心,等我重构完这个项目之后再写第二篇教程。

先上代码仓库github

看过我第一篇文章的朋友们应该已经大致了解了 react ssr 的基本思路了,如果没有第一篇文章的同学建议先看教程一,但是只是掌握这些还是远远不够的。

首先梳理下上篇教程所带来的问题

  • 路由配置了两次,并且还要手动保持 react-routerkoa-router 路径一致。
  • 同样的请求,需要编写两次。
  • 即使客户端资源完成打包,服务端依旧依赖了客户端的源代码。
  • 没办法写css module
  • 开发环境不友好,需要启动两个服务,并且热更新支持很差。

非常幸运,以上的问题在 v2 中都已解决。下面就跟着我依次解决上述问题,由于考虑文章篇幅,这次我不会贴出太多的源码,只叙述我的思路以及部分核心代码,强烈建议掘友们自己动手 码一码

重构路由以及请求

在上次的文章中我分别采用了 react-routerkoa-router 来构建项目的路由,并且手动保持两端路由的一致性,这样的好处是更加的灵活以及解耦,但缺点是是编写很多重复的代码,考虑我们实际开发中,对于输出 html 的路由前后端基本是一致的,并且数据处理出入不大,则我们在 koa-routerhtml 路由部分可以完全采用 react-router 的配置。

首先我们npm i react-router-config -S,这个包在后面会发挥至关重要的作用。

重构路由配置如下


import React from 'react';
import Home from './pages/home'
import Detail from './pages/detail'

export default [
    {
        path: '/',
        component: Home,
        exact: true,
    },
    {
        path: '/detail/:id',
        component: Detail,
        exact: true,
    },
]

koa-router 修改如下

router.get('/api/flash', HomeControl.flash);
router.get('/api/column', HomeControl.column);
router.get('/api/detail', DetailControl.detail);
router.get('*', async (ctx, next) => {
    await render(ctx, template);
    next();
})

这样我们所有直出html 的路由部分走同一个控制器,想知道render 干了什么事?

其实和之前一样,通过 renderToString 输出对应路由的html,然后填充数据,返回最终的html,简单看下

import { renderRoutes } from 'react-router-config';
function templating(template) {
    return props => template.replace(/<!--([\s\S]*?)-->/g, (_, key) => props[key.trim()]);
}

function(ctx, template) {
    try {
        const render = templating(template);
        const html = renderToString(
            <Provider store={store}>
                <StaticRouter location={ctx.url} context={ctx}>
                    { renderRoutes(routerConfig) } // 这里的routerConfig就是上面配置的路由信息
                </StaticRouter>
            </Provider>
        );
        const body = render({
            html,
            store: `<script>window.__STORE__ = ${JSON.stringify(ctx.store.getState())}</script>`,
        });
        ctx.body = body;
        ctx.type = 'text/html';
    }
    catch (err) {
        console.error(err.message);
        ctx.body = err.message;
        ctx.type = 'text/html';
    }
}

在模板中使用注释当做占位符,抛弃了花括号,这样前后端就可以共用一个模板了。

但是上面的store 部分我们怎么去获取呢?在之前我们是在每个路由渲染之前请求数据然后将数据传递给render 函数,现在我们路由走的是同一个控制器,应该如何处理store ?

下面我们就来重构下store

首先在每一个路由组件上面编写一个静态方法 asyncData

function mapDispatchToProps(dispatch) {
    return {
        fetchHome: (id) => dispatch(homeActions.fetchHome(id)),
        fetchColumn: (page) => dispatch(homeActions.fetchColumn(page)),
    }
}

class Home extends React.Component {
    state = {
        tabs: [
            { title: '科技新闻', index: 0 },
            { title: '24h快讯', index: 1 }
        ],
        columnPage: this.props.column.length > 0 ? 1 : 0,
    }

    static asyncData(store) {
        const { fetchHome, fetchColumn } = mapDispatchToProps(store.dispatch);
        // 这里必须return Promise 并且这里发起请求走的是node环境,api路径必须写绝对路径。
        return Promise.all([
            fetchHome(),
            fetchColumn(),
        ])
    }
}

然后在我们的 render 函数中去调用对应组件的 asyncData 去初始化 store

import { renderRoutes, matchRoutes } from 'react-router-config';
import createStore from '../createStore.js'
function templating(template) {
    return props => template.replace(/<!--([\s\S]*?)-->/g, (_, key) => props[key.trim()]);
}

function(ctx, template) {
    try {
        // 初始化store
        const store = createStore();
        // 先获取所有匹配上的路由信息
        const routes = matchRoutes(routerConfig, ctx.url);
        // 如果没有匹配上路由则返回404
        if (routes.length <= 0) {
            return reject({ code: 404, message: 'Not Page' });
        }
        // 等所有数据请求回来之后在render, 注意这里不能用ctx上的路由信息,要使用前端的路由信息
        const promises = routes
        .filter(item => item.route.component.asyncData) // 过滤掉没有asyncData的组件
        .map(item => item.route.component.asyncData(store, item.match)); // 调用组件内部的asyncData,这里就修改了store
        Promise.all(promises).then(() => {
            ....同上
        })
    }
    catch (err) {
        ....同上
    }
}

现在 store 的初始化完全都由 action 控制,不需要我们手动的通过初始值去初始化 store。不懂的看下图

好的,到这里我们路由和数据处理以及重构完成。

重构koa代码

在上篇教程中,由于我们的服务端代码中充斥着 jsx 代码,所以我们在运行之前需要使用 babel 编译下源代码,可是 jsx 代码就那么一小部分,为了这一小部分,而且编译整个服务端代码,这是非常错误的决定,所以现在我们来重构下 koa 的代码

既想不编译 koa 代码,又想让 node 识别 jsx,那我们应该怎么处理呢?非常的简单,只要我们把包含 jsx 代码的这部分抽取到一个单独的文件,然后我们只编译这个文件,这样不就行了?

其实上面的思路就是编写一个服务端入口文件。现在我们既有客户端入口,也有服务端入口,并且他们都依赖 React React-router Redux,则我们先编写一个公共文件,导出这部分的代码。

// createApp.js
import routerConfig from './router';
import createStore from './redux/store/createStore';
import { renderRoutes } from 'react-router-config';


export default function(store = {}) {
    return {
        router: renderRoutes(routerConfig),
        store: createStore(store),
        routerConfig,
    }
}

然后编写 server-entry.js 返回一个 controller

import ReactDom from 'react-dom';
import { StaticRouter } from 'react-router-dom';
import React from 'react';
import { Provider } from 'react-redux';
import { matchRoutes } from 'react-router-config';
import createApp from './createApp';

export default ctx => {
    return new Promise((resolve, reject) => {
        const { router, store, routerConfig } = createApp();

        const routes = matchRoutes(routerConfig, ctx.url);

        // 如果没有匹配上路由则返回404
        if (routes.length <= 0) {
            return reject({ code: 404, message: 'Not Page' });
        }

        // 等所有数据请求回来之后在render, 注意这里不能用ctx上的路由信息,要使用前端的路由信息
        const promises = routes
        .filter(item => item.route.component.asyncData)
        .map(item => item.route.component.asyncData(store, item.match));

        Promise.all(promises).then(() => {
            ctx.store = store; // 挂载到ctx上,方便渲染到页面上
            resolve(
                <Provider store={store}>
                    <StaticRouter location={ctx.url} context={ctx}>
                        { router }
                    </StaticRouter>
                </Provider>
            )
        }).catch(reject);
    })
}

现在我们只需要编写一个服务端打包的 webpack 配置文件, 将服务端入口打包成 node 可以识别的文件,然后在node端引入这个编译后的 controller 即可。

const merge = require('webpack-merge');
const webpack = require('webpack');
const baseConfig = require('./webpack.base.config');
const config = require('./config')[process.env.NODE_ENV];
const nodeExternals = require('webpack-node-externals');
const { resolve } = require('./utils');

module.exports = merge(baseConfig(config), {
    target: 'node',
    devtool: config.devtool,
    entry: resolve('app/server-entry.js'),
    output: {
        filename: 'js/server-bundle.js',
        libraryTarget: 'commonjs2' // 使用commonjs模块化
    },
    // 服务端打包的时候忽略外部的npm包
    externals: nodeExternals({
        // 当然外部的css还是可以打进来的
        whitelist: /\.css$/
    }),
    plugins: [
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify(config.env),
            'process.env.VUE_ENV': '"server"'
        }),
    ]
})

具体的请看 github,值得说明的是,千万不要吧 css 打进这个包,node 是不识别 css 的,所以需要抽离 css 代码。

现在我们在服务端可以舒舒服服的写代码了,无需编译即可运行,并且我们不在依赖前端的源代码,也可以开心的使用 css module

开启 css module 很简单,css-loader 就自带这个功能。

{
    loader: 'css-loader',
    options: {
        modules: true, // 开启css module
        localIdentName: '[path][local]-[hash:base64:5]' // css module 命名规则
    },
},

最后我们只需要npm build打包客户端资源和服务端资源,就可以直接 npm start 启动服务了。

由于我们启动的服务需要依赖打包后的文件,生产环境没问题,但是开发环境我总不能每次修改了代码就要重新打包一次吧,这样会严重影响效率。下面我们来说下开发环境如何处理这个问题呢?

开发环境构建

起初我准备和上次一样,开启两个服务,客户端使用 webpack-dev-server 服务端做一层转发,将静态资源转发到 dev-server 服务,但是这样做在开发环境就不能实现 ssr,所以我决定合并这两个服务,由 koa 实现 dev-server 的功能。

编写 dev-server.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')

const readFile = (fs, file) => {
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
}

module.exports = function(app, templatePath) {
    let bundle
    let template
    let clientHtml

    // 这里其实就是吧resolve单独拿出来了,其实你也可以直接吧下面的代码写在promise里面,这样的好处就是减少代码嵌套。
    let ready
    const readyPromise = new Promise(r => {
        ready = r
    })

    // 更新触发的函数
    const update = () => {
        if (bundle && clientHtml) {
            ready({ bundle, clientHtml });
        }
    }

    // 监听模版文件
    template = fs.readFileSync(templatePath, 'utf-8')
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8')
        console.log('index.html template updated.')
        update()
    })

    // 添加热更新的入口
    clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
    clientConfig.output.filename = '[name].js'
    clientConfig.plugins.push(
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoEmitOnErrorsPlugin()
    )

    // 创建dev服务
    const clientCompiler = webpack(clientConfig)
    const devMiddleware = require('koa-webpack-dev-middleware')(clientCompiler, {
        publicPath: clientConfig.output.publicPath,
        noInfo: true
    });
    app.use(devMiddleware)
    clientCompiler.hooks.done.tap('DevPlugin', stats => {
        stats = stats.toJson()
        stats.errors.forEach(err => console.error(err))
        stats.warnings.forEach(err => console.warn(err))
        if (stats.errors.length) return
        // 获取dev内存中入口html
        clientHtml = readFile(
            devMiddleware.fileSystem,
            'server.tpl.html',
        )
        update()
    })

    // 开启热更新
    app.use(require('koa-webpack-hot-middleware')(clientCompiler))

    // 监听并且更新server入口文件
    const serverCompiler = webpack(serverConfig)

    // 创建一个内存文件系统
    const mfs = new MFS()
    serverCompiler.outputFileSystem = mfs
    serverCompiler.watch({}, (err, stats) => {
        if (err) throw err
        stats = stats.toJson()
        if (stats.errors.length) return

        // 获取内存中的server-bundle,并用eval函数执行,返回controller
        bundle = eval(readFile(mfs, 'js/server-bundle.js')).default;
        update()
    })

    return readyPromise
}

最后在 koa 中区分下两个环境

if (isPro) {
    // 生成环境直接使用打包好的资源
    serverBundle = require('../dist/js/server-bundle').default;
    template = fs.readFileSync(resolve('../dist/server.tpl.html'), 'utf-8');
} else {
    // 开发环境创建一个服务
    readyPromise = require('../build/dev-server')(app, resolve('../app/index.html'));
}

router.get('*', async (ctx, next) => {
    if (isPro) {
        await render(ctx, serverBundle, template);
    } else {
        // 等待内存中文件获取到之后再渲染。
        const { bundle, clientHtml } = await readyPromise;
        await render(ctx, bundle, clientHtml);
    }
    next();
})

好了,本篇教程到这里就结束了,如果帮助到你了,那么请不要吝啬你的赞和 start 有问题可以在下面评论或者在 github 上留言。最后各位看官给我的 github 点个 start,小编感激不尽啊。