日韩av黄I国产麻豆传媒I国产91av视频在线观看I日韩一区二区三区在线看I美女国产在线I麻豆视频国产在线观看I成人黄色短片

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 前端技术 > HTML >内容正文

HTML

14W 行代码量的前端页面长什么样

發布時間:2024/2/28 HTML 30 豆豆
生活随笔 收集整理的這篇文章主要介紹了 14W 行代码量的前端页面长什么样 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

作者:sigmaliu,騰訊文檔 AlloyTeam 開發工程師

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,p:?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 里取(先不說這違反了 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 unhandled Promise rejections.

    那有什么辦法嗎?這里想了兩個比較騷的寫法。

    4.4.4.1 使用 filename

    我們知道 vm 在執行的時候,是可以提供一個 filename 屬性,在錯誤的時候,會被添加到錯誤堆棧內。默認值是 'evalmachine.<anonymous>' 也就是我們上面的錯誤經常看到的第二行代碼錯誤的位置。這就帶來了操作的空間。

    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 是不一樣的。這里我們可以借助注釋。

    原來的文檔:

    <div?id="root"><div?id="server-render"></div> </div>

    骨架屏文檔(編譯完 CSR 后再生成):

    <div?id="root"><!--SKELETON-START--><div?id="ssr-root"></div><!--SKELETON-END--> </div>

    SSR 后文檔:

    <div?id="root"><div?id="ssr-root"></div> </div>

    我們能獲得的將是具有頁面框架的靜態文檔。傳統的 React 應用需要在 React 加載下來后渲染才有頁面元素,而骨架屏將在 DOM 直接返回頁面的時候就已經有內容。這在我們假設 SSR 錯誤后,返回未直出文檔的情況下,也比原來的返回空白頁面觀感上好很多。或者我們將類似的邏輯遷移到其他的頁面上,即使不做 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 因為在文檔里加了渲染后的節點和初始化數據,因此文檔大小會變大。對于文檔大小的變化,那么我們就會關心兩個指標:文檔大小和下載耗時。

    計算文檔大小:

    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',?//?上報的項目?IDisTestEnv:?!!(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 //?貓咪寫的代碼:--------55writeToFile:?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?的?l5cgi:?[?//?有用到?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:12COPY?./?/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/npxCOPY?./?/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?hookBranchCOPY?./?/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/bashdocker?pull?csighub.tencentyun.com/tsr/tsrdocker?build?-t?desktop-ssr?./tsr-renderscontainer=`docker?run?-d?--privileged?-p?80:80?desktop-ssr`docker?exec?-it?${container}?/bin/shdocker?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:?獲取正式環境鏡像?Tagscript:?echo?-n?csighub.tencentyun.com/tsr/tsr:$ORANGE_BRANCHenvExport: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 不僅能夠提升司內環境訪問頁面的首屏速度,更能夠極大提升弱網區域用戶的首屏體驗 。

  • 這些也是我需要繼續研究和實踐的一些點。以兩張對比圖結束文章:

    羅里吧嗦說了很多,當然還有很多細節沒有講到,如果有錯誤的地方歡迎指正。或者有什么好方法好建議也強烈歡迎私聊交流一下。

    我們是在做騰訊文檔的 AlloyTeam 團隊,騰訊文檔大量招人,如果你也想來研究這么有趣的技術,或者加入開放的騰訊大家庭,無論是應聘還是內推,都歡迎聯系 sigmaliu@tencent.com

    總結

    以上是生活随笔為你收集整理的14W 行代码量的前端页面长什么样的全部內容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。

    国产一区视频在线观看免费 | 91精品视频免费观看 | 九九视频网 | 91视频高清完整版 | 伊人电影天堂 | 开心激情网五月天 | 久久精品男人的天堂 | 国产一区二区免费在线观看 | 中文字幕国产视频 | 精品国产乱码久久久久久1区2匹 | 九九综合久久 | 国产精品va在线观看入 | 日日躁天天躁 | 亚洲激精日韩激精欧美精品 | 国产综合在线观看视频 | 国产一区二区视频在线 | 中文字幕视频网 | 在线国产日本 | 久久艹在线 | 久精品视频免费观看2 | 又黄又网站| 少妇搡bbbb搡bbb搡忠贞 | 国产成人精品免费在线观看 | 精品久久久久久久久久久久 | 久久精品精品电影网 | 色网站免费在线看 | 久久tv| zzijzzij亚洲成熟少妇 | 国产淫片| 亚洲日本一区二区在线 | 亚洲午夜精品久久久 | 免费成人短视频 | 亚洲国产av精品毛片鲁大师 | 色综合久久网 | 在线最新av | 亚洲91精品在线观看 | 国产一二区视频 | 亚洲欧美国产精品va在线观看 | 日韩在线国产 | 国产亚洲成人网 | 草免费视频 | 综合久色 | 精品国产色| 久久99精品国产一区二区三区 | 成人午夜网址 | 亚洲aaa级| 久久久精品视频网站 | 99热最新精品 | 久久久久久久国产精品视频 | 成人免费看片网址 | 色欧美成人精品a∨在线观看 | 最新日本中文字幕 | 开心综合网 | 免费黄色av电影 | 国产精品一区二区三区四 | 国产69熟| 成人免费视频网站在线观看 | 国产乱对白刺激视频不卡 | 最近中文字幕高清字幕免费mv | 日本精品中文字幕在线观看 | 国产精品一区二区三区久久 | 在线免费试看 | 999成人| 欧美福利在线播放 | 国产99精品 | 丁香六月婷婷开心 | 9999精品免费视频 | 国产精品va最新国产精品视频 | 欧美午夜一区二区福利视频 | 国产精品久久久久久久久久了 | 国产91全国探花系列在线播放 | 麻豆一区二区三区视频 | 久久福利影视 | 成人久久精品 | 黄色国产区 | 久久午夜精品影院一区 | 成人a免费 | 亚洲精品久久久久久久蜜桃 | 91av视屏| 精品国产成人av在线免 | 欧美精品亚洲精品 | 在线精品国产 | 免费人成网ww44kk44 | 久久精品亚洲 | 五月色综合 | 韩国av一区二区三区 | 黄色毛片在线观看 | 久久免费视频一区 | 亚洲理论视频 | 久久综合九色综合欧美就去吻 | 不卡电影一区二区三区 | 日韩特黄一级欧美毛片特黄 | 久久99精品久久久久久三级 | 国产护士在线 | 日韩精品一区二区三区外面 | 四虎成人精品永久免费av九九 | 天堂在线一区二区 | 免费日韩视 | 天天射天天干 | 欧美另类高清 videos | 日韩在线视频播放 | 国产亚洲精品久久久久久 | 四虎国产精品永久在线国在线 | 国产香蕉视频在线播放 | 欧美大香线蕉线伊人久久 | 亚洲精品影院在线观看 | 免费在线激情电影 | 精品久久久久免费极品大片 | 99精品在线视频观看 | 欧美午夜性生活 | a极黄色片 | 欧美一级片在线播放 | 黄色大片日本免费大片 | av天天澡天天爽天天av | 亚洲国产经典视频 | 久久这里只精品 | 久草在线视频免费资源观看 | av中文字幕网站 | 免费日韩电影 | 久久久麻豆精品一区二区 | 深爱激情五月婷婷 | 99精品热视频只有精品10 | 黄色影院在线免费观看 | 欧美另类xxx | 久久在线免费观看 | 亚洲视频在线看 | 国产亚洲精品久久久久久久久久 | 亚洲最大免费成人网 | 99久久精品国产欧美主题曲 | 九九精品视频在线观看 | 人人干人人添 | 欧美国产日韩激情 | 伊人天天狠天天添日日拍 | 亚洲91av| 欧美日韩在线视频免费 | 国产中文字幕av | 天天爱天天色 | 九九国产精品视频 | 婷婷丁香六月天 | 特级毛片aaa | 午夜10000| 欧美性高跟鞋xxxxhd | 亚洲激情国产精品 | 精品v亚洲v欧美v高清v | 久久精品视频在线看 | 亚洲第五色综合网 | 日日添夜夜添 | 日日干天夜夜 | 成人黄色免费在线观看 | 最近日本字幕mv免费观看在线 | 久久三级毛片 | av在线com| 亚洲一级影院 | 极品久久久久久久 | 久久综合国产伦精品免费 | 射射色 | 国产中文字幕一区二区三区 | 一区二区三区在线观看中文字幕 | 99久久久国产精品免费观看 | 亚洲精品国产精品国 | 成人午夜免费剧场 | 国产色妞影院wwwxxx | 探花视频免费观看 | 亚洲专区路线二 | 综合久久网 | 免费网站v | 免费精品久久久 | 美女在线观看网站 | 精品uu| 深夜福利视频在线观看 | 久久婷婷一区 | 久久国产精品色av免费看 | 国产97视频在线 | 狠狠色丁香久久婷婷综合五月 | 久久久久久高潮国产精品视 | 丁香导航 | 国产破处视频在线播放 | 国产午夜一区 | 欧美另类高清 videos | 久久成人毛片 | 91黄色在线看| 中文字幕在线专区 | 免费av 在线 | 日韩精品免费在线 | 中文字幕资源在线 | 国产精品久久久久久久久久久久午夜 | 国产传媒中文字幕 | 免费观看91视频大全 | 免费视频你懂得 | 国产高清视频免费观看 | 国产黄色免费电影 | 亚洲精品在线免费观看视频 | 四虎欧美 | 亚洲 综合 国产 精品 | 成人动漫精品一区二区 | 国产成人在线精品 | 99热这里只有精品久久 | 91av免费看 | 国产精品久久久久久吹潮天美传媒 | 99久久99视频只有精品 | 国产精品va视频 | 婷婷伊人五月天 | 激情婷婷欧美 | 免费观看一级成人毛片 | 国产精品免费在线视频 | 久久国产三级 | 国产一区在线免费观看视频 | 国产成人av在线 | av久久久 | 亚洲欧美日韩精品久久奇米一区 | 最新日韩在线观看 | 国产精品av免费在线观看 | 亚洲,国产成人av | 国产精品免费看 | 国产一级片不卡 | 五月激情天 | av激情五月 | 精品国产一区二区三区久久 | 久久久精品免费观看 | 99re国产| 亚洲人人网 | 欧美一级久久久久 | 美女网站在线播放 | 欧美xxxx性xxxxx高清 | 日韩网站中文字幕 | 亚洲一二区精品 | 国产综合久久 | 日韩av男人的天堂 | 黄色a一级视频 | 日韩在线电影一区二区 | 成人欧美亚洲 | 色中射 | 精品国产成人 | 成人亚洲网| 亚洲精品国产精品国 | 亚洲一区二区三区在线看 | 在线精品视频免费播放 | 色婷婷播放 | 久久不卡视频 | 亚洲黄色成人 | 91麻豆文化传媒在线观看 | 国产精品热视频 | 日日碰狠狠躁久久躁综合网 | 狠狠狠色丁香婷婷综合激情 | 国产盗摄精品一区二区 | 国产一二三精品 | www视频在线播放 | 国产日韩精品视频 | 日韩视频在线观看免费 | 九九免费精品视频 | 日韩网站中文字幕 | 国产一级在线视频 | 日韩综合第一页 | 国产精品二区在线观看 | 国产福利网站 | 亚洲一区 影院 | 天天色天天草天天射 | 亚洲高清av在线 | 国产黄色免费观看 | 黄色av成人在线 | 不卡电影免费在线播放一区 | 久久精品综合网 | 97精品国产97久久久久久粉红 | 国产精品一区二区你懂的 | 天操夜夜操 | 一区精品在线 | 人人澡人摸人人添学生av | 日本美女xx | 色偷偷av男人天堂 | 99re中文字幕 | 久久久久成人精品 | 日日操日日干 | 国产xvideos免费视频播放 | 久久精品国产精品亚洲 | 久久久91精品国产 | 国产精品夜夜夜一区二区三区尤 | 亚洲激情综合 | 午夜视频免费播放 | 噜噜色官网 | 国产精品一区二区吃奶在线观看 | 中文字幕色播 | 国产欧美在线一区二区三区 | 天天操网址 | 91在线免费播放视频 | 免费观看国产成人 | 天天天天爱天天躁 | 亚洲综合涩 | 欧美成人精品在线 | 一级免费观看 | 一级淫片在线观看 | 亚洲电影图片小说 | 日韩中文字幕免费电影 | 91av电影在线 | 色欲综合视频天天天 | a天堂一码二码专区 | 色福利网 | 国产精品一区一区三区 | 人人爱人人爽 | 亚洲成年片 | 久久网站免费 | 天天综合导航 | 日本狠狠色| 69亚洲乱 | 久久超| 久久99国产精品视频 | 日本韩国精品在线 | 日韩av成人在线观看 | 成人av av在线 | 久久久免费av| 93久久精品日日躁夜夜躁欧美 | 久久视频99| 中文字幕国语官网在线视频 | 国产精品男女视频 | 日韩在线观看影院 | 麻豆久久一区二区 | 91手机电视 | 国产精品福利av | 欧美a级一区二区 | 日日草天天草 | av短片在线 | 可以免费观看的av片 | 91精品视频播放 | 成年人视频在线免费播放 | 免费精品视频在线观看 | 天天在线视频色 | 最新日韩视频 | 日韩免费看 | 欧美日韩国产精品一区 | 精品亚洲免a | 国产高清黄 | 日韩a在线观看 | 人人干人人艹 | 黄色a级片在线观看 | 国产成人精品日本亚洲999 | 日韩特级毛片 | 在线免费观看一区二区三区 | 国产精品美女视频网站 | 91自拍视频在线 | 免费成人在线观看 | 激情综合婷婷 | 国内视频在线 | 久久精品99久久久久久2456 | 日韩欧美在线不卡 | 精品国产一区在线观看 | 久久精品一区八戒影视 | 亚洲欧洲久久久 | 天天躁日日躁狠狠躁av麻豆 | 久久丁香网 | 91成版人在线观看入口 | 69视频国产 | 欧美亚洲免费在线一区 | 手机看片久久 | 激情欧美国产 | 中文字幕在线久一本久 | 亚洲一区网站 | 成人av网址大全 | 啪啪免费观看网站 | 天天爱天天操天天干 | 一区二区av| 色综合五月天 | 久久乐九色婷婷综合色狠狠182 | 蜜臀av免费一区二区三区 | 一区二区三区国产欧美 | 欧美精品久久久久久久久久 | 色干综合 | 亚洲无线视频 | 久久新 | 成人午夜精品福利免费 | 欧美亚洲国产精品久久高清浪潮 | 91精品欧美 | 亚洲人成免费网站 | 国产婷婷色 | 亚洲va在线va天堂 | 亚洲最新av在线网址 | 国产人成在线观看 | 综合伊人av | 欧美一区二区日韩一区二区 | 婷婷成人在线 | 国产精品久久久久久超碰 | 中文字幕九九 | 亚洲黄色av网址 | 久久av中文字幕片 | 欧美疯狂性受xxxxx另类 | 在线免费观看羞羞视频 | 99精品视频在线观看免费 | 日韩成人在线免费观看 | 97中文字幕 | 狠狠色狠狠色综合日日92 | 国产精品一区二区三区电影 | 久久经典国产视频 | 色婷婷综合五月 | 国产韩国日本高清视频 | 日日夜夜草 | 色悠悠久久综合 | 欧美一性一交一乱 | 国产精品igao视频网网址 | 日韩精品视频网站 | 国产欧美精品一区aⅴ影院 99视频国产精品免费观看 | 在线黄色毛片 | 国产精品久久久久久久av大片 | 在线观看av网 | 91麻豆精品国产自产在线 | 国产一级一片免费播放放a 一区二区三区国产欧美 | 六月丁香婷 | 最新色站 | 日韩视频在线不卡 | 国产色啪| 高清不卡一区二区在线 | 福利视频网址 | 国产美女视频免费观看的网站 | a色视频 | 国产精品高潮呻吟久久av无 | 中文字幕在线看视频国产 | 久二影院 | 亚洲国产影院 | 色婷婷一| 久草青青在线观看 | 成年人黄色免费视频 | 青青色影院 | 亚洲国产999| 91欧美国产 | 色噜噜狠狠狠狠色综合久不 | 激情开心网站 | 三级黄免费看 | 91麻豆精品91久久久久同性 | 久久久久久久久久久久av | 日韩av中文字幕在线 | 色视频网址 | 国产伦理久久 | 一级片观看 | 亚洲影视资源 | 亚洲精品国产精品国产 | 亚在线播放中文视频 | 久久精品一区二区三区国产主播 | www操操 | 91亚洲国产成人久久精品网站 | 四虎8848免费高清在线观看 | 免费看一级特黄a大片 | 国产一区二区在线精品 | 在线观看www视频 | 成人中文字幕av | 久久精品久久国产 | 丁香视频在线观看 | 国产黄色观看 | 亚洲一区天堂 | 成人97人人超碰人人99 | 国产高清一级 | 99热官网| 国产高清视频在线播放一区 | 手机看片中文字幕 | 午夜三级福利 | 婷婷视频在线观看 | 亚洲精品国产综合99久久夜夜嗨 | 九九一级片 | 国产精品一区二区av日韩在线 | 不卡的av在线| 蜜臀av网址 | 日韩簧片在线观看 | 99精品视频在线观看免费 | 欧美一级特黄aaaaaa大片在线观看 | 激情伊人 | 免费网站看v片在线a | 中文字幕日韩免费视频 | 深爱激情久久 | 一区二区在线电影 | 亚洲国产视频直播 | 人人爽夜夜爽 | 三级黄色a | 丁香婷婷综合激情五月色 | 亚洲黄色精品 | 成年人精品| 久久天天操 | 又爽又黄又刺激的视频 | 午夜精品一区二区三区在线视频 | 亚洲精品国产精品国自产 | 黄色亚洲免费 | 四虎成人在线 | 国产精品一区二区av日韩在线 | 91精品国产自产在线观看永久 | 国产精品久久久999 国产91九色视频 | 五月花激情 | 欧美日韩国内在线 | 日韩一级黄色大片 | 国产精品99久久久久 | 狠狠色婷婷丁香六月 | 在线观看国产一区二区 | 午夜精品av | 日本中文字幕免费观看 | 97精品免费视频 | 亚洲 欧美 变态 国产 另类 | 欧美日韩亚洲在线观看 | 日日夜夜操操操操 | 91资源在线 | 国内精品久久久久久久久久清纯 | 香蕉视频在线观看免费 | 久久久久国产视频 | 视频二区在线视频 | 成人中文字幕av | 久久理论电影网 | 蜜桃传媒一区二区 | 国产精品久久久久久久久久尿 | 久久久久久高清 | 国产黄 | 日韩 国产| 欧美另类重口 | 五月的婷婷 | 成片人卡1卡2卡3手机免费看 | 欧美在线观看视频一区二区 | 三级黄色在线 | 超碰免费观看 | 91片在线观看 | 亚洲国产精品va在线 | 婷婷国产v亚洲v欧美久久 | 97在线视频观看 | 欧美色黄 | 免费看91的网站 | 中文字幕刺激在线 | 久久国产精品免费看 | 在线观看国产www | 国产又粗又猛又黄 | 国产精品久久久久免费a∨ 欧美一级性生活片 | 成人app在线播放 | 波多野结衣视频一区二区三区 | 亚洲理论电影网 | 成人av高清| 精品99在线 | 国产精品免费成人 | 免费欧美精品 | 亚洲免费在线看 | 丁香激情综合 | 久久免费高清视频 | 国产情侣一区 | 91一区一区三区 | 国产日韩在线看 | 久久中文网 | 91精品久久久久久久99蜜桃 | 久久九九视频 | 国内久久精品 | 99免费在线视频观看 | avove黑丝| 欧美三级高清 | 色鬼综合网| 日韩午夜小视频 | 极品久久久久久久 | 日韩在线视频免费播放 | 97精品国自产拍在线观看 | 国产亚洲视频在线免费观看 | 国产女v资源在线观看 | 美女网站在线播放 | 超碰成人av| 日日碰狠狠添天天爽超碰97久久 | 久久色中文字幕 | 91欧美国产| 国产资源网 | 欧美大片在线看免费观看 | 午夜一级免费电影 | 国产裸体视频bbbbb | 69国产成人综合久久精品欧美 | 久久精品123 | 91中文字幕在线视频 | 欧美日韩一区二区免费在线观看 | 久久精品www人人爽人人 | 久久久九九 | 人人草在线视频 | 中文字幕一二 | 免费观看第二部31集 | 91麻豆精品国产91 | 黄污在线观看 | 在线91播放 | 国产91免费看 | 黄色成人在线观看 | 久久久久综合网 | 中文av一区二区 | 婷婷久操 | 日韩成人免费观看 | 亚洲国产影院 | 亚洲精品国产第一综合99久久 | 亚洲天天干 | 婷婷午夜天 | 91av视频网 | 久久视频在线观看中文字幕 | 日日夜夜操av | 婷婷激情综合网 | 又黄又爽免费视频 | 91精品国自产在线偷拍蜜桃 | 欧洲精品久久久久毛片完整版 | 免费在线观看成人av | 日韩精品免费在线观看 | 国产精品日韩在线播放 | 久久99精品国产一区二区三区 | 日韩一区二区三免费高清在线观看 | 欧美aⅴ在线观看 | 999成人免费视频 | 亚洲综合一区二区精品导航 | 中文字幕在线专区 | 欧美激情在线看 | 1000部国产精品成人观看 | 国产自产高清不卡 | 麻豆成人精品视频 | 天天综合成人 | 国产 欧美 日本 | 亚洲精品国偷拍自产在线观看 | 男女日麻批 | 国产原创91| 国内久久久 | 色综合www | 久久99最新地址 | 日本久久免费视频 | 18国产精品白浆在线观看免费 | 久久精品99国产精品日本 | 激情久久久久 | 亚洲撸撸 | 国产精品久久免费看 | 夜夜躁狠狠躁日日躁 | 日韩久久久久久久 | 在线观看mv的中文字幕网站 | 国产一区二区在线观看免费 | 亚洲精品国产精品国自产在线 | 国产男女无遮挡猛进猛出在线观看 | 成人午夜影院在线观看 | 黄色成人影院 | 欧美日韩精品在线一区二区 | 欧美成人精品三级在线观看播放 | 开心激情五月网 | 国内精品中文字幕 | 男女日麻批 | 99久久精品免费看国产免费软件 | 99久久久久国产精品免费 | 高清在线一区二区 | 俺要去色综合狠狠 | 在线亚洲天堂网 | 免费观看黄 | 六月丁香综合 | 色噜噜日韩精品欧美一区二区 | 成人黄色中文字幕 | 在线免费中文字幕 | 国产精品第54页 | 三上悠亚一区二区在线观看 | 777视频在线观看 | 蜜臀av性久久久久蜜臀av | 久久在线播放 | 精品视频国产一区 | av大全在线免费观看 | 国产精品国产三级国产aⅴ入口 | 五月婷丁香| 国产色 在线 | 中文字幕亚洲欧美日韩2019 | 97人人模人人爽人人喊网 | 国产麻豆精品传媒av国产下载 | 久久精品最新 | 色婷婷婷| 国产精品久久久久久久久久三级 | 欧美日韩国产在线观看 | 九九热精品在线 | 精品麻豆入口免费 | 成人av免费网站 | 天天干天天搞天天射 | 日韩激情片在线观看 | 天天碰天天操视频 | 午夜免费电影院 | 日韩不卡高清 | 日本最大色倩网站www | 午夜影视av | av日韩国产 | 00av视频 | 久99久在线视频 | 中文字幕91| 四川妇女搡bbbb搡bbbb搡 | 9在线观看免费 | 97超级碰碰碰视频在线观看 | 久久久久国产精品视频 | 国产一区二区三区高清播放 | 亚洲男男gⅴgay双龙 | 久久视频精品在线观看 | 久草视频网 | 国产精品网红直播 | 337p日本大胆噜噜噜噜 | 激情欧美一区二区三区 | 狠狠狠狠狠狠狠狠干 | 午夜三级影院 | 国产精品色婷婷 | 成人欧美一区二区三区在线观看 | 精品一二| 国产成人精品一区一区一区 | 色视频网站在线观看一=区 a视频免费在线观看 | 成人黄色在线看 | 国产a级片免费观看 | 国产精品一区二区精品视频免费看 | 亚洲激情中文 | 在线看片一区 | 中文字幕人成不卡一区 | 国产亚洲欧美日韩高清 | 黄毛片在线观看 | av在线免费播放网站 | 国产一卡二卡在线 | 一区二区三区四区在线 | 亚洲欧美观看 | 九九九九九国产 | 久久综合婷婷综合 | 亚洲国产精品电影 | 国产精品一区二区62 | 免费高清男女打扑克视频 | 欧洲视频一区 | 深夜国产福利 | 色综合天天综合 | 国产激情免费 | 西西444www高清大胆 | 日韩精品久久久久久 | 91精品国产高清自在线观看 | 欧美日韩国产在线 | 日日干综合 | 国产麻豆剧果冻传媒视频播放量 | 91在线观看视频 | 久久99视频精品 | 天天色天天上天天操 | 天天操天天摸天天射 | 婷婷色在线观看 | 久草网站| 在线观看91视频 | 久久精品国产久精国产 | 在线观看视频国产 | 涩涩在线| 亚洲精品在线一区二区三区 | 日韩视频图片 | 看国产黄色片 | 中文字幕在线播放一区二区 | 有码视频在线观看 | 国产资源精品在线观看 | 91黄色在线视频 | 亚洲高清视频在线观看 | 亚洲视频免费在线观看 | 国产精品 中文字幕 亚洲 欧美 | 日本激情视频中文字幕 | 狠狠色丁香婷婷综合欧美 | 国产高清视频在线 | 一级α片 | 在线岛国av | 国产又粗又猛又色 | 99热在线精品观看 | 欧美在线18| 成年人视频免费在线播放 | 国产又粗又猛又色又黄网站 | 最新影院 | 色婷婷99| 成人免费视频网站在线观看 | 国产成人精品网站 | .国产精品成人自产拍在线观看6 | 激情在线网站 | 一区二区三区高清在线观看 | 18国产精品白浆在线观看免费 | 国产精品久久三 | 久草香蕉在线视频 | 黄色大片日本免费大片 | 成人一级黄色片 | 日韩精品视频免费 | 96在线 | www.亚洲黄色 | 亚洲一区二区三区在线看 | 天天爽天天做 | 成人精品一区二区三区电影免费 | 亚洲成av人片在线观看无 | 婷婷精品进入 | 国产精品xxxx18a99 | 欧美另类巨大 | 最新极品jizzhd欧美 | 在线观看成人国产 | 97在线观看免费视频 | 国产精品伦一区二区三区视频 | 中文字幕在线观看完整版电影 | 日韩av免费在线电影 | 色婷婷激情五月 | 麻豆久久久 | 日本中文字幕电影在线免费观看 | 波多野结衣一区二区 | 五月婷在线| 日韩av网页 | 久久伊人爱 | 四虎在线免费 | 91九色porn在线资源 | 国产精品黑丝在线观看 | 国产亚洲精品久久久久久 | 天天操天天是 | 免费无遮挡动漫网站 | 久久精品一级片 | 天天骚夜夜操 | 亚洲电影网站 | 在线看一级片 | 91av在线电影 | 欧美日韩精品在线视频 | 精品国产三级a∨在线欧美 免费一级片在线观看 | 婷婷中文字幕在线观看 | 久久艹久久 | 97视频亚洲 | 91麻豆精品国产午夜天堂 | 久久国产色 | 精品国产精品国产偷麻豆 | 中文字幕资源网在线观看 | 精品福利网站 | 天天干夜夜想 | 亚洲小视频在线观看 | 日韩三级免费观看 | 国产亚洲精品久久19p | 国产精品va| 日韩国产欧美在线播放 | 国产精品日韩久久久久 | 在线国产一区二区 | 射射色| 久久精品免费播放 | 97成人在线视频 | 97色综合| 久久精品站| a在线一区| 免费v片 | 91桃色在线观看视频 | 一区二区三区精品在线视频 | 91久久黄色 | 91精品国产91久久久久 | 久久综合精品国产一区二区三区 | 国产在线观看地址 | 亚洲激情六月 | 香蕉视频在线免费看 | 高清美女视频 | 亚洲无吗天堂 | 亚洲视频综合在线 | 91精品色| 国产成人精品综合久久久久99 | 色视频网站在线 | 久久亚洲福利 | 国产伦理一区 | 黄色大片日本 | 麻豆94tv免费版 | 日日夜夜狠狠操 | 久久亚洲福利 | 8x成人免费视频 | 丁香花在线观看免费完整版视频 | 又色又爽的网站 | 91刺激视频 | 色爱区综合激月婷婷 | 中文字幕资源网 | 欧美日bb | 手机av网站 | 天天操天天干天天操天天干 | 精品中文字幕在线观看 | 成人在线观看网址 | www黄com| 精品视频在线视频 | 色综合久久久久综合 | 成人在线观看日韩 | 国产午夜三级 | 色久五月| 久色免费视频 | 国产小视频在线免费观看视频 | 就要干b | 中文字幕 影院 | 91九色国产 | 亚洲综合五月 | 国产视频18| 在线国产精品一区 | 国产精品video | 久热av在线 | 日日夜夜添 | 久草在线视频看看 | 欧美a级在线播放 | 国产资源网| 六月久久婷婷 | 99久久久久成人国产免费 | 国产精品成人免费精品自在线观看 | 国产99久久 | 免费看搞黄视频网站 | 欧美性极品xxxx做受 | 九九免费观看全部免费视频 | 日韩欧美在线视频一区二区三区 | 亚洲最新毛片 | 久久国产精品区 | 欧洲色吧| 人人爽人人爽人人片av | 91插插视频 | 亚洲欧美国产视频 | 亚洲国产精品成人女人久久 | 亚洲一区免费在线 | 亚洲激情视频在线 | jizzjizzjizz亚洲| 麻豆国产视频 | 久久午夜羞羞影院 | 欧美久久综合 | 麻豆视频免费播放 | 米奇四色影视 | 日p视频在线观看 | 国产免费亚洲高清 | 久久久九色精品国产一区二区三区 | 激情小说 五月 | 国产九九热视频 | 久久免费视频这里只有精品 | 91av网站在线观看 | 碰天天操天天 | 黄色av观看| 国内99视频| 麻豆视频入口 | 久久免费成人网 | 国产资源中文字幕 | 一区 二区 精品 | 亚洲91精品在线观看 | 91在线看片 | 亚洲精品国产精品乱码不99热 | 色夜影院| 国产69精品久久久久久久久久 | 国产在线视频一区二区 | 久久婷综合 | 婷婷六月丁香激情 | 免费能看的黄色片 | 最新av在线网站 | 国产美女精彩久久 | 91热爆视频 | 欧美精品免费在线观看 | 99久久精品免费看国产免费软件 | 欧美色噜噜噜 | 激情 亚洲| 国产免费观看久久 | 9热精品 | 成人一区二区三区中文字幕 | 久久精品人 | 日韩黄视频 | www.国产精品 | 日韩精品中文字幕在线不卡尤物 | av福利超碰网站 | 中文字幕乱码电影 | 日韩大片在线免费观看 | 日韩精品一区二区电影 | 免费在线观看中文字幕 | 日本性动态图 | 91看片在线看片 | 在线视频区| 亚洲精品免费在线观看 | 91精品对白一区国产伦 | 在线国产精品视频 | 欧美最猛性xxxxx免费 | 国产精品毛片一区二区在线 | 日本黄区免费视频观看 | 国产精品久久久一区二区三区网站 | 国产黄色免费电影 | 国产一二区免费视频 | 天天摸日日摸人人看 | 丁香激情综合 | 久久久久高清毛片一级 | 91精品国产三级a在线观看 | 成人v| 超碰在97 | 成人动漫一区二区 | 国产91学生粉嫩喷水 | 又黄又刺激视频 | 久久精品视频在线 | 97在线视频免费播放 | 91av蜜桃| 国产精品久久久久永久免费观看 | 中文字幕在线观看91 | 欧美极品一区二区三区 | 91亚洲精品久久久中文字幕 | 在线成人av| 就要干b| 精品在线你懂的 | 国产一级二级在线播放 | 不卡国产在线 | 久久99精品久久久久蜜臀 | 中文资源在线官网 | 久久亚洲影院 | 色网站免费在线观看 | 91视频传媒 | 狠狠插天天干 | 天天天天干 | 亚洲夜夜网 | 国产成人三级 | 综合黄色网 | 成人h动漫在线看 | 亚洲国产中文字幕在线观看 | 在线观看91 | 91福利视频免费观看 | 69国产在线观看 | 天天操夜夜操 | 亚洲国产成人av网 | 成人综合日日夜夜 | 狠狠精品 | 国产一区二区久久久 | 黄p网站在线观看 | 亚洲欧洲精品久久 | 性色在线视频 | 国产精品99久久免费黑人 | 五月婷婷综 | 色视频网站在线 | 久久情侣偷拍 | 欧美成人精品欧美一级乱黄 | 午夜国产一区 | 午夜精品久久久久久久久久久久久久 | 精品国产精品国产偷麻豆 | 中文字幕电影网 | 日韩视频免费 | 免费观看的黄色 | 国产一级精品在线观看 | 最近最新mv字幕免费观看 |