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

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

实习级这一档是干什么的 信息流的推荐链路、IM 的长连接与消息可靠投递这些核心台面,实习生大概率碰不到。这一档收的是社区客户端里实习生真的会被分派、但仍然有明确技术含量的活。下面三个模块彼此独立——只做过一个就只写一个。
怎么讲才不显得 low 「我做了个消息页,拉接口渲染列表」和「红点和列表的未读数会不一致,因为清未读是异步的、而用户可能在请求返回前就退出了页面;后来把「本地先清、失败回滚」和「进入 tab 才清而不是进页面就清」两件事分开处理」——同一件事,后者面试官会顺着追问。这一档的破解办法是找到「异步结果回来时界面已经变了」这类问题:红点不一致、搜索结果错乱、返回后滚动位置丢失,本质上是同一类。
三条自检 一、能说出不这么做会怎样(红点清不掉用户会反复点进来、搜索请求后发先至会显示上一次的结果、长列表不回收滚到几百条会卡);二、能说出你踩过的具体坑;三、能说出量级(列表多少条、图片多大、在什么机型上测的)。三条都有就能写。
项目背景设定 社区 App 的 uni-app 客户端(微信小程序 + iOS/Android),Vue 3 组合式 API。我负责的是消息中心、搜索页、个人主页这三个「基础但每天都被用」的页面。
为什么这三块值得写 它们共同的技术难点是「异步结果回来的时候,界面已经不是发起请求时的那个界面了」:未读数清了但请求失败、搜索快速输入导致先发的请求后返回、返回上一页时列表已经被回收。这类问题在小规模手测时几乎不出现,真机快速操作和弱网下必现。而这正是客户端开发和「拉接口渲染」的分界线。

模块一:互动消息中心

  1. 互动消息中心(分类未读与红点一致性 + 同内容多互动聚合 + 已读的本地先行与失败回滚 + 新消息插入不打断阅读)★★
    简历这样写 互动消息中心(uni-app + Vue 3 组合式 API + Pinia + 本地缓存):按赞、评论、关注、系统分类展示并各自维护未读数,解决原先一个总数无法区分类型、用户为了消掉红点必须逐条点开的问题;同一条内容的多个互动聚合成一条(展示头像堆叠与总人数,点击可展开明细),消息条数大幅压缩;已读采用本地先清、请求失败回滚并把清除时机从「进入页面」改为「进入对应分类 tab 且实际曝光」,修复红点与列表未读数不一致;列表刷新时新消息不直接插入当前可视区,改为顶部提示条由用户点击加载,避免阅读位置被顶走;未读数在多端与页面间通过统一状态源同步,不再各页面自行请求导致数字打架。
    展开完整拆解
    为什么要这么设计

    消息中心第一版就是「拉一个列表接口、渲染、进页面时调一次清未读」。看起来完全没问题,上线后用户反馈和线上问题集中在四个点。

    一是红点消不掉。用户点进消息页,红点没了;退出来,红点又回来了。原因是我在进入页面时调了清未读接口,但没等它返回就本地把红点清了——请求在弱网下失败了,本地红点清了、服务端还是未读,下次进页面重新拉到未读数,红点又出现。用户的感受是「这个 App 的红点是坏的」,而且他会反复点进来试

    二是消息刷屏。一条内容火了之后,收到几百个赞,消息列表就是几百条「XX 赞了你的内容」。用户想找一条重要的评论,得在几百条赞里翻。而这几百条赞对他的信息价值等于一条。

    三是一个总未读数没法用。用户看到「99+」,不知道是有人评论了还是只是一堆赞。而他关心的只有评论和关注,赞可以不看。为了消掉这个红点他必须把所有消息都过一遍。

    四是刷新时阅读位置被顶走。他正在看第 20 条,下拉刷新拉到 5 条新消息直接插到顶部,列表整体下移,他刚才在看的那条跑到屏幕外了

    所以四个改动:分类展示各自维护未读数同内容多互动聚合成一条已读改成本地先清加失败回滚、且清除时机改为进入 tab 并实际曝光新消息用顶部提示条而不是直接插入

    这个模块最想说的一句话是:客户端的坑绝大多数出在「异步结果回来时界面已经变了」。本地先清红点是为了响应快,但请求可能失败、用户可能已经退出页面、可能同时有新消息进来——这三种情况都会让本地状态和服务端状态分叉。解决办法不是不做本地先行(那样体验很差),而是明确定义失败时怎么回滚、并且让状态只有一个来源。

    整体链路
    未读数的统一状态源(这是一致性的前提) │ ├─ 全局 store 持有各分类未读数与总数 │ 总数 = 各分类之和,不单独从接口取 │ 单独取总数的后果:总数和分类之和对不上,用户一眼就看到 │ ├─ 所有需要展示红点的位置都读这一个 store │ tabBar 角标 · 消息页顶部 tab · 首页入口 │ 各页面自行请求 = 数字必然打架(刷新时机不同) │ └─ 更新来源:拉列表时带回 · 推送到达时增加 · 已读时减少 分类与聚合(解决刷屏和总数没法用) │ ├─ 四个分类:赞 · 评论 · 关注 · 系统 │ 分类的依据是「用户关心程度不同」,不是数据类型不同 │ ├─ 同一条内容的多个同类互动聚合为一条 │ 「张三、李四等 128 人赞了你的内容」+ 头像堆叠 │ 点击展开明细(明细是单独一个分页列表) │ ├─ 聚合的时间窗:同内容同类型的互动,落在窗口内的合并 │ 窗口太长 → 新互动被合进旧条目,用户看不到「有新的」 │ 窗口太短 → 聚合效果差,还是刷屏 │ └─ 评论类不聚合(每条评论的内容都不同,都有信息价值) 这一条是关键:不是所有类型都该聚合 已读处理(本地先行 + 失败回滚) │ ├─ 时机:进入对应分类 tab 且列表实际渲染出来 │ 不是进入页面就全清 —— 用户可能只看了「评论」tab │ 进页面全清的后果:赞的未读被莫名清掉,他以为漏看了 │ ├─ 本地先把该分类未读置 0(界面立刻响应) │ ├─ 发清除请求,带上「已读到的最新消息标识」 │ 不要传「全部已读」—— 请求发出后到达服务端前的新消息会被误清 │ ├─ 失败 → 回滚本地未读数并重试(有限次) │ 回滚时要注意期间可能来了新消息,回滚的是「原值」而非「置回旧数字」 │ └─ 用户在请求返回前退出页面 → 请求照常发,结果只更新 store 不动界面 新消息插入(不打断阅读) │ ├─ 首屏与下拉刷新:直接替换列表(用户主动要求刷新) │ ├─ 页面停留期间收到推送 → 不插入列表 │ 顶部出现「N 条新消息」提示条,点击才加载并滚到顶部 │ └─ 直接插入的后果:列表整体下移,用户正在看的条目跑出屏幕 长列表与缓存 ├─ 分页加载,滚动到底加载下一页 ├─ 首屏结果本地缓存,冷启动先渲染缓存再请求(弱网下有内容可看) └─ 缓存要带时间戳,过期的缓存不展示(避免显示很旧的消息)
    分步拆解
    1. 未读数必须有唯一的状态源。tabBar 角标、消息页 tab、首页入口三处都要显示红点,各自请求必然导致数字打架(刷新时机不同)。统一到一个 store,所有位置读它。
    2. 总数由分类之和算出,不单独取接口。单独取的话,总数和分类之和对不上,用户一眼就能看到。这类「同一份数据两条来源」的问题在客户端很常见。
    3. 分类的依据是「用户关心程度」而不是数据类型。赞可以不看、评论必须看。混在一起的后果是用户为了消红点必须把几百条赞也过一遍。
    4. 同内容同类型的互动要聚合,但评论不聚合。几百个赞的信息价值等于一条,而每条评论的内容都不同、都有价值。「不是所有类型都该聚合」是这里最容易做错的判断。
    5. 聚合的时间窗要慎选。窗口太长,新互动被合进旧条目,用户感知不到「有新的」;窗口太短,聚合效果差还是刷屏。要按实际互动密度定,并且能配置。
    6. 已读的清除时机是「进入对应分类 tab 且列表实际渲染」,不是进入页面。用户可能只看了评论 tab,进页面就全清会把赞的未读莫名清掉,他会以为自己漏看了什么。
    7. 本地先清是必要的(界面要立刻响应),但必须定义失败怎么办。我踩的坑就是只做了本地先清没做回滚,请求在弱网下失败,红点清了又回来,用户认为红点是坏的。
    8. 清除请求要带「已读到的最新消息标识」而不是「全部已读」。因为请求发出到服务端处理之间可能来了新消息,传「全部已读」会把这些新消息也误清。
    9. 回滚要回滚到「原值」,注意期间可能有新消息进来。简单地把旧数字写回去会覆盖掉期间的增量。正确做法是记录「减了多少」,回滚时加回这个增量。
    10. 用户在请求返回前退出页面,请求要照常发完。不要因为页面卸载就取消请求——取消的结果是已读状态没提交,红点还在。结果只更新 store 不动界面即可。
    11. 页面停留期间的新消息不要直接插入列表。列表整体下移会把用户正在看的条目顶出屏幕。用顶部提示条让用户自己决定什么时候加载。
    12. 下拉刷新可以直接替换列表,因为那是用户主动要求的。区别在于「用户主动」和「系统自动」——前者他预期界面会变,后者不预期。
    13. 首屏结果要本地缓存,冷启动先渲染缓存。弱网下至少有内容可看,而不是一直转圈。
    14. 缓存必须带时间戳,过期不展示。否则用户可能看到几天前的消息列表,比空白更困惑。
    关键决策与取舍

    本地先清红点 vs 等请求成功再清。等请求成功的好处是状态永远一致,代价是用户点进消息页要等几百毫秒红点才消失,弱网下要等更久甚至一直不消失——这个体验很差,用户会以为没点到。本地先清的代价是要处理失败回滚。我选本地先清,因为「界面立刻响应」在客户端是硬要求,而失败回滚是可以做对的。关键是不能只做一半——我第一版就是只做了本地先清没做回滚,反而比不做本地先清更糟(红点会闪现回来,用户觉得是 bug)。

    清除请求传「已读到某条」而不是「全部已读」,代价是要维护一个游标。但传「全部已读」有一个真实的时序问题:请求在网络上走的这段时间里到达的新消息会被一起清掉。用户会遇到「明明有人评论了但我没收到通知」。这类时序问题在手测时几乎不出现(手速不够快),但在真实的高互动账号上会稳定发生。

    聚合的取舍在于「压缩信息」和「丢失感知」之间。聚合窗口长,列表干净但新互动感知不到;窗口短,感知及时但压缩效果差。而更关键的判断是「哪些类型不该聚合」——评论、私信这类每条内容都不同的绝不能聚合。我最初把评论也聚合了,用户反馈「看不到别人具体说了什么」,这是把优化用错了地方。

    踩过的坑一:红点清了又回来,被用户反馈成「红点坏了」。根因是本地先清但请求失败无回滚。更麻烦的是这个问题在办公室网络下从不出现,我一直复现不了,直到用弱网工具限速才稳定重现。教训是:涉及网络的状态同步,必须在弱网和失败路径下测,成功路径正确不代表功能正确。

    踩过的坑二:进入页面就清所有分类的未读。用户只想看评论,结果一进去赞的红点也没了。他会怀疑「是不是有消息被吞了」。修法是清除时机绑定到具体 tab 的实际曝光。这个坑的通用教训是:「已读」的语义应该对应用户真的看到了,而不是他打开了某个容器。

    踩过的坑三:新消息直接插入列表把阅读位置顶走。用户正在看第 20 条,5 条新消息插到顶部,他看的那条跑出屏幕。而他不知道发生了什么,只觉得列表乱跳。修法是顶部提示条。这一条和搜索结果、个人主页的滚动位置问题是同一类——界面在用户没有预期的时候发生了变化。

    没做的部分:没做消息的本地全量存储与离线可读(只缓存首屏)。全量存储要处理本地库、增量同步、多端一致,复杂度高出一个量级,而消息中心的使用场景基本是在线查看。取舍依据是「离线读历史消息」这个需求实际很少被提。

    数字是怎么测的

    红点一致性用断言型用例,而且必须在失败路径下测:用弱网工具让清除请求失败,断言本地未读数回滚到原值、红点重新出现且数字正确;再断言重试成功后红点正确消失。这两条是我踩的坑的直接回归。

    时序问题的验证:清除请求发出后、返回前,人为注入一条新消息,断言这条新消息的未读没有被清掉。这条要靠打桩制造时序,手测测不出来。

    聚合效果报「消息条数压缩比」并说清样本:取一个高互动账号的真实互动数据,报聚合前后的条数对比。要说明是哪种账号(互动量级不同压缩比差很多),不能只报一个数。

    未读数一致性:断言 tabBar 角标、消息页 tab、首页入口三处显示的数字始终相同,且总数等于各分类之和。这条防的是多来源问题。

    列表性能报机型:加载到多少条时滚动帧率下降到多少,在哪个机型上测的。低端安卓机和 iOS 差别很大,只报一个数没有参考价值。

    不要报什么:不要报「消息阅读率提升 N%」——这受消息内容和推送策略影响,归因不清。该报的是「弱网失败时未读数正确回滚」「请求在途期间的新消息不被误清」「三处红点数字一致且等于分类之和」「某量级账号的条数压缩比」这四件可核对的事。

    面试追问
    Q:红点这种小功能能有什么坑? A:我上线后收到的第一个用户反馈就是「你们的红点是坏的」——点进消息页红点消失,退出来又回来了。根因是我做了「本地先清」但没做「失败回滚」:清未读的请求在弱网下失败了,本地红点清了、服务端还是未读,下次拉数据红点又出现。更麻烦的是这个问题我一直复现不了,因为办公室网络下请求从不失败,直到用弱网工具限速才稳定重现。教训是:涉及网络的状态同步,成功路径正确不代表功能正确,必须专门测失败路径。第二个坑是清除时机——我在「进入页面」时清了所有分类的未读,但用户可能只想看评论,结果赞的红点也莫名消失了,他会怀疑消息被吞了。所以改成绑定具体 tab 的实际曝光。「已读」的语义应该对应用户真的看到了,而不是他打开了某个容器。第三个坑更隐蔽:清除请求最初传的是「全部已读」,而请求在网络上走的那段时间里到达的新消息会被一起清掉——高互动账号上这个必现。改成传「已读到某条消息」的游标才解决。这三个坑本质上是同一类问题:异步结果回来时,界面和数据已经不是发起请求时的那个状态了。
    Q:消息聚合看起来是个显示优化,为什么说它有技术含量? A:聚合本身不难,难的是判断「哪些该聚合、窗口多长」,这两个判断错了功能就是负优化。我第一版把评论也聚合了——「张三等 5 人评论了你的内容」,用户反馈「看不到别人具体说了什么」。这个反馈让我意识到聚合的前提是「这些条目的信息价值重复」:几百个赞的信息价值确实等于一条(就是「有人赞了」),但每条评论的内容都不同,聚合掉就是信息丢失。所以后来的规则是赞和关注聚合、评论和私信不聚合。时间窗那个判断也有坑:窗口太长,新来的互动被合进已读的旧条目,用户完全感知不到有新互动——本来是要减少干扰,结果变成了屏蔽通知;窗口太短,聚合效果差还是刷屏。我的做法是按实际互动密度定并且做成可配置,因为不同量级的账号需要的窗口完全不同。顺带说,聚合之后「未读数怎么算」也要重新定义:一条聚合消息里有 3 个新赞,未读数是加 1 还是加 3?我们选的是按聚合条目算(加 1),因为未读数应该对应「有多少条需要你看的东西」,而不是原始事件数——否则红点上还是 99+,聚合的意义就没了。

模块二:搜索页与输入建议

  1. 搜索页与输入建议(请求竞态导致后发先至的丢弃策略 + 防抖与最小触发长度 + 分类结果各自独立分页 + 历史记录的本地治理)★★★
    简历这样写 社区搜索页(uni-app + Vue 3 组合式 API + 请求序号丢弃 + 本地存储):输入建议接口存在响应后发先至问题(快速输入时先发的请求后返回,界面显示的是上一个关键词的结果),引入请求序号与过期响应丢弃并在支持的平台上主动中断在途请求;输入侧做防抖与最小触发长度,把建议接口的请求量压到原来的一小部分;结果页按用户、内容、话题分类展示且各分类维护独立的分页与滚动状态,切换 tab 不再丢失已加载的结果;搜索历史本地存储并支持单条删除与整体清空(涉及隐私,不上传服务端),且做去重、按时间排序、限制条数;点击结果先收起软键盘再跳转,修复安卓上键盘遮挡导致的误触。
    展开完整拆解
    为什么要这么设计

    搜索页第一版是最直接的写法:输入框绑定 input 事件,每次输入调建议接口;点搜索按钮调结果接口;结果渲染成一个列表。本地测试完全正常,真机上问题很快就来了,四个问题。

    一是建议列表会显示错误的结果。用户输入「篮球」,建议列表里出现的是「篮」的建议。原因是我为每次输入都发了请求,而请求的返回顺序不保证和发出顺序一致——输入「篮」发了一个请求,输入「篮球」发了第二个,如果第一个请求慢、第二个快,那么第二个先返回被渲染,接着第一个返回又把它覆盖了。用户看到的是自己两个字符之前的建议。这个问题在本地测试时几乎不出现(响应太快),在真机弱网下必现。

    二是请求量过大。每次输入都发请求,输入「篮球比赛」是四次请求。而其中前三次的结果用户根本没看到(输入太快)。这些请求既浪费服务端资源也占用了客户端的并发额度。

    三是切换分类 tab 会丢失结果。用户在「内容」tab 翻了五页,切到「用户」tab 看了一下,切回来又回到第一页,之前翻的五页白翻了。因为我用了一个共享的列表状态。

    四是安卓上点击结果会误触。软键盘还弹着,点击列表项时键盘先收起、页面高度变化、列表位置上移,手指落点已经不在原来那一项上了——他点开的是另一条内容。

    所以四个改动:请求序号加过期响应丢弃防抖加最小触发长度各分类独立维护分页与滚动状态点击结果先收键盘再跳转

    这个模块最想说的一句话是:并发请求的响应顺序不保证和发出顺序一致,所以「用最后返回的响应更新界面」是错的,必须是「用最新发出的那个请求的响应更新界面」。这两句话听起来差不多,但实现完全不同,而且前者在本地测试时看不出问题——这是我在这个页面上学到最重要的一件事。

    整体链路
    输入阶段(建议) │ ├─ 防抖:停止输入若干毫秒后才发请求 │ 连续输入四个字从四次请求压到一次 │ ├─ 最小触发长度:单字符不发请求(结果没有区分度,纯浪费) │ ├─ 请求序号:每次发请求自增一个序号并随请求带上 │ 响应回来时比对序号,不是最新序号就直接丢弃 │ 这是解决「后发先至」的核心 —— 只认最新请求的响应 │ ├─ 平台支持时主动中断在途请求(uni.request 返回的任务对象可 abort) │ 能省流量,但不能替代序号丢弃 —— abort 不保证及时 │ └─ 输入被清空 → 取消在途请求 + 清空建议 + 回到历史与热搜视图 不清空的话会出现「输入框空了但建议还在」 提交搜索 │ ├─ 触发方式:点搜索按钮 · 键盘回车 · 点建议项 · 点历史 · 点热搜 │ 五个入口要走同一个提交函数(否则历史记录、埋点会漏) │ ├─ 写入搜索历史(本地) │ 去重(相同词提到最前)· 按时间倒序 · 限制条数 │ └─ 收起软键盘 → 请求结果 结果页(分类 tab) │ ├─ 三个分类:内容 · 用户 · 话题 │ ├─ 每个分类维护自己的状态 │ 页码 · 已加载列表 · 是否还有更多 · 滚动位置 · 加载中标记 │ 共享一份状态的后果:切走再切回,翻过的页全丢 │ ├─ 懒加载:切到某个 tab 才请求它的数据(不预加载全部) │ 预加载三个分类 = 用户只看一个也发三次请求 │ ├─ 结果里的关键词高亮(要处理特殊字符,不能直接拼字符串) │ └─ 空态要区分两种情况 「没有匹配结果」→ 给出建议(换个词 · 检查错别字) 「请求失败」→ 给出重试按钮 混成一种的后果:网络错误被显示成「没有结果」,用户以为真没有 搜索历史(本地,涉及隐私) ├─ 只存本地,不上传服务端 ├─ 支持单条删除与整体清空(入口要明显) ├─ 限制条数,超出淘汰最旧的 └─ 存储失败要静默降级(历史丢了不影响搜索主流程) 软键盘处理 ├─ 点击结果项:先收起键盘,等布局稳定后再跳转 ├─ 直接跳转的问题:键盘收起导致布局变化,手指落点已偏移 └─ 结果列表底部留出安全距离,避免最后一项被键盘遮住
    分步拆解
    1. 请求序号加过期响应丢弃是这个模块的核心。每次发请求自增序号,响应回来比对,不是最新序号就丢弃。这样即使先发的请求后返回,也不会覆盖新结果。
    2. 主动中断在途请求是优化不是解法。abort 能省流量,但它不保证及时(请求可能已经在返回路上),所以序号丢弃这层必须有。两者是叠加关系。
    3. 防抖把连续输入压成一次请求。输入四个字从四次请求变一次,而被省掉的那三次结果用户本来也看不到(输入太快)。防抖时间要按输入习惯调,太长会感觉迟钝。
    4. 最小触发长度过滤掉单字符请求。单字符的建议没有区分度,是纯浪费。
    5. 输入被清空时要取消在途请求并清空建议。否则会出现「输入框已经空了但建议列表还在」,用户会以为界面卡住了。
    6. 五个提交入口要走同一个函数。搜索按钮、回车、建议项、历史、热搜。各写一套的后果是历史记录和埋点在某些入口漏了——这类漏很难被发现,因为主入口是好的。
    7. 每个分类 tab 必须维护独立的分页与滚动状态。共享一份状态的后果是切走再切回,翻过的五页全丢,用户要重新翻。这是最被用户抱怨的点。
    8. 分类数据要懒加载,不要预加载全部。预加载三个分类等于用户只看一个也发三次请求。切到才请求,且已加载过的不重复请求。
    9. 关键词高亮要处理特殊字符。直接把用户输入拼进正则或 HTML 会出问题(正则元字符、标签注入)。要转义,并且优先用文本分段渲染而不是拼 HTML。
    10. 空态必须区分「没有结果」和「请求失败」。混成一种的后果是网络错误显示成「没有匹配结果」,用户以为真的没有,就不会重试了。失败态要给重试按钮。
    11. 「没有结果」时要给出可操作的建议(换个关键词、检查错别字、看看热搜)。空白页面对用户没有帮助。
    12. 搜索历史只存本地不上传。搜索词属于隐私敏感信息,而这个功能完全不需要服务端参与。能不上传就不上传是默认原则。
    13. 历史要支持单条删除与整体清空,入口要明显。用户搜过一些不想留下痕迹的词,删不掉会让他不敢用搜索。清空入口藏太深等于没有。
    14. 历史要去重、按时间倒序、限制条数。重复词提到最前而不是新增一条。不限制条数的话本地存储会一直增长。
    15. 历史存储失败要静默降级。本地存储可能满或被限制,历史丢了不影响搜索主流程,不要因此弹错误提示。
    16. 点击结果项要先收起软键盘、等布局稳定再跳转。直接跳转的问题是键盘收起导致布局变化,手指落点已经偏移到另一项上了——用户点开的是错误的内容。这个坑在安卓上尤其明显。
    17. 结果列表底部要留安全距离。否则键盘弹起时最后几项被遮住,用户不知道下面还有内容。
    关键决策与取舍

    用请求序号丢弃而不是「串行请求」。串行(等上一个返回再发下一个)也能保证顺序,但它会让建议明显变慢——用户输入很快,串行意味着要排队。序号丢弃的代价是有些请求发出去了但结果被丢弃(浪费一点流量),换来的是永远显示最新关键词的建议判据是「用户对建议延迟的敏感度」——建议是输入过程中的辅助,慢了就没意义,所以宁可浪费请求。

    防抖时间的取舍:短了省不下请求,长了感觉迟钝。我们最后取的值是在真机上试出来的——关键是要在真机上边输入边看感受,而不是拍一个数字。另外要注意防抖不能用在「点击提交」上,只用在输入触发的建议上;提交必须立即响应。

    各 tab 独立状态的代价是内存占用增加。三个分类各存几页数据。但相比「切回来重新翻五页」的体验损失,这点内存是值得的。缓解办法是只保留最近访问的两个 tab 的完整数据,更早的只保留首页——不过我们的量级下没有做到这一步,因为还没到需要的程度。这里我倾向于诚实说明「没做」而不是编一个优化。

    搜索历史不上传服务端,代价是换设备就没有历史了。产品提过「多端同步历史」。我的意见是不做:搜索词属于隐私敏感信息,上传会带来存储、合规、删除权的一系列责任,而收益(换设备保留历史)很小「能不收集就不收集」是我认为对的默认原则。

    踩过的坑一:建议列表显示上一个关键词的结果,本地完全测不出来。本地响应几十毫秒,几乎不会乱序;真机弱网下稳定复现。这件事让我第一次真正理解「并发请求的响应顺序不保证」——以前知道这个概念,但没意识到它会以「界面显示错误内容」的形式出现。后来我把「有没有处理响应竞态」加进了自己的自检清单,凡是输入即请求的地方都要检查。

    踩过的坑二:安卓上点搜索结果打开的是另一条内容。我一直以为是列表渲染的 bug,查了很久。真正的原因是软键盘收起导致布局上移,手指抬起时落点已经不在原来那一项。修法是先收键盘、等布局稳定再处理点击。教训是:客户端上「界面在用户操作过程中发生变化」会导致各种诡异问题,而这类问题往往不在你怀疑的那个模块里。

    踩过的坑三:网络错误被显示成「没有搜索结果」。用户以为真的没有,就不会重试。我们从客诉里发现的——用户说「你们搜不到东西」,实际是他当时网络有问题。教训是空态必须区分原因,「没有数据」和「拿不到数据」对用户的行动指引完全不同。

    没做的部分:没做搜索结果的本地缓存(同一个词二次搜索直接出结果)。内容型搜索结果时效性强,缓存容易展示过期内容,而且用户重复搜同一个词的比例不高。取舍依据是「缓存命中率低 + 过期风险高」,收益不划算。

    数字是怎么测的

    响应竞态的验证必须靠打桩制造乱序,不能靠手测:让第一个请求延迟返回、第二个正常返回,断言界面最终显示的是第二个(最新)请求的结果,第一个的响应被丢弃。这条是我踩的坑的直接回归,也是这个模块最重要的用例。

    防抖效果报请求次数:模拟输入一个四字词,断言只发出一次建议请求(原来是四次)。这个数字很具体也很好验证。

    建议延迟要报「从停止输入到建议出现」的时间,包含防抖等待。只报接口耗时会漏掉防抖那部分,而用户感知的是总时间。

    tab 状态保持:在内容 tab 加载到第五页,切到用户 tab 再切回,断言仍在第五页且滚动位置不变

    软键盘问题的验证要在真机上做,而且要报机型:断言点击列表项打开的是该项对应的内容。这条在模拟器上测不出来(模拟器的键盘行为和真机不同),要诚实说明测试环境。

    空态区分:断言无结果时显示建议文案、请求失败时显示重试按钮,两者文案不同

    历史治理:断言重复搜索同一词不会新增条目而是提到最前、超出条数上限时淘汰最旧的、单条删除与清空都生效。

    不要报什么:不要报「搜索转化率提升」——这受内容质量和排序算法影响,不是这个页面的功劳。该报的是「乱序响应被正确丢弃」「四字输入只发一次建议请求」「tab 切换后分页与滚动位置保持」「空态区分两种原因」这四件可核对的事。

    面试追问
    Q:搜索建议就是输入的时候调个接口,为什么会显示错的结果? A:因为并发请求的响应顺序不保证和发出顺序一致。用户输入「篮球」,我为「篮」发了一个请求、为「篮球」发了第二个。如果第一个慢、第二个快,那么第二个先返回被渲染,然后第一个返回又把它覆盖了——用户看到的是自己两个字符之前的建议。这个问题在本地测试时几乎不出现,因为响应只要几十毫秒、很难乱序;但在真机弱网下稳定复现。解法有两个层次:一是给每次请求分配一个自增序号,响应回来时比对,不是最新序号就直接丢弃;二是在支持的平台上主动中断在途请求。但第二个只是优化不能替代第一个——abort 不保证及时,请求可能已经在返回的路上了。我总结出的一句话是:不能「用最后返回的响应更新界面」,必须「用最新发出的那个请求的响应更新界面」。这两句听起来差不多但实现完全不同。为什么不用串行请求(等上一个回来再发下一个)?因为那会让建议明显变慢——用户输入很快,串行意味着排队,而建议是输入过程中的辅助,慢了就失去意义。所以宁可浪费一些被丢弃的请求。这个坑之后我把「有没有处理响应竞态」加进了自检清单,凡是「输入即请求」的地方都要检查。
    Q:安卓上点搜索结果打开错误内容,你是怎么定位的? A:这个我查了很久,因为我一直在怀疑错误的地方。现象是安卓上点搜索结果,打开的是列表里另一条内容。我最初以为是列表渲染的问题——怀疑虚拟列表的索引错位、怀疑数据绑定串了,把渲染逻辑翻了一遍没发现问题。后来我注意到一个细节:只有在软键盘还弹着的时候才会出现。顺着这条线才找到根因:点击时键盘先收起,页面可视高度变化导致列表整体上移,而手指抬起时的落点已经不在原来那一项上了——系统按新布局判定点到了哪一项。修法是先收起键盘,等布局稳定之后再处理跳转这件事给我的教训有两个。一是「界面在用户操作过程中发生变化」会导致各种诡异问题,而这类问题往往不在你怀疑的那个模块里——我在渲染逻辑上花的时间全浪费了。二是定位问题要先找复现条件的边界:如果我一开始就注意到「只有键盘弹着时才出现」,就能少走很多弯路。顺带说,这类问题在模拟器上测不出来,因为模拟器的键盘行为和真机不同,所以涉及键盘、手势、返回这些的功能必须真机验证,我在报测试结论时也会说明是在哪些机型上测的。

模块三:个人主页内容网格

  1. 个人主页内容网格(长列表分片渲染与节点回收 + 固定宽高比容器防布局抖动 + 返回恢复滚动位置 + 图片懒加载与失败降级)★★★
    简历这样写 个人主页内容网格(uni-app + Vue 3 组合式 API + 分片渲染 + 图片懒加载):网格加载到数百条后滚动明显卡顿,改为分片渲染加视口外节点回收,同时把每次追加的数据量控制在合理范围以避免单次更新负载过大;封面图尺寸不一致导致加载完成瞬间布局跳动,改为固定宽高比容器加统一裁剪,图片加载前后占位尺寸一致;从详情页返回时列表重新渲染丢失位置,改为缓存列表数据与滚动位置并在返回时恢复;图片走懒加载并对失败做占位降级(不显示破图,且失败不影响网格结构);作品、喜欢、收藏三个 tab 各自维护独立的列表与滚动状态。改造后长列表滚动帧率明显改善,返回不再回到顶部。
    展开完整拆解
    为什么要这么设计

    个人主页就是一个三列的内容网格,看起来是最简单的页面。第一版用了一个 v-for 直接渲染全部数据,问题在真实数据量下集中爆发,四个问题。

    一是加载到几百条后滚动卡顿。头部作者的主页有上千条内容,用户一直往下滑,DOM 节点持续累积,滚动帧率明显下降,低端安卓机上会卡到几乎滑不动。而这个页面是社区里访问量很高的页面。

    二是图片加载时布局跳动。封面图的尺寸不统一,我没给容器设固定高度,图片加载完成的瞬间容器高度按图片实际比例变化,整个网格重排、用户正在看的位置被顶走。而图片是陆续加载的,所以跳动会持续一段时间。

    三是从详情页返回后回到列表顶部。用户滑了很久点进一条内容,返回时列表重新请求、重新渲染、滚动位置归零,他要重新滑几十屏才能找回原来的位置。这是被抱怨最多的一点。

    四是切换 tab 丢失状态。作品、喜欢、收藏三个 tab 共享一份列表状态,切走再切回就要重新加载。

    所以四个改动:分片渲染加视口外节点回收固定宽高比容器加统一裁剪缓存列表与滚动位置并在返回时恢复三个 tab 各自维护独立状态

    这个模块最想说的一句话是:客户端页面的性能问题基本都来自「节点数量」和「布局变化次数」这两件事,而它们在数据量小的时候完全看不出来。我本地测试用的是自己的账号,只有十几条内容,三个问题一个都没暴露。所以后来我养成的习惯是用真实的极端数据(最多内容的账号、最差的机型、最慢的网络)来验收,而不是用自己的账号。

    整体链路
    数据加载 │ ├─ 分页请求,滚动接近底部时加载下一页 │ 触发距离要留足(贴底才加载,用户会看到明显的等待) │ ├─ 每次追加的数据量要控制 │ 一次追加太多 → 单次更新负载大,小程序端尤其明显 │ 一次追加太少 → 请求次数多,滚动时频繁触发 │ └─ 三个 tab 各持有独立的数据与分页游标 共享一份的后果:切走再切回全部重新加载 渲染:分片 + 视口外回收(解决节点累积) │ ├─ 分片渲染:新到的数据分批插入而不是一次性全插 │ 一次插入几十条会造成明显的掉帧 │ ├─ 视口外的项只保留占位(保持高度)不渲染内容 │ 节点数被控制在与屏幕相关的常数级,与总条数无关 │ 这是解决「越滑越卡」的根本办法 │ ├─ 占位必须保持准确高度,否则滚动条与位置会乱跳 │ 网格是固定宽高比的,高度可以精确算出来 —— 这是前提 │ └─ 回收阈值要留缓冲(视口外一定范围内仍保留) 阈值太紧 → 快速滚动时来不及渲染,出现空白 图片:固定宽高比 + 懒加载 + 失败降级 │ ├─ 容器固定宽高比(三列网格算出确定的宽高) │ 图片按容器裁剪填充,加载前后占位尺寸完全一致 │ 不固定的后果:图片加载完瞬间高度变化,整个网格重排 │ ├─ 加载前显示占位背景(不是空白,也不是转圈) │ 转圈动画在网格里同时出现几十个,视觉噪音很大 │ ├─ 懒加载:仅视口附近的图片才真正加载 │ └─ 加载失败 → 显示统一占位图,不显示破图 失败不能影响网格结构(高度已由容器固定,天然不影响) 返回恢复位置(被抱怨最多的问题) │ ├─ 离开页面时记录:列表数据 · 分页游标 · 滚动位置 · 当前 tab │ ├─ 返回时先用缓存数据渲染,再恢复滚动位置 │ 顺序很重要:必须等列表渲染出高度后才能设置滚动位置 │ 直接设置的话列表还没高度,滚动会被钳制到 0 │ ├─ 恢复位置后再按需静默刷新(不打断用户) │ 刷新结果如果导致列表变化,不要直接替换 —— 会再次跳位置 │ └─ 缓存要有失效条件(时间过久 / 用户主动下拉刷新则丢弃) 骨架屏与空态 ├─ 首屏加载显示与网格结构一致的骨架(不是全屏转圈) ├─ 空态区分「该用户没有内容」和「加载失败」 └─ 失败态给重试,不要显示成「没有内容」
    分步拆解
    1. 视口外节点回收是解决「越滑越卡」的根本办法。不回收的话节点数随滑动线性增长,卡顿是必然的、只是迟早问题。回收之后节点数被控制在与屏幕相关的常数级,与总条数无关。
    2. 回收的前提是能准确算出占位高度。网格是固定宽高比的,高度可以精确算出来——这正是「固定宽高比容器」这个改动的额外收益。如果高度不确定(比如瀑布流),回收要复杂得多。
    3. 回收阈值要留缓冲。只保留视口内的话,快速滚动时来不及渲染,用户会看到空白。视口外一定范围内仍要保留。
    4. 新数据要分片插入而不是一次性全插。一次插入几十条会造成明显掉帧,在小程序端尤其明显(更新有序列化开销)。
    5. 每次追加的数据量要控制在合理范围。太多则单次更新负载大,太少则滚动时频繁触发请求。这个值要在真机上试。
    6. 容器固定宽高比是防布局抖动的关键。三列网格的宽度是确定的,按固定比例算出高度,图片按容器裁剪填充。这样图片加载前后占位尺寸完全一致,不会重排。
    7. 不固定宽高比的后果是持续的布局跳动。图片是陆续加载的,每张加载完都会让网格重排一次,用户正在看的位置反复被顶走——这个体验问题比卡顿更让人烦躁。
    8. 加载前的占位用背景色而不是转圈动画。网格里同时出现几十个转圈的视觉噪音很大,而且转圈会让用户觉得「很慢」——即使实际速度一样。
    9. 图片要懒加载,只加载视口附近的。否则打开主页会同时发起几十个图片请求,抢占带宽反而让首屏更慢。
    10. 图片加载失败显示统一占位图,不显示破图。因为高度已由容器固定,失败天然不影响网格结构——这是固定宽高比带来的又一个好处。
    11. 离开页面时要记录完整状态:列表数据、分页游标、滚动位置、当前 tab。只记滚动位置不记数据是没用的——数据重新加载后条目可能变了,位置对应的内容也就不同了。
    12. 恢复的顺序很重要:先用缓存数据渲染,等列表有了高度再设置滚动位置。直接设置的话列表还没高度,滚动值会被钳制回 0。这是我踩过的具体坑。
    13. 恢复位置后的静默刷新不要直接替换列表。替换会导致位置再次跳动。正确做法是只在用户主动下拉时才替换。
    14. 缓存要有失效条件。时间过久或用户主动下拉刷新就丢弃。否则用户可能看到很久之前的列表状态。
    15. 三个 tab 各自维护独立的列表与滚动状态。和搜索页的分类 tab 是同一个道理。
    16. 首屏用与网格结构一致的骨架屏,不用全屏转圈。骨架屏让用户提前知道「这里会出现一个三列网格」,感知等待时间更短。
    17. 空态要区分「该用户没有内容」和「加载失败」。失败显示成「没有内容」会让用户以为这个账号是空的。
    关键决策与取舍

    选固定宽高比裁剪而不是保留原始比例的瀑布流。瀑布流视觉上更好、图片不被裁掉,但代价有两个:一是高度不确定,视口外回收要先测量每一项的高度,实现复杂度高出一个量级;二是图片加载前无法确定占位高度,必然有布局抖动(除非服务端返回图片尺寸,那又要改接口和存量数据)。我们选固定宽高比,因为它同时解决了抖动和回收两个问题,代价只是图片被裁掉一部分(而封面图的主体一般在中间)。判据是「这个视觉收益值不值得那两个技术代价」——在个人主页这个场景,用户主要是浏览和定位自己的内容,裁剪的影响很小。

    视口外回收 vs 只做分片渲染不回收。只分片能解决「一次插入造成掉帧」,但解决不了「节点累积导致越滑越卡」。两个是不同的问题,都要做。回收的代价是滚动时有渲染开销、快速滚动可能出现瞬时空白(靠缓冲区缓解)。判据是「用户会不会滑到那个量级」——头部作者上千条内容,用户确实会一直滑,所以必须回收。

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

    踩过的坑一:本地测试用自己的账号,只有十几条内容,三个性能问题一个都没暴露。是测试同学用一个头部作者的账号才发现卡顿。教训是:性能问题必须用真实的极端数据验收——最多内容的账号、最差的机型、最慢的网络。我后来把这三个「最」固定成了自己的验收清单。

    踩过的坑二:恢复滚动位置时直接设置滚动值,结果一直是 0。查了半天才发现是列表还没渲染出高度,滚动值被钳制了。修法是等列表渲染完成后再设置。这个坑的通用教训是:在客户端设置滚动位置、测量元素尺寸这类操作,都必须等布局完成之后做,而「布局完成」的时机不等于「数据赋值完成」。

    踩过的坑三:布局跳动最初被我当成了「图片加载慢」。我一直在优化图片加载速度,但真正的问题是加载完成瞬间的容器高度变化——加载再快也会跳,只是跳得更快而已。教训是:现象和根因不一定在同一件事上,「图片加载完才跳」不代表问题在图片加载。

    没做的部分:没做图片的多尺寸适配(按设备像素密度请求不同尺寸)。它能省流量、提升加载速度,但需要服务端支持多规格裁剪,超出了我这次改动的范围。我把它作为后续建议提了出来,并给出了实测的流量数据作为依据。

    数字是怎么测的

    滚动帧率必须报清三个条件:多少条数据、什么机型、什么网络。只报「帧率提升」没有意义。我的报法是「在某型低端安卓机上,加载到 500 条时滚动帧率从 X 提升到 Y」,并说明是用性能面板录制滚动过程取的平均值。

    节点数是比帧率更本质的指标:报「加载到 500 条时页面节点数」,改造前随条数线性增长、改造后基本恒定。这个对比直接解释了为什么帧率改善了,比单报帧率有说服力。

    布局抖动用「布局偏移」衡量:录制首屏加载过程,统计图片加载引起的位置偏移量。固定宽高比之后应该接近零。要说明测量方式。

    返回恢复位置是断言型用例:滑到某个位置、进详情、返回,断言滚动位置与离开时一致(允许小误差)、列表数据未重新加载。这条要在真机上测。

    图片懒加载效果报「打开主页时发起的图片请求数」:改造前是全部条目的图片、改造后只有视口附近的。这个数字很直观。

    失败降级:把图片地址改成无效的,断言显示统一占位、网格结构不变、不出现破图。

    不要报什么:不要报「页面性能提升 N%」——性能不是单一数字。该报的是「某机型某条数下的帧率对比」「节点数从线性增长变为恒定」「布局偏移接近零」「返回位置恢复准确」这四件带条件的、可核对的事。带条件恰恰说明你知道自己在测什么。

    面试追问
    Q:一个内容网格页面,能有什么性能问题? A:数据量小的时候确实没有问题——我本地用自己的账号测,只有十几条内容,什么问题都没暴露。是测试同学用一个头部作者的账号(上千条内容)才发现的,问题有两个层面。第一是节点累积:我用 v-for 直接渲染全部数据,用户一直往下滑,DOM 节点持续增长,低端安卓机上滑到几百条就卡到几乎滑不动。这个只能靠视口外节点回收解决——不回收的话卡顿是必然的,只是迟早问题。第二是布局抖动,这个更隐蔽:封面图尺寸不统一,我没给容器设固定高度,每张图片加载完成的瞬间容器高度按实际比例变化,整个网格重排一次。而图片是陆续加载的,所以跳动会持续一段时间,用户正在看的位置反复被顶走。我最初把这个当成了「图片加载慢」,花时间去优化加载速度——但加载再快也会跳,只是跳得更快。修法是固定宽高比容器加统一裁剪。这个改动还有个额外收益:高度确定之后,视口外回收才好做(占位高度能精确算出来)。我从这件事学到两点:一是性能问题必须用真实极端数据验收(最多内容的账号、最差机型、最慢网络),二是现象和根因不一定在同一件事上。
    Q:为什么不做瀑布流?那样图片不用被裁掉,视觉效果更好。 A:瀑布流的视觉确实更好,但在这个页面它的技术代价我认为不划算。两个代价。一是高度不确定导致视口外回收难做:回收的前提是能算出未渲染项的占位高度,固定宽高比可以精确算,瀑布流要先测量每一项的实际高度才知道,实现复杂度高出一个量级(而且测量本身有开销)。二是图片加载前无法确定占位高度,布局抖动无法根治——除非服务端在列表接口里返回每张图的原始尺寸,但那要改接口协议、还要给存量图片补尺寸数据,不是我这次改动的范围。而固定宽高比一个改动同时解决了抖动和回收两个问题,代价只是图片被裁掉一部分。我的判据是「这个视觉收益值不值得那两个技术代价」:个人主页的用户行为主要是浏览和定位自己的内容(他知道自己发了什么,看封面主要是为了认出来),裁剪的影响很小,而卡顿和跳动的影响很大如果是以图片欣赏为主的场景(比如摄影社区),我的结论可能就反过来了——那时候值得为瀑布流付出让服务端返回图片尺寸的成本。这类问题我觉得关键是说清「什么条件下这个选择成立」,而不是说哪种布局更好。

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

项目拆解 · 社区客户端常用页(uni-app · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据