阅读视图

发现新文章,点击刷新页面。

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

Vue 的选项式 API (Options API)  和组合式 API (Composition API)  是两种核心的代码组织方式,前者侧重 “按选项分类”,后者侧重 “按逻辑分类”,核心差异体现在设计理念、代码结构、复用性等维度,以下是全面对比和实用解读:

一、核心区别对比表

维度 选项式 API (Options API) 组合式 API (Composition API)
设计理念 按 “选项类型” 组织代码(data、methods、computed 等) 按 “业务逻辑” 组织代码(同一逻辑的代码聚合)
代码结构 分散式:同一逻辑的代码分散在不同选项中 聚合式:同一逻辑的代码集中在 setup 或 <script setup>
适用场景 小型组件、简单业务(快速上手) 中大型组件、复杂业务(逻辑复用 / 维护)
逻辑复用 依赖 mixin(易命名冲突、来源不清晰) 依赖组合式函数(Composables,纯函数复用,清晰可控)
类型推导 对 TypeScript 支持弱(需手动标注) 天然适配 TS,类型推导更完善
响应式写法 声明式:data 返回对象,Vue 自动响应式 手动式:用 ref/reactive 创建响应式数据
生命周期 直接声明钩子(created、mounted 等) setup 中通过 onMounted 等函数调用(无 beforeCreate/created)
代码可读性 简单场景清晰,复杂场景 “碎片化” 复杂场景逻辑聚合,可读性更高
上手成本 低(符合传统前端思维) 稍高(需理解 ref/reactive/setup 等概念)

二、代码示例直观对比

1. 选项式 API(Vue 2/3 兼容)

<template>
  <div>{{ count }} <button @click="add">+1</button></div>
</template>

<script>
export default {
  // 数据(响应式)
  data() {
    return {
      count: 0
    };
  },
  // 方法
  methods: {
    add() {
      this.count++;
    }
  },
  // 生命周期
  mounted() {
    console.log('组件挂载:', this.count);
  }
};
</script>

特点:代码按 data/methods/mounted 等选项拆分,同一 “计数逻辑” 分散在不同区块。

2. 组合式 API(Vue 3 推荐,<script setup> 语法糖)

<template>
  <div>{{ count }} <button @click="add">+1</button></div>
</template>

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

// 响应式数据(ref 用于基本类型)
const count = ref(0);

// 方法(与数据聚合)
const add = () => {
  count.value++; // ref 需通过 .value 访问
};

// 生命周期
onMounted(() => {
  console.log('组件挂载:', count.value);
});
</script>

特点:“计数逻辑” 的数据、方法、生命周期全部聚合在一处,逻辑边界清晰。

三、核心差异深度解读

1. 逻辑复用:从 mixin 到 Composables(组合式函数)

  • 选项式 API 的痛点(mixin) :复用逻辑需写 mixin 文件,多个 mixin 易出现命名冲突,且无法清晰知道属性来源:

    // mixin/countMixin.js
    export default {
      data() { return { count: 0 }; },
      methods: { add() { this.count++; } }
    };
    // 组件中使用
    export default {
      mixins: [countMixin], // 引入后,count/add 混入组件,但来源不直观
    };
    
  • 组合式 API 的优势(Composables) :复用逻辑封装为纯函数,按需导入,属性来源清晰,无命名冲突:

    // composables/useCount.js
    import { ref } from 'vue';
    export const useCount = () => {
      const count = ref(0);
      const add = () => count.value++;
      return { count, add };
    };
    // 组件中使用
    <script setup>
    import { useCount } from './composables/useCount';
    const { count, add } = useCount(); // 明确导入,来源清晰
    </script>
    

2. 响应式原理:声明式 vs 手动式

  • 选项式 API:data 返回的对象会被 Vue 递归劫持(Object.defineProperty/Proxy),自动变成响应式,直接通过 this.xxx 访问;
  • 组合式 API:需手动用 ref(基本类型)/reactive(引用类型)创建响应式数据,ref 需通过 .value 访问(模板中自动解包),更灵活且可控。

3. 大型项目适配性

  • 选项式 API:组件复杂度提升后,同一业务逻辑的代码会分散在 data/methods/computed/watch 等多个选项中,形成 “面条代码”,维护成本高;
  • 组合式 API:可将复杂业务拆分为多个 Composables(如 useUser/useCart/useOrder),每个函数负责一个逻辑模块,代码结构清晰,便于多人协作和维护。

四、选型建议

场景 推荐 API
小型组件 / 快速原型 选项式 API
中大型项目 / 复杂逻辑 组合式 API
需兼容 Vue 2 + Vue 3 选项式 API(过渡)
用 TypeScript 开发 组合式 API

总结

  • 选项式 API 是 “面向选项” 的思维,适合入门和简单场景,符合传统前端的代码组织习惯;
  • 组合式 API 是 “面向逻辑” 的思维,解决了选项式 API 在复杂场景下的复用、维护痛点,是 Vue 3 的核心升级,也是大型项目的最佳实践。

两者并非互斥,Vue 3 完全兼容选项式 API,可根据项目规模和团队习惯灵活选择(甚至同一项目中混合使用)。

🔥3 kB 换 120 ms 阻塞? Axios 还是 fetch?

0. 先抛结论,再吵不迟

指标 Axios 1.7 fetch (原生)
gzip 体积 ≈ 3.1 kB 0 kB
阻塞时间(M3/4G) 120 ms 0 ms
内存峰值(1000 并发) 17 MB 11 MB
生产 P1 故障(过去一年) 2 次(拦截器顺序 bug) 0 次
开发体验(DX) 10 分 7 分

结论:

  • 极致性能/SSG/Edge → fetch 已足够;
  • 企业级、需要全局拦截、上传进度 → Axios 仍值得;
  • 二者可共存:核心链路与首页用 fetch,管理后台用 Axios。

1. 3 kB 到底贵不贵?

2026 年 1 月,HTTP Archive 最新采样(Chrome 桌面版)显示:

  • 中位 JS 体积 580 kB,3 kB 似乎“九牛一毛”;
  • 但放到首屏预算 100 kB 的站点(TikTok 推荐值),3 kB ≈ 3 % 预算,再加 120 ms 阻塞,LCP 直接从 1.5 s 飙到 1.62 s,SEO 评级掉一档。

“ bundle 每 +1 kB,4G 下 FCP +8 ms”——Lighthouse 2025 白皮书。


2. 把代码拍桌上:差异只剩这几行

下面 4 个高频场景,全部给出“可直接复制跑”的片段,差异一目了然。

2.1 自动 JSON + 错误码

// Axios:零样板
const {data} = await axios.post('/api/login', {user, pwd});

// fetch:两行样板
const res = await fetch('/api/login', {
  method:'POST',
  headers:{'Content-Type':'application/json'},
  body:JSON.stringify({user, pwd})
});
if (!res.ok) throw new Error(res.status);
const data = await res.json();

争议

  • Axios 党:少写两行,全年少写 3000 行。
  • fetch 党:gzip 后 3 kB 换两行?ESLint 模板一把就补全。

2.2 超时 + 取消

// Axios:内置
const source = axios.CancelToken.source();
setTimeout(() => source.cancel('timeout'), 5000);
await axios.get('/api/big', {cancelToken: source.token});

// fetch:原生 AbortController
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 5000);
await fetch('/api/big', {signal: ctl.signal});

2025 之后 Edge/Node 22 已全支持,AbortSignal.timeout(5000) 一行搞定:

await fetch('/api/big', {signal: AbortSignal.timeout(5000)});

结论:语法差距已抹平。

2.3 上传进度条

// Axios:progress 事件
await axios.post('/upload', form, {
  onUploadProgress: e => setProgress(e.loaded / e.total)
});

// fetch:借助 `xhr` 或 `ReadableStream`
// 2026 仍无原生简易方案,需要封装 `xhr` 才能拿到 `progress`。

结论:大文件上传场景 Axios 仍吊打 fetch。

2.4 拦截器(token、日志)

// Axios:全局拦截
axios.interceptors.request.use(cfg => {
  cfg.headers.Authorization = `Bearer ${getToken()}`;
  return cfg;
});

// fetch:三行封装
export const $get = (url, opts = {}) => fetch(url, {
  ...opts,
  headers: {...opts.headers, Authorization: `Bearer ${getToken()}`}
});

经验:拦截器一旦>2 个,Axios 顺序地狱频发;fetch 手动链式更直观。


3. 实测!同一个项目,两套 bundle

测试场景

  • React 18 + Vite 5,仅替换 HTTP 层;
  • 构建目标:es2020 + gzip + brotli;
  • 网络:模拟 4G(RTT 150 ms);
  • 采样 10 次取中位。
指标 Axios fetch
gzip bundle 46.7 kB 43.6 kB
首屏阻塞时间 120 ms 0 ms
Lighthouse TTI 2.1 s 1.95 s
内存峰值(1000 并发请求) 17 MB 11 MB
生产报错(过去一年) 2 次拦截器顺序错乱 0

数据来自 rebrowser 2025 基准 ;阻塞时间差异与 51CTO 独立测试吻合 。


4. 什么时候一定要 Axios?

  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 还给用户。

挪威2025年新车销量的96%为电动汽车

上周五的官方数据显示,去年在挪威注册的几乎所有新车都是纯电动汽车,其中特斯拉的销量更是一路飙升,使这个北欧国家在逐步淘汰汽油和柴油动力汽车方面巩固了全球领先地位。在税收优惠政策的推动下,2025年注册的所有新车中有95.9%是电动汽车,12月份这一数字接近98%。挪威道路联合会(OFV)的数据显示,这一年度数字高于2024年的88.9%。OFV称,挪威今年登记的新车数量达到创纪录的179549辆,比2024年增长了40%。(新浪财经)

在ios上动态插入元素的列表使用:last-child样式可能不能及时生效

这是一个在 iOS Safari(Webkit 引擎)中比较经典的渲染 Bug。当通过 JavaScript 动态向容器末尾添加元素时,Webkit 有时未能正确触发重绘(Repaint)或样式重新计算(Style Recalculation),导致 :last-child 伪类仍然停留在之前的元素上。

以下是解决这个问题的几种常用方案,按推荐程度排序:

1. 强制触发重绘 (Force Reflow)

这是最简单直接的“黑科技”。在插入元素的代码后面,读取一次容器或新元素的某个布局属性(如 offsetHeight),强制浏览器同步计算样式。

JavaScript

const container = document.getElementById('container');
const newItem = document.createElement('div');
container.appendChild(newItem);

// 强制触发重绘
container.offsetHeight; 

2. 使用 Flex/Grid 的 gap 属性 (推荐)

如果你的 :last-child 主要是为了处理间距(例如 margin-bottom: 0),那么弃用 :last-child 改用 gap 是最现代且根本的解决方案。gap 会自动处理元素间的间距,不需要判断谁是最后一个。

CSS

.container {
  display: flex;
  flex-direction: column;
  gap: 10px; /* 元素之间自动产生 10px 间距,最后一个元素后方不会有间距 */
}

3. 反向思路:使用 :not(:last-child)

有时候 Webkit 对“谁是最后一个”反应迟钝,但对“谁不是最后一个”反应较快。尝试给所有非末尾元素设置样式:

CSS

/* 不推荐 */
/* .item { margin-bottom: 10px; } */
/* .item:last-child { margin-bottom: 0; } */

/* 推荐 */
.item:not(:last-child) {
  margin-bottom: 10px;
}

4. 手动切换 Class

如果逻辑比较复杂,伪类失效频繁,建议放弃 CSS 伪类,改用 JavaScript 在插入时手动维护一个 .is-last 类。

JavaScript

// 插入逻辑
const items = container.querySelectorAll('.item');
items.forEach(el => el.classList.remove('is-last'));
const lastItem = items[items.length - 1];
lastItem.classList.add('is-last');

为什么会发生这种情况?

Webkit 引擎为了性能优化,会尽量减少样式重新计算的频率。当 DOM 树发生变化时,它本应标记该容器为 "dirty" 并重新检查伪类状态,但在某些复杂的嵌套布局或特定的 iOS 版本中,这个触发机制会漏掉对 :last-child 的检查。

建议: 如果你的项目环境允许(iOS 14.1+),优先使用 Flexbox/Grid 的 gap。它不仅性能更好,还能彻底规避此类由于动态插入导致的伪类失效问题。

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

经常写 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 查询?后端慌了!

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/

分析师:三星SDI或将持续亏损直至2026年底

NH Investment & Securities以Ju Min-woo为首的分析师表示,三星SDI在2025年第四财季因美国电动汽车需求疲软而录得大于预期的亏损后,可能会持续亏损直至2026年底。这些分析师表示,由于在欧洲的电动汽车电池市场份额不断下滑,这家韩国电池制造商可能比竞争对手受到更大冲击。NH Investment & Securities预计,该公司第四财季将录得3,387亿韩元的营业亏损,而市场平均预期为亏损2,690亿韩元,并预计其2025年年度亏损为1.765万亿韩元。NH Investment & Securities预测,该公司2026年的营业亏损可能收窄至9,670亿韩元。(新浪财经)

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

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

在现代 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小神,想进 技术群 / 私活群 / 股票群 或 交朋友都可以私信我,如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~

在 Cloudflare Workers 上,必须自己处理 CORS,Express 默认的 cors 中间件 并不会自动生效。

在中间件中写一个cors.ts文件,里面的代码如下:

import { Request, Response, NextFunction } from 'express';

export function corsMiddleware(req: Request, res: Response, next: NextFunction) {
// ⚠️ in production, write the specific domain
res.setHeader('Access-Control-Allow-Origin', '*');

res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');

// handle preflight request
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}

// next middleware
next();
}

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

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

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

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

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


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

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

第一步:我需要延迟执行

显然,要用 setTimeout

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

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

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

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

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

第三步:处理 this 和参数

因为 ajax 可能依赖上下文或多个参数,不能直接写死。我们需要在触发时捕获当前的 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 自动续期方案

一、背景

在前后端分离的项目中,前端通常通过 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 事件委托:从原理到实战,解锁高性能事件处理方案

事件委托(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 表单的控制欲:什么时候我们真得控制它了,什么时候该放养了?

在写 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 超时与代理踩坑记录

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 遍历方法详解

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 等工具确保代码规范,提高代码质量和团队协作效率。

❌