0. 前言
騰訊文檔列表頁在不久前經歷了一次完全重構后,首屏速度其實已經是不錯。但是我們仍然可以引入 SSR 來進一步加快速度。這篇文章就是用來記錄和整理我最近實現 SSR 遇到的一些問題和思考。雖然其中有一些基礎設施可能和騰訊或文檔強相關,但是作為一篇涉及 Node
、React 組件
、性能
、網絡
、docker 鏡像
、云上部署
、灰度和發布
等內容的文章,仍然可以小小地作為參考或者相似需求的 Checklist。
就是這樣一個頁面,內部邏輯復雜,優秀的重構同學做到了組件盡可能地復用,未壓縮的編譯后開發代碼仍然有 14W 行,因此也不算標題黨了。
1. 整體流程
1.1 CSR
我們回顧 CSR(客戶端渲染)的流程
- 一個 React 應用,通常我們把 CSS 放在 head,有個 React 應用掛載的根節點空標簽,以及 React 應用編譯后的主體文件。瀏覽器在加載 HTML 后,加載 CSS 和 JS,到這時候為止,瀏覽器呈現給用戶的仍然是個空白的頁面。
- < 紅色箭頭部分> JS 開始執行,狀態管理會初始化個 store,會先拿這個 store 去渲染頁面,這時候頁面開始渲染元素(白屏時間結束)。但是還沒有列表的詳細信息,也沒有頭像、用戶名那些信息。
- 初始化 store 后會發起異步的 CGI 請求,在請求回來后會更新 store,觸發 React 重新渲染頁面,綁定事件,整個頁面完全呈現(首屏時間結束)。
1.2 SSR
- < 綠色箭頭部分> 首先我們復用原來的 React 組件編譯出可以在 Node 環境下運行的文件,并且部署一個 Node 服務。
- < 藍色箭頭部分> 在瀏覽器發起 HTML 請求時,我們的 Node 服務會接收到請求??梢詮恼埱罄锶〕?HTTP 頭部,Cookie 等信息。運行對應的 JS 文件,初始化 store,發起 CGI 請求填充數據,調用 React 渲染 DOM 節點(這里和 CSR 的差異在于我們得等 CGI 請求回來數據改變后再渲染,也就是需要的數據都準備好了再渲染)。
- 將渲染的 DOM 節點插入到原 React 應用根節點的內部,同時將 store 以全局變量的形式注入到文檔里,返回最終的頁面給瀏覽器。瀏覽器在拿到頁面后,加上原來的 CSS,在 JS 下載下來之前,就已經能夠渲染出完整的頁面了(白屏時間結束、首屏時間結束)。
- < 紅色箭頭部分> JS 開始執行,拿服務端注入的數據初始化 store,渲染頁面,綁定事件(可交互時間結束)(這里其實后面可能還有一些 CGI,因為有一些 CGI 是不適合放在服務端的,且不影響首頁直出的頁面,會放在客戶端上加快首屏速度。這里的一個優化點在于我們將盡量避免在服務端有串行的 CGI 存在,比如需要先發起一個 CGI,等結果返回后才發起另外一個 CGI,因為這會將 SSR 完全拖垮一個 CGI 的速度)。
2. 入口文件
2.1 服務端入口文件
要把代碼在 Node 下跑起來,首先要編譯出文件來。除了原來的 CSR 代碼外,我們創建一個 Node 端的入口文件,引入 CSR 的 React 組件。
(async () => {
const store = useStore();
await Promise.all([
store.dispatch.user.requestGetUserInfo(),
store.dispatch.list.refreshRecentOpenList(),
]);
const initialState = store.getState();
const initPropsDataHtml = getStateScriptTag(initialState);
const bodyHtml = ReactDOMServer.renderToString(
<Provider store={store}>
<ServerIndex />
</Provider>
);
// 回調函數,將結果返回的
TSRCALL.tsrRenderCallback(false, bodyHtml + initPropsDataHtml);
})();
服務端的 store,Provider, reducer,ServerIndex 等都是復用的客戶端的,這里的結構和以下客戶端渲染的一致,只不過多了 renderToString
以及將結果返回的兩部分。
2.2 客戶端入口文件
相應的,客戶端的入口文件做一點改動:
export default function App() {
const initialState = window.__initial_state__ || undefined;
const store = useStore(initialState);
// 額外判斷數據是否完整的
const { getUserInfo, recentList } = isNeedToDispatchCGI(store);
useEffect(() => {
Promise.race([
getUserInfo && store.dispatch.user.requestGetUserInfo(),
store.dispatch.notification.requestGetNotifyNum(),
]).finally(async () => {
store.dispatch.banner.requestGetUserGrowthBanner();
recentList && store.dispatch.list.requestRecentOpenList();
});
}, []);
}
主要是復用服務端注入到全局變量的數據以及 CGI 是否需要重發的判斷。
2.3 代碼編譯
將服務端的代碼編譯成 Node 下運行的文件,最主要的就是設置 webpack 的 target: 'node'
,以及為了在復用的代碼里區分服務端還是客戶端,會注入編譯變量。
new webpack.DefinePlugin({
__SERVER__: (process.env.RENDER_ENV === 'server'),
})
其他的大部分保持和客戶端的編譯配置一樣就 OK 了,一些細微的調整后面會說到。
3. 代碼改造
將代碼編譯出來,但是先不管跑起來能否結果一致,能不報錯大致跑出個 DOM 節點來又是另外一回事。
3.1 運行時差異
首先擺在我們前面的問題在于瀏覽器端和 Node 端運行環境的差異。就最基本的,window
,document
在 Node 端是沒有的,相應的,它們以下的好多方法就不能使用。 我們當然可以選擇使用 jsdom
來模擬瀏覽器環境,以下是一個 demo:
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const { window } = new JSDOM(``, {
url: 'http://localhost',
});
global.localStorage = window.localStorage;
localStorage.setItem('AlloyTeam', 'NB');
console.log(localStorage.getItem('AlloyTeam'));
// NB
但是當我使用的時候,有遇到不支持的 API,就需要去補 API。且在 Node 端跑預期之外的代碼,生成的是否是預期的結果也是存疑,工作量也會較大,因此我選擇用編譯變量來屏蔽不支持的代碼,以及在全局環境下注入很有限的變量(vm + context)。
3.2 非必需依賴
對于不支持 Node 環境的依賴模塊來說,比如瀏覽器端的上報庫,統一的打開窗口的庫,模塊動態加載庫等,對首頁直出是不需要的,可以選擇配置 alias
并使用空函數代替防止調用報錯或 ts 檢查報錯。
alias: {
src: path.resolve(projectDir, 'src'),
'@tencent/tencent-doc-report': getRewriteModule('./tencent-doc-report.ts'),
'@tencent/tencent_doc_open_url': getRewriteModule('./tencent-doc-open-url.ts'),
'script-loader': getRewriteModule('./script-loader.ts'),
'@tencent/docs-scenario-components-message-center': getRewriteModule('./message-center.ts'),
'@tencent/toggle-client-js': getRewriteModule('./tencent-client-js.ts'),
},
例如里面的 script-loader(模塊加載器,用來動態創建 <script>
標簽注入 JS 模塊的),整個模塊屏蔽掉。
const anyFunc = (...args: any[]) => {};
export const ScriptLoader = {
init: anyFunc,
load: anyFunc,
listen: anyFunc,
dispatch: anyFunc,
loadRemote: anyFunc,
loadModule: anyFunc,
};
3.3 必需依賴
對于必需的依賴但是又不支持 Node 環境的,也只能是推動兼容一下。整個過程來說只有遇到兩個內部模塊是不支持的,兼容工作很小。對于社區成熟的庫,很多都是支持 Node 下環境的。
比如組件庫里默認的掛載點,在默認導出里使用 document.body
,只要多一下判斷就可以了。
3.4 不支持的方法
舉一些不支持方法的案例:
像這種在組件渲染完成后注冊可見性事件的,明顯在服務端是不需要的,直接屏蔽就可以了。
export const registerOnceVisibilityChange = () => {
if (__SERVER__) {
return;
}
if (onVisibilityChange) {
removeVisibilityChange(onVisibilityChange);
}
};
useLayoutEffect
在服務端不支持,也應該屏蔽。但是需要看一下是否需要會有影響的邏輯。比如有個組件是 Slide,它的功能就像是頁簽,在子組件掛載后,切換子組件的顯示。在服務端上明顯是沒有 DOM 掛載后的回調的,因此在服務端就需要改成直接渲染要顯示的子組件就可以了。
export default function TransitionView({ visible = false, ...props }: TransitionViewProps) {
if (!__SERVER__) {
useLayoutEffect(() => {
}, [visible, props.duration]);
useLayoutEffect(() => {
}, [_visible]);
}
}
useMemo
方法在服務端也不支持。
export function useStore(initialState?: RootStore) {
if (__SERVER__) {
return initializeStore(initialState);
}
return useMemo(() => initializeStore(initialState), [initialState]);
}
總的來說使用屏蔽的方法,加上注入的有限的全局變量,其實屏蔽的邏輯不多。對于引入 jsdom 來說,結果可控,工作量又小。
3.5 基礎組件庫 DUI
對于要直出一個 React 應用,基礎組件庫的支持是至關重要的。騰訊文檔里使用自己開發的 DUI
組件庫,因為之前沒有 SSR 的需求,所以雖然代碼里有一些支持 Node 環境的邏輯,但是還不完善。
3.5.1 后渲染組件
有一些組件需要在鼠標動作或者函數式調用才渲染的,比如 Tooltip
,Dropdown
,Menu
,Modal
組件等。在特定動作后才渲染子組件。在服務端上,并不會觸發這些動作,就可以用空組件代替。(理想情況當然是組件里原生支持 Node 環境,但是有五六個組件需要支持,就先在業務里去兼容,也算給組件庫提供個思路)
以 Tooltip 為例,這樣可以支持組件同時運行在服務端和客戶端,這里還補充了 className
,是因為發現這個組件的根節點設置的樣式會影響子組件的顯示,因此加上。
import { Tooltip as BrowserTooltip } from '@tencent/dui/lib/components/Tooltip';
import { ITooltipProps } from './interface';
function ServerTooltip(props: ITooltipProps) {
// 目前知道這個 tooltip 的樣式會影響,因此加上 dui 的樣式
return (
<div className="dui-trigger dui-tooltip dui-tooltip-wrapper">
{props.children}
</div>
);
}
const Tooltip = __SERVER__ ? ServerTooltip : BrowserTooltip;
export default Tooltip;
3.5.2 動態插入樣式
DUI
組件會在第一次運行的時候會將對應組件的樣式使用 <style>
標簽動態插入。但是當我們在服務端渲染,是沒有節點讓它插入樣式的。因此是在 vm 里提供了一些全局方法,供運行代碼可以在文檔的指定位置插入內容。需要注意的是我們首屏可能只用到了幾個組件,但是如果把所有的組件樣式都插到文檔里,文檔將會變大不少,因此還需要過濾一下。
if (isBrowser) {
const styleElement = document.createElement('style');
styleElement.setAttribute('type', 'text/css');
styleElement.setAttribute('data-dui-key', key);
styleElement.innerText = css;
document.head.appendChild(styleElement);
} else if (typeof injectContentBeforeRoot === 'function') {
const styleElement = `<style type="text/css" data-dui-key="${key}">${css}</style>`;
injectContentBeforeRoot(styleElement);
}
同時組件用來在全局環境下管理版本號的方法,也需要抹平瀏覽器端和 Node 端的差異(這里其實還可以實現將 window.__dui_style_registry__
注入到文檔里,客戶端從全局變量取出,實現復用)。
class StyleRegistryManage {
nodeRegistry: Record<string, string[]> = {};
constructor() {
if (isBrowser && !window.__dui_style_registry__) {
window.__dui_style_registry__ = {};
}
}
// 這里才是重點,在不同的端存儲的地方不一樣
public get registry() {
if (isBrowser) {
return window.__dui_style_registry__;
} else {
return this.nodeRegistry;
}
}
public get length() {
return Object.keys(this.registry).length;
}
public set(key: string, bundledsBy: string[]) {
this.registry[key] = bundledsBy;
}
public get(key: string) {
return this.registry[key];
}
public add(key: string, bundledBy: string) {
if (!this.registry[key]) {
this.registry[key] = [];
}
this.registry[key].push(bundledBy);
}
}
3.6 公用組件庫 UserAgent
騰訊文檔里封裝了公用的判斷代碼運行環境的組件庫 UserAgent
。雖然自執行的模塊在架構設計上會帶來混亂,因為很有可能隨著調用地方的增多,你完全不知道模塊在什么樣的時機被以什么樣的值初始化。對于 SSR 來說就很怕這種自執行的邏輯,因為如果模塊里有不支持 Node 環境的代碼,意味著你要么得改模塊,要么不用,而不能只是屏蔽初始化。
但是這個庫仍然得支持自執行,因為這個被引用得如此廣泛,而且假設你要 ua.isMobile
這樣使用,難道得每個文件內都 const ua = new UserAgent()
嗎?這個庫原來讀取了 window.navigator.userAgent
,為了里面的函數仍然能準確地判斷運行環境,在 vm 虛擬機里通過讀取 HTTP 頭,提供了 global.navigator.userAgent
,在模塊內兼容了這種情況。
3.7 客戶端存儲
有個場景是列表頭有個篩選器,當用戶篩選了后,會將篩選選項存在 localStorage
,刷新頁面后,仍然保留篩選項。對于這個場景,在服務端直出的頁面當然也是需要篩選項這個信息的,否則就會出現直出的頁面已經呈現給用戶后。但是我們在服務端如何知道 localStorage
的值呢?換個方式想,如果我們在設置 localStorage
的時候,同步設置 localStorage
和 cookie
,服務端從 cookie 取值是否就可以了。
class ServerStorage {
getItem(key: string) {
if (__SERVER__) {
return getCookie(key);
}
return localStorage.getItem(key);
}
setItem(key: string, value: string) {
if (__SERVER__) {
return;
}
localStorage.setItem(key, value);
setCookie(key, value, 365);
}
}
還有個場景是基于文件夾來存儲的,即用戶當前處于哪個文件夾下,就存儲當前文件夾下的篩選器。如果像客戶端一樣每個文件夾都存的話,勢必會在 cookie
里制造很多不必要的信息。為什么說不必要?因為其實服務端只關心上一次文件夾的篩選器,而不關心其他文件夾的,因為它只需要直出上次文件夾的內容就可以了。因此這種邏輯我們就可以特殊處理,用同一個 key
來存儲上次文件夾的信息。在切換文件夾的時候,設置當前文件夾的篩選器到 cookie 里。
3.8 虛擬列表
3.8.1 react-virtualized
騰訊文檔列表頁為了提高滾動性能,使用 react-virtualized
組件。而且為了支持動態高度,還使用了 AutoSizer
, CellMeasurer
等組件。這些組件需要瀏覽器寬高等信息來動態計算列表項的高度。但是在服務端上,我們是無法知道瀏覽器的寬高的,導致渲染的列表高度是 0。
3.8.2 Client Hints
雖然有項新技術 Client Hints
可以讓服務端知道屏幕寬度,視口寬度和設備像素比 (DPR),但是瀏覽器的支持度并不好。
即使有 polyfill,用 JS 讀取這些信息,存在 cookie 里。但是我們想如果用戶第一次訪問呢?勢必會沒有這些信息。再者即使是移動端寬高固定的情況,如果是旋轉屏幕呢?更不用說 PC 端可以隨意調節瀏覽器寬高了。因此這完全不是完美的解決方案。
3.8.3 使用 CSS 自適應
如果我們將虛擬列表渲染的項單獨渲染而不通過虛擬列表,用 CSS 自適應寬高呢?反正首屏直出的情況下是沒有交互能力的,也就沒有滾動加載列表的情況。甚至因為首屏不可滾動,我們在移動端還可以減少首屏列表項的數目以此來減少 CGI 數據。
function VirtualListServer<T>(props: VirtualListProps<T>) {
return (
<div className="pc-virtual-list">
{
props.list.map((item, index) => (props.itemRenderer && props.itemRenderer(props.list[index], index)))
}
{!props.bottomText
? null
: <div className="pc-virtual-list-loading" style={{ height: 60 }}>
{props.bottomText}
</div>
}
</div>
);
}
const VirtualList = __SERVER__ ? VirtualListServer : VirtualListClient;
3.9 不可序列化對象
本來這個小章節算是原 CSR 代碼里實現的問題,但是涉及的邏輯較多,因此也只是在運用數據前來做轉換。
前面說過我們會往文檔里以全局變量的方式注入 state,怎么注入?其實就是用 JSON.stringify
將 state 序列化成字符串,如果這時候 state 里包含了函數呢?那么函數就會丟失。(不過看到下一小章節你會發現 serialize-javascript
是有保留函數的選項的,只是我覺得 state 應該是純數據,正確的做法應該是將函數從 state 里移除,兩種方式自由取舍吧)
例如這里的 pageRange,里面包含了 add
,getNext
等方法,在數據注入到客戶端后,就只剩下純數據:
const getDefaultList = () => ({
list: [],
loading: true,
section: false,
allObtained: false,
pageRange: new PageRange({ start: -listLimit, limit: listLimit }),
scrollTop: 0,
});
在客戶端使用的時候,還需要將 pageRange 轉成新的實例:
export function pageRangeTransform(opt: PageRange) {
if (typeof opt.add === 'function') {
return opt;
}
return new PageRange(opt);
}
3.10 引用類型的 state
還遇到一個比較有趣的問題如下圖:
- db 是一個內存上的數據對象,用來存儲列表等相關的數據的,而 state 里的列表其實只是 db 里的一個引用;
- 在更新列表數據的時候,發送了 CGI,其實是更新了 db 里的列表數據;
- 在更新列表項是否可編輯的數據的時候,其實也是更改的 db 里的數據,然后通過一個 forceTime 來強制 state 更新視圖;
這對于加入了 SSR 的 CSR 來說會有幾點問題:
- 因為我們復用了服務端注入的數據,省去了 CGI 的步驟,在客戶端上也就沒有往 db 里添加列表數據;
- state 里的列表數據不再是引用的 db 里的數據,因此更新 forceTime,是強制不了 state 更新視圖的;
兩個典型的 Bug(代碼里寫了注釋,應該不用再解釋了):
/*
* 如果有 preloadState,需要調用 db 來設置一下數據。有一個問題是:
* 1. CSR 列表的 0-30 的數據是通過 API 拉取的,在 API 里通過 db 設置了 0-30 的數據
* 2. SSR 0-30 的數據是通過 preloadedState 注入到客戶端的,沒有通過 db 設置 0-30 的數據
* 3. 列表往下拉的時候,通過 CGI 拉取 30-60 的數據,這時候通過 db 合并,會丟失 0-30 的數據
*/
if (preloadedState) {
const db = getDBSingleton();
if (preloadedState.list && preloadedState.list.recent) {
const transedList = transformForInitialState(preloadedState.list.recent.list);
preloadedState.list.recent.list = db.register(ListTypeInStore.recent, transedList);
}
}
if (preloadedState.folderUI && preloadedState.folderUI.viewStack.length) {
const folderData = preloadedState.folderUI.viewStack[0];
const { folderID, list } = folderData;
if (list && list.length) {
/*
* 為什么要用 db.register 返回的 list 重新賦值?因為客戶端上的 state 引用的是 db 里的數據,在調用
* forceUpdate 的時候只是更新了個時間,如果這里不保持一致,在調用 forceUpdate 的時候就不會更新了。
* 典型的 Bug,按了右鍵重命名無效
*/
folderData.list = registerDBForInitialState(folderID, transformForInitialState(list));
}
}
3.11 安全
使用字符串拼接的方式插入初始化的 state,需要轉義而避免 xss 攻擊。我們可以使用 serialize-javascript
庫來轉義數據。
import serialize from 'serialize-javascript';
export function injectDataToClient(key: string, data: any) {
const serializedData = serialize(data, {
isJSON: true,
ignoreFunction: true,
});
return `<script>window["${key}"] = ${serializedData}</script>`;
}
export function getStateScriptTag(initialState: any) {
return injectDataToClient('__initial_state__', initialState);
};
3.12 服務端路由
對于單頁面來說,使用 react-router
來管理路由,服務端也需要直出相對于的組件。需要做的只是將路由組件換成 StaticRouter
,通過 localtion
提供頁面地址和 context
存儲上下文 。
import { StaticRouter as Router } from 'react-router-dom';
(async () => {
const routerContext = {};
const bodyHtml = ReactDOMServer.renderToString(
<Router basename={'/desktop'} location={TSRENV.href} context={routerContext} >
<Provider store={store}>
<ServerIndex />
</Provider>
</Router>
);
})();
4. 運行環境
4.1 網絡
4.1.1 網絡請求
當瀏覽器發起 CGI 請求,形如 https://docs.qq.com/cgi-bin/xxx,不僅需要解析 DNS,還需要建立 HTTPS 鏈接,還要再經過公司的統一網關接入層。如果我們的 SSR 服務同部署在騰訊云上,是否有請求出去繞一圈再繞回來的感覺?因為我們的服務都接入了 L5(服務發現和負載均衡),那么我們可以通過解析 L5 獲得 IP 和端口,以 HTTP 發起請求。
以兼容 L5 的北極星 SDK 來解析(cl5 需要依賴環境,在我使用的基礎鏡像 tlinux-mini 上會有錯誤)。
PS: Axios 發送 HTTPS 請求會報錯,因此在 Node 端換成了 Got,方便本地開發。
const { Consumer } = require('@tencent/polaris');
const consumer = new Consumer();
async function resolve(namespace, service) {
const response = await consumer.select(namespace, service);
if (response) {
const {
instance: { host, port },
} = response;
return `${host}:${port}`;
}
return null;
}
需要注意的是,北極星的第一次解析比較耗時,大概 200ms
的樣子,因此應該在應用啟動的時候就調用解析一次,后續再解析就會是 1~3ms
了。
這里還有個點是我們應該請求哪個 L5?假設有兩個 CGI,doclist 和 userInfo,我們是解析它們各自的 L5,通過 OIDB 的協議請求嗎?考慮三個方面:
- 這里詢問了文檔后臺,通過 OIDB 并沒有比通過 HTTP 協議快多少;
- 我們需要一直維護 CGI 和 L5 的對應關系,如果后臺重構,信息同步不到位,換了新的 L5,服務將會掛掉;
- 沒有更新 xsrf 的邏輯;
好在文檔還有個統一的接入層 tsw,因此我們其實只需要解析接入層 tsw 的 L5,將請求都發往它就可以了。
4.1.2 cookie
在 SSR 代發起 CGI 請求,不僅需要從請求取出客戶端傳遞過來的 cookie 來使用,在我們的 tsw 服務上,還會驗證 csrf,因此 SSR 發出 CGI 請求后,可能 tsw 會更新 csrf,因此還需要將 CGI 請求返回的 set-cookie
再設置回客戶端。
const setCookie = require('set-cookie-parser');
function setCookies(cookis) {
const parsedCookies = setCookie.parse(cookis || []) || [];
if (ctx.headerSent) {
return;
}
parsedCookies.map((cookieInfo) => {
const { name, value, path, domain, expires, secure, httpOnly, sameSite } = cookieInfo;
try {
ctx.cookies.set(name, value, {
overwrite: true,
path,
domain,
expires,
secure,
httpOnly,
sameSite,
});
} catch (err) {
logger.error(err);
}
});
}
overwrite
設置為 true 是因為當我們有多個 CGI 請求,所返回的同名 set-cookie
如果不覆蓋的話,會使得 SSR 返回的 HTTP 頭很大。
還要說說 secure
參數。這個參數表示 cookie 是否只能是 HTTPS 下傳輸。我們的應用是在 tsw 服務之后的,一般來講也都會在 nginx 之后以 http 提供服務。那么我們就設置不了這個 secure
參數。如果要設置的話,需要有兩步:
-
初始化 koa 的時候,設置 proxy;
const app = new Koa({ proxy: true })
-
koa 前面的代理設置
X-Forwarded-Proto
頭部,表明是工作在 HTTPS 模式下;
但是實際上在我的服務里沒有收到這個頭部,因此仍然會報錯,由于我們沒法去改 tsw,也很清楚地知道我們是工作在代理之后,有個解決方案:
this.app.use(async (ctx, next) => {
ctx.cookies.secure = true;
await next();
});
4.2 并發和上下文隔離
我們來考慮這樣一種情況:
當有兩個請求 A 和 B 一前一后到達 Server,在經過一大串的異步邏輯之后。到達后面的那個處理邏輯的時候,它怎么知道它在處理哪個請求?方法當然是有:
- 把 koa 的
ctx
一層一層傳遞,只要有涉及到具體請求的函數,都傳遞一下ctx
(是不是瘋狂?); - 或者把 ctx 存在 state 里,需要
ctx
的話從 state 里?。ㄏ炔徽f這違反了 state 里應該放純數據的原則,如果是一些工具函數呢?比如getCookie
這樣的函數,讓它的 cookie 從哪里???想想是不是頭大?);
因此我們需要想個辦法,將 A 和 B 的請求隔離開來。
4.2.1 cluster 和 worker
如果說要隔離請求,我們可以有 cluster
模塊提供進程粒度的隔離,也可以通過 worker_threads
模塊提供線程粒度的隔離。但是難道我們一個進程和一個線程同時只能處理一個請求,只有一個請求完全返回結果后才能處理下一個嗎?這顯然是不可能的。
但是為了下面的錯誤捕獲問題,我確實用 worker_threads
+ vm
嘗試了好幾種方法,雖然最后都放棄了。并且因為使用 worker_threads
可以共享線程數據的優點在這個場景下并沒有多大的應用場景,反而是 cluster
可以共享 TCP 端口,最后是用 cluster
+ vm
,不過這是后話了。
4.2.2 domain
上下文隔離的技術,從 QQ 空間團隊 tsw 那里學了個比較騷的方法,主要有兩個關鍵點:
- process.domain 總是指向當前異步流程的上下文,因此可以將需要的數據掛載到 process.domian 上;
- 用
Object.defineProperty
設置數據的 getter 和 setter 函數,保證操作到的是 process.domain 上的對應數據;
用簡短的代碼演示就是這樣的:
const domain = require('domain');
Object.defineProperty(global, 'myVar', {
get: () => process.domain.myVar,
set: (value) => {
process.domain.myVar = value;
},
});
const handler = (label) => {
setTimeout(() => {
console.log(`${label}: ${global.myVar}`);
}, (1 + Math.random() * 4) * 1000);
};
for (let i = 0; i < 3; i++) {
const d = domain.create();
d.run(() => {
global.myVar = i;
handler(`test-${i}`);
});
}
// test-1: 1
// test-0: 0
// test-2: 2
但是這個方案存在什么樣的問題?
- domain 沒法保證雖然對象在它 run 函數里初始化,process.domain 一定有值,也可能是
undefined
; - requre 過的文件,被 cache 了,需要執行清除緩存的操作,重新 require。雖然可以用 defineProperty 來定義值,但是如果有的模塊是
const moduleVar = global.myVar; module.exports = moduleVar;
沒有重新執行的話,導出的值將是錯誤的;
4.3 vm
上下文隔離,我們還可以用 vm 來做。(然后我們的挑戰就變成了怎么把十幾萬行的代碼放在 vm 里跑,為什么需要把十幾萬行代碼都放進去?因為后面會說到被 require 的模塊里訪問 global 的問題,雖然后面的后面解決了這個問題)
vm 的一個基本使用姿勢是這樣的:
const vm = require('vm');
const code = 'console.log(myVar)';
vm.runInNewContext(code, {
myVar: 'AlloyTeam',
console,
});
// AlloyTeam
功能是不是很像 eval?,使用 eval 的話:
let num = 1;
eval('num ++');
console.log(num);
// 2
使用 Function 的話:
/**
* @file function.js
*/
global.num = 1;
(new Function('step', 'num += step'))(1);
console.log(num);
// node function.js
// >2
細心的讀者可能會發現,Function
的例子里,我寫的是 global.num = 1
而不是 let num = 1
,這是為什么?
- 由
Function
構造器創建的函數不會創建當前環境的閉包,而是被創建在全局環境里; - 我這里的代碼寫在
function.js
文件里,是當做一個模塊被運行的,是在模塊的作用域里; - 基于以上 2 點,
Function
里的代碼能訪問到的變量就是global
和它的局部變量step
,如果寫成let num = 1
將會報錯;
使用 evel 和 Function 可以做到嗎?感覺理論上像是可以的,假設我們給每個請求分配 ID,使用 Object.defineProperty 來定義數據的存取。但是我沒有試過,而是使用成熟的 vm 模塊,好奇的讀者可以試一下。
另外因為我們并沒有運行外部的代碼,要在 vm 里跑的都是業務代碼,因此不關心 vm 的進程逃逸問題,如果有這方面擔憂的可以用 vm2。
4.3.1 global
我們在 Node 環境下訪問全局變量,有兩種方式:
(() => {
a = 1;
global.b = 2;
})();
console.log(a);
console.log(b);
// 1
// 2
而在 vm 里,是沒有 global 的,考察以下代碼:
const vm = require('vm');
global.a = 1;
const code = `
console.log(typeof global);
console.log(typeof a);
`;
vm.runInNewContext(code, {
console,
});
// undefined
// undefined
因此假設我們要支持代碼里能夠以 global.myVar
和 myVar
兩種方式來訪問上下文里的全局變量的話,就要構造出一個 global 變量。
上下文的全局變量默認是空的,不僅 global 沒有,還有一些函數也沒有,我們來看看最終構造出的上下文是都有什么:
async getVMContext(renderJSFile) {
const pathInfo = path.parse(renderJSFile);
// 模塊系統的變量
const moduleGlobal = {
__filename: renderJSFile,
__dirname: pathInfo.dir,
};
const commonContext = {
Buffer,
process,
console,
require,
exports,
module,
};
/* 業務上定義的的全局對象,運行的時候會重新賦值
* {
* window: undefined,
* navigator: {
* userAgent: '',
* },
* location: {
* search: '',
* },
* }
*/
const browserGlobal = renderConfig.vmGlobal(renderJSFile);
return vm.createContext({
...commonContext,
...moduleGlobal,
...global,
...browserGlobal,
// 重寫 global 循環變量
global: {
...browserGlobal,
},
});
}
4.3.2 require
前面說到 vm 的上下文默認是空的,然后我們給它傳遞了 module,exports,require,那么它能 require 外部模塊了,但是被 require 的模塊如果訪問 global,會是 vm 里我們創建的 global,還是宿主環境下的 global 呢?
我們有個文件 vm-global-required.js
是要被 require 的:
const myVar = global.myVar;
console.log('[required-file]:', myVar);
我們還有個文件是宿主環境:
const vm = require('vm');
global.myVar = 1;
const code = `
console.log("[vm-host]:", global.myVar);
require('./vm-global-required');
`;
vm.runInNewContext(code, {
global: {
myVar: 2,
},
console,
require,
});
運行代碼,結果是:
// [vm-host]: 2
// [required-file]: 1
可以看到被 require 的模塊所訪問的 global 并不是 vm 定義的上下文,而是宿主環境的 global。
4.3.3 代碼編譯緩存
以 vm 創建的代碼沙箱是需要編譯的,我們不可能每個請求過來都重復編譯,因此可以在啟動的時候就提前編譯緩存:
compilerVMByFile(renderJSFile) {
const scriptContent = fileManage.getJSContent(renderJSFile);
if (!scriptContent) {
return;
}
const scriptInstance = new vm.Script(scriptContent, {
filename: renderJSFile,
});
return scriptInstance;
}
getVMInstance(renderJSFile) {
if (!this.vmInstanceCache[renderJSFile]) {
const vmInstance = this.compilerVMByFile(renderJSFile);
this.vmInstanceCache[renderJSFile] = vmInstance;
}
return this.vmInstanceCache[renderJSFile];
}
但是其實 v8 編譯是不編譯函數體的,好在可以設置一下:
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');
(編譯部分還嘗試過 createCachedData
,可以詳見以下錯誤捕獲的使用 filename 章節)
4.3.4 超時
vm 運行的時候可以設置 timeout
參數控制超時,當超過時間后會報錯:
const vm = require('vm');
const vmFunc = new vm.Script(`
while(1) {}
`);
try {
vmFunc.runInNewContext({
http,
console,
}, {
timeout: 100,
})
} catch (err) {
console.log('vm-timeout');
}
// vm-timeout
但是它的超時真的有效嗎?我們來做個試驗。如以下代碼:
- 設置了
timeout
是 100; - 用 process 監聽了錯誤,如果超時觸發了錯誤,process 就會捕獲到錯誤輸出出來;
/timeout-get
在 2000ms 后才返回結果;
const Koa = require('koa');
const Router = require('koa-router');
const vm = require('vm');
const http = require('http');
const app = new Koa();
const router = new Router();
router.get('/timeout-get', async (ctx) => {
await new Promise((resolve) => {
setTimeout(() => {
ctx.body = 'OK';
resolve();
}, 2000);
});
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000);
process.on('unhandledRejection', (err) => {
console.log('unhandledRejection', err);
});
process.on('uncaughtException', (err) => {
console.log('uncaughtException', err);
});
console.time('http-cost');
const vmFunc = new vm.Script(`
http.get('http://127.0.0.1:3000/timeout-get', (res) => {
const { statusCode } = res;
console.log('statusCode:', statusCode);
console.timeEnd('http-cost');
process.exit(0);
})`
);
vmFunc.runInNewContext({
http,
console,
process,
}, {
timeout: 100,
microtaskMode: 'afterEvaluate',
})
console.log('vm-executed');
輸出結果是什么呢?
vm-executed
statusCode: 200
http-cost: 2016.098ms
說明 vm 的這個 timeout 參數在我們的場景下是不一定有效的,因此我們還需要在宿主環境額外設置超時返回。
4.4 錯誤捕獲
我們的 SSR 和普通的后臺服務最大的區別在于什么?我想是在于我們不允許返回空內容。后臺的 CGI 服務在錯誤的時候,返回個錯誤碼,有前端來以更友好的方式展示錯誤信息。但是 SSR 的服務,即使錯誤了,也需要返回內容給用戶,否則就是白屏。因此錯誤的捕獲顯得尤為重要。
總結一下背景的話:
- vm 所執行的代碼可能來自于第三方,但是整個項目是提供基礎鏡像,第三方基于鏡像自行部署的,因此不關心 vm 里的代碼安全問題,不用用到 vm2
- vm 里的代碼是有可能出錯的,錯誤可能來自于同步代碼、異步代碼或者未處理的 Promise 錯誤
- vm 代碼是異步并行的,假設每次執行 vm 代碼都有一個 id
- vm 里的代碼即使出錯,也必須要知道是哪個 id 的 vm 代碼執行出錯了,來執行兜底的策略
4.4.1 process 捕獲
在 node 里,如果要捕獲未知的錯誤,我們當然可以用 process 來捕獲
process.on('unhandledRejection', (err) => {
// do something
});
process.on('uncaughtException', (err) => {
// do something
});
這代碼不僅可以捕獲同步、異步錯誤,也能捕獲 Promise 錯誤。但同時,我們從 err 對象上也獲取不了出錯時候的上下文信息。像背景里的要求,就不知道是哪個 id 的 vm 出錯了
4.4.2 try...catch
如果以 vm 來執行代碼的話,我們大可以在代碼的外部包裹 try...catch 來捕獲異常??聪旅娴睦?,try...catch 捕獲到了錯誤,錯誤就沒再冒泡到 process。
const vm = require('vm');
process.on('uncaughtException', (err) => {
console.log('[uncaughtException]:', err);
});
const script = new vm.Script(`
try {
throw new Error('from vm')
} catch (err) {
console.log(err)
}
`);
script.runInNewContext({ Error, console });
// Error: from vm
// at evalmachine.<anonymous>:3:15
4.4.3 異步錯誤
改寫上面的例子,將錯誤在異步函數里拋出,try...catch 捕獲不到錯誤,錯誤冒泡到 process,被 uncaughtException 事件捕獲到
const vm = require('vm');
process.on('uncaughtException', (err) => {
console.log('[uncaughtException]:', err);
});
process.on('unhandledRejection', (err) => {
console.log('[unhandledRejection]:', err);
});
const script = new vm.Script(`
try {
setTimeout(() => {
throw new Error('from vm')
})
} catch (err) {
console.log(err)
}
`);
script.runInNewContext({ Error, console, setTimeout });
// [uncaughtException]: Error: from vm
// at Timeout._onTimeout (evalmachine.<anonymous>:4:19)
那有什么辦法捕獲異步錯誤嗎?辦法還是有的,node 里有個 domain 模塊,可以用來捕獲異步錯誤。(雖然已經標記為廢棄狀態,但是已經用 async_hooks 重寫了,意味著即使真的被廢棄,也能自己實現一個)
繼續改寫上面的例子,將 vm 放在 domain 里執行,可以看到錯誤被 domain 捕獲到了
const vm = require('vm');
const domain = require('domain');
process.on('uncaughtException', (err) => {
console.log('[uncaughtException]:', err);
});
process.on('unhandledRejection', (err) => {
console.log('[unhandledRejection]:', err);
});
const script = new vm.Script(`
try {
setTimeout(() => {
throw new Error('from vm')
})
} catch (err) {
console.log(err)
}
`);
const d = domain.create();
d.on('error', (err) => {
console.log('[domain-error]:', err);
});
d.run(() => {
script.runInNewContext({ Error, console, setTimeout });
});
// [domain-error]: Error: from vm
// at Timeout._onTimeout (evalmachine.<anonymous>:4:19)
4.4.4 Promise 錯誤
但是假如將上一個例子的 vm 代碼改成 Promise 執行呢?domain 捕獲不到錯誤,錯誤冒泡到 process 上
const vm = require('vm');
const domain = require('domain');
process.on('uncaughtException', (err) => {
console.log('[uncaughtException]:', err);
});
process.on('unhandledRejection', (err) => {
console.log('[unhandledRejection]:', err);
});
const script = new vm.Script(`
Promise.resolve().then(() => {
throw new Error('notExistPromiseFunc')
})
`);
const d = domain.create();
d.on('error', (err) => {
console.log('[domain-error]:', err);
});
d.run(() => {
script.runInNewContext({ Error, console, setTimeout });
});
// [unhandledRejection]: Error: notExistPromiseFunc
// at evalmachine.<anonymous>:3:15
為什么?node 文檔里是這么說的
Domains will not interfere with the error handling mechanisms for promises. In other words, no
'error'
event will be emitted for unhandledPromise
rejections.
那有什么辦法嗎?這里想了兩個比較騷的寫法。
4.4.4.1 使用 filename
我們知道 vm 在執行的時候,是可以提供一個 filename
屬性,在錯誤的時候,會被添加到錯誤堆棧內。默認值是 'evalmachine.<anonymous>'
也就是我們上面的錯誤經??吹降牡诙写a錯誤的位置。這就帶來了操作的空間。
const vm = require('vm');
const markStart = '<vm-error>';
const markEnd = '</vm-error>';
const getContext = () =>
vm.createContext({
console,
process,
setTimeout,
});
const parseErrorStack = (err) => {
const errorStr = err.stack;
const valueStart = errorStr.indexOf(markStart);
const valueEnd = errorStr.lastIndexOf(markEnd);
if (valueStart !== -1 && valueEnd !== -1) {
return errorStr.slice(valueStart + markStart.length, valueEnd);
}
console.log('[parse-error]');
return null;
};
process.on('unhandledRejection', (err) => {
console.log('[unhandledRejection]:', parseErrorStack(err));
});
process.on('uncaughtException', (err) => {
console.log('[uncaughtException]:', parseErrorStack(err));
});
const getScript = (flag) => {
const filename = `${markStart}${flag}${markEnd}`;
return new vm.Script(
`
(() => {
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('${flag}'));
}, 100)
})
})()
`,
{ filename }
);
};
(async () => {
for (let i = 0; i < 3; i++) {
await getScript(i).runInContext(getContext());
}
})();
// [unhandledRejection]: 0
// [unhandledRejection]: 1
// [unhandledRejection]: 2
看下上面的代碼結構,我們做了幾件事:
- 在 vm 代碼編譯的時候,以
vm-error
標識符標記了我們要傳遞到錯誤堆棧的值 - 在 process 捕獲 Promise 錯誤
- 在 process 捕獲到 Promise 錯誤的時候,從錯誤堆棧上根據標識符解析出我們要的值
但是這樣的代碼存在什么問題?
最主要的問題在于 filename 是編譯進去的,即使生成 v8 代碼緩存的 Buffer,后面用這個 Buffer 來編譯一個新的 script 實例,傳遞進新的 filename,仍然改變不了之前的值。所以會帶來代碼每次都需要編譯的成本。
我們可以來實踐以下:
const vm = require('vm');
require('v8').setFlagsFromString('--no-lazy');
const markStart = '<vm-error>';
const markEnd = '</vm-error>';
const getContext = myVar => vm.createContext({
console,
process,
setTimeout,
myVar,
});
const parseErrorStack = (err) => {
const errorStr = err.stack;
const valueStart = errorStr.indexOf(markStart);
const valueEnd = errorStr.lastIndexOf(markEnd);
if (valueStart !== -1 && valueEnd !== -1) {
return errorStr.slice(valueStart + markStart.length, valueEnd);
}
console.log('[parse-error]');
return null;
};
process.on('unhandledRejection', (err) => {
console.log('[unhandledRejection]:', parseErrorStack(err));
});
process.on('uncaughtException', (err) => {
console.log('[uncaughtException]:', parseErrorStack(err));
});
const getFileName = flag => `${markStart}${flag}${markEnd}`;
const code = `
(() => {
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error(myVar));
}, 100)
})
})()
`;
const scriptCache = new vm.Script(code, {
filename: getFileName(-1),
});
const scriptCachedData = scriptCache.createCachedData();
const getScript = flag => new vm.Script(' '.repeat(code.length), {
filename: getFileName(flag),
cachedData: scriptCachedData,
});
(async () => {
for (let i = 0; i < 3; i++) {
await getScript(i).runInContext(getContext(i));
}
})();
看上面的代碼,對比上一個例子,主要有這幾個改動:
- 緩存了 vm 代碼編譯后的實例,filename 設置的 -1
- 循環內的 flag 標志是通過 myVar 注入到 vm 的全局變量,在 vm 里 throw 這個 flag 錯誤值的
- 循環內的 vm 執行,filename 設置的 0 - 3
結果:編譯后的代碼實例并不會因為使用 cachedData 重新編譯后,filename 就會被改變,因此就無法使用 cacheData + filename 的方式來既要減少編譯時間又想要自定義錯誤堆棧。
4.4.4.2 重寫 Promise
當我們想同步和異步代碼都能捕獲得到,那么只剩下 Promise 錯誤了。什么情況會報 Promise 未處理的錯誤呢?也就是沒有寫 catch 的情況。那么如果我們改寫 Promise ,將每個 Promise 都加上一個默認的 catch 函數,是否能達到期望呢?
const vm = require('vm');
let processFlag;
process.on('unhandledRejection', (err) => {
console.log('[unhandledRejection-processFlag]:', processFlag);
});
const getVMPromise = (flag) => {
const vmPromise = function (...args) {
const p = new Promise(...args);
p.then(
() => {},
(err) => {
processFlag = flag;
throw err;
}
);
return p;
};
['then', 'catch', 'finally', 'all', 'race', 'allSettled', 'any', 'resolve', 'reject', 'try'].map((key) => {
if (Promise[key]) {
vmPromise[key] = Promise[key];
}
});
return vmPromise;
};
const getContext = (flag) =>
vm.createContext({
Promise: getVMPromise(flag),
console,
setTimeout,
});
const getScript = (flag) => {
return new vm.Script(`
new Promise((resolve, reject) => {
setTimeout(() => {
console.log("[vm-current-task]:", "${flag}");
reject()
}, (1 + Math.random() * 4) * 1000);
})
`);
};
for (let i = 0; i < 3; i++) {
getScript(i).runInContext(getContext(i));
}
// [vm-current-task]: 0
// [unhandledRejection-processFlag]: 0
// [vm-current-task]: 2
// [unhandledRejection-processFlag]: 2
// [vm-current-task]: 1
// [unhandledRejection-processFlag]: 1
考察以上的代碼,我們做了這些事:
- 改寫了 Promise,在 Promise 添加了第一個 then 方法來處理錯誤
- 在自定義的 Promise 的第一個 then 方法里存儲了當前異步任務的上下文
- 將自定義的 Promise 當做全局變量傳遞給 vm
結果:在一個隨機的任務 ID 上,成功在 process 上捕獲到了上下文的信息。(但是 Promise 實現的精華在于 then 之后的鏈式調用,這在上面的代碼是沒有體現的。)
4.4.5 必要性思考
重寫 Promise 的方案可行嗎?看起來是可行的,但其實最后也沒有用這個方案(其實是我還沒實施。。。)。因為假設我一個 32 核的 Pod,fork 出 32 個進程處理請求,平均分到每個進程的請求同一時間也不會很多。而出錯是應該在編碼和系統測試就應該避免的,或者自動化測試,或者生成骨架屏時避免。如果要同時捕獲這三個錯誤,需要在異步代碼都使用 domain 捕獲(可能會有性能問題)和 Promise 記錄上下文。其實我們可以在出錯的時候將當前進程所處理的所有請求直接返回原文檔,回退到無 SSR 的狀態。(不過 Promise 的方案仍然值得研究嘗試一下,會發大篇幅也是因為之前陷進去了這個問題,研究了好一段時間)
4.5 重定向
登錄態的問題和文檔強相關,但是仍然想要拋出來和大家探討一下重定向的這個問題。
騰訊文檔的登錄態在前端是無法完全判斷的,只有兩種最基本的情況前端是知道沒有登錄態:
- 沒有 cookie;
- cookie 里沒有 uid 和 uid_key;
如果是登錄態過期,那么只能是在發起 CGI 請求,后臺返回具體的錯誤碼之后才知道。所以 CSR 的登錄是在列表頁顯示,并且正常渲染的情況下,發現 CGI 有具體的登錄態錯誤碼了,就動態加載登錄模塊來提醒用戶登錄。整個的效果就是這樣的:
4.5.1 rewrite
當我們引入了 SSR 后,發送 CGI 請求遇到特定的登錄態錯誤碼我們是知道的。那么我們為什么不直接返回登錄頁就可以了呢?很簡單,直接 ctx.redirect 302
重定向到登錄頁就可以了,但是問題來了:
- 我們的 PC 端沒有獨立的登錄頁,是用動態加載模塊的方式來在當前頁面展示登錄框的;
- 需要處理 URL 跳轉的問題,不僅是從外部跳轉過來的帶有登錄態的 URL,還要處理登陸完后的 URL 跳轉問題;
- 登錄的模塊在其他的庫,就需要去改到那個庫發布才可以;
有沒有更好的方法呢?
- 我們另外做一個很簡單的 login 頁面,這個頁面只用來做一件事,復用原來的代碼在這個頁面動態加載登錄模塊;
- 如果用戶登錄態有效,返回請求的頁面,如果登錄態失效,就讀取 login 頁面的內容返回;
這樣就做到了不用更改登錄模塊邏輯,也不會更改到鏈接地址,也就不用處理 URL 跳轉的問題。
但是需要注意的是,因為以下會提到同時接入 SSR 服務和原 Nginx 服務,因此如果要不改變現網表現的話,login 頁面不應該被發到 Nginx 機器上。類似的還有獨立密碼的登錄頁。
這樣實現的效果就是:
4.5.2 redirect
像上面的登錄態問題,在移動端上有獨立的登錄頁,那么我們就只需要用 ctx.redirect
使用 302 跳轉到對應的頁面就 OK 了。相似的應用場景還有如果是 PC 端訪問了移動端的 URL 地址,或者移動端訪問了 PC 端的地址,需要讀取 UA 來判斷訪問端和 URL 地址,跳轉到對應的頁面。
4.5.3 小程序登錄態
要額外提到的小程序登錄態是因為,小程序是通過小程序殼登錄,再將登錄態附加在 webviewer
里的 URL 地址上,由前端解析 URL 地址來種登錄態的。這意味著小程序登錄后,SSR 的 cookie 里是沒有登錄態的,發起 CGI 請求就會報錯。所以我們就需要做兩件事:
- 從 URL 上解析登錄態,將登錄態信息附加到當次請求的 cookie 里,保證當次請求不會出錯,也不會因為沒有登錄態重復跳到登錄頁;
- 設置新的具有登錄態的 cookie 到客戶端;
const appendAndSetCookie = (ctx, key, value) => {
const oldCookie = ctx.header.cookie || '';
ctx.header.cookie = `${oldCookie}${oldCookie.endsWith(';') ? '' : ';'}${key}=${value};`;
ctx.cookies.set(key, value);
};
5. 骨架屏
5.1 基本實現
回顧整個生成首屏頁面的流程:
- 創建 redux 的 store;
- 發送 CGI 填充 store 數據;
- 以 store 的數據為基礎渲染 react 應用;
除了發送 CGI 這一步需要在線上環境,在用戶瀏覽器發起請求時由 SSR Server 代理請求外,空的 store 和以空的 store 渲染出 React 應用,是我們在編譯期間就可以確定的。那么我們就可以很方便地獲得一個骨架屏,而所需要做的在原來 SSR 的基礎上只有幾步:
-
創建一個空的 ctx,以復用原來的 SSR 邏輯:
const generateCTX = (renderJSFile, renderHtmlFile) => ({ headers: [], url: '', body: '', renderJSFile, renderHtmlFile, originalUrl: '', request: { href: '', }, });
-
傳遞給應用標識當前是生成骨架屏邏輯,應用里不發送 CGI:
if (!TSRENV.isSkeleton) { await Promise.all([ store.dispatch.user.requestGetUserInfo(), store.dispatch.list.refreshRecentOpenList(), ]); }
-
將生成的 HTML 寫入原文檔:
if (renderConfig.skeleton.writeToFile) { fileManage.writeHtmlFile(renderHtmlFile); }
但是這里我們考慮應該以怎樣的方式來寫入。假設原來是將
<div id="root"><div id="server-render"></div></div>
里的server-render
整個標簽(包括 div)替換成渲染后的文檔(為什么原來不也是用注釋的方式?因為很可能編譯后會被去掉注釋)。那么我們生成的骨架屏也將這個替換掉的話,后續 SSR 找不到這個標簽。如果插入在這個標簽里面的話,顯然骨架屏生成的 DOM 在層級上和 SSR 生成的 DOM 是不一樣的。這里我們可以借助注釋。原來的文檔:
骨架屏文檔(編譯完 CSR 后再生成):
SSR 后文檔:
我們能獲得的將是具有頁面框架的靜態文檔。傳統的 React 應用需要在 React 加載下來后渲染才有頁面元素,而骨架屏將在 DOM 直接返回頁面的時候就已經有內容。這在我們假設 SSR 錯誤后,返回未直出文檔的情況下,也比原來的返回空白頁面觀感上好很多?;蛘呶覀儗㈩愃频倪壿嬤w移到其他的頁面上,即使不做 SSR,也可以在靜態編譯的時候生成骨架屏,在幾十毫秒內就能結束白屏時間。
5.2 白屏時間思考
我們引入了 SSR,好處當然是首屏時間會大大降低,但是同時白屏時間會增加。有辦法解決嗎?
- < 紅色箭頭部分> 瀏覽器請求前端 HTML 頁面,Server 返回骨架屏,同時在骨架屏內注入 CGI 請求的 JS。這樣我們可以以近乎靜態資源請求的速度獲得極低的白屏時間;
- < 藍色箭頭部分> Server 在返回骨架屏的同時,開始 SSR 的渲染;
- < 綠色箭頭部分> 注入到骨架屏的 JS 開始發起 CGI 請求,這個請求不是到后端的 Go Server,而是到我們的 SSR Server,SSR 返回渲染后的 DOM 節點字符串,前端直接注入到頁面渲染;
這個方案能給我們帶來什么?
- 極低的白屏時間;
- 相對于 SSR 更短的響應耗時(但是總的首屏時間會稍微長一點點),因為 SSR 的響應耗時將會減少 Server 返回骨架屏到瀏覽器再次發起 SSR CGI 的時間;
有采用嗎?沒有。因為在重定向部分說到我們有一個比較嚴重的登錄體驗問題,如果使用了這個方案,那么又會變成先訪問了列表頁才出現登錄的問題。而考察現網的數據,訪問了列表頁的大概有 20% 用戶是未登錄狀態,那么我們就不能采用這個方案。但也算是一個研究,供參考。
6. 性能測速
當我們做了 SSR,當然關心能夠給我們的業務帶來多少的性能提升,這里我主要關注這幾點:
6.1 首屏時間
引入 SSR 我們最主要的目的就是為了降低首屏時間。這里因為我們知道列表是最慢也是最主要的頁面資源,因此以列表加載的時間為準。假設沒有引入 SSR,我們的首屏時間是這么算的。以列表第一次渲染的時間為準:
// speed.ts
let hasReport = false;
const openSpeedReport = () => {
if (hasReport) {
return;
}
hasReport = true;
console.log((new Date()).getTime() - performance.timing.navigationStart);
};
// list.tsx
useEffect(() => {
// 因為一開始可能是沒有數據渲染的,所以要判斷列表有數據才計算
if (list.length) {
openSpeedReport();
}
});
如果引入了 SSR,可認為文檔加載完后,首屏時間結束:
const firstScreenCost = performance.timing.responseEnd - performance.timing.navigationStart;
6.2 白屏時間
白屏時間用來表征瀏覽器開始顯示內容的時間。按上節所說,我們用空的 state 渲染了靜態的頁面生成骨架屏。那么可以認為文檔加載下來就結束了白屏時間
const cost = timing.responseEnd - timing.navigationStart;
那么作為對比,如果沒有接入骨架屏,白屏時間以 performance 的 paint 時間為準
try {
const paintTimings = performance.getEntriesByType('paint');
if (paintTimings && paintTimings.length) {
const paintTiming = paintTimings[0] as PerformanceEntry;
return paintTiming.startTime;
}
} catch (err) {}
如果不支持 getEntriesByType
方法的瀏覽器,可以在 JS 開始執行時記錄時間,會有一點偏差,但是偏差很小。
window.performanceData.jsStartRun - timing.navigationStart;
6.3 交互時間
6.3.1 主體可交互時間
對于我們的業務來說,列表是交互的主體,我們更關心的其實是列表可交互的時間。那么這個時間就是列表第一次被渲染后,注冊了事件的時間。這個時間可以認為和沒有引入直出的首屏時間相同,見上方首屏時間。
我們考慮在引入了 SSR 后,這個時間會變長或變短?雖然文檔相應時間變長,導致 JS 加載時間延后,但是文檔加載后,是帶了初始化數據的,這個數據是會比客戶端發起 CGI 請求取回數據來得快的。因此也就意味著列表的渲染時間提前,主體可交互時間變短。
6.3.2 整體可交互時間
在瀏覽器資源加載都完成后,說明達到整體可交互的狀態。
const loadEventAndReport = () => {
const timing = performance.timing;
if (timing.loadEventEnd > 0) {
const cost = timing.loadEventEnd - timing.navigationStart;
console.log(cost);
}
}
if (document.readyState === 'complete') {
// 為了避免調用測速函數的時候,已經加裝完成,不會有 load 事件
loadEventAndReport();
} else {
// window 的 onload 事件結束之后 performance 才有 loadEventEnd 數據
window.addEventListener('load', loadEventAndReport);
}
6.4 HTML 請求耗時
6.4.1 響應耗時
響應耗時也就是 SSR 渲染的耗時,表示從瀏覽器發起請求開始,到開始接收請求結束。這是我們用來觀察 SSR 性能的重要指標。
const htmlResponseCost = performance.timing.responseStart - performance.timing.requestStart;
6.4.2 文檔大小
SSR 因為在文檔里加了渲染后的節點和初始化數據,因此文檔大小會變大。對于文檔大小的變化,那么我們就會關心兩個指標:文檔大小和下載耗時。
計算文檔大?。?/p>
try {
const navigationTimings = performance.getEntriesByType('navigation');
if (navigationTimings && navigationTimings.length) {
const navigationTiming = navigationTimings[0] as PerformanceNavigationTiming;
const size = navigationTiming.encodedBodySize; // 因為開始了 gzip 壓縮,所以關注的是編碼后的大小
console.log(`${(size / 1000).toFixed(2)}KB`);
}
} catch (err) {}
6.4.3 下載耗時
const htmlDownlaodCost = performance.timing.responseEnd - performance.timing.responseStart;
6.5 Node 節點測速
console.time
、console.timeEnd
是我們很經常用來測速某個節點耗時的工具。但是在異步場景下,考察以下的代碼:
const calculate = () => {
console.time('async-time');
setTimeout(() => {
console.timeEnd('async-time');
}, (1 + Math.random() * 10) * 1000);
};
for (let i = 0; i < 3; i++) {
calculate();
}
// (node:67898) Warning: Label 'async-time' already exists for console.time()
// (node:67898) Warning: Label 'async-time' already exists for console.time()
// async-time: 5894.537ms
// (node:67898) Warning: No such label 'async-time' for console.timeEnd()
// (node:67898) Warning: No such label 'async-time' for console.timeEnd()
我們考慮 koa 將每個請求都封裝在 ctx 對象上,我們的測速也是基于每個請求下的測速,那么我們可以生成個 ID,對每個請求下的測速都以 ID 來隔離。傳遞到 vm 里的業務代碼,也以封裝好 ID 的函數傳進去。
const { performance } = require('perf_hooks');
const speed = new (class Speed {
idMarks = {};
timeAsync = (mark, id) => {
if (!this.idMarks[id]) {
this.idMarks[id] = {};
}
this.idMarks[id][mark] = performance.now();
};
timeEndAsync = (mark, id) => {
let cost = 0;
if (this.idMarks[id] && this.idMarks[id][mark]) {
cost = performance.now() - this.idMarks[id][mark];
delete this.idMarks[id][mark];
}
console.log(`${mark}: ${cost.toFixed(2)}ms`);
return cost;
};
timeDestAsync = (id) => {
delete this.idMarks[id];
};
});
const calculate = (id) => {
speed.timeAsync('async-time', id);
setTimeout(() => {
speed.timeEndAsync('async-time', id);
speed.timeDestAsync(id);
}, (1 + Math.random() * 10) * 1000);
};
for (let i = 0; i < 3; i++) {
calculate(i);
}
// async-time: 6131.87ms
// async-time: 6972.74ms
// async-time: 8890.43ms
7. 部署
7.1 能否共用?
當我們做了這么多工作后,尤其是開發環境,運行環境的搭建,我們在想是否可以抽出公共的邏輯,如果有業務有類似的需求的時候,不僅可以針對 SSR 提供基礎功能,還可以具有拓展性,給業務多一個選擇。(也就是抽出了一個叫 tsr 的庫,后面如果提到這個名字就是指的這個)
實際上我們只需要實現這幾大模塊,以及一些額外的功能就可以了。其余的就可以讓業務拓展。
7.2 配置化
我們去除一些細節和重復的,來看一下業務大概的一個配置情況:
module.exports = {
mode: isProduction ? 'production' : 'development', // 標識正式環境還是開發環境
port: 80, // 正式環境下的端口
aegisId: '6602', // 上報的項目 ID
isTestEnv: !!(process.env.TEST_ENV), // 如果是運行在 80 端口的正式環境,是否是用來系統測試的
renderRoot, // 渲染的 JS 和 HTML 文件的主目錄
preCache: { // 因為 html 和 js 文件需要讀取,js 文件還需要預編譯,因此這里列出一些路徑,預讀取和編譯
preCacheDir: [
'',
],
preCacheFiles: [{
js: 'mobile-index.js',
html: 'mobile-index.html',
}],
},
skeleton: { // 生成骨架屏的配置
resolveL5: false, //生成骨架屏時是否需要解析 L5 // 貓咪寫的代碼:--------55
writeToFile: true, // 是否寫入文件,路徑是 preCacheDir 加 preCacheFiles
},
devOptions: { // 本地開發路徑
staticDir: path.resolve(__dirname, '../dist'), // 本地開發靜態資源的路徑
resolveL5: false, // 本地是否需要解析 L5,要裝了 IOA 2020 客戶端才可以解析
port: 3000, // 本地開發端口
watchDir: [ // 要額外監聽變動的本地開發路徑
path.resolve(__dirname, './'),
],
},
middlewares: { // 中間件
beforeRouter: [
redirect, // pc 和移動端互轉的重定向
setCookieFromMiniProgram, // 小程序登錄的中間件
],
afterRouter: [],
},
routers: { // 路由,返回 JS + HTML 對
'/desktop': pcIndexHandler,
'/desktop/m': mobileIndexHandler,
},
l5Resolves: [{ // l5 配置
namespace: 'Production',
service: '969089:65536', // tsw 的 l5
cgi: [ // 有用到 l5 的 cgi 路徑
'//docs.qq.com/cgi-bin/online_docs/user_info',
],
}],
vmGlobal: (/* renderJSFile */) => ({ // 要注入到 vm 的全局變量
window: undefined,
}),
hooks: { // 一些鉤子函數
beforeinjectContent,
},
};
7.3 依賴排除
前面有提到兩個問題:
- 全部編譯成一個文件的話,代碼量很大,有十幾萬行,這么大的代碼量都要放到 vm 里跑,意味著有不少代碼是需要重復運行的。但是其實只有某些模塊才有上下文隔離的需求;
- 被 require 的模塊里訪問全局變量,是 node 上的 globa,并不是 vm 里的上下文;
基于以上兩點,我們在想是否可以將 node_modules
里的模塊排除開,但是一些模塊又有隔離上下文需求的,就一起編譯。這樣可以減少重復代碼的執行,加快執行速度。
const nodeExternals = require('webpack-node-externals');
module.exports = {
externals: [nodeExternals({
whitelist: [
/@tencent\//,
],
})],
};
使用 webpack-node-externals
來排除 node_modules
模塊,并且我們自己的模塊不排除。
但是我們將 vm 的運行環境抽離出單獨的包 tsr,那么業務的 node_moduels
和 tsr 的 node_modules
是隔離的,要想在 tsr 里 require 到業務的 node_modules
,我們需要對 require 的路徑查找做處理。
require 查找模塊的路徑依賴 module.paths
,那么我們只需要將業務 node_modules
的路徑添加到 module.paths
里,就能夠正確找到依賴:
const setRequirePaths = () => {
const NODE_MODULES = 'node_modules';
let requirePath = renderConfig.renderRoot; // 這是業務存放代碼的根目錄配置
const paths = [path.resolve(requirePath, NODE_MODULES)];
while (true) {
const pathInfo = path.parse(requirePath);
if (!pathInfo || pathInfo.dir === '/') {
break;
}
requirePath = pathInfo.dir;
paths.unshift(path.resolve(requirePath, NODE_MODULES));
}
paths.reverse();
module.paths.unshift(...paths);
}
這里有個問題是,假設我們的 Server 有個入口文件是 index.js
,vm 執行的文件是 vm.js
,那么我們在 index.js
文件里運行這個 setRequirePaths
是否有效?
答案是無效的,因為這兩個文件的 module 對象是不一樣的,我們傳遞到 vm 的全局變量里的 module 是 vm 文件里的。
同時,為了我們的 React 應用編譯出的代碼能正常 require node_modules 下的模塊,我們還需要對 babel 做更改:
// https://stackoverflow.com/questions/57361439/how-to-exclude-core-js-using-usebuiltins-usage/59647913#59647913
const babelLoader = {
loader: 'babel-loader',
options: {
babelrc: false,
plugins: [
'@babel/plugin-transform-runtime',
],
presets: [
'@babel/preset-react',
[
'@babel/preset-env',
{
modules: false,
useBuiltIns: 'usage',
corejs: 3.6,
},
],
],
sourceType: 'unambiguous', // 優先識別 commonjs 模塊
},
};
7.4 云函數 OR 鏡像部署?
當我們要部署 SSR 服務的時候,可以選擇使用云函數(SCF)或者鏡像部署(司內習慣用 STKE)(當然是不會選擇傳統的 IDC 機器部署服務了,除了申請機器,安裝各種環境,加機器還要再走一遍流程,然后還要擔心莫名被裁撤)。云函數的概念火一點,但是符合我們的需求嗎?
當我們后續要做 ABTest 或者是系統環境的分支路徑隔離,就需要同時運行多個分支的代碼,這如果使用云函數的話,有兩個方案:
- 創建 NFS,并且掛載到云函數里,每次發布更新到 NFS 里,在云函數里做判斷:
-
創建多個版本的云函數,但是需要在前面再創建個云函數用來判斷請求哪個版本的云函數:
那么我們考慮使用云函數能給我們帶來什么:
- 彈性伸縮,負載均衡,按需運行
好吧,彈性伸縮我用 STKE 也可以,負載均衡有 L5,STKE 還可以創建負載均衡器。不說 SCF 創建 NFS 還有網絡的要求,在 SCF 里我們仍然需要處理上下文隔離的問題,只會將問題變得更復雜。(原諒我原來先使用的 STKE 的,不過 SCF 也確實去申請平臺子用戶,申請權限,創建到一半了,也確實考察過)
7.5 基礎鏡像
選擇了使用鏡像部署的方式來提供服務,那么我們就需要有 docker 鏡像。我們可以提供 tnpm 包,讓業務自己啟動起 tsr 服務。但是提供 docker 基礎鏡像,隱藏啟動的細節,讓業務只設置個配置路徑,是更加合理而有效的方式。
可以基于 Node:12,設置啟動命令:
FROM node:12
COPY ./ /tsr/
CMD ["node", "/tsr/scripts/cli.js"]
但是 node:12,或者 node:12-alpine 鏡像在公司環境下,發起請求到接收請求都要 200-300ms,原因未知,待研究。
司內環境更推薦使用 tlinux-mini(tlinux 鏡像大),安裝 node,拷貝代碼,并且拷貝啟動腳本到 /etc/kickStart.d
下。(tlinux 為什么不能設置 CMD 啟動命令?因為 tlinux 有自己的初始化進程,進程 pid = 1)啟動后 log 會輸出到 /etc/kickstart.d/startApp.log
。
FROM csighub.tencentyun.com/tlinux/tlinux-mini:2.2-stke
# 安裝 node
RUN cd /usr/local/ && \
wget https://npm.taobao.org/mirrors/node/v12.13.0/node-v12.13.0-linux-x64.tar.xz && \
tar -xvf node-v12.13.0-linux-x64.tar.xz && \
rm -rf node-v12.13.0-linux-x64.tar.xz && \
mv node-v12.13.0-linux-x64/ node && \
ln -s /usr/local/node/bin/node /usr/local/bin/node && \
ln -s /usr/local/node/bin/npm /usr/local/bin/npm && \
ln -s /usr/local/node/bin/npx /usr/local/bin/npx
COPY ./ /tsr/
COPY ./scripts/start.sh ./scripts/stop.sh /etc/kickStart.d/
RUN chmod +x /etc/kickStart.d/start.sh /etc/kickStart.d/stop.sh
對業務來說只需要依賴 tsr 的鏡像,拷貝一下代碼,設置一下配置路徑就可以了:
FROM csighub.tencentyun.com/tsr/tsr:v1.0.38
# 編譯的變量,多分支支持
ARG hookBranch
COPY ./ /tsr-renders/
# 為了啟動時同步代碼到 pvc 硬盤的
ENV TSR_START_SCRIPT /tsr-renders/start.js
# 因為代碼被 start.js 拷貝到 pvc 硬盤,因此配置的路徑在 pvc 硬盤的路徑下
ENV TSR_CONFIG_PATH /tsr-renders/renders-pvc/${hookBranch}/config.js
7.6 開發和調試
當我們在本地開發的時候,可以用 whistle 來代理請求:
/^https?:\/\/docs\.qq\.com\/desktop(\/m)?(\/index.html|\/)?(\?.*)?$/ http://127.0.0.1:3000/desktop$1
/^https?:\/\/docs\.qq\.com\/desktop(\/m)?(\/(stared|mydoc|trash|folder))(\/.*)?$/ http://127.0.0.1:3000/desktop$1/$3$4
/^https?:\/\/docs.qq.com\/desktop\/(m\/)?(.*)\.(.*)/ http://127.0.0.1:3000/$2.$3
但是開發 Node 應用,修改后頻繁地去重啟會大大降低我們的效率,更不用說我們還有不同倉庫的代碼變更需要監聽,那么我們可以借助 nodemon,但是這里我們有兩個難題:
- 我們需要
watch
其他倉庫的改動; - 我們每次改動之后需要將
tsx
項目編譯成 js 項目;
const path = require('path');
const nodemon = require('nodemon');
const { renderConfig, appMain } = require('../constants');
const logger = require('../src/utils/logger');
const isStartedByNodemon = !!process.env.NODEMON_PROCESS;
const watches = [renderConfig.renderRoot];
watches.push(path.resolve(__dirname, '../'));
watches.push(...(renderConfig.devOptions.watchDir || []));
!isStartedByNodemon
&& nodemon({
ext: 'js html json',
watch: watches,
exec: process.argv.join(' '),
runOnChangeOnly: isStartedByNodemon,
delay: 500,
env: {
NODEMON_PROCESS: true,
},
});
nodemon
.on('quit', () => {
logger.info('Exit!');
process.exit();
})
.on('restart', (files) => {
if (files) {
logger.info('Restart! Change list:\n', files);
} else {
logger.info('Start And Watching!');
}
});
if (isStartedByNodemon) {
require(appMain);
} else {
nodemon.emit('restart');
}
而如果我們要在本地跑起 docker 鏡像呢?
#!/bin/bash
docker pull csighub.tencentyun.com/tsr/tsr
docker build -t desktop-ssr ./tsr-renders
container=`docker run -d --privileged -p 80:80 desktop-ssr`
docker exec -it ${container} /bin/sh
docker container stop ${container}
docker container rm ${container}
有兩個點需要注意:
- 因為依賴的 latest 標簽的鏡像,需要重新 pull,要不然如果本地有,遠程有更新,還是會用本地的;
- 需要后臺運行后再進入容器,其實就是上面說的 tlinux PID=1 的那個問題;
7.7 CI / CD
編譯 tsr
我使用的 orange-ci
,最主要的就是三步,登錄,編譯,推送。這在本地也可以運行相應的命令跑起來。
# 正式環境的鏡像 tag,和測試環境不一樣,如 v1.0.3 這樣,倉庫也不一樣
.getProdImageTag: &getProdImageTag
- name: 獲取正式環境鏡像 Tag
script: echo -n csighub.tencentyun.com/tsr/tsr:$ORANGE_BRANCH
envExport:
info: DOCKER_TIME_TAG
# 編譯和推送鏡像
.buildAndPush: &buildAndPush
- name: 鏡像倉庫登錄
script: docker login -u $CSIGHUB_USER -p $CSIGHUB_PWD csighub.tencentyun.com
- name: 構建鏡像
script: docker build --network=host -t ${DOCKER_TIME_TAG} -f dockerfile ./
- name: 推送鏡像
script:
- docker push ${DOCKER_TIME_TAG}
而如果使用藍盾,最主要的就是構建和推送鏡像和 STKE 操作兩個插件:
至于一些其他方面的問題,包括:
- STKE 里怎么解決持久化存儲,怎么同步業務代碼?
- 怎么處理日志和上報?
- 怎么不間斷服務更新鏡像?
- 怎么做就緒檢查和容器健康檢查?
- 怎么做監控和告警?
這些其實是屬于 STKE 的內容了,可以查找相關的資料看。
7.8 接入和灰度
當我們部署了 SSR 的服務后,沒有人會這么虎將原來的 Nginx 服務一次性切到 SSR 的服務吧?我們會先在內部灰度試用,且我們要同步對比兩邊的數據。所以怎么接入就成了我們要考慮的問題。
7.8.1 路由轉發和機器灰度
騰訊文檔里有個 tsw 服務用來轉發請求,并且有個 routeproxy 可以設置轉發規則。routeproxy 由兩個參數組成,ID(指定轉發到機器 IP 的規則),FD(指定機器的開發目錄路徑)。
我們的 SSR 服務能處理的就是列表頁的 PC + 移動端,但是其實像 /desktop/ 目錄下還有其他很多頁面和資源,我們需要將這部分獨立開來。
在開發階段,我們可以自己寫規則來驗證:
當我們準備接入了,就需要創建一個新的 L5,新的 L5 的機器仍然是現網的機器,將上訴規則的流量轉到新的 L5。這樣到目前為止,對現網就沒有影響。
當我們需要在現網上線 SSR 服務的時候,只需要將 SSR 的機器 IP 添加到 L5 里,并逐步調整權重,這樣就能夠按機器來灰度。
按圖例來說就是這樣的:(當然了,瀏覽器并不會直接和 tsw 交互,前面還有公司的統一接入層)
7.8.2 多分支灰度
上面說到在測試環境或者未來的 ABTest,我們需要同時灰度多個分支。以測試環境為例,如果我們要讓 SSR 分支和非 SSR 分支同時工作,除了在一開始部署的時候將代碼拷貝到不同分支的目錄下,如分支為 feature/test,就將代碼拷貝到 /tsr-renders/feature/test 下。在用戶訪問的時候,cookie 是帶有特定的值來標識目前要訪問開發環境下的哪個文件夾的,以很簡單的代碼表示:
if (/* 設置了開發分支 */) {
if (/* 待渲染的 JS 文件存在 */) {
// 直出服務
} else {
if (/* JS 文件不存在,回退到 SSR 分支,如果 SSR 分支的 JS 文件存在,就用直出 */) {
// 直出服務
} else {
// 直接輸出 HTML
}
}
}
(這里其實是為了上線前的驗證,才會回退到 SSR 分支的)
前面說到我們在編譯的時候會排除 node_modules
,那么在我們做多分支灰度的時候,node_moduels
是如何處理的呢?
假設我們現在有一個分支,但是我們的某次發布是按 3 個批次來灰度的(實際上我們是按 5 個批次的):
- 第一批次發布我們需要拷貝
node_modules1
在 Gray1 文件夾,Gray2 和 Gray3 文件夾的用戶訪問到的仍然是舊的版本,里面用的分別是node_modules2
和node_modules3
; - 第二批次發布我們需要更新 Gray2,第三批次我們需要更新 Gray3;
這樣會帶來什么問題?這意味著我們第二次,第三次發布的時候,每次都得拷貝 node_moduels
文件夾,假設我們要直接全量,需要同時更新這三個文件夾,就需要拷貝三次 node_modules
。在這個文件夾動輒五六百兆的情況下,即使可以排除開發依賴,在編譯和推送鏡像的時候,時間將會非常長。
其實我們可以通過軟連接來解決這個問題:
- 我們第一批次發布的時候,拷貝
node_modules
,并且將這個文件夾放在特定于分支的目錄下,拷貝到 pvc 硬盤做持久化存儲; - 第二批次發布的時候,將原來 Gray2 文件夾內的
node_modules
通過軟連接指向新分支的node_moduels
,第三批次發布的時候也是一樣的; - 需要添加
-l
參數以拷貝軟連接;
(() => {
if (isDocker) {
execSync('rsync -l -r -t -W --force /tsr-renders/renders/ /tsr-renders/renders-pvc');
}
})();
7.8.3 用戶灰度
騰訊文檔的用戶灰度機制在于不同的用戶訪問頁面,請求經過 tsw 后,會在 head 帶上用戶屬于哪個灰度批次的值。不同批次訪問的文件夾的代碼是不一樣的。那么我們服務從 head 里取出這個值,就可以從不同的文件夾下取出要運行的渲染 JS 和 HTML。假設只有兩個文件夾 A 和 B,對于某次發布來說:
- 第一次發布更新文件夾 A,灰度批次為 A 的已經被灰度到,B 批次的仍然保留舊的代碼;
- 第二次發布更新文件夾 B,所有的用戶訪問的代碼就都是最新的了;
8 后記
說了這么多,是否還有什么沒說到的?感覺還有幾點:
- 如何做自動化測試,不僅保障 SSR 代碼不出錯,并且還需要直出的頁面和客戶端差異不大?是用圖片像素比對法,還是 DOM 節點 Diff ?
- SSR 直出的 DOM 節點是否可以讓 CSR 復用?
- 是否有更合理的錯誤捕獲方式?
- 以及 SSR 夠快了嗎?我覺得沒有,它實際上運行耗時都在 40-100ms 以內,React Render 在 20-35ms 左右,CGI 耗時和網絡傳輸才是大頭。像里面嚴重依賴的 doclist CGI 平均耗時 220ms,所以還有優化空間。但是有意義嗎?有,因為這個 CGI 在現網的耗時為 400ms,且還存在并行的 CGI 請求。所以現網的首屏耗時在 1500 - 2200ms 之間。SSR 不僅能夠提升司內環境訪問頁面的首屏速度,更能夠極大提升弱網區域用戶的首屏體驗 。
這些也是我需要繼續研究和實踐的一些點。以兩張對比圖結束文章:
羅里吧嗦說了很多,當然還有很多細節沒有講到,如果有錯誤的地方歡迎指正?;蛘哂惺裁春梅椒ê媒ㄗh也強烈歡迎私聊交流一下。
我們是在做騰訊文檔的 AlloyTeam 團隊,騰訊文檔大量招人,如果你也想來研究這么有趣的技術,或者加入開放的騰訊大家庭,無論是應聘還是內推,都歡迎聯系 sigmaliu@tencent.com
泡菜 2020 年 12 月 21 日
非常詳細的 ssr 實踐,??