普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月3日掘金 前端

Vue3 应用实例创建及页面渲染底层原理

2026年1月2日 23:13

整体流程

完整的创建与渲染流程可以分成这些阶段:

  1. 创建 App 实例
  2. 创建根组件实例
  3. 设置响应式状态
  4. 创建渲染器(Renderer)
  5. 挂载 Mount
  6. vnode -> DOM 渲染
  7. 数据变更触发更新
  8. 重新渲染 / 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)

核心逻辑:

  1. props diff
  2. children diff
  3. unkeyed/keyed diff 算法(最小化移动)

具体见 patchChildrenpatchKeyedChildren

整体核心对象关系架构

App
 └─ vnode(root)
     └─ ComponentInstance
         ├─ props / slots
         ├─ setupState
         └─ render() -> subTree
             └─ vnode tree
                 └─ DOM nodes

响应式依赖结构:

reactive state
 ├─ effects[]
 └─ track -> effect
              └─ scheduler -> patch

赫蹏(hètí):为中文网页内容赋予优雅排版的开源利器

作者 修己xj
2026年1月2日 23:42

fHJ9cZeOp.jpg

fHJ9cZeOp.jpg

在当今信息爆炸的时代,内容呈现的形式往往决定了阅读体验的优劣。对于中文网站来说,一个长期存在的挑战是如何实现符合传统中文排版美学的网页展示。尽管现代CSS技术已经十分强大,但针对中文特点的排版优化仍然不够完善。今天,我们将介绍一个专门为解决这一问题而生的开源项目——赫蹏(hètí)。

什么是赫蹏?

赫蹏是一个专为中文内容展示设计的排版样式增强库,名称取自古代对纸张的雅称。这个项目由开发者Sivan创建,基于通行的中文排版规范,旨在为网站的读者提供更加舒适、专业的文章阅读体验。

简单来说,赫蹏让中文网页内容“自动变好看”

该项目在github 已有6.6k star

_20260102_222650.png

_20260102_222650.png

_20260102_223923.png

_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

_20260102_224539.png

我使用ai 基于赫蹏(hètí)做了一个竖版排版的网站,感兴趣的家人们也可以使用下

博主竖版排版网址: h5.xiuji.mynatapp.cc/heti/

_20260102_232901.png

_20260102_232901.png

_.png

_.png

结语:让内容回归本质

在追求炫酷交互和复杂动画的今天,赫蹏提醒我们一个基本事实:对于内容型网站,优秀的可读性才是最重要的用户体验。这个项目以极低的成本,为中文网页带来了显著的品质提升。

如果你正在经营一个以中文内容为主的网站,或者只是想在个人博客上获得更好的排版效果,不妨尝试一下赫蹏。正如项目README中所说的:“总之,用上就会变好看。”

赫蹏不仅是一个工具,更是对中文数字阅读体验的一次优雅致敬。在这个注意力稀缺的时代,为读者提供一个舒适的阅读环境,或许就是最好的内容策略。

昨天 — 2026年1月2日掘金 前端

面试官 : “ Vue 选项式api 和 组合式api 什么区别? “

作者 千寻girling
2026年1月2日 18:12

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?

2026年1月2日 17:50

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?

  1. 需要上传进度(onUploadProgress)且不想回退 xhr;
  2. 需要请求/响应拦截链 >3 层,且团队对“黑盒”可接受;
  3. 需要兼容 IE11(2026 年政务/银行仍存);
  4. 需要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样式可能不能及时生效

作者 tjswk2008
2026年1月2日 17:39

这是一个在 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。它不仅性能更好,还能彻底规避此类由于动态插入导致的伪类失效问题。

CSS 写 SQL 查询?后端慌了!

作者 小小荧
2026年1月2日 17:09

CSS 写 SQL 查询?后端慌了!

cover_image

初次接触到这个项目时,我的第一反应只有四个字

这也行?

最近在 X 上大火的一个叫 TailwindSQL 的项目,引发了广泛讨论。

其核心玩法非常简单——通过 CSS 的 className 来实现 SQL 查询功能。

前端发展到这个地步了吗?

让我们先看一个示例:

<DB className="db-users-name-where-id-1" />

如果你是前端开发者,可能会下意识地认为这是在定义样式

但如果你是后端开发者,估计已经开始皱眉了。

然而实际上,这段代码执行的是:

SELECT name FROM users WHERE id = 1;

看到这里,我确实愣了一下。

TailwindSQL 的本质

简而言之,它将 SQL 语句拆解为一个个「类名」片段。

这种做法类似于 TailwindCSSCSS 的处理方式:

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 请求,提升用户体验

2026年1月2日 16:16

在现代 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% 以上。

四、为什么防抖能解决性能问题?

  1. 减少无效请求
    用户输入过程中产生的中间状态(如“j”、“ja”)通常无需处理,防抖直接忽略它们。
  2. 避免竞态条件(Race Condition)
    后发的请求覆盖先发的结果,确保 UI 始终显示最新、最完整的查询结果。
  3. 降低服务器压力
    假设每天有 10 万用户使用搜索,平均每人输入 10 次,不防抖产生 100 万请求;防抖后可能仅 10 万请求,节省 90% 计算资源。
  4. 提升前端性能
    减少 JavaScript 执行、DOM 更新和网络回调的频率,主线程更“轻盈”,页面更流畅。

五、防抖的适用场景

表格

场景 说明
搜索框建议 用户输入时延迟请求,等输入稳定后再查
窗口 resize 防止调整窗口大小时频繁触发布局计算
表单提交 防止用户狂点“提交”按钮导致重复提交
按钮点击 如“点赞”功能,避免快速连点

⚠️ 注意:滚动加载(scroll)更适合用节流(Throttle) ,因为用户持续滚动时仍需定期触发(如每 200ms 检查是否到底部),而防抖会在滚动结束才触发,可能错过加载时机。


六、总结:防抖是前端性能优化的基石

通过本文的对比实验,我们可以清晰看到:不加控制的事件监听是性能杀手,而防抖则是优雅的“减速阀” 。它利用闭包保存状态,通过定时器智能合并高频操作,在不牺牲用户体验的前提下,大幅降低系统开销。

在实际项目中,建议:

  • 对 inputkeyupresize 等高频事件默认使用防抖或节流
  • 使用成熟的工具库(如 Lodash 的 _.debounce)避免手写 bug
  • 根据业务调整延迟时间(搜索建议常用 300–500ms)

记住:好的前端工程师,不仅要让功能跑起来,更要让它跑得稳、跑得快、跑得省。  而防抖,正是你工具箱中不可或缺的一把利器。

🌟 小提示:下次当你看到百度搜索框在你打字时不急不躁、等你停手才给出建议时,就知道——背后一定有防抖在默默守护性能!

cloudflare使用express实现api防止跨域cors

作者 1024小神
2026年1月2日 15:49

大家好,我是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();
}

然后配置中间件在所有的路由前面:

然后重启项目,再次发送请求就没事了:

如果你有好的想法或需求,都可以私信我,我这里有很多程序员朋友喜欢用代码来创造丰富多彩的计算机世界

从定时器管理出发,彻底搞懂防抖与节流的实现逻辑

作者 烟袅破辰
2026年1月2日 15:01

在前端开发中,高频事件(如输入、滚动、窗口缩放)若不加控制,极易引发性能问题。为应对这一挑战,防抖(debounce)节流(throttle) 成为必备工具。


一、防抖:每次触发都重置定时器

假设我们要实现一个功能:用户在输入框打字时,只有当他停止输入超过 1 秒,才发送请求。

第一步:我需要延迟执行

显然,要用 setTimeout

setTimeout(() => {
    ajax(value);
}, 1000);

第二步:但如果用户继续输入,之前的请求就不该发

→ 所以必须取消之前的定时器,再建一个新的。

这就要求我们保存定时器 ID

let timerId;
// 每次触发时:
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
    ajax(value);
}, 1000);

第三步:处理 this 和参数

因为 ajax 可能依赖上下文或多个参数,不能直接写死。我们需要在触发时捕获当前的 thisarguments

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 自动续期方案

2026年1月2日 15:00

一、背景

在前后端分离的项目中,前端通常通过 accessToken 来访问业务接口。 但由于 accessToken 有有效期限制,过期后的处理方式,直接影响系统的安全性用户体验

本文将介绍一种 基于 Axios 响应拦截器 + 请求队列 的 Token 自动刷新方案,适用于实际生产环境。

二、整体设计思路

核心目标只有三个:

  1. accessToken 过期时自动刷新
  2. refreshToken 只请求一次
  3. 刷新完成后自动重试过期前的请求

整体流程如下:

token_FlowChart.png

三、基于业务 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();

原因

  • 利用 闭包 保存刷新状态

  • 确保所有请求共享:

    • isRefreshingToken
    • pendingRequests
  • 防止重复刷新

完整代码示例

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,
  };
}

React 表单的控制欲:什么时候我们真得控制它了,什么时候该放养了?

作者 烟袅破辰
2026年1月2日 14:56

在写 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 表单设计的真正智慧。

❌
❌