使用 Next.js 改造大型电商站点经验分享
在调研使用Next.js开发电商类网站时,读到 Jonne Kats 的这篇文章 ,觉得很有价值,于是略译出来。
Jonne 公司团队 使用 既有技术栈(ASP.NET + React)开发一些新大流量项目 ,发现有性能瓶颈,尝试迁移 Next.js 后成功 改善了性能,并总结了多条 项目经验 ,我以为是很有价值。
Rebuilding A Large E-Commerce Website With Next.js (Case Study)
我们公司为客户开发电商应用 已经 有很多年,这些年也经历了行业技术的演变,从原初 的服务端渲染的传统网站,到 很强前端交互的 JS 单页型应用。
最初我使用是的纯 ASP.NET,后来 前端交互开始增加,我们选用了React。虽然,ASP.NET 混合 React看起来增加了复杂性,但是想到这种混合 能满足用户需求,我们依然觉得很高兴。直到我们遇到 大流量用户。
基于各种原因,我们使用混合技术栈开发的 大流量项目 上线后,Core Web Vitals标示出性能问题。性能对于电商网站成功至关重要,有研究报告发现,网站性能改善一点点收益都很大,如响应速度提高 0.1秒,流量转换高达 10%。
为了缓解性能问题,我们想尽了各种办法,包括添加服务器,设立反向代理来添加页面缓存,这些新部署甚至牺牲了部分 应用功能 。但即使 部署复杂 ,牺牲功能 和 加大成本 ,收效依然不理想。
很显然 我们对这些改造 是不满意的,直到我们发现了Next.js。
Next.js 支持静态页面部署到像 Vercel 或 Netlify 这样的CDN上,访问延迟很低,再加支持 后端渲染,非常适用开发 电商应用注1 。
新工具挑战
Next.js 很清新,但一定有挑战性注2。第一,社区很新,没有太多的社区经验;第二,即时可见的开发体验大大提高开发效率同时,也加速引入bug的速度。因为当我们只关注代码开发效率时,会忽视了代码的可维护性;第三,随着代码量的增加,JS的无类型 也会损害 代码库的品质,bug的数量 和 代码生产效率成正比。
面对这些挑战,我们积累了以下一些经验 :
- Modularize Your Codebase
- Lint And Format Your Code
- Use TypeScript
- Add Automated Tests
- Plan For Performance And Measure Performance
- Add Performance Checks To Your Quality Gate
- Aggressively Manage Your Dependencies
- Use A Log Aggregation Service
- Next.js’s Rewrite Functionality Enables Incremental Adoption
1 给你的源码库设计一个好的概念模型 |Modularize Your Codebase
项目 目录结构 是需要一定的组织设计的,但是目前还没有一致的最佳实践。Next.js的 项目生成工具(create-next-app)会自动生成一个 直观简易的项目目录,如果你不加设计的直接使用,那么有可能后期会出现 “大泥球” 困难。
/public
logo.gif
/src
/lib
/hooks
useForm.js
/api
content.js
/components
Header.js
Layout.js
/pages
Index.js
我们一开始也直接使用这个目录结构,只是在 /components 内为一些 大组件 创建子目录。这种直观设计 对于小型项目 是够用的,但是,随着项目演化,我们发现项目中 组件关系很难记住。我们甚至发现有一些组件 已经无用了!这预示这种目录设计随代码的增长一定会出现 “大泥球” 的现象 ,因为你很难 推理 组件之间的依赖关系 。
大泥球是指一个随意化的杂乱的结构化系统,只是代码的堆砌和拼凑,往往会导致很多错误或者缺陷。
为此,我们重新设计了目录结构,在目录上加了一层 功能模块 (functional modules )的组织。
/src
/modules
/catalog
/components
productblock.js
/checkout
/api
cartservice.js
/components
cart.js
上面的示例中,/modules 目录 分出两个子模块 —— 产品列表(catalog)和 下单流程(checkout)。
这种设计 明显提高了 代码的组织性:从目录名你就知道了代码将实现的功能,和从哪里去修改代码。
这种设计 也更能明了代码之间的依赖关系。产品列表 和 下单流程 有自己的互不依赖的组件,也有通用的组件(common)。在分支设计上 catalog 和 checkout 分别从 common的公共分支分出来。
/src
/modules
/common
/atoms
/lib
/catalog
/components
productblock.js
/checkout
/api
cartservice.js
/components
cart.js
/search
/project
/layout
/components
/templates
productdetail.js
cart.js
/pages
cart.js
在 模块 之外,我们还设计 一个 并立 的 project 模块 管理 全局的布局模板,和通用页面模板(page templates)。设计大概是这样:
在 project 目录里,我们保存一些全局的 layout,和通用的页面模板。在 Next.js设计 里,保存在/pages目录的是页面路由,一个文件对应一个路由项。而我们的经验是,/pages 页面一般都 会动态的参数化组装,所以 /project 有 一些 page templates,用来组装 路由页面。
review:Jonne 这个 目录设计有创新性,但不得不说已经落后了。不但是因为 Next.js 13的新路由,还有就是他的会话结构意识还没有。
2 添加品质自动化审查 | Lint And Format Your Code
我们学到的第二条经验是,多人项目一定要使用 自动化格式工具,像linter。人工约定(所谓代码规范),和人工审查是不够的。
linter 是一种 代码缺陷防范检测工具,同时支持格式化检查,能让你的代码保持一定的品质(包括Web性能指标 Core Web Vitals)。我们使用的是 ESLint + prettier。有了这种自动化格式工具,我们可以卸下代码规范的思想包袱,专心推理,有格式问题让 linter 来提示。
Next.js自 11起内置支持 ESlint,包括内置了 React的规则,和Next.js自己特定的扩展规则,完全开箱即用。
3 添加程序行为强类型约束 |Use TypeScript
我们学到的第三条经验是,体验了 强类型审查(TypeScript)的价值。
我们发现,经过了一段时间,一些有被重构(refactored)过的组件,有 prop已经 没有被用了 ,并且也遇到过一些组件prop类型不匹配的bug。这些都是因为没有使用强类型检查 而导致的。
一开始 ,我们没有觉得项目引入 TS有什么意义 ,甚至觉得增加了多余的抽象 。不过 我们有一个同事对TS比较熟,坚持让我们试一试。幸好,原来 Next.js 开箱即用 TS!并且支持“试用”——可部分代码使用TS,不必一次性全面升级。
让人惊艳的是,当我们开始引入TS后 就立即发现了 组件调用 存在着类型问题。TS另一大利好是,开发测试反馈周期更短,在IDE上就发现问题,不必启动浏览器。我们还发现,TS还有助于代码重构,因为你可以很容易查看 组件间的依赖关系(包括调用 参数信息)。总体来说,TS的好处是:
第一,降低的 bug的数量;第二,代码更易重构;第三,代码更易分析阅读;
4 添加自动化测试和持续集成工作流 | Add Automated Tests
我们学到的第五条经验是,引入自动化测试 ,并且引用持续集成工作流。
如果项目 不引入单元测试(或其他类型测试),随着代码库的演化,你很难保证对某个模块(尤其是通用模块)的重构 不会出现问题。我们经验里,引入 端到端(E2E)测试 可以有效防范这些危险。即使是很小的项目,引入简单的冒烟测试(注:冒烟测试类似端到端测试的快速简版)也是很有必要的。
测试 工具我们选择 Cyress 。此外,我们还引入持续集成工作流,结合Netlify 和 Vercel 创建 一个临时的集成发布环境 ,可以很方便 在其上对每个 推送请求(Pull request) 进行 E2E 测试 。我们使用 Github Action 和这个 脚本 。
此外,根据项目的类型和性质,我们可能需要添加 组件测试(Enzyme)和 单元测试(JEST),在更细致的粒度上对程序品质进行保卫。
5 认识页面的渲染性能特性并实施策略 | Plan For Performance And Measure Performance
我们学到的第五条经验是,为不同类型页面选择不同的渲染策略改善性能,另外还要对最终打包到的 bundle 进行策略性调优。
Next.js 同时支持 页面的静态(编译时)生成(SSG)和运行时服务端动态渲染(SSR)。编译时静态渲染一定是性能是最优的,但不是所有页面都适用,例如 产品详细页面,库存信息(stock)会变化,不可能编译时能知道的,如果每次库存变更都编译一次 显然 是不现实的。
另外,大规模静态页面的项目 ,SSG 也会损害 编译性能,加载性能(bundle大小),针对这个问题 Next.js 还有一种 增量式渲染( Incremental Static Regeneration ), 编译只生成一个模板,具体的页面在 具体请求时才真正渲染。
有了这些策略工具,当我们设计一个页面时,为了性能选择最佳的渲染方式。尽量选择 SSG,若满足不了 再考虑 ISR,和性能损耗最大的 SSR。
6 在持续集成工流中添加性能自动检测 |Add Performance Checks To Your Quality Gate
第六条经验是,将性能指标的测试(lighthouse) 像E2E测试 那样纳入持续集成的自动化工作流中。
工作中,我们发现即使 Next.js 有很多性能调优策略,像SSG,性能依然不稳定。我们试过好多次,给项目添加新功能后,性能指标的分数(lighthouse score)坠降。为此,我们使用 Github Action 为 lighthouse score 添加 了自动化测试 。我们使用是这个脚本 https://github.com/treosh/lighthouse-ci-action 。
7 谨慎对待为项目添加的依赖 |Aggressively Manage Your Dependencies
第七条经验是,主动有意识地对 引入新抽象工具(及工具版本)依赖 进行利害判别。
大项目会引入大量的复杂性需要专业(抽象过)的工具(package),在有没必要使用第三方工具,使用哪一个,以及哪一个版本上 都有额外的工作,因为项目增加新依赖是有代价的。我们试过很多次 为项目 添加新依赖,或更新了版本 而产生bug和性能下降。
所以 ,在为项目 安装 新NPM package前,你必须先问自己几个问题:
- 这个包质量如何 ?
- 对最终bundle的 大小有什么影响?
- 这个包真的有必要吗,有没有其他替补方案?
- 这个包 是否还活跃开发?
理论上 增加依赖一定的会对性能有影响的,所以无必要不增依赖,尽量让编译结果轻盈。
注:这个 VSCODE插件 可以自动检测 引入包的大小。
Next.js 新版跟进
跟上技术趋势,怎么说都是对的。Nextjs 和 React 还在不断演化,新版利好是无疑的。利好包括 新功能,新概念模型 ,和新的安全方面的修正。Next.js官方 提供 了自动化的升级工具 Codemods。
更新所有依赖
同样道理,其它工具依赖也应该跟进。Github的 dependabot 帮助我们完成这项任务,它会为每个 依赖更新 产生一个 推送请求(Pull Requests)。更新依赖有利好,但也必须特别注意,因为有可能产生错误(break things),所以必须对更新进行严格测试。
知识:推送请求 和 新版本审查 A pull request is a request to merge changes into a project's code repository It is a way to submit and review changes, also called patches, before integrating them with the main branch.
A pull request notifies the repository maintainer or manager, who is responsible for approving the updates.
8 添加日志监控 | Use A Log Aggregation Service
为确保 应用程序 的功能正常,及尽早发现问题,我们发现 非常有必有在系统中集成一种 日志聚合的服务(log aggregation service)。
Vercel 的控制台有提供 app 运行的即时日志,但这是活动的一次性的,并且不支持自定义编程,既不能保存日志,也不能在别的地方查看到这些日志 。我们需要额外的工具。
我们有过样的的经验,就是有些异常反馈得很慢。例如,我们在某些页面使用 swr (Stale-While-Revalidate )缓存策略后存在bug,这些页面上线后 被 我们发现数据没有被正确的刷新,而当我们查看 Vercel 的日志 后发现这些页面在渲染时报错了。如果我们有 一个 日志聚合服务,那么发现异常会更加迅速。
日志聚合功能 还可以用来 监视 Vercel 的云主机套餐的超额事件。Vercel 的控制面板 可以查看套餐使用情况,但是 日志聚合功能 可让我们 在达到某种阀值时自动收到通知。
Vercel 官方提供了很多即用的 日志聚合 集成工具 ,流行的有:Datadog, Logtail, Logalert, 和Sentry。
知识:stale-while-revalidate是什么
stale-while-revalidate 是HTTP的响应头cache-control的一个扩展,它允许浏览器发现一个过期缓存(stale)时返回这个已经过时的响应缓存,同时背后默默重新发起一次请求(revalidate)。这样隐藏了服务器(或网络)的响应延时。平衡了「新鲜性」和「快速响应体验 」之间的矛盾。
9 按需逐步改造 |Next.js’s Rewrite Functionality Enables Incremental Adoption
最后一条经验,对于一些旧项目,可以使用Next.js的Rewrite 功能 实现逐步的改造。
我们遇到的大多数客户,对站点全面改造 都是抗拒的,他们更感兴趣的是对性能痛点进行针对性的“治疗”。这也是我们所学到的,不必全盘推翻原有的代码,只是针对某些性能关键的页面进行改造。例如,我们曾试过改造产品详细(product detail),和产品分类(category),大大提高了站点性能。
Next.js的Rewrite 功能 可以实现这个目标。
小结
要做出一个有商业价值的成品 ,需要多方面的努力,多方面的知识,技能和经验。商业软件品质靠 高价值功能,优越性能和 高效成本控制。
我以为 Jonne团队的这些经验 都是直接或间接围绕提高 开发出有品质的 商业网站的,并不是仅仅是改善性能这一条。
例如:
- 1 先进的目录结构(会话概念模型)改进开发体验,更好的控制 维护成本 ;
- 2 品质审查、3 强类型编程 和 4 自动化测试 是保证程序功能和安全和正确性;
- 5 渲染策略是直接 改善性能,而6 性能检测 和 7 审慎对待新依赖 则 间接的;
- 8 日志监控 属于 间接的保证 程序功能 的安全和正确性;
- 9 逐步改造 应该属性于 改进开发体验;