React ISR 如何實現

2023-06-28 06:00:49

之前寫了兩個 demo 講解了如何實現 SSRSSG,今天再寫個 demo 說在 ISR 如何實現。

什麼是 ISR

ISRIncremental Static Regeneration 增量靜態再生,是指在 SSG 的前提下,可以在收到請求時判定頁面是否需要重新整理,如果需要則重新構建該頁面,這樣既擁有了靜態頁面的優勢又可以避免頁面長時間未更新導致資訊過時。且由於在頁面維度驗證,所以每次可以只構建特定的頁面。

ISR 一般適用於符合 SSG 場景,但是卻對頁面的時限性有一定要求時。

如何實現

簡單的 ISR 實現也很簡單,只需要在收到頁面請求時按照更新策略判斷是否需要需要重新生成頁面,如果需要觸發頁面的構建更新。需要注意一般情況下生成頁面不會影響頁面的響應,而是後臺去做構建。

現在就基於之前寫的 SSG demo,做一下改造讓其支援 ISR

修改構建指令碼

由於 ISR 構建會同時在構建指令碼和伺服器中觸發,所以需要對之前的程式碼做一些小小的改動。

首先抽離出一個通用的構建函數(由於伺服器會使用到儘量避免同步程式碼):

import fs from 'fs/promises';
import { renderToString } from 'react-dom/server';
import React from 'react';
import Post from './ui/Post';
import List from './ui/List';

async function build(type: 'list'): Promise<void>;
async function build(type: 'post', name: string): Promise<void>;
async function build(type: 'list' | 'post', name?: string) {
    if (type === 'list') {
        const posts = await fs.readdir('posts');
        await fs.writeFile(
            'dist/index.html',
            `<div id="root">${renderToString(
                <List
                    list={posts.map(post => {
                        delete require.cache['posts/' + post];
                        return { ...require('./posts/' + post), key: post.replace('.json', '') };
                    })}
                />
            )}</div>`
        );
    } else {
        delete require.cache['posts/' + name];
        const postInfo = require('./posts/' + name);
        const fileName = `dist/posts/${name}.html`;
        await fs.writeFile(fileName, `<div id="root">${renderToString(<Post data={postInfo} />)}</div>`);
    }
}

export default build;

這樣就可以通過 build 函數來構建指定的 post 或者 list 頁面。

然後再將原先的構建指令碼做一下簡單的修改:

import fs from 'fs';
import build from './build-util';

// make sure the dir exists
if (!fs.existsSync('dist')) {
    fs.mkdirSync('dist');
}
if (!fs.existsSync('dist/posts')) {
    fs.mkdirSync('dist/posts');
}

// get all the files in posts
const posts = fs.readdirSync('posts');

(async () => {
    for await (const post of posts) {
        await build('post', post.replace('.json', ''));
    }
    await build('list');
})();

伺服器

由於 ISR 需要在請求時做是否構建的判定,所以原先的靜態伺服器方案無法繼續使用,我們換成 express 來實現:

import express from 'express';
import path from 'path';
import fs from 'fs';
import build from '../build-util';

const app = express();

const expiresTime = 1000 * 60 * 10;

app.use(function (req, res, next) {
    setTimeout(() => {
        const filename = req.path.indexOf('.html') >= 0 ? req.path : req.path + 'index.html';

        // get the file's create timestamps
        fs.stat(path.join('./dist', filename), function (err, stats) {
            if (err) {
                console.error(err);
                return;
            }
            if (Date.now() - +stats.mtime > expiresTime) {
                console.log(filename, 'files expired, rebuilding...');
                if (filename === '/index.html') {
                    build('list');
                } else {
                    build('post', path.basename(filename).replace('.html', ''));
                }
            }
        });
    });

    next();
});

app.use(express.static('dist'));

app.listen(4000, () => {
    console.log('Listening on port 4000');
});

我們增加一個 express 的中介軟體,讓其來判定檔案是否過期,這裡以十分鐘為例,實際場景可按需定義過期判定。這裡過期後就會呼叫 build 檔案來重新構建該檔案。要注意此處先返回再構建,所以使用者不會等待構建,並且此次存取依舊是舊的內容,構建完成後存取的才是新的內容。

更多細節

  • 注意給構建任務加鎖,避免一個頁面過期後多個請求同時觸發多個同樣的構建任務
  • 給構建任務加佇列,避免請求過多時同時出現過多的後臺構建任務導致伺服器資源問題
  • 可以為每個檔案制定特定的過期判定條件,比如 post 原始檔的修改時間等等

總結

ISR 對比 SSG 可以有效的控制頁面的時效性,但也要付出額外的代價:

  • 需要額外的開發成本
  • 需要額外的伺服器資源投入
  • 無法使用一般的靜態檔案伺服器

沒有最佳,只有最適合,所以實際場景下還是按需選用。

最後

本文的 demo 程式碼放置在 React ISR Demo 中,可自行取閱。