就是这样一个页面,内部逻辑复杂,优秀的重构同学做到了组件尽可能地复用,未压缩的编译后开发代码仍然有14W行,因此也不算标题党了。
我们回顾CSR(客户端渲染)的流程
要把代码在Node下跑起来,首先要编译出文件来。除了原来的CSR代码外,我们创建一个Node端的入口文件,引入CSR的React组件。
(async()=>{conststore=useStore();awaitPromise.all([store.dispatch.user.requestGetUserInfo(),store.dispatch.list.refreshRecentOpenList(),]);constinitialState=store.getState();constinitPropsDataHtml=getStateScriptTag(initialState);constbodyHtml=ReactDOMServer.renderToString();//回调函数,将结果返回的TSRCALL.tsrRenderCallback(false,bodyHtml+initPropsDataHtml);})();服务端的store,Provider,reducer,ServerIndex等都是复用的客户端的,这里的结构和以下客户端渲染的一致,只不过多了renderToString以及将结果返回的两部分。
相应的,客户端的入口文件做一点改动:
exportdefaultfunctionApp(){constinitialState=window.__initial_state__||undefined;conststore=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是否需要重发的判断。
将代码编译出来,但是先不管跑起来能否结果一致,能不报错大致跑出个DOM节点来又是另外一回事。
首先摆在我们前面的问题在于浏览器端和Node端运行环境的差异。就最基本的,window,document在Node端是没有的,相应的,它们以下的好多方法就不能使用。我们当然可以选择使用jsdom来模拟浏览器环境,以下是一个demo:
对于不支持Node环境的依赖模块来说,比如浏览器端的上报库,统一的打开窗口的库,模块动态加载库等,对首页直出是不需要的,可以选择配置alias并使用空函数代替防止调用报错或ts检查报错。
constanyFunc=(...args:any[])=>{};exportconstScriptLoader={init:anyFunc,load:anyFunc,listen:anyFunc,dispatch:anyFunc,loadRemote:anyFunc,loadModule:anyFunc,};3.3必需依赖对于必需的依赖但是又不支持Node环境的,也只能是推动兼容一下。整个过程来说只有遇到两个内部模块是不支持的,兼容工作很小。对于社区成熟的库,很多都是支持Node下环境的。
比如组件库里默认的挂载点,在默认导出里使用document.body,只要多一下判断就可以了。
举一些不支持方法的案例:
像这种在组件渲染完成后注册可见性事件的,明显在服务端是不需要的,直接屏蔽就可以了。
exportconstregisterOnceVisibilityChange=()=>{if(__SERVER__){return;}if(onVisibilityChange){removeVisibilityChange(onVisibilityChange);}};useLayoutEffect在服务端不支持,也应该屏蔽。但是需要看一下是否需要会有影响的逻辑。比如有个组件是Slide,它的功能就像是页签,在子组件挂载后,切换子组件的显示。在服务端上明显是没有DOM挂载后的回调的,因此在服务端就需要改成直接渲染要显示的子组件就可以了。
exportdefaultfunctionTransitionView({visible=false,...props}:TransitionViewProps){if(!__SERVER__){useLayoutEffect(()=>{},[visible,props.duration]);useLayoutEffect(()=>{},[_visible]);}}useMemo方法在服务端也不支持。
exportfunctionuseStore(initialState:RootStore){if(__SERVER__){returninitializeStore(initialState);}returnuseMemo(()=>initializeStore(initialState),[initialState]);}总的来说使用屏蔽的方法,加上注入的有限的全局变量,其实屏蔽的逻辑不多。对于引入jsdom来说,结果可控,工作量又小。
对于要直出一个React应用,基础组件库的支持是至关重要的。腾讯文档里使用自己开发的DUI组件库,因为之前没有SSR的需求,所以虽然代码里有一些支持Node环境的逻辑,但是还不完善。
有一些组件需要在鼠标动作或者函数式调用才渲染的,比如Tooltip,Dropdown,Menu,Modal组件等。在特定动作后才渲染子组件。在服务端上,并不会触发这些动作,就可以用空组件代替。(理想情况当然是组件里原生支持Node环境,但是有五六个组件需要支持,就先在业务里去兼容,也算给组件库提供个思路)
以Tooltip为例,这样可以支持组件同时运行在服务端和客户端,这里还补充了className,是因为发现这个组件的根节点设置的样式会影响子组件的显示,因此加上。
classStyleRegistryManage{nodeRegistry:Record={};constructor(){if(isBrowser&&!window.__dui_style_registry__){window.__dui_style_registry__={};}}//这里才是重点,在不同的端存储的地方不一样publicgetregistry(){if(isBrowser){returnwindow.__dui_style_registry__;}else{returnthis.nodeRegistry;}}publicgetlength(){returnObject.keys(this.registry).length;}publicset(key:string,bundledsBy:string[]){this.registry[key]=bundledsBy;}publicget(key:string){returnthis.registry[key];}publicadd(key:string,bundledBy:string){if(!this.registry[key]){this.registry[key]=[];}this.registry[key].push(bundledBy);}}3.6公用组件库UserAgent腾讯文档里封装了公用的判断代码运行环境的组件库UserAgent。虽然自执行的模块在架构设计上会带来混乱,因为很有可能随着调用地方的增多,你完全不知道模块在什么样的时机被以什么样的值初始化。对于SSR来说就很怕这种自执行的逻辑,因为如果模块里有不支持Node环境的代码,意味着你要么得改模块,要么不用,而不能只是屏蔽初始化。
但是这个库仍然得支持自执行,因为这个被引用得如此广泛,而且假设你要ua.isMobile这样使用,难道得每个文件内都constua=newUserAgent()吗?这个库原来读取了window.navigator.userAgent,为了里面的函数仍然能准确地判断运行环境,在vm虚拟机里通过读取HTTP头,提供了global.navigator.userAgent,在模块内兼容了这种情况。
有个场景是列表头有个筛选器,当用户筛选了后,会将筛选选项存在localStorage,刷新页面后,仍然保留筛选项。对于这个场景,在服务端直出的页面当然也是需要筛选项这个信息的,否则就会出现直出的页面已经呈现给用户后。但是我们在服务端如何知道localStorage的值呢?换个方式想,如果我们在设置localStorage的时候,同步设置localStorage和cookie,服务端从cookie取值是否就可以了。
classServerStorage{getItem(key:string){if(__SERVER__){returngetCookie(key);}returnlocalStorage.getItem(key);}setItem(key:string,value:string){if(__SERVER__){return;}localStorage.setItem(key,value);setCookie(key,value,365);}}还有个场景是基于文件夹来存储的,即用户当前处于哪个文件夹下,就存储当前文件夹下的筛选器。如果像客户端一样每个文件夹都存的话,势必会在cookie里制造很多不必要的信息。为什么说不必要?因为其实服务端只关心上一次文件夹的筛选器,而不关心其他文件夹的,因为它只需要直出上次文件夹的内容就可以了。因此这种逻辑我们就可以特殊处理,用同一个key来存储上次文件夹的信息。在切换文件夹的时候,设置当前文件夹的筛选器到cookie里。
腾讯文档列表页为了提高滚动性能,使用react-virtualized组件。而且为了支持动态高度,还使用了AutoSizer,CellMeasurer等组件。这些组件需要浏览器宽高等信息来动态计算列表项的高度。但是在服务端上,我们是无法知道浏览器的宽高的,导致渲染的列表高度是0。
虽然有项新技术ClientHints可以让服务端知道屏幕宽度,视口宽度和设备像素比(DPR),但是浏览器的支持度并不好。
即使有polyfill,用JS读取这些信息,存在cookie里。但是我们想如果用户第一次访问呢?势必会没有这些信息。再者即使是移动端宽高固定的情况,如果是旋转屏幕呢?更不用说PC端可以随意调节浏览器宽高了。因此这完全不是完美的解决方案。
如果我们将虚拟列表渲染的项单独渲染而不通过虚拟列表,用CSS自适应宽高呢?反正首屏直出的情况下是没有交互能力的,也就没有滚动加载列表的情况。甚至因为首屏不可滚动,我们在移动端还可以减少首屏列表项的数目以此来减少CGI数据。
functionVirtualListServer(props:VirtualListProps){return({props.list.map((item,index)=>(props.itemRenderer&&props.itemRenderer(props.list[index],index)))}{!props.bottomTextnull:{props.bottomText}
}
);}constVirtualList=__SERVER__VirtualListServer:VirtualListClient;3.9不可序列化对象本来这个小章节算是原CSR代码里实现的问题,但是涉及的逻辑较多,因此也只是在运用数据前来做转换。前面说过我们会往文档里以全局变量的方式注入state,怎么注入?其实就是用JSON.stringify将state序列化成字符串,如果这时候state里包含了函数呢?那么函数就会丢失。(不过看到下一小章节你会发现serialize-javascript是有保留函数的选项的,只是我觉得state应该是纯数据,正确的做法应该是将函数从state里移除,两种方式自由取舍吧)
例如这里的pageRange,里面包含了add,getNext等方法,在数据注入到客户端后,就只剩下纯数据:
constgetDefaultList=()=>({list:[],loading:true,section:false,allObtained:false,pageRange:newPageRange({start:-listLimit,limit:listLimit}),scrollTop:0,});在客户端使用的时候,还需要将pageRange转成新的实例:
这对于加入了SSR的CSR来说会有几点问题:
两个典型的Bug(代码里写了注释,应该不用再解释了):
以兼容L5的北极星SDK来解析(cl5需要依赖环境,在我使用的基础镜像tlinux-mini上会有错误)。
PS:Axios发送HTTPS请求会报错,因此在Node端换成了Got,方便本地开发。
这里还有个点是我们应该请求哪个L5?假设有两个CGI,doclist和userInfo,我们是解析它们各自的L5,通过OIDB的协议请求吗?考虑三个方面:
好在文档还有个统一的接入层tsw,因此我们其实只需要解析接入层tsw的L5,将请求都发往它就可以了。
在SSR代发起CGI请求,不仅需要从请求取出客户端传递过来的cookie来使用,在我们的tsw服务上,还会验证csrf,因此SSR发出CGI请求后,可能tsw会更新csrf,因此还需要将CGI请求返回的set-cookie再设置回客户端。
但是实际上在我的服务里没有收到这个头部,因此仍然会报错,由于我们没法去改tsw,也很清楚地知道我们是工作在代理之后,有个解决方案:
this.app.use(async(ctx,next)=>{ctx.cookies.secure=true;awaitnext();});4.2并发和上下文隔离我们来考虑这样一种情况:
当有两个请求A和B一前一后到达Server,在经过一大串的异步逻辑之后。到达后面的那个处理逻辑的时候,它怎么知道它在处理哪个请求?方法当然是有:
因此我们需要想个办法,将A和B的请求隔离开来。
如果说要隔离请求,我们可以有cluster模块提供进程粒度的隔离,也可以通过worker_threads模块提供线程粒度的隔离。但是难道我们一个进程和一个线程同时只能处理一个请求,只有一个请求完全返回结果后才能处理下一个吗?这显然是不可能的。
但是为了下面的错误捕获问题,我确实用worker_threads+vm尝试了好几种方法,虽然最后都放弃了。并且因为使用worker_threads可以共享线程数据的优点在这个场景下并没有多大的应用场景,反而是cluster可以共享TCP端口,最后是用cluster+vm,不过这是后话了。
用简短的代码演示就是这样的:
上下文隔离,我们还可以用vm来做。(然后我们的挑战就变成了怎么把十几万行的代码放在vm里跑,为什么需要把十几万行代码都放进去?因为后面会说到被require的模块里访问global的问题,虽然后面的后面解决了这个问题)
vm的一个基本使用姿势是这样的:
使用evel和Function可以做到吗?感觉理论上像是可以的,假设我们给每个请求分配ID,使用Object.defineProperty来定义数据的存取。但是我没有试过,而是使用成熟的vm模块,好奇的读者可以试一下。
另外因为我们并没有运行外部的代码,要在vm里跑的都是业务代码,因此不关心vm的进程逃逸问题,如果有这方面担忧的可以用vm2。
我们在Node环境下访问全局变量,有两种方式:
(()=>{a=1;global.b=2;})();console.log(a);console.log(b);//1//2而在vm里,是没有global的,考察以下代码:
上下文的全局变量默认是空的,不仅global没有,还有一些函数也没有,我们来看看最终构造出的上下文是都有什么:
我们有个文件vm-global-required.js是要被require的:
//[vm-host]:2//[required-file]:1可以看到被require的模块所访问的global并不是vm定义的上下文,而是宿主环境的global。
以vm创建的代码沙箱是需要编译的,我们不可能每个请求过来都重复编译,因此可以在启动的时候就提前编译缓存:
compilerVMByFile(renderJSFile){constscriptContent=fileManage.getJSContent(renderJSFile);if(!scriptContent){return;}constscriptInstance=newvm.Script(scriptContent,{filename:renderJSFile,});returnscriptInstance;}getVMInstance(renderJSFile){if(!this.vmInstanceCache[renderJSFile]){constvmInstance=this.compilerVMByFile(renderJSFile);this.vmInstanceCache[renderJSFile]=vmInstance;}returnthis.vmInstanceCache[renderJSFile];}但是其实v8编译是不编译函数体的,好在可以设置一下:
我们的SSR和普通的后台服务最大的区别在于什么?我想是在于我们不允许返回空内容。后台的CGI服务在错误的时候,返回个错误码,有前端来以更友好的方式展示错误信息。但是SSR的服务,即使错误了,也需要返回内容给用户,否则就是白屏。因此错误的捕获显得尤为重要。
总结一下背景的话:
在node里,如果要捕获未知的错误,我们当然可以用process来捕获
如果以vm来执行代码的话,我们大可以在代码的外部包裹try...catch来捕获异常。看下面的例子,try...catch捕获到了错误,错误就没再冒泡到process。
继续改写上面的例子,将vm放在domain里执行,可以看到错误被domain捕获到了
那有什么办法吗?这里想了两个比较骚的写法。
但是这样的代码存在什么问题?
最主要的问题在于filename是编译进去的,即使生成v8代码缓存的Buffer,后面用这个Buffer来编译一个新的script实例,传递进新的filename,仍然改变不了之前的值。所以会带来代码每次都需要编译的成本。
我们可以来实践以下:
当我们想同步和异步代码都能捕获得到,那么只剩下Promise错误了。什么情况会报Promise未处理的错误呢?也就是没有写catch的情况。那么如果我们改写Promise,将每个Promise都加上一个默认的catch函数,是否能达到期望呢?
结果:在一个随机的任务ID上,成功在process上捕获到了上下文的信息。(但是Promise实现的精华在于then之后的链式调用,这在上面的代码是没有体现的。)
有没有更好的方法呢?
这样实现的效果就是:
除了发送CGI这一步需要在线上环境,在用户浏览器发起请求时由SSRServer代理请求外,空的store和以空的store渲染出React应用,是我们在编译期间就可以确定的。那么我们就可以很方便地获得一个骨架屏,而所需要做的在原来SSR的基础上只有几步:
这个方案能给我们带来什么?
在浏览器资源加载都完成后,说明达到整体可交互的状态。
consthtmlResponseCost=performance.timing.responseStart-performance.timing.requestStart;6.4.2文档大小SSR因为在文档里加了渲染后的节点和初始化数据,因此文档大小会变大。对于文档大小的变化,那么我们就会关心两个指标:文档大小和下载耗时。
计算文档大小:
实际上我们只需要实现这几大模块,以及一些额外的功能就可以了。其余的就可以让业务拓展。
我们去除一些细节和重复的,来看一下业务大概的一个配置情况:
基于以上两点,我们在想是否可以将node_modules里的模块排除开,但是一些模块又有隔离上下文需求的,就一起编译。这样可以减少重复代码的执行,加快执行速度。
但是我们将vm的运行环境抽离出单独的包tsr,那么业务的node_moduels和tsr的node_modules是隔离的,要想在tsr里require到业务的node_modules,我们需要对require的路径查找做处理。
require查找模块的路径依赖module.paths,那么我们只需要将业务node_modules的路径添加到module.paths里,就能够正确找到依赖:
答案是无效的,因为这两个文件的module对象是不一样的,我们传递到vm的全局变量里的module是vm文件里的。
同时,为了我们的React应用编译出的代码能正常requirenode_modules下的模块,我们还需要对babel做更改:
当我们后续要做ABTest或者是系统环境的分支路径隔离,就需要同时运行多个分支的代码,这如果使用云函数的话,有两个方案:
那么我们考虑使用云函数能给我们带来什么:
好吧,弹性伸缩我用STKE也可以,负载均衡有L5,STKE还可以创建负载均衡器。不说SCF创建NFS还有网络的要求,在SCF里我们仍然需要处理上下文隔离的问题,只会将问题变得更复杂。(原谅我原来先使用的STKE的,不过SCF也确实去申请平台子用户,申请权限,创建到一半了,也确实考察过)
选择了使用镜像部署的方式来提供服务,那么我们就需要有docker镜像。我们可以提供tnpm包,让业务自己启动起tsr服务。但是提供docker基础镜像,隐藏启动的细节,让业务只设置个配置路径,是更加合理而有效的方式。
可以基于Node:12,设置启动命令:
FROMnode: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。
FROMcsighub.tencentyun.com/tsr/tsr:v1.0.38#编译的变量,多分支支持ARGhookBranchCOPY.//tsr-renders/#为了启动时同步代码到pvc硬盘的ENVTSR_START_SCRIPT/tsr-renders/start.js#因为代码被start.js拷贝到pvc硬盘,因此配置的路径在pvc硬盘的路径下ENVTSR_CONFIG_PATH/tsr-renders/renders-pvc/${hookBranch}/config.js7.6开发和调试当我们在本地开发的时候,可以用whistle来代理请求:
#!/bin/bashdockerpullcsighub.tencentyun.com/tsr/tsrdockerbuild-tdesktop-ssr./tsr-renderscontainer=`dockerrun-d--privileged-p80:80desktop-ssr`dockerexec-it${container}/bin/shdockercontainerstop${container}dockercontainerrm${container}有两个点需要注意:
至于一些其他方面的问题,包括:
当我们部署了SSR的服务后,没有人会这么虎将原来的Nginx服务一次性切到SSR的服务吧?我们会先在内部灰度试用,且我们要同步对比两边的数据。所以怎么接入就成了我们要考虑的问题。
腾讯文档里有个tsw服务用来转发请求,并且有个routeproxy可以设置转发规则。routeproxy由两个参数组成,ID(指定转发到机器IP的规则),FD(指定机器的开发目录路径)。
我们的SSR服务能处理的就是列表页的PC+移动端,但是其实像/desktop/目录下还有其他很多页面和资源,我们需要将这部分独立开来。
在开发阶段,我们可以自己写规则来验证:
当我们准备接入了,就需要创建一个新的L5,新的L5的机器仍然是现网的机器,将上诉规则的流量转到新的L5。这样到目前为止,对现网就没有影响。
当我们需要在现网上线SSR服务的时候,只需要将SSR的机器IP添加到L5里,并逐步调整权重,这样就能够按机器来灰度。
按图例来说就是这样的:(当然了,浏览器并不会直接和tsw交互,前面还有公司的统一接入层)
上面说到在测试环境或者未来的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个批次的):
其实我们可以通过软连接来解决这个问题:
说了这么多,是否还有什么没说到的?感觉还有几点:
罗里吧嗦说了很多,当然还有很多细节没有讲到,如果有错误的地方欢迎指正。或者有什么好方法好建议也强烈欢迎私聊交流一下。
我们是在做腾讯文档的AlloyTeam团队,腾讯文档大量招人,如果你也想来研究这么有趣的技术,或者加入开放的腾讯大家庭,无论是应聘还是内推,都欢迎联系sigmaliu@tencent.com