平台原生优先:最省代码的那一层
梯子的第 4 级单独有一份文档,因为「平台已经做了」是省钱效果最猛的一级。这一章就是那份对照表的中文精选。
1为什么这一级省得最多
前三级是「少写」,第 4 级是「整个不用写」。一个手搓的日期选择器组件要包含触发按钮、弹出层、月份网格、键盘导航、无障碍属性、样式表、时区讨论……而浏览器从很多年前就有 <input type="date">。
这就是实测里 -94% 那种数字的来源:不是助手写得更精炼,而是它换掉了整个方案。第 7 章会完整拆一个例子。
提问方式的变化:从「这个组件怎么写」变成「平台是不是本来就有这个」。这一问,往往就跳过了后面几百行。
2浏览器已经有的表单控件
下面每一条左边是「你以为要装的库」,右边是「平台其实自带」。注意这些原生控件还顺带带来了无障碍、移动端原生键盘和系统主题适配——库反而经常做不到。
| 你以为需要 | 平台其实有 |
|---|---|
| 日期选择器库 | <input type="date"> |
| 时间选择器库 | <input type="time"> |
| 颜色选择器库 | <input type="color"> |
| 滑块库 | <input type="range"> |
| 进度条组件 | <progress value="70" max="100"> |
| 仪表盘 / 刻度组件 | <meter value="0.7"> |
| 弹窗 / 模态库 | <dialog> + dialog.showModal() |
| 折叠面板 / FAQ 组件 | <details><summary> |
| 搜索下拉框 | <input list="id"> + <datalist> |
| 自动增高的 textarea | field-sizing: content(CSS) |
| 吸顶头部 | position: sticky; top: 0 |
<dialog> 和 <details> 尤其值得记住:前者自带焦点陷阱和 Esc 关闭,后者自带键盘与读屏支持。手搓这两个功能的库,通常在这两件事上做得不如浏览器。3以前要 JS、现在 CSS 就够
| 以前用 JS 做 | 现在 CSS 能 |
|---|---|
| 响应式字号 / 间距 | clamp(1rem, 2.5vw, 2rem) |
| 深色模式 | @media (prefers-color-scheme: dark) |
| 减少动效偏好 | @media (prefers-reduced-motion: reduce) |
| 不用断点的自适应网格 | repeat(auto-fill, minmax(250px, 1fr)) |
| 组件级响应式 | @container 查询 |
| 设计变量 / 换肤 | CSS 自定义属性 --color-primary |
| 平滑滚动 | scroll-behavior: smooth |
| 滚动吸附轮播 | scroll-snap-type: x mandatory |
| 固定宽高比 | aspect-ratio: 16 / 9 |
| 多行文本截断 | -webkit-line-clamp: 3 |
| 父选择器 | :has(input:checked) |
顺带一提:你现在看的这个教程站就是按这套规则做的——折线箭头、渐变卡片、深浅主题全部是 CSS 变量,没有引入任何 UI 框架。
4运行时已经带了,别再 npm install
这一类特别值得看:npm 上周下载量几千万的包,做的事可能只有一行。左列这些包名很多人是条件反射装的。
| 你以为需要装 | 平台其实有 |
|---|---|
query-string / qs | new URLSearchParams(location.search) |
lodash.clonedeep | structuredClone(obj) |
lodash.groupby | Object.groupBy(arr, fn) |
uuid(v4) | crypto.randomUUID() |
numeral / accounting | Intl.NumberFormat 带 style:"currency" |
date-fns 的格式化 | Intl.DateTimeFormat |
date-fns 的相对时间 | Intl.RelativeTimeFormat |
clipboard.js | navigator.clipboard.writeText() |
| 无限滚动库 | new IntersectionObserver(cb) |
is-online 之类联网检测 | navigator.onLine + online/offline 事件 |
| 分享面板库 | navigator.share({ title, text, url }) |
| fetch 超时封装 | AbortSignal.timeout(5000) |
原文对 debounce 的处理很有意思:它没有说「用原生 API」,因为原生确实没有 debounce。它的答案是承认这件事,然后给三行代码:
// ponytail: 3 lines beats a dependency
let t;
const debounce = (fn, ms) => (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
这段注释本身就是项目风格:知道自己在哪里抄了近路,就写清楚代价。「3 行胜过 1 个依赖」是判断,「
ponytail:」是标记。5服务端与数据库:同样的思路
原则不只在浏览器里成立。下面三张表是原文里 Node / Python / SQL 三节的精选。
| 层 | 你以为要写 / 要装 | 平台其实有 |
|---|---|---|
| Node | mkdirp / make-dir | fs.mkdirSync(p, { recursive: true }) |
| Node | rimraf | fs.rmSync(p, { recursive: true, force: true }) |
| Node | path-exists / load-json-file | fs.existsSync / JSON.parse(fs.readFileSync(p,"utf8")) |
| Python | pytz | zoneinfo.ZoneInfo("America/New_York")(3.9+) |
| Python | python-dateutil(简单解析) | datetime.fromisoformat()(3.7+) |
| Python | attrs(简单数据类) | @dataclass |
| Python | six / pathlib2 / enum34 | Python 2 已经没了;pathlib / enum 都在标准库 |
| SQL | 应用层算分页、跑排名 | LIMIT / OFFSET、窗口函数 RANK() OVER (PARTITION BY …) |
| SQL | 应用层查重、查引用完整性 | UNIQUE / FOREIGN KEY / CHECK (price > 0) |
| SQL | 应用层做去重、软删过滤 | SELECT DISTINCT / ON CONFLICT DO NOTHING / 生成列 + 部分索引 |
数据库那几行是最容易被违反的:应用层写一堆校验,却把
UNIQUE 约束留在外面——并发下必然漏。约束放在数据能保证它的地方,才是真正的小 diff。6包装层的宿命
原文用四行讲完了整个论证,这四行值得抄下来贴在显示器边上:
Platform team spends years solving the problem.
Package author wraps it.
You install the wrapper.
The wrapper goes unmaintained.
You debug the wrapper.
翻译过来:平台团队花几年把问题解决掉,包作者包了一层,你装了这层包装,包装没人维护了,于是你在调试包装。跳过包装。平台是跟着你的 App 一起免费发的。
但要读全——原文紧接着写:当原生方案真的不够用时(要支持旧浏览器、有它处理不了的边界、规模化后在易用性上有实质差别),库才配得上它的位置。那时候再装,不是之前。
7怎么把这一级变成习惯
- 写之前先问:浏览器 / 系统 / 数据库本身有没有?搜索词要带
MDN或native,而不是带包名 - 找到了就先用原生版跑通,再判断够不够。通常够
- 不够用时再装库,并且在回复里写清楚为什么原生不够(这正好符合 Ponytail 的「三行说明」契约)
- 已经装了 Ponytail 的情况下,你其实不用自己记这些表——助手会被规则逼着先去查第 4 级。这也是它为什么能砍掉 94% 行数的真实原因