普通视图

发现新文章,点击刷新页面。
今天 — 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。它不仅性能更好,还能彻底规避此类由于动态插入导致的伪类失效问题。

Vue 组件通信的 8 种最佳实践,你知道几种?

作者 刘大华
2026年1月2日 17:11

经常写 Vue 的朋友应该很熟悉,在 Vue 的应用中,组件化开发可以让我们的代码更容易维护,而组件之间的数据传递事件通信也是我们必须要解决的问题。

经过多个项目的实践,我逐渐摸清了Vue3中8种组件通信方式和适用场景。

下面来给大家分享一下。

1. Props / Emits:最基础的父子传值

这是 Vue 的官方推荐通信方式,遵循单向数据流原则,数据只能从上往下流,事件从下往上传。

Props:父传子的单向数据流

适用场景:当你需要把配置、用户信息、状态等数据从父组件传递给子组件时。

<!-- 父组件 Parent.vue -->
<template>
  <div class="parent">
    <h2>父组件</h2>
    <!-- 传递静态和动态数据 -->
    <ChildComponent 
      title="用户信息" 
      :user="userData"
      :count="clickCount"
    />
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import ChildComponent from './ChildComponent.vue'

const userData = reactive({
  name: '张三',
  age: 25,
  email: 'zhangsan@example.com'
})

const clickCount = ref(0)
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
  <div class="child">
    <h3>{{ title }}</h3>
    <div class="user-card">
      <p>姓名:{{ user.name }}</p>
      <p>年龄:{{ user.age }}</p>
      <p>邮箱:{{ user.email }}</p>
    </div>
    <p>点击次数:{{ count }}</p>
  </div>
</template>

<script setup>
// 方式1:简单定义
// defineProps(['title', 'user', 'count'])

// 方式2:带类型验证(推荐)
defineProps({
  title: {
    type: String,
    required: true
  },
  user: {
    type: Object,
    default: () => ({})
  },
  count: {
    type: Number,
    default: 0
  }
})

// 方式3:使用 TypeScript(最佳实践)
interface Props {
  title: string
  user: {
    name: string
    age: number
    email: string
  }
  count?: number
}

defineProps<Props>()
</script>

为什么推荐带验证?

它能提前发现传参错误,比如把字符串传给了 count,Vue 会在控制台报错,避免线上bug。


Emits:子传父的事件机制

适用场景:子组件需要通知父组件有事发生,比如表单提交、按钮点击、输入变化等。

<!-- 子组件 ChildComponent.vue -->
<template>
  <div class="child">
    <button @click="handleButtonClick">通知父组件</button>
    <input 
      :value="inputValue" 
      @input="handleInputChange"
      placeholder="输入内容..."
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 定义可触发的事件
const emit = defineEmits(['button-clicked', 'input-changed', 'update:modelValue'])

const inputValue = ref('')

const handleButtonClick = () => {
  // 触发事件并传递数据
  emit('button-clicked', {
    message: '按钮被点击了!',
    timestamp: new Date().toISOString()
  })
}

const handleInputChange = (event) => {
  inputValue.value = event.target.value
  emit('input-changed', inputValue.value)
  
  // 支持 v-model 的更新方式
  emit('update:modelValue', inputValue.value)
}
</script>
<!-- 父组件 Parent.vue -->
<template>
  <div class="parent">
    <ChildComponent 
      @button-clicked="handleChildButtonClick"
      @input-changed="handleChildInputChange"
    />
    
    <div v-if="lastEvent">
      <p>最后收到的事件:{{ lastEvent.type }}</p>
      <p>数据:{{ lastEvent.data }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const lastEvent = ref(null)

const handleChildButtonClick = (data) => {
  lastEvent.value = {
    type: 'button-clicked',
    data: data
  }
  console.log('收到子组件消息:', data)
}

const handleChildInputChange = (value) => {
  lastEvent.value = {
    type: 'input-changed',
    data: value
  }
  console.log('输入内容:', value)
}
</script>

关键点:

  • 子组件不直接修改父组件数据,而是发出请求,由父组件决定如何处理。
  • 这种解耦设计让组件更可复用、更易测试。

2. v-model:双向绑定的语法糖

v-model 在 Vue3 中变得更加强大,支持多个 v-model 绑定。

基础用法

<!-- 父组件 -->
<template>
  <div>
    <CustomInput v-model="username" />
    <p>当前用户名:{{ username }}</p>
    
    <!-- 多个 v-model -->
    <UserForm
      v-model:name="userName"
      v-model:email="userEmail"
      v-model:age="userAge"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'

const username = ref('')
const userName = ref('')
const userEmail = ref('')
const userAge = ref(0)
</script>
<!-- 子组件 CustomInput.vue -->
<template>
  <div class="custom-input">
    <label>用户名:</label>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="input-field"
    />
  </div>
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
<!-- 子组件 UserForm.vue -->
<template>
  <div class="user-form">
    <div class="form-group">
      <label>姓名:</label>
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </div>
    <div class="form-group">
      <label>邮箱:</label>
      <input
        :value="email"
        @input="$emit('update:email', $event.target.value)"
        type="email"
      />
    </div>
    <div class="form-group">
      <label>年龄:</label>
      <input
        :value="age"
        @input="$emit('update:age', parseInt($event.target.value) || 0)"
        type="number"
      />
    </div>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  email: String,
  age: Number
})

defineEmits(['update:name', 'update:email', 'update:age'])
</script>

v-model的核心优势:

  • 语法简洁,减少样板代码
  • 符合双向绑定的直觉
  • 支持多个v-model绑定
  • 类型安全(配合TypeScript)

适用场景:自定义表单控件(如日期选择器、富文本编辑器)需要双向绑定。


3. Ref / 模板引用:直接操作组件

当需要直接访问子组件或 DOM 元素时,模板引用是最佳选择。

<!-- 父组件 -->
<template>
  <div class="parent">
    <ChildComponent ref="childRef" />
    <CustomForm ref="formRef" />
    <video ref="videoRef" controls>
      <source src="./movie.mp4" type="video/mp4">
    </video>
    
    <div class="controls">
      <button @click="focusInput">聚焦输入框</button>
      <button @click="getChildData">获取子组件数据</button>
      <button @click="playVideo">播放视频</button>
      <button @click="validateForm">验证表单</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue'

// 创建引用
const childRef = ref(null)
const formRef = ref(null)
const videoRef = ref(null)

// 确保 DOM 更新后访问
const focusInput = async () => {
  await nextTick()
  childRef.value?.focusInput()
}

const getChildData = () => {
  if (childRef.value) {
    const data = childRef.value.getData()
    console.log('子组件数据:', data)
  }
}

const playVideo = () => {
  videoRef.value?.play()
}

const validateForm = () => {
  formRef.value?.validate()
}

// 组件挂载后访问
onMounted(() => {
  console.log('子组件实例:', childRef.value)
})
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
  <div class="child">
    <input ref="inputEl" type="text" placeholder="请输入..." />
    <p>内部数据:{{ internalData }}</p>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const inputEl = ref(null)
const internalData = ref('这是内部数据')

// 暴露给父组件的方法和数据
defineExpose({
  focusInput: () => {
    inputEl.value?.focus()
  },
  getData: () => {
    return {
      internalData: internalData.value,
      timestamp: new Date().toISOString()
    }
  },
  internalData
})
</script>

适用场景:需要调用子组件方法(如弹窗打开)、聚焦输入框、操作原生元素(如 video 播放)。

4. Provide / Inject:跨层级数据传递

解决"prop 逐级传递"问题,实现祖先与后代组件的直接通信。

<!-- 根组件 App.vue -->
<template>
  <div id="app">
    <Header />
    <div class="main-content">
      <Sidebar />
      <ContentArea />
    </div>
    <Footer />
  </div>
</template>

<script setup>
import { provide, ref, reactive, computed } from 'vue'

// 提供用户信息
const currentUser = ref({
  id: 1,
  name: '张三',
  role: 'admin',
  permissions: ['read', 'write', 'delete']
})

// 提供应用配置
const appConfig = reactive({
  theme: 'dark',
  language: 'zh-CN',
  apiBaseUrl: import.meta.env.VITE_API_URL
})

// 提供方法
const updateUser = (newUserData) => {
  currentUser.value = { ...currentUser.value, ...newUserData }
}

const updateConfig = (key, value) => {
  appConfig[key] = value
}

// 计算属性
const userPermissions = computed(() => currentUser.value.permissions)

// 提供数据和方法
provide('currentUser', currentUser)
provide('appConfig', appConfig)
provide('updateUser', updateUser)
provide('updateConfig', updateConfig)
provide('userPermissions', userPermissions)
</script>
<!-- 深层嵌套的组件 ContentArea.vue -->
<template>
  <div class="content-area">
    <UserProfile />
    <ArticleList />
  </div>
</template>

<script setup>
// 这个组件不需要处理 props,直接渲染子组件
</script>
<!-- 使用注入的组件 UserProfile.vue -->
<template>
  <div class="user-profile">
    <h3>用户信息</h3>
    <div class="profile-card">
      <p>姓名:{{ currentUser.name }}</p>
      <p>角色:{{ currentUser.role }}</p>
      <p>权限:{{ userPermissions.join(', ') }}</p>
      <p>主题:{{ appConfig.theme }}</p>
    </div>
    <button @click="handleUpdateProfile">更新资料</button>
  </div>
</template>

<script setup>
import { inject } from 'vue'

// 注入数据和方法
const currentUser = inject('currentUser')
const appConfig = inject('appConfig')
const userPermissions = inject('userPermissions')
const updateUser = inject('updateUser')

const handleUpdateProfile = () => {
  updateUser({
    name: '李四',
    role: 'user'
  })
}
</script>

Provide/Inject的优势

  • 避免Props逐层传递的繁琐
  • 实现跨层级组件通信
  • 提供全局状态和方法的统一管理
  • 提高代码的可维护性

适用场景:当数据需要从顶层组件传递到底层组件,中间隔了好几层(比如主题、用户信息、语言设置)。


5. Pinia:现代化状态管理

对于复杂应用,Pinia 提供了更优秀的状态管理方案。

创建 Store

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    isLoggedIn: false,
    token: '',
    permissions: []
  }),
  
  getters: {
    userName: (state) => state.user?.name || '未登录用户',
    isAdmin: (state) => state.user?.role === 'admin',
    hasPermission: (state) => (permission) => 
      state.permissions.includes(permission)
  },
  
  actions: {
    async login(credentials) {
      try {
        // 模拟 API 调用
        const response = await mockLoginApi(credentials)
        
        this.user = response.user
        this.token = response.token
        this.isLoggedIn = true
        this.permissions = response.permissions
        
        // 保存到 localStorage
        localStorage.setItem('token', this.token)
        
        return { success: true }
      } catch (error) {
        console.error('登录失败:', error)
        return { success: false, error: error.message }
      }
    },
    
    logout() {
      this.user = null
      this.token = ''
      this.isLoggedIn = false
      this.permissions = []
      
      localStorage.removeItem('token')
    },
    
    async updateProfile(userData) {
      if (!this.isLoggedIn) {
        throw new Error('请先登录')
      }
      
      this.user = { ...this.user, ...userData }
      // 这里可以调用 API 更新后端数据
    }
  }
})

// 模拟登录 API
const mockLoginApi = (credentials) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        user: {
          id: 1,
          name: credentials.username,
          role: 'admin'
        },
        token: 'mock-jwt-token',
        permissions: ['read', 'write', 'delete']
      })
    }, 1000)
  })
}

在组件中使用 Store

<!-- UserProfile.vue -->
<template>
  <div class="user-profile">
    <div v-if="userStore.isLoggedIn" class="logged-in">
      <h3>欢迎回来,{{ userStore.userName }}!</h3>
      <div class="user-info">
        <p>角色:{{ userStore.user.role }}</p>
        <p>权限:{{ userStore.permissions.join(', ') }}</p>
      </div>
      
      <div class="actions">
        <button 
          @click="updateName" 
          :disabled="!userStore.hasPermission('write')"
        >
          更新姓名
        </button>
        <button @click="userStore.logout" class="logout-btn">
          退出登录
        </button>
      </div>
    </div>
    
    <div v-else class="logged-out">
      <LoginForm />
    </div>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'
import LoginForm from './LoginForm.vue'

const userStore = useUserStore()

const updateName = () => {
  userStore.updateProfile({
    name: `用户${Math.random().toString(36).substr(2, 5)}`
  })
}
</script>
<!-- LoginForm.vue -->
<template>
  <div class="login-form">
    <h3>用户登录</h3>
    <form @submit.prevent="handleLogin">
      <div class="form-group">
        <input 
          v-model="credentials.username" 
          placeholder="用户名"
          required
        />
      </div>
      <div class="form-group">
        <input 
          v-model="credentials.password" 
          type="password" 
          placeholder="密码"
          required
        />
      </div>
      <button type="submit" :disabled="loading">
        {{ loading ? '登录中...' : '登录' }}
      </button>
    </form>
    
    <div v-if="message" class="message" :class="messageType">
      {{ message }}
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

const credentials = reactive({
  username: '',
  password: ''
})

const loading = ref(false)
const message = ref('')
const messageType = ref('')

const handleLogin = async () => {
  loading.value = true
  message.value = ''
  
  const result = await userStore.login(credentials)
  
  if (result.success) {
    message.value = '登录成功!'
    messageType.value = 'success'
  } else {
    message.value = `登录失败:${result.error}`
    messageType.value = 'error'
  }
  
  loading.value = false
}
</script>

Pinia 优势:

  • 无 mutations,直接修改 state
  • 完美支持 TypeScript
  • DevTools 调试友好
  • 模块化设计,易于拆分

适用场景:中大型应用,多个组件需要共享复杂状态(如用户登录态、购物车、全局配置)。

6. 事件总线:轻量级全局通信

Vue3 移除了实例上的 onon、off 方法,不再支持这种模式,但我们可以使用 mitt 库实现。

// utils/eventBus.js
import mitt from 'mitt'

// 创建全局事件总线
const eventBus = mitt()

// 定义事件类型
export const EVENTS = {
  USER_LOGIN: 'user:login',
  USER_LOGOUT: 'user:logout',
  NOTIFICATION_SHOW: 'notification:show',
  MODAL_OPEN: 'modal:open',
  THEME_CHANGE: 'theme:change'
}

export default eventBus
<!-- 发布事件的组件 -->
<template>
  <div class="publisher">
    <h3>事件发布者</h3>
    <div class="buttons">
      <button @click="sendNotification">发送通知</button>
      <button @click="openModal">打开模态框</button>
      <button @click="changeTheme">切换主题</button>
    </div>
  </div>
</template>

<script setup>
import eventBus, { EVENTS } from '@/utils/eventBus'

const sendNotification = () => {
  eventBus.emit(EVENTS.NOTIFICATION_SHOW, {
    type: 'success',
    title: '操作成功',
    message: '这是一个来自事件总线的通知',
    duration: 3000
  })
}

const openModal = () => {
  eventBus.emit(EVENTS.MODAL_OPEN, {
    component: 'UserForm',
    props: { userId: 123 },
    title: '用户表单'
  })
}

const changeTheme = () => {
  const themes = ['light', 'dark', 'blue']
  const randomTheme = themes[Math.floor(Math.random() * themes.length)]
  
  eventBus.emit(EVENTS.THEME_CHANGE, {
    theme: randomTheme,
    timestamp: new Date().toISOString()
  })
}
</script>
<!-- 监听事件的组件 -->
<template>
  <div class="listener">
    <h3>事件监听者</h3>
    <div class="events-log">
      <div 
        v-for="(event, index) in events" 
        :key="index"
        class="event-item"
      >
        <strong>{{ event.type }}</strong>
        <span>{{ event.data }}</span>
        <small>{{ event.timestamp }}</small>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import eventBus, { EVENTS } from '@/utils/eventBus'

const events = ref([])

// 事件处理函数
const handleNotification = (data) => {
  events.value.unshift({
    type: EVENTS.NOTIFICATION_SHOW,
    data: `通知: ${data.title} - ${data.message}`,
    timestamp: new Date().toLocaleTimeString()
  })
}

const handleModalOpen = (data) => {
  events.value.unshift({
    type: EVENTS.MODAL_OPEN,
    data: `打开模态框: ${data.component}`,
    timestamp: new Date().toLocaleTimeString()
  })
}

const handleThemeChange = (data) => {
  events.value.unshift({
    type: EVENTS.THEME_CHANGE,
    data: `主题切换为: ${data.theme}`,
    timestamp: new Date().toLocaleTimeString()
  })
}

// 注册事件监听
onMounted(() => {
  eventBus.on(EVENTS.NOTIFICATION_SHOW, handleNotification)
  eventBus.on(EVENTS.MODAL_OPEN, handleModalOpen)
  eventBus.on(EVENTS.THEME_CHANGE, handleThemeChange)
})

// 组件卸载时移除监听
onUnmounted(() => {
  eventBus.off(EVENTS.NOTIFICATION_SHOW, handleNotification)
  eventBus.off(EVENTS.MODAL_OPEN, handleModalOpen)
  eventBus.off(EVENTS.THEME_CHANGE, handleThemeChange)
})
</script>

不太推荐使用。为什么?

  • 数据流向不透明,难以追踪
  • 容易忘记 off 导致内存泄漏
  • 大型项目维护困难
  • 建议:优先用 Pinia 或 provide/inject

适用场景:小型项目中,两个无关联组件需要临时通信(如通知弹窗、模态框控制)。


7. 属性透传($attrs)和边界处理

当你封装一个组件,并希望把未声明的属性自动传递给内部元素时,就用 $attrs。

<!-- 基础组件 BaseButton.vue -->
<template>
  <button 
    v-bind="filteredAttrs"
    class="base-button"
    @click="handleClick"
  >
    <slot></slot>
  </button>
</template>

<script setup>
import { computed, useAttrs } from 'vue'

const attrs = useAttrs()

// 过滤掉不需要透传的属性
const filteredAttrs = computed(() => {
  const { class: className, style, ...rest } = attrs
  return rest
})

const emit = defineEmits(['click'])

const handleClick = (event) => {
  emit('click', event)
}

// 也可以选择性地暴露 attrs
defineExpose({
  attrs
})
</script>
</style>
<!-- 使用基础组件 -->
<template>
  <div>
    <!-- 透传 class、style、data-* 等属性 -->
    <BaseButton
      class="custom-btn"
      style="color: red;"
      data-testid="submit-button"
      title="提交按钮"
      @click="handleSubmit"
    >
      提交表单
    </BaseButton>
    
    <!-- 多个按钮使用相同的基组件 -->
    <BaseButton
      class="secondary-btn"
      data-testid="cancel-button"
      @click="handleCancel"
    >
      取消
    </BaseButton>
  </div>
</template>

<script setup>
const handleSubmit = () => {
  console.log('提交表单')
}

const handleCancel = () => {
  console.log('取消操作')
}
</script>

<style>
.custom-btn {
  background: blue;
  color: white;
}

.secondary-btn {
  background: gray;
  color: white;
}
</style>

特性

  • 用户传的 class 和 style 会和组件内部的样式合并(Vue 自动处理)。
  • 所有 data-、title、aria- 等原生 HTML 属性都能正常生效。
  • 你不用提前知道用户会传什么,也能支持!

适用场景:封装通用组件(如按钮、输入框),希望保留原生 HTML 属性(class、style、data-* 等)。

8. 组合式函数:逻辑复用

对于复杂的通信逻辑,可以使用组合式函数封装。

// composables/useCommunication.js
import { ref, onUnmounted } from 'vue'

export function useCommunication() {
  const messages = ref([])
  const listeners = new Map()

  const sendMessage = (type, data) => {
    messages.value.unshift({
      type,
      data,
      timestamp: new Date().toISOString()
    })
    
    // 通知监听者
    if (listeners.has(type)) {
      listeners.get(type).forEach(callback => {
        callback(data)
      })
    }
  }

  const onMessage = (type, callback) => {
    if (!listeners.has(type)) {
      listeners.set(type, new Set())
    }
    listeners.get(type).add(callback)
  }

  const offMessage = (type, callback) => {
    if (listeners.has(type)) {
      listeners.get(type).delete(callback)
    }
  }

  // 清理函数
  const cleanup = () => {
    listeners.clear()
  }

  onUnmounted(cleanup)

  return {
    messages,
    sendMessage,
    onMessage,
    offMessage,
    cleanup
  }
}
<!-- 使用组合式函数 -->
<template>
  <div class="communication-demo">
    <div class="senders">
      <MessageSender />
      <EventSender />
    </div>
    <div class="receivers">
      <MessageReceiver />
      <EventReceiver />
    </div>
    <div class="message-log">
      <h4>消息日志</h4>
      <div 
        v-for="(msg, index) in messages" 
        :key="index"
        class="log-entry"
      >
        [{{ formatTime(msg.timestamp) }}] {{ msg.type }}: {{ msg.data }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { useCommunication } from '@/composables/useCommunication'
import MessageSender from './MessageSender.vue'
import MessageReceiver from './MessageReceiver.vue'
import EventSender from './EventSender.vue'
import EventReceiver from './EventReceiver.vue'

const { messages } = useCommunication()

const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}
</script>

优势

  • 逻辑高度复用
  • 类型安全(配合 TS)
  • 易于单元测试

适用场景:将复杂的通信逻辑抽象成可复用的函数,比如 WebSocket 连接、本地存储同步等。


避坑指南

1. Props 设计原则

// 好的 Props 设计
defineProps({
  // 必需属性
  title: { type: String, required: true },
  
  // 可选属性带默认值
  size: { type: String, default: 'medium' },
  
  // 复杂对象
  user: { 
    type: Object, 
    default: () => ({ name: '', age: 0 }) 
  },
  
  // 验证函数
  count: {
    type: Number,
    validator: (value) => value >= 0 && value <= 100
  }
})

2. 事件命名规范

// 使用 kebab-case 事件名
defineEmits(['update:title', 'search-change', 'form-submit'])

// 避免使用驼峰命名
// defineEmits(['updateTitle']) // 不推荐

3. Provide/Inject 的响应性

// 保持响应性
const data = ref({})
provide('data', readonly(data))

// 提供修改方法
const updateData = (newData) => {
  data.value = { ...data.value, ...newData }
}
provide('updateData', updateData)

4. 内存泄漏预防

// 及时清理事件监听
onUnmounted(() => {
  eventBus.off('some-event', handler)
})

// 清理定时器
const timer = setInterval(() => {}, 1000)
onUnmounted(() => clearInterval(timer))

总结

经过上面的详细讲解,相信大家对 Vue3 的组件通信有了更深入的理解。让我最后做个总结:

  • 核心原则:根据组件关系选择合适方案
  • 父子组件:优先使用 Props/Emits,简单直接
  • 表单控件:v-model是最佳选择,语法优雅
  • 深层嵌套:Provide/Inject 避免 prop 透传地狱
  • 全局状态:Pinia 专业强大,适合复杂应用
  • 临时通信:事件总线可用但需谨慎
  • 组件封装:属性透传提供更好用户体验
  • 逻辑复用:组合式函数提升代码质量

在实际开发中,可以这样:

  1. 先从 Props/Emits 开始,这是基础
  2. 熟练掌握 v-model 的表单处理
  3. 在需要时引入 Pinia,不要过度设计
  4. 保持代码的可读性和可维护性

简单的需求用简单的方案,复杂的需求才需要复杂的工具。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《重构了20个SpringBoot项目后,总结出这套稳定高效的架构设计》

《代码里全是 new 对象,真的很 Low 吗?我认真想了一晚》

《这 5 个冷门 HTML 标签,让我直接删了100 行 JS 代码》

《这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上》

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/

前端面试题整理(方便自己看的)

2026年1月2日 16:46

JavaScript题

1.JavaScript中的数据类型?

JavaScript中,分两种类型:

  • 基本类型
  • 引用类型

基本类型主要有以下6种:Number、String、Boolean、Undefined、null、symbol。 引用类型主要有Object、Array、Function。其它的有Date、RegExp、Map等

2.DOM

文档对象模型(DOM)HTMLXML文档的编程接口。 日常开发离不开DOM的操作,对节点的增删改查等操作。在以前,使用Jquery,zepto等库来操作DOM,之后在vue,Angular,React等框架出现后,通过操作数据来控制DOM(多数情况下),越来越少的直接去操作DOM

3.BOM

3.1 BOM是什么?

BOM(Browser Object Model),浏览器对象模型,提供了独立于内容与浏览器窗口进行交互的对象。其作用就是跟浏览器做一些交互效果,比如:进行页面的后退、前进、刷新、浏览器窗口发生变化,滚动条滚动等。

3.2 window

Bom的核心对象是window,它表示浏览器的一个实例。 在浏览器中,window即是浏览器窗口的一个接口,又是全局对象。

4 == 和 === 区别,分别在什么情况使用

image.png

等于操作符用俩个等于号(==)表示,如果操作数相等,则会返回true。 等于操作符(==)在比较中会先进行类型转换,再确定操作数是否相等。

全等操作符由3个等于号(===)表示,只有俩个操作数在不转换的前提下相等才返回true,即类型相同,值也相同。

区别:等于操作符(==)会做类型转换再进行值的比较,全等操作符不会做类型转换。 nullundefined 比较,相等操作符为true 全等为false

5 typeof 和 instanceof 的区别

typeof 操作符返回一个字符串,表示未经计算的操作数的类型。

instanceof 运算符用于检测构造函数的prototype属性是否出现在某个实例对象的原型链上。

区别:

  • typeof 会返回一个变量的基本类型,instanceof 返回的是一个Boolean.
  • instanceof 可以准确的判断复杂引用数据类型,但是不能正确判断基础数据类型。
  • 如果需要通用检测数据类型,可以通过Object.prototype.toString,调用该方法,统一返回格式 [object XXX]的字符串。

6 JavaScript 原型,原型链?有什么特点?

原型

JavaScript常被描述为一种基于原型的语言---每个对象拥有一个原型对象。访问一个对象的属性时,它不仅仅在该对象上搜寻,还会搜寻该对象的原型,以及该对象原型的原型,依次层层向上搜索,直到找到一个名字匹配的属性或达到原型链的末尾。

原型链

原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链,它解释了为何一个对象会拥有定义在其他对象中的属性和方法。

在对象实例和它的构造器之间建立一个链接(它是_proto_属性,是从构造函数的prototype属性派生的),之后通过上溯原型链,在构造器中找到这些属性和方法。

  • 一切对象都是继承自Object对象,object对象直接继承根源对象null
  • 一切的函数对象(包括object对象),都是继承自Function对象
  • Object 对象直接继承自 Function 对象
  • Function 对象的 _proto_ 会指向自己的原型对象,最终还是继承自 Object 对象

7.对作用域链的理解

作用域,即变量和函数生效的区域或集合,作用域决定了代码区块中变量和其他资源的可见性。

  • 全局作用域:任何不在函数中或是大括号中声明的变量,都是在全局作用域下,全局作用域下声明的变量可以在任意位置访问。
  • 函数作用域:函数作用域也叫局部作用域,如果一个变量是在函数内部声明的,它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问。
  • 块级作用域:ES6引入了letconst关键字,和var关键字不同,在大括号中使用letconst声明的变量存在于块级作用域中。在大括号外面不能访问这些变量。

作用域链

当在JavaScript中使用一个变量的时候,首先JavaScript引擎会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。

8. 谈谈对this对象的理解

8.1定义

函数的this关键字在JavaScript中的表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别。在绝大数情况下,函数的调用方式决定了this的值。 this关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象。

8.2 new绑定

通过构造函数new 关键字生成一个实例对象,此时this指向这个实例对象。

apply()、call()、bind()、是函数的一个方法,作用是改变函数的调用对象。它的第一个参数就表示改变后的调用这个函数的对象。因此,这时this指的就是这个第一个参数。

8.3 箭头函数

在ES6的语法中,提供了箭头函数法,让我们在代码书写时就能确定this的指向。

9.new操作符具体干了什么

  • 创建一个新的对象
  • 将对象与构建函数通过原型链链接起来
  • 将构建函数中的this绑定到新建的对象上
  • 根据构建函数返回类型做判断,如果原始值则被忽略,如果是返回对象,需要正常处理。

10.bind、call、apply区别?

bindcallapply、作用是改变函数执行时的上下文,改变函数运行时的this指向。

区别:

  • 三者都可以改变函数的this指向
  • 三者第一个参数都是this要指向的对象,如果没有这个参数或者参数为undefinednull,则默认指向全局window
  • 三者都可以传参,但是apply是数组,而call是参数列表,且applycall是一次性传入参数,而bind可以分多次传入
  • bind是返回绑定this之后的函数,applycall则是立即执行

11.闭包的理解?闭包使用场景?

11.1 闭包是什么?

一个函数和对其周围状态的引用捆绑在一起,这样的组合就是闭包。闭包让你可以在一个内层函数中访问到其外层函数的作用域。

11.2 闭包使用场景

  • 创建私有变量
  • 延长变量的生命周期

11.3 柯里化函数

柯里化的目的在于避免频繁调用具有相同参数函数的同时,又能够轻松的重用。

11.4 闭包的缺点

如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能有负面影响。

12.深拷贝浅拷贝的区别?实现一个深拷贝?

12.1 浅拷贝

Object.assignArray.prototype.slice()Array.prototype.concat()拓展运算符实现复制。

var obj = {
    name: 'xxx',
    age: 17
}
var newObj = Object.assign({}, obj);
const a = [1,2,3];
const b = a.slice(0);
b[1] = 4;
console.log(a, b);// [1,2,3] [1,4,3]
const a = [1,2,3];
const b = [...a];
b[1] = 4;
console.log(a, b);// [1,2,3] [1,4,3]

12.2 深拷贝

常见深拷贝方式:

  • _.cloneDeep()
  • jQuery.extend()
  • JSON.stringify()
  • 手写循环递归
const _ = require('lodash');
const obj1 = {
    a: 1,
    b: { f: { g: 1 } },
    c: [1, 2, 3]
};
const obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f); // false

JSON.stringify()

// 有缺点 会忽略undefined、symbol、函数
const obj2=JSON.parse(JSON.stringify(obj1));

循环递归

function deepClone(obj, hash = new WeakMap()) {
    if (obj === null) return obj; //null或者undefined就不拷贝
    if (obj instanceof Date) return new Date(obj);
    if (obj instanceof RegExp) return new RegExp(obj);
    // 可能是对象或者普通的值 如果是函数的话不拷贝
    if (typeof obj !== "object") return obj;
    // 是对象的话就要进行深拷贝
    if (hash.get(obj)) return hash.get(obj);
    let cloneObj = new obj.constructor();
    hash.set(obj, cloneObj);
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            // 实现一个递归拷贝
            cloneObj[key] = deepClone(obj[key], hash);
        }
    }
    return cloneObj;
}

12.3 区别

浅拷贝只复制属性指向某个对象的指针,而不复制对象本身,新旧对象还是共享一块内存,修改对象属性会影响原对象。

深拷贝会另外创建一个一模一样的对象,不共享内存,不影响原对象。

13. JavaScript字符串的常用方法

let stringValue = "hello world"; 
console.log(stringValue.slice(3, 7)); // "lo w"
console.log(stringValue.substring(3,7)); // "lo w"
console.log(stringValue.substr(3, 7)); // "lo worl"
// 删除前、后或者前后所有空格符,再返回新的字符串
let stringValue = " hello world ";
let trimmedStringValue = stringValue.trim();
console.log(stringValue); // " hello world "
console.log(trimmedStringValue); // "hello world"
// 接收一个整数参数,将字符串复制多少次,返回拼接所有副本后的结果
let stringValue = "na ";
let copyResult = stringValue.repeat(2) // na na
  • toUpperCase()、toLowerCase() 大小写转化
  • indexOf() 从字符串开头去搜索传入的字符串,并返回位置(没找到返回-1)
  • includes() 字符串是否包含传入的字符串
  • split() 把字符串按照指定分隔符,拆分成数组
  • replace() 接收俩个参数,第一个参数为匹配的内容,第二个参数为替换的元素

14.数组常用方法

  • push() 添加到数组末尾
  • unshift() 在数组开头添加
  • splice() 传入3个参数,开始位置、0(要删除的元素数量)、插入的元素
  • concat() 合并数组,返回一个新数组

  • pop() 删除数组最后一项,返回被删除的项。
  • shift() 删除数组的第一项,返回被删除的项。
  • splice()传入两个参数,开始位置,删除元素的数量,返回包含删除元素的数组。
  • slice() 用于创建一个包含原有数组中一个或多个元素的新数组,不会影响原始数组。

  • indexOf() 返回要查找元素在数组中的位置,如果没找到则返回 -1.
  • includes() 返回查找元素是否在数组中,有返回true,否则false.
  • find() 返回第一个匹配的元素。

排序方法

  • reverse() 将数组元素方向反转
  • sort() 接受一个比较函数,用于判断那个值在前面
function compare(value1, value2) {
    if (value1 < value2) {
        return -1;
    } else if (value1 > value2) {
        return 1;
    } else {
        return 0;
    }
}
let values = [0, 1, 5, 10, 15];
values.sort(compare);
alert(values); // 0,1,5,10,15

转换方法

join() 方法接收一个参数,即字符串分隔符,返回包含所有项的字符串。

循环方法

some() 和 every() 方法一样

对数组每一项都运行传入的测试函数,如果至少有一个元素返回true,则这个方法返回true.

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
let someResult = numbers.some((item, index, array) => item > 2);
console.log(someResult) // true
forEach()

对数组每一项都运行传入的函数,没有返回值

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
numbers.forEach((item, index, array) => {
    //执行操作
});
filter()

函数返回true 的项会组成数组之后返回。

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
let filterResult = numbers.filter((item, index, array) => item > 2);
console.log(filterResult); // 3,4,5,4,3
map()

返回由每次函数调用的结果构成的数组。

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
let mapResult = numbers.map((item, index, array) => item * 2);
console.log(mapResult) // 2,4,6,8,10,8,6,4,2

15.事件循环的理解?

事件循环

JavaScript是一门单线程的语言,实现单线程非阻塞的方法就是事件循环。 在JavaScript中,所有的任务都可以分为:

  • 同步任务:立即执行的任务,同步任务一般会直接进入到主线程执行。
  • 异步任务:异步的比如ajax网络请求,setTimeout定时函数等。

image.png

同步任务进入主线程,异步任务进入任务队列,主线程内的任务执行完毕为空,会去任务队列读取对应的任务,推入主线程执行。上述过程不断重复就是事件循环

宏任务与微任务
console.log(1)
setTimeout(()=>{
    console.log(2)
}, 0)
new Promise((resolve, reject)=>{
    console.log('new Promise')
    resolve()
}).then(()=>{
    console.log('then')
})
console.log(3)
  • 遇到 console.log(1),直接打印1
  • 遇到定时器,属于新的宏任务,留着后面执行
  • 遇到 new Promise,这个是直接执行的,打印'newPromise
  • .then 属于微任务,放入微任务队列,后面再执行
  • 遇到 console.log(3)直接打印 3
  • 好了本轮宏任务执行完毕,现在去微任务列表查看是否有微任务,发现.then 的回调,执行它打印'then'
  • 当一次宏任务执行完,再去执行新的宏任务,这里就剩一个定时器的宏任务了,执行它,打印 2

结果是:1=>'new Promise'=> 3 => 'then' => 2

异步任务执行顺序,事件队列其实是一个“先进先出”的数据结构,排在前面的事件会优先被主线程读取。

微任务

常见的微任务有:

  • Promise.then
  • MutaionObserver
  • process.nextTice(node.js)
宏任务

宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合.

常见的宏任务有:

  • script(可以理解为外层同步代码)
  • setTimeout/setInterval
  • Ul rendering/Ul事件
  • postMessage、MessageChannel
  • setlmmediate、1/0(Node.is) 这时候,事件循环,宏任务,微任务的关系如图所示

image.png

它的执行机制是:

  • 执行一个宏任务,如果遇到微任务就将它放到微任务的事件队列中
  • 当前宏任务执行完成后,会查看微任务的事件队列,然后将里面的所有微任务依次执行完
async 与 await

async就是用来声明一个异步方法,await是用来等待异步方法执行。

async函数返回一个promise对象,下面代码是等效的:

function f() {
    return Promise.resolve('TEST');
}
async function asyncF() {
    return 'TEST';
}

正常情况下, await 命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。

async function f() {
    // 等同于 return 123
    return await 123
}
f().then(i => console.log(i)) // 123

不管 await 后面跟着的是什么,await 都会阻塞后面的代码。

async function fn1 (){
    console.log(1)
    await fn2()
    console.log(2) // 阻塞
}
async function fn2 (){
    console.log('fn2')
}
fn1()
console.log(3)

await 会阻塞下面的代码(即加入微任务队列),上面的例子中,先执行 async 外面的同步代码同步代码执行完,再回到 async函数中,再执行之前阻塞的代码

输出:1,fn2,3,2

async function async1() {
    console.log('1')
    await async2()
    console.log('2')
}
async function async2() {
    console.log('3')
}
console.log('4')
setTimeout(function () {
    console.log('settimeout')
})
async1()
new Promise(function (resolve) {
    console.log('5')
    resolve()
}).then(function () {
    console.log('6')
})
console.log('7');
// 输出结果: 4 1 3 5 7 2 6 settimeout

分析过程:

  • 1.执行整段代码,遇到 console.log('4')直接打印结果,输出 4;
  • 2.遇到定时器了,它是宏任务,先放着不执行;
  • 3.遇到 async1(),执行 async1 函数,先打印 1 ,下面遇到 await 怎么办?先执行 async2,打印 3,然后阻塞下面代码(即加入微任务列表),跳出去执行同步代码
  • 4.跳到 new Promise 这里,直接执行,打印 5,下面遇到 .then(),它是微任务,放到微任务列表等待执行;
  • 5.最后一行直接打印 7 ,现在同步代码执行完了,开始执行微任务,即 await 下面的代码,打印 2;
  • 6.继续执行下一个微任务,即执行 then 的回调,6;
  • 7.上一个宏任务所有事都做完了,开始下一个宏任务,就是定时器,打印 settimeout所以最后的结果是: 4 1 3 5 7 2 6 settimeout

16.JavaScript本地存储方式有哪些?区别及应用场景?

16.1 方式

javaScript 本地缓存的方法主要讲述以下四种:

  • cookie
  • sessionStorage
  • localStorage
  • indexedDB
16.1.1.cookie

Cookie ,类型为「小型文本文件」,指某些网站为了辨别用户身份而储存在用户本地终端上的数据。是为了解决 HTTP 无状态导致的问题。 作为一段一般不超过 4KB 的小型文本数据,它由一个名称(Name)、一个值(Value)和其它几个用于控制 cookie 有效期、安全性、使用范围的可选属性组成。

但是 cookie 在每次请求中都会被发送,如果不使用 HTTPS 并对其加密,其保存的信息很容易被窃取,导致安全风险。

16.1.2 localStorage
  • 生命周期:持久化的本地存储,除非主动删除数据,否则数据是永远不会过期的。
  • 存储的信息在同一域中是共享的。
  • 当本页操作(新增、修改、删除)了 localStorage 的时候,本页面不会触发 storage 事件,但是别的页面会触发 storage 事件。
  • 大小:5M(跟浏览器厂商有关系)。
  • localstorage 本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡。
  • 受同源策略的限制。
localStorage.setItem('username','你的名字');
localStorage.getItem('username');
localStorage.key(0) // 获取第一个键名
localStorage.removeItem('username');
localStorage.clear(); // 清空localStorage
16.1.3 sessionStorage

sessionStoragelocalstorage 使用方法基本一致,唯一不同的是生命周期,一旦页面(会话)关闭,sessionStorage 将会删除数据。

16.1.4 indexedDB

indexedDB 是一种低级AP,用于客户端存储大量结构化数据(包括,文件/blobs)。该API使用索引来 实现对该数据的高性能搜索。

虽然 Web Storage 对于存储较少量的数据很有用,但对于存储更大量的结构化数据来说,这种方法不太有用。

优点:

  • 储存量理论上没有上限
  • 所有操作都是异步的,相比LocalStorage 同步操作性能更高,尤其是数据量较大时
  • 原生支持储存 JS 的对象
  • 是个正经的数据库,意味着数据库能干的事它都能干

缺点:

  • 操作非常繁琐
  • 本身有一定门槛
区别
  • 存储大小: cookie 数据大小不能超过 4ksessionStorage 和 localStorage 虽然也有存储大小的限制,但比cookie 大得多,可以达到5M或更大。
  • 有效时间: localStorage 存储持久数据,浏览器关闭后数据不丢失除非主动删除数据; sessionStorage 数据在当前浏览器窗口关闭后自动删除; cookie 设置的 cookie 过期时间之前一直有效,即使窗口或浏览器关闭。
  • 数据与服务器之间的交互方式,cookie 的数据会自动的传递到服务器,服务器端也可以写 cookie 到客户端;sessionStorage 和 localStorage 不会自动把数据发给服务器,仅在本地保存

17.Ajax 原理是什么?如何实现?

Ajax 的原理简单来说通过 XmlHttpRequest 对象来向服务器发异步请求,从服务器获得数据,然后用 JavaScript 来操作 DOM 而更新页面。

简单封装一个ajax请求:

function ajax(options) {
    //创建XMLHttpRequest对象
    const xhr = new XMLHttpRequest();
    //初始化参数的内容
    options = options || {};
    options.type = (options.type || 'GET').toUpperCase();
    options.dataType = options.dataType 'json';
    const params = options.data;

    // 发送请求
    if (options.type === 'GET') {
        xhr.open('GET', options.url + '?' + params, true) xhr.send(null)
    } else if (options.type === 'POST') {
        xhr.open('POST', options.url, true) xhr.send(params)
        // 接收请求
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4) {
                let status = xhr.status;
                if (status >= 200 && status < 300) {
                    options.success && options.success(xhr.responseText, xhr.responseXML)
                } else {
                    options.fail && options.fail(status)
                }
            }
        }
    }
}

// 调用
ajax({
    type: 'post',
    dataType: 'json',
    data: {},
    url: 'https://xxxx',
    success: function(valse, xml){ 
        console.log(valse)
    },
    fail: function(status){ 
        console.log(status)
    }
})

18. 防抖和节流?区别?如何实现?

  • 节流: n秒内只运行一次,若在n秒内重复触发,只有一次生效。
  • 防抖: n 秒后在执行该事件,若在n秒内被重复触发,则重新计时

应用场景:

防抖在连续的事件,只需触发一次回调的场景有:

  • 搜索框搜索输入。只需用户最后一次输入完,再发送请求
  • 手机号、邮箱验证输入检测
  • 窗口大小 resize 。只需窗口调整完成后,计算窗口大小。防止重复渲染。

节流在间隔一段时间执行一次回调的场景有:

  • 滚动加载,加载更多或滚到底部监听
  • 搜索框,搜索联想功能

节流

function throttled(fn, delay) {
    let timer = null;
    let starttime = Date.now();
    return function () {
        let curTime = Date.now(); // 当前时间
        let remaining = delay - (curTime - starttime); // 从上一次到现在,还剩下多少多余事件
        let context = this; // 保存this指向
        let args = arguments; // 拿到event对象
        clearTimeout(timer);
        if (remaining <= 0) {
            fn.apply(context, args);
            starttime = Date.now();
        } else {
            timer = setTimeout(fn, remaining);
        }
    }
}

防抖

function debounce(func, wait) {
    let timeout;
    return function () {
        let context = this; // 保存this指向
        let args = arguments; // 拿到event对象
        clearTimeout(timeout)
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, wait);
    }
}

如果需要立即执行防抖,可加入第三个参数

function debounce(func, wait, immediate) {
    let timeout;
    return function() {
        let context = this; // 保存this指向
        let args = arguments; // 拿到event对象
        if (timeout) clearTimeout(timeout); // timeout 不为 null
        if (immediate) {
            let callNow = !timeout; // 第一次会立即执行,以后只有事件执行后才会触发
            timeout = setTimeout(function() {
                timeout = null;
            },
            wait);
            if (callNow) {
                func.apply(context, args)
            }
        } else {
            timeout = setTimeout(function() {
                func.apply(context, args)
            },
            wait);
        }
    }
}

区别

相同点

  • 都可以通过使用 setTimeout 实现
  • 目的都是,降低回调执行频率。节省计算资源

不同点

  • 函数防抖,在一段连续操作结束后,处理回调,利用clearTimeout和 setTimeout 实现。函数节流,在一段连续操作中,每一段时间只执行一次,频率较高的事件中使用来提高性能。
  • 函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一次例如,都设置时间频率为500ms,在2秒时间内,频繁触发函数,节流,每隔500ms 就执行一次。防抖,则不管调动多少次方法,在2s后,只会执行一次。

19. web常见的攻击方式有哪些?如何防御?

常见的有:

  • XSS 跨站脚本攻击
  • CSRF 跨站请求伪造
  • SQL 注入攻击

防止csrf常用方案如下:

  • 阻止不明外域的访问,同源检测,Samesite Coolkie
  • 提交时要求附加本域才能获取信息 CSRF Token, 双重Cookie验证

预防SQL如下:

  • 严格检查输入变量的类型和格式
  • 过滤和转义特殊字符
  • 对访问数据库的web应用程序采用web应用防火墙

20.JavaScript内存泄露的几种情况?

内存泄漏(Memory leak)是在计算机科学中,由于疏忽或错误造成程序未能释放已经不再使用的内存。

Javascript 具有自动垃圾回收机制,也就是说,执行环境会负责管理代码执行过程中使用的内存。

常见的内存泄露情况:

  • 意外的全局变量。a='我是未声明的变量'.
  • 定时器

21. JavaScript数字精度丢失的问题?如何解决?

0.1 + 0.2 === 0.3; // false

可以使用parseFloat解决

CSS题型整理

1.盒模型

盒模型:由4个部分组成,content,padding,border,margin.

2.BFC的理解

BFC:即块级格式化上下文。

常见页面情况有:

  • 元素高度没了
  • 俩栏布局没法自适应
  • 元素间距奇怪
2.1清除内部浮动

元素添加overflow: hidden;

3.元素水平垂直居中的方法有哪些?

实现方式如下:

  • 利用定位+margin:auto
  • 利用定位+margin: 负值
  • 利用定位+transform
  • flex布局等

4.实现两栏布局,右侧自适应?三栏布局中间自适应?

两栏布局的话:

  • 使用float左浮动布局
  • 右边模块使用margin-left 撑出内容块做内容展示
  • 为父级元素添加BFC,防止下方元素跟上方内容重叠。

flex布局:

  • 简单易用代码少

三栏布局:

  • 两边用float,中间用margin
  • 两边用absolute,中间用margin
  • display: table
  • flex
  • grid网格布局

5.css中,有哪些方式隐藏页面元素?

例如:

  • display: none; 最常用,页面彻底消失,会导致浏览器重排和重绘
  • visibility: hidden; dom存在,不会重排,但是会重绘
  • opacity: 0; 元素透明 元素不可见,可以响应点击事件
  • position: absolute; 将元素移出可视区域,不影响页面布局

6.如何实现单行/多行文本溢出的省略样式

单行:

<style>
p {
    overflow: hidden;
    line-height: 40px;
    width:400px;
    height:40px;
    border:1px solid red;
    text-overflow: ellipsis;
    white-space: nowrap;
}

</style>
<p>文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本</p>

多行

<style>
.demo {
    position: relative;
    line-height: 20px;
    height: 40px;
    overflow: hidden;
}
.demo::after {
    content: "...";
    position: absolute;
    bottom: 0;
    right: 0;
    padding: 0 20px 0 10px;
}
</style>
<body>
    <div class="demo">文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本</div>
</body>

css实现

<style>
p {
    width: 400px;
    border-radius: 1px solid red;
    -webkit-line-clamp: 2;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    overflow: hidden;
    text-overflow: ellipsis;
}
</styl

7.CSS3新增了哪些新特性?

选择器:

  • nth-child(n)
  • nth-last-child(n)
  • last-child

新样式:

  • border-radius; 创建圆角边框
  • box-shadow; 为元素添加阴影
  • border-image; 图片绘制边框
  • background-clip; 确定背景画区
  • background-size; 调整背景图大小

文字:

  • word-wrap: normal|break-word; 使用浏览器默认的换行 | 允许在单词内换行;
  • text-overflow; clip | ellipsis; 修剪文本 | 显示省略符号来代表被修剪的文本;
  • text-decoration; text-fill-color| text-stroke-color | text-stroke-width;

transition 过渡、transform 转换、animatin动画、渐变、等

8.CSS提高性能的方法有哪些?

如下:

  • 内联首屏关键css
  • 异步加载css
  • 资源压缩(webpack/gulp/grunt)压缩代码
  • 合理的使用选择器
  • 不要使用@import
  • icon图片合成等

ES6

1.var,let, const的区别?

  • var 声明的变量会提升为全局变量,多次生成,会覆盖。
  • let let声明的变量只在代码块内有效。
  • const 声明一个只读常量,常量的值不能改变。

区别:

  • 变量提升,var会提升变量到全局。let, const直接报错
  • 暂时性死区
  • 块级作用域
  • 重复声明
  • 修改声明的变量

2.ES6中数组新增了哪些扩展?

  • 扩展运算符...
  • 构造函数新增的方法 Array.from(),Array.of()
  • 数组实例新增方法有:copyWithin(),find(),findIndex(),fill(),includes(),keys(),values()等

3.对象新增了哪些扩展

对象名跟对应值名相等的时候,可以简写。 const a = {foo: foo} == const a = {foo}

属性的遍历:

  • for...in:循环遍历对象自身的和继承的可枚举属性(不含Symbol属性)
  • Object.keys(obj):返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含Symbol属性)的键名
  • Object.getOwnPropertyNames(obj):回一个数组,包含对象自身的所有属性(不含Symbol属性,但是包括不可枚举属性)的键名
  • Object.getOwnPropertySymbols(obj):返回一个数组,包含对象自身的所有Symbol属性的键名----- Reflect.ownKeys(obj):返回一个数组,包含对象自身的(不含继承的)所有键名,不管键名是Symbol或字符串,也不管是否可枚举.

对象新增的方法

  • Object.is();
  • Object.assign();
  • Object.getOwnPropertyDescriptors() ;
  • Object.keys(), Object.values(),Object.entries();
  • Object.fromEntries();

4.理解ES6中Promise的?

优点:

  • 链式操作减低了编码难度
  • 代码可读性增强

promise对象仅有三种状态,pending(进行中),fulfilled(已成功),rejected(已失败)。一旦状态改变(从 pending变为 fulfilled和从 pending变为 rejected),就不会再变,任何时候都可以得到这个结果。

使用方法

const promise = new Promise(function(resolve, reject) {});

Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject

  • resolve函数的作用是,将Promise对象的状态从"未完成"变为"成功"
  • reject函数的作用是,将Promise对象的状态从"未完成"变为"失败"

实例方法:

  • then() 是实例状态发生改变时的回调函数。
  • catch() 指定发生错误时的回调函数。
  • finally() 不管Prosime对象最后状态如何,都会执行。

构造函数方法 Promise构造函数存在以下方法:

  • all() 将多个Promise实例包装成一个新的Promise实例。
  • race() 将多个Promise实例包装成一个新的Promise实例。
  • allSettled() 接受一组Promise实例作为参数,只有等所有这些参数返回结果,实例才会结束。
  • resolve() 将现有对象转为Promise对象。
  • reject() 返回一个新的Promise实例,状态为rejected。

Vue2面试题

1.生命周期?

beforeCreate -> created -> beforeMount -> mounted -> beforeUpdate -> updated -> beforeDestroy -> destroyed

1.4 数据请求在created和mouted的区别

created 是在组件实例一旦创建完成的时候立刻调用,这时候页面 dom 节点并未生成; mounted是在页面dom节点渲染完毕之后就立刻执行的。触发时机上created是比mounted要更早的,

两者的相同点:都能拿到实例对象的属性和方法。讨论这个问题本质就是触发的时机,放在mounted中的请求有可能导致页面闪动(因为此时页面dom结构已经生成),但如果在页面加载前完成请求,则不会出现此情况。建议对页面内容的改动放在 created 生命周期当中。

2.双向数据绑定是什么?

释义:当js代码更新Model时,view也会自动更新,用户更新view,Model的数据也会自动被更新,就是双向绑定。

3.Vue组件之间的通信方式有哪些?

  • 1.通过props传递 (父给子组件传递)
  • 2.通过$emit触发自定义事件 (子传父)
  • 3.使用ref (父组件使用子组件的时候)
    1. EventBus (兄弟组件传值)
    1. attrs 与 listeners (祖先传递给子孙)
    1. Provide 与 Inject (在祖先组件定义provide)返回传递的值,在后代组件通过inject 接收组件传递过来的值。
    1. Vuex (复杂关系组件数据传递,存放共享变量)

4.v-if和v-for的优先级是什么?

v-for的优先级比v-if的高

注意

不能把v-if 和 v-for 同时在同一个元素上,带来性能方面的浪费。必须使用的话可以在外层套一个template

5. 未完待续。。。

防抖(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,
  };
}

吃透 JS 事件委托:从原理到实战,解锁高性能事件处理方案

2026年1月2日 14:56

事件委托(Event Delegation)是 JavaScript 中最核心的事件处理技巧之一,也是前端面试的高频考点。它基于事件冒泡机制,能大幅减少事件绑定数量、解决动态元素事件失效问题,同时降低内存占用、提升页面性能。本文将从原理拆解、实战场景、性能优化到避坑指南,全方位带你吃透事件委托。

一、为什么需要事件委托?先看痛点

在未使用事件委托的场景中,我们通常会给每个元素单独绑定事件,比如一个列表的所有项:

// 传统方式:给每个li绑定点击事件
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
  item.addEventListener('click', () => {
    console.log('点击了列表项:', item.textContent);
  });
});

这种写法会暴露三个核心问题:

  1. 性能损耗:如果列表有 1000 个项,就会创建 1000 个事件处理函数,占用大量内存;
  2. 动态元素失效:新增的列表项(如通过 JS 动态添加)不会自动绑定事件,需要重新执行绑定逻辑;
  3. 代码冗余:重复的事件绑定逻辑,增加维护成本。

而事件委托能一次性解决这些问题 —— 只给父元素绑定一次事件,就能处理所有子元素的事件触发。

二、事件委托的核心原理:事件流

要理解事件委托,必须先掌握 DOM 事件流的三个阶段:

  1. 捕获阶段:事件从 window 向下传播到目标元素(从外到内);
  2. 目标阶段:事件到达目标元素本身;
  3. 冒泡阶段:事件从目标元素向上传播回 window(从内到外)。

事件委托的核心逻辑是:利用事件冒泡,将子元素的事件绑定到父元素(甚至根元素)上,通过判断事件源(target)来区分具体触发的子元素

举个直观的例子:点击列表中的<li>,事件会先触发<li>的 click 事件,然后冒泡到<ul><div>,直到documentwindow。我们只需要在<ul>上绑定一次事件,就能捕获所有<li>的点击行为。

三、基础实战:实现一个列表的事件委托

1. 核心实现代码

<ul id="list" class="item-list">
  <li class="list-item" data-id="1">列表项1</li>
  <li class="list-item" data-id="2">列表项2</li>
  <li class="list-item" data-id="3">列表项3</li>
</ul>
<button id="addItem">新增列表项</button>

<script>
// 父元素绑定事件(只绑定一次)
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
  // 核心:判断触发事件的目标元素
  const target = e.target;
  // 确认点击的是列表项(避免点击ul空白处触发)
  if (target.classList.contains('list-item')) {
    const id = target.dataset.id;
    console.log(`点击了列表项${id}:`, target.textContent);
  }
});

// 动态新增列表项(无需重新绑定事件)
const addItem = document.getElementById('addItem');
let index = 4;
addItem.addEventListener('click', () => {
  const li = document.createElement('li');
  li.className = 'list-item';
  li.dataset.id = index;
  li.textContent = `列表项${index}`;
  list.appendChild(li);
  index++;
});
</script>

2. 关键知识点解析

  • e.target:触发事件的原始元素(比如点击的<li>);
  • e.currentTarget:绑定事件的元素(这里是<ul>);
  • 类名 / 属性判断:通过classListdataset等方式精准匹配目标元素,避免非目标元素触发逻辑;
  • 动态元素兼容:新增的<li>无需重新绑定事件,因为事件委托在父元素上,天然支持动态元素。

四、进阶场景:精细化事件委托

实际开发中,事件委托的场景往往更复杂,比如多层嵌套、多类型事件、需要阻止冒泡等,以下是高频进阶用法:

1. 多层嵌套元素的委托

当目标元素嵌套在其他元素中(比如<li>里有<span><button>),需要通过closest找到最外层的目标元素:

<ul id="list">
  <li class="list-item" data-id="1">
    <span>列表项1</span>
    <button class="delete-btn">删除</button>
  </li>
</ul>

<script>
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
  // 找到最近的list-item(解决点击子元素触发的问题)
  const item = e.target.closest('.list-item');
  if (item) {
    // 区分点击的是列表项还是删除按钮
    if (e.target.classList.contains('delete-btn')) {
      console.log(`删除列表项${item.dataset.id}`);
      item.remove();
    } else {
      console.log(`点击列表项${item.dataset.id}`);
    }
  }
});
</script>

closest方法会从当前元素向上查找,返回匹配选择器的第一个祖先元素(包括自身),是处理嵌套元素的最佳方案。

2. 多类型事件的统一委托

可以在父元素上绑定多个事件类型,或通过一个处理函数区分不同事件:

// 一个处理函数处理多个事件类型
list.addEventListener('click', handleItemEvent);
list.addEventListener('mouseenter', handleItemEvent);
list.addEventListener('mouseleave', handleItemEvent);

function handleItemEvent(e) {
  const item = e.target.closest('.list-item');
  if (!item) return;

  switch(e.type) {
    case 'click':
      console.log('点击:', item.dataset.id);
      break;
    case 'mouseenter':
      item.style.backgroundColor = '#f5f5f5';
      break;
    case 'mouseleave':
      item.style.backgroundColor = '';
      break;
  }
}

3. 委托到 document/body(全局委托)

对于全局范围内的动态元素(如弹窗、动态按钮),可以将事件委托到documentbody

// 全局委托:处理所有动态生成的按钮
document.addEventListener('click', (e) => {
  if (e.target.classList.contains('dynamic-btn')) {
    console.log('点击了动态按钮:', e.target.textContent);
  }
});

// 动态创建按钮
setTimeout(() => {
  const btn = document.createElement('button');
  btn.className = 'dynamic-btn';
  btn.textContent = '动态按钮';
  document.body.appendChild(btn);
}, 1000);

⚠️ 注意:全局委托虽方便,但不要滥用 ——document上的事件会监听整个页面的点击,过多的全局委托会增加事件处理的耗时,建议优先委托到最近的父元素。

五、性能优化:让事件委托更高效

事件委托本身是高性能方案,但不当使用仍会产生性能问题,以下是优化技巧:

1. 选择最近的父元素

尽量避免直接委托到document/body,而是选择离目标元素最近的固定父元素。比如列表的事件委托到<ul>,而非document,减少事件传播的层级和处理函数的触发次数。

2. 节流 / 防抖处理高频事件

如果委托的是scrollresizemousemove等高频事件,必须结合节流 / 防抖:

// 节流函数
function throttle(fn, delay = 100) {
  let timer = null;
  return (...args) => {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, delay);
    }
  };
}

// 委托scroll事件(节流处理)
document.addEventListener('scroll', throttle((e) => {
  // 处理滚动逻辑
  console.log('滚动了');
}, 200));

3. 及时移除无用的委托事件

如果委托的父元素被销毁(比如弹窗关闭),要及时移除事件监听,避免内存泄漏:

const modal = document.getElementById('modal');
const handleModalClick = (e) => {
  // 弹窗内的事件逻辑
};

// 绑定事件
modal.addEventListener('click', handleModalClick);

// 弹窗关闭时移除事件
function closeModal() {
  modal.removeEventListener('click', handleModalClick);
  modal.remove();
}

六、避坑指南:事件委托的常见问题

1. 事件被阻止冒泡

如果子元素的事件处理函数中调用了e.stopPropagation(),会导致事件无法冒泡到父元素,委托失效:

// 错误示例:子元素阻止冒泡,委托失效
document.querySelector('.list-item').addEventListener('click', (e) => {
  e.stopPropagation(); // 阻止冒泡
  console.log('子元素点击');
});

// 父元素的委托事件不会触发
list.addEventListener('click', (e) => {
  console.log('委托事件'); // 不会执行
});

✅ 解决方案:避免在子元素中随意阻止冒泡,若必须阻止,需确保不影响委托逻辑。

2. 目标元素是不可冒泡的事件

部分事件不支持冒泡(如focusblurmouseentermouseleave),直接委托会失效:

// 错误示例:mouseenter不冒泡,委托失效
list.addEventListener('mouseenter', (e) => {
  console.log('鼠标进入列表项'); // 不会触发
});

✅ 解决方案:使用事件捕获模式(第三个参数设为true):

// 捕获模式处理不冒泡的事件
list.addEventListener('mouseenter', (e) => {
  const item = e.target.closest('.list-item');
  if (item) {
    console.log('鼠标进入列表项');
  }
}, true); // 开启捕获模式

3. 动态修改元素的类名 / 属性

如果目标元素的类名、dataset等用于判断的属性被动态修改,可能导致委托逻辑失效:

// 动态修改类名后,委托无法匹配
const item = document.querySelector('.list-item');
item.classList.remove('list-item'); // 移除类名
// 此时点击该元素,委托逻辑不会触发

✅ 解决方案:尽量使用稳定的标识(如固定的data-*属性),而非易变的类名。

七、框架中的事件委托(Vue/React)

现代前端框架虽封装了事件处理,但底层仍基于事件委托,且有专属的使用方式:

1. Vue3 中的事件委托

Vue 的v-on@)指令默认会利用事件委托(绑定到组件根元素),也可手动实现精细化委托:

<template>
  <ul @click="handleListClick">
    <li v-for="item in list" :key="item.id" :data-id="item.id">
      {{ item.name }}
      <button class="delete-btn">删除</button>
    </li>
  </ul>
</template>

<script setup>
import { ref } from 'vue';
const list = ref([{ id: 1, name: '列表项1' }, { id: 2, name: '列表项2' }]);

const handleListClick = (e) => {
  const item = e.target.closest('[data-id]');
  if (item) {
    const id = item.dataset.id;
    if (e.target.classList.contains('delete-btn')) {
      list.value = list.value.filter(item => item.id !== Number(id));
    } else {
      console.log(`点击列表项${id}`);
    }
  }
};
</script>

2. React 中的事件委托

React 的合成事件系统本身就是基于事件委托(所有事件绑定到document),无需手动实现,但可通过e.target判断目标元素:

import { useState } from 'react';

function List() {
  const [list, setList] = useState([{ id: 1, name: '列表项1' }]);

  const handleListClick = (e) => {
    const item = e.target.closest('[data-id]');
    if (item) {
      const id = item.dataset.id;
      console.log(`点击列表项${id}`);
    }
  };

  return (
    <ul onClick={handleListClick}>
      {list.map(item => (
        <li key={item.id} data-id={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

八、总结

事件委托是前端开发中 “四两拨千斤” 的技巧,核心是利用事件冒泡,将多个子元素的事件绑定到父元素,通过目标元素判断执行逻辑。它的优势在于:

  • 减少事件绑定数量,降低内存占用;
  • 天然支持动态元素,无需重复绑定;
  • 简化代码逻辑,提升可维护性。

使用时需注意:

  1. 优先委托到最近的父元素,避免全局委托;
  2. 处理嵌套元素用closest,处理不冒泡事件用捕获模式;
  3. 高频事件结合节流 / 防抖,及时移除无用事件;
  4. 避免随意阻止冒泡,防止委托失效。

掌握事件委托,不仅能写出更高效的代码,更能深入理解 DOM 事件流的本质 —— 这也是从 “初级前端” 到 “中高级前端” 的必经之路。

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

Docker Compose Pull 超时与代理踩坑记录

作者 Johnnyhaha
2026年1月2日 14:54

Docker Compose Pull 超时与代理踩坑记录

问题一:docker compose pull 直接超时

报错

> docker compose pull
Error response from daemon: Get "https://registry-1.docker.io/v2/": dial tcp 108.160.170.33:443: i/o timeout (Client.Timeout exceeded while awaiting headers)

原因

Docker daemon 无法直连 Docker Hub(国内网络被阻断)。

解决办法

给 Docker 配代理,让 daemon 走代理访问外网。

sudo mkdir -p /etc/systemd/system/docker.service.d
sudo vi /etc/systemd/system/docker.service.d/proxy.conf

内容

[Service]
Environment="HTTP_PROXY=http://172.24.48.1:7890"
Environment="HTTPS_PROXY=http://172.24.48.1:7890"
Environment="NO_PROXY=localhost,172.24.48.1"

然后

sudo systemctl daemon-reload
sudo systemctl restart docker

问题二:配置代理后仍然失败

报错

> docker compose pull
Error response from daemon: Get "https://registry-1.docker.io/v2/": read tcp 172.24.48.247:60752->172.24.48.1:7890: read: connection reset by peer

定位方式

curl -I https://registry-1.docker.io/v2/ --proxy http://172.24.48.1:7890

结果

HTTP/1.1 200 Connection established 
curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to registry-1.docker.io:443

原因

docker域名没有设置代理规则,走了直连

解决办法

在代理(如 Clash)中强制 Docker Hub 走代理

rules:
  - DOMAIN,registry-1.docker.io,PROXY
  - DOMAIN,auth.docker.io,PROXY
  - DOMAIN-SUFFIX,docker.com,PROXY
  - DOMAIN-SUFFIX,docker.io,PROXY

重启 Clash,再试

最终验证

curl -I https://registry-1.docker.io/v2/ --proxy http://172.24.48.1:7890
# 成功

docker compose pull
# 成功

优化

  1. 为避免每次拉取新的clash配置时重置自定义 rules,将上述 docker rules 设置到 mixins 中
module.exports.parse = ({ content, name, url }, { yaml, axios, notify }) => {
  const rules = [
    // ...

    // docker
    "DOMAIN-SUFFIX,docker.com,PROXY",
    "DOMAIN-SUFFIX,docker.io,PROXY",
    "DOMAIN,registry-1.docker.io,PROXY",
    "DOMAIN,auth.docker.io,PROXY"
  ]
  content.rules.unshift(...rules)
  return content
}

JavaScript 遍历方法详解

作者 UIUV
2026年1月2日 14:27

JavaScript 遍历方法详解

JavaScript 提供了多种遍历方法,每种方法都有其独特的语法结构、使用场景和注意事项。掌握这些遍历方法不仅能提高代码质量,还能使开发更加高效。本文将系统地介绍 JavaScript 中常见的遍历方法,包括对象遍历和数组遍历两大类,并分析它们的特点、适用场景及最佳实践。

对象遍历方法

for...in 循环

语法

for (const property in object) {
  // 使用object[property]访问属性值
}

用法:for...in 循环用于遍历对象的所有可枚举属性,包括继承的属性。它会按顺序返回对象自身的所有可枚举属性,以及原型链上可枚举的属性,直到到达原型链的终点。

const person = {
  name: 'Alice',
  age: 25,
  occupation: 'Engineer'
};

for (const key in person) {
  console.log(`${key}: ${person[key]}`);
}
// 输出: name: Alice, age: 25, occupation: Engineer

注意事项

  • 遍历继承属性:for...in 会遍历对象原型链上的属性,可能导致意外结果。

  • 使用 hasOwnProperty 过滤:若只需遍历对象自身的属性,应使用 hasOwnProperty 方法过滤。

    for (const key in person) {
      if (person.hasOwnProperty(key)) {
        console.log(`${key}: ${person[key]}`);
      }
    }
    
  • 不处理 Symbol 类型属性:for...in 无法遍历 Symbol 类型的属性。

  • 不可枚举属性不被访问:即使属性不可枚举,for...in 也不会遍历它们。

  • 迭代过程中修改对象可能有问题:在循环过程中添加、删除或修改对象属性可能导致不可预测的行为。

Object.keys() + for...of/forEach

语法

// 结合for...of
for (const key of Object.keys(object)) {
  // 使用object[key]访问属性值
}

// 结合forEach
Object.keys(object).forEach(key => {
  // 使用object[key]访问属性值
});

用法:Object.keys () 返回对象所有可枚举的自有属性名组成的数组,结合 for...of 或 forEach 可安全遍历对象自身属性。

const person = {
  name: 'Alice',
  age: 25,
  occupation: 'Engineer'
};

// for...of结合
for (const key of Object.keys(person)) {
  console.log(`${key}: ${person[key]}`);
}
// 输出: name: Alice, age: 25, occupation: Engineer

// forEach结合
Object.keys(person).forEach(key => {
  console.log(person[key]);
});
// 同样输出三个属性值

注意事项

  • 仅遍历自有属性:与 for...in 不同,Object.keys () 仅遍历对象自身的可枚举属性。
  • 不包含 Symbol 键:Object.keys () 仅返回字符串类型的属性名。
  • ESLint 推荐:许多 JavaScript 风格指南推荐使用 Object.keys () 代替 for...in 遍历数组。
  • 返回值是数组:Object.keys () 返回的是数组,可以像其他数组一样进行操作(如排序、过滤)。

Object.values() + for...of/forEach

语法

// 结合for...of
for (const value of Object.values(object)) {
  // 直接使用value
}

// 结合forEach
Object.values(object).forEach(value => {
  // 直接使用value
});

用法:Object.values () 返回对象所有可枚举的自有属性值组成的数组,结合 for...of 或 forEach 可直接遍历对象属性值。

const person = {
  name: 'Alice',
  age: 25,
  occupation: 'Engineer'
};

// for...of结合
for (const value of Object.values(person)) {
  console.log(value);
}
// 输出: Alice, 25, Engineer

// forEach结合
Object.values(person).forEach(value => {
  console.log(value);
});
// 同样输出三个属性值

注意事项

  • 仅遍历自有属性值:与 Object.keys () 类似,Object.values () 也仅遍历对象自身的可枚举属性。
  • 不包含键信息:无法直接获取属性名,只能访问属性值(若需键名需使用 Object.entries ())。
  • ESLint 推荐:当只需要属性值时,使用 Object.values () 比 for...in 更高效、更安全。
  • 返回值是数组:Object.values () 返回的是数组,支持数组的所有方法(如 map、filter)。

Object.entries() + for...of/forEach

语法

// 结合for...of
for (const [key, value] of Object.entries(object)) {
  // 同时使用key和value
}

// 结合forEach
Object.entries(object).forEach(([key, value]) => {
  // 同时使用key和value
});

用法:Object.entries () 返回对象所有可枚举的自有属性的键值对数组,结合 for...of 或 forEach 可同时访问属性名和属性值。

const person = {
  name: 'Alice',
  age: 25,
  occupation: 'Engineer'
};

// for...of结合
for (const [key, value] of Object.entries(person)) {
  console.log(`Key: ${key}, Value: ${value}`);
}
// 输出: Key: name, Value: Alice; Key: age, Value: 25; Key: occupation, Value: Engineer

// forEach结合
Object.entries(person).forEach(([key, value]) => {
  console.log(`Key: ${key}, Value: ${value}`);
});
// 同样输出三个键值对

注意事项

  • 仅遍历自有属性:与 Object.keys () 和 Object.values () 一样,Object.entries () 也仅遍历对象自身的可枚举属性。
  • 返回键值对数组:每个键值对以数组形式返回,索引 0 为键,索引 1 为值,支持数组解构赋值。
  • ESLint 推荐:当需要同时访问键和值时,Object.entries () 是比 for...in 更安全的选择。
  • 返回值是数组:Object.entries () 返回的是数组,可结合数组方法进行复杂操作。

Reflect.ownKeys()

语法

const keys = Reflect.ownKeys(object);

用法:Reflect.ownKeys () 返回对象所有自有属性(包括不可枚举属性和 Symbol 类型的属性)的键集合,是遍历对象所有属性的最全面方法。

const person = {
  name: 'Alice',
  age: 25,
  occupation: 'Engineer'
};

// 添加不可枚举属性
Object.defineProperty(person, 'id', {
  value: 1001,
  enumerable: false // 不可枚举
});

// 添加Symbol类型属性
person[Symbol('secret')] = 'abc123';

const allKeys = Reflect.ownKeys(person);
console.log(allKeys); 
// 输出: ['name', 'age', 'occupation', 'id', Symbol(secret)]

注意事项

  • 包含所有自有属性:包括可枚举和不可枚举的属性,不受 enumerable 属性影响。
  • 支持 Symbol 类型的键:与 Object.keys () 不同,Reflect.ownKeys () 可以返回 Symbol 类型的键。
  • 不遍历继承属性:仅遍历对象自身的属性,不包含原型链上的属性。
  • 返回数组:返回一个包含所有自有属性键的数组,可以像其他数组一样进行操作。
  • 性能考量:相比 Object.keys (),Reflect.ownKeys () 可能稍慢,因为需要处理更多类型的键(不可枚举、Symbol)。

其他自有属性方法

JavaScript 还提供了其他几种遍历对象自有属性的方法,适用于特定场景:

1. Object.getOwnPropertyNames()
  • 语法Object.getOwnPropertyNames(object)

  • 用法:返回对象所有自有属性名(包括不可枚举的)的数组,但不包含 Symbol 类型的键。

  • 示例

    const person = {
      name: 'Alice',
      age: 25
    };
    Object.defineProperty(person, 'id', { value: 1001, enumerable: false });
    
    const keys = Object.getOwnPropertyNames(person);
    console.log(keys); // 输出: ['name', 'age', 'id']
    
2. Object.getOwnPropertySymbols()
  • 语法Object.getOwnPropertySymbols(object)

  • 用法:返回对象所有自有 Symbol 类型属性名的数组,仅包含 Symbol 类型的键。

  • 示例

    const person = {
      name: 'Alice',
      [Symbol('secret')]: 'abc123'
    };
    
    const symbols = Object.getOwnPropertySymbols(person);
    console.log(symbols); // 输出: [Symbol(secret)]
    
3. for...in 循环 + hasOwnProperty
  • 语法

    for (const key in object) {
      if (object.hasOwnProperty(key)) {
        // 处理属性
      }
    }
    
  • 用法:通过 hasOwnProperty 过滤继承属性,仅遍历对象自身的属性,是 for...in 的安全用法。

  • 示例

    const person = {
      name: 'Alice',
      age: 25
    };
    
    // 给原型添加属性(继承属性)
    Object.prototype.gender = 'female';
    
    for (const key in person) {
      if (person.hasOwnProperty(key)) {
        console.log(`${key}: ${person[key]}`);
      }
    }
    // 输出: name: Alice, age: 25(过滤了gender属性)
    

注意事项

  • 性能差异:Object.getOwnPropertyNames () 和 Object.getOwnPropertySymbols () 相比 Object.keys () 会返回更多属性,但性能略低。
  • 适用场景:当需要遍历所有自有属性(包括不可枚举的)时,使用这些方法;当仅需可枚举属性时,优先使用 Object.keys ()/values ()/entries ()。
  • Symbol 键的特殊性:Symbol 类型的键不会被 for...in、Object.keys () 等方法捕获,需使用 Reflect.ownKeys () 或 Object.getOwnPropertySymbols () 专门处理。

数组遍历方法

传统 for 循环

语法

for (let i = 0; i < array.length; i++) {
  // 使用array[i]访问元素
}

用法:传统 for 循环是最基础的数组遍历方式,通过索引控制循环流程,支持灵活的循环控制(break、continue、return)。

const numbers = [1, 2, 3, 4, 5];

// 遍历数组并打印元素
for (let i = 0; i < numbers.length; i++) {
  console.log(`Index ${i}: ${numbers[i]}`);
}
// 输出: Index 0: 1; Index 1: 2; ...; Index 4: 5

// 遍历数组并修改元素
for (let i = 0; i < numbers.length; i++) {
  numbers[i] = numbers[i] * 2;
}
console.log(numbers); // 输出: [2, 4, 6, 8, 10]

// 中断循环(找到第一个偶数)
for (let i = 0; i < numbers.length; i++) {
  if (numbers[i] % 2 === 0) {
    console.log(`第一个偶数: ${numbers[i]}`);
    break;
  }
}

注意事项

  • 完全控制循环:支持 break(中断循环)、continue(跳过当前迭代)、return(退出函数)等控制语句。

  • 可修改原数组:通过索引直接访问元素,可直接修改原数组的值。

  • 性能最佳:在大型数组遍历中,传统 for 循环的性能通常优于 forEach、map 等方法(减少函数调用开销)。

  • 索引管理:需要手动管理索引变量(i 的初始化、条件判断、递增),容易出现索引错误(如数组越界)。

  • 缓存数组长度:对于长度固定的数组,可缓存 length 属性以提高性能:

    const len = numbers.length;
    for (let i = 0; i < len; i++) {
      // 处理逻辑
    }
    

for...of 循环

语法

for (const element of array) {
  // 直接使用element
}

用法:for...of 循环是 ES6 引入的遍历可迭代对象(数组、字符串、Map、Set 等)的方法,直接遍历元素值,无需索引。

const numbers = [1, 2, 3, 4, 5];

// 遍历数组元素
for (const num of numbers) {
  console.log(num);
}
// 输出: 1, 2, 3, 4, 5

// 遍历数组并获取索引(结合Array.prototype.entries())
for (const [index, num] of numbers.entries()) {
  console.log(`Index ${index}: ${num}`);
}
// 输出: Index 0: 1; Index 1: 2; ...; Index 4: 5

// 遍历字符串
const str = 'hello';
for (const char of str) {
  console.log(char);
}
// 输出: h, e, l, l, o

注意事项

  • 直接遍历元素:无需手动管理索引,直接访问元素值,代码更简洁。
  • 支持可迭代对象:除数组外,还支持遍历字符串、Map、Set、Generator 等可迭代对象。
  • 可中断循环:支持 break、continue、return 等控制语句,可提前终止循环。
  • 不遍历非数字属性:与 for...in 不同,for...of 仅遍历数组的数字索引属性,不会遍历非数字属性(如数组的自定义属性)。
  • 不支持修改数组长度:在循环中修改数组长度可能导致遍历不完整或重复遍历(建议避免)。

forEach () 方法

语法

array.forEach((currentValue, [index], [array]) => {
  // 处理逻辑
}, [thisArg]);

参数说明

  • currentValue:当前遍历的元素。
  • index(可选):当前元素的索引。
  • array(可选):被遍历的原数组。
  • thisArg(可选):回调函数中 this 的指向对象。

用法:forEach () 方法对数组中的每个元素执行一次回调函数,无返回值,仅用于遍历执行操作。

const numbers = [1, 2, 3, 4, 5];

// 基础用法
numbers.forEach(num => {
  console.log(num);
});
// 输出: 1, 2, 3, 4, 5

// 带索引参数
numbers.forEach((num, index) => {
  console.log(`Index ${index}: Value ${num}`);
});
// 输出: Index 0: Value 1; Index 1: Value 2; ...; Index 4: Value 5

// 使用thisArg参数
const obj = { multiplier: 2 };
numbers.forEach(function(num) {
  console.log(num * this.multiplier); // this指向obj
}, obj);
// 输出: 2, 4, 6, 8, 10

注意事项

  • 无返回值:forEach () 不返回新数组,仅执行操作(若需返回结果,应使用 map、filter 等方法)。

  • 无法中断循环:不支持 break 和 continue,无法提前终止循环(即使抛出异常也不推荐)。

  • 回调函数特性

    • 若使用普通函数作为回调,this 值由 thisArg 参数指定;
    • 若使用箭头函数,thisArg 参数无效,this 指向外层作用域的 this。
  • 不改变原数组:forEach () 本身不会修改原数组,但若在回调函数中显式修改元素(如array[index] = num * 2),则会改变原数组。

  • ESLint 警告:某些 ESLint 配置(如no-foreach)会警告使用 forEach,因为它可能隐藏副作用(建议优先使用函数式方法)。

map () 方法

语法

const newArray = array.map((currentValue, [index], [array]) => {
  // 处理逻辑,返回新值
}, [thisArg]);

用法:map () 方法对数组中的每个元素执行回调函数,将回调函数的返回值组成新数组返回,原数组保持不变。适用于数组元素的转换操作。

const numbers = [1, 2, 3, 4, 5];

// 基础转换(数值翻倍)
const doubled = numbers.map(num => num * 2);
console.log(doubled); // 输出: [2, 4, 6, 8, 10]
console.log(numbers); // 原数组不变: [1, 2, 3, 4, 5]

// 转换为对象数组
const objects = numbers.map((num, index) => ({
  id: index,
  value: num,
  squared: num * num
}));
console.log(objects);
// 输出: [
//   { id: 0, value: 1, squared: 1 },
//   { id: 1, value: 2, squared: 4 },
//   ...
// ]

// 字符串处理
const names = ['alice', 'bob', 'charlie'];
const capitalized = names.map(name => name.charAt(0).toUpperCase() + name.slice(1));
console.log(capitalized); // 输出: ['Alice', 'Bob', 'Charlie']

注意事项

  • 返回新数组:始终返回与原数组长度相同的新数组(即使回调函数返回 undefined),原数组不变。

  • 不可中断循环:不支持 break 和 continue,必须遍历完所有元素。

  • 函数式编程:鼓励使用无副作用的纯函数(回调函数不修改外部变量或原数组)。

  • 性能考量:创建新数组可能带来额外内存开销,对于大型数组(百万级元素)需谨慎使用。

  • 常见错误

    • 忘记返回值:回调函数未返回值时,新数组元素为 undefined;
    • 误用索引:将 index 作为元素值使用(如map(index => index * 2));
    • 副作用操作:在回调函数中修改外部变量或原数组(违反纯函数原则)。

filter () 方法

语法

const newArray = array.filter((currentValue, [index], [array]) => {
  // 返回布尔值,决定元素是否保留
}, [thisArg]);

用法:filter () 方法通过回调函数(布尔函数)过滤数组元素,返回由满足条件(回调函数返回 true)的元素组成的新数组,原数组保持不变。适用于数组元素的筛选操作。

const numbers = [1, 2, 3, 4, 5, 6];

// 筛选偶数
const evenNumbers = numbers.filter(num => num % 2 === 0);
console.log(evenNumbers); // 输出: [2, 4, 6]

// 筛选长度>=5的字符串
const words = ['apple', 'banana', 'kiwi', 'grape', 'orange'];
const longWords = words.filter(word => word.length >= 5);
console.log(longWords); // 输出: ['apple', 'banana', 'orange']

// 筛选对象数组(年龄>=25)
const people = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 20 }
];
const adults = people.filter(person => person.age >= 25);
console.log(adults); // 输出: [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }]

注意事项

  • 返回新数组:返回的新数组长度可能小于或等于原数组(取决于满足条件的元素数量),原数组不变。
  • 不可中断循环:不支持 break 和 continue,必须遍历完所有元素。
  • 布尔返回值:回调函数必须返回布尔值(true/false),非布尔值会被自动转换(如 0→false、非 0→true)。
  • 函数式编程:鼓励使用无副作用的纯函数,回调函数不应修改外部变量或原数组。
  • 性能考量:创建新数组可能带来额外内存开销,对于大型数组需结合实际场景优化。

reduce () 方法

语法

const result = array.reduce((accumulator, currentValue, [index], [array]) => {
  // 累积逻辑,返回新的累积值
}, [initialValue]);

参数说明

  • accumulator:累加器,存储上一次回调函数的返回值(初始值为 initialValue 或数组第一个元素)。
  • currentValue:当前遍历的元素。
  • index(可选):当前元素的索引。
  • array(可选):被遍历的原数组。
  • initialValue(可选):累加器的初始值,若未提供则使用数组第一个元素作为初始值,且从第二个元素开始遍历。

用法:reduce () 方法对数组中的每个元素执行回调函数,将其结果汇总为单个值返回(如求和、求积、对象聚合等),功能强大且灵活。

const numbers = [1, 2, 3, 4, 5];

// 1. 求和(提供初始值)
const sum = numbers.reduce((acc, num) => acc + num, 0);
console.log(sum); // 输出: 15

// 2. 求积(未提供初始值)
const product = numbers.reduce((acc, num) => acc * num);
console.log(product); // 输出: 120(1*2*3*4*5)

// 3. 聚合对象(统计年龄总和)
const people = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 20 }
];
const ageSum = people.reduce((acc, person) => acc + person.age, 0);
console.log(ageSum); // 输出: 75

// 4. 分组统计(按年龄分组)
const ageGroups = people.reduce((acc, person) => {
  const key = person.age >= 25 ? 'adults' : 'youngsters';
  acc[key] = acc[key] ? [...acc[key], person] : [person];
  return acc;
}, {});
console.log(ageGroups);
// 输出: {
//   adults: [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }],
//   youngsters: [{ name: 'Charlie', age: 20 }]
// }

// 5. 数组扁平化(二维数组转一维)
const nestedArray = [[1, 2], [3, 4], [5, 6]];
const flattened = nestedArray.reduce((acc, arr) => [...acc, ...arr], []);
console.log(flattened); // 输出: [1, 2, 3, 4, 5, 6]

注意事项

  • 返回单个值:最终返回一个汇总值(可以是数字、对象、数组等),而非数组。

  • 初始值影响

    • 提供初始值:累加器从初始值开始,遍历所有元素;
    • 未提供初始值:累加器从数组第一个元素开始,遍历从第二个元素开始;
    • 空数组无初始值:会抛出 TypeError(必须提供初始值)。
  • 不可中断循环:不支持 break 和 continue,必须遍历完所有元素。

  • 函数式编程:鼓励使用无副作用的纯函数,每次迭代应返回新的累加器(而非修改原累加器),确保可预测性。

  • 性能考量:在大型数组中可能性能略低,但通常与 forEach、map 等方法差异不大,且功能更强大。

遍历方法对比与选择指南

对象遍历方法对比

方法 遍历继承属性 处理 Symbol 键 返回值类型 性能 适用场景
for...in 属性名字符串(逐个返回) 中等 遍历对象所有可枚举属性(包括继承),调试场景
Object.keys() 可枚举自有属性名数组 遍历对象自身可枚举属性名
Object.values() 可枚举自有属性值数组 遍历对象自身可枚举属性值
Object.entries() 可枚举自有属性键值对数组 中等 同时遍历对象自身可枚举属性的键和值
Reflect.ownKeys() 所有自有属性键数组 中等 遍历对象所有自有属性(包括不可枚举、Symbol)
Object.getOwnPropertyNames() 所有自有属性名数组 中等 遍历对象所有自有属性名(包括不可枚举)
Object.getOwnPropertySymbols() 所有自有 Symbol 属性名数组 中等 遍历对象所有自有 Symbol 属性名

数组遍历方法对比

方法 返回值类型 可中断循环 性能 函数式特性 适用场景
传统 for 循环 需要精确控制索引、修改数组、中断循环
for...of 中等 遍历可迭代对象(数组、字符串等),无需索引
forEach() 中等 遍历数组执行操作,无需返回结果
map() 新数组(转换后) 中等 数组元素转换,生成新数组
filter() 新数组(筛选后) 中等 数组元素筛选,生成新数组
reduce() 单个汇总值 中等 数组元素累积计算(求和、分组、扁平化等)

适用场景选择建议

1. 对象遍历场景
  • 调试对象属性:使用for...in(快速查看所有可枚举属性,包括继承)。

  • 安全遍历自身可枚举属性

    • 仅需键名:Object.keys() + for...of/forEach;
    • 仅需值:Object.values() + for...of/forEach;
    • 需键值对:Object.entries() + for...of/forEach(ESLint 推荐)。
  • 遍历所有自有属性(包括不可枚举)Reflect.ownKeys() 或 Object.getOwnPropertyNames()

  • 处理 Symbol 类型属性Reflect.ownKeys() 或 Object.getOwnPropertySymbols()

  • 避免继承属性干扰:坚决避免使用for...in,优先使用Object.keys()/values()/entries()。

2. 数组遍历场景
  • 需要索引或修改数组:使用传统 for 循环(性能最佳,控制灵活)。

  • 仅需遍历元素执行操作

    • 无需中断循环:forEach()(代码简洁);
    • 可能需要中断循环:for...of(支持 break/continue)。
  • 转换元素生成新数组map()(一对一转换,保持长度不变)。

  • 筛选元素生成新数组filter()(按条件筛选,长度可能变化)。

  • 累积计算(求和、分组等)reduce()(功能强大,支持复杂聚合)。

  • 遍历可迭代对象(字符串、Map 等)for...of(通用遍历方案)。

3. 函数式编程场景
  • 优先使用mapfilterreduce等高阶函数,代码更简洁、声明式,可维护性更高。
  • 鼓励使用纯函数(无副作用),避免在回调函数中修改外部变量或原数组。
  • 复杂逻辑可组合使用高阶函数(如map().filter().reduce()),替代多层 for 循环。

ESLint 规范与最佳实践

ESLint 对遍历方法的建议

ESLint 作为前端常用的代码检查工具,对遍历方法有明确的规范建议,旨在提高代码质量和一致性:

  1. 禁止使用 for...in 遍历数组(规则:no-for-in

    • 原因:for...in 会遍历数组的非数字属性(如自定义属性、原型链属性),导致意外结果。
    • 解决方案:使用for...offorEach、传统 for 循环或Object.keys()替代。
  2. 优先使用安全的对象遍历方法(规则:prefer-object-spreadprefer-destructuring

    • 原因:for...in可能遍历继承属性,存在安全风险。

    • 解决方案:使用Object.keys()/values()/entries()结合解构赋值,如:

      // 推荐
      for (const [key, value] of Object.entries(obj)) {
        // 处理逻辑
      }
      
      // 不推荐
      for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
          // 处理逻辑
        }
      }
      
  3. 禁止在循环中修改数组(规则:no-unsafe-optional-chainingno-param-reassign

    • 原因:在forEachfor...of等循环中修改数组长度或元素,可能导致遍历不完整或重复遍历。
    • 解决方案:使用mapfilter等高阶函数创建新数组,而非修改原数组。
  4. 优先使用函数式方法替代 forEach(规则:prefer-mapprefer-filterprefer-reduce

    • 原因:forEach可能隐藏副作用,且无法返回结果,函数式方法更具表达力。

    • 解决方案:

      // 不推荐
      const result = [];
      arr.forEach(num => {
        if (num % 2 === 0) {
          result.push(num * 2);
        }
      });
      
      // 推荐
      const result = arr.filter(num => num % 2 === 0).map(num => num * 2);
      

函数式编程最佳实践

  1. 使用纯函数

    • 回调函数不应修改外部变量或原数组,仅依赖输入参数返回结果。

    • 示例:

      // 纯函数(推荐)
      const double = num => num * 2;
      const doubled = [1, 2, 3].map(double);
      
      // 非纯函数(不推荐)
      let total = 0;
      [1, 2, 3].forEach(num => {
        total += num; // 修改外部变量
      });
      
  2. 避免回调地狱

    • 多层forEach嵌套会降低代码可读性,可使用mapfilterreduce组合替代。

    • 示例:

      // 不推荐(嵌套forEach)
      const data = [
        { id: 1, items: [10, 20] },
        { id: 2, items: [30, 40] }
      ];
      const result = [];
      data.forEach(item => {
        item.items.forEach(num => {
          result.push(num * 2);
        });
      });
      
      // 推荐(reduce + map)
      const result = data.reduce((acc, item) => {
        return [...acc, ...item.items.map(num => num * 2)];
      }, []);
      
  3. 合理使用解构赋值

    • 遍历对象键值对时,使用解构赋值简化代码。

    • 示例:

      Object.entries(obj).forEach(([key, value]) => {
        console.log(`${key}: ${value}`);
      });
      
  4. 处理边界情况

    • 数组为空时,reduce必须提供初始值,避免抛出错误。

    • 示例:

      // 推荐(提供初始值)
      const sum = [].reduce((acc, num) => acc + num, 0); // 0
      
      // 不推荐(无初始值,空数组会报错)
      const sum = [].reduce((acc, num) => acc + num); // TypeError
      
  5. 性能优化

    • 大型数组(百万级元素)遍历优先使用传统 for 循环(减少函数调用开销)。
    • 避免在回调函数中执行复杂操作,可提前提取逻辑或缓存中间结果。

常见问题与解决方案

1. for...in 遍历数组时的问题

问题:使用 for...in 遍历数组时,会遍历到数组的非数字属性(如自定义属性、原型链属性),导致意外结果。

const arr = [1, 2, 3];
arr.test = 'value'; // 添加非数字属性
Object.prototype.gender = 'female'; // 原型链添加属性

for (const key in arr) {
  console.log(key); // 输出: 0, 1, 2, 'test', 'gender'
}

解决方案

  • 使用for...of遍历数组(仅遍历数字索引属性):

    for (const num of arr) {
      console.log(num); // 输出: 1, 2, 3
    }
    
  • 使用Object.keys()过滤数字索引:

    Object.keys(arr).forEach(key => {
      if (!isNaN(Number(key))) { // 仅处理数字索引
        console.log(arr[key]); // 输出: 1, 2, 3
      }
    });
    
  • 避免给数组添加非数字属性(遵循数组的设计初衷)。

2. reduce () 方法的初始值问题

问题:reduce () 方法在数组为空或未提供初始值时,行为不符合预期。


// 问题1:空数组无初始值 → 抛出TypeError
[].reduce((acc, num) => acc + num); // Uncaught TypeError: Reduce of empty array with no initial value

// 问题2:数组只有一个元素无初始值 → 直接返回该元素,不调用回调
[5].reduce((acc, num) => acc + num); // 5(回调未执行)

解决方案

  • 始终提供初始值(推荐):

    const sum = [1, 2, 3].reduce((acc, num) => acc + num, 0); // 6
    const emptySum = [].reduce((acc, num) => acc + num, 0); // 0(无错误)
    
  • 明确处理边界情况(数组可能为空时):

    const array = [];
    const sum = array.length === 0 ? 0 : array.reduce((acc, num) => acc + num);
    

3. Symbol 键的遍历问题

问题:Symbol 类型的键无法被for...inObject.keys()等方法捕获,导致遍历不完整。

const obj = {
  name: 'Alice',
  [Symbol('id')]: 123,
  [Symbol('secret')]: 'abc'
};

console.log(Object.keys(obj)); // 输出: ['name'](未包含Symbol键)
for (const key in obj) {
  console.log(key); // 输出: 'name'(未包含Symbol键)
}

解决方案

  • 使用Reflect.ownKeys()遍历所有自有属性键(包括 Symbol):

    const allKeys = Reflect.ownKeys(obj);
    console.log(allKeys); // 输出: ['name', Symbol(id), Symbol(secret)]
    
  • 使用Object.getOwnPropertySymbols()专门获取 Symbol 键:

    const symbols = Object.getOwnPropertySymbols(obj);
    console.log(symbols); // 输出: [Symbol(id), Symbol(secret)]
    

4. 高阶函数的回调参数误用

问题:map、filter 等高阶函数的回调参数误用(如混淆参数顺序、遗漏参数),导致意外结果。


// 问题1:map回调参数顺序错误(误将index作为value)
const numbers = ['1', '2', '3'];
const parsed = numbers.map((index, value) => parseInt(value)); 
// 输出: [NaN, NaN, NaN](参数顺序颠倒)

// 问题2:filter回调未返回布尔值
const evenNumbers = [1, 2, 3, 4].filter(num => {
  num % 2 === 0; // 遗漏return,默认返回undefined → 转换为false
});
console.log(evenNumbers); // 输出: [](所有元素都被过滤)

解决方案

  • 明确回调函数参数顺序:

    • map/filter 回调:(currentValue, index, array)
    • reduce 回调:(accumulator, currentValue, index, array)
  • 确保 filter 回调返回布尔值:

    // 正确示例
    const parsed = numbers.map((value) => parseInt(value)); // 输出: [1, 2, 3]
    const evenNumbers = [1, 2, 3, 4].filter(num => num % 2 === 0); // 输出: [2, 4]
    

5. 遍历过程中修改数组的问题

问题:在forEachfor...of等循环中修改数组(如删除、添加元素),导致遍历不完整或重复遍历。

// 问题:删除元素后,数组长度变化,导致某些元素被跳过
const arr = [1, 2, 3, 4, 5];
arr.forEach((num, index) => {
  if (num % 2 === 0) {
    arr.splice(index, 1); // 删除当前元素
  }
});
console.log(arr); // 输出: [1, 3, 5]?实际输出: [1, 3, 5](看似正确,但逻辑有风险)

// 问题升级:数组长度变化导致遍历异常
const arr2 = [1, 2, 3, 4, 5];
for (let i = 0; i < arr2.length; i++) {
  if (arr2[i] === 2) {
    arr2.splice(i, 1); // 删除索引1的元素,数组变为[1,3,4,5]
  }
  console.log(arr2[i]); // 输出: 1, 3, 4, 5(跳过了3之后的元素?实际输出:1,3,4,5)
}

解决方案

  • 使用filter创建新数组(推荐,无副作用):

    const arr = [1, 2, 3, 4, 5];
    const filtered = arr.filter(num => num % 2 !== 0);
    console.log(filtered); // 输出: [1, 3, 5](原数组不变)
    
  • 传统 for 循环从后往前遍历(修改原数组时):

    const arr = [1, 2, 3, 4, 5];
    for (let i = arr.length - 1; i >= 0; i--) {
      if (arr[i] % 2 === 0) {
        arr.splice(i, 1); // 从后往前删除,不影响前面的索引
      }
    }
    console.log(arr); // 输出: [1, 3, 5]
    

结论

JavaScript 提供了丰富的遍历方法,每种方法都有其独特的适用场景和优缺点。掌握这些方法的核心差异和最佳实践,能帮助开发者编写更高效、更安全、更易维护的代码。

核心总结

  • 对象遍历:优先使用Object.keys()/values()/entries()(安全、高效),避免for...in;需遍历所有自有属性(包括不可枚举、Symbol)时使用Reflect.ownKeys()

  • 数组遍历

    • 需控制索引或中断循环:传统 for 循环;
    • 仅遍历元素:for...of
    • 转换元素:map()
    • 筛选元素:filter()
    • 累积计算:reduce()
    • 函数式编程:优先组合使用mapfilterreduce
  • ESLint 规范:遵循no-for-inprefer-map等规则,避免常见错误。

  • 最佳实践:使用纯函数、避免副作用、处理边界情况(如空数组、Symbol 键)。

在实际开发中,应根据具体需求(如是否需要索引、是否修改数组、是否返回结果)选择合适的遍历方法,避免盲目追求 “流行” 方法。同时,结合 ESLint 等工具确保代码规范,提高代码质量和团队协作效率。

防抖与节流:前端性能优化的两大利器

作者 ohyeah
2026年1月2日 13:50

在现代 Web 开发中,用户交互越来越频繁,而每一次交互都可能触发复杂的逻辑处理或网络请求。如果不加以控制,这些高频操作会带来严重的性能问题。为此,防抖(Debounce)节流(Throttle) 成为了前端开发中不可或缺的性能优化手段。

本文将结合一段实际代码和详细注释,深入浅出地讲解防抖与节流的核心思想、实现方式以及适用场景,并重点解析其中的关键逻辑。


一、为什么需要防抖和节流?

设想这样一个场景:用户在搜索框中输入关键词,每按一次键就发起一次 AJAX 请求获取搜索建议。如果用户快速输入“react”,那么会依次触发 rrereareacreact 五次请求。

  • 问题1:请求开销大
    每次请求都需要消耗带宽、服务器资源,甚至可能造成接口限流。
  • 问题2:用户体验差
    如果请求响应慢,旧的请求结果可能会覆盖新的输入内容,导致显示错乱。

因此,我们需要一种机制来减少不必要的执行次数,只保留关键的操作。这就是防抖和节流要解决的问题。

防抖:在一定时间内,只执行最后一次操作。
节流:每隔固定时间,最多执行一次操作。


二、防抖(Debounce)——“只认最后一次”

1. 核心思想

无论执行多少次,只执行最后一次。

就像王者荣耀中的“回城”技能:如果你在回城过程中被攻击,回城会被打断并重新计时。只有当你完整地等待一段时间后,回城才会真正生效。

2. 代码实现与闭包应用

// 高阶函数 参数或者返回值是函数 (返回值是函数 -> 闭包)
function debounce(fn, delay) {
  var id; // 自由变量,闭包保存
  return function(args) {
    if (id) clearTimeout(id); // 清除已有定时器,重新计时
    var that = this; // 保存 this 上下文
    id = setTimeout(function() {
      fn.call(that, args); // 延迟执行原函数 并绑定正确的this和参数
    }, delay);
    // 这样只有最后一次触发后等待delay毫秒后才会真正执行
  };
}

关键点解析:

  • 闭包的作用id 是一个自由变量,被返回的函数所引用,从而在多次调用之间保持状态。这使得每次触发都能访问并清除上一次的定时器。
  • clearTimeout(id) :确保只有最后一次触发后的 delay 时间才会真正执行函数。
  • this 和参数传递:通过 callapply 确保原函数在正确的上下文中执行,并传入正确的参数。

3. 使用示例

const inputb = document.getElementById('debounce');
let debounceAjax = debounce(ajax, 200);
inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

用户快速输入时,只有停止输入 200ms 后,才会发送最终的完整关键词请求,极大减少了无效请求。


三、节流(Throttle)——“冷却期内不执行,但最后补一次”

1. 核心思想

每隔一定时间,最多执行一次。

但注意:我们实现的是带尾随执行(trailing)的节流,即在冷却期结束后,如果期间有触发,会补一次执行

就像技能有 CD(冷却时间),但如果你在 CD 期间一直按技能,CD 结束后会自动释放一次。

2. 代码实现与“尾随执行”逻辑

function throttle(fn, delay) {
  let last, deferTimer; // last上一次执行事件  deferTimer延迟执行的定时器
  return function() {
    let that = this;  
    let _args = arguments; // 类数组对象 保存所有参数
    let now = +new Date(); // 拿到当前时间戳  +强制类型转换 毫秒数

    if (last && now < last + delay) {
      // 处于冷却期 上次执行时间存在 且当前时间还没到下次允许执行的时间
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function() {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 已过冷却期,立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}

重点解析 if (last && now < last + delay) 分支:

  • 条件成立含义:已经执行过至少一次(last 存在),且当前时间距离上次执行不足 delay 毫秒 → 正处于冷却期。
  • 但不能忽略这次触发!因为这可能是用户最后一次有效操作(比如完整输入了“react”)。
  • 所以我们设置一个延迟定时器,计划在冷却期结束后执行。
  • clearTimeout(deferTimer) 的作用:用户可能在冷却期内多次触发,但我们只关心最后一次,所以每次都要清除旧的定时器,只保留最新的。

3. 为什么需要“尾随执行”?

核心原因:避免丢失最后一次有效操作。

假设用户想搜 “react”,在 200ms 内快速打完,而节流 delay = 500ms:

  • 简单节流(无尾随)

    • r(0ms)→ 立即执行
    • re(100ms)→ 被忽略
    • rea(150ms)→ 被忽略
    • react(200ms)→ 被忽略
      → 用户停止输入 但永远不会发送'react' 搜索框显示的是r的结果 而不是用户真正想搜的react
  • 带尾随的节流

    • r(0ms)→ 立即执行(last = 0
    • re(100ms)→ 冷却期,设 timer(600ms 执行)
    • rea(150ms)→ 更新 timer(650ms)
    • react(200ms)→ 更新 timer(700ms)
      → 用户停止输入后,在 700ms 自动执行 ajax('react')结果正确

4. 什么时候不需要尾随?

按钮防连点:用户点击“提交”按钮,你希望 2 秒内只能点一次。
这种情况下,不需要在 2 秒后自动再提交一次!此时应使用无尾随的简单节流


四、防抖 vs 节流:如何选择?

特性 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后 delay ms 执行 每隔 delay ms 最多执行一次
是否保证最后一次 ✅ 是 ✅(带尾随时)
典型场景 搜索建议、窗口 resize 滚动加载、鼠标移动、按钮点击(防连点)
类比 回城技能(被打断重计时) 技能 CD(冷却后可再放)
  • 搜索建议 → 用防抖:用户输入是连续的,我们只关心最终结果。
  • 滚动加载 → 用节流:用户持续滚动,我们需要定期检查是否到底部,不能等到停止滚动才加载。

五、总结

防抖和节流虽然都是用于限制函数执行频率,但它们的触发逻辑和适用场景截然不同

  • 防抖强调“只执行最后一次”,适用于用户意图明确、操作连续的场景,如搜索、表单校验。
  • 节流强调“定期执行”,适用于高频但需周期性响应的场景,如滚动、拖拽、游戏帧更新。

而我们在实现节流时,特别加入了尾随执行(trailing) 机制,这是为了兼顾性能与用户体验——既避免了过度请求,又确保不会丢失用户的最终操作。

正如注释中所说:
“核心原因:避免丢失最后一次有效操作。”

通过合理运用闭包、定时器和上下文绑定,我们不仅实现了功能,还保证了代码的健壮性和可复用性。这些技巧,正是前端工程师在性能优化道路上的必备武器。


小提示:在实际项目中,Lodash 等工具库已提供了成熟的 debouncethrottle 实现,支持更多选项(如 leadingtrailing 开关)。但理解其底层原理,才能在复杂场景中灵活应对。

希望本文能帮助你更清晰地掌握防抖与节流的本质。欢迎在评论区分享你的使用经验!

大模型Function Calling的函数如何调用的?

作者 海云前端1
2026年1月2日 13:48

在真实开发中,大模型的 Function Calling(函数调用)不是“模型直接执行代码”,而是一套“声明-生成-解析-执行-反馈”的安全闭环机制。以下是我在项目中(如智能编程助手、自动化运维 Agent)的实际做法:

一、核心流程(生产级标准做法)

二、具体步骤

1. 注册函数

在调用 LLM 前,向模型描述有哪些函数可用(OpenAI 格式为例):

const tools = [{
  type: "function",
  function: {
    name: "read_file",
    description: "读取项目中的文件内容",
    parameters: {
      type: "object",
      properties: {
        path: { type: "string", description: "文件相对路径,如 src/main.ts" }
      },
      required: ["path"]
    }
  }
}];

关键:参数必须有明确 schema,防止模型传非法值。

2. 调用 LLM 并启用工具

const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages: [...],
  tools,               // ← 注册的函数列表
  tool_choice: "auto"  // 模型可自主决定是否调用
});

3. 解析模型返回

模型不会执行函数,而是返回结构化调用请求:

{
  "tool_calls": [
    {
      "id": "call_abc123",
      "function": {
        "name": "read_file",
        "arguments": "{"path":"src/utils.ts"}"
      }
    }
  ]
}

4. 安全执行函数

  • 绝不直接 eval!而是通过白名单映射:
const toolMap = {
  read_file: (args) => {
    // 1. 校验路径是否在项目目录内
    if (!args.path.startsWith("src/")) throw new Error("Access denied");
    // 2. 读取文件(沙箱隔离)
    return fs.readFileSync(args.path, "utf8");
  }
};

const result = await toolMap[funcName](parsedArgs);
  • 所有操作在受限环境中执行(如 Docker 沙箱、只读文件系统)。

5. 将结果反馈给模型

把函数执行结果作为“tool message”送回对话:

messages.push({
  role: "tool",
  tool_call_id: "call_abc123",
  content: result  // 文件内容
});

→ 模型基于此生成下一步(继续调用 or 最终回答)。

三、真实项目中的关键实践

问题 解决方案
模型传错参数(如 path: "../../../etc/passwd") 参数校验 + 路径归一化 + 白名单目录
函数执行超时/卡死 设置 timeout(如 5s) + AbortController
敏感操作(如删文件) 禁止高危函数,或需用户二次确认
多次调用循环 限制最大 tool_calls 次数(如 5 次)
调试困难 记录完整 trace:prompt → tool_call → result → final answer

四、为什么不用模型直接“写代码执行”?

  • 安全风险极高(任意代码执行 = RCE 漏洞);
  • 不可控(无法限流、审计、降级);
  • 不可靠(模型可能生成语法错误代码)。

正确做法:Function Calling 是“受控 API 调用”,不是“代码生成执行”

总结:

在实际项目中,大模型的 Function Calling 是一个安全代理机制

  1. 我们先向模型声明可用函数及其参数 schema
  2. 模型返回结构化调用请求(非执行);
  3. 后端严格校验参数、权限、路径,在沙箱中执行真实函数;
  4. 将结果反馈给模型,形成多轮推理闭环。

核心原则:模型只负责“决策”,不负责“执行” ——这是生产系统安全落地的底线。

海云前端丨前端开发丨简历面试辅导丨求职陪跑

React 中的 Props:组件通信与复用的核心机制

作者 Zyx2007
2026年1月2日 13:02

在 React 的组件化开发范式中,Props(属性) 是连接父子组件、实现数据流动与功能定制的关键桥梁。如果说状态(state)是组件内部的“私有记忆”,那么 Props 就是外部世界与组件对话的“公共接口”。通过 Props,父组件可以向子组件传递数据、回调函数、甚至其他组件本身,从而构建出高度可复用、可组合且职责清晰的 UI 体系。

组件即函数:参数驱动的 UI 单元

React 中的组件本质上是 JavaScript 函数。正如函数通过参数接收外部输入,组件也通过 props 对象接收来自父组件的配置信息:

function Greeting(props) {
  const { name, message, showIcon } = props;
  return (
    <>
      {showIcon && <span>👋</span>}
      <div>{name} {message}</div>
    </>
  );
}

当在父组件中使用 <Greeting name="张三" message="你好" showIcon /> 时,这些属性会被打包成一个对象传入 Greeting 函数。这种设计使得组件行为完全由输入决定,符合纯函数的思想,极大提升了可预测性与可测试性。

类型约束:提升健壮性与协作效率

为避免因传入错误类型的数据导致运行时错误,React 社区广泛采用 prop-types 库进行运行时类型检查:

import PropTypes from 'prop-types';

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
  message: PropTypes.string,
  showIcon: PropTypes.bool
};

通过声明 name 为必需的字符串、showIcon 为布尔值,开发者能在控制台收到清晰的警告信息,尤其在团队协作中,这相当于一份自文档化的 API 契约,显著降低沟通成本。

children:内容分发的灵活通道

除了普通属性,React 还提供了一个特殊 prop —— children,用于传递组件标签之间的内容:

const Card = ({ children, className = '' }) => {
  return <div className={`card ${className}`}>{children}</div>;
};

// 使用
<Card className="user-card">
  <h2>张三</h2>
  <p>高级前端工程师</p>
</Card>

children 可以是任意 JSX、文本或组件,使得 Card 成为一个通用容器,其内部结构由使用者自由定义。这种模式类似于 Web Components 中的 <slot>,是实现高阶组件和布局复用的核心技巧。

组件作为 Prop:极致的定制能力

更进一步,Props 甚至可以接收整个组件作为值,从而实现动态 UI 结构:

const MyHeader = () => <h2 style={{ margin: 0, color: 'blue' }}>自定义标题</h2>;
const MyFooter = () => (
  <button onClick={() => alert('关闭弹窗')}>关闭</button>
);

<Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
  <p>这是一个弹窗内容</p>
</Modal>

Modal 内部,通过 {<HeaderComponent />} 动态渲染传入的组件:

function Modal({ HeaderComponent, FooterComponent, children }) {
  return (
    <div style={styles.overlay}>
      <div style={styles.modal}>
        <HeaderComponent />
        <div style={styles.content}>{children}</div>
        <FooterComponent />
      </div>
    </div>
  );
}

这种方式将模态框的头部、尾部与主体内容完全解耦,调用方可以按需注入任意逻辑,使 Modal 具备极强的通用性和扩展性。

状态与 Props 的分工协作

在一个典型应用中,状态通常集中在上层组件(如页面级组件)管理,而下层 UI 组件则通过 Props 接收数据与行为:

// App.jsx(持有状态)
function App() {
  const [user] = useState({ name: "张三", role: "前端工程师" });
  return <Card><UserInfo user={user} /></Card>;
}

// UserInfo.jsx(仅展示)
function UserInfo({ user }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.role}</p>
    </div>
  );
}

这种“状态提升”模式确保了数据流的单向性:父组件负责数据来源与更新逻辑,子组件专注渲染。当需求变化时,只需调整父组件的状态管理,子组件无需修改,极大增强了系统的可维护性。

样式传递:兼顾封装与灵活性

组件常需支持自定义样式。通过 className Prop,可在保留内部默认样式的前提下,允许外部覆盖:

/* Card.css */
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1rem;
}
// 合并类名
<div className={`card ${className}`}>{children}</div>

这样,<Card className="user-card"> 既能继承 .card 的基础样式,又能应用 .user-card 的特定风格,实现样式层面的“开闭原则”。

总结

Props 不仅是数据传递的通道,更是 React 组件设计哲学的体现:通过明确的输入输出契约,构建可组合、可复用、可测试的 UI 单元。从简单的字符串属性,到复杂的组件函数与嵌套内容,Props 提供了多层次的定制能力。配合类型检查、children 模式与组件作为 Prop 的高级用法,开发者能够像搭积木一样,将小型组件组装成复杂界面,同时保持各部分的独立性与清晰职责。掌握 Props 的各种使用场景,是编写高质量 React 应用的基石。

React Hooks:函数组件的状态与副作用管理艺术

作者 Zyx2007
2026年1月2日 12:58

在现代 React 开发中,函数组件已不再是“无状态”的代名词。借助 Hooks——以 use 开头的一系列内置函数,开发者可以在不编写类的前提下,轻松管理组件的状态、执行副作用、订阅外部数据源,甚至自定义逻辑复用机制。这一设计不仅让代码更贴近原生 JavaScript 的表达习惯,也推动了组件逻辑的清晰化与模块化。

useState:声明响应式状态

useState 是最基础的 Hook,用于在函数组件中引入可变状态:

const [num, setNum] = useState(0);

它返回一个包含当前状态值和更新函数的数组。值得注意的是,useState 的初始值可以是一个函数,适用于需要复杂同步计算的场景:

const [num, setNum] = useState(() => {
  const a = 1 + 2;
  const b = 2 + 3;
  return a + b; // 返回确定的初始值
});

这种形式确保初始化逻辑仅在组件首次渲染时执行一次,避免不必要的重复计算。但需注意:该函数必须是同步且纯的,不能包含异步操作(如 fetch),因为状态必须在渲染前确定。

此外,setNum 不仅能接收新值,还可接受一个函数,其参数为上一次的状态:

<button onClick={() => setNum(prev => prev + 1)}>
  {num}
</button>

当状态更新依赖于前一状态时(如计数器、列表追加),使用函数式更新能避免因闭包捕获旧值而导致的竞态问题,确保状态演进的正确性。

useEffect:统一处理副作用

如果说 useState 负责“记忆”,那么 useEffect 就负责“行动”。它用于执行副作用操作——即那些不影响组件渲染结果但必须发生的逻辑,如数据请求、定时器、DOM 操作等。

useEffect(() => {
  console.log('组件挂载完成');
}, []);

通过传入空依赖数组 [],该副作用仅在组件首次挂载后执行一次,等效于类组件中的 componentDidMount

当依赖项变化时,useEffect 会重新运行:

useEffect(() => {
  console.log(`num 变为 ${num}`);
}, [num]);

这类似于 componentDidUpdate,可用于监听特定状态或 props 的变化并作出响应。

清理副作用:防止内存泄漏

许多副作用需要在组件卸载或重新执行前进行清理,例如清除定时器、取消网络请求、移除事件监听器等。useEffect 支持返回一个清理函数

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  return () => {
    console.log('清除定时器');
    clearInterval(timer);
  };
}, [num]);

该清理函数会在以下两种情况下被调用:

  1. 组件卸载时:释放资源,防止内存泄漏;
  2. 下一次副作用执行前(若依赖项变化):先清理旧副作用,再执行新副作用。

这种机制确保了副作用的生命周期与组件状态严格同步,避免了常见的“已卸载组件仍尝试更新状态”错误。

副作用的本质:打破纯函数的边界

React 组件本质上应是一个纯函数:给定相同的 props 和 state,始终返回相同的 JSX。而副作用(如修改全局变量、发起网络请求、改变 DOM)则打破了这一原则,因其结果具有不确定性或对外部环境产生影响。

例如,以下函数存在副作用:

function add(nums) {
  nums.push(3); // 修改了外部数组
  return nums.reduce((a, b) => a + b, 0);
}

调用后,原始 nums 数组被改变,后续代码行为不可预测。而在 React 中,useEffect 正是将这类“不纯”的操作集中管理的容器,使主渲染逻辑保持纯净,提升可测试性与可维护性。

实际应用:数据获取与条件渲染

结合 useStateuseEffect,可实现典型的数据驱动 UI:

function App() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(setData);
  }, []);

  return data ? <div>{data}</div> : <p>加载中...</p>;
}

这里,数据请求作为副作用在挂载后执行,成功后通过 setData 触发重新渲染,展示最新内容。整个流程清晰、线性,无需关心生命周期钩子的切换。

总结

React Hooks 通过 useStateuseEffect 等核心 API,将状态管理和副作用处理从类组件的生命周期中解放出来,赋予函数组件完整的逻辑表达能力。它们以声明式的方式描述“何时做什么”,而非“在哪个阶段做什么”,更符合直觉。同时,依赖数组机制强制开发者显式声明副作用的触发条件,提升了代码的可读性与健壮性。掌握 Hooks,不仅是使用现代 React 的必备技能,更是迈向函数式、响应式前端开发思维的关键一步。

第3章 Nest.js拦截器

作者 XiaoYu2002
2026年1月2日 12:30

3.1 拦截器介绍

Nest.js的拦截器和axios的拦截器是类似的,可以在网络请求处理的前后去执行额外的逻辑。拦截器从字面意思理解就是拦截,假设有流程A->B,拦截器要做的是A到B的过程中,将内容拦截下来处理后再丢给B,变成了A->拦截器->B。

在网络请求的逻辑中,拦截器的拦截位置如下:

  • 客户端请求->拦截器(前置逻辑)->路由处理器->拦截器(后置逻辑)->客户端响应。

Nest.js拦截器效果如图3-1所示。

image-20251212195404071

图3-1 Nest.js拦截器

Nest.js拦截器主要的用途有以下5点:

(1)统一响应格式:将返回数据包装成统一格式。

(2)日志记录:记录请求耗时、请求参数等信息。

(3)缓存处理:对响应数据进行缓存。

(4)异常映射:转换异常类型。

(5)数据转换:对响应数据进行序列化/转换。

在英语AI项目中,主要使用到第5点数据转换,因此我们主要学习这一点。

3.2 拦截器创建

如表1-2所示,可以通过nest g itc interceptor快速创建一个拦截器(interceptor可以替换为任何你想取的拦截器名称)。通过该命令会在src文件夹下创建interceptor文件夹,而interceptor文件夹下存放interceptor.interceptor.ts文件。

根据命令的生成规则,我们知道文件夹和文件的名称取决于我们命令对拦截器的名称,从而生成xxx文件夹和xxx.interceptor.ts文件。并且在这唯一的文件中,会提前生成好对应的Demo示例。

//src/interceptor/interceptor.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle();
  }
}

拦截器有两种使用方式:

(1)局部使用。

(2)全局使用。

当想局部使用时,例如只想在src文件夹下的user模块使用,我们只需要注册到user模块中。那怎么注册?有3种注册方式,在user.module.ts、user.controller.ts以及user.controller.ts都可以注册,最主要的区别在于局部作用范围不同。Nest.js拦截器局部注册如表3-1所示。

表3-1 Nest.js拦截器局部注册

注册方式 作用范围 代码位置 优点 缺点
模块级别 整个模块所有控制器 user.module.ts 统一管理,自动应用到所有路由 无法灵活排除某些路由
控制器级别 单个控制器所有路由 user.controller.ts 控制器粒度控制 需在每个控制器添加装饰器
路由级别 单个路由方法 user.controller.ts 最精细的控制 代码重复,管理复杂

局部使用的具体代码不演示,可通过AI或者官方文档学习使用。

3.3 全局拦截器使用

在英语AI项目中会使用到全局使用,我们这里学习具体如何全局使用。步骤为以下2步:

(1)使用nest g itc <拦截器名称>快速创建一个拦截器。

(2)将拦截器注册到main.ts文件中,即在main.ts文件中导入刚创建的拦截器,并且使用Nest应用程序实例方法useGlobalInterceptors()。

// main.ts文件
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new InterceptorInterceptor());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

当然由于InterceptorInterceptor拦截器是一个类,所以我们需要使用new运算符创建拦截器的实例以供使用。到这里,InterceptorInterceptor拦截器就是全局使用,即每一个接口都会经过该拦截器。Nest.js全局注册的官方文档如图3-2所示。

image-20251212212343423

图3-2 Nest.js拦截器全局注册

此时来编写InterceptorInterceptor拦截器内的逻辑,可见引入了来自rxjs的Observable类,rxjs是Nest.js内部自带的,主要用于处理流的,使用频率不高。通常获取数据需要区分同步与异步,同步直接获取,而异步通过Promise的then或者catch方法获取。如果此时有rxjs,就不需要我们去关注获取的数据是同步或者异步的问题,减少心智负担。rxjs会将这些数据统一转成一个数据流,然后通过管道(pipe)去接收,接收到之后可由我们处理该数据格式,无论是通过map遍历处理还是filter过滤等等,最终将处理好的数据格式返回就行。

以上是rxjs的核心理念,除此之外,它还可以同时处理多个异步,而then或者catch方法每次只能处理一个。

像InterceptorInterceptor拦截器中的所返回的next.handle()就是一个Observable(数据流),所以我们需要通过pipe(管道)去接收数据然后使用rxjs的map方法对数据处理之后再返回数据。

我们将原始数据包裹在一个标准响应结构中,添加了时间戳、请求路径、业务状态码、成功标志和自定义消息。这样确保了所有经过此拦截器的HTTP响应都遵循统一的JSON格式,包括 { timestamp, data, path, message, code, success } 等标准化字段,前端可以统一处理和错误追踪。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 将通用的执行上下文切换到HTTP特定的上下文
    const ctx = context.switchToHttp();
    // 获取当前HTTP请求的详细对象,包含了请求方法、URL、请求头、参数、主体等所有信息。
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: data,
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

此时在浏览器的URL输入http://localhost:3000/user/123,访问带参数的get请求,get请求拦截效果如图3-3所示。在这里体现的是:路由处理器->拦截器(后置逻辑)->客户端响应。

image-20251212220853053

图3-3 Nest.js全局拦截器-get请求拦截效果

message和code字段属于业务逻辑的部分,后续完成英语AI项目时,会根据业务实际逻辑去自定义设置。

3.4 优化全局拦截器

但此时全局拦截器还有一个很大的Bug,假如接口返回一个很大的数据,我们通过BigInt数据类型去处理返回,那么在通过全局拦截器时就会出现报错情况,全局拦截器处理BigInt类型报错如图3-4所示。

image-20251212222211323

图3-4 全局拦截器处理BigInt类型报错

报错是error TS2322: Type 'bigint' is not assignable to type 'string'。即bigint类型无法赋值给string类型,这是很正常的。因为全局拦截器的这些参数都是通过JavaScript标准内置对象JSON.stringify()进行格式化的,而JSON.stringify()是没办法处理BigInt值的。在MDN文档中是这样表述这一异常情况:当尝试去转换 BigInt类型的值会抛出TypeError("BigInt value can't be serialized in JSON")(BigInt 值不能 JSON 序列化)。

// src/app.service.ts文件
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello() {
    return BigInt(123456789123456789123456789)
  }
}

所以我们需要针对BigInt类型的值去处理,通过编写transformBigInt方法去单独处理这一情况,主要处理的事情是当遇到BigInt类型的值就将它转成一个字符串。

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  return data;
};

此时将接口(get请求)返回给用户的data数据放入transformBigInt方法中即可。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  return data;
};

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: transformBigInt(data),
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

但此时还会报错同样的问题(Type 'bigint' is not assignable to type 'string'),这是很正常的。我们来梳理下流程:

(1)接口返回数据给前端。

(2)全局拦截器拦截接口返回的数据进行处理。

(3)全局处理后的数据返回给前端。

我们已经在全局拦截器中处理好类型转换问题(BigInt转String),如果还有问题,就只能在第一步的接口返回数据给前端的步骤中。前端访问的是接口,而接口是体现形式是路由,路由层从业务层获取数据返回给前端。因此在业务层的数据是BigInt类型,则路由层所拿到的数据也会是BigInt类型。由于Nest.js是强制使用TypeScript的,所以我们需要到app.controller.ts文件中将get默认请求所返回的类型限制从string改成any类型或者string和bigint的联合类型。此时就能正常运行代码。

// src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) { }

  @Get()
  getHello(): string | bigint {
    return this.appService.getHello();
  }
}

出于严谨的考虑,我们需要处理相应的边界判断,假如BigInt类型在数组里,在对象里呢?原有的处理方式就又解析不了了。

return [BigInt(123456789123456789123456789)];
return { a: BigInt(123456789123456789123456789) };

所以需要进一步强化transformBigInt方法,对数组遍历处理内部可能存在的BigInt类型,而对象则通过Object.entries()静态方法将对象切换成保存键值对的二维数组后,遍历键值对并针对其中的value值处理可能存在的BigInt类型,最后通过Object.fromEntries()静态方法将键值对形式的二维数组重新转换回原始对象。

  • 对象打印效果:{ foo: "bar", baz: 42 }。

  • 将可迭代对象切成二维数组:[ ['foo', 'bar'], ['baz', 42] ]。

将对象切成二维数组更方便找到键值对的值并进行遍历操作。

const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  if(Array.isArray(data)){
    return data.map(transformBigInt);
  }
  if(typeof data === 'object' && data !== null){
    return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
  }
  return data;
};

做完以上的优化后,我们会发现接口要返回Date日期没办法正常返回给前端了,因为我们把对象全部都处理了,而JavaScript标准内置对象Date的使用是通过new运算符调用的实例对象,实例对象也是对象,也会被transformBigInt方法一并处理,所以在判断对象的内部逻辑中还需要判断是否是Date类型,若为Date类型则直接原路返回,不处理。

if(typeof data === 'object' && data !== null){
  if(data instanceof Date){
    return data
  }
  return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
}

完整的全局拦截器如下代码所示,后续英语AI项目中,会将该全局拦截器直接拿过去使用。

// src/interceptor/interceptor.interceptor.ts文件
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from 'express';
//同步 异步 then catch ->数据流->pipe -> map filter -> 返回



const transformBigInt = (data: any) => {
  if (typeof data === 'bigint') {
    return data.toString();
  }
  if(Array.isArray(data)){
    return data.map(transformBigInt);
  }
  if(typeof data === 'object' && data !== null){
    if(data instanceof Date){
      return data
    }
    return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, transformBigInt(value)]));
  }
  return data;
};

@Injectable()
export class InterceptorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    return next.handle().pipe(map((data) => {
      return {
        timestmap: new Date().toISOString(),
        data: transformBigInt(data),
        path: request.url,
        message: 'success',//业务逻辑自定义
        code: 200,//业务逻辑自定义
        success: true,
      };
    }));
  }
}

接下来对异常也格式化统一处理一下,逻辑思路与全局拦截器类似。当前端发起不符合规范和要求的网络请求,后端就会返回异常信息,方便前端去统一处理。

image-20251212235546507

图3-5 异常情况的处理

此时我们需要总结nest命令的表1-2,找到filter命令来生成一个过滤器。命令是:nest g f <过滤器名称>,我们就通过nest g f exceptionFilter来生成一份过滤器吧。成功在src文件夹下创建exception-filter文件夹和exception-filter文件夹下的exception-filter.filter.ts文件,这些生成文件的命名规则都是一致的,不再赘述。

// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';

@Catch()
export class ExceptionFilterFilter<T> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

通过以上exception-filter.filter.ts文件的代码,我们发现异常处理@Catch()装饰器是空的,空的表示处理所有的异常操作,包括非HTTP请求都会处理,但我希望这个业务只处理和HTTP相关的异常就可以了。所以我们需要从@nestjs/common中引入一个HttpException类,然后让@Catch()装饰器去继承HttpException类就可以了。

// src/exception-filter/exception-filter.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';

@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

在这里我们可以看到这个很有意思的设计理念,通过Nest命令生成的内容,它希望我们都能用得上,这种思想和TypeScript所想表达的含义是一致的,只写用得上且必要的部分。因此在通过Nest CLI 在生成过滤器模板时,会会默认使用 @Catch()(不带任何参数),示例性地展示如何捕获所有异常。但它只是一个类模板,需要我们手动把它注册为全局过滤器,或者在控制器上使用。

只有当我们明确在@Catch()中指定具体的异常类型(如 @Catch(HttpException) 或 @Catch(WsException)),过滤器才会从“捕获所有异常”转变为“仅处理特定类型的异常”。如图3-6所示的官方文档也说明了不同协议层(HTTP 与 WebSocket)对应的异常类型不同,因此需要在 @Catch() 中明确指定对应的异常类型。

image-20251212235819873

图3-6 HTTP异常过滤层的说明

接下来我们来对异常处理情况进行统一的格式化处理。这里的code(异常状态码)就不采用我们自定义的,而是使用exception内部定义的状态码,因为Nest内置的HttpException已经为所有常见错误定义了标准化的状态码(如 400、401、403、404、500 等),这些状态码符合 HTTP 协议本身的语义。直接使用exception.getStatus()可以确保服务端返回的错误信息在网络层面是可预测和通用的。Nest.js内置异常处理层说明如图3-7所示。

image-20251213000123007

图3-7 Nest.js内置异常处理层说明

当token过期了,exception.getStatus()会自动识别并设置成401状态码,没有权限则403状态码。因此exception.getStatus()会自动化的根据实际情况去调整,非常方便。对应的详细讲解可阅读Nest.js的官方文档:Exception filters | NestJS - A progressive Node.js framework

如果再自定义一套error code,就等于需要维护两套错误体系:HTTP 状态码 + 我们自己额外设计的业务错误码,这会造成重复劳动、文档负担加重以及维护难度上升。而直接使用 HttpException 内部的状态码可以保持异常捕获逻辑与框架一致,不需要额外重复造轮子。

// src/exception-filter/exception-filter.filter.ts文件
import { ArgumentsHost, Catch, ExceptionFilter,HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class ExceptionFilterFilter<T extends HttpException> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const request = ctx.getRequest<Request>()
    const response = ctx.getResponse<Response>()
    return response.status(exception.getStatus()).json({
      timestamp: new Date().toISOString(),
      path: request.url,
      message: exception.message,
      code: exception.getStatus(),
      success: false,
    })
  }
}

最后,过滤器和拦截器一样,在main.ts文件中全局注册一下,则可以作用于整个项目的异常情况处理。

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { InterceptorInterceptor } from './interceptor/interceptor.interceptor';
import { ExceptionFilterFilter } from './exception-filter/exception-filter.filter';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new InterceptorInterceptor());
  app.useGlobalFilters(new ExceptionFilterFilter());
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

全局异常情况的过滤处理效果如图3-8所示。

image-20251212235417767

图3-8 全局异常情况的过滤处理效果

❌
❌