我們的業務在展開的程序中,前端渲染的模式主要經歷了三個階段:服務端渲染、前端渲染和目前的同構直出渲染方案,
服務端渲染的主要特點是前后端沒有分離,前端寫完頁面樣式和結構后,再將頁面交給后端套資料,最后再一起聯調,同時前端的發布也依賴于后端的同學;但是優點也很明顯:頁面渲染速度快,同時 SEO 效果好,
為了解決前后端沒有分離的問題,后來就出現了前端渲染的這種模式,路由選擇和頁面渲染,全部放在前端進行,前后端通過介面進行互動,各端可以更加專注自己的業務,發布時也是獨立發布,但缺點是頁面渲染慢,嚴重依賴 js 檔案的加載速度,當 js 檔案加載失敗或者 CDN 出現波動時,頁面會直接掛掉,我們之前大部分的業務都是前端渲染的模式,有部分的用戶反饋頁面 loading 時間長,頁面渲染速度慢,尤其是在老舊的 Android 機型上這個問題更加地明顯,
node同構直出渲染方案可以避免服務端渲染和前端渲染存在的缺點,同時前后端都是用 js 寫的,能夠實作資料、組件、工具方法等能實作前后端的共享,
1. 效果
首先來看下統計資料的結果,可以看到從前端渲染模式切換到 node 同構直出渲染模式后,整頁的加載耗時從 3500ms 降低到了 2100 毫秒左右,整體的加載速度提高了將近 40%,

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

從 3500ms 降低到 1600ms,整整加快了 1900ms 的加載速度,整體提升了 54%,優化的手段在稍后也會講解到,
2. 遇到的挑戰
在進行同構直出渲染方案,也對目前存在的技術,并結合自身的技術堆疊,對整體的架構進行梳理,

梳理出接下來存在的重點和難點:
- 如何保持資料、路由、狀態、基礎組件的同構共用?如何區分客戶端和服務端?
- 如何進行資料請求,是否存在跨域的請求?在服務端、瀏覽器端和新聞客戶端內都是怎樣進行資料請求的,各自都有什么特點,是否可以封裝一下?
- 工程化:如何區分開發環境、測驗環境、預發布環境和正式環境?單元測驗如何執行?是否可以自動化發布?
- 專案的頁面有什么特點,頁面、介面資料、組件等是否可以快取?如何進行快取?是否存在個性化的資料?
- 如何記錄日志,上報專案的性能資料,如請求量、前端頁面加載的整頁耗時、錯誤率、后端耗時等資料?如何在 node 服務出現例外時(如負載過高、記憶體泄露)進行告警?
- 如何進行容災處理,當出現例外情況時如何降級,并告知開發者快速的修復!
- node 是單執行緒運行,如何充分利用多核?
- 性能優化:預加載、圖片懶加載、使用 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,
在同構的程序中,最開始時還沒太理解這個概念,在編碼階段就遇到了這樣的問題,例如我們有個小輪播,這個輪播是將陣列打亂隨機展示的,我將從服務端請求到的資料打亂后渲染到頁面上,結果除錯視窗中輸出一條錯誤資訊(我們這里用個樣例資料來代替):
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 快取
快取的優點自不必多說:
- 加快了瀏覽器加載網頁的速度;
- 減少了冗余的資料傳輸,節省網路流量和帶寬;
- 減少服務器的負擔,大大提高了網站的性能,
但同時增加快取,整體專案的復雜度也會增加,我們需要評估下專案是否適合快取、適用于哪種快取機制、快取失效時如何處理,
快取的機制主要有:
- 瀏覽器強快取或 nginx 快取:快取固定的時長,例如 30ms 的時間,在這 30ms 的時間內讀取快取中的資料,這種快取的缺點是資料無法及時更新,必須等到快取時間到后才能更新;
- 狀態快取或全局快取:這適用于路由之間多次切換或者快取用戶個性化的資料,只在單次訪問的程序中有效;
- 記憶體快取:將快取存盤于記憶體中,無需額外的 I/O 開銷,讀寫速度快;但缺點是資料容易失效,一旦程式出現例外時快取直接丟失,同時記憶體快取無法達到行程之間的共享,這里當我們使用瀏覽器的協商快取時,即根據生成的內容產生
ETag值,若 etag 值相同則使用快取,否則請求服務器的資料,這就會造成不同行程之間快取的資料可能不一樣,etag 多次失效的問題,記憶體快取尤其要注意記憶體泄露的問題 - 分布式快取:使用獨立的第三方快取,如 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;
同時我們也需要捕獲前端代碼中的一些報錯:
- 全域捕獲,error:
window.addEventListener(
'error',
(message, filename, lineNo, colNo, stackError) => {
console.log(message); // 錯誤資訊的描述
console.log(filename); // 錯誤所在的檔案
console.log(lineNo); // 錯誤所在的行號
console.log(colNo); // 錯誤所在的列號
console.log(stackError); // 錯誤的堆疊資訊
}
);
- 全域捕獲,unhandledrejection:
當 Promise 被 reject 且沒有 reject 處理器的時候,會觸發 unhandledrejection 事件;這可能發生在 window 下,但也可能發生在 Worker 中, 這對于除錯回退錯誤處理非常有用,
window.addEventListener('unhandledrejection', event => {
console.log(event);
});
- 介面異步請求時
這里可以對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;
});
};
服務端的日志根據嚴重程度,主要可以分為以下的幾個類別:
- error: 錯誤,未預料到的問題;
- warning: 警告,出現了在預期內的例外,但是專案可以正常運行,整體可控;
- info: 常規,正常的資訊記錄;
- 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 的方式進行兼容處理處理;

我將這個功能封裝為一個組件,對外提供幾個監聽方法,將需要懶加載的組件或者資源作為子組件,進行包裹,同時,我們這里也建議建議使用者,使用默認的骨架屏撐起元素未渲染時的頁面,因為在直接使用懶加載渲染時,假如不使用骨架屏的話,用戶是先看到白屏,然后突然渲染內容,頁面給用戶一種強烈抖動的感覺,真實組件在最后真正展示出來時,需要一定的時間和空間,時間是從資源加載到渲染完畢需要時間;而空間指的是頁面布局中需要給真實組件留出一定的問題,一個是為了避免頁面,再一個使用骨架屏后:
- 提升用戶的感知體驗
- 保證切換的一致性
- 提供可見性觀察的目標物件,為執行懶加載的組件保證可見性的區域
這里實作的通用懶加載組件,對外提供了幾個回呼方法:onInPage, onOutPage, onInited 等,
這個通用的組件懶加載方案可以使用在如下的場景下:
- 懶加載的粒度可大可小,大到 1 個組件或者幾個組件,小到一個圖片即可;
- 頁面模塊曝光率的資料上報,這樣可以計算模塊從曝光到參與的一個漏斗資料;
- 長串列中的無限滾動:我們可以監聽頁面底部的一個透明元素,當這個透明元素即將可見時,加載并渲染下一頁的資料,
當然,長串列無限滾動的優先,不僅限于使用可見性代替滾動事件,也還有其他的優化手段,
4. 總結
雖然啰里啰嗦了一大堆,但也這是我們同構直出渲染方案的開始,我們還有很長的路要走,應用型技術的難點不是在克服技術問題,而是在于能夠不斷的結合自身的產品體驗,發現其中存在的體驗問題,不斷使用更好的技術方案去優化用戶的體驗,為整個產品發展添磚加瓦,

蚊子的前端博客鏈接: https://www.xiabingbao.com ,
歡迎關注我的微信公眾號: wenzichel

轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/180934.html
標籤:JavaScript
上一篇:JavaScript 日期格式
下一篇:JS 物件屬性名排序
