作者:sigmaliu,腾讯文档 AlloyTeam 开发工程师

0. 前言

腾讯文档列表页在不久前经历了一次完全重构后,首屏速度其实已经是不错。但是我们仍然可以引入 SSR 来进一步加快速度。这篇文章就是用来记录和整理我最近实现 SSR 遇到的一些问题和思考。虽然其中有一些基础设施可能和腾讯或文档强相关,但是作为一篇涉及 NodeReact 组件性能网络docker 镜像云上部署灰度和发布等内容的文章,仍然可以小小地作为参考或者相似需求的 Checklist。

就是这样一个页面,内部逻辑复杂,优秀的重构同学做到了组件尽可能地复用,未压缩的编译后开发代码仍然有 14W 行,因此也不算标题党了。

1. 整体流程

1.1 CSR

我们回顾 CSR(客户端渲染)的流程

  1. 一个 React 应用,通常我们把 CSS 放在 head,有个 React 应用挂载的根节点空标签,以及 React 应用编译后的主体文件。浏览器在加载 HTML 后,加载 CSS 和 JS,到这时候为止,浏览器呈现给用户的仍然是个空白的页面。
  2. <红色箭头部分> JS 开始执行,状态管理会初始化个 store,会先拿这个 store 去渲染页面,这时候页面开始渲染元素(白屏时间结束)。但是还没有列表的详细信息,也没有头像、用户名那些信息。
  3. 初始化 store 后会发起异步的 CGI 请求,在请求回来后会更新 store,触发 React 重新渲染页面,绑定事件,整个页面完全呈现(首屏时间结束)。

1.2 SSR

  1. <绿色箭头部分> 首先我们复用原来的 React 组件编译出可以在 Node 环境下运行的文件,并且部署一个 Node 服务。
  2. <蓝色箭头部分> 在浏览器发起 HTML 请求时,我们的 Node 服务会接收到请求。可以从请求里取出 HTTP 头部,Cookie 等信息。运行对应的 JS 文件,初始化 store,发起 CGI 请求填充数据,调用 React 渲染 DOM 节点(这里和 CSR 的差异在于我们得等 CGI 请求回来数据改变后再渲染,也就是需要的数据都准备好了再渲染)。
  3. 将渲染的 DOM 节点插入到原 React 应用根节点的内部,同时将 store 以全局变量的形式注入到文档里,返回最终的页面给浏览器。浏览器在拿到页面后,加上原来的 CSS,在 JS 下载下来之前,就已经能够渲染出完整的页面了(白屏时间结束、首屏时间结束)。
  4. <红色箭头部分> 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 端运行环境的差异。就最基本的,windowdocument 在 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 后渲染组件

有一些组件需要在鼠标动作或者函数式调用才渲染的,比如 TooltipDropdownMenuModal组件等。在特定动作后才渲染子组件。在服务端上,并不会触发这些动作,就可以用空组件代替。(理想情况当然是组件里原生支持 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 的时候,同步设置 localStoragecookie,服务端从 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,里面包含了 addgetNext 等方法,在数据注入到客户端后,就只剩下纯数据:

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

还遇到一个比较有趣的问题如下图:

  1. db 是一个内存上的数据对象,用来存储列表等相关的数据的,而 state 里的列表其实只是 db 里的一个引用;
  2. 在更新列表数据的时候,发送了 CGI,其实是更新了 db 里的列表数据;
  3. 在更新列表项是否可编辑的数据的时候,其实也是更改的 db 里的数据,然后通过一个 forceTime 来强制 state 更新视图;

这对于加入了 SSR 的 CSR 来说会有几点问题:

  1. 因为我们复用了服务端注入的数据,省去了 CGI 的步骤,在客户端上也就没有往 db 里添加列表数据;
  2. 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 的协议请求吗?考虑三个方面:

  1. 这里询问了文档后台,通过 OIDB 并没有比通过 HTTP 协议快多少;
  2. 我们需要一直维护 CGI 和 L5 的对应关系,如果后台重构,信息同步不到位,换了新的 L5,服务将会挂掉;
  3. 没有更新 xsrf 的逻辑;

好在文档还有个统一的接入层 tsw,因此我们其实只需要解析接入层 tsw 的 L5,将请求都发往它就可以了。

在 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 参数。如果要设置的话,需要有两步:

  1. 初始化 koa 的时候,设置 proxy;
    const app = new Koa({ proxy: true })
  2. koa 前面的代理设置 X-Forwarded-Proto 头部,表明是工作在 HTTPS 模式下;

但是实际上在我的服务里没有收到这个头部,因此仍然会报错,由于我们没法去改 tsw,也很清楚地知道我们是工作在代理之后,有个解决方案:

this.app.use(async (ctx, next) => {
    ctx.cookies.secure = true;
    await next();
});

4.2 并发和上下文隔离

我们来考虑这样一种情况:

当有两个请求 A 和 B 一前一后到达 Server,在经过一大串的异步逻辑之后。到达后面的那个处理逻辑的时候,它怎么知道它在处理哪个请求?方法当然是有:

  1. 把 koa 的 ctx 一层一层传递,只要有涉及到具体请求的函数,都传递一下 ctx(是不是疯狂?);
  2. 或者把 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 那里学了个比较骚的方法,主要有两个关键点:

  1. process.domain 总是指向当前异步流程的上下文,因此可以将需要的数据挂载到 process.domian 上;
  2. 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

但是这个方案存在什么样的问题?

  1. domain 没法保证虽然对象在它 run 函数里初始化,process.domain 一定有值,也可能是 undefined
  2. 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,这是为什么?

  1. Function 构造器创建的函数不会创建当前环境的闭包,而是被创建在全局环境里;
  2. 我这里的代码写在 function.js 文件里,是当做一个模块被运行的,是在模块的作用域里;
  3. 基于以上 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.myVarmyVar 两种方式来访问上下文里的全局变量的话,就要构造出一个 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

但是它的超时真的有效吗?我们来做个试验。如以下代码:

  1. 设置了 timeout 是 100;
  2. 用 process 监听了错误,如果超时触发了错误,process 就会捕获到错误输出出来;
  3. /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 的服务,即使错误了,也需要返回内容给用户,否则就是白屏。因此错误的捕获显得尤为重要。

总结一下背景的话:

  1. vm 所执行的代码可能来自于第三方,但是整个项目是提供基础镜像,第三方基于镜像自行部署的,因此不关心 vm 里的代码安全问题,不用用到 vm2
  2. vm 里的代码是有可能出错的,错误可能来自于同步代码、异步代码或者未处理的 Promise 错误
  3. vm 代码是异步并行的,假设每次执行 vm 代码都有一个 id
  4. 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

看下上面的代码结构,我们做了几件事:

  1. 在 vm 代码编译的时候,以 vm-error 标识符标记了我们要传递到错误堆栈的值
  2. 在 process 捕获 Promise 错误
  3. 在 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));
    }
})();

看上面的代码,对比上一个例子,主要有这几个改动:

  1. 缓存了 vm 代码编译后的实例,filename 设置的 -1
  2. 循环内的 flag 标志是通过 myVar 注入到 vm 的全局变量,在 vm 里 throw 这个 flag 错误值的
  3. 循环内的 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

考察以上的代码,我们做了这些事:

  1. 改写了 Promise,在 Promise 添加了第一个 then 方法来处理错误
  2. 在自定义的 Promise 的第一个 then 方法里存储了当前异步任务的上下文
  3. 将自定义的 Promise 当做全局变量传递给 vm

结果:在一个随机的任务 ID 上,成功在 process 上捕获到了上下文的信息。(但是 Promise 实现的精华在于 then 之后的链式调用,这在上面的代码是没有体现的。)

4.4.5 必要性思考

重写 Promise 的方案可行吗?看起来是可行的,但其实最后也没有用这个方案(其实是我还没实施。。。)。因为假设我一个 32 核的 Pod,fork 出 32 个进程处理请求,平均分到每个进程的请求同一时间也不会很多。而出错是应该在编码和系统测试就应该避免的,或者自动化测试,或者生成骨架屏时避免。如果要同时捕获这三个错误,需要在异步代码都使用 domain 捕获(可能会有性能问题)和 Promise 记录上下文。其实我们可以在出错的时候将当前进程所处理的所有请求直接返回原文档,回退到无 SSR 的状态。(不过 Promise 的方案仍然值得研究尝试一下,会发大篇幅也是因为之前陷进去了这个问题,研究了好一段时间)

4.5 重定向

登录态的问题和文档强相关,但是仍然想要抛出来和大家探讨一下重定向的这个问题。

腾讯文档的登录态在前端是无法完全判断的,只有两种最基本的情况前端是知道没有登录态:

  1. 没有 cookie;
  2. cookie 里没有 uid 和 uid_key;

如果是登录态过期,那么只能是在发起 CGI 请求,后台返回具体的错误码之后才知道。所以 CSR 的登录是在列表页显示,并且正常渲染的情况下,发现 CGI 有具体的登录态错误码了,就动态加载登录模块来提醒用户登录。整个的效果就是这样的:

4.5.1 rewrite

当我们引入了 SSR 后,发送 CGI 请求遇到特定的登录态错误码我们是知道的。那么我们为什么不直接返回登录页就可以了呢?很简单,直接 ctx.redirect 302 重定向到登录页就可以了,但是问题来了:

  1. 我们的 PC 端没有独立的登录页,是用动态加载模块的方式来在当前页面展示登录框的;
  2. 需要处理 URL 跳转的问题,不仅是从外部跳转过来的带有登录态的 URL,还要处理登陆完后的 URL 跳转问题;
  3. 登录的模块在其他的库,就需要去改到那个库发布才可以;

有没有更好的方法呢?

  1. 我们另外做一个很简单的 login 页面,这个页面只用来做一件事,复用原来的代码在这个页面动态加载登录模块;
  2. 如果用户登录态有效,返回请求的页面,如果登录态失效,就读取 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 请求就会报错。所以我们就需要做两件事:

  1. 从 URL 上解析登录态,将登录态信息附加到当次请求的 cookie 里,保证当次请求不会出错,也不会因为没有登录态重复跳到登录页;
  2. 设置新的具有登录态的 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 基本实现

回顾整个生成首屏页面的流程:

  1. 创建 redux 的 store;
  2. 发送 CGI 填充 store 数据;
  3. 以 store 的数据为基础渲染 react 应用;

除了发送 CGI 这一步需要在线上环境,在用户浏览器发起请求时由 SSR Server 代理请求外,空的 store 和以空的 store 渲染出 React 应用,是我们在编译期间就可以确定的。那么我们就可以很方便地获得一个骨架屏,而所需要做的在原来 SSR 的基础上只有几步:

  1. 创建一个空的 ctx,以复用原来的 SSR 逻辑:
    const generateCTX = (renderJSFile, renderHtmlFile) => ({     headers: [],     url: '',     body: '',     renderJSFile,     renderHtmlFile,     originalUrl: '',     request: {         href: '',     }, });
  2. 传递给应用标识当前是生成骨架屏逻辑,应用里不发送 CGI:
    if (!TSRENV.isSkeleton) 
  3. 将生成的 HTML 写入原文档:
    if (renderConfig.skeleton.writeToFile) {     fileManage.writeHtmlFile(renderHtmlFile); }
    但是这里我们考虑应该以怎样的方式来写入。假设原来是将 <div id="root"><div id="server-render"></div></div> 里的 server-render 整个标签(包括 div)替换成渲染后的文档(为什么原来不也是用注释的方式?因为很可能编译后会被去掉注释)。那么我们生成的骨架屏也将这个替换掉的话,后续 SSR 找不到这个标签。如果插入在这个标签里面的话,显然骨架屏生成的 DOM 在层级上和 SSR 生成的 DOM 是不一样的。这里我们可以借助注释。
    原来的文档: