普通视图
Vue3 应用实例创建及页面渲染底层原理
整体流程
完整的创建与渲染流程可以分成这些阶段:
- 创建 App 实例
- 创建根组件实例
- 设置响应式状态
- 创建渲染器(Renderer)
- 挂载 Mount
- vnode -> DOM 渲染
- 数据变更触发更新
- 重新渲染 / diff / patch
流程图大致如下:
createApp() ───> app.mount('#app')
│ │
▼ ▼
createRootComponent createRenderer
│ │
▼ ▼
setup() / render() render(vnode) -> patch
│ │
▼ ▼
effect(fn) ────> scheduler -> patch updates
1、createApp 初始化
Vue 应用的入口通常是:
createApp(App).mount('#app')
从源码看 createApp:
// packages/runtime-core/src/apiCreateApp.ts
export function createAppAPI(render) {
return function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
_container: null,
_context: createAppContext()
}
const proxy = (app._instance = {
app
})
// register global APIs
// ...
return {
mount(container) {
const vnode = createVNode(rootComponent, rootProps)
app._container = container
render(vnode, container)
},
unmount() { /* ... */ }
}
}
}
关键点:
-
createAppAPI(render)生成 createApp 函数 - app 内保存
_component、上下文_context -
app.mount调用render(vnode, container)
render 由 平台渲染器 注入(在 web 下是 DOM 渲染器)。
2、createVNode 创建虚拟节点(VNode)
在 mount 前会创建一个虚拟节点:
function createVNode(type, props, children) {
const vnode = {
type,
props,
children,
shapeFlag: getShapeFlag(type),
el: null,
key: props && props.key
}
return vnode
}
vnode 是渲染的基础单元:
shapeFlag 用来快速判断 vnode 类型,是内部性能优化。
3、渲染器 Renderer 初始化
Vue3 是平台无关的(runtime-core),真正依赖 DOM 的是在 runtime-dom 中。
创建 Renderer:
export const renderer = createRenderer({
createElement: hostCreateElement,
patchProp: hostPatchProp,
insert: hostInsert,
remove: hostRemove,
setElementText: hostSetElementText
})
createRenderer 返回了我们前面在 createApp 中使用的 render(vnode, container) 函数。
4、render & patch
核心渲染入口:
function render(vnode, container) {
patch(null, vnode, container, null, null)
}
patch 是渲染补丁函数:
function patch(n1, n2, container, parentComponent, anchor) {
const { type, shapeFlag } = n2
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement()
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(...)
}
}
简化为:
- 如果是 DOM 元素 vnode → 挂载/更新
- 如果是 组件 vnode → 创建组件实例、挂载、渲染子树
5、组件实例创建
当渲染组件时:
function processComponent(n1, n2, container, parentComponent, anchor) {
mountComponent(n2, container, parentComponent, anchor)
}
function mountComponent(vnode, container, parentComponent, anchor) {
const instance = createComponentInstance(vnode, parentComponent)
setupComponent(instance)
setupRenderEffect(instance, container, anchor)
}
- processComponent 处理组件
- mountComponent 挂载组件
- createComponentInstance 创建组件实例
- setupComponent 创建组件对象
createComponentInstance:
function createComponentInstance(vnode, parent) {
const instance = {
vnode,
parent,
proxy: null,
ctx: {},
props: {},
attrs: {},
slots: {},
setupState: {},
isMounted: false,
subTree: null
}
return instance
}
实例保存基础信息,还没运行 setup。
6、 setupComponent(初始化组件)
function setupComponent(instance) {
initProps(instance, vnode.props)
initSlots(instance, vnode.children)
setupStatefulComponent(instance)
}
内部会执行:
const { setup } = Component
if (setup) {
const setupResult = setup(props, ctx)
handleSetupResult(instance, setupResult)
}
setup 返回值:
- 返回对象 → 作为响应式状态 state
- 返回函数 → render 函数
最终让组件拥有 instance.render。
7、创建响应式状态
Vue3 的响应式来自 reactivity 包:
const state = reactive({ count: 0 })
底层是 Proxy 拦截 getter/setter:
- getter:收集依赖
- setter:触发依赖更新
依赖管理核心是 effect / track / trigger。
8、 setupRenderEffect 与首次渲染
创建渲染器副作用,并调度组件挂载和异步更新:
function setupRenderEffect(instance, container, anchor) {
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
const subTree = (instance.subTree = instance.render.call(proxy))
patch(null, subTree, container, instance, anchor)
instance.isMounted = true
} else {
// 更新更新逻辑
}
}, {
scheduler: queueJob
})
}
这里:
- 创建一个 响应式 effect
- 第一次执行 render 得到 subTree
- patch 子树到 DOM
effect+scheduler实现异步更新。
9、vnode-> 真实 DOM(DOM mount)
当 patch 到真正的 DOM 时,走的是 element 分支:
function processElement(...) {
if (!n1) {
mountElement(vnode, container)
} else {
patchElement(n1, n2)
}
}
mountElement
function mountElement(vnode, container) {
const el = (vnode.el = hostCreateElement(vnode.type))
// props
for (key in props) {
hostPatchProp(el, key, null, props[key])
}
// children
if (typeof children === 'string') {
hostSetElementText(el, children)
} else {
children.forEach(c => patch(null, c, el))
}
hostInsert(el, container)
}
10、更新 & Diff 算法
当响应式状态改变:
state.count++
触发 setter → trigger:
- 将 effect 放入更新队列
- 异步执行 scheduler
- 调用 instance.update 再次 patch
更新阶段:
patchElement(n1, n2)
核心逻辑:
- props diff
- children diff
- unkeyed/keyed diff 算法(最小化移动)
具体见
patchChildren和patchKeyedChildren。
整体核心对象关系架构
App
└─ vnode(root)
└─ ComponentInstance
├─ props / slots
├─ setupState
└─ render() -> subTree
└─ vnode tree
└─ DOM nodes
响应式依赖结构:
reactive state
├─ effects[]
└─ track -> effect
└─ scheduler -> patch
webpack异步加载原理梳理解构
面试官 : “ 说一下 Vue 的 8 个生命周期钩子都做了什么 ? ”
面试官 : ” 说一下 Vue 中的 setup 中的 props 和 context “
赫蹏(hètí):为中文网页内容赋予优雅排版的开源利器
![]()
fHJ9cZeOp.jpg
在当今信息爆炸的时代,内容呈现的形式往往决定了阅读体验的优劣。对于中文网站来说,一个长期存在的挑战是如何实现符合传统中文排版美学的网页展示。尽管现代CSS技术已经十分强大,但针对中文特点的排版优化仍然不够完善。今天,我们将介绍一个专门为解决这一问题而生的开源项目——赫蹏(hètí)。
什么是赫蹏?
赫蹏是一个专为中文内容展示设计的排版样式增强库,名称取自古代对纸张的雅称。这个项目由开发者Sivan创建,基于通行的中文排版规范,旨在为网站的读者提供更加舒适、专业的文章阅读体验。
简单来说,赫蹏让中文网页内容“自动变好看” 。
该项目在github 已有6.6k star
![]()
_20260102_222650.png
- github 地址: github.com/sivan/heti
- 在线预览地址:sivan.github.io/heti/
![]()
_20260102_223923.png
核心特性一览
🎯 贴合网格的精准排版
赫蹏实现了基于网格系统的中文排版,确保文字、段落和间距都遵循严谨的视觉规律,让页面呈现出整齐划一的专业感。
📝 全标签样式美化
项目不仅仅针对段落文本,而是对整个HTML文档中的各类标签(标题、列表、表格、代码块等)都进行了细致的美化,形成统一而和谐的整体视觉风格。
🏮 传统版式支持
赫蹏贴心地预置了古文、诗词样式,并提供了多种传统排版样式支持:
- 行间注(类似于古籍中的双行小注)
- 多栏排版
- 竖排文字
- 为需要展示传统文学内容的网站提供了极大便利
🌗 智能适配设计
- 自适应黑暗模式:跟随系统设置自动切换明暗主题
- 移动端优先:在各种屏幕尺寸上都有良好表现
- 简繁中文支持:满足不同地区用户的需求
✨ 智能排版增强(基于JavaScript)
这是赫蹏的“黑科技”部分:
- 中西文混排美化:自动在中英文之间添加适当间距,再也不用手动敲空格
- 全角标点挤压:智能调整标点符号的间距,避免出现难看的空白
🎨 字体优化
提供多种预设的中文字体族选择(桌面端),可根据不同内容风格搭配最合适的字体组合。
极简的安装与使用
赫蹏的设计哲学是“最小化侵入”,使用起来异常简单:
基础使用(仅CSS)
<!-- 1. 引入样式 -->
<link rel="stylesheet" href="//unpkg.com/heti/umd/heti.min.css">
<!-- 2. 添加类名 -->
<article class="heti">
<h1>文章标题</h1>
<p>这里是你所有的中文内容...</p>
</article>
只需这两步,你的内容就会立刻获得专业级的中文排版效果。
增强功能(添加JavaScript)
<script src="//unpkg.com/heti/umd/heti-addon.min.js"></script>
<script>
const heti = new Heti('.heti');
heti.autoSpacing(); // 启用智能中西文混排和标点挤压
</script>
实际效果展示
我比较喜欢的是竖排排版的样式,我们在markdown中也可以直接使用,如下使用
<link rel="stylesheet" href="//unpkg.com/heti/umd/heti.min.css">
<div class="card__vertical-container">
<section class="heti--vertical heti--ancient">
<h1>出師表</h1>
<p class="heti-small">作者:<abbr title="字孔明">諸葛亮</abbr>(181年-234年10月8日)</p>
<p>先帝創業未半,而中道崩殂;今天下三分,益州疲弊,此誠危急存亡之秋也﹗然侍衞之臣,不懈於內;忠志之士,忘身於外者,蓋追先帝之殊遇,欲報之於陛下也。</p>
<p>誠宜開張聖聽,以光先帝遺德,恢弘志士之氣﹔不宜妄自菲薄,引喻失義,以塞忠諫之路也。</p>
<p>宮中、府中,俱為一體;陟罰臧否,不宜異同。若有作姦、犯科,及為忠善者,宜付有司,論其刑賞,以昭陛下平明之治;不宜偏私,使內外異法也。</p>
<p>侍中、侍郎郭攸之、費禕、董允等,此皆良實,志慮忠純,是以先帝簡拔以遺陛下。愚以為宮中之事,事無大小,悉以咨之,然後施行,必能裨補闕漏,有所廣益。將軍向寵,性行淑均,曉暢軍事,試用於昔日,先帝稱之曰「能」,是以眾議舉寵為督。愚以為營中之事,悉以咨之,必能使行陣和睦,優劣得所。</p>
<p>親賢臣,遠小人,此先漢所以興隆也﹔親小人,遠賢臣,此後漢所以傾頹也。先帝在時,每與臣論此事,未嘗不歎息痛恨於桓、靈也!侍中、尚書、長史、參軍,此悉貞良死節之臣,願陛下親之、信之,則漢室之隆,可計日而待也。</p>
<p>臣本布衣,躬耕於南陽,苟全性命於亂世,不求聞達於諸侯。先帝不以臣卑鄙,猥自枉屈,三顧臣於草廬之中,諮臣以當世之事;由是感激,遂許先帝以驅馳。後值傾覆,受任於敗軍之際,奉命於危難之間,爾來二十有一年矣。先帝知臣謹慎,故臨崩寄臣以大事也。受命以來,夙夜憂歎,恐託付不效,以傷先帝之明。故五月渡瀘,深入不毛。今南方已定,兵甲已足,當獎率三軍,北定中原,庶竭駑鈍,攘除姦凶,興復漢室,還於舊都。此臣所以報先帝而忠陛下之職分也。至於斟酌損益,進盡忠言,則攸之、禕、允之任也。</p>
<p>願陛下託臣以討賊興復之效;不效,則治臣之罪,以告先帝之靈。若無興德之言,則責攸之、禕、允等之慢,以彰其咎。陛下亦宜自謀,以諮諏善道,察納雅言,深追先帝遺詔。臣不勝受恩感激。今當遠離,臨表涕零,不知所言!</p>
</section>
</div>
效果如下:
![]()
_20260102_224539.png
我使用ai 基于赫蹏(hètí)做了一个竖版排版的网站,感兴趣的家人们也可以使用下
博主竖版排版网址: h5.xiuji.mynatapp.cc/heti/
![]()
_20260102_232901.png
![]()
_.png
结语:让内容回归本质
在追求炫酷交互和复杂动画的今天,赫蹏提醒我们一个基本事实:对于内容型网站,优秀的可读性才是最重要的用户体验。这个项目以极低的成本,为中文网页带来了显著的品质提升。
如果你正在经营一个以中文内容为主的网站,或者只是想在个人博客上获得更好的排版效果,不妨尝试一下赫蹏。正如项目README中所说的:“总之,用上就会变好看。”
赫蹏不仅是一个工具,更是对中文数字阅读体验的一次优雅致敬。在这个注意力稀缺的时代,为读者提供一个舒适的阅读环境,或许就是最好的内容策略。
面试官 : “ Vue 选项式api 和 组合式api 什么区别? “
Vue 的选项式 API (Options API) 和组合式 API (Composition API) 是两种核心的代码组织方式,前者侧重 “按选项分类”,后者侧重 “按逻辑分类”,核心差异体现在设计理念、代码结构、复用性等维度,以下是全面对比和实用解读:
一、核心区别对比表
| 维度 | 选项式 API (Options API) | 组合式 API (Composition API) |
|---|---|---|
| 设计理念 | 按 “选项类型” 组织代码(data、methods、computed 等) | 按 “业务逻辑” 组织代码(同一逻辑的代码聚合) |
| 代码结构 | 分散式:同一逻辑的代码分散在不同选项中 | 聚合式:同一逻辑的代码集中在 setup 或 <script setup>
|
| 适用场景 | 小型组件、简单业务(快速上手) | 中大型组件、复杂业务(逻辑复用 / 维护) |
| 逻辑复用 | 依赖 mixin(易命名冲突、来源不清晰) | 依赖组合式函数(Composables,纯函数复用,清晰可控) |
| 类型推导 | 对 TypeScript 支持弱(需手动标注) | 天然适配 TS,类型推导更完善 |
| 响应式写法 | 声明式:data 返回对象,Vue 自动响应式 | 手动式:用 ref/reactive 创建响应式数据 |
| 生命周期 | 直接声明钩子(created、mounted 等) | setup 中通过 onMounted 等函数调用(无 beforeCreate/created) |
| 代码可读性 | 简单场景清晰,复杂场景 “碎片化” | 复杂场景逻辑聚合,可读性更高 |
| 上手成本 | 低(符合传统前端思维) | 稍高(需理解 ref/reactive/setup 等概念) |
二、代码示例直观对比
1. 选项式 API(Vue 2/3 兼容)
<template>
<div>{{ count }} <button @click="add">+1</button></div>
</template>
<script>
export default {
// 数据(响应式)
data() {
return {
count: 0
};
},
// 方法
methods: {
add() {
this.count++;
}
},
// 生命周期
mounted() {
console.log('组件挂载:', this.count);
}
};
</script>
特点:代码按 data/methods/mounted 等选项拆分,同一 “计数逻辑” 分散在不同区块。
2. 组合式 API(Vue 3 推荐,<script setup> 语法糖)
<template>
<div>{{ count }} <button @click="add">+1</button></div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
// 响应式数据(ref 用于基本类型)
const count = ref(0);
// 方法(与数据聚合)
const add = () => {
count.value++; // ref 需通过 .value 访问
};
// 生命周期
onMounted(() => {
console.log('组件挂载:', count.value);
});
</script>
特点:“计数逻辑” 的数据、方法、生命周期全部聚合在一处,逻辑边界清晰。
三、核心差异深度解读
1. 逻辑复用:从 mixin 到 Composables(组合式函数)
-
选项式 API 的痛点(mixin) :复用逻辑需写 mixin 文件,多个 mixin 易出现命名冲突,且无法清晰知道属性来源:
// mixin/countMixin.js export default { data() { return { count: 0 }; }, methods: { add() { this.count++; } } }; // 组件中使用 export default { mixins: [countMixin], // 引入后,count/add 混入组件,但来源不直观 }; -
组合式 API 的优势(Composables) :复用逻辑封装为纯函数,按需导入,属性来源清晰,无命名冲突:
// composables/useCount.js import { ref } from 'vue'; export const useCount = () => { const count = ref(0); const add = () => count.value++; return { count, add }; }; // 组件中使用 <script setup> import { useCount } from './composables/useCount'; const { count, add } = useCount(); // 明确导入,来源清晰 </script>
2. 响应式原理:声明式 vs 手动式
- 选项式 API:
data返回的对象会被 Vue 递归劫持(Object.defineProperty/Proxy),自动变成响应式,直接通过this.xxx访问; - 组合式 API:需手动用
ref(基本类型)/reactive(引用类型)创建响应式数据,ref需通过.value访问(模板中自动解包),更灵活且可控。
3. 大型项目适配性
- 选项式 API:组件复杂度提升后,同一业务逻辑的代码会分散在
data/methods/computed/watch等多个选项中,形成 “面条代码”,维护成本高; - 组合式 API:可将复杂业务拆分为多个 Composables(如
useUser/useCart/useOrder),每个函数负责一个逻辑模块,代码结构清晰,便于多人协作和维护。
四、选型建议
| 场景 | 推荐 API |
|---|---|
| 小型组件 / 快速原型 | 选项式 API |
| 中大型项目 / 复杂逻辑 | 组合式 API |
| 需兼容 Vue 2 + Vue 3 | 选项式 API(过渡) |
| 用 TypeScript 开发 | 组合式 API |
总结
- 选项式 API 是 “面向选项” 的思维,适合入门和简单场景,符合传统前端的代码组织习惯;
- 组合式 API 是 “面向逻辑” 的思维,解决了选项式 API 在复杂场景下的复用、维护痛点,是 Vue 3 的核心升级,也是大型项目的最佳实践。
两者并非互斥,Vue 3 完全兼容选项式 API,可根据项目规模和团队习惯灵活选择(甚至同一项目中混合使用)。
🔥3 kB 换 120 ms 阻塞? Axios 还是 fetch?
0. 先抛结论,再吵不迟
| 指标 | Axios 1.7 | fetch (原生) |
|---|---|---|
| gzip 体积 | ≈ 3.1 kB | 0 kB |
| 阻塞时间(M3/4G) | 120 ms | 0 ms |
| 内存峰值(1000 并发) | 17 MB | 11 MB |
| 生产 P1 故障(过去一年) | 2 次(拦截器顺序 bug) | 0 次 |
| 开发体验(DX) | 10 分 | 7 分 |
结论:
- 极致性能/SSG/Edge → fetch 已足够;
- 企业级、需要全局拦截、上传进度 → Axios 仍值得;
- 二者可共存:核心链路与首页用 fetch,管理后台用 Axios。
1. 3 kB 到底贵不贵?
2026 年 1 月,HTTP Archive 最新采样(Chrome 桌面版)显示:
- 中位 JS 体积 580 kB,3 kB 似乎“九牛一毛”;
- 但放到首屏预算 100 kB 的站点(TikTok 推荐值),3 kB ≈ 3 % 预算,再加 120 ms 阻塞,LCP 直接从 1.5 s 飙到 1.62 s,SEO 评级掉一档。
“ bundle 每 +1 kB,4G 下 FCP +8 ms”——Lighthouse 2025 白皮书。
2. 把代码拍桌上:差异只剩这几行
下面 4 个高频场景,全部给出“可直接复制跑”的片段,差异一目了然。
2.1 自动 JSON + 错误码
// Axios:零样板
const {data} = await axios.post('/api/login', {user, pwd});
// fetch:两行样板
const res = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({user, pwd})
});
if (!res.ok) throw new Error(res.status);
const data = await res.json();
争议:
- Axios 党:少写两行,全年少写 3000 行。
- fetch 党:gzip 后 3 kB 换两行?ESLint 模板一把就补全。
2.2 超时 + 取消
// Axios:内置
const source = axios.CancelToken.source();
setTimeout(() => source.cancel('timeout'), 5000);
await axios.get('/api/big', {cancelToken: source.token});
// fetch:原生 AbortController
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 5000);
await fetch('/api/big', {signal: ctl.signal});
2025 之后 Edge/Node 22 已全支持,AbortSignal.timeout(5000) 一行搞定:
await fetch('/api/big', {signal: AbortSignal.timeout(5000)});
结论:语法差距已抹平。
2.3 上传进度条
// Axios:progress 事件
await axios.post('/upload', form, {
onUploadProgress: e => setProgress(e.loaded / e.total)
});
// fetch:借助 `xhr` 或 `ReadableStream`
// 2026 仍无原生简易方案,需要封装 `xhr` 才能拿到 `progress`。
结论:大文件上传场景 Axios 仍吊打 fetch。
2.4 拦截器(token、日志)
// Axios:全局拦截
axios.interceptors.request.use(cfg => {
cfg.headers.Authorization = `Bearer ${getToken()}`;
return cfg;
});
// fetch:三行封装
export const $get = (url, opts = {}) => fetch(url, {
...opts,
headers: {...opts.headers, Authorization: `Bearer ${getToken()}`}
});
经验:拦截器一旦>2 个,Axios 顺序地狱频发;fetch 手动链式更直观。
3. 实测!同一个项目,两套 bundle
测试场景
- React 18 + Vite 5,仅替换 HTTP 层;
- 构建目标:es2020 + gzip + brotli;
- 网络:模拟 4G(RTT 150 ms);
- 采样 10 次取中位。
| 指标 | Axios | fetch |
|---|---|---|
| gzip bundle | 46.7 kB | 43.6 kB |
| 首屏阻塞时间 | 120 ms | 0 ms |
| Lighthouse TTI | 2.1 s | 1.95 s |
| 内存峰值(1000 并发请求) | 17 MB | 11 MB |
| 生产报错(过去一年) | 2 次拦截器顺序错乱 | 0 |
数据来自 rebrowser 2025 基准 ;阻塞时间差异与 51CTO 独立测试吻合 。
4. 什么时候一定要 Axios?
- 需要上传进度(onUploadProgress)且不想回退 xhr;
- 需要请求/响应拦截链 >3 层,且团队对“黑盒”可接受;
- 需要兼容 IE11(2026 年政务/银行仍存);
- 需要Node 16 以下老版本(fetch 需 18+)。
5. 共存方案:把 3 kB 花在刀刃上
// core/http.js
export const isSSR = typeof window === 'undefined';
export const HTTP = isSSR || navigator.connection?.effectiveType === '4g'
? { get: (u,o) => fetch(u,{...o, signal: AbortSignal.timeout(5000)}) }
: await import('axios'); // 动态 import,只在非 4G 或管理后台加载
结果:
- 首屏 0 kB;
- 管理后台仍享受 Axios 拦截器;
- 整体 bundle 下降 7 %,LCP −120 ms。
6. 一句话收尸
2026 年的浏览器,fetch 已把“缺的课”补完:取消、超时、Node 原生、TypeScript 完美。
3 kB 的 Axios 不再是“默认”,而是“按需”。
上传进度、深链拦截、老浏览器——用 Axios;
其余场景,让首页飞一把,把 120 ms 还给用户。
在ios上动态插入元素的列表使用:last-child样式可能不能及时生效
这是一个在 iOS Safari(Webkit 引擎)中比较经典的渲染 Bug。当通过 JavaScript 动态向容器末尾添加元素时,Webkit 有时未能正确触发重绘(Repaint)或样式重新计算(Style Recalculation),导致 :last-child 伪类仍然停留在之前的元素上。
以下是解决这个问题的几种常用方案,按推荐程度排序:
1. 强制触发重绘 (Force Reflow)
这是最简单直接的“黑科技”。在插入元素的代码后面,读取一次容器或新元素的某个布局属性(如 offsetHeight),强制浏览器同步计算样式。
JavaScript
const container = document.getElementById('container');
const newItem = document.createElement('div');
container.appendChild(newItem);
// 强制触发重绘
container.offsetHeight;
2. 使用 Flex/Grid 的 gap 属性 (推荐)
如果你的 :last-child 主要是为了处理间距(例如 margin-bottom: 0),那么弃用 :last-child 改用 gap 是最现代且根本的解决方案。gap 会自动处理元素间的间距,不需要判断谁是最后一个。
CSS
.container {
display: flex;
flex-direction: column;
gap: 10px; /* 元素之间自动产生 10px 间距,最后一个元素后方不会有间距 */
}
3. 反向思路:使用 :not(:last-child)
有时候 Webkit 对“谁是最后一个”反应迟钝,但对“谁不是最后一个”反应较快。尝试给所有非末尾元素设置样式:
CSS
/* 不推荐 */
/* .item { margin-bottom: 10px; } */
/* .item:last-child { margin-bottom: 0; } */
/* 推荐 */
.item:not(:last-child) {
margin-bottom: 10px;
}
4. 手动切换 Class
如果逻辑比较复杂,伪类失效频繁,建议放弃 CSS 伪类,改用 JavaScript 在插入时手动维护一个 .is-last 类。
JavaScript
// 插入逻辑
const items = container.querySelectorAll('.item');
items.forEach(el => el.classList.remove('is-last'));
const lastItem = items[items.length - 1];
lastItem.classList.add('is-last');
为什么会发生这种情况?
Webkit 引擎为了性能优化,会尽量减少样式重新计算的频率。当 DOM 树发生变化时,它本应标记该容器为 "dirty" 并重新检查伪类状态,但在某些复杂的嵌套布局或特定的 iOS 版本中,这个触发机制会漏掉对 :last-child 的检查。
建议: 如果你的项目环境允许(iOS 14.1+),优先使用 Flexbox/Grid 的 gap。它不仅性能更好,还能彻底规避此类由于动态插入导致的伪类失效问题。
Vue 组件通信的 8 种最佳实践,你知道几种?
CSS 写 SQL 查询?后端慌了!
CSS 写 SQL 查询?后端慌了!
![]()
初次接触到这个项目时,我的第一反应只有四个字:
![]()
这也行?
最近在 X 上大火的一个叫 TailwindSQL 的项目,引发了广泛讨论。
![]()
其核心玩法非常简单——通过 CSS 的 className 来实现 SQL 查询功能。
前端发展到这个地步了吗?
让我们先看一个示例:
<DB className="db-users-name-where-id-1" />
如果你是前端开发者,可能会下意识地认为这是在定义样式;
但如果你是后端开发者,估计已经开始皱眉了。
然而实际上,这段代码执行的是:
SELECT name FROM users WHERE id = 1;
看到这里,我确实愣了一下。
TailwindSQL 的本质
简而言之,它将 SQL 语句拆解为一个个「类名」片段。
这种做法类似于 TailwindCSS 对 CSS 的处理方式:
db-users
db-users-name
db-users-name-where-id-1
db-products-orderby-price-desc
这些 className 最终会被解析为 SQL 语句,并在 React Server Components 中直接执行。
你甚至无需编写 API 接口,也无需使用 ORM 框架。
这个方案可靠吗?
从工程实践的角度来看,答案其实很明确:
并不可靠。
SQL 的复杂性,从来不是语法层面的问题。
真正的挑战在于:
-
表关系管理
-
复杂 JOIN 操作
-
嵌套子查询
-
事务控制
-
权限验证
-
边界条件处理
一旦查询逻辑稍显复杂,className 就会变得越来越冗长,最终形成一串难以维护的代码片段。
说实话,我很难想象在实际项目中,会有开发者认真地写出这样的代码:
className="db-orders-user-products-joinwhere-user-age-gt-18and-order-status-paidgroupby-user-id"
这已经不再是 DSL(领域特定语言)了,而是一种折磨。
我认为 TailwindSQL 很难在生产环境中得到应用,它更像是 vibe coding(氛围编程)的产物。
是否使用?可以了解一下,然后继续编写你熟悉的 SQL 吧。
-
TailwindSQL 官网:
https://tailwindsql.com/
前端面试题整理(方便自己看的)
防抖(Debounce)实战解析:如何用闭包优化频繁 AJAX 请求,提升用户体验
在现代 Web 开发中,用户交互越来越丰富,但随之而来的性能问题也日益突出。一个典型场景是:搜索框实时建议功能。当用户在输入框中快速打字时,如果每按一次键就立即向服务器发送一次 AJAX 请求,不仅会造成大量无效网络开销,还可能导致页面卡顿、响应错乱,甚至压垮后端服务。本文将以“百度搜索建议”为例,通过对比未防抖与防抖两种实现方式,深入浅出地讲解防抖技术的原理、实现及其带来的显著优势。
一、问题引入:不防抖的“蛮力请求”有多糟糕?
假设我们正在开发一个类似百度搜索的自动补全功能。用户在输入框中输入关键词,前端实时将内容发送到服务器,获取匹配建议并展示。
❌ 不防抖的实现(反面教材)
const input = document.getElementById('search');
input.addEventListener('input', function(e) {
ajax(e.target.value); // 每次输入都立刻发请求
});
function ajax(query) {
console.log('发送请求:', query);
// 实际项目中这里是 fetch 或 XMLHttpRequest
}
用户输入 “javascript” 的过程:
表格
| 输入步骤 | 触发次数 | 发送的请求 |
|---|---|---|
| j | 1 | "j" |
| ja | 2 | "ja" |
| jav | 3 | "jav" |
| java | 4 | "java" |
| … | … | … |
| javascript | 10 | "javascript" |
后果分析:
- 资源浪费:前9次请求几乎无意义(用户还没输完),却消耗了带宽、CPU 和服务器连接。
- 响应错乱:如果“j”的响应比“javascript”晚到,页面会先显示“j”的结果,再跳变到最终结果,体验极差。
- 页面卡顿:高频 DOM 操作 + 网络回调,容易导致主线程阻塞,输入框变得“卡手”。
这就是典型的“执行太密集、任务太复杂”问题——事件触发频率远高于实际需求。
二、解决方案:用防抖(Debounce)优雅降频
✅ 什么是防抖?
防抖(Debounce) 是一种函数优化技术:在事件被频繁触发时,仅在最后一次触发后等待指定时间,才真正执行函数。
通俗理解:
用户打字时,我不急着查;等他停手500毫秒,我才认为他“打完了”,这时才发请求。
🔧 防抖的核心实现(基于闭包)
function debounce(fn, delay) {
let timer; // 闭包变量:保存定时器ID
return function(...args) {
const context = this;
clearTimeout(timer); // 清除上一次的定时器
timer = setTimeout(() => {
fn.apply(context, args); // 延迟执行,并保持this和参数
}, delay);
};
}
关键点解析:
-
闭包作用:
timer被内部函数引用,不会被垃圾回收,可跨多次调用共享。 - 清除旧定时器:每次触发都重置倒计时,确保只执行“最后一次”。
-
保留上下文:通过
apply保证原函数的this和参数正确传递。
✅ 防抖后的使用
const debouncedAjax = debounce(ajax, 500);
input.addEventListener('input', function(e) {
debouncedAjax(e.target.value);
});
用户输入 “javascript” 的效果:
- 快速打完10个字母 → 只触发1次请求(“javascript”)
- 中途停顿超过500ms → 触发当前值的请求(如打到“java”停住)
三、对比实验:防抖 vs 不防抖
我们在 HTML 中放置两个输入框:
<input id="undebounce" placeholder="不防抖(危险!)">
<input id="debounce" placeholder="防抖(推荐)">
绑定不同逻辑:
// 不防抖:每输入一个字符就请求
undebounce.addEventListener('input', e => ajax(e.target.value));
// 防抖:500ms 内只执行最后一次
debounce.addEventListener('input', e => debouncedAjax(e.target.value));
打开浏览器控制台,分别快速输入 “react”:
- 不防抖输入框:控制台瞬间打印 5 条日志(r, re, rea, reac, react)
- 防抖输入框:控制台仅在你停止输入后 0.5 秒打印 1 条日志(react)
用户体验差异:
- 不防抖:页面可能闪烁、卡顿,建议列表频繁跳动。
- 防抖:输入流畅,结果稳定,资源消耗降低 80% 以上。
四、为什么防抖能解决性能问题?
-
减少无效请求
用户输入过程中产生的中间状态(如“j”、“ja”)通常无需处理,防抖直接忽略它们。 -
避免竞态条件(Race Condition)
后发的请求覆盖先发的结果,确保 UI 始终显示最新、最完整的查询结果。 -
降低服务器压力
假设每天有 10 万用户使用搜索,平均每人输入 10 次,不防抖产生 100 万请求;防抖后可能仅 10 万请求,节省 90% 计算资源。 -
提升前端性能
减少 JavaScript 执行、DOM 更新和网络回调的频率,主线程更“轻盈”,页面更流畅。
五、防抖的适用场景
表格
| 场景 | 说明 |
|---|---|
| 搜索框建议 | 用户输入时延迟请求,等输入稳定后再查 |
| 窗口 resize | 防止调整窗口大小时频繁触发布局计算 |
| 表单提交 | 防止用户狂点“提交”按钮导致重复提交 |
| 按钮点击 | 如“点赞”功能,避免快速连点 |
⚠️ 注意:滚动加载(scroll)更适合用节流(Throttle) ,因为用户持续滚动时仍需定期触发(如每 200ms 检查是否到底部),而防抖会在滚动结束才触发,可能错过加载时机。
六、总结:防抖是前端性能优化的基石
通过本文的对比实验,我们可以清晰看到:不加控制的事件监听是性能杀手,而防抖则是优雅的“减速阀” 。它利用闭包保存状态,通过定时器智能合并高频操作,在不牺牲用户体验的前提下,大幅降低系统开销。
在实际项目中,建议:
- 对
input、keyup、resize等高频事件默认使用防抖或节流 - 使用成熟的工具库(如 Lodash 的
_.debounce)避免手写 bug - 根据业务调整延迟时间(搜索建议常用 300–500ms)
记住:好的前端工程师,不仅要让功能跑起来,更要让它跑得稳、跑得快、跑得省。 而防抖,正是你工具箱中不可或缺的一把利器。
🌟 小提示:下次当你看到百度搜索框在你打字时不急不躁、等你停手才给出建议时,就知道——背后一定有防抖在默默守护性能!
cloudflare使用express实现api防止跨域cors
大家好,我是1024小神,想进 技术群 / 私活群 / 股票群 或 交朋友都可以私信我,如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~
![]()
在 Cloudflare Workers 上,必须自己处理 CORS,Express 默认的 cors 中间件 并不会自动生效。
在中间件中写一个cors.ts文件,里面的代码如下:
import { Request, Response, NextFunction } from 'express';
export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
// ⚠️ in production, write the specific domain
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
// handle preflight request
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
// next middleware
next();
}
然后配置中间件在所有的路由前面:
![]()
然后重启项目,再次发送请求就没事了:
![]()
从定时器管理出发,彻底搞懂防抖与节流的实现逻辑
在前端开发中,高频事件(如输入、滚动、窗口缩放)若不加控制,极易引发性能问题。为应对这一挑战,防抖(debounce) 与 节流(throttle) 成为必备工具。
一、防抖:每次触发都重置定时器
假设我们要实现一个功能:用户在输入框打字时,只有当他停止输入超过 1 秒,才发送请求。
第一步:我需要延迟执行
显然,要用 setTimeout:
setTimeout(() => {
ajax(value);
}, 1000);
第二步:但如果用户继续输入,之前的请求就不该发
→ 所以必须取消之前的定时器,再建一个新的。
这就要求我们保存定时器 ID:
let timerId;
// 每次触发时:
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
ajax(value);
}, 1000);
第三步:处理 this 和参数
因为 ajax 可能依赖上下文或多个参数,不能直接写死。我们需要在触发时捕获当前的 this 和 arguments:
function debounce(fn, delay) {
let timerId;
return function(...args) {
const context = this;
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
到此,防抖完成。它的全部逻辑就源于一句话: “每次触发,先删旧定时器,再建新定时器。”
所谓“停手后执行”,只是这种操作的自然结果。
二、节流:控制执行频率,必要时预约补发
现在需求变了:不管用户多快输入,每 1 秒最多只发一次请求,且最后一次输入不能丢。
第一步:我能立即执行吗?
用时间戳判断是否已过 delay:
const now = Date.now();
if (now - last >= delay) {
fn();
last = now; // 记录执行时间
}
这能保证最小间隔,但有个致命缺陷:如果用户快速输入后立刻停止,最后一次可能永远不会执行。
第二步:如何不丢尾?
→ 在冷却期内,预约一次未来的执行。这就要用到 setTimeout。
于是逻辑分裂为两条路径:
-
路径 A(可立即执行) :时间到了,马上执行,更新
last - 路径 B(还在冷却) :清除之前的预约,重新预约一次执行
第三步:管理预约定时器
我们需要一个变量 deferTimer 来保存预约任务的 ID:
let last = 0;
let deferTimer = null;
当处于冷却期时:
clearTimeout(deferTimer); // 清除旧预约
deferTimer = setTimeout(() => {
last = Date.now(); // 关键:这次执行也要记录时间!
fn.apply(this, args);
}, delay - (now - last)); // 精确计算剩余等待时间
第四步:整合逻辑
function throttle(fn, delay) {
let last = 0;
let deferTimer = null;
return function(...args) {
const context = this;
const now = Date.now();
if (now - last >= delay) {
// 路径 A:立即执行
last = now;
fn.apply(context, args);
} else {
// 路径 B:预约执行
if (deferTimer) clearTimeout(deferTimer);
deferTimer = setTimeout(() => {
last = Date.now(); // 必须更新!
fn.apply(context, args);
}, delay - (now - last));
}
};
}
节流的核心,是两种执行方式的协同:
- 立即执行靠时间戳判断
- 补发执行靠
setTimeout预约
而两者共享同一个last状态,确保整体节奏不乱。
三、对比总结:防抖 vs 节流的机制差异
| 维度 | 防抖(Debounce) | 节流(Throttle) |
|---|---|---|
| 核心操作 | 每次触发都 clearTimeout + setTimeout
|
冷却期内 clearTimeout + setTimeout,否则立即执行 |
| 状态变量 | 仅需 timerId
|
需 last(时间) + deferTimer(预约ID) |
| 执行特点 | 只执行最后一次 | 固定间隔执行,且不丢尾 |
| 适用场景 | 搜索建议、表单校验 | 滚动加载、按钮限频、实时位置上报 |
前端 Token 刷新机制实战:基于 Axios 的 accessToken 自动续期方案
一、背景
在前后端分离的项目中,前端通常通过 accessToken 来访问业务接口。 但由于 accessToken 有有效期限制,过期后的处理方式,直接影响系统的安全性和用户体验。
本文将介绍一种 基于 Axios 响应拦截器 + 请求队列 的 Token 自动刷新方案,适用于实际生产环境。
二、整体设计思路
核心目标只有三个:
- accessToken 过期时自动刷新
- refreshToken 只请求一次
- 刷新完成后自动重试过期前的请求
整体流程如下:
三、基于业务 code 的统一错误抛出
项目中后端返回统一的数据结构:
{
code:number;
msg:string;
data:any
}
在 Axios 的响应拦截器的 成功回调 中:
if (code === ApiCodeEnum.SUCCESS) {
return data;
}
// 业务错误,主动抛出
ElMessage.error(msg || "系统出错");
return Promise.reject(new Error(msg || "Error"));
- 成功流只处理 真正成功的数据
- 所有业务异常 统一进入 error 分支
- 后续逻辑更清晰、集中
四、在 error 分支中统一处理 Token 异常
在 Axios 响应拦截器的 error 回调中:
async (error) => {
const { response, config } = error;
if (!response) {
ElMessage.error("网络连接失败");
return Promise.reject(error);
}
const { code, msg } = response.data;
switch (code) {
case ApiCodeEnum.ACCESS_TOKEN_INVALID:
return refreshTokenAndRetry(config, service);
case ApiCodeEnum.REFRESH_TOKEN_INVALID:
await redirectToLogin("登录已过期");
return Promise.reject(error);
default:
ElMessage.error(msg || "系统出错");
return Promise.reject(error);
}
};
设计要点
- 只在一个地方判断 Token 失效
- 不在业务代码中关心 Token 状态
五、Token 刷新的难点:并发请求问题
如果多个接口同时返回 ACCESS_TOKEN_INVALID:
- ❌ 会触发多次 refreshToken 请求
- ❌ 后端压力大
- ❌ Token 状态混乱
解决方案:请求队列 + 刷新锁
六、基于闭包的 Token 刷新队列实现
通过组合式函数 useTokenRefresh 实现:
核心状态
let isRefreshingToken = false;
const pendingRequests = [];
刷新 Token 并重试请求
async function refreshTokenAndRetry(config, httpRequest) {
return new Promise((resolve, reject) => {
const retryRequest = () => {
const newToken = AuthStorage.getAccessToken();
config.headers.Authorization = `Bearer ${newToken}`;
httpRequest(config).then(resolve).catch(reject);
};
pendingRequests.push({ resolve, reject, retryRequest });
if (!isRefreshingToken) {
isRefreshingToken = true;
useUserStoreHook()
.refreshToken()
.then(() => {
pendingRequests.forEach(req => req.retryRequest());
pendingRequests.length = 0;
})
.catch(async () => {
pendingRequests.forEach(req =>
req.reject(new Error("Token refresh failed"))
);
pendingRequests.length = 0;
await redirectToLogin("登录已失效");
})
.finally(() => {
isRefreshingToken = false;
});
}
});
}
七、为什么要提前初始化刷新函数?
在创建 Axios 函数中要提前初始化刷新函数
const { refreshTokenAndRetry } = useTokenRefresh();
原因
-
利用 闭包 保存刷新状态
-
确保所有请求共享:
isRefreshingTokenpendingRequests
-
防止重复刷新
完整代码示例
import type { InternalAxiosRequestConfig } from "axios";
import { useUserStoreHook } from "@/store/modules/user.store";
import { AuthStorage, redirectToLogin } from "@/utils/auth";
/**
* 等待请求的类型接口
*/
type PendingRequest = {
resolve: (_value: any) => void;
reject: (_reason?: any) => void;
retryRequest: () => void;
};
/**
* Token刷新组合式函数
*/
export function useTokenRefresh() {
// Token 刷新相关状态s
let isRefreshingToken = false;
const pendingRequests: PendingRequest[] = [];
/**
* 刷新 Token 并重试请求
*/
async function refreshTokenAndRetry(
config: InternalAxiosRequestConfig,
httpRequest: any
): Promise<any> {
return new Promise((resolve, reject) => {
// 封装需要重试的请求
const retryRequest = () => {
const newToken = AuthStorage.getAccessToken();
if (newToken && config.headers) {
config.headers.Authorization = `Bearer ${newToken}`;
}
httpRequest(config).then(resolve).catch(reject);
};
// 将请求加入等待队列
pendingRequests.push({ resolve, reject, retryRequest });
// 如果没有正在刷新,则开始刷新流程
if (!isRefreshingToken) {
isRefreshingToken = true;
useUserStoreHook()
.refreshToken()
.then(() => {
// 刷新成功,重试所有等待的请求
pendingRequests.forEach((request) => {
try {
request.retryRequest();
} catch (error) {
console.error("Retry request error:", error);
request.reject(error);
}
});
// 清空队列
pendingRequests.length = 0;
})
.catch(async (error) => {
console.error("Token refresh failed:", error);
// 刷新失败,先 reject 所有等待的请求,再清空队列
const failedRequests = [...pendingRequests];
pendingRequests.length = 0;
// 拒绝所有等待的请求
failedRequests.forEach((request) => {
request.reject(new Error("Token refresh failed"));
});
// 跳转登录页
await redirectToLogin("登录状态已失效,请重新登录");
})
.finally(() => {
isRefreshingToken = false;
});
}
});
}
return {
refreshTokenAndRetry,
};
}
吃透 JS 事件委托:从原理到实战,解锁高性能事件处理方案
React 表单的控制欲:什么时候我们真得控制它了,什么时候该放养了?
在写 React 代码时,你有没有过这样的困惑:
我只是想让用户输入一段文字,然后提交。
为什么非要搞个useState来管理它?
用ref直接读取 DOM 值不行吗?
如果你也这样想过,那你不是一个人。
今天我们就来聊一聊这个看似简单、实则深藏玄机的问题:React 中的受控组件和非受控组件,到底该选谁?
一、问题的起点:我该如何获取表单值?
最原始的需求是:用户输入内容 → 点击提交 → 获取输入内容。
这听起来很简单,但实现方式却有两条路:
-
方案A:给
<input>加一个value属性,并通过onChange更新状态。 -
方案B:不设置
value,只用ref在提交时读取 DOM 的.value。
我们先看一段代码:
const [value, setValue] = useState('');
const inputRef = useRef(null);
const doLogin = (e) => {
e.preventDefault();
console.log(inputRef.current.value); // 从 DOM 读值
};
return (
<form onSubmit={doLogin}>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
<input type="text" ref={inputRef} />
<button type="submit">登录</button>
</form>
);
这段代码里有两个输入框:
- 第一个是受控的(
value={value}) - 第二个是非受控的(
ref={inputRef})
当你点击“登录”,你会看到两个值都被打印出来 —— 但它们的来源完全不同。
二、思考:为什么需要“控制”?
我们先问自己一个问题:
如果我不用
state控制输入框,而是直接读 DOM,是不是更省事?
看起来是的。代码少,逻辑清晰,还能避免状态同步的问题。
但这里有一个关键的认知偏差:我们以为“读取值”只需要一次操作,但实际上,用户的交互是一个持续的过程。
🌪️ 想象这样一个场景:
你在做一个登录表单,要求密码至少 6 位,且包含数字。
如果使用非受控组件,你只能在提交时判断是否符合规则。
但如果用户输入了 abc123,系统提示“太短了”,然后他继续打 456,变成 abc123456,这时候你才发现合格。
问题是:你怎么知道他在中间改了多少次?怎么实时反馈?
而受控组件可以做到这一点:
<input
type="password"
value={form.password}
onChange={(e) => {
const pwd = e.target.value;
setForm({ ...form, password: pwd });
if (pwd.length >= 6 && /\d/.test(pwd)) {
setValid(true);
} else {
setValid(false);
}
}}
/>
这就是“控制”的价值:把数据流从“被动响应”变成“主动驱动”。
三、再深入一步:什么是“受控”?
很多人误以为“受控”就是“加了个 value”,其实不然。
真正的“受控”是一种设计理念:
所有的 UI 状态都由 React 的 state 驱动,而不是由 DOM 自行决定。
这意味着:
- 输入框的值来自
state - 用户输入触发事件,更新
state - 页面重新渲染,显示新的值
这是一个闭环,形成了单向数据流。
这种模式的好处在于:
- 数据可预测(不会出现“页面显示 A,实际是 B”的问题)
- 可以在任意时刻进行校验、重置、保存
- 更容易测试和调试
而 ref 虽然能拿到值,但它绕过了 React 的状态系统,属于“黑箱操作”。
四、那什么时候该“放手”?
既然受控这么好,为什么还要有非受控组件?
因为有些场景,我们并不需要“控制”。
比如评论框:
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if (!comment) {
alert('请输入评论');
return;
}
console.log(comment);
};
在这个例子中:
- 用户输入完就提交
- 不需要实时校验
- 不需要联动其他字段
- 也不需要预览或自动补全
这时候,用 ref 是一种轻量级的选择。
而且,在某些性能敏感的场景下,频繁触发 setState 会影响性能。例如文件上传、富文本编辑器等,这些组件内部有自己的状态管理机制,强行用 React 控制反而会增加复杂度。
五、结论:不是选择题,而是权衡题
回到最初的问题:我应该用受控还是非受控?
答案不是“哪个更好”,而是:
根据业务需求做权衡。
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 登录/注册 | 受控组件 | 需要校验、联动、错误提示 |
| 评论/留言 | 非受控组件 | 一次性提交,无需实时处理 |
| 文件上传 | 非受控组件 | DOM 内部状态复杂,不适合 React 管理 |
| 实时搜索 | 受控组件 | 需要即时反馈结果 |
| 富文本编辑器 | 非受控组件 | 使用第三方库,内部状态独立 |
六、最后的思考:React 的本质是什么?
React 的核心思想是:UI 是状态的函数。
也就是说,页面长什么样,完全取决于当前的状态。
当你使用受控组件时,你是在践行这一理念:每一个变化,都是状态驱动的结果。
而当你使用非受控组件时,你实际上是在说:“这个部分我暂时不想管,让它自己玩。”
这不是坏事,但你要清楚地知道:你在放弃一部分控制权。
所以,不要为了“简洁”而滥用非受控组件,也不要为了“规范”而过度使用受控组件。
我们应该要在“控制”与“放手”之间找到平衡点。
写在最后
技术没有绝对的对错,只有合适的时机。
下次面对一个表单时,可以先想想
“我需要在用户输入的过程中做什么?”
如果答案是“什么也不做”,那就放手吧。(useRef)
如果答案是“我要校验、联动、展示”,那就牢牢抓住它。(useState)
这才是 React 表单设计的真正智慧。