原来是纯客户端渲染的单页应用。功能上没问题,但两个致命缺陷。
一是搜索引擎抓不到内容。爬虫拿到的 HTML 是一个空的 div id="app",商品名、价格、描述全靠 JS 渲染。虽然主流搜索引擎号称能执行 JS,但实际抓取的时机和完整度都不可控,收录效果很差。对一个靠自然流量获客的官网来说这是直接影响收入的问题。
二是首屏太慢。用户要等 JS 下载、解析、执行、发请求、拿数据、渲染,才能看到内容。在网络较差的地区这个链条要好几秒,期间是白屏。
但也不能无脑全上 SSR——SSR 有服务器成本、有 Node 服务的运维复杂度,而且有些页面压根不需要(用户中心是登录后才看的,不需要被收录,也不在乎首屏那几百毫秒)。所以核心决策是按页面分配渲染模式,而不是全站统一。
/en/ /ja/ 这种形式对 SEO 最友好(路径清晰、易于配置 hreflang),子域名要各自建立权重,查询参数容易被判定为重复内容。x-default 指向语言选择页或默认版本。为什么不全站 SSG。SSG 最快最省,但商品价格、库存、活动信息经常变,全站 SSG 意味着每次变更都要重新构建部署,几千个商品页的构建时间会长到不可接受,而且变更生效延迟太大。折中方案是「SSR + 短 TTL 缓存」——它在效果上接近 SSG(大部分请求命中缓存直接吐 HTML),但内容变更几分钟内就能生效。这个判断的依据是内容变更频率,要能说出来。
SSR 带来的运维成本。从「一堆静态文件丢 CDN」变成「要维护一个 Node 服务集群」,多了进程管理、内存泄漏排查、扩容、灰度这些问题。SSR 的内存泄漏尤其麻烦——服务端渲染时如果在模块作用域挂了状态(比如全局的 store 单例),会导致请求之间数据串台,而且这类 bug 在开发环境压根不复现。这个代价必须主动承认。
踩过的坑:hydration 不匹配。服务端渲染的 HTML 和客户端首次渲染的结果不一致,控制台报警告,页面会闪一下或者事件绑不上。原因通常是渲染时用了服务端和客户端不一致的值——最典型的是 new Date()(服务端和客户端时区/时刻不同)、随机数、window 相关的判断(服务端没有 window,条件分支走的路不一样)。修法是这类逻辑挪到 onMounted 之后执行,或者用专门的客户端渲染包装。这个坑是 SSR 的头号常见问题,必须能讲。
没做的部分:没做 ISR(增量静态再生成)。理想状态是静态页面在后台按需重新生成,兼具 SSG 的速度和 SSR 的新鲜度。当时的技术栈支持不完善,用 SSR 加缓存替代了,效果接近但服务器成本更高。
LCP:用 Lighthouse 在固定的节流条件下测(比如模拟 4G、CPU 降速 4 倍),跑多次取中位数。节流条件必须说出来——不节流的话本地测 LCP 都在几百毫秒,那个数字没有参考价值。改造前约 3.3s,改造后 1.4s 上下。更有说服力的是真实用户数据(RUM),能按国家分别看 P75,但那需要埋点和数据平台支持。
「爬虫能抓到完整内容」怎么验证:三个手段。一是用 curl 直接取 HTML 看里面有没有商品名和价格(最直接,改造前是空壳);二是用搜索引擎的站长工具做「实时抓取测试」,看它渲染出的页面;三是过一段时间用 site: 查收录量的变化。不要报「SEO 流量提升 X%」——那受内容、外链、竞品、算法更新影响太多,把它算成自己的技术成果一问就穿。
缓存命中率:SSR 的 HTML 缓存命中率要能报出来,它直接决定服务器成本。这个数字从 CDN 或者网关的监控取。命中率如果很低,说明缓存键设计有问题(比如把用户相关的维度加进了缓存键)。
最早的国际化是「把所有语言的文案打进一个大 JSON,全量加载」。支持 3 种语言时还行,加到 8 种之后语言包接近 200KB,而用户只需要其中一种,剩下的全是浪费。
更严重的是一堆隐蔽的正确性问题,这些比体积问题难缠得多:
金额精度。后端返回浮点数的价格,前端做运算(算总价、折扣),浮点误差累积后出现「三件 9.99 显示成 29.969999」这种情况。日期时区。后端返回 UTC 时间,前端直接格式化显示,用户看到的是 UTC 时间不是本地时间,「订单在昨天创建」显示成前天。数字和日期格式。硬编码成 YYYY-MM-DD 和逗号千分位,但不同地区的习惯完全不同(有的用点做小数点、逗号做千分位)。复数和语序。把「你有 {n} 件商品」拆成「你有」+ n +「件商品」拼接,换成其他语言语序完全错乱。
所以这个模块的核心不是「翻译」,而是把所有和地区相关的逻辑统一收口,不允许散落在业务代码里手写。
price * quantity,然后出精度问题。Intl.DateTimeFormat 要传 timeZone,优先用用户在设置里选的,没有则用 Intl.DateTimeFormat().resolvedOptions().timeZone 拿浏览器时区。不要用固定偏移量(比如 +8)代替时区,因为夏令时会导致偏移量在一年中变化,用固定值必然在切换日期前后出错。margin-inline-start 代替 margin-left,padding-inline-end 代替 padding-right。这样切换 dir="rtl" 时布局自动镜像,不需要写两套样式。但有些元素不能镜像——比如带方向含义的图标(返回箭头要镜像,播放按钮不要)、数字和代码(永远从左到右),这些要单独标记。为什么不把翻译放到后端接口返回。那样前端不用维护语言包,但代价是每次请求都要传大量文案,而且静态文案(按钮、标签)压根不该走接口。我们的划分是:UI 文案在前端语言包,业务数据的文案(商品名、描述、运营配置的文字)由后端按 locale 返回。这个划分的依据是「变更频率和管理者」——UI 文案跟着代码走由开发管,业务文案跟着数据走由运营管。
按需加载语言包的代价。首包小了,但切换语言时要重新加载对应的语言包,有短暂的加载态。而且如果拆分粒度太细,会导致请求数变多,在高延迟网络下反而更慢。所以粒度控制在「按路由分组」而不是「按组件分」——太细了得不偿失。
踩过的坑:SSR 环境下 Intl 的默认时区不对。服务端渲染时 Node 进程的时区是服务器时区(通常 UTC),渲染出的日期是 UTC 时间;客户端 hydrate 时用的是用户本地时区,两边不一致,导致 hydration 警告和日期闪烁。修法是日期这类依赖环境的内容不在 SSR 阶段渲染,服务端输出占位或者 UTC 的原始值,客户端挂载后格式化。或者把用户时区通过 cookie 传给服务端,两边用同一个时区渲染。这个坑是 SSR 和国际化叠加才会出现的,能讲出来说明是真做过。
没做的部分:没做翻译平台对接。文案是靠人工维护 JSON 文件,翻译更新要提代码。规模再大应该接翻译管理平台,让翻译人员直接在平台上改,前端通过 API 或者构建时拉取。
语言包体积:看构建产物的分析报告(webpack-bundle-analyzer 之类),对比改造前后主包里语言包相关的体积。改造前约 190KB(8 种语言全打进去),改造后主包只有 common 部分十几 KB。要说清是压缩前还是压缩后的体积——gzip 后 JSON 的压缩率很高,两个数字能差三四倍,不说明白容易被认为在夸大。
「未再出现金额精度与时区偏差问题」:这是定性描述,依据是问题工单的分类统计。不要写成「精度问题降为 0」——绝对化的表述一定会被追问,而且只要有一个漏网的案例就被推翻。用「未再出现」并且能说出改造前的具体案例(比如「三件 9.99 显示成 29.969999」),比给一个百分比可信得多。
可以补充的验证手段:写单元测试覆盖各 locale 下的格式化输出(美元/日元/欧元的显示、夏令时切换日的日期转换)。这类正确性改造用测试用例数量比用百分比更有说服力。
transform: scaleX(-1)。二是混排——阿拉伯语句子里嵌了英文品牌名或数字,方向会混乱,需要用 Unicode 的方向控制字符或者 bdi 标签隔离。三是动画方向,轮播、滑入这些动效在 RTL 下应该反向。四是第三方组件,很多组件库的 RTL 支持不完整,日期选择器、下拉菜单的定位经常出问题,要逐个验证。RTL 的工作量通常被严重低估,真正的成本在这些细节上。
terms 插槽里放一个 router-link。这样翻译人员可以自由调整占位的位置以适应各语言的语序,而链接本身由代码控制。如果确实要渲染富文本文案(比如运营配置的带格式的公告),必须先做 HTML 净化,只允许白名单标签。
性能问题的起点是没有数据。改造前团队对性能的认知只有「我这边打开挺快的」——因为开发都在网络好的地方用好机器。而真实用户分布在多个国家,一部分人用几年前的低端安卓机、3G 或者不稳定的 4G。没有分国家、分机型的监控数据,压根不知道该优化什么。
建了监控之后问题清晰了:首包 JS 三百多 KB(gzip 后),在慢网络下要几秒才下载完;LCP 元素是首页的主视觉图,而它是 JS 执行后才插入的,等于必须等完整个 JS 链路;一部分低端机上 hydrate 的 CPU 耗时明显。
所以三个方向:把首包压下去、让 LCP 元素尽早出现、持续监控防止回退。第三点最容易被忽略但最重要——性能优化做完不加门禁,三个月后就会退回原样,因为每个人都只多加一点点依赖。
Intl 替代日期库;工具函数改成按需引入或者手写几个;图表库如果只用一种图,换成体积小的专用库。每个替换都要评估「省下的体积」对「重写的成本和风险」,不是所有依赖都值得换。preload 和 fetchpriority="high",让浏览器一拿到 HTML 就开始下载这张图。这一步对 LCP 的改善通常比压缩 JS 体积更直接。srcset 和 sizes 让浏览器按屏幕宽度和 DPR 选合适的尺寸,用 picture 提供现代格式并保留兜底。给手机发一张 2000px 宽的图是最常见也最浪费的错误。aspect-ratio 预留空间。CLS 是三个核心指标里最容易修也最容易被忽略的。font-display: swap 避免字体加载期间文字不可见;只加载实际用到的字重(很多项目引了 6 个字重实际只用 2 个);中日韩字体要做子集化,全量字体文件几兆,必须按实际用到的字符裁剪。体积门禁的阈值怎么定,以及它的副作用。阈值太松没意义,太紧会挡住正常的功能开发(有时候确实需要引入一个库)。做法是设一个当前值加少量余量的阈值,并允许通过 PR 显式提升阈值(但要走评审)。这样变大是可以的,但必须是有意识的决定而不是悄悄发生的。副作用是会引起团队摩擦,所以引入时要先沟通清楚目的,否则会被当成阻碍。
监控上报本身的成本。上报请求会占用带宽和后端资源。做法是合并上报(攒一批一起发)、用 sendBeacon(不阻塞页面卸载)、性能数据采样(不需要全量,采样足够看分布)。错误数据全量上报但要做去重和限流——一个死循环里的报错能一秒上报几千次,必须在客户端就限流,否则会把监控服务打挂。
踩过的坑:优化了指标但体验没变好。为了压 LCP,把首屏图换成了一张很小的模糊占位图,LCP 数字确实降了,但用户看到的是一张糊图,体验更差。这是典型的「优化指标而不是优化体验」。修正后的做法是保证 LCP 图片本身质量合格,通过预加载和格式优化来提速,而不是靠降质量刷指标。这个反思很值得讲——它说明你理解指标是手段不是目的。
没做的部分:没做首屏的服务端流式渲染(把 HTML 分块流式输出,让浏览器更早开始渲染)。这对首屏有进一步的收益,但改造成本和风险较高,当时的收益优先级排在后面。
首包 JS 体积:构建产物分析工具直接给出,要明确说是 gzip 后的体积(或者 brotli),因为原始体积和压缩后能差三倍以上。「310KB 降到 130KB」如果不说压缩状态,容易被认为在挑好看的数字。另外要说清「首包」包含什么——是入口 chunk 加公共 chunk,还是包含了首屏路由的 chunk。
LCP:两套数据都要有。实验室数据用 Lighthouse 在固定节流条件下多次取中位数,用于开发时对比;真实用户数据用 Web Vitals 上报后按国家看 P75,用于判断真实效果。两者会有明显差异,实验室数据通常好看得多,所以简历上如果只报实验室数据要注明条件。
「LCP 元素在 HTML 内直接可见」怎么验证:curl 取 HTML 看首屏图的 img 标签在不在里面;或者在 Performance 面板看 LCP 元素的出现时机是否早于 JS 执行完成。这是个结构性的改变,比数字更能说明问题。
不要报「跳出率下降」「转化率提升」。这些业务指标受太多因素影响,把它们算成前端性能优化的成果是站不住的。如果确实有 A/B 实验数据支撑可以提,但要说清实验设计。
没有匹配的内容,换个关键词试试。
项目拆解 · 国际站官网(Vue)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据