使用 React + Koa 從零開始一步一步的帶你開發一個 36kr SSR 案例(二)

zwmmm發表於2019-04-23

前言

本來在上週就想寫下這篇文章,但是在學習的過程中,越來越覺得之前的很多思路需要修改,所以就下定決心,等我重構完這個專案之後再寫第二篇教程。

先上程式碼倉庫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。不懂的看下圖

使用 React + Koa 從零開始一步一步的帶你開發一個 36kr SSR 案例(二)

好的,到這裡我們路由和資料處理以及重構完成。

重構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,小編感激不盡啊。

相關文章