作者: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-loadi