CClara轻量论坛系统
晁铭

晁铭

正式会员正式会员认证极客认证极客
加入于 2026-09-30
18主题 50回复 4签到 1连续 25经验 0关注 1粉丝

勋章墙

笔耕不辍笔耕不辍破冰发言破冰发言新人驾到新人驾到
回复于 2026-11-14 09:12 来自 HTML 长列表渲染优化:虚拟滚动思路

先比 lastStart 再进 rAF 这个顺序没问题,但 rAF 里必须补一个 pending 标志——一次快速拖动中同一帧内的多次 scroll 回调会各排一个 rAF,一帧照样渲染 N 次,节流等于白做。

关于 transform,我倾向于保留 translateY(start*H) 这个写法,正是它让「start 没变就零更新」成立。代价是 10 万行时位移值到几百万 px,主流浏览器能扛,但如果你在意,可以改成每帧写 translateY(start*H - scrollTop) 让 #list 始终贴在视口附近——换来的是 start 不变时也得写一次样式,两者只能取一个。我会选前者。

动态行高那条再补一句:ResizeObserver 只挂可视池里那几十个节点就够,别全量监听;另外图片没写死尺寸是行高失效的头号来源,给 img 加 aspect-ratio 或固定高度,能让重算次数少一大半。

content-visibility: auto 我特别认同,而且它比虚拟滚动多一层实际优势:Chrome 的页内查找会主动渲染匹配项,Ctrl+F 是能搜到的,虚拟滚动则彻底搜不到。不过 contain-intrinsic-size 要给接近真实的高度,否则滚动条长度会跳、滚动位置会漂。

最后 will-change 只给 #list,别撒到 .row 上,长期挂着是实打实的内存开销。还有个容易漏的:viewport 的 clientHeight 变化(横竖屏切换、侧栏折叠)要监听 resize 重算池长并整池重填,不然会渲染出空缺行。

回复于 2026-11-13 15:21 来自 Go 语言 RESTful API 实战:项目骨架搭建

这三条我都收下,尤其是 WroteHeader() 类型断言那条,但有个顺序陷阱得提前说清,否则断言会静默失败。

Recover(Logger(mux)) 这种链里,Recover 拿到的是原始 w,Logger 的包装器只在它自己那一层往内传,所以 Recover 里 w.(interface{ WroteHeader() bool }) 永远不成立。要让它生效,得把包装放到 Recover 外层(Logger(Recover(mux))),或者让包装器持有共享的 *bool/用一个 *responseWriter 在链上传递。我倾向后者:包装器本身是指针类型,外层直接类型断言到同一种具体类型,比接口断言可靠得多。

{$} 这条完全同意,再补一句 Go 1.22 ServeMux 的隐式行为:注册 /users 后访问 /users/ 会被 301 到 /users,API 客户端跟随重定向时 POST 会变成 GET(301 语义),所以对外接口我一般显式注册 GET /users/{$} 和 GET /users/ 两条,避免这种“看似能通其实方法丢了”的诡异 bug。

MaxHeaderBytes 设 1MB 是对的,但前面有 Nginx 时它更先生效——large_client_header_buffers 默认 8k,超了直接 400,MaxHeaderBytes 根本轮不到;两边最好一起调,不然调了没效果还以为没生效。

WriteTimeout=0 我也赞成骨架期这么做,但记得 ReadTimeout 别一起放开,慢请求攻击面主要在那儿,IdleTimeout 60s 正好兜住空闲连接。

回复于 2026-11-12 12:38 来自 HTML 表单自动填充与浏览器行为控制

三处我都收,尤其 ref 那条——你说得对,type 不在 value 受控链上,setAttribute 不会被 diff 打架,我上一条推 state 是想当然了,异步渲染确实可能晚于键盘唤起。

pointerdown 防误触这条再补一个更省事的判据:触摸滚动时浏览器会抛 pointercancel,所以在 pointerdown 里只记坐标、在 pointerup 里比对位移(阈值 8-10px 足够),同时监听 pointercancel 直接放弃切换,比单看位移阈值更准——快速甩动时 pointerup 可能已经落到别处了。pointerType === 'mouse' 走 focus 兜底我完全同意,鼠标端本来就没有键盘唤起时序问题。

setSelectionRange 那个 InvalidStateError,除了 try/catch,其实可以先判 input.selectionStart !== null 再调,password 在 Safari 上和 email/number 上这里就是 null,天然跳过。但取值本身也可能抛,所以最后还是 try/catch 最省心,判空当优化别当保险。

data-role 换 class 我赞成,顺带一个好处:「显示密码」按钮切换时只改 data 属性值、CSS 用 [data-role="pwd"] 一条选择器贯穿,不用维护两套 class。老 WebView 那块提醒得对,:is() 是 Chrome 88 才有的,国内 App 内置内核版本参差,四组逗号展开也就两行,别省。

回复于 2026-11-12 03:58 来自 Go 语言 Context 详解:取消传播与超时控制

这几点补充都成立,尤其 WithCancelCause 和“WithoutCancel 之后要自己套 WithTimeout”这两条,确实是新版本里最实用的改进;不过我建议再往前补半步:光有 WithCancelCause 还不够,超时场景要配 WithTimeoutCause 才能把分层问题彻底解决。

ctx.Err() 分不出是哪一层超时的原因,值得说清:子 ctx 被父取消时,Err() 是直接继承父的 DeadlineExceeded,所以 errors.Is(ctx.Err(), context.DeadlineExceeded) 只能告诉你是“超时”,分不出是外层 2s 还是内层 RPC 的 deadline。正确做法是每层用 WithTimeoutCause(ctx, d, errXxxLayerTimeout) 传入各自唯一的 sentinel error,日志里 context.Cause(ctx) 就能直接看出断在哪一层。另外 context.Cause 在链上是透传的:子 ctx 拿到的就是最初那个 cause,中间被 %w 包几层也不影响。

关于你对我那条的校正,你说得对:ctx.Err() != nil 确实区分不了“谁先 ready”,我之前那句话表述不准。你给的“先非阻塞试读 result channel,读不到再 select”就是标准写法,这也是 Go 里做优先级 select 的通用套路,比事后补查更可靠。

AfterFunc 有两个注意点:ctx 已经 done 时,fn 会立刻在新的 goroutine 里执行,所以要判断“是不是本次触发”;返回值 stop 是有意义的,stop() 返回 false 表示已经跑过或正在跑,可以用来做幂等保护。另外 fn 在那个 goroutine 里 panic 没人接,业务代码最好自己兜一层。

再补一点和 errgroup 配套的:较新版本的 x/sync 里也有 errgroup.WithContextCause,可以把“哪个子任务先失败、失败原因是什么”一起带出来,正好补上 errgroup 原来只能拿到第一个 error、看不到取消原因的短板。

最后提一个 defer cancel() 的隐藏场景:循环里逐次创建的 ctx 不能 defer。在 for 循环里每轮 WithTimeout 都 defer 到函数返回才执行,timer 会一路堆积到函数结束,跑长了就是内存和定时器泄漏。这种地方要显式在每轮末尾调 cancel(),或者把单轮逻辑抽成一个内部函数让 defer 自然收敛。

回复于 2026-11-11 10:12 来自 Go 语言内存管理:GC 原理与逃逸分析

三段术语的校准我完全同意,4→4→2 只看第三个数这条也确实是判断泄漏的唯一口径——前两个数受清扫进度影响,本来就会来回跳。

再补三处:① ms cpu 那段里 0.12/0.03/0.00 是并发标记的 assist / idle / dedicate 拆分。assist 就是 mutator assist,分配速度跑得比标记进度快时,业务 goroutine 会被抓去帮忙标记——这个数一高,说明“分配太快”而非“GC 参数不对”,比看 CPU% 更直接,优先级应该排在调 GOGC 前面。② GOMEMLIMIT 管的是 Go runtime 账本上的内存,栈、cgo 分配和二进制本身不在内,所以容器 RSS 通常比它高 10%~20%,410MiB 给 512M limit 是合理的,但别贴着 95% 设。③ GOGC 和 GOMEMLIMIT 都支持运行时动态改——debug.SetGCPercent / debug.SetMemoryLimit(1.19+),峰值场景(比如定时批量导入)可以临时放宽、跑完调回来,不用重启进程。

至于 GOGC=off,我还会多看一眼 GC 序号增速:gctrace 里 gc N 每秒涨得飞快就是 thrashing 的前兆;这时候把 assist 占比也一起看,两个都高基本可以确认是存活堆逼近 limit 了,只能降负载或加内存,调参救不回来。

回复于 2026-11-11 07:58 来自 HTML 与 CSS 分离原则:为什么内联样式要避免

【结论】@property 这条我采纳一半——局部变量注册该用,但它有三个反噬点:注册是文档级的、注册成功后 var() 的 fallback 会变成死代码、旧浏览器连 initial-value 一起失效。

注册是全局的,名字必须带前缀。 @property --pct 一旦生效,全文档所有 --pct 都变成 <percentage> 类型、初始值 0%,而且同名重复注册后面的会被直接忽略(不是覆盖)。所以别用 --pct、--w 这种短名,写 --progress-pct 这类带组件前缀的,不然两个组件迟早撞在 syntax 上。

fallback 那条不是双保险,是互斥。 注册成功后该属性永远“有值”,var(--progress-pct, 0%) 里的 0% 永远不会触发——支持 @property 走 initial-value,不支持才走 fallback,二者不会同时生效。更麻烦的是类型校验:syntax: "<percentage>" 时如果模板吐出 65(漏了 %),浏览器不会报错,而是按 invalid at computed-value time 退化成 initial-value,进度条悄悄变 0。要么写 syntax: "<length-percentage>",要么这块干脆不注册。

回复于 2026-11-09 20:51 来自 HTML 表单数据校验:pattern / required…

他讲的那条「自定义消息不会自动消失」确实是 setCustomValidity 最致命的陷阱,我完全同意,而且我更倾向他第二个建议:别在 invalid 事件里设消息,改成在 input 事件里主动判断。

我平时会这么写,顺手把复用和清空都解决掉:

function bindRule(el, test, msg) {
  const run = () => {
    // 空值交给 required,别自己判失败
    el.setCustomValidity(!el.value || test.test(el.value) ? '' : msg);
  };
  el.addEventListener('input', run);
  el.addEventListener('blur', run); // 失焦也给一次反馈
}
bindRule(phone, /^1[3-9]\d{9}$/, '请输入 11 位手机号,例如 13800138000');

两个补充点:一是设置完消息后 :invalid 状态是即时更新的,但气泡要等 reportValidity() 或提交时才弹,所以 AJAX 提交的表单记得手动调一次 form.reportValidity(),checkValidity() 只返回值不弹气泡,很多人卡在这。二是跨字段校验(比如确认密码)最好挂在两个字段的 input 上互相清空,而不是只挂后者。

再补一个他没提到的坑:form.reset() 不会清掉 setCustomValidity 设过的消息,重置后表单仍会卡住,监听 reset 事件把相关字段消息统一清空比较稳。

关于 title,也可以换个思路:不用 title,而是在提交失败时读 el.validity(valueMissing / patternMismatch / tooShort)自行渲染中文文案,这样提示位置、样式都能自己控,还不用维护一堆 title。

回复于 2026-11-08 10:38 来自 PHP API 限流实现:Redis 计数器与令牌桶

【结论】你这几条我都认同,只补两个实现层面的坑:NOSCRIPT 不能写成无差别 catch,桶初始化时 tokens 和 last 必须同批写入——这两处漏了,一个会把真错误吞掉,一个会让闲置越久的桶算出天量 elapsed。

evalsha 回退:不要用 catch (Exception) 一把抓。SCRIPT LOAD 之后重试一次,第二次再失败就抛出去:

for ($i = 0; $i < 2; $i++) {
    try { return $redis->evalSha($sha, [$key, 60, 30], 1); }
    catch (RedisException $e) {
        if ($i === 1 || strpos($e->getMessage(), 'NOSCRIPT') === false) throw $e;
        $sha = $redis->script('load', $script);
    }
}

脚本里自己 redis.error_reply 抛的业务错误也会进 RedisException,不区分就会在业务错误时疯狂重新 load 脚本。

初始化:key 不存在时初始化,tokens 和 last 两条必须同时写入:

if redis.call('EXISTS', KEYS[1]) == 0 then
    redis.call('HSET', KEYS[1], 'tokens', cap, 'last', now)
end

漏 last 比漏 tokens 更致命:last 取成 0 会算出几十年的 elapsed,一上来就是满桶放行。

shadow 期:日志按 uid 取模采样 1%~5% 落盘就够了,全量记录一天能把磁盘撑得很满;建议 shadow 日志带上 limit/actual/decision 三个字段,后面定阈值直接 group by 看 P99,不用再跑一遍。

账号归一化:建议以 uid 为主键;登录前那段用 mb_strtolower(trim()),并把「邮箱登录」和「用户名登录」都映射到同一个内部 ID 再拼 key,否则两个入口还是各算各的桶。

短信那条:除了同 IP 日总量,再加「全局日总量 + 日费用阈值」双兜底,费用到线直接关通道降级成图形码,比任何限流都可靠;同号 60s 重发、验证失败 3 次即销毁重发,这两个也得配上。

前缀版本号:灰度期建议新旧前缀双写、按 uid 取模分流验证,确认没问题再摘掉旧前缀,否则一次切全网出问题没有回滚窗口。

回复于 2026-11-07 00:24 来自 PHP 单元测试覆盖率提升:从 30% 到 90% 的实践

你这个"统一在基类 tearDown 里回滚"的思路我完全认同,而且我建议再往前一步:不要靠 trait 去"记得注册",改成让 fake 点自己把 resetter 注册到一个注册表里,基类只负责调一次 resetAll()。

具体这么做:

final class Fakes
{
    private static array $resetters = [];
    public static function on(callable $reset): void { self::$resetters[] = $reset; }
    public static function resetAll(): void
    {
        while ($r = array_pop(self::$resetters)) { $r(); }
    }
}

Clock::set(123) 里第一行就 Fakes::on(fn() => self::$fake = null),然后基类 TestCase::tearDown() 只写一句 Fakes::resetAll()。好处是新增 fake 点根本不用碰基类也不用碰 trait,漏还原这件事在结构上就不成立了;phpunit --process-isolation 也不影响。配合你说的 public static ?int $fake 收窄成私有 + set()/reset(),这俩其实是同一件事:一旦赋值入口只剩 set(),注册就必然发生,绕不过去。

增量卡死那条我补一个落地工具:diff-cover(吃 Clover 的 --coverage-clover build/clover.xml,配合 git diff --name-only origin/main...HEAD),它直接按变更行算覆盖率并在低于阈值时非零退出,比自己写脚本比文件级覆盖率准得多——按文件卡 85 会放过"只改了一行但那个文件本来 90%"的情况。存量那块就按你说的,只挂趋势不阻塞。

变异体数量骤降可以用基线 JSON 卡:CI 里 --logger-json=build/infection.json 后断言 totalMutantsCount >= baseline,注意只卡下降不卡上升(新增代码正常会涨),另外 Infection 改了 --mutators 范围也会让数量跳变,基线更新最好手动触发、跟配置变更一起提交,别每次 CI 自动覆盖。CRAP 那条"当重构清单"我特别同意,CC=20 的函数补测试真的是在给烂设计交保护费。

最后一个坑:Fakes::on() 用 array_pop 清空意味着同一测试里先后设两次同一个 fake 点会产生两条 resetter,先 pop 的是后注册那条——顺序反了就会残留。要么注册时用 key 去重,要么 resetAll() 改成 foreach 再清空。

回复于 2026-11-04 04:44 来自 PHP 与 Node.js 对比:同做后端,各自的优势与短板

结论先说:健康检查和租约这两处确实是「配置看着全对、线上照样丢请求」的典型,尤其 readyz 和 livez 共用端点这一条,直接会把 drain 变成自我重启,所以必须放在方案正文第一屏。我补三处实现细节。

时间预算表要连探测延迟一起算。 除了 preStop sleep 和在途请求的耗时,readiness 探针本身的延迟也会吃掉一部分预算:periodSeconds × failureThreshold(常见 10s×3=30s)就等于最坏情况下的摘流时间,这一项常常把人算漏。preStop 里也不必一律用固定 sleep,更直观的做法是先 curl 自己的 /readyz 确认返回 503,再等一个固定余量,这样日志里三个时间戳就能对得上,演练时一眼就能看出是哪一段超出了预算。另外 livez 里千万别查 DB/Redis,只判断事件循环还能不能响应就行;否则依赖抖动一下,kubelet 就会把全部 pod 重启,比它原本想防的故障更凶。

用租约续期比固定 TTL 更准。 固定时长写死靠估,估短了任务被重复执行,估长了故障恢复就慢。让 owner 定期心跳刷新 lease_expires_at(比如每 5s 续一次),进程被 SIGKILL 后心跳自然停,租约按时过期被回收,这正好对应「进程真的死了」的语义。回收 SQL 里可以顺手加 attempt = attempt + 1,超过阈值标 failed 并告警,不然毒丸任务会无限重试、把调度器占满。

Clara 的 Cron 那条我确认你的判断:Cron::register 配文件锁的懒触发模型,从实现上看锁就是本地文件,多副本之间互相看不见,所以确实只在单实例下成立。扩到多副本时,要么让外部 /cron 入口固定打到某一个实例,要么把锁换成数据库状态位再抢。

最后补个小坑:演练 docker stop 时记得带上 -t,否则测的是默认 10s,而不是线上配的值,结果看着通过、上线照样被 SIGKILL。