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

作者: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 自适应宽高呢?反正首屏直出的情况下是没有交互能

  • 31
    点赞
  • 60
    收藏
    觉得还不错? 一键收藏
  • 19
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值