怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

无实习这一档是给谁的 完全没有实习经历时,简历上唯一能写的就是自己做的项目。但「图书馆座位预约」「学生成绩管理」这类选题面试官一年看几百份,而硬套企业项目(微服务、分布式事务、消息中间件)会被一问就穿——他很清楚学生不可能有那种环境。这一档收的是复刻主流产品的核心链路、一个人真能做出来、技术栈朴素但有真实难点的项目。这一档的模块比企业档多——因为企业项目里你只被分到一个模块,而个人项目是从登录态到手势交互全都你自己写的,能讲的面本来就宽。下面六个模块彼此独立——只做过哪几个就只写哪几个。
怎么讲才不像课设 复刻类项目的含金量不在「我做了个图文社区」,而在「我搞懂了它们为什么那样设计」:为什么主流图文社区的瀑布流图片位置从来不跳、为什么它们的列表图明显比详情图糊、为什么发布页在你选完图之后就开始上传了。你能把这几个细节讲出原因,就已经超过绝大多数只会讲功能的人。
只写你当场证得出来的 个人项目最容易穿的不是技术,是那些用来撑场面、但一追问就散的外部背书:「有几百个注册用户」会被问用户从哪来、怎么收集反馈、留存多少;「开源项目」「有人 star」「参赛获奖」同样如此——面试时你拿不出来,说了等于给对方一个突破口正确的立足点只有一条:我没有真实流量,但我能复现问题、能讲清每个数字的来路。造几百条带真实图片的测试内容、用弱网工具把上行限到几十 KB、在低端安卓机上滑到几百条——这些条件你能当场复述,它比任何用户量都硬。
三条自检 一、你踩的坑能不能复现——能说清「用什么机型、什么网络、造多少数据,它就必然出现」;二、能说出坑的根因,尤其是「在我的电脑和好网络下完全正常」的那种;三、每个数字都能回答「这个数是怎么来的」(多少条数据、什么机型、限速到多少)。三条都有就能写。
项目背景设定 我照着几个图文内容社区的样子做了一个客户端(双列瀑布流首页、图文详情、多图发布、个人主页),uni-app + Vue 3,跑在微信小程序和安卓上,后端是我自己写的一个简单的 Spring Boot 服务,图片存在服务器本地目录。
数据和测试条件先说清楚 这是个人项目,没有真实用户量。下面所有数字都来自我自己构造的条件:用脚本灌了几百条内容、每条挂 1 到 9 张真实拍摄的图片(原图两三兆、长宽比刻意做得很杂,含超长图和近正方形)用开发者工具把上行带宽限到几十 KB 每秒模拟弱网;在一台旧安卓机上验证滑动帧率和内存这些条件我能当场复述,也能现场再跑一遍。
为什么这六块值得写 它们的共同点是在我的电脑加良好网络下全都正常,换到真实条件下全都崩。前三块(瀑布流、发布器、跨页状态)是被弱网和旧机器打出来的:图片位置乱跳、上传大面积失败、返回列表回到顶部;而我在排查它们的过程中反过来看懂了主流产品的几个设计——为什么它们的图片位置从不跳、为什么列表图那么糊、为什么选完图就开始上传。后三块(登录态与请求层、首屏性能、图片查看器的手势)是一个人做整个客户端绕不开、但教程里几乎不讲的部分——它们的问题不在单个页面里,而在「所有页面共用的那一层」和「用户手指同时触发了两种意图」这类地方。六块都是我自己写的,所以每一块我都能讲到根因;这也是个人项目相比「企业里被分到一个模块」的唯一优势——面比较宽,别浪费。

模块一:双列瀑布流与图片尺寸治理

  1. 双列瀑布流与图片尺寸治理(上传时记录宽高让前端能预留占位 + 分列的贪心均衡 + 列表缩略图与详情大图分离 + 视口外节点回收)★★★
    简历这样写 图文社区客户端(个人项目)(uni-app + Vue 3 + Spring Boot):首页双列瀑布流的图片位置在加载过程中持续跳动,根因是前端要等图片下载完才知道高度,改为上传时解析并存储图片宽高、列表接口一并返回,前端按宽高比预先撑出占位,图片加载前后布局尺寸一致;分列由「交替放入左右两列」改为放入当前较短的那一列,消除了两列长度差距过大的问题;列表原先直接加载原图(单图两三兆),改为上传时生成缩略图、列表用缩略图详情用大图,首屏图片总下载量大幅下降;长列表加入视口外节点回收,滑到数百条后帧率不再持续下降(占位高度可精确计算是回收得以实现的前提)。以上问题在电脑与良好网络下均不出现,是在旧安卓机与限速环境下复现的。
    展开完整拆解
    为什么要这么设计

    双列瀑布流是这类产品的门面,我以为最难的部分是「怎么把高度不一的卡片排整齐」。算法本身半天就写完了,真正花时间的是四个我完全没预料到的问题——而它们在我的电脑上一个都不出现。

    一是图片位置一直在跳。我的卡片高度是由图片撑开的,而图片的高度只有下载完成之后才知道结果是:列表先渲染出一堆没有高度的空卡片,然后图片陆续加载完,每加载完一张就把它下面的所有内容往下顶一次用户正在看的内容会反复被顶走,而且图片越多、网络越慢,跳动持续得越久。我在电脑上因为图片加载得太快,几乎看不出这个问题;换到限速环境下,整个列表跳了好几秒。

    二是两列长度差距很大。我最初的分列是「第一张放左边、第二张放右边、第三张放左边」这样交替。但图片高度差异很大,交替放的结果是某一列全是长图、另一列全是短图,两列底部能差出一屏多。

    三是列表直接加载原图,流量和内存都撑不住。我造的测试图片是手机原图,单张两三兆首屏 10 张卡片就是二三十兆的下载量——在限速环境下首屏要等很久;而在旧安卓机上多滑几屏就明显卡顿,因为几十张大图同时在内存里。

    四是不回收节点,滑到几百条就卡死。我用 v-for 直接渲染全部数据,节点数随滑动线性增长在旧安卓机上滑到几百条时已经滑不动了。

    所以四个改动:上传时解析并存储图片宽高、列表接口返回、前端按宽高比预留占位分列改为放入当前较短的那一列上传时生成缩略图,列表用缩略图详情用大图加视口外节点回收

    这个模块最想说的一句话是:前端之所以不知道图片高度,是因为这个信息在上传的那一刻就丢掉了——而它本来是知道的。我一直在前端想办法(先加载一遍拿尺寸、用固定比例猜),全都是在补救;真正的解法是在上传时把宽高存下来。想通这一点之后我去翻了几个主流图文社区的列表接口,发现它们返回的图片信息里都带着宽和高——原来这就是它们的图片位置从来不跳的原因。

    整体链路
    上传时就把尺寸信息留下来(这是关键的一步) │ ├─ 服务端接收图片 → 解析出宽高 → 连同图片地址一起入库 │ 解析在上传时做一次,之后永远不用再算 │ ├─ 同时生成缩略图(按列宽的若干倍出图,供列表使用) │ 原图保留,供详情页使用 │ └─ 列表接口返回:缩略图地址 · 原图地址 · 宽 · 高 不返回宽高的后果 前端只能等图片下载完才知道高度 列表先渲染无高度的空卡片,图片陆续加载,每张都把下面顶一次 用户正在看的内容反复被顶走,网络越慢跳动越久 前端渲染:先撑占位,再填图片 │ ├─ 拿到宽高 → 按「列宽 × 高 ÷ 宽」算出卡片图片区域的高度 │ 在图片还没下载时就把这个高度撑出来 │ ├─ 占位用纯色块,不要用转圈动画 │ 瀑布流里同时出现十几个转圈,视觉噪音很大 │ 而且会让人觉得「很慢」,即使实际速度一样 │ ├─ 图片加载完成后填入占位,尺寸完全一致 → 不产生任何位移 │ └─ 图片加载失败 → 占位保留,显示统一的失败图标 因为高度已经由宽高比确定,失败天然不影响布局 分列:放入当前较短的那一列 │ ├─ 维护两列各自的累计高度 ├─ 新卡片放进累计高度较小的那一列,然后更新该列高度 │ └─ 交替放入左右两列的后果 图片高度差异大 → 某列全是长图、另一列全是短图 两列底部能差出一屏多,视觉上很难看 而这个算法本身只是一次比较,成本几乎为零 图片尺寸分级(流量与内存的主要来源) │ ├─ 列表:缩略图(按列宽出图) ├─ 详情:大图(原图或按屏宽出的图) ├─ 直接用原图的后果 │ 单张原图两三兆,首屏 10 张就是二三十兆 │ 限速环境下首屏等很久;旧安卓机上几十张大图同时在内存里会卡 │ └─ 图片按需加载:只加载视口附近的 打开首页就发起几十个图片请求会互相抢带宽,反而更慢 长列表:视口外节点回收 ├─ 视口外的卡片只保留占位(高度已知,可精确计算) ├─ 节点数被控制在与屏幕相关的常数级,与总条数无关 ├─ 回收阈值要留缓冲,否则快速滑动会看到空白 └─ 回收能做的前提正是「高度可精确计算」 如果高度要等图片加载才知道,回收几乎无法实现 所以「上传时存宽高」这一个改动同时解决了跳动和回收两件事
    分步拆解
    1. 在上传时解析图片宽高并入库,这是整个模块的关键一步。前端不知道高度,是因为这个信息在上传那一刻就被丢掉了——而服务端当时是知道的。
    2. 列表接口必须返回宽高。不返回的后果是前端只能等图片下载完才知道高度,于是每张图加载完都把下面的内容顶一次。
    3. 前端按「列宽 × 高 ÷ 宽」预先撑出占位高度。图片加载前后尺寸完全一致,不产生任何位移。
    4. 占位用纯色块,不要用转圈动画。瀑布流里同时出现十几个转圈视觉噪音很大,而且会让人觉得慢——即使实际速度一样。
    5. 图片加载失败时占位保留、显示失败图标。因为高度已由宽高比确定,失败天然不影响布局。
    6. 分列要放进「当前累计高度较小的那一列」。交替放的后果是某列全长图、另一列全短图,两列底部能差出一屏多——而改成比较高度只是一次判断,成本几乎为零。
    7. 上传时就生成缩略图,列表用缩略图。直接用原图的后果是单张两三兆、首屏十张二三十兆,限速下首屏等很久。
    8. 详情页才用大图。两种尺寸各用在合适的地方——这也是为什么主流产品的列表图明显比详情图糊。
    9. 图片要按需加载,只加载视口附近的。打开首页发起几十个图片请求会互相抢带宽,反而更慢。
    10. 长列表要做视口外节点回收。不回收的话节点数随滑动线性增长,旧安卓机上滑到几百条已经滑不动了。
    11. 回收阈值要留缓冲。只保留视口内的话,快速滑动时来不及渲染会看到空白。
    12. 要意识到「上传时存宽高」这一个改动同时解决了两件事。除了消除跳动,它还让占位高度可精确计算——而这是节点回收得以实现的前提。如果高度要等图片加载才知道,回收几乎做不了。
    13. 历史数据要补宽高。我加这个字段的时候已经有一批图片入库了,写了个脚本回扫补齐——不补的话老内容仍然会跳。
    14. 宽高要做合法性校验。解析失败或异常值(为零、极端比例)要有兜底比例,否则前端算出的高度会是零或者撑满整屏。
    关键决策与取舍

    把「获取图片尺寸」从前端挪到上传时的服务端,这是我在这个项目里做过的最有价值的一个判断。前端的几种办法我都试过:先用一个隐藏的图片元素加载一遍拿尺寸(等于加载两次,更慢)、用固定比例猜(猜错了照样跳)、只在首屏用固定高度(滑下去还是跳)——全都是在补救,因为信息本身不在前端手里而服务端在接收上传的那一刻就能拿到宽高,存一下几乎零成本。判据是「这个信息最早在哪一步是已知的」:在上传时已知,那就在那时候存下来,不要等到用的时候再想办法算。

    列表用缩略图而不是统一用原图,代价是要多存一份文件。统一用原图省掉生成和存储。但它同时带来三个问题:下载量大、内存占用高、首屏慢,而列表上的图只有一列宽,原图的分辨率完全是浪费。判据是「展示尺寸和原始尺寸差多少」:差一个数量级,那就必须分级。缩略图的生成放在上传时(一次)而不是请求时(每次),这一点也很重要。

    选固定宽高比的瀑布流而不是等高网格。等高网格实现最简单、高度天然确定、回收也好做。但瀑布流是这类产品的视觉特征,改成等高网格就不像了;而且等高会把长图裁得只剩中间一条。代价是要处理分列均衡和高度计算——而在「上传时存宽高」之后,这两件事都变得很简单了。

    踩过的坑一:图片位置跳动,而我在电脑上几乎看不出来。因为图片加载得太快。我是把开发者工具的带宽限到几十 KB 才看清整个过程的——列表跳了好几秒。教训是:涉及「资源加载过程」的体验问题,必须在慢网络下看,因为快网络会把过程压缩到你看不见。我现在做前端会习惯性地先限速跑一遍。

    踩过的坑二:我在前端花了很多时间想办法拿图片尺寸,方向从一开始就错了。我试过隐藏元素预加载、试过固定比例。后来才想到:服务端在上传时本来就知道宽高,是我没存教训是:遇到「这个信息我拿不到」的问题时,先往上游追一下——它在哪一步是已知的?很多时候不是拿不到,是在某一步被丢掉了。

    踩过的坑三:交替分列导致两列差一屏多。这个我在电脑上是能看到的,但因为我最初造的测试图片比例比较接近,差距不明显后来我故意造了一批极端比例的图(超长图、近正方形混在一起),问题立刻放大。教训是:造测试数据要故意造极端值,用「正常」的数据测不出边界。

    没做的部分:没做图片的渐进式加载(先出一张极小的模糊图再替换成清晰图)。它能让首屏观感更好,但需要再生成一档极小图、还要处理两次加载的切换而我已经有缩略图这一档、首屏时间在限速下也能接受了取舍依据是「收益是观感上的,而当前的主要矛盾(跳动和流量)已经解决」——先解决正确性和量级问题,观感优化排后面。

    数字是怎么测的

    布局跳动要报「布局偏移」并说清测试条件:用开发者工具把带宽限到几十 KB,录制首屏加载过程、统计图片加载引起的累计位移报法是「限速到 X 的条件下,改造前首屏加载过程中出现持续数秒的位移,改造后位移接近零」——必须带上限速条件,因为不限速这个数字本来就接近零。

    首屏图片下载量是这个模块最直观的数字:报「首屏 N 张卡片的图片总下载量从 X 兆降到 Y」,并说明缩略图是按列宽的多少倍出的。这个数字很容易核对。

    滑动帧率必须报机型和条数:「在某型旧安卓机上,加载到 N 条时滑动帧率从 X 提升到 Y」。只报帧率不报机型和条数没有参考价值——而且要说明是用性能面板录制滑动过程取的。

    节点数比帧率更本质:报「加载到 N 条时页面节点数,改造前随条数线性增长、改造后基本恒定」。这个对比解释了为什么帧率改善了。

    分列均衡用断言型用例:造一批极端比例的图片(超长图与近正方形混合),断言两列累计高度差在一屏以内要说明测试图片是故意造成极端比例的,因为用正常比例的图测不出问题。

    宽高兜底:造几条宽高为零或极端比例的数据,断言前端不会算出零高度或撑满整屏。

    历史数据补齐:断言回扫脚本跑完后,所有内容都带宽高,老内容也不再跳动。

    不要报什么:不要报「首屏加载速度提升 N 倍」——它完全取决于限速设成多少。该报的是「限速到某个值时改造前后的位移对比」「首屏图片下载量的绝对值对比」「某机型某条数下的帧率与节点数」「极端比例图片下两列高度差在一屏内」这几件带条件的、可核对的事。带条件恰恰说明你知道自己在测什么。

    面试追问
    Q:瀑布流图片位置跳动,这不是没办法的事吗?图片总得下载完才知道多高。 A:前端确实拿不到,但这个信息本来是知道的——是在上传的那一刻被丢掉了。我一开始也在前端想办法:用一个隐藏的图片元素先加载一遍拿尺寸(等于加载两次,更慢)、用固定比例猜(猜错了照样跳)、只给首屏固定高度(滑下去还是跳)——全都是补救,因为信息不在前端手里真正的解法是:服务端在接收上传时解析出宽高存进数据库,列表接口一并返回,前端按「列宽 × 高 ÷ 宽」在图片还没下载时就把占位高度撑出来,图片加载完填进去,尺寸完全一致、不产生任何位移。想通这一点之后我去翻了几个主流图文社区的列表接口,发现它们返回的图片信息里都带着宽和高——原来这就是它们的图片位置从来不跳的原因。我总结的判据是:遇到「这个信息我拿不到」的问题,先往上游追一下——它在哪一步是已知的?很多时候不是拿不到,是在某一步被丢掉了。另外这个改动还有一个我没预料到的额外收益:占位高度可以精确计算之后,视口外节点回收才做得起来——如果高度要等图片加载才知道,回收几乎无法实现。一个改动同时解决了跳动和长列表性能两件事。顺带说,这个问题我在电脑上几乎看不出来(图片加载太快),是把带宽限到几十 KB 才看清整个过程的,列表跳了好几秒。
    Q:列表和详情用两套图片,不觉得麻烦吗?直接用原图不是更简单? A:更简单,但我实测过它撑不住,三个问题一起来。我造的测试图片是手机原图,单张两三兆首屏 10 张卡片就是二三十兆的下载量,在我限速到几十 KB 的条件下首屏要等很久;在旧安卓机上多滑几屏就明显卡顿,因为几十张大图同时在内存里;而且列表上的图只有一列宽,原图那个分辨率完全是浪费判据是「展示尺寸和原始尺寸差多少」——差一个数量级,那就必须分级。所以我在上传时就生成一档缩略图(按列宽的若干倍出图),列表用缩略图、详情用大图两个实现细节值得说:一是缩略图在上传时生成一次,而不是请求时实时生成——实时生成会把开销压在每次请求上;二是原图要保留,因为详情页要用,而且以后想再出别的规格还得靠它。这件事也让我看懂了一个之前一直觉得奇怪的现象:主流图文社区的列表图明显比详情图糊——我原来以为是压缩没做好,其实是它们故意用了低一档的图,因为列表根本不需要那个分辨率。另外图片还要按需加载,只请求视口附近的——我最初是一进首页就发起全部图片请求,结果它们互相抢带宽,首屏反而更慢。

模块二:多图发布器与本地草稿

  1. 多图发布器与本地草稿(选图即开始上传把等待前置 + 限并发而非全并发 + 已成功的图在重试时复用 + 编辑中断的草稿恢复)★★★
    简历这样写 多图发布器(uni-app + Vue 3 + 前端压缩 + 本地草稿):发布流程原为「填完文案点发布时才开始上传九张图」,弱网下点击发布后需长时间等待且失败率高,改为选图后立即在后台开始上传,用户填写文案的时间被用来传图,点发布时多数图片已就绪;上传由串行改为限制并发数(全并发会互相抢占上行带宽反而更慢,串行则完全没有利用并行度);单张失败只重传该张,并且已上传成功的图片凭据会被缓存复用——发布接口失败后重试不会重新上传任何图片;图片在客户端按长边与质量压缩后再上传(原图两三兆,压缩后体积大幅下降且不影响观感);编辑过程中自动保存本地草稿(含已上传成功的图片凭据),切后台被系统回收或误退出后可完整恢复。上述问题在良好网络下均不出现,是把上行限速到几十 KB 后复现的。
    展开完整拆解
    为什么要这么设计

    发布页我一开始按最直觉的顺序做:选图 → 填文案 → 点发布 → 上传图片 → 创建内容 → 跳转在我的电脑和好网络下这个流程一气呵成,几乎察觉不到上传的存在。把上行带宽限到几十 KB 之后,四个问题全出来了。

    一是点了发布之后要等很久,而且失败率很高。九张图、每张两三兆,在限速条件下要传好几分钟而这段时间用户只能盯着一个进度条什么都做不了——他刚才填文案的那一两分钟完全被浪费了。更糟的是传到第七张失败,整个发布就失败了。

    二是我改成并发上传之后,反而更慢了。我把九张图全部同时发出去,结果它们互相抢上行带宽,每一张都传得很慢,总时间没有明显改善,而且失败率更高了(因为每个连接都长时间处于低速状态)。

    三是失败之后要整套重来。我的实现是「上传全部图片 → 拿到全部地址 → 调创建内容接口」。任何一张失败就重新走一遍,前面成功的八张白传了更蠢的是有一次图片全传完了、创建内容的接口失败了(我的参数校验有 bug),重试的时候九张图又全传了一遍。

    四是编辑中断内容全丢。我在安卓机上测的时候,切到后台去查个东西,回来发现小程序被系统回收了,填的文案和选的图全没了而我刚才等了好几分钟才把图传完。

    所以四个改动:选图后立即在后台开始上传限制并发数而不是全并发单张失败只重传该张、已成功的图片凭据缓存复用自动保存本地草稿(含已上传成功的图片凭据),另外补了客户端压缩

    这个模块最想说的一句话是:用户点「发布」的那一刻才开始上传,是把一段本来可以并行的等待硬做成了串行。他填文案的那一两分钟里网络完全是空闲的。把上传提前到「选完图」这一刻,等待时间没有变短,但它被藏进了用户本来就要花的时间里。想通之后我去看了几个主流图文社区的发布页,发现它们在你选完图返回编辑页时,图片旁边已经有进度了——原来那不是装饰,是真的在传。

    整体链路
    选图(上传从这一刻就开始,不等发布) │ ├─ 用户选完图返回编辑页 → 立即在后台开始上传 │ 等点发布才传的后果 │ 用户填文案的一两分钟里网络完全空闲 │ 点发布后要干等几分钟,还可能失败 │ ├─ 每张图旁边显示自己的进度与状态(等待 / 上传中 / 成功 / 失败) │ 整体一个进度条的问题:某张失败看不出是哪张 │ └─ 上传得到的是「图片凭据」(服务端返回的标识或地址) 文案与凭据在发布时才组合成一条内容 客户端压缩(在传输之前解决传输问题) │ ├─ 按长边缩放 + 质量压缩,原图两三兆压到几百 KB │ ├─ 要留下限:压过头会让画面明显发虚 │ 图文社区的内容就是图,压坏了功能本身就没了 │ 我的做法是在真实照片上试参数,以「放大看不出明显涂抹」为准 │ └─ 压缩后本地预览,让用户确认选的是对的那张 并发控制(不是越并发越快) │ ├─ 限制同时上传的数量(我实测取了一个较小的值) │ ├─ 全并发的后果 │ 九张图同时发 → 互相抢上行带宽 → 每张都很慢 │ 总时间没有明显改善,失败率反而更高 │ ├─ 串行的后果:完全没利用并行度,总时间最长 │ └─ 队列化:成功一张就补一张进来,始终保持在限制数量内 失败处理(只重传该张 + 凭据复用) │ ├─ 单张失败 → 该张显示失败与重传按钮,其余不受影响 │ 整套重来的后果:在限速下意味着又要几分钟 │ ├─ 已上传成功的图片凭据缓存在内存与草稿里 │ 发布接口失败 → 重试时直接复用凭据,不重新上传 │ 不缓存的后果:我真的遇到过图全传完、创建内容接口失败 │ 重试时九张图又全传了一遍 │ ├─ 全部成功才允许点发布(按钮置灰而不是提示) │ 提示会被忽略,禁用他就必须处理 │ └─ 确定性失败(格式不支持 · 超出大小上限)不重试,直接提示换图 本地草稿(编辑随时会被打断) │ ├─ 文案 · 已选图片 · 已成功的图片凭据 · 标签,防抖写入本地 │ 只存文案不存凭据的后果:恢复后还要把图重传一遍 │ ├─ 再次进入发布页 → 提示「有未完成的草稿,是否继续」 │ 不要静默恢复 —— 他可能想重新发一条 │ ├─ 发布成功后清除草稿 │ └─ 草稿写入失败要静默降级,不要因此弹错误 草稿丢了不影响主流程 发布提交 ├─ 提交内容 = 文案 + 图片凭据列表 + 标签 ├─ 提交要幂等(用客户端生成的请求标识去重) │ 弱网下用户会重复点,不幂等会发出两条一样的内容 └─ 提交成功 → 清草稿 → 跳转到详情,而不是回到空白的发布页
    分步拆解
    1. 选完图立即开始上传,不要等点发布。用户填文案的一两分钟里网络完全空闲,把上传提前到这段时间里,等待就被藏进了他本来就要花的时间。
    2. 每张图要显示自己的进度与状态。整体一个进度条的后果是某张失败看不出是哪张,用户只能整套重来。
    3. 客户端压缩是必须的,原图两三兆在弱网下传不动。问题出在传输环节,就必须在传输之前解决。
    4. 压缩要留下限,压过头画面会明显发虚。图文社区的内容就是图,压坏了功能本身就没了——我的验收标准是「放大看不出明显涂抹」。
    5. 压缩后要本地预览。让用户确认选的是对的那张,而不是发出去才发现选错。
    6. 限制并发数,不要全并发。九张图同时发会互相抢上行带宽,每张都很慢、总时间没改善、失败率反而更高(每个连接长时间处于低速状态)。
    7. 也不要串行。串行完全没利用并行度,总时间最长。并发数要在限速条件下实测取值。
    8. 用队列保持并发数恒定:成功一张就补一张进来。不要分批等一批全完再发下一批——那样会被最慢的那张拖住。
    9. 单张失败只重传该张。整套重来在限速下意味着又要几分钟,而这个代价会直接让用户放弃发布。
    10. 已上传成功的图片凭据必须缓存复用。我真的遇到过图全传完、创建内容接口失败(我自己的参数校验 bug),重试时九张图又全传了一遍
    11. 全部上传成功才允许点发布,用按钮禁用而不是提示。提示会被忽略,禁用他就必须处理。
    12. 确定性失败不要重试。格式不支持、超出大小上限,重试也不会成功,直接提示换图。
    13. 草稿必须包含已成功的图片凭据。只存文案的后果是恢复后还要把图重传一遍,草稿的价值就减半了。
    14. 草稿恢复要提示不要静默。他可能想重新发一条,静默恢复会让他困惑。
    15. 草稿写入失败要静默降级。本地存储可能满,草稿丢了不影响主流程,不要因此弹错误。
    16. 发布提交要幂等,用客户端生成的请求标识去重。弱网下用户会重复点,不幂等会发出两条一样的内容。
    17. 发布成功后跳转到详情,不要回到空白的发布页。让他看到自己发出去的东西,也避免他以为没发成功。
    关键决策与取舍

    把上传提前到「选完图」,这是这个模块收益最大的改动,而它几乎不增加代码复杂度。代价是用户可能选完图又放弃发布,那些图就白传了(服务器上多了没被引用的文件)。我的处理是给这类文件加一个定时清理——上传超过一定时间且没有被任何内容引用的就删掉判据是「白传几张图的成本」和「点发布后干等几分钟的成本」哪个大:后者大得多,而且后者会直接导致放弃发布。

    限并发而不是全并发,这一点和直觉相反。我最初以为「并发越多越快」,实测发现九张全并发时它们互相抢上行带宽,每个连接都长时间处于低速状态,总时间没有明显改善、失败率反而更高判据是「瓶颈是不是共享资源」:上行带宽是共享的、总量固定,那么增加并发只是把同一份带宽切得更碎,并不会变快;而连接数增加本身还有额外开销。并发数的具体取值我是在限速条件下逐个试出来的,而不是拍一个数。

    客户端压缩而不是让服务端处理大图。服务端压缩不损失原始画质、前端更简单,但它解决不了核心问题——上传本身就慢、就容易失败问题出在传输环节,就必须在传输之前解决。代价是原图不再保留、以及压缩参数要拿准。压缩参数我的验收标准是「放大看不出明显涂抹」,在真实照片上试出来的——因为图文社区的内容就是图,压坏了整个功能就没意义了。

    草稿存本地而不是服务端。存服务端能跨设备恢复。但未发布的内容存到服务端要处理清理、隐私和额外的接口;而这个场景的实际需求是「刚才被打断了、现在回来继续」,基本都在同一台设备上判据是「跨设备的需求有多强」:很弱,那就用最简单的方案。

    踩过的坑一:图全传完之后创建内容的接口失败,重试时九张图又全传了一遍。失败原因还是我自己的参数校验 bug。我当时看着进度条重新从零开始,特别难受教训是:一个多步骤流程里,已经完成的昂贵步骤必须能被后续重试复用——而「昂贵」在这里指的是耗时和流量,不是计算量。修法很简单:把已成功的图片凭据缓存起来,重试时先看有没有。

    踩过的坑二:以为并发越多越快,结果更慢。我是把限速开着、分别试串行、限并发、全并发三种,看总耗时和失败率才发现的教训是:并发的收益依赖「瓶颈是不是可以并行」——上行带宽不能,所以增加并发只是把带宽切碎。这一条我之前只在书上见过,自己实测一遍之后才真的信。

    踩过的坑三:切后台被回收,文案和图全丢。而我刚才等了几分钟才把图传完。教训是:移动端要假设「页面随时会被系统回收」——这不是异常情况,是安卓上很常见的正常行为。而且草稿里必须存已上传成功的凭据,只存文案的话恢复后还要重传,等于没救回来多少。

    没做的部分:没做大文件分片续传。压缩之后单张图只有几百 KB,分片续传的收益很小而复杂度不低(要处理分片编号、合并、断点记录)。取舍依据是「压缩已经把问题缩小到不需要续传的程度」——先用便宜的办法把问题规模压下来,往往比直接上复杂方案划算。

    数字是怎么测的

    全部数字必须带限速条件,这是这个模块的前提:我报的是「把上行带宽限到几十 KB 每秒」。不限速的话所有对比都接近零差异——因为好网络下九张原图也就几秒。

    压缩效果报三个数:原图体积、压缩后体积、以及画质是否可接受(放大看不出明显涂抹)只报压缩比不报画质,等于没说清这个优化有没有副作用。

    「感知等待」是这个模块最有说服力的指标:报「点击发布后仍需等待的时间,从『九张图的完整上传时间』降到『剩余未完成图片的时间』,多数情况下接近零」。要说明这不是上传变快了,而是等待被前置到了用户填文案的时间里——诚实说明这一点比夸大更可信。

    并发数的选择要报实测过程:「在限速条件下分别试串行、限并发、全并发,记录总耗时与失败率,取总耗时与失败率都较优的并发数」。直接说一个数字而不说怎么来的,就是拍的。

    单张重传:断言让第三张失败时,前两张保持成功状态、只有第三张显示失败与重传、重传后不影响其他张。

    凭据复用是踩坑的直接回归:让创建内容接口失败,断言重试时不重新上传任何图片(可以看网络请求数为零)。

    草稿恢复:填一半、选好图、等图传完,然后杀掉进程重进,断言文案与已上传图片都恢复、且不需要重新上传后半句最容易漏测。

    提交幂等:弱网下连续点两次发布,断言只产生一条内容。

    不要报什么:不要报「上传成功率 100%」——弱网下不可能。该报的是「限速到某个值时压缩前后的体积与画质结论」「点发布后的剩余等待时间对比」「三种并发策略的实测耗时与失败率」「创建内容失败后重试不重传图片」这几件带条件的、可核对的事。

    面试追问
    Q:上传图片为什么不等用户点发布?他可能选完图就不发了。 A:确实会有白传的情况,但我算过这两个成本,差距很大。等点发布才传的问题是:九张图、每张两三兆,在我限速到几十 KB 的条件下要传好几分钟,而这段时间用户只能盯着进度条什么都做不了——他刚才填文案的那一两分钟里网络完全是空闲的。更糟的是传到第七张失败,整个发布就失败了。把上传提前到「选完图」这一刻,等待时间并没有变短,但它被藏进了用户本来就要花的时间里——点发布时多数图片已经就绪,剩余等待接近零。代价是用户选完图又放弃发布的话,那些图就白传了、服务器上多了没被引用的文件我的处理是给这类文件加一个定时清理——上传超过一定时间且没有被任何内容引用的就删掉判据是「白传几张图的成本」和「点发布后干等几分钟的成本」哪个大:后者大得多,而且后者会直接导致用户放弃发布,那才是真的损失。想通这一点之后我去看了几个主流图文社区的发布页,发现你选完图返回编辑页时,图片旁边已经有进度了——原来那不是装饰,是真的在传。配套还有两件必须做的事:每张图显示自己的状态(整体一个进度条的话,某张失败看不出是哪张);以及已上传成功的图片凭据要缓存复用——我踩过一个坑,图全传完之后创建内容的接口失败了(我自己的参数校验 bug),重试时九张图又全传了一遍
    Q:九张图为什么不全部并发上传?那样不是最快? A:我一开始就是全并发,实测下来反而更慢、失败率还更高——这一点和直觉相反。我把限速开着,分别试了串行、限制并发数、全并发三种,记录总耗时和失败率。全并发的问题是九张图互相抢上行带宽,每个连接都长时间处于低速状态:总时间没有明显改善,而失败率上去了(连接维持得越久越容易断)串行的问题是完全没利用并行度,总时间最长。最后取的是一个较小的并发数,并且用队列保持这个数量恒定——成功一张就补一张进来,而不是分批等一批全完再发下一批(那样会被最慢的那张拖住)判据是「瓶颈是不是可以并行」:上行带宽是共享的、总量固定,增加并发只是把同一份带宽切得更碎,并不会变快,而连接数增加本身还有额外开销。这个道理我之前只在书上见过,自己在限速条件下实测一遍之后才真的信——所以我报并发数的时候会说清是怎么试出来的,而不是直接给一个数字。另外还有一个比并发更有效的改动:客户端压缩。原图两三兆压到几百 KB,这是在传输之前解决传输问题——服务端压缩解决不了「传输本身慢」。压缩参数我留了下限,验收标准是「放大看不出明显涂抹」,在真实照片上试出来的——因为图文社区的内容就是图,压坏了整个功能就没意义了。

模块三:跨页状态同步与返回态保持

  1. 跨页状态同步与返回态保持(详情页的互动结果回传列表 + 列表数据与滚动位置缓存 + 恢复顺序不能颠倒 + 轮播图的相邻预加载)★★
    简历这样写 跨页状态与返回态(uni-app + Vue 3 + 全局状态缓存):在详情页点赞收藏后返回列表,列表上的状态与数字仍是旧值,因为两个页面各自持有一份数据;改为用全局状态层集中保存本次会话中被改动过的内容状态,列表渲染时以它为准合并,任何页面的改动都会立即反映到其他页面;从详情页返回时列表原先重新请求并回到顶部,改为缓存列表数据与滚动位置并在返回时恢复,并修正了「先设置滚动位置再渲染导致位置被钳制为 0」的顺序问题;详情页图片轮播加入相邻预加载(只预加载前后各一张而非全部九张,避免打开详情就抢占带宽);返回后的静默刷新不直接替换列表,避免位置二次跳动。相关问题均在旧安卓机与限速条件下复现。
    展开完整拆解
    为什么要这么设计

    这个模块解决的都是「页面之间」的问题,而它们的共同特点是:单看任何一个页面都完全正常,只有在页面之间来回走才会暴露。四个问题。

    一是详情页点了赞,返回列表还是没赞。我的列表和详情各自请求各自的数据,两份数据互不相干用户在详情页点了赞、返回列表,卡片上的心还是空的、数字也没变——他会以为刚才没点上,再点一次就变成取消了这个问题在单个页面里怎么测都不会出现。

    二是返回列表回到顶部。我从列表进详情,返回时列表重新请求、重新渲染、滚动位置归零用户在瀑布流里滑了几十屏点进一条内容,返回时要重新滑回去——这是我自己用的时候最烦躁的一点。

    三是我加了滚动位置恢复之后,位置一直是 0。我在页面显示时读出缓存的滚动值直接设置上去,但列表数据还没渲染完、页面根本没有那个高度,滚动值被钳制回了 0我查了很久才明白是顺序问题——必须等列表渲染出高度之后再设置。

    四是详情页打开时把九张图全部预加载了。我以为「提前加载体验更好」,结果是九张图同时请求、互相抢带宽,连第一张都出得很慢而用户往往只看前两三张。

    所以四个改动:用全局状态层集中保存被改动过的内容状态缓存列表数据与滚动位置并在返回时恢复修正恢复顺序(先渲染再设置滚动)轮播图只预加载相邻的前后各一张

    这个模块最想说的一句话是:只要同一份数据会在两个页面上出现,它就不能由两个页面各自持有——必须有一个共同的来源。我最初的写法是每个页面自己请求自己的数据,这在单页面视角下完全合理,但它意味着「同一条内容的点赞状态」在系统里有两份,而两份就一定会不一致这一条和后端「同一份数据不要有两条来源」是同一个道理,只是发生在前端页面之间。

    整体链路
    跨页状态同步(同一份数据不能由两个页面各自持有) │ ├─ 全局状态层:保存「本次会话中被改动过的内容状态」 │ 内容标识 → { 是否点赞 · 点赞数 · 是否收藏 · 评论数 } │ ├─ 任何页面执行互动 → 先更新全局状态层 → 再发请求 │ 请求失败 → 回滚全局状态层并提示 │ ├─ 列表渲染时:接口数据 与 全局状态层 合并,以后者为准 │ 不合并的后果 │ 详情页点了赞、返回列表心还是空的、数字没变 │ 用户以为没点上,再点一次就变成取消了 │ ├─ 只保存「被改动过的」,而不是缓存全部内容 │ 全量缓存要处理过期与内存,而这里只需要覆盖改动 │ └─ 这一层不做持久化,仅本次会话有效 下次冷启动重新从接口拿最新值就好 返回态保持(列表数据 + 滚动位置) │ ├─ 离开列表页时记录:已加载数据 · 分页游标 · 滚动位置 · 当前分类 │ 只记滚动位置不记数据是没用的 │ 因为数据重新加载后条目变了,同一个位置对应的内容也变了 │ ├─ 返回时恢复的顺序(顺序错了就无效) │ 第一步 用缓存数据渲染列表 │ 第二步 等列表渲染出真实高度 │ 第三步 才设置滚动位置 │ 直接设置的后果:页面还没有那个高度,滚动值被钳制回 0 │ ├─ 恢复位置之后再做静默刷新(按需,不打断) │ 刷新结果不要直接替换列表 —— 会导致位置二次跳动 │ 只在用户主动下拉时才整体替换 │ └─ 缓存要有失效条件:时间过久 · 用户主动下拉刷新 · 切换分类 详情页轮播图预加载 │ ├─ 只预加载当前图的前后各一张 │ ├─ 全部预加载的后果 │ 九张图同时请求 → 互相抢带宽 → 连第一张都出得很慢 │ 而用户往往只看前两三张 │ ├─ 首图优先:进入详情时先只加载第一张,出图后再预取相邻 │ └─ 从列表进入详情时,可以直接复用列表已加载的缩略图做首屏过渡 大图加载完再替换 —— 用户几乎感觉不到等待 其他细节 ├─ 详情页返回时若内容已被删除,要明确提示而不是空白 ├─ 列表上的数字变化不要做动画跳变,直接更新即可 └─ 互动请求失败要回滚并提示,不能让界面停在「已赞」的假成功状态
    分步拆解
    1. 建一个全局状态层,集中保存本次会话中被改动过的内容状态。不做的后果是详情页点了赞、返回列表心还是空的,用户以为没点上、再点一次就变成取消。
    2. 互动时先更新全局状态层再发请求,失败回滚。本地先响应保证界面立刻有反馈,而回滚保证不会停在假成功状态。
    3. 列表渲染时用「接口数据 + 全局状态层」合并,以后者为准。这是让改动生效的关键一步。
    4. 只保存「被改动过的」内容,不要缓存全部数据。全量缓存要处理过期和内存,而这里只需要覆盖改动。
    5. 这一层不做持久化,仅本次会话有效。下次冷启动从接口拿最新值就好,持久化反而会带来「本地状态比服务端旧」的问题。
    6. 离开列表页时要记录完整状态:数据、游标、滚动位置、分类。只记滚动位置是没用的——数据重新加载后条目变了,同一个位置对应的内容也不同了。
    7. 恢复的顺序不能颠倒:先用缓存数据渲染,等列表有了真实高度,再设置滚动位置。直接设置的后果是页面还没有那个高度,滚动值被钳制回 0——这个坑我查了很久。
    8. 恢复位置之后的静默刷新不要直接替换列表。替换会导致位置二次跳动,只在用户主动下拉时才整体替换。
    9. 列表缓存要有失效条件:时间过久、主动下拉、切换分类。否则用户会看到很旧的列表。
    10. 轮播图只预加载前后各一张。全部预加载的后果是九张图同时请求、互相抢带宽,连第一张都出得很慢——而用户往往只看前两三张。
    11. 进入详情时首图优先,出图后再预取相邻。让用户最快看到他点进来想看的东西。
    12. 可以复用列表已加载的缩略图做详情首屏过渡。大图加载完再替换——用户几乎感觉不到等待,而这个成本几乎为零。
    13. 返回时若内容已被删除要明确提示。不要显示空白,让用户知道发生了什么。
    14. 数字变化不要做动画跳变。点赞数从 12 变 13 直接更新就好,加动画反而让人注意到「数字在变」这件事。
    关键决策与取舍

    用「只保存被改动过的状态」而不是「缓存全部内容数据」。全量缓存能让所有页面共享一份数据、彻底消除不一致。但它要处理缓存过期、内存上限、以及「什么时候该用缓存什么时候该用接口」这些问题,复杂度明显上升而我实际要解决的问题很窄:只是互动状态需要跨页同步——内容本身(图文、作者)是不会变的。判据是「真正会在页面间变化的字段有哪些」:只有互动状态,那就只管它。这个范围收窄让整个实现简单了很多。

    全局状态层不做持久化,只在本次会话有效。持久化能让冷启动后也保持状态。但那会引入一个更麻烦的问题:本地状态可能比服务端旧(比如我在另一台设备上取消了赞),而本地又以自己为准,就会长期显示错的判据是「本地状态和服务端冲突时以谁为准」:应该以服务端为准,那本地就不该长期持有。

    返回时用缓存渲染而不是重新请求。重新请求能拿到最新数据。但它有两个问题:位置无法恢复(数据还没回来)、而且刷新后条目可能变了导致位置对不上用缓存的代价是可能显示稍旧的数据——我认为这个代价可以接受,因为用户刚从详情页返回,他的预期是「回到我刚才看的地方」而不是「看到最新内容」。

    轮播只预加载相邻的,而不是全部。全部预加载在好网络下确实体验更好。但在限速条件下它是负优化——九张图抢带宽,连用户想看的第一张都出得慢判据是「预加载抢占的资源会不会影响当前要用的东西」:会,那就必须限制范围。预加载的边界通常是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」。

    踩过的坑一:详情页的互动结果不回传列表。用户以为没点上、再点一次就取消了。我最初的写法是每个页面自己请求自己的数据,这在单页面视角下完全合理——但它意味着「同一条内容的点赞状态」在系统里有两份,而两份就一定会不一致教训是:只要同一份数据会在两个页面上出现,它就不能由两个页面各自持有,必须有一个共同的来源。这一条和后端「同一份数据不要有两条来源」是同一个道理。

    踩过的坑二:设置滚动位置一直无效,值总是 0。我一开始怀疑是缓存没存对、怀疑是取值时机不对,查了很久才明白是列表还没渲染出高度、滚动值被钳制了教训是:设置滚动位置、测量元素尺寸这类操作,都必须等布局完成之后做——而「布局完成」的时机不等于「数据赋值完成」。

    踩过的坑三:以为预加载越多越好。详情页打开就把九张图全请求了,结果连第一张都很慢教训是:预加载是在和「当前正在用的资源」抢带宽——它不是免费的,范围必须受限。

    没做的部分:没做列表数据的本地持久化(冷启动直接显示上次的内容)。它能让冷启动更快看到内容,但要处理数据过期、以及「显示的内容点进去已经被删了」这类情况取舍依据是「冷启动的频率远低于页面间跳转」——先把高频路径做好。

    数字是怎么测的

    跨页状态一致性用断言型用例,这是最核心的一条:在列表进入详情、点赞、返回,断言列表卡片上的点赞状态与数字已同步;再进入该内容的详情,断言状态仍然一致第二步容易漏,但它能验证全局状态层真的生效而不是碰巧刷新了。

    失败回滚:让点赞请求失败,断言界面回滚为未点赞状态并给出提示,而不是停在「已赞」的假成功

    返回态恢复要在真机上测并报机型:滑到某个位置、进详情、返回,断言滚动位置与离开时一致(允许小误差)、列表数据未重新加载要说明是在哪台机器上测的——不同机型的渲染时机有差异。

    恢复顺序是踩坑的直接回归:断言「先渲染列表、再设置滚动位置」的顺序下位置生效;可以再写一条反向用例说明颠倒顺序时位置会变成 0——保留这条反例,改动代码时能立刻发现回归。

    静默刷新不跳位置:返回后触发静默刷新,断言滚动位置不发生二次变化。

    轮播预加载要报请求数:「打开详情页时发起的图片请求数从 9 降到 2(当前图 + 相邻 1 张)」。这个数字很直观,而且首图出图时间的对比要带限速条件。

    首屏过渡:断言从列表进详情时先显示列表已加载的缩略图、大图加载完成后替换,过程中不出现空白。

    不要报什么:不要报「体验提升」这类无法度量的说法。该报的是「详情页互动后列表状态同步(含二次进入仍一致)」「返回后滚动位置一致且数据未重新加载」「详情页图片请求数从 9 降到 2」「请求失败后界面正确回滚」这几件可核对的事。

    面试追问
    Q:详情页点赞后列表不同步,返回时重新请求一次列表不就解决了? A:能解决状态问题,但会带来两个更糟的问题,而且它没解决根因。重新请求的问题:一是滚动位置没法恢复(数据还没回来,页面没有高度)——用户在瀑布流里滑了几十屏点进一条内容,返回时被扔回顶部,这是我自己用的时候最烦躁的一点二是刷新后条目可能变了(有新内容插进来),即使记住了滚动位置,那个位置对应的内容也不是他刚才看的那条了而根因是我最初让每个页面自己请求自己的数据——这在单页面视角下完全合理,但它意味着「同一条内容的点赞状态」在系统里有两份,两份就一定会不一致。所以正确的解法是建一个全局状态层,集中保存本次会话中被改动过的内容状态(内容标识 → 是否点赞、点赞数、是否收藏),任何页面互动都先更新它、列表渲染时以它为准合并这里有两个刻意收窄的范围只保存「被改动过的」而不是缓存全部内容——因为真正会在页面间变化的只有互动状态,图文和作者是不变的,范围收窄让实现简单很多;以及不做持久化、只在本次会话有效——持久化会带来「本地状态比服务端旧」的问题(比如我在另一台设备上取消了赞),而冲突时本该以服务端为准,那本地就不该长期持有我总结的是:只要同一份数据会在两个页面上出现,它就不能由两个页面各自持有——这和后端「同一份数据不要有两条来源」是同一个道理。
    Q:滚动位置恢复不了,你是怎么定位的? A:这个我查了很久,因为我一直在怀疑错误的地方。现象是我在页面显示时读出缓存的滚动值、设置上去,但位置始终是 0。我最初怀疑缓存没存对(打日志确认值是对的)、又怀疑读取时机不对(换了几个生命周期钩子都不行)。转折点是我在设置滚动值之后立刻把页面高度打出来——发现高度几乎是 0根因是列表数据虽然赋值了,但还没渲染出真实高度,滚动值被钳制回了 0修法是把恢复拆成严格的三步:先用缓存数据渲染列表、等列表渲染出真实高度、才设置滚动位置。教训有两层:一是设置滚动位置、测量元素尺寸这类操作,都必须等布局完成之后做——而「布局完成」的时机不等于「数据赋值完成」,这两件事之间隔着一次渲染;二是定位问题时应该先确认「我的输入是不是我以为的那样」,我如果一开始就把页面高度打出来,能少走很多弯路。另外还有两个配套的细节缓存必须包含列表数据本身,只记滚动位置是没用的(数据重新加载后条目变了,位置对应的内容也变了);以及恢复位置之后的静默刷新不能直接替换列表,否则位置会二次跳动——只在用户主动下拉时才整体替换。我还专门留了一条反向用例:颠倒顺序时断言位置会变成 0,这样以后改动代码能立刻发现回归。

模块四:登录态与请求拦截层

  1. 登录态与请求拦截层(令牌过期后的静默续期与原请求重放 + 并发失效只续期一次 + 未登录跳转要能回到原页面 + 请求层统一收敛错误与加载态)★★★
    简历这样写 登录态与请求层(uni-app + Vue 3 + 请求拦截封装):令牌过期原先直接把用户踢回登录页,体验断裂且正在填写的内容丢失;改为在请求拦截层做静默续期并重放原请求(用户无感知),并针对多个请求同时遇到令牌失效的情况做续期请求合并(原实现会并发发起多次续期,且后完成的那次会让先前换到的令牌失效,导致连环失败);未登录跳转登录页时记录来源页面与参数,登录成功后回到原页面而不是首页;把令牌读写收敛到唯一入口(此前各页面直接读写本地存储,退出登录时有页面漏清理导致「退出了还能看到数据」);请求层统一处理加载态、超时、错误提示与重试,业务代码不再各自写一套。相关问题均通过人为将令牌改为过期值、并发发起多个请求复现。
    展开完整拆解
    为什么要这么设计

    请求层是我最早写、也是最久没有动过的一块——因为它「能用」直到我把令牌的有效期改短了几分钟做测试,四个问题一起冒出来。

    一是令牌过期就把用户踢回登录页,体验完全断裂。我的拦截器逻辑是「收到未授权的响应就跳登录页」。结果是用户正在写一条内容、选好了九张图,令牌过期了——直接跳走,内容全丢而令牌过期是一个纯技术细节,用户完全不知道自己做错了什么。

    二是我加了静默续期之后,出现了更奇怪的问题:连环失败。页面首屏会并发发好几个请求。令牌刚过期时,这几个请求几乎同时收到未授权,于是各自都去发起了一次续期——而服务端每次续期都会让上一个令牌失效,所以后完成的那次续期把先前那次换来的令牌作废了表现是有的请求成功、有的失败,刷新一下又好了,非常难查。

    三是登录成功后总是回首页。用户点了一条内容的分享链接,未登录被跳到登录页,登录成功之后回到了首页——他要重新去找那条内容而他本来的意图是看那条内容。

    四是退出登录之后还能看到数据。我在各个页面里直接读写本地存储的令牌。退出登录时我清理了主要的几处,但漏了一个页面自己缓存的副本——那个页面在退出之后依然能正常请求。

    所以四个改动:令牌过期改为静默续期并重放原请求并发失效时合并成一次续期未登录跳转记录来源、登录后回到原页面令牌读写收敛到唯一入口

    这个模块最想说的一句话是:请求层是所有页面共用的那一层,所以它的问题不会只出现在一个地方,而是「到处都有一点」。我一开始把令牌读写散在各个页面里,看起来每处都很简单,但退出登录时我必须记得清理全部这些地方——而我漏了一个把它收敛成唯一入口之后,退出登录只需要改一个地方,而且不可能漏。

    整体链路
    令牌的读写收敛(先做这一步,后面才好做) │ ├─ 令牌的读 · 写 · 清除,全部只经过一个模块 │ 各页面直接读写本地存储的后果 │ 退出登录时要记得清理全部这些地方 —— 我漏了一个 │ 那个页面在退出之后依然能正常请求 │ ├─ 内存里保留一份,避免每个请求都读一次本地存储 │ 但要保证内存与本地存储始终一致(只在唯一入口里改) │ └─ 退出登录:清令牌 + 清用户信息 + 清各页面缓存 + 断开长连接 只清令牌的后果:界面上还残留着上一个用户的数据 请求拦截:发出前 │ ├─ 自动带上令牌(业务代码不用关心) ├─ 统一超时时间,可按接口覆盖 └─ 统一加载态:请求开始与结束通知给调用方 各页面自己维护加载态的后果:每个页面写一遍、总有页面忘记关 请求拦截:收到未授权响应时(这一块是重点) │ ├─ 不要直接跳登录页 │ 跳的后果:用户正在写内容、选好了九张图 —— 直接跳走,全丢 │ 而令牌过期是纯技术细节,用户不知道自己做错了什么 │ ├─ 先尝试静默续期 │ 续期成功 → 用新令牌重放原请求 → 用户完全无感知 │ 续期失败(续期凭据也过期了)→ 才跳登录页 │ ├─ 并发失效必须合并成一次续期(我踩的最难查的坑) │ 首屏会并发发好几个请求 │ 令牌刚过期时它们几乎同时收到未授权 → 各自发起一次续期 │ 而服务端每次续期都会让上一个令牌失效 │ → 后完成的那次把先前换来的令牌作废了 │ 表现:有的请求成功、有的失败,刷新一下又好了,非常难查 │ ├─ 合并的做法 │ 第一个遇到失效的请求负责发起续期,并登记一个「正在续期」标记 │ 其余请求挂起等待,不发起新的续期 │ 续期完成 → 唤醒全部挂起的请求,用新令牌依次重放 │ 续期失败 → 全部挂起的请求一起失败,然后跳登录页 │ └─ 重放要有次数上限 不设的后果:续期成功但接口仍返回未授权时会无限循环 未登录跳转与回跳 ├─ 跳登录页时记录来源页面路径与参数 ├─ 登录成功后回到原页面,而不是首页 │ 回首页的后果:用户点分享链接进来,登录完要重新去找那条内容 ├─ 回跳目标要校验是站内路径,不能直接用传入的任意地址 └─ 已经在登录页时不要重复跳转(否则会叠一堆登录页) 错误处理统一收敛 ├─ 网络错误 · 超时 · 服务端错误 · 业务错误,分类给出不同提示 ├─ 哪些错误自动提示、哪些交给业务自己处理,要有明确约定 │ 全部自动提示的后果:业务想自己处理时会出现两个提示 ├─ 幂等的读请求可以自动重试一次,写请求绝对不自动重试 └─ 业务代码只处理「成功后做什么」,不再各自写一套错误分支
    分步拆解
    1. 先把令牌的读写清除收敛到唯一入口。散在各页面的后果是退出登录时要记得清理全部这些地方——而我漏了一个,那个页面在退出后依然能正常请求。
    2. 内存里保留一份令牌避免每次读本地存储,但只在唯一入口里改。两处不一致会导致请求带的令牌和实际状态对不上。
    3. 退出登录要清四样:令牌、用户信息、各页面缓存、长连接。只清令牌的后果是界面上还残留着上一个用户的数据。
    4. 请求发出前自动带令牌、统一超时、统一加载态。各页面自己维护加载态的后果是每个页面写一遍,总有页面忘记关。
    5. 收到未授权响应不要直接跳登录页。跳的后果是用户正在写的内容和选好的图全丢,而令牌过期是纯技术细节,他不知道自己做错了什么。
    6. 先尝试静默续期,成功后用新令牌重放原请求。用户完全无感知——这是这个改动的核心价值。
    7. 续期也失败才跳登录页。说明续期凭据也过期了,这时候跳转是合理的。
    8. 并发失效必须合并成一次续期。不合并的后果是几个请求各自发起续期,而服务端每次续期都会让上一个令牌失效,后完成的那次把先前换来的作废了——表现是有的成功有的失败、刷新一下又好了,非常难查。
    9. 合并的做法是「第一个负责续期、其余挂起等待」。续期完成后唤醒全部挂起请求依次重放,续期失败则一起失败。
    10. 重放要有次数上限。不设的后果是续期成功但接口仍返回未授权时会无限循环。
    11. 跳登录页要记录来源页面路径与参数。登录成功后回到原页面——回首页的后果是用户点分享链接进来、登录完要重新去找那条内容。
    12. 回跳目标要校验是站内路径。不能直接用传入的任意地址,否则会成为一个把用户导向外部的入口。
    13. 已经在登录页时不要重复跳转。否则会叠一堆登录页,用户要连按很多次返回。
    14. 错误要分类:网络错误、超时、服务端错误、业务错误各给不同提示。统一一个「请求失败」等于没有信息。
    15. 要约定清楚哪些错误自动提示、哪些交给业务处理。全部自动提示的后果是业务想自己处理时会出现两个提示。
    16. 幂等的读请求可以自动重试一次,写请求绝对不自动重试。写请求自动重试会产生重复数据(除非有幂等键)。
    关键决策与取舍

    令牌过期做静默续期而不是直接跳登录页,代价是请求层复杂度明显上升。直接跳登录页只有一行代码。但它把一个纯技术细节(令牌到期)变成了用户的损失(正在写的内容全丢)——而用户完全不知道自己做错了什么判据是「这个失败用户有没有能力预防」:没有能力(他无法知道令牌何时过期),那就应该由系统自己消化掉,而不是让他承担后果而续期带来的复杂度(合并、重放、次数上限)是这个体验的必要成本。

    续期合并选「第一个负责、其余挂起」而不是「每个请求各自续期」。各自续期实现最简单(不需要任何协调)。但服务端每次续期都会让上一个令牌失效——这是一个安全上合理的设计(防止续期凭据被重复利用),可它意味着并发续期必然互相踩踏合并的代价是要维护「正在续期」这个状态和一个等待队列判据是「这个操作能不能被并发执行」:不能(因为它有副作用且会互相作废),那就必须在客户端侧串行化。这一条和后端「同一个键的加载只执行一次」本质上是同一个模式。

    令牌读写收敛到唯一入口,这是我做完之后觉得最应该早点做的一件事。散在各页面时每一处都很简单、改起来也快。但它有一个隐藏成本:任何涉及「所有令牌使用方」的改动(退出登录清理、加静默续期、换存储方式)都要改很多处,而且必然会漏收敛之后退出登录只需要改一个地方,而且不可能漏判据是「以后会不会有需要一次性改动全部使用方的需求」:会(而且已经发生了两次),那就该收敛。

    读请求可以自动重试、写请求绝对不自动重试。统一都重试能提高成功率。但写请求自动重试会产生重复数据——除非它带幂等键,而我不能假设所有写接口都带所以请求层的默认策略是「读重试、写不重试」,需要重试的写接口由业务显式声明并保证幂等判据是「重复执行有没有副作用」:有,那默认就不能重试。

    踩过的坑一:令牌过期直接跳登录页,用户写了一半的内容全丢。我是把令牌有效期改短做测试时撞到的,那一刻我意识到这个设计对用户是很粗暴的教训是:区分「用户的错」和「系统的技术细节」——后者不应该让用户付代价。

    踩过的坑二:并发续期导致连环失败,这是我在这个项目里查得最久的问题之一。现象很诡异:首屏有的请求成功、有的失败,刷新一下又好了。我怀疑过接口不稳定、怀疑过超时设置。后来打日志才看到几个请求各自发起了续期而服务端每次续期都会让上一个令牌失效,所以它们互相把对方的令牌作废了教训是:「有副作用且会互相作废」的操作绝对不能被并发执行,即使调用它的地方是天然并发的(首屏多个请求)——必须在客户端侧主动串行化。

    踩过的坑三:退出登录之后有个页面还能正常请求。因为它自己缓存了一份令牌副本,而我清理时漏了它。教训是:同一份敏感状态在系统里存了几份,就有几个地方需要在清理时被记得——而「记得」是不可靠的正确做法是让它只有一份。

    没做的部分:没做令牌的自动预续期(在过期前主动换新,让请求永远不会遇到失效)。它能进一步消除续期时的那一次延迟,但需要在客户端维护一个准确的过期倒计时,而客户端时间不可信、应用还会被挂起——倒计时不准反而会造成不必要的续期取舍依据是「被动续期已经把体验做到了无感知,主动预续期的增量收益很小、而它依赖一个不可靠的前提」。

    数字是怎么测的

    令牌失效的测法要说清怎么构造:我是把本地存的令牌手动改成一个过期值(或把服务端的有效期改短到几分钟),这样可以稳定复现。报法要带上这个构造方式——否则「令牌过期」这个场景听起来无法验证。

    静默续期是断言型用例:令牌失效状态下发起一个请求,断言用户没有被跳转到登录页、请求最终成功返回、且期间只发生了一次续期三条都要断言,第三条是重点。

    并发合并是踩坑的直接回归,也是这个模块最有价值的一条:令牌失效状态下并发发起 N 个请求断言续期接口只被调用 1 次(可以在网络面板里数)、N 个请求全部成功、没有任何一个因令牌被作废而失败报法是「N 个并发请求下续期调用次数从 N 降到 1」。

    续期失败的路径:让续期接口也返回失败,断言全部挂起的请求一起失败、然后跳转登录页、且只跳一次。

    重放次数上限:让接口在续期成功后仍返回未授权,断言重放达到上限后停止,不会无限循环。

    回跳:从一条内容详情页触发未登录跳转,登录成功后断言回到该详情页且参数完整断言传入站外地址时回跳被拒绝。

    退出登录的清理完整性:退出后逐个页面断言无法再请求成功、界面上无残留数据、长连接已断开「逐个页面」是关键——我漏的那个坑正是只测了主要页面。

    加载态:断言请求结束(无论成功失败)后加载态一定被关闭,包括超时和被拦截的情况。

    不要报什么:不要说「登录体验提升」——无法度量。该报的是「构造令牌失效的方式」「N 个并发请求下续期只调用 1 次」「续期失败时只跳一次登录页」「重放有上限不会无限循环」「退出后逐页面均无法请求成功」这几件可核对的事。

    面试追问
    Q:令牌过期了跳登录页不是很正常吗?为什么要做静默续期? A:我第一版就是直接跳登录页,是把令牌有效期改短做测试时撞到问题的:用户正在写一条内容、已经选好了九张图,令牌过期了——直接跳走,内容全丢。而令牌过期是一个纯技术细节,用户完全不知道自己做错了什么。判据是「这个失败用户有没有能力预防」:他没有能力(无法知道令牌何时过期),那就应该由系统自己消化掉,而不是让他承担后果。所以改成在请求拦截层收到未授权响应时先尝试静默续期,成功就用新令牌重放原请求,用户完全无感知;只有续期也失败(续期凭据也过期了)才跳登录页但改完之后我踩了一个查得最久的坑:连环失败。现象很诡异——首屏有的请求成功、有的失败,刷新一下又好了。我怀疑过接口不稳定、怀疑过超时设置,后来打日志才看到:首屏并发发了好几个请求,令牌刚过期时它们几乎同时收到未授权,于是各自都去发起了一次续期;而服务端每次续期都会让上一个令牌失效(这是安全上合理的设计,防止续期凭据被重复利用),所以它们互相把对方的令牌作废了。解法是续期必须合并:第一个遇到失效的请求负责续期并登记「正在续期」标记,其余请求挂起等待、不再发起新的续期;续期完成后唤醒全部挂起请求用新令牌依次重放,续期失败则一起失败再跳登录页。我总结的判据是:有副作用且会互相作废的操作绝对不能被并发执行,即使调用它的地方是天然并发的——必须在客户端侧主动串行化。这和后端「同一个键的加载只执行一次」本质上是同一个模式。另外重放要有次数上限,否则续期成功但接口仍返回未授权时会无限循环。
    Q:令牌就存在本地存储里,各个页面自己读不是更方便吗? A:每一处确实都更简单,但它有一个隐藏成本,而我是被一个真实的 bug 教育的:退出登录之后,有个页面还能正常请求、还能看到数据。原因是那个页面自己缓存了一份令牌副本,而我在退出登录时清理了主要的几处、漏了它教训是:同一份敏感状态在系统里存了几份,就有几个地方需要在清理时被「记得」——而「记得」是不可靠的。所以我把令牌的读、写、清除全部收敛到一个模块,其他地方一律通过它。收敛之后退出登录只需要改一个地方,而且不可能漏。而这个收敛带来的收益远超我最初的预期:后来我加静默续期时,只需要改这一层;如果当初散在各页面,我得改很多处、而且很可能漏掉某个页面导致它仍然直接跳登录页判据是「以后会不会有需要一次性改动全部使用方的需求」:会——而且在我这个项目里已经发生了两次(退出清理、加续期),那就该收敛顺带说退出登录要清的不止令牌:还有用户信息、各页面缓存的列表数据、以及长连接——只清令牌的后果是界面上还残留着上一个用户的数据,这在共用设备上是个隐私问题。我的验证方式也做了调整:退出后逐个页面去断言「无法再请求成功、界面无残留、长连接已断开」——「逐个页面」是关键,因为我漏的那个坑正是只测了主要页面。

模块五:首屏速度与包体积治理

  1. 首屏速度与包体积治理(串行请求改并行消除等待叠加 + 分包让主包只装首屏用得到的 + 骨架屏按真实结构而非转圈 + 首屏图片优先级高于预加载)★★★
    简历这样写 首屏速度与包体积(uni-app + Vue 3 + 分包):首屏原为登录态校验、用户信息、列表数据三个请求串行发出,耗时是三者相加,改为能并行的并行、并把不阻塞渲染的请求延后,首屏等待时间明显下降;主包原先把全部页面与依赖都打进去,接近平台体积上限且启动变慢,改为按功能分包(发布器、图片编辑、设置等非首屏页面拆出去)并移除仅个别页面用到的重依赖,主包体积大幅下降;加载态从全屏转圈改为与真实结构一致的骨架屏(转圈让人感觉更慢,骨架屏让用户提前知道会出现什么);修正了首屏图片与预加载抢带宽的问题——预加载改为等首屏图片就绪后再启动。全部数字在把下行限速到几百 KB 的条件下测得,并在旧安卓机上验证启动耗时。
    展开完整拆解
    为什么要这么设计

    首屏速度是我最后才认真处理的一块——因为在我的电脑和好网络下,打开首页几乎是瞬间的把下行限到几百 KB、再换到旧安卓机上,四个问题一起暴露。

    一是首屏的几个请求是串行发的,耗时直接相加。我的写法很自然:先校验登录态、拿到用户信息、再去请求列表数据——每一步都等上一步返回限速之后这三次往返叠加起来,首屏要等好几秒而实际上其中两个请求之间并没有真正的依赖关系,完全可以同时发。

    二是主包塞了全部页面,接近平台体积上限而且启动变慢。我把所有页面和依赖都打在一起。其中「图片编辑」这个页面引入了一个比较重的依赖,而它只在发布流程里被用到——但它被打进了主包,导致每个用户打开首页都要先下载它

    三是加载态是一个全屏转圈,让人觉得更慢。限速下这个圈要转好几秒。而用户看到转圈时,除了「在加载」什么信息都没有——他不知道要等多久、也不知道会出现什么。

    四是我做的图片预加载在首屏阶段是负优化。首页一打开就开始预加载后面几屏的图片,而这些预加载请求和首屏那几张图抢同一份带宽——结果是首屏图片出得更慢了这个问题和我在播放流那边遇到的完全同源:预加载不是免费的。

    所以四个改动:能并行的请求并行、不阻塞渲染的延后按功能分包并移除主包里的重依赖全屏转圈改为与真实结构一致的骨架屏预加载等首屏图片就绪后再启动

    这个模块最想说的一句话是:首屏慢的原因往往不是「某一步慢」,而是「本来可以同时做的事情被排成了一队」。我最初的三个串行请求里,只有一处是真依赖(要先知道是谁才能拿他的信息),另一处纯粹是我按书写顺序排下来的把它拆开之后首屏时间下降的幅度,比我优化任何单个接口都大——而这个改动几乎不涉及任何新技术。

    整体链路
    请求编排(先看清哪些是真依赖) │ ├─ 把首屏用到的请求列出来,逐个问「它依赖谁的结果」 │ 我原来的顺序:校验登录态 → 拿用户信息 → 请求列表 │ 逐个检查后发现 │ 「拿用户信息」确实依赖登录态(要先知道是谁) │ 「请求列表」并不依赖用户信息 —— 它只需要令牌 │ 也就是说三个串行里只有一处是真依赖 │ ├─ 真依赖的保持串行,其余并行发出 │ 串行的后果:三次往返的耗时直接相加,限速下要等好几秒 │ ├─ 不阻塞首屏渲染的请求延后 │ 例:未读数 · 推荐话题 · 配置项 —— 等首屏出来再拉 │ └─ 首屏只请求「第一屏看得见的数据量」,不要顺手多拉几页 多拉的后果:首屏变慢,而多出来的数据用户可能根本不会滑到 包体积(分包) │ ├─ 主包只放首屏与高频路径用到的页面 │ ├─ 非首屏页面拆到分包:发布器 · 图片编辑 · 设置 · 个人资料编辑 │ 全部打进主包的后果 │ 接近平台体积上限、启动变慢 │ 「图片编辑」引入了一个较重的依赖,只在发布流程用到 │ 但它被打进主包 → 每个用户打开首页都要先下载它 │ ├─ 重依赖要检查「谁在用它」 │ 只有个别页面用到的重依赖,应该跟着那个页面进分包 │ ├─ 分包的代价:首次进入该分包页面时有一次加载 │ 缓解:在用户明显要去的时候提前预载该分包 │ 例:他点了「发布」按钮的那一刻就开始预载发布器分包 │ └─ 公共代码要放在合适的位置,避免多个分包各打一份 加载态(骨架屏而不是转圈) │ ├─ 骨架屏要和真实结构一致(双列瀑布流就画双列卡片轮廓) │ ├─ 全屏转圈的问题 │ 除了「在加载」什么信息都没有 │ 用户不知道要等多久、也不知道会出现什么 │ 而转圈本身会让人感觉更慢(即使实际速度一样) │ ├─ 骨架屏不要做太花的动画,微弱的呼吸感就够 │ └─ 数据到达后骨架平滑替换成真实内容,避免闪一下 首屏图片的优先级 │ ├─ 首屏可见的图片优先加载 ├─ 预加载(后面几屏的图)要等首屏图片就绪后才启动 │ 同时启动的后果:预加载和首屏图抢同一份带宽 │ 结果首屏图片出得更慢 —— 预加载成了负优化 └─ 这一条和播放流那边的预缓冲问题完全同源 预加载不是免费的,它抢的是当前正要用的资源 其他可测的小改动 ├─ 图片域名提前建立连接,省掉首次请求的连接建立时间 ├─ 首屏数据可以本地缓存一份,冷启动先渲染缓存再刷新 │ 但要标注数据时间,并在刷新后平滑替换 └─ 启动阶段不做非必要的初始化(日志上报 · 统计 · 非首屏依赖) 这些都挪到首屏渲染完成之后
    分步拆解
    1. 先把首屏用到的请求列出来,逐个问「它依赖谁的结果」。我原来三个串行请求里只有一处是真依赖,另一处纯粹是我按书写顺序排下来的。
    2. 真依赖保持串行,其余全部并行发出。串行的后果是三次往返的耗时直接相加,限速下要等好几秒。
    3. 不阻塞首屏渲染的请求要延后。未读数、推荐话题、配置项——等首屏出来再拉。
    4. 首屏只请求第一屏看得见的数据量。顺手多拉几页的后果是首屏变慢,而多出来的数据用户可能根本不会滑到。
    5. 主包只放首屏与高频路径的页面,其余拆分包。发布器、图片编辑、设置、资料编辑都不是首屏必需的。
    6. 重依赖要检查「谁在用它」。只有个别页面用到的重依赖应该跟着那个页面进分包——我的图片编辑依赖被打进主包,导致每个用户打开首页都要先下载它。
    7. 分包的代价是首次进入该页面时有一次加载。缓解办法是在用户明显要去的时候提前预载(他点了「发布」按钮的那一刻就开始预载发布器分包)。
    8. 公共代码要放在合适的位置。否则多个分包会各打一份,总体积反而变大。
    9. 加载态用与真实结构一致的骨架屏,不要全屏转圈。转圈除了「在加载」什么信息都没有,而且会让人感觉更慢(即使实际速度一样)。
    10. 骨架屏不要做太花的动画。微弱的呼吸感就够——花哨的动画本身也占渲染资源。
    11. 数据到达后骨架要平滑替换。硬切会闪一下,而闪一下会让人觉得「卡了」。
    12. 首屏可见的图片要优先加载,预加载等首屏就绪后再启动。同时启动的后果是预加载和首屏图抢同一份带宽,首屏图片出得更慢——预加载成了负优化。
    13. 图片域名可以提前建立连接。省掉首次图片请求的连接建立时间,这个改动成本几乎为零。
    14. 首屏数据可以本地缓存,冷启动先渲染缓存再刷新。但要标注数据时间,并在刷新后平滑替换。
    15. 启动阶段不做非必要的初始化。日志上报、统计、非首屏依赖全部挪到首屏渲染完成之后。
    关键决策与取舍

    请求并行化是这个模块里收益最大、成本最低的一个改动,而它不涉及任何新技术。我最初的串行顺序不是设计出来的,是我按书写顺序自然排下来的——先校验登录、再拿用户信息、再拿列表逐个检查依赖关系之后发现「请求列表」根本不依赖「用户信息」,它只需要令牌判据很简单:这个请求真的需要上一个请求的结果吗——大多数时候答案是不需要。我特意强调这一点,因为它说明首屏慢往往不是「某一步慢」,而是「本来可以同时做的事被排成了一队」。

    分包的代价是「首次进入某个页面会多一次加载」,我用「提前预载」来缓解而不是取消分包。不分包的话所有页面都是即时的。但代价是每个用户打开首页都要先下载他这次可能根本不会用到的东西——而首屏是每个用户必经的,发布器只有一部分用户会用判据是「这段代码有多大比例的用户会用到」:比例低的就该延后加载。缓解手段的关键是「在用户表现出意图的那一刻」预载(点了发布按钮而不是打开首页时),这样既不占首屏带宽、又几乎没有等待。

    骨架屏而不是转圈,代价是每个页面要多画一份骨架结构。转圈是一行代码、通用、不用维护。但它传递的信息量是零——用户不知道要等多久、会出现什么而骨架屏让他提前知道「这里会出现一个双列瀑布流」,感知等待时间明显更短代价是骨架结构要跟着真实结构一起维护(改了布局要记得改骨架)判据是「等待时间有多长」:限速下要等好几秒,那就值得为它做骨架;如果只等几十毫秒,转圈甚至什么都不做反而更好。

    预加载让位于首屏图片,而不是同时进行。同时进行看起来「更充分利用带宽」。但带宽是共享且总量固定的——预加载抢走的正是首屏那几张图需要的部分判据是「这个提前准备抢的是谁的资源」:抢的是当前正要用的资源,那就必须让位。这一条和我在播放流那边的预缓冲问题完全同源,同一个错误我犯了两次,所以后来把它写成了一条自检。

    踩过的坑一:三个首屏请求串行,我完全没意识到它们可以并行。因为代码读起来非常顺(一步接一步)。是限速之后看请求瀑布图才发现三个请求整整齐齐地排成了一列教训是:代码的书写顺序会不自觉地变成执行顺序,而它们之间往往并没有真正的依赖——看请求瀑布图是发现这类问题最快的方式,比读代码快得多。

    踩过的坑二:一个只在发布流程用到的重依赖被打进了主包。我是在查主包体积构成时发现的。教训是:依赖的「引入位置」决定了它被打到哪里,而这一点在写代码时完全感觉不到——必须专门去看体积构成,否则主包会不知不觉地长大。

    踩过的坑三:预加载在首屏阶段是负优化,而且这是我第二次犯同一个错误。第一次在播放流的预缓冲上,第二次在这里教训是:如果同一类错误犯了两次,说明它不是疏忽而是认知缺口——所以我把「预加载抢的是谁的资源」写成了一条固定自检,每次做提前加载都过一遍。

    没做的部分:没做首屏数据的服务端聚合(把几个首屏请求合并成一个接口)。它能把并行的几次往返进一步压成一次,收益是实在的;但代价是后端多一个只服务于这个页面的聚合接口,页面改动时它也要跟着改取舍依据是「并行化之后首屏时间已经可以接受,而聚合接口会把前后端耦合得更紧」——不过我能说清它是下一步,以及它的代价在哪。

    数字是怎么测的

    所有数字必须带限速条件,这是这个模块的前提:我报的是「把下行限到几百 KB 每秒」,并在旧安卓机上验证启动耗时不限速的话所有对比都接近零差异——在我的电脑上首页几乎是瞬间的。

    请求并行化的效果最好用请求瀑布图说明:报「首屏请求从三次往返串行叠加,变为一次依赖串行加两次并行」,并报首屏数据就绪时间从 X 降到 Y瀑布图的形状(一列变成一个台阶)比数字更直观,而这也是我发现问题的方式。

    包体积要报主包体积的绝对值与构成:「主包体积从 X 降到 Y,其中移出的最大一项是某个只在发布流程用到的依赖,约占 Z」。报出「最大的一项是什么」比只报总量有用得多,因为它说明我看过体积构成。

    启动耗时要报机型:「在某型旧安卓机上,从点击图标到首屏骨架出现的时间从 X 降到 Y」。要区分「骨架出现」和「数据就绪」两个时间点——前者主要受包体积影响、后者主要受请求影响,混成一个数字就说不清是哪个改动起的作用。

    骨架屏的效果不适合用技术指标衡量,可以报「首个有意义的画面出现时间」:骨架出现的时间远早于数据就绪的时间,这个差值就是骨架屏填补的那段等待。诚实说明这不是「变快了」,而是「等待期间有了内容」。

    预加载让位的效果用对照实测:「限速条件下,预加载与首屏图片同时启动 vs 首屏就绪后再启动,两种策略下首屏图片全部出图的时间分别是 X 和 Y」。报出「同时启动反而更慢」这个反直觉结果。

    分包预载:断言点击「发布」按钮时开始预载分包,进入发布页时无额外等待并断言不点击时不会加载该分包(可以看网络请求确认)。

    不要报什么:不要报「首屏速度提升 N 倍」——倍数完全取决于限速设成多少。该报的是「限速值与机型」「首屏请求从串行叠加变为一次串行加两次并行及数据就绪时间对比」「主包体积绝对值与移出的最大一项」「骨架出现与数据就绪两个时间点」「预加载两种策略的对照」这几件带条件的、可核对的事。

    面试追问
    Q:首屏慢你是怎么定位的? A:先说一个前提:在我的电脑和好网络下,打开首页几乎是瞬间的,所以我一开始根本不觉得有问题。把下行限到几百 KB、再换到旧安卓机上之后才暴露的。定位方式很直接:看请求瀑布图——一眼就看到三个请求整整齐齐地排成了一列。我的写法是「先校验登录态、拿到用户信息、再去请求列表数据」,每一步都等上一步返回,限速之后三次往返的耗时直接相加然后我逐个问「它依赖谁的结果」:拿用户信息确实依赖登录态(要先知道是谁),但请求列表并不依赖用户信息——它只需要令牌。也就是说三个串行里只有一处是真依赖,另一处纯粹是我按书写顺序自然排下来的。教训是:代码的书写顺序会不自觉地变成执行顺序,而它们之间往往并没有真正的依赖——而看请求瀑布图是发现这类问题最快的方式,比读代码快得多。改完之后首屏时间下降的幅度比我优化任何单个接口都大,而这个改动几乎不涉及任何新技术另外还做了两件事不阻塞首屏渲染的请求延后(未读数、推荐话题、配置项等首屏出来再拉);首屏只请求第一屏看得见的数据量,不顺手多拉几页——多拉的后果是首屏变慢,而多出来的数据用户可能根本不会滑到我报数字时会把「骨架出现」和「数据就绪」两个时间点分开,因为前者主要受包体积影响、后者主要受请求影响,混成一个数字就说不清是哪个改动起的作用。
    Q:分包之后首次进入那个页面要等一下,用户不会觉得卡吗? A:会有一次加载,这是分包的真实代价,我用「在用户表现出意图的那一刻预载」来缓解,而不是取消分包。先说为什么必须分包:我原来把全部页面和依赖都打进主包,接近平台体积上限而且启动变慢其中「图片编辑」这个页面引入了一个比较重的依赖,而它只在发布流程里被用到——但它被打进了主包,导致每个用户打开首页都要先下载它判据是「这段代码有多大比例的用户会用到」:首屏是每个用户必经的,而发布器只有一部分用户会用,比例低的就该延后加载。缓解手段的关键在时机:不是打开首页时预载(那又占了首屏带宽),而是他点了「发布」按钮的那一刻开始预载发布器分包——这时候他的意图已经明确,而从点按钮到页面真正需要渲染之间有一小段时间可以用,实测下来基本没有额外等待。我还专门断言了「不点击时不会加载该分包」,否则预载就变成了变相的不分包。另一个容易忽略的点是公共代码的位置:如果多个分包都用到同一段代码,放错位置会让每个分包各打一份,总体积反而变大顺带说我发现主包问题的方式:不是写代码时感觉到的,而是专门去查了主包的体积构成——依赖的「引入位置」决定了它被打到哪里,而这一点在写代码时完全感觉不到,必须专门去看,否则主包会不知不觉地长大。

模块六:图片查看器的手势冲突

  1. 图片查看器的手势冲突(缩放与切换与关闭三种手势的优先级 + 放大后拖到边界才交出滑动权 + 双击与单击的判定延迟 + 长按保存与手势的互斥)★★★
    简历这样写 图片查看器(uni-app + Vue 3 + 手势状态机):查看器需同时支持双指缩放、左右切换、下拉关闭、双击放大、长按保存,初版各手势独立监听导致互相误触发(放大后想拖动图片却切到了下一张、想下拉关闭却把图片拖歪、双击被识别成两次单击);改为用一个手势状态机统一裁决:按触点数量先分流(双指一律进缩放,不参与切换与关闭判定),单指则按当前缩放状态与移动方向决定归属;放大状态下拖动优先给图片自身,只有拖到图片边界仍继续同方向移动时才把滑动权交给切换;单击与双击通过短延迟裁决(先等一小段时间确认没有第二次点击才算单击);长按在移动超过阈值后取消,避免拖动过程中误触发保存。全部问题在真机上通过反复实际操作复现并逐项回归。
    展开完整拆解
    为什么要这么设计

    图片查看器是我以为「装个组件就完了」的一块,结果因为要同时支持五种手势,它成了整个客户端里逻辑最绕的一个地方。四个问题全都是我自己在真机上反复操作时撞出来的。

    一是放大之后想拖动图片,结果切到了下一张。我的实现是「单指横向移动就切换图片」。但图片放大之后,用户横向拖动的意图是「看图片的右半边」,而不是「换一张」——我把两种完全不同的意图用同一个手势判定了。

    二是想下拉关闭,结果把图片拖歪了。我同时监听了「纵向移动关闭」和「拖动图片」。两个逻辑都被触发,图片一边跟手往下走、一边还在自己位移——动作看起来是坏的。

    三是双击放大经常失效,被识别成两次单击(于是关闭了查看器)。我的单击处理是「点一下就关闭」。用户双击时,第一次点击立刻触发了关闭,第二次点击落在了已经关掉的界面上——双击放大这个功能实际上永远不会生效。

    四是拖动过程中误触发长按保存。我的长按判定只看「按住超过多久」。用户按住图片开始拖动,拖了一会儿之后长按计时也到了——保存弹窗突然冒出来打断了拖动。

    所以四个改动:用一个手势状态机统一裁决而不是各手势独立监听放大状态下拖动优先给图片、拖到边界才交出滑动权单击与双击用短延迟裁决长按在移动超过阈值后取消

    这个模块最想说的一句话是:手势冲突的根因不是某个手势写错了,而是「同一个物理动作可以对应多种意图」,而我让多个独立的监听各自去猜。各自都猜得「有道理」,合起来就是互相打架解法只能是把裁决权收到一处——由一个状态机根据触点数量、当前缩放状态、移动方向和距离,决定这一次动作归谁,而不是让五个监听各自认领。

    整体链路
    为什么必须统一裁决 │ ├─ 同一个物理动作可以对应多种意图 │ 单指横向移动 = 切换图片?还是看放大后的右半边? │ 单指纵向移动 = 下拉关闭?还是拖动放大后的图片? │ 按住不动 = 长按保存?还是准备开始拖动? │ └─ 各手势独立监听的后果(我的第一版) 每个监听都觉得「这个动作是我的」→ 同时触发 → 互相打架 放大后想拖动却切到下一张 · 想下拉关闭却把图片拖歪 第一层分流:按触点数量 │ ├─ 两个触点 → 一律进入缩放,且不参与切换与关闭的判定 │ 不排除的后果:双指靠拢时两指的横向位移被当成了滑动切换 │ ├─ 一个触点 → 进入下面的单指裁决 │ └─ 触点数量变化时要重置手势状态 从两指变一指(松开一根手指)不应该立刻被当成拖动开始 第二层:单指裁决(看当前缩放状态) │ ├─ 未放大状态 │ 横向移动 → 切换图片 │ 纵向移动 → 下拉关闭 │ 方向由「首次移动超过阈值时的主方向」锁定,之后不再改变 │ 不锁定方向的后果:斜着拖时两种行为交替触发,画面乱跳 │ └─ 已放大状态 拖动优先给图片自身(用户想看图片的其他部分) 只有「已经拖到图片边界、且仍在继续同方向移动」时 才把滑动权交给切换图片 这一条是关键 —— 它让「看右半边」和「换下一张」自然区分开 第三层:点击类手势的裁决 │ ├─ 单击与双击冲突 │ 我的单击行为是「关闭查看器」 │ 不裁决的后果 │ 用户双击时,第一次点击立刻关闭了查看器 │ 第二次点击落在已经关掉的界面上 → 双击放大永远不生效 │ 做法:单击先等一小段时间,确认没有第二次点击才执行 │ 代价:单击关闭会有轻微延迟(可接受) │ └─ 长按与拖动冲突 长按只看「按住多久」的后果 用户按住开始拖动,拖了一会儿长按计时也到了 保存弹窗突然冒出来打断拖动 做法:移动距离超过阈值就取消长按计时 缩放本身的边界处理 ├─ 最小缩放不小于适配屏幕的尺寸,松手回弹 ├─ 最大缩放设上限,超过后跟手但松手回弹 ├─ 缩放中心跟随双指中点,而不是固定在图片中心 │ 固定中心的后果:用户想放大某个角落,结果放大的是中间 └─ 从放大状态切换图片时,新图片要重置为未放大 其他细节 ├─ 切换图片的判定要看「移动距离 + 速度」,不能只看距离 │ 只看距离的后果:快速轻扫切不动,慢慢拖一点点却切了 ├─ 关闭时图片跟手缩小并淡出,比直接消失自然 └─ 长按保存要处理权限被拒的情况,给出明确指引
    分步拆解
    1. 先认清根因:同一个物理动作可以对应多种意图。单指横向移动既可能是「切换图片」也可能是「看放大后的右半边」——让多个独立监听各自去猜,结果就是互相打架。
    2. 把裁决权收到一个状态机里。由它根据触点数量、缩放状态、移动方向和距离决定归属,而不是让五个监听各自认领。
    3. 第一层按触点数量分流:两指一律进缩放,且不参与切换与关闭判定。不排除的后果是双指靠拢时两指的横向位移被当成了滑动切换。
    4. 触点数量变化时要重置手势状态。从两指变一指(松开一根手指)不应该立刻被当成拖动开始。
    5. 未放大状态下,方向由「首次移动超过阈值时的主方向」锁定。不锁定的后果是斜着拖时切换与关闭两种行为交替触发,画面乱跳。
    6. 已放大状态下,拖动优先给图片自身。因为用户的意图是看图片的其他部分,而不是换一张。
    7. 只有「拖到图片边界且仍在继续同方向移动」时才把滑动权交给切换。这一条是关键——它让「看右半边」和「换下一张」自然区分开,不需要用户学习任何规则。
    8. 单击要先等一小段时间确认没有第二次点击。不等的后果是用户双击时第一次点击就关闭了查看器,双击放大永远不生效。
    9. 接受单击关闭的轻微延迟。这是双击能成立的必要代价,而这个延迟用户基本感觉不到。
    10. 长按要在移动距离超过阈值后取消。只看「按住多久」的后果是用户按住开始拖动、拖了一会儿长按计时也到了,保存弹窗突然打断拖动。
    11. 缩放中心要跟随双指中点,不能固定在图片中心。固定中心的后果是用户想放大某个角落,结果放大的是中间。
    12. 最小与最大缩放都要设边界,超出后跟手但松手回弹。硬性卡住会让人觉得「坏了」,跟手加回弹才有物理感。
    13. 从放大状态切换图片时,新图片要重置为未放大。不重置的后果是新图片一上来就是放大的,用户看不到全貌。
    14. 切换图片的判定要看「移动距离加速度」。只看距离的后果是快速轻扫切不动、慢慢拖一点点却切了——两种都不符合直觉。
    15. 关闭时让图片跟手缩小并淡出。比直接消失自然,而且让用户知道自己的下拉动作是有效的。
    16. 长按保存要处理权限被拒的情况。给出明确指引(去系统设置开权限),不要只弹一个「保存失败」。
    关键决策与取舍

    用一个手势状态机统一裁决,而不是各手势独立监听。独立监听的写法最自然(每个手势一段代码、互不影响)。但它的前提假设是「这些手势不会同时被触发」——而这个假设在触摸交互里根本不成立同一个物理动作可以对应多种意图,各个监听都觉得「这个动作是我的」代价是所有手势的逻辑集中在一处,读起来更复杂判据是「这些行为之间有没有互斥关系」:有互斥关系的行为不能各自独立判定,必须有一个地方做仲裁这一条和我在请求层「令牌读写收敛到唯一入口」的道理是相通的——需要全局一致的决策,就不能分散。

    放大状态下「拖到边界才交出滑动权」,而不是用一个显式的开关区分两种模式。显式开关(比如加一个「切换/拖动」的模式按钮)实现最简单、也不会误判。但它要求用户去学习和切换模式,而这是主流图片查看器都不需要的「拖到边界才交出」的好处是它符合物理直觉:图片已经拖到头了、你还在往同一个方向推,那自然是想去下一张代价是边界判定要准确(包括缩放后图片实际尺寸的计算)判据是「能不能让用户不学习就用对」:能,那就值得多花实现成本。

    单击加延迟裁决,接受「关闭有轻微延迟」这个代价。不加延迟的话单击响应最快。但它让双击放大永远不生效——因为第一次点击已经把查看器关了而这个延迟很短,用户基本感觉不到判据是「哪种损失更大」:一个功能完全不可用 vs 另一个功能慢几十毫秒,显然是前者更严重。这类「为了让 B 成立而牺牲 A 一点点」的取舍,关键是量清 A 被牺牲了多少——如果延迟长到用户能感觉出来,我的结论会反过来。

    长按用「移动超阈值即取消」而不是「移动超阈值后仍允许长按」。后者更宽容(手抖也能触发长按)。但它会在拖动过程中突然弹出保存弹窗、打断拖动——而拖动是一个持续的动作,被打断的感受很糟取消的代价是手抖的用户可能触发不了长按(他需要按稳一点)判据是「误触发的代价」和「触发不了的代价」哪个大:误触发打断了正在进行的动作,代价更大。

    踩过的坑一:放大之后想拖动图片,结果切到了下一张。我的判定是「单指横向移动就切换」。教训是:判定一个手势的意图,不能只看动作本身,还要看当前的状态——同样的横向移动,在未放大和已放大两种状态下,用户的意图完全不同。我一开始的实现完全没有把「当前缩放状态」纳入判定条件。

    踩过的坑二:双击放大永远不生效。我查了很久为什么双击没反应,后来才发现问题不在双击的判定上,而在于第一次点击已经把查看器关了——第二次点击落在了一个已经不存在的界面上教训是:当 B 功能「完全不生效」时,先看看有没有 A 功能提前把它的前提破坏了——我一直在检查双击的代码,而问题在单击那边。

    踩过的坑三:斜着拖动时画面乱跳。因为横向和纵向的判定都在持续生效、交替触发。教训是:方向类手势必须在开始时锁定主方向、之后不再改变——持续判定方向会让斜向移动变成两种行为的高频切换。

    没做的部分:没做双指旋转。它需要在缩放的基础上再叠加一个旋转量,而旋转与缩放的手势特征高度重合(都是双指),误判会很多;而且旋转之后「边界在哪」的计算会复杂很多(影响拖到边界才切换那条规则)取舍依据是「这个手势的使用频率很低,而它会让已经调好的缩放与边界判定重新变得不可靠」——收益小、风险大,所以不做。

    数字是怎么测的

    这个模块的验证以断言型用例为主,而且必须在真机上实际操作——不适合报性能数字。我的做法是把每一种冲突场景写成一条可复述的操作步骤,逐项回归报法是「N 种手势冲突场景逐项验证通过」并列出清单——列清单本身就说明我把组合想全了。

    放大后拖动不误切:放大图片、向右拖动,断言图片自身移动、没有切换到下一张;继续拖到右边界后仍向右移动,断言此时才切换。

    双指不误触发切换:用双指做靠拢与张开,断言只发生缩放、不触发切换与关闭(这一条对应「两指一律进缩放且不参与其他判定」)。

    斜向拖动不乱跳:斜着 45 度拖动,断言只发生一种行为(按首次超过阈值时的主方向),不出现切换与关闭交替触发。

    双击放大生效:双击图片,断言放大且查看器没有被关闭——这条是踩坑的直接回归,改造前它是必然失败的。

    单击关闭仍然可用:单击一次,断言在短延迟后关闭并报出这个延迟的具体值,说明它在用户可感知阈值以下。

    长按不误触发:按住图片并拖动超过阈值,断言保存弹窗不出现;按住不动,断言保存弹窗正常出现。两条一起测才说明阈值取得合理。

    缩放中心:在图片的某个角落做双指张开,断言放大的是该角落而不是图片中心。

    状态重置:放大后切换到下一张,断言新图片是未放大状态;双指松开一根手指,断言不会立刻被当成拖动开始。

    不要报什么:不要报「手势识别准确率」——没有客观的分母,而且这类问题是「有或没有」而不是「多少比例」。该报的是「N 种冲突场景的清单与逐项验证结果」「单击关闭的延迟具体值」「长按取消的移动阈值」以及「测试是在哪些真机上做的」——手势相关的结论必须说明机型,因为不同设备的触摸采样和系统手势会有差异。

    面试追问
    Q:图片查看器不是有现成组件吗?为什么要自己处理手势? A:现成组件能解决单一手势,但我要同时支持五种手势(双指缩放、左右切换、下拉关闭、双击放大、长按保存),而它们会互相打架——这部分冲突没有组件能替我裁决。我的第一版是各手势独立监听,撞出四个问题放大后想拖动图片却切到了下一张(单指横向移动被判定为切换);想下拉关闭却把图片拖歪了(关闭和拖动两个逻辑同时被触发);双击放大永远不生效(第一次点击就把查看器关了);拖动过程中突然弹出保存弹窗(长按计时到了)。根因不是某个手势写错了,而是同一个物理动作可以对应多种意图——而我让多个独立的监听各自去猜,各自都猜得「有道理」,合起来就是互相打架。解法只能是把裁决权收到一处:用一个手势状态机,先按触点数量分流(两指一律进缩放,且不参与切换与关闭判定——不排除的话双指靠拢时两指的横向位移会被当成滑动切换),单指则按当前缩放状态与移动方向决定归属判据是「这些行为之间有没有互斥关系」:有互斥关系的行为不能各自独立判定,必须有一个地方做仲裁。这一条和我在请求层把令牌读写收敛到唯一入口的道理是相通的——需要全局一致的决策,就不能分散。另外有一个我觉得很值得说的判定细节:未放大状态下,方向要由「首次移动超过阈值时的主方向」锁定、之后不再改变——不锁定的话斜着拖时切换与关闭两种行为会交替触发,画面乱跳。
    Q:放大之后左右拖动,你怎么知道用户是想看图片还是想换一张? A:靠「拖到边界才交出滑动权」这一条规则,它的好处是符合物理直觉、用户不需要学习。具体是:放大状态下拖动优先给图片自身(用户的意图是看图片的其他部分);只有当图片已经拖到边界、而他仍在继续同方向移动时,才把滑动权交给切换图片这就像你在桌上推一张纸,推到桌沿了还继续推,那自然是想拿下一张。我也考虑过更简单的方案:加一个显式的模式开关(切换/拖动)——实现最简单、绝不会误判,但它要求用户去学习和切换模式,而这是主流图片查看器都不需要做的事判据是「能不能让用户不学习就用对」:能,那就值得多花实现成本。代价是边界判定必须准确,包括缩放之后图片实际尺寸的计算——这一块我调了挺久,因为算错了会导致「明明还没到边就切了」或者「到边了却切不动」。这个坑给我的更一般的教训是:判定一个手势的意图,不能只看动作本身,还要看当前的状态——同样的横向移动,在未放大和已放大两种状态下用户的意图完全不同,而我第一版完全没把「当前缩放状态」纳入判定条件。还有一个相关的小规则:从放大状态切换到下一张时,新图片要重置为未放大——不重置的话新图片一上来就是放大的,用户看不到全貌。

没有匹配的内容,换个关键词试试。

项目拆解 · 图文内容社区客户端(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据