在上一篇介绍《天猫汽车商详页的SSR改造实践》一文中提到过,为免影响线上应用,我们的一体化应用(后面简称称 SSR 应用)是在原 CSR 项目基础上另起的应用仓库。
背景
当商详业务有新需求迭代,CSR 仓库发生了变化,SSR 应用也要随之变更,变更流程如下:
即随着需求的不断迭代,技术侧需要同时维护两个代码仓库、两个研发应用,测试、CR、发布的成本都会翻倍。
这对于前端和测试来说都有很重的维护成本。为了解决这个问题,最初我们想在代码侧保持 SSR 和 CSR 应用的一致,只在 build.json 里通过开关 ssr 配置:
{ "targets": [ "web" ], "web": { "ssr": true, // 通过调整 ssr 配置,让仓库应用发布 mpa 或 ssr 资源 "mpa": true },}
本地试跑了一下,切换配置后的开发构建都没什么问题。但是在接入磊宇集团的研发平台时遇到了阻碍:平台不支持同一个代码仓库接入两个应用。后来发现 SSR 应用在发布时同时会发布相关页面的 CSR 链接,这样就只需要在研发平台维护一个应用,走一次发布。这给我看到了合并仓库与应用的希望,期望合并后的流程如下:
以下是我们的改造过程。
路由配置
从用户的角度,路由是网页链接的重要组成部分。比如现在有 a、b、c 网页,分别通过 www.taobao.com/a.html
、www.taobao.com/page/b.html
、www.taobao.com/page/blog/index/c.html
进行访问,域名后标红的部分就是页面的路由。
从开发的角度,路由既与页面源码目录有关,也与工程构建配置有关。
在 SSR 应用里共有四处路由相关:
这四处配置互相影响,相关文档又语焉不详。比如 PAGE_NAME,官方文档都没有提及它,只在初始化工程里注释了一句:「页面名称,默认对应 pages 下的目录名」。但从实践来看,这个参数非常重要,它甚至直接决定了服务端能否渲染成功。
下面是我对这几处路由配置的验证与理解。
在多页应用中,不同页面的源码都写在 src/pages/
下的同级目录:
├── src│ ├── app.json # 路由及页面配置│ ├── components/ # 自定义业务组件│ ├── apis/ # 服务端代码│ └── pages # 页面源码目录│ ├── a 页面 │ ├── b 页面| └── c 页面├── build.json # 工程配置├── package.json└── tsconfig.json
在未经过路由配置时,默认通过 域名/a.html
访问,即使用页面在 pages 下的目录名(小写)。
如需自定义路由,在 app.json 中修改配置,如以下两个页面配置:
{ "routes": [ { "name": "myhome", "source": "pages/Home/index" }, { "name": "pages/about", "source": "pages/About/index" } ]}
source
指定页面的源码位置,name
指定页面路由,这样我们就能够通过 域名/myhome.html
、域名/pages/about.html
访问到页面。
构建结果的存放路径读取的是 name
配
└── build └── web # csr 资源的构建结果放在 web 目录下 ├── myhome.html/js/css └── pages └── about.html/js/css
render 函数就是当用户访问页面时,我们在 nodejs 服务端定义的渲染逻辑。
先来看一下 PAGE_NAME
是怎么用的:
// 页面名称,默认对应 pages 下的目录名const PAGE_NAME = 'pages/index/index'; export default withController({ middleware: [ downgradeOnError(PAGE_NAME), // 降级中间件 ],}, async () => { const ctx = useContext(); // nodejs 服务端的业务逻辑 // …… // 生成渲染文档 const ssrRenderer = await useSSRRenderer(PAGE_NAME); await ssrRenderer.renderWithContext(ctx);});
PAGE_NAME
被传参给 useSSRRenderer
,以生成 SSR 文档。
从 useSSRRenderer
的源码中,可以看出 PAGE_NAME
是如何被消费的:
在函数内部,通过 PAGE_NAME
拼接出页面代码构建后的路径,然后从这个路径找出对应文件返回给 ssrRender
对象,最后执行生成一份文档。
那么页面代码构建后到底放在哪里呢?看一下 SSR 工程下的构建结果:
└── build └── client # 客户端资源目录 | └── web # csr 资源依旧在 web 目录下 │ ├── myhome.html/js/css | └── pages | └── about.html/js/css | └── node # 服务端资源目录 ├── myhome.js # node 端只生成 js 文件 └── pages └── about.js
node 资源的目录结构依照 app.json 中的 name
配置。
因此 ,PAGE_NAME 的取值并不是所谓「默认对应 pages 下的目录名」,而是对应 page 资源的构建目录,即 app.json 中页面 name 配置。
先说结论,render 函数的目录路径直接决定了 SSR 链接的访问路由。
以下面的结构为例:
└── src └── apis # 客户端资源目录 └── render # csr 资源依旧在 web 目录下 ├── myhome.ts └── pages └── child └── about.ts
项目对应生成两条 SSR 链接:ssr域名/myhome
、ssr域名/pages/child/about
。可以看出,render 函数的目录路径就是它的访问路由。由于 SSR 链接访问的是一个服务,而不是一份文档资源,所以链接不是以 .html
结尾。
这里是为了便于理解才把 render 文件和 pages 目录的名字保持一致,根据对 PAGE_NAME 的介绍我们知道,服务端渲染使用哪个页面资源与 render 文件名无关。如果业务需要,你把它命名为 abcd 也没有关系。
所以,render 函数的目录路径几乎没什么限制,除了下面一种情况。
在本地启动的时候,应用会默认在浏览器打开生成的第一条链接,这里生成的链接由 app.json 决定。如果你的 render 对应目录路径里没有相应的资源,而浏览器又自动帮你打开了这个链接,服务端就会报错,甚至直接断开。所以 render 函数的最佳实践是与对应页面在 app.json 中的 name 配置保持一致。
▐ 总结
总结,SSR 应用中相关路径与页面路由的关系如下图所示:
技术问题
在搞清楚上面的路由问题以后,很快地完成了 CSR 应用路由向 SSR 应用的迁移工作,顺利在本地启动了页面,并且成功验证了 SSR 页面和 CSR 页面功能。
但意外还是发生了:在预发布的云构建时,出现了熟悉的 window 错误——环境错误吗。
查了 diff,把所有改造时带 window 的对象全干掉了,再试错误还在,继续删删删,直到把这次改动删得七零八落,错误依然存在。
回忆想起,引了 jsdom 做服务端环境模拟,即使模拟再不给力,window 不至于不存在,更何况本地开发和构建都成功了。
这个错误像是构建时期执行了页面代码,于是把问题提交给了架构组。
很快,架构组同学确认了问题存在并给出了解决方案:一个尚未正式发布的构建器。
在测试预发 SSR 链接时,新的环境问题出现了。
定位了一下问题,发现是一个调试插件的锅。梳理一下它的执行逻辑,大概是这样:
window.__mito_result = 'something'; // 定义变量,挂载在 window 上 console.log(__mito_result); // 使用变量时,没有通过 window
变量挂在了 window 下,但没有通过 window 访问,这种直接读取未明确定义变量的方式,运行时会从当前环境的 globalThis 找,node 端的 globalThis 并不是 window,导致这个变量没有找到,报 not define
错。
这个问题有几个解决方式:
因为这个插件只会注入到预发文件里,不影响正式发布的文件。因此而修改插件或者功能实现,感觉做得有点重。综合下来还是选择了环境模拟方案。
一体化应用在框架侧进行用户自定义环境变量模拟有以下几步:
// 第一步、在 build.json 里配置 mockEnvBrowser // build.json{ "web": { "ssr": { "mockBrowserEnv": true }, },} // 第二步、在 render 函数中进行传参// src/apis/render/render-function-path.jsexport default withController({ middleware: [ downgradeOnError(PAGE_NAME), phaIntercept(PAGE_NAME), ],}, async () => { // 。。。 const ssrRenderer = await useSSRRenderer(PAGE_NAME, { mockBrowserEnv: true, // 需要再次配置为 true globalVariableNameList: [ // 待模拟的变量名列表 '__mito_data', '__mito_result' ], // 可以不对上面的变量进行实现,这时上面的变量在执行时值为 undefined browserEnv: {}, // 待模拟变量的对应实现 }); // 。。。}
这里顺便研究了一下框架侧的环境变量实现,还挺有意思的。在 useSSRRenderer 源码里,如果发现配置了 mockBrowserEnv: true
,会走到下面这个逻辑,其中最核心的是字符串构造的函数:
剥离一下它的执行核心逻辑:
// 定义一个执行函数function mockEnvFn (...globalVariableNameList) { // 定义形参 execute('page.js'); // 页面处在 mockEnvFn 函数的上下文,页面逻辑中需要用到 window 的,可以从该函数的传参中取得} // 执行该函数mockEvnFn(globalVariableList); // 传入实参
框架层给页面函数包了一个外层函数,为这个外层函数定义了形参列表,然后执行这个外层函数,这样页面函数就处于形参的上下文里,从而实现了环境模拟。
在 CSR 的 app.json 中,有 metas 属性和 scripts 属性,可以向文档中插入一些媒体属性和页面代码执行前的相关工具库脚本。但是 SSR 模式下,这两个属性不会生效,需要将其改写到 documents 中。与 metas 标签有一点区别的是,js 执行脚本最好放到 dangerouslySetInnerHTML 属性中,执行位置在
Copyright © 2023 leiyu.cn. All Rights Reserved. 磊宇云计算 版权所有 许可证编号:B1-20233142/B2-20230630 山东磊宇云计算有限公司 鲁ICP备2020045424号
磊宇云计算致力于以最 “绿色节能” 的方式,让每一位上云的客户成为全球绿色节能和降低碳排放的贡献者