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

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

服務端渲染的主要特色是先後端沒有分離,前端寫完頁面樣式和結構後,再將頁面交給後端套數據,最後再一塊兒聯調。同時前端的發佈也依賴於後端的同窗;可是優勢也很明顯:頁面渲染速度快,同時 SEO 效果好。css

爲了解決先後端沒有分離的問題,後來就出現了前端渲染的這種模式,路由選擇和頁面渲染,所有放在前端進行。先後端經過接口進行交互,各端能夠更加專一本身的業務,發佈時也是獨立發佈。但缺點是頁面渲染慢,嚴重依賴 js 文件的加載速度,當 js 文件加載失敗或者 CDN 出現波動時,頁面會直接掛掉。咱們以前大部分的業務都是前端渲染的模式,有部分的用戶反饋頁面 loading 時間長,頁面渲染速度慢,尤爲是在老舊的 Android 機型上這個問題更加地明顯。html

node同構直出渲染方案能夠避免服務端渲染和前端渲染存在的缺點,同時先後端都是用 js 寫的,可以實現數據、組件、工具方法等能實現先後端的共享。前端

1. 效果

首先來看下統計數據的結果,能夠看到從前端渲染模式切換到 node 同構直出渲染模式後,整頁的加載耗時從 3500ms 下降到了 2100 毫秒左右,總體的加載速度提升了將近 40%。java

優化效果1-同構直出方案-蚊子前端博客

但這個數據也不是最終的數據,由於當時要趕着上線的時間,不少東西還沒來及優化,在後續的優化完成後,能夠看到總體的的加載耗時又降低到了 1600ms 左右,再次降低了 500ms 左右。node

優化效果2-同構直出方案-蚊子前端博客

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

2. 遇到的挑戰

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

技術選型-同構直出方案-蚊子前端博客

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

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

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

3. 功能實現

3.1 先後端的同構

使用 node 服務端同構指出渲染方案,最主要的是數據等結構可以實現先後端的同構共享

同構方面主要是實現:數據同構、狀態同構、組件同構和路由同構等。

數據同構:對於相同的虛擬 DOM 元素,在服務端使用 renderToNodeStream 把渲染結果以「流「的形式塞給 response 對象,這樣就不用等到 html 都渲染出來才能給瀏覽器端返回結果,「流」的做用就是有多少內容給多少內容,可以進一步改進了「第一次有意義的渲染時間」。同時,在瀏覽器端,使用 hydrate 把虛擬 dom 渲染爲真實的 DOM 元素。若瀏覽器端對比服務端渲染的組件數,若發生不一致的狀況時,再也不直接丟掉所有的內容,而是進行局部的渲染。所以在使用服務端的渲染過程當中,要保證先後端組件數據的一致性。這裏將服務端請求的數據,插入到 js 的全局變量中,隨着 html 一塊兒渲染到瀏覽器端(脫水);這是在瀏覽器端,就能夠拿到脫水的數據來初始化組件,添加交互等等(注水)。

狀態同構方面:咱們這裏使用mobx爲每一個用戶建立一個全局的狀態管理,這樣數據能夠進行統一的管理,而不用組件之間衣岑層傳遞。

組件同構:編寫的基礎組件或其餘組件能夠在服務端和客戶端都能使用,同時使用typeof window==='undefined'process.browser來判斷當前是客戶端仍是服務端,以此來屏蔽某端不支持的操做。

路由統一:客戶端使用BrowserRouter,服務端使用StaticRouter

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

const list = ['勳章', '答題卡', '達人榜', '紅包', '公告'];

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. 接口異步請求時

這裏能夠對fetchXMLHttpRequest進行從新的封裝,既不影響正常的業務邏輯,也能夠進行錯誤上報。

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 的方式進行兼容處理處理;

IntersectionObserver-同構直出方案-蚊子前端博客

我將這個功能封裝爲一個組件,對外提供幾個監聽方法,將須要懶加載的組件或者資源做爲子組件,進行包裹,同時,咱們這裏也建議建議使用者,使用默認的骨架屏撐起元素未渲染時的頁面。由於在直接使用懶加載渲染時,假如不使用骨架屏的話,用戶是先看到白屏,而後忽然渲染內容,頁面給用戶一種強烈抖動的感受。真實組件在最後真正展現出來時,須要必定的時間和空間,時間是從資源加載到渲染完畢須要時間;而空間指的是頁面佈局中須要給真實組件留出必定的問題,一個是爲了不頁面,再一個使用骨架屏後:

  1. 提高用戶的感知體驗
  2. 保證切換的一致性
  3. 提供可見性觀察的目標對象,爲執行懶加載的組件保證可見性的區域

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

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

  1. 懶加載的粒度可大可小,大到 1 個組件或者幾個組件,小到一個圖片便可;
  2. 頁面模塊曝光率的數據上報,這樣能夠計算模塊從曝光到參與的一個漏斗數據;
  3. 長列表中的無限滾動:咱們能夠監聽頁面底部的一個透明元素,當這個透明元素即將可見時,加載並渲染下一頁的數據。

固然,長列表無限滾動的優先,不只限於使用可見性代替滾動事件,也還有其餘的優化手段。

4. 總結

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

node同構直出渲染方案-蚊子前端博客

蚊子的前端博客連接: https://www.xiabingbao.com

歡迎關注個人微信公衆號: wenzichel
微信公衆號-蚊子前端博客

相關文章
相關標籤/搜索