在上一篇介绍《天猫汽车商详页的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 应用里共有四处路由相关:
页面源码目录:通常放在 src/pages/ 根目录下,较少嵌套情况;
app.json 配置:app.json 里配置各页面的访问路由与对应页面资源;
SSR render 函数目录:SSR 页面的渲染逻辑所在之处,位于 src/apis/render/ 下,可能有嵌套;
render 函数中的 PAGE_NAME:render 函数需要使用的页面资源路径,服务端靠执行这个页面文件生成有内容的文档。
这四处配置互相影响,相关文档又语焉不详。比如 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 函数中的 PAGE_NAME
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 函数的目录路径
先说结论,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 应用中相关路径与页面路由的关系如下图所示:
pages 页面资源路径决定了 app.json 里的 source 配置
app.json 的 name 配置决定了构建后产物位置(CSR/SSR 产物都依赖这个值)、CSR 访问路由
render 函数文件的路径决定了 SSR 访问路由
render 函数中的 PAGE_NAME 决定了该函数使用的页面资源,需要与对应页面的 name 值配置保持一致
技术问题
▐ 云构建问题
在搞清楚上面的路由问题以后,很快地完成了 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.js
export 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 属性中,执行位置在 <Script /> 组件之上,<Root /> 标签以下。
在实际构建产物中,CSR 链接取的是 app.json 中的 metas/scripts 配置,SSR 链接取的是 document 中的标签。
需要说明的是,当 SSR 链接降级到 CSR 模式时,metas/scripts 取的是 app.json 中的配置。因此,就算 SSR 应用没有投放 CSR 链接的需要,也应当同时在两处维护 metas/scripts,否则 SSR 降级后表现可能会不一致。
业务问题
在研发平台中,一个仓库只能接入一个应用,应用生成的访问链接又与应用名称紧耦合。使得合仓后发布的 CSR 链接与原应用的 CSR 链接不同,需要测试侧进行功能回归和业务侧进行链接替换。
▐ 由于资源访问特性带来的测试问题
由于 SSR 应用发布时不构建的特性,日常和预发构建的文档会被用作线上,文档中的资源访问地址也都默认打到线上(访问资源的链接都是 g.alicdn.com)。而资源的发布并未变化(预发时资源在 dev.alicdn.com,线上时资源在 g.alicdn.com),这就导致预发文档加载时拿不到线上资源。
SSR 服务端可以能够通过动态设置 webpack_public_path 改变构建文档里的资源链接:
预发链接就能够正常访问到预发 cdn 下的资源。
修改 __webpack_public_path__
实质上是 SSR 拿到运行时的配置,在访问时动态替换了资源链接。而 CSR 模式下,文档在构建结束后就生成了,没有办法在运行时动态更改资源链接。
为了测试同学能够正常工作,需要在访问网页时,通过资源代理的方式,对资源路径进行重定向。
▐ 商详链接替换问题
至于业务域如何平稳替换线上链接,这就与每个业务的具体情况相关了。
以汽车商详为例,我们既有搜索、会场、店铺、订单、互动等基础链路域的访问,也有小程序等汽车阵地的导购引流,以及端外留资的商业化线索投放。本就需要根据不同场景,投放不同的链接,需要各处去分别推动。
总结
SSR 改造做到现在,各种分享做了不少。曾有其他团队的工程师问我,怎么你碰到的这些问题,我一个都没有碰到过?
我自己复盘后,结论是:原本为改造项目,需要带着镣铐跳舞。原项目已经做了一年多,本身复杂度比较高。此外,业务场景的多样性,使我们不得不同时维护 CSR 和 SSR 链接,这也是大多数 SSR 应用不必承担的责任。
这三点原因使我不得不去探索少有人走的道路,虽然曲折,好在走通了,这也要感谢架构团队大佬们的支持。
另外,也有一些别的体会。在推动一项技术在团队内落地的过程中,很大一部分因素不在技术层,而在于需求的定位、资源的协调、业务的落地。这些非技术问题如果能想在前面做在前面,对技术的落地只会大有裨益。
团队介绍
我们是阿里巴巴大淘宝技术汽车技术团队,是一支集研发、数据、算法一体的部门,利用互联网+数字化垂直整合汽车行业,打造消费者线上看车、买车、养车的极致体验。在这里,你会接触到新零售核心技术,交易、供应链、结算、阵地运营等。在这里,团队氛围融洽,业务发展迅猛,技术挑战多多,让你有商业思考,又有技术深度。阿里汽车高速发展的路上,期待您的加入!
¤ 拓展阅读 ¤
3DXR技术 | 终端技术 | 音视频技术
服务端技术 | 技术质量 | 数据算法