天天看點

騰訊新聞搶金達人活動node同構直出渲染方案的總結

我們的業務在展開的過程中,前端渲染的模式主要經曆了三個階段:服務端渲染、前端渲染和目前的同構直出渲染方案。

服務端渲染的主要特點是前後端沒有分離,前端寫完頁面樣式和結構後,再将頁面交給後端套資料,最後再一起聯調。同時前端的釋出也依賴于後端的同學;但是優點也很明顯:頁面渲染速度快,同時 SEO 效果好。

為了解決前後端沒有分離的問題,後來就出現了前端渲染的這種模式,路由選擇和頁面渲染,全部放在前端進行。前後端通過接口進行互動,各端可以更加專注自己的業務,釋出時也是獨立釋出。但缺點是頁面渲染慢,嚴重依賴 js 檔案的加載速度,當 js 檔案加載失敗或者 CDN 出現波動時,頁面會直接挂掉。我們之前大部分的業務都是前端渲染的模式,有部分的使用者回報頁面 loading 時間長,頁面渲染速度慢,尤其是在老舊的 Android 機型上這個問題更加地明顯。

node同構直出渲染方案

可以避免服務端渲染和前端渲染存在的缺點,同時前後端都是用 js 寫的,能夠實作資料、元件、工具方法等能實作前後端的共享。

1. 效果

首先來看下統計資料的結果,可以看到從前端渲染模式切換到 node 同構直出渲染模式後,整頁的加載耗時從 3500ms 降低到了 2100 毫秒左右,整體的加載速度提高了将近 40%。

但這個資料也不是最終的資料,因為當時要趕着上線的時間,很多東西還沒來及優化,在後續的優化完成後,可以看到整體的的加載耗時又下降到了 1600ms 左右,再次下降了 500ms 左右。

從 3500ms 降低到 1600ms,整整加快了 1900ms 的加載速度,整體提升了 54%。優化的手段在稍後也會講解到。

2. 遇到的挑戰

在進行同構直出渲染方案,也對目前存在的技術,并結合自身的技術棧,對整體的架構進行梳理。

梳理出接下來存在的重點和難點:

  1. 如何保持資料、路由、狀态、基礎元件的同構共用?如何區分用戶端和服務端?
  2. 如何進行資料請求,是否存在跨域的請求?在服務端、浏覽器端和新聞用戶端内都是怎樣進行資料請求的,各自都有什麼特點,是否可以封裝一下?
  3. 工程化:如何區分開發環境、測試環境、預釋出環境和正式環境?單元測試如何執行?是否可以自動化釋出?
  4. 項目的頁面有什麼特點,頁面、接口資料、元件等是否可以緩存?如何進行緩存?是否存在個性化的資料?
  5. 如何記錄日志,上報項目的性能資料,如請求量、前端頁面加載的整頁耗時、錯誤率、後端耗時等資料?如何在 node 服務出現異常時(如負載過高、記憶體洩露)進行告警?
  6. 如何進行容災處理,當出現異常情況時如何降級,并告知開發者快速的修複!
  7. node 是單線程運作,如何充分利用多核?
  8. 性能優化:預加載、圖檔懶加載、使用 service worker、延遲加載 js、IntersectionObserver 延遲加載元件等

針對我們項目初期的規劃中,可能出現的問題一一進行解決,最終我們的項目也能夠實作的差不離了,某些比較大的子產品我可能需要單獨拿出來寫一篇文章進行總結。

3. 功能實作

3.1 前後端的同構

使用 node 服務端同構指出渲染方案,最主要的是資料等結構能夠實作

前後端的同構共享

同構方面主要是實作:資料同構、狀态同構、元件同構和路由同構等。

資料同構:對于相同的虛拟 DOM 元素,在服務端使用 renderToNodeStream 把渲染結果以“流“的形式塞給 response 對象,這樣就不用等到 html 都渲染出來才能給浏覽器端傳回結果,“流”的作用就是有多少内容給多少内容,能夠進一步改進了“第一次有意義的渲染時間”。同時,在浏覽器端,使用 hydrate 把虛拟 dom 渲染為真實的 DOM 元素。若浏覽器端對比服務端渲染的元件數,若發生不一緻的情況時,不再直接丢掉全部的内容,而是進行局部的渲染。是以在使用服務端的渲染過程中,要保證前後端元件資料的一緻性。這裡将服務端請求的資料,插入到 js 的全局變量中,随着 html 一起渲染到浏覽器端(脫水);這是在浏覽器端,就可以拿到脫水的資料來初始化元件,添加互動等等(注水)。

狀态同構方面:我們這裡使用

mobx

為每個使用者建立一個全局的狀态管理,這樣資料可以進行統一的管理,而不用元件之間衣岑層傳遞。

元件同構:編寫的基礎元件或其他元件可以在服務端和用戶端都能使用,同時使用

typeof window==='undefined'

process.browser

來判斷目前是用戶端還是服務端,以此來屏蔽某端不支援的操作。

路由統一:用戶端使用

BrowserRouter

,服務端使用

StaticRouter

在同構的過程中,最開始時還沒太了解這個概念,在編碼階段就遇到了這樣的問題。例如我們有個小輪播,這個輪播是将數組打亂随機展示的,我将從服務端請求到的資料打亂後渲染到頁面上,結果調試視窗中輸出一條錯誤資訊(我們這裡用個樣例資料來代替):

render()

中随機輸出:

{
    list.sort(() => (Math.random() < 0.5 ? 1 : -1)).map(item => (
        <p key={item}>{item}</p>
    ));
}                

結果在控制台輸出了警告資訊,同時最終展示出來的資訊并不是打亂排序:

Warning: Text content did not match. Server: "紅包" Client: "答題卡"

輸出的警告資訊是因為用戶端發現目前與服務端的資料不一緻後,用戶端重新進行了渲染,并給出了警告資訊。我們在渲染的時候才把數組打亂順序,服務端是按照打亂順序後的資料渲染的,但是傳遞給用戶端的資料還是原始資料,造成了前後端資料不一緻的問題。

如果真的想要随機排序,可以在擷取服務端的資料後,直接先排好序,然後再渲染,這樣服務端和用戶端的資料就會保持一緻。在 nextjs 中就是

getInitialProps

中操作。

3.2 如何進行資料請求

基于我們項目主要是在新聞用戶端内運作的特點,我們要考慮多種資料請求的方式:服務端、浏覽器端、新聞用戶端内,是否跨域等特點,然後形成一個完整的統一的多終端資料請求體系。

  • 服務端:使用 http 子產品或者 axios 等第三方元件發起 http 請求,并透傳 ua 和 cookie 給接口;
  • 新聞用戶端:使用新聞用戶端提供的 jsapi 發起接口請求,注意 iOS 和 Android 不同 APP 中請求方式的差異;
  • 浏覽器端跨域請求:建立一個 script 标簽發起接口請求,并設定逾時時間;
  • 浏覽器端同域請求:優先使用

    fetch

    ,然後使用

    XMLHttpRequest

    發起接口請求。

這裡将多終端的資料進行封裝,對外提供統一而穩定的調用方式,業務層無需關心目前的請求從哪個終端發起。

// 發起接口請求
// @params {string} url 請求的位址
// @params {object} opts 請求的參數
const request = (url: string, opts: any): Promise<any> => {};                

同時,我們也在請求接口的方法中添加上監控處理,如監控接口的請求量、耗時、失敗率等資訊,做到詳細的資訊記錄,快速地進行定位和相應。

3.3 工程化

工程化是一個很大的概念,我們這裡僅僅從幾個小點上進行說明。

我們的項目目前都是部署在 skte 上,通過設定不同的環境變量來區分目前是測試環境、預釋出環境和正式環境。

同時,因為我們的業務主要是在新聞用戶端内通路的特點,很多的單元測試無法完全覆寫,隻能進行部分的單元測試,確定基礎功能的正常運作。

現在接入了完全自動化的 CI(持續內建)/CD(持續部署),基于 git 分支的方式進行釋出建構,當開發者完成編碼工作後,推送到 test/pre/master 分支後,進行單元測試的校驗,通過後就會自動內建和部署。

3.4 緩存

緩存的優點自不必多說:

  • 加快了浏覽器加載網頁的速度;
  • 減少了備援的資料傳輸,節省網絡流量和帶寬;
  • 減少伺服器的負擔,大大提高了網站的性能。

但同時增加緩存,整體項目的複雜度也會增加,我們需要評估下項目是否适合緩存、适用于哪種緩存機制、緩存失效時如何處理。

緩存的機制主要有:

  1. 浏覽器強緩存或 nginx 緩存:緩存固定的時長,例如 30ms 的時間,在這 30ms 的時間内讀取緩存中的資料,這種緩存的缺點是資料無法及時更新,必須等到緩存時間到後才能更新;
  2. 狀态緩存或全局緩存:這适用于路由之間多次切換或者緩存使用者個性化的資料,隻在單次通路的過程中有效;
  3. 記憶體緩存:将緩存存儲于記憶體中,無需額外的 I/O 開銷,讀寫速度快;但缺點是資料容易失效,一旦程式出現異常時緩存直接丢失,同時記憶體緩存無法達到程序之間的共享。這裡當我們使用浏覽器的協商緩存時,即根據生成的内容産生

    ETag

    值,若 etag 值相同則使用緩存,否則請求伺服器的資料,這就會造成不同程序之間緩存的資料可能不一樣,etag 多次失效的問題。記憶體緩存尤其要注意記憶體洩露的問題
  4. 分布式緩存:使用獨立的第三方緩存,如 Redis 或 Memcached,好處時多個程序之間可以共享,同時減少項目本身對緩存淘汰算法的處理

不同的項目或者不同的頁面采用不同的緩存政策。

  • 不常更新資料的頁面如首頁、排行榜頁面等,可以使用浏覽器強緩存或者接口緩存;
  • 使用者頭像、昵稱、個性化等資料使用狀态管理;
  • 接口資料可以使用第三方緩存

在對接口的資料緩存時,尤其要注意的是接口正常傳回時,才緩存資料,否則交給業務層處理。

同時,在使用緩存的過程中,還注意緩存失效的問題。

緩存失效 含義 解決方案
緩存雪崩 所有的緩存同一時間失效 設定随機的緩存時間
緩存穿透 緩存中不存在,資料庫中也不存在 緩存中設定一個空值,且緩存時間較短
随機 key 請求 惡意地使用随機 key 請求,導緻無法命中緩存 布隆過濾器,未在過濾器中的資料直接攔截
為緩存的 key 緩存中沒有但資料庫中有 請求成功後,緩存資料,并将資料傳回

3.5 日志記錄

詳細的日志記錄能夠讓我們很友善地了解項目效果和排查問題。前後端的表現形式不一樣,我們也區分前後端進行日志的上報。

前端主要上報頁面的性能資訊,服務端主要上報程式的異常、CPU 和記憶體的使用狀況等。

在前端方面,我們可以使用

window.performance

經過簡單的計算得到一些網頁的性能資料:

  • 首次加載耗時: domLoading - fetchStart;
  • 整頁耗時: loadEventEnd - fetchStart;
  • 錯誤率: 錯誤日志量/請求量;
  • DNS 耗時: domainLookupEnd - domainLookupStart;
  • TCP 耗時: connectEnd - connectStart;
  • 後端耗時: responseStart - requestStart;
  • html 耗時: responseEnd - responseStart;
  • DOM 耗時: domContentLoadedEventEnd - responseEnd;

同時我們也需要捕獲前端代碼中的一些報錯:

  1. 全局捕獲,error:
window.addEventListener(
    'error',
    (message, filename, lineNo, colNo, stackError) => {
        console.log(message); // 錯誤資訊的描述
        console.log(filename); // 錯誤所在的檔案
        console.log(lineNo); // 錯誤所在的行号
        console.log(colNo); // 錯誤所在的列号
        console.log(stackError); // 錯誤的堆棧資訊
    }
);                
  1. 全局捕獲,unhandledrejection:

當 Promise 被 reject 且沒有 reject 處理器的時候,會觸發 unhandledrejection 事件;這可能發生在 window 下,但也可能發生在 Worker 中。 這對于調試回退錯誤處理非常有用。

window.addEventListener('unhandledrejection', event => {
    console.log(event);
});                
  1. 接口異步請求時

這裡可以對

fetch

XMLHttpRequest

進行重新的封裝,既不影響正常的業務邏輯,也可以進行錯誤上報。

XMLHttpRequest 的封裝:

const xmlhttp = window.XMLHttpRequest;
const _oldSend = xmlhttp.prototype.send;

xmlhttp.prototype.send = function() {
    if (this['addEventListener']) {
        this['addEventListener']('error', _handleEvent);
        this['addEventListener']('load', _handleEvent);
        this['addEventListener']('abort', _handleEvent);
    } else {
        var _oldStateChange = this['onreadystatechange'];
        this['onreadystatechange'] = function(event) {
            if (this.readyState === 4) {
                _handleEvent(event);
            }
            _oldStateChange && _oldStateChange.apply(this, arguments);
        };
    }
    return _oldSend.apply(this, arguments);
};                

fetch 的封裝:

const oldFetch = window.fetch;
window.fetch = function() {
    return _oldFetch
        .apply(this, arguments)
        .then(res => {
            if (!res.ok) {
                // True if status is HTTP 2xx
                // 上報錯誤
            }
            return res;
        })
        .catch(error => {
            // 上報錯誤
            throw error;
        });
};                

服務端的日志根據嚴重程度,主要可以分為以下的幾個類别:

  1. error: 錯誤,未預料到的問題;
  2. warning: 警告,出現了在預期内的異常,但是項目可以正常運作,整體可控;
  3. info: 正常,正常的資訊記錄;
  4. silly: 不明原因造成的;

我們針對可能出現的異常程度進行不同類别(level)的上報,這裡我們采用了兩種記錄政策,分别使用網絡日志

boss

和本地日志

winston

分别進行記錄。boss 日志裡記錄較為簡單的資訊,友善通過浏覽器進行快速地排查;winston 記錄詳細的本地日志,當通過簡單的日志資訊無法定位時,則使用更為詳細的本地日志進行排查。

使用

winston

進行服務端日志的上報,按照日期進行分類,上報的主要資訊有:目前時間、伺服器、程序 ID、消息、堆棧追蹤等:

// https://github.com/winstonjs/winston
logger = createLogger({
    level: 'info',
    format: combine(label({ label: 'right meow!' }), timestamp(), myFormat), // winston.format.json(),
    defaultMeta: { service: 'user-service' },
    transports: [
        new transports.File({
            filename: `/data/log/question/answer.error.${date.getFullYear()}-${date.getMonth() +
                1}-${date.getDate()}.log`,
            level: 'error'
        })
    ]
});                

同時 nodejs 服務本身的監控機制也充分利用上,例如包括 http 狀态碼,記憶體占用(process.memoryUsage)等。

在日志的統計過程中,加入告警機制,當告警數量或者數值超過一定的範圍,則向開發者的微信和郵箱發出告警資訊和裝置。例如其中的一條告警規則是:當頁面的加載時間小于 10ms 或者超過 6000ms 則發出告警資訊,小于 10ms 時說明頁面挂掉了,大于 6000ms 說明伺服器可能出現異常,導緻資源加載時間過長。

同時也要及時地關注使用者回報平台,若産生了一個使用者的回報,必然是有更多的使用者存在這樣的問題。

3.6 容災處理

日志記錄和告警等都是事故發生後才産生的行為,我們應當如何保證在我們看到日志資訊并修複問題之前的這段時間裡,服務至少能夠還是是正常運作的,而不是白屏或者 5xx 等資訊。這裡我們要做的就是線上服務的容災處理。

可能存在的問題 容災措施
後端接口異常 使用預設資料,并及時告知接口方
瞬時流量高、CPU 負載率過高 自動擴容,并告警
node 服務異常,如 4xx,5xx 等 nginx 自動将服務轉向靜态頁面,并告警轉發的次數
靜态資源導緻的樣式異常 将首屏或者首頁的樣式嵌入到頁面中

容災處理與日志資訊的記錄,保障我們項目能夠正常地線上上運作。

3.7 cluster 子產品

nodejs 作為一種單線程、單程序運作的程式,如果隻是簡單的使用的話(

node app.js

),存在着如下一些問題:

  • 無法充分利用多核 cpu 機器的性能,
  • 服務不穩定,一個未處理的異常都會導緻整個程式退出
  • 沒有成熟的日志管理方案、
  • 沒有服務/程序監控機制

所幸,nodejs 為我們提供了

cluster

子產品,什麼是cluster:

簡單的說,

  • 在伺服器上同時啟動多個程序。
  • 每個程序裡都跑的是同一份源代碼(好比把以前一個程序的工作分給多個程序去做)。
  • 更神奇的是,這些程序可以同時監聽一個端口(Cluster 實作原理)。

其中:

  • 負責啟動其他程序的叫做 Master 程序,他好比是個『包工頭』,不做具體的工作,隻負責啟動其他程序。
  • 其他被啟動的叫 Worker 程序,顧名思義就是幹活的『勞工』。它們接收請求,對外提供服務。
  • Worker 程序的數量一般根據伺服器的 CPU 核數來定,這樣就可以完美利用多核資源。

cluster 子產品可以建立共享伺服器端口的子程序。這裡舉一個著名的官方案例:

const cluster = require('cluster');
const http = require('http');
const os = require('os');

if (cluster.isMaster) {
    // 目前為主程序
    console.log(`主程序 ${process.pid} 正在運作`);

    // 啟動子程序
    for (let i = 0, len = os.cpus().length; i < len; i++) {
        cluster.fork();
    }

    cluster.on('exit', worker => {
        console.log(`子程序 ${worker.process.pid} 已退出`);
    });
} else {
    http.createServer((req, res) => {
        res.writeHead(200);
        res.end('hello world\n');
    }).listen(8000);

    console.log(`子程序 ${process.pid} 已啟動`);
}                

當有程序退出時,則會觸發

exit

事件,例如我們 kill 掉 69030 的程序時:

> kill -9 69030

子程序 69030 已退出
           

我們嘗試 kill 掉某個程序,發現子程序是不會自動重新建立的,這裡我可以修改下

exit

事件,當觸發這個事件後重新建立一個子程序:

cluster.on('exit', worker => {
    console.log(`子程序 ${worker.process.pid} 已退出`);
    // log日志記錄

    cluster.fork();
});                

主程序與子程序之間的通信:每個程序之間是互相獨立的,可是每個程序都可以與主程序進行通信。這樣就能把很多需要每個子程序都需要處理的問題,放到主程序裡處理,例如日志記錄、緩存等。我們在 3.4 緩存小節中也有講“記憶體緩存無法達到程序之間的共享”,可是我們可以把緩存提高到主程序中進行緩存。

if (cluster.isMaster) {
    Object.values(cluster.workers).forEach(worker => {
        // 向所有的程序都釋出一條消息
        worker.send({ timestamp: Date.now() });

        // 接收目前worker發送的消息
        worker.on('message', msg => {
            console.log(
                `主程序接收到 ${worker.process.pid} 的消息:` +
                    JSON.stringify(msg)
            );
        });
    });
} else {
    process.on('message', msg => {
        console.log(`子程序 ${process.pid} 擷取資訊:${JSON.stringify(msg)}`);
        process.send({
            timestamp: msg.timestamp,
            random: Math.random()
        });
    });
}                

不過若線上生産環境使用的話,我們需要給這套代碼添加很多的邏輯。這裡可以使用

pm2

來維護我們的 node 項目,同時 pm2 也能啟用 cluster 模式。

pm2 的官網是http://pm2.keymetrics.io,github 是https://github.com/Unitech/pm2。主要特點有:

  • 原生的叢集化支援(使用 Node cluster 叢集子產品)
  • 記錄應用重新開機的次數和時間
  • 背景 daemon 模式運作
  • 0 秒停機重載,非常适合程式更新
  • 停止不穩定的程序(避免無限循環)
  • 控制台監控
  • 實時集中 log 處理
  • 強健的 API,包含遠端控制和實時的接口 API ( Nodejs 子產品,允許和 PM2 程序管理器互動 )
  • 退出時自動殺死程序
  • 内置支援開機啟動(支援衆多 linux 發行版和 macos)

nodejs 服務的工作都可以托管給 pm2 處理。

pm2 以目前最大的 CPU 數量啟動 cluster 模式:

pm2 start server.js -i max
           

不過我們的項目使用配置檔案來啟動的,ecosystem.config.js:

module.exports = {
    apps: [
        {
            name: 'question',
            script: 'server.js',
            instances: 'max',
            exec_mode: 'cluster',
            autorestart: true,
            watch: false,
            max_memory_restart: '1G',
            env_test: {
                NEXT_APP_ENV: 'testing'
            },
            env_pre: {
                NEXT_APP_ENV: 'pre'
            },
            env: {
                NEXT_APP_ENV: 'production'
            }
        }
    ]
};                

然後啟動即可:

pm2 start ecosystem.config.js
           

關于使用 node 來編寫 cluster 模式,還是用 pm2 來啟動 cluster 模式,還是要看項目的需要。使用 node 編寫時,自己可以控制各個程序之間的通信,讓每個程序做自己的事情;而 pm2 來啟動的話,在整體健壯性上更好一些。

3.8 性能優化

我們應當首先保證首頁和首屏的加載,一個是首屏需要的樣式直接嵌入到頁面中加載,再一個是首屏和次屏的資料分開加載。我們在首頁的資料主要是瀑布流的方式加載,而瀑布流是需要 js 計算的,是以這裡我們先加載幾條資料,保證首屏是有資料的,然後接下來的資料使用 js 計算應當放在哪個位置。

再一個是使用 service worker 來本地緩存 css 和 js 資源,更具體的使用,可以通路service worker 在新聞紅包活動中的應用。

這裡我們使用 IntersectionObserver 封裝了通用的元件懶加載方案,因為在使用 scroll 事件中,我們可能還需要手動節流和防抖動,同時,因為圖檔加載的快慢,導緻需要多次擷取元素的 offsetTop 值。而 IntersectionObserver 就能完美地避免這些問題,同時,我們也能看到,這一屬性在高版本浏覽器中也得到了支援,在低版本浏覽器中,我可以使用 polyfill 的方式進行相容處理處理;

我将這個功能封裝為一個元件,對外提供幾個監聽方法,将需要懶加載的元件或者資源作為子元件,進行包裹,同時,我們這裡也建議建議使用者,使用預設的骨架屏撐起元素未渲染時的頁面。因為在直接使用懶加載渲染時,假如不使用骨架屏的話,使用者是先看到白屏,然後突然渲染内容,頁面給使用者一種強烈抖動的感覺。真實元件在最後真正展示出來時,需要一定的時間和空間,時間是從資源加載到渲染完畢需要時間;而空間指的是頁面布局中需要給真實元件留出一定的問題,一個是為了避免頁面,再一個使用骨架屏後:

  1. 提升使用者的感覺體驗
  2. 保證切換的一緻性
  3. 提供可見性觀察的目标對象,為執行懶加載的元件保證可見性的區域

這裡實作的通用懶加載元件,對外提供了幾個回調方法:onInPage, onOutPage, onInited 等。

這個通用的元件懶加載方案可以使用在如下的場景下:

  1. 懶加載的粒度可大可小,大到 1 個元件或者幾個元件,小到一個圖檔即可;
  2. 頁面子產品曝光率的資料上報,這樣可以計算子產品從曝光到參與的一個漏鬥資料;
  3. 長清單中的無限滾動:我們可以監聽頁面底部的一個透明元素,當這個透明元素即将可見時,加載并渲染下一頁的資料。

當然,長清單無限滾動的優先,不僅限于使用可見性代替滾動事件,也還有其他的優化手段。

4. 總結

雖然啰裡啰嗦了一大堆,但也這是我們同構直出渲染方案的開始,我們還有很長的路要走。應用型技術的難點不是在克服技術問題,而是在于能夠不斷的結合自身的産品體驗,發現其中存在的體驗問題,不斷使用更好的技術方案去優化使用者的體驗,為整個産品發展添磚加瓦。

蚊子的前端部落格連結: https://www.xiabingbao.com 。