阅读视图

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

移动web开发常见问题

1. 去掉图片下方的空白间隙

默认情况下,图片会与其下方的元素产生一段空白的间隙

<img>默认是inline(行内替换元素),遵循基线对齐,下方预留文字下行空间;

image.png

<style>
  .box img {
    width: 200px;
    border: 1px solid red;
    /*1. 添加此样式*/
    vertical-align: top;
    /*2. 或者转为块级元素*/
    display:black;
  }
  .box p {
    margin: 0;
    height: 20px;
    background-color: khaki;
  }
</style>

<div class="box">
  <img src="./images/pic1.png" alt="" />
  <p></p>
</div>
  1. 清除父元素的行高影响图片的基线对齐会受父元素 line-height 影响,将父元素行高设为 0,也能消除间隙:
   .img-parent { /* .img-parent 是 img 的父容器 */
     line-height: 0;
   }

2. 元素宽高等比缩放

GIF2025-7-816-26-47.0becbb02.gif

利用 padding-top 来撑开元素的高,padding-top 百分比是相对于当前元素的父元素宽而言,而当前元素宽与父元素宽一样,则相当于元素的高是相对于当前元素的宽而言。

<style>
 .box {
    width: 25%;
  }
 .box .bl16-9 {
    --bl: calc(9 / 16 * 100%);
    padding-top: var(--bl);
    background-color: khaki;
    position: relative;
  }
  .box .content {
    position: absolute;
    inset: 0;
  }
</style>

<!-- 元素宽高比为 16:9 等比缩放 -->
<div class="box">
  <div class="bl16-9">
    <div class="content">元素宽高比为 16:9 等比缩放</div>
  </div>
</div>

3. 图片等比缩放

GIF2025-7-816-24-32.28c3c218.gif

在实际的网站上线后,数据都是从后台读取的,用户在上传图片尺寸时,并不一定会按照设计师设计的比例来上传,这样就会造成图片上传后,大小不一样。

  • 图片高度过小,会在下面留下空白
  • 图片高度过大,会有一部分下面的内容看不到
  • 我们实际在设计一张图时,重要的内容会在中间显示,所以最理想的效果是让图片能水平垂直居中于容器中

要实现图片按一定的宽高比等比缩放,可以先将图片的直接父元素实现宽高等比缩放,然后再给图片添加如下代码

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
<style>
  .box {
    width: 25%;
  }
  .box .bl16-9 {
    --bl: calc(9 / 16 * 100%);
    padding-top: var(--bl);
    background-color: khaki;
    position: relative;
  }
  .box .content {
    position: absolute;
    inset: 0;
  }
  .box img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
</style>

<!-- 元素宽高比为 16:9 等比缩放 -->
<div class="box">
  <div class="bl16-9">
    <div class="content">
      <img src="./images/pic1.png" alt="" />
    </div>
  </div>
</div>

4. 背景图等比缩放

GIF-2022-8-16-18-50-31.f89069c2.gif

  • 当背景图片的宽高比与容器的宽高比不一样时
  • 我们希望不管容器宽高如何缩放,图片一直填充整个容器,然后水平垂直居中显示
/* 背景图填充整个容器 */
background-size: cover;
<style>
  body {
    margin: 0;
  }
  .box {
    width: 100vw;
    height: 31.467vw;
    /* 背景图片  不重复  水平垂直居中显示 */
    background: url(./images/banner1-@2x.png) no-repeat center;
    /* 背景图填充整个容器 */
    background-size: cover;
  }
</style>
<body>
  <div class="box"></div>
</body>

5. 2倍精灵图使用

  • 精灵图采用的是 2 倍图
  • 所以在处理精灵图时,我们需要通过 background-size: 50% auto; ,来将背景图片大小缩小一半
  • 测量尺寸时,也需要按一半的大小来测量

image.png

<style>
  .icon-play {
    width: 26px;
    height: 26px;
    border: 1px solid red;
    background-image: url(images/sprite.png);
    background-position: -36px -29px;
    background-size: 200px;
  }
</style>
<body>
  <div class="icon-play"></div>
</body>

6.经典的 1 像素问题

6.1、何为 1 像素问题

PSD 设计稿

  • 我们的设计稿是以 750px 宽来设计的,而我们实际开发时,代码是按 375px 来的。
  • 在 750px 设计稿中的 1px,按我们实际的开发代码来说,要折半,折成 0.5px 才对。
  • 但是不同手机上,不同浏览器对小数的处理是不一样的
  • 0.5px 在一些老的 IOS 和 Android 设备上不支持,他会把低于 0.5px 当成 0 来处理,>= 0.5px 当成 1px 来显示。
  • IOS 上会把>= 0.75px 的当作 1px 来处理 ,< 0.75 当成 0.5px 来处理 < 0.5px 当成 0 来处理
  • 而且 IOS 上,用 height: 1px 来代替 border-bottom: 1px solid red;测出的效果不同
  • 不同的手机上,效果不一样,具体以真机测试为主
  • 所以直接把代码折半,设置成 0.5px 显然是达不到目的。

1px 实际显示的大小

1px 在 dpr (像素比)不同时,其显示的大小不同,如下

image.png 所以在不同 dpr 下,显示的 1px 的线的宽度是下面图标出的蓝色的高度大小(1 物理像素大小) image.png 因此, 1px 像素问题(用当前设备的物理像素的1px进行渲染这根线的高度),本质上不是问题,如果公司觉得没有必要,也就不用处理。
如果公司认为就是要用设备能显示的最细的那个小方格显示,那我们就要处理这个问题。

6.2、1px 像素解决方案(最优的解决方案 transform+伪元素来实现)

实现原理:

  • 利用伪元素来绘制 1px 的线条,然后利用定位,把线条放在对应位置
  • 利用 media 查询判断不同的设备像素比对线条进行缩放
<style>
  .box {
    height: 50px;
    margin: 0 auto;
    position: relative;
  }
  .border-1px::before {
    position: absolute;
    content: "";
    height: 1px;
    width: 100%;
    background-color: red;
    bottom: 0;
    /* transform: scaleY(0.5); */
    /* 变换原点 */
    transform-origin: 50% 0%;
  }
  /* dpr=2,需要缩放一半,即0.5 */
  @media only screen and (-webkit-min-device-pixel-ratio: 2) {
    .border-1px:before {
      transform: scaleY(0.5);
    }
  }
  /* dpr=3,需要缩放到1/3,即0.33 */
  @media only screen and (-webkit-min-device-pixel-ratio: 3) {
    .border-1px:before {
      transform: scaleY(0.33);
    }
  }
</style>
<body>
  <div class="box border-1px"></div>
</body>

也可以通过 js 来判断 dpr,然后给元素添加对应的 Class 名字,来实现

if (window.devicePixelRatio && devicePixelRatio >= 2) {
  document.querySelector(".box").className = "border-1px";
}

React 之 自定义 Hooks 🚀

自定义 Hooks 🚀

在 React 的世界里,Hooks 就像一把神奇的钥匙,为函数组件打开了状态管理和生命周期的大门。今天我们就来深入探索自定义 Hooks 的奥秘,看看它如何让我们的代码更优雅、更可复用!

一、hooks 详解 🧐

1. 什么是 hooks ?

Hooks 是 React 16.8 引入的新特性,它是一种函数编程思想的实践,允许我们在不编写类组件的情况下,在函数组件中使用状态(State)和其他 React 特性(如生命周期)。简单来说,Hooks 就是 “钩子”,能让我们轻松 “钩入” React 的内部特性,让函数组件拥有更强大的能力。

2. hooks 分类

Hooks 主要分为两类:

  • React 内置 Hooks:如useState(管理状态)、useEffect(处理副作用)、useContext(共享上下文)等,这些是 React 官方提供的基础工具。
  • 自定义 Hooks:由开发者根据业务需求封装的 Hooks,命名以use开头,本质是对内置 Hooks 和业务逻辑的组合封装,方便复用。

(前面我们讲解过 React 内置的 hooks 函数,感兴趣的小伙伴可以去翻翻我前面的文章看看)

3. hooks 有什么作用?

  • 让函数组件具备状态管理能力,摆脱类组件的繁琐语法(如this绑定、生命周期函数嵌套)。
  • 将组件中的相关逻辑聚合在一起,而非分散在不同的生命周期函数中(比如类组件中componentDidMountcomponentDidUpdate可能写重复逻辑)。
  • 实现逻辑复用,通过自定义 Hooks 将相同的状态逻辑抽离,供多个组件使用。

4. 为什么需要自定义 hooks ?

在开发中,我们经常会遇到多个组件需要共享相同状态逻辑的场景。比如:多个组件都需要监听鼠标位置、都需要处理本地存储数据、都需要发起相同的 API 请求等。

如果没有自定义 Hooks,我们可能会通过 “复制粘贴代码” 或 “高阶组件”“render props” 等方式复用逻辑,但这些方式要么导致代码冗余,要么增加组件层级复杂度。

而自定义 Hooks 就像一个 “逻辑容器”,能将这些重复逻辑抽离成独立函数,让组件只关注 UI 渲染,极大提升代码的复用性和可维护性!

二、先来看一个简单案例:响应式显示鼠标的位置 🖱️

1. 需求分析

我们要实现一个包含两个核心功能的小应用:

(1)计数功能:一个计数器,点击按钮可以增加数字。

(2)条件显示鼠标位置:当计数器的值为偶数时,显示鼠标在页面上的实时坐标;为奇数时,不显示。

2. 核心实现(两个文件)

(1)App2.jsx:主组件,负责管理计数器状态和根据计数奇偶性条件渲染鼠标位置组件。

(2)useMouse.js:自定义 Hooks,封装鼠标位置监听的逻辑,提供xy坐标供组件使用(向外暴露的状态和方法放在 return 中返回)。

3. 代码展示

(1)App2.jsx

jsx

import { useState } from 'react';
import { useMouse } from './hooks/useMouse.js';

// 鼠标位置展示组件(纯UI组件)
function MouseMove() {
    // 调用自定义Hook获取鼠标坐标
    const { x, y } = useMouse();
    return (
        <>
            <div>
                鼠标位置:{x}, {y}
            </div>
        </>
    );
}

export default function App() {
    // 定义计数器状态,初始值为0
    const [count, setCount] = useState(0);

    return (
        <>
            {/* 显示当前计数 */}
            {count}
            {/* 点击按钮增加计数(使用函数式更新确保获取最新count) */}
            <button onClick={() => setCount((count) => count + 1)}>
                点击增加
            </button>
            {/* 当count为偶数时,渲染MouseMove组件显示鼠标位置 */}
            {count % 2 === 0 && <MouseMove />}
        </>
    )
}

代码逻辑详解

  • App组件通过useState管理计数器count的状态,点击按钮时通过setCount更新值。
  • 定义了MouseMove组件,它不包含任何业务逻辑,仅通过调用useMouse()获取鼠标坐标并渲染,是一个纯 UI 组件。
  • 通过条件渲染{count % 2 === 0 && <MouseMove />}实现 “偶数显示鼠标位置,奇数不显示” 的需求。
(2)useMouse.js

js

import { useState, useEffect } from 'react';

// 自定义Hook:封装鼠标位置监听逻辑(命名以use开头)
export function useMouse() {
    // 定义状态存储鼠标x、y坐标,初始值为0
    const [x, setX] = useState(0);
    const [y, setY] = useState(0);

    // 副作用:监听鼠标移动事件
    useEffect(() => {
        // 事件处理函数:更新x、y坐标
        const update = (event) => {
            console.log('/////');
            setX(event.pageX); // 更新x坐标为鼠标相对于文档的水平位置
            setY(event.pageY); // 更新y坐标为鼠标相对于文档的垂直位置
        }
        // 绑定mousemove事件,触发时调用update更新坐标
        window.addEventListener('mousemove', update);
        console.log('||||| 挂载'); // 组件挂载时打印

        // 清理函数:组件卸载时移除事件监听,避免内存泄漏
        return () => {
            window.removeEventListener('mousemove', update); // 移除相同的事件处理函数
            console.log('===== 清除'); // 清理时打印
        }
    }, []); // 空依赖数组:仅在组件挂载时执行一次,卸载时执行清理

    // 返回鼠标坐标,供组件使用
    return {
        x,
        y,
    }
}

代码逻辑详解

  • useMouse遵循命名规范(以use开头),内部可以调用内置 Hooks(useStateuseEffect)。

  • useState定义xy状态,分别存储鼠标的水平和垂直坐标。

  • useEffect处理鼠标监听的副作用:

    • 挂载时:绑定mousemove事件,鼠标移动时触发update函数更新xy
    • 卸载时:通过useEffect的返回函数移除mousemove事件监听,避免组件已卸载但事件仍触发的内存泄漏(比如当count为奇数时,MouseMove组件卸载,此时会执行清理函数)。
  • 最后返回包含xy的对象,让组件可以获取鼠标坐标。

QQ20260103-184336.png

4. 效果展示:

  • 当点击按钮使count为 0、2、4 等偶数时,页面会显示 “鼠标位置:x, y”,移动鼠标时坐标会实时更新,控制台打印 “||||| 挂载”。
  • 当点击按钮使count为 0、2、4 等偶数时,移动鼠标,控制台打印“/////”
  • count为 1、3、5 等奇数时,鼠标位置不显示,控制台打印 “===== 清除”且不打印“/////”(表示事件监听已移除)。

QQ202613-165111.gif

三、详解自定义 hooks 📚

通过上面的简单案例,我们可以总结出关于自定义 Hooks 的必备知识:

1. 什么时候需要自定义 hooks ?

(1)逻辑复用场景当多个组件需要共享相同的状态逻辑时,通过自定义 Hook 封装可避免代码重复。上面案例中,useMouse封装了鼠标位置监听的完整逻辑(状态管理、事件绑定 / 解绑),使得MouseMove组件无需重复编写该逻辑。若其他组件(如 “鼠标轨迹绘制组件”)也需要获取鼠标位置,可直接复用useMouse

(2)分离 UI 与业务逻辑当组件中同时包含 UI 渲染和复杂业务逻辑(如事件监听、数据处理)时,自定义 Hooks 可将业务逻辑抽离,让组件只专注于 UI 展示。上面案例中,MouseMove组件仅负责渲染鼠标坐标(纯 UI 逻辑),而鼠标监听的业务逻辑被封装在useMouse中,使组件代码更简洁易维护。

(3)抽象复杂副作用逻辑当逻辑涉及useStateuseEffect等 Hook 的组合使用(如状态管理 + 副作用处理)时,自定义 Hook 可将其抽象为独立单元,提高可读性。上面案例中useMouse整合了useState(管理 x、y 坐标)和useEffect(鼠标事件监听 / 清理),将分散的逻辑聚合为可复用的单元。

2. 如何自定义 hooks?

(1)遵循命名规范函数名必须以use开头(如useMouseuseTodos),这是 React 的强制约定,确保 React 能识别 Hook 的调用规则(如只能在组件或其他 Hook 中使用)。

(2)封装核心逻辑在函数内部可调用其他 Hook(如useStateuseEffect),并通过return暴露需要的状态或方法。案例中useMouse的实现步骤:

  • useState定义xy状态,存储鼠标坐标;
  • useEffect绑定mousemove事件,实时更新坐标;
  • 通过return { x, y }将坐标暴露给组件使用。

(3)设计返回值根据需求返回状态、方法或对象,方便组件灵活使用。案例中返回包含xy的对象,组件通过const { x, y } = useMouse()直接获取坐标;若逻辑复杂(如待办事项管理),可返回状态和操作方法的集合(如{ todos, addTodo, deleteTodo })。

3. 自定义 hooks 有什么注意事项?

(1)严格遵循 Hook 调用规则

  • 只能在组件函数或其他自定义 Hook 中调用(上面案例中useMouseMouseMove组件中调用,符合规则);
  • 不能在循环、条件判断或普通函数中调用(避免 React 无法保证 Hook 调用顺序的一致性)。

(2)清理副作用,避免内存泄漏若 Hook 包含副作用(如事件监听、定时器、API 请求),必须在useEffect的清理函数中移除副作用。上面案例中,useMouseuseEffect的返回函数中调用removeEventListener,确保MouseMove组件卸载时(count为奇数时)移除鼠标监听,避免组件已卸载但事件仍触发的内存泄漏(控制台会打印 “===== 清除” 验证)。

(3)保持单一职责一个自定义 Hook 应专注于解决一个特定问题。上面案例中useMouse仅负责鼠标位置监听,职责单一;若强行加入其他逻辑(如键盘监听)会导致 Hook 臃肿,降低复用性。

四、复杂案例实战:待办事项(Todo List)应用 📝

1. 需求分析:

本案例是一个基于 React 的待办事项应用,核心需求围绕待办事项的全生命周期管理,具体包括:

  • 输入框输入待办内容,点击添加按钮创建新待办;
  • 勾选复选框标记待办事项为 “已完成” 或 “未完成”;
  • 点击删除按钮移除特定待办事项;
  • 页面刷新后,已添加的待办数据不丢失(本地持久化);
  • 无待办事项时显示空状态提示。

核心痛点:如何将数据管理逻辑与 UI 渲染逻辑分离,实现代码复用和维护性提升 —— 这正是自定义 Hook 的核心应用场景。

2. 实现架构设计:

(1)整体架构采用 “UI 组件 + 自定义 Hook” 的分离模式:

  • UI 组件:负责渲染界面和处理用户交互(输入、点击等);
  • 自定义 Hooks(useTodos):封装数据状态、业务逻辑和持久化操作。

(2)核心设计:自定义 hooks 的职责useTodos作为数据和逻辑的 “中央处理器”,承担以下职责:

  • 管理待办事项的响应式状态(todos数组);
  • 提供操作待办的方法(添加、切换状态、删除);
  • 实现数据本地持久化(localStorage读写)。

3. 代码展示(核心代码)

(1)自定义 Hook:useTodos.js

js

// 封装响应式的todos业务逻辑
import { useState, useEffect } from 'react';

// 定义localStorage的键名,用于存储待办数据
const STORAGE_KEY = 'todos';

// 从localStorage加载待办数据
function loadFromStorge() {
    const StoredTodos = localStorage.getItem(STORAGE_KEY);
    // 若有数据则解析为JSON,否则返回空数组
    return StoredTodos ? JSON.parse(StoredTodos) : [];
}

// 将待办数据保存到localStorage
function saveToStorage(todos) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

export const useTodos = () => {
    // 初始化todos状态:从localStorage加载(useState接收函数,确保只执行一次)
    const [todos, setTodos] = useState(loadFromStorge);

    // 监听todos变化,实时保存到localStorage(持久化)
    useEffect(() => {
        saveToStorage(todos);
    }, [todos]); // 依赖todos:只有todos变化时才执行

    // 添加待办事项
    const addTodo = (text) => {
        setTodos([
            ...todos, // 复制现有待办
            { 
                id: Date.now(), // 用时间戳作为唯一ID
                text, // 待办内容
                completed: false // 初始状态为未完成
            }
        ]);
    }

    // 切换待办事项的完成状态
    const toggleTodo = (id) => {
        setTodos(
            todos.map((todo) => {
                // 找到目标待办,反转completed状态
                if (todo.id === id) {
                    return { ...todo, completed: !todo.completed };
                }
                return todo; // 非目标待办不变
            })
        );
    }

    // 删除待办事项
    const deleteTodo = (id) => {
        setTodos(
            todos.filter((todo) => todo.id !== id) // 过滤掉要删除的待办
        );
    }

    // 暴露状态和方法给组件使用
    return {
        todos,
        addTodo,
        toggleTodo,
        deleteTodo,
    }
}

逻辑详解

  • loadFromStorgesaveToStorage:封装localStorage的读写逻辑,实现数据持久化(页面刷新后数据不丢失)。
  • useTodos内部用useState管理todos状态,初始化时从本地存储加载数据。
  • useEffect监听todos变化,每次变化都调用saveToStorage保存到本地,确保数据实时同步。
  • 提供addTodo(添加)、toggleTodo(切换状态)、deleteTodo(删除)三个方法,通过setTodos更新状态,遵循 “不可变数据” 原则(用扩展运算符、mapfilter创建新数组)。
  • 最后返回todos状态和操作方法,供 UI 组件使用。
(2)UI 组件:App.jsx(主组件)

jsx

import { useState } from 'react';
import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';

export default function App() {
    // 调用自定义Hook获取待办数据和操作方法
    const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

    return (
        <>
            {/* 输入组件:传递addTodo方法用于添加待办 */}
            <TodoInput addTodo={addTodo} />
            {/* 条件渲染:有待办时显示列表,否则显示空状态 */}
            {
                todos.length > 0 ?
                    <TodoList
                        todos={todos}
                        deleteTodo={deleteTodo}
                        toggleTodo={toggleTodo}
                    /> : 
                    (<div>暂无待办事项</div>)
            }
        </>
    )
}

逻辑详解

  • App组件作为入口,通过useTodos()获取待办数据(todos)和操作方法(addTodo等)。
  • 渲染TodoInput组件(负责输入待办)并传递addTodo方法,让输入组件能触发添加操作。
  • 渲染TodoList组件(负责展示待办列表)并传递todosdeleteTodotoggleTodo,让列表组件能展示数据和处理删除 / 切换操作。
  • 通过条件渲染实现 “无待办时显示空提示” 的需求。
(3)UI 组件:TodoInput.jsx(输入组件)

jsx

import { useState } from 'react';

export default function TodoInput({ addTodo }) {
    // 管理输入框文本状态(受控组件)
    const [text, setText] = useState('');

    // 输入框变化时更新text状态
    const handleChange = (e) => {
        setText(e.target.value);
    }

    // 表单提交时添加待办
    const handleSubmit = (e) => {
        e.preventDefault(); // 阻止表单默认提交行为
        if (text.trim() === '') { // 过滤空输入
            return;
        }
        addTodo(text); // 调用父组件传递的addTodo方法添加待办
        setText(''); // 清空输入框
    }

    return (
        <form className='todo-input' onSubmit={handleSubmit}>
            {/* 受控组件:value绑定text,变化触发handleChange */}
            <input type='text' value={text} onChange={handleChange} />
            <button type='submit'>添加</button>
        </form>
    )
}

逻辑详解

  • 纯 UI 组件,仅负责输入框的状态管理和提交逻辑,不关心数据如何存储或处理。
  • 通过useState管理输入框的text状态,实现 “受控组件”(输入框值由 React 状态控制)。
  • 表单提交时调用addTodo(从App组件传递的方法)添加待办,并清空输入框。
(4)UI 组件:TodoList.jsx(列表组件)

jsx

import TodoItem from './TodoItem.jsx';

export default function TodoList({ todos, deleteTodo, toggleTodo }) {
    return (
        <ul className="todo-list">
            {/* 遍历todos数组,为每个待办渲染TodoItem组件 */}
            {todos.map((todo) => (
                <TodoItem 
                 key={todo.id} // 唯一key
                 todo={todo} // 传递单个待办数据
                 deleteTodo={deleteTodo} // 传递删除方法
                 toggleTodo={toggleTodo} // 传递切换状态方法
                />
            ))}
        </ul>
    )
}

逻辑详解

  • 接收todos数组,通过map遍历渲染每个待办项(TodoItem)。
  • 仅负责列表渲染,将单个待办的数据和操作方法传递给TodoItem,自身不处理业务逻辑。
(5)UI 组件:TodoItem.jsx(单个待办项)

jsx

export default function TodoItem({ todo, deleteTodo, toggleTodo }) {
    return (
        <li className="todo-item">
            {/* 复选框:状态绑定todo.completed,点击触发toggleTodo */}
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
            />
            {/* 待办文本:已完成时添加completed类(可用于样式区分) */}
            <span className={todo.completed ? 'completed' : ''}>
                {todo.text}
            </span>
            {/* 删除按钮:点击触发deleteTodo */}
            <button onClick={() => deleteTodo(todo.id)}>删除</button>
        </li>
    )
}

逻辑详解

  • 接收单个待办数据(todo)和操作方法,渲染复选框、文本和删除按钮。
  • 复选框状态与todo.completed绑定,点击时调用toggleTodo切换状态。
  • 文本根据completed状态添加样式类(如划线效果),删除按钮点击时调用deleteTodo删除当前待办。

QQ20260103-170824.png

4. 效果展示:

  • 输入框输入内容,点击 “添加” 按钮,待办列表会新增一项。
  • 勾选复选框,待办文本会显示为 “已完成” 样式。
  • 点击 “删除” 按钮,对应待办项会从列表中移除。
  • 刷新页面后,所有待办数据依然存在(本地存储生效)。
  • 当列表为空时,页面显示 “暂无待办事项”。

QQ202613-1709.gif

5. 案例总结:

  • 逻辑复用最大化useTodos封装了待办事项的所有核心逻辑(状态管理、CRUD 操作、本地持久化),若其他组件(如 “待办统计组件”)需要使用待办数据,可直接调用useTodos,无需重复编写逻辑。
  • UI 与业务彻底分离:所有 UI 组件(TodoInputTodoList等)仅负责渲染和传递交互,不包含任何数据处理逻辑,代码结构清晰,维护成本低。
  • 副作用集中管理:本地存储的读写逻辑被封装在useTodosuseEffect中,避免副作用分散在多个组件中,便于统一维护。

五、面试官会问 🤔

  1. 自定义 Hook 和普通函数有什么区别? 自定义 Hook 以use开头,内部可以调用其他 Hook(如useStateuseEffect),且必须遵循 Hook 调用规则(只能在组件或其他 Hook 中调用);普通函数不能调用 Hook,也没有命名限制。
  2. 为什么自定义 Hook 必须以 use 开头? 这是 React 的约定,确保 React 能通过命名识别 Hook,从而验证 Hook 的调用规则(如避免在条件语句中调用),防止出现逻辑错误。
  3. 如何避免自定义 Hook 中的内存泄漏? 若 Hook 包含副作用(如事件监听、定时器),必须在useEffect的清理函数中移除副作用(如removeEventListenerclearTimeout),确保组件卸载时副作用被清除。
  4. 自定义 Hook 如何实现状态隔离? 每个组件调用自定义 Hook 时,React 都会为其创建独立的状态实例,不同组件之间的状态互不干扰(如两个组件调用useMouse,会分别维护自己的xy状态)。
  5. 什么时候应该抽离自定义 Hook? 当多个组件需要共享相同的状态逻辑,或组件中业务逻辑过于复杂(导致 UI 与逻辑混杂)时,就应该抽离为自定义 Hook。

六、结语 🌟

自定义 Hooks 是 React 中 “逻辑复用” 的最佳实践,它让我们的代码从 “重复冗余” 走向 “简洁高效”,从 “UI 与逻辑混杂” 走向 “职责清晰分离”。

通过本文的两个案例(鼠标位置监听和待办事项应用),我们可以看到:一个设计良好的自定义 Hook,就像一个 “功能模块”,能让组件专注于 UI 渲染,让逻辑专注于业务处理。

希望大家在实际开发中多思考、多实践,将自定义 Hooks 运用到项目中,让代码更优雅、更可维护!🎉

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

整体流程

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

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

流程图大致如下:

createApp() ───> app.mount('#app')
         │                 │
         ▼                 ▼
   createRootComponent    createRenderer
         │                 │
         ▼                 ▼
 setup() / render()   render(vnode) -> patch
         │                 │
         ▼                 ▼
   effect(fn) ────> scheduler -> patch updates

1、createApp 初始化

Vue 应用的入口通常是:

createApp(App).mount('#app')

从源码看 createApp:

// packages/runtime-core/src/apiCreateApp.ts
export function createAppAPI(render) {
  return function createApp(rootComponent, rootProps = null) {
    const app = {
      _component: rootComponent,
      _props: rootProps,
      _container: null,
      _context: createAppContext()
    }
    const proxy = (app._instance = {
      app
    })
    // register global APIs
    // ...
    return {
      mount(container) {
        const vnode = createVNode(rootComponent, rootProps)
        app._container = container
        render(vnode, container)
      },
      unmount() { /* ... */ }
    }
  }
}

关键点:

  • createAppAPI(render) 生成 createApp 函数
  • app 内保存 _component、上下文 _context
  • app.mount 调用 render(vnode, container)

render平台渲染器 注入(在 web 下是 DOM 渲染器)。

2、createVNode 创建虚拟节点(VNode)

在 mount 前会创建一个虚拟节点:

function createVNode(type, props, children) {
  const vnode = {
    type,
    props,
    children,
    shapeFlag: getShapeFlag(type),
    el: null,
    key: props && props.key
  }
  return vnode
}

vnode 是渲染的基础单元:

shapeFlag 用来快速判断 vnode 类型,是内部性能优化。

3、渲染器 Renderer 初始化

Vue3 是平台无关的(runtime-core),真正依赖 DOM 的是在 runtime-dom 中。

创建 Renderer:

export const renderer = createRenderer({
  createElement: hostCreateElement,
  patchProp: hostPatchProp,
  insert: hostInsert,
  remove: hostRemove,
  setElementText: hostSetElementText
})

createRenderer 返回了我们前面在 createApp 中使用的 render(vnode, container) 函数。

4、render & patch

核心渲染入口:

function render(vnode, container) {
  patch(null, vnode, container, null, null)
}

patch 是渲染补丁函数:

function patch(n1, n2, container, parentComponent, anchor) {
  const { type, shapeFlag } = n2
  if (shapeFlag & ShapeFlags.ELEMENT) {
    processElement()
  } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    processComponent(...)
  }
}

简化为:

  • 如果是 DOM 元素 vnode → 挂载/更新
  • 如果是 组件 vnode → 创建组件实例、挂载、渲染子树

5、组件实例创建

当渲染组件时:

function processComponent(n1, n2, container, parentComponent, anchor) {
  mountComponent(n2, container, parentComponent, anchor)
}
function mountComponent(vnode, container, parentComponent, anchor) {
  const instance = createComponentInstance(vnode, parentComponent)
  setupComponent(instance)
  setupRenderEffect(instance, container, anchor)
}
  • processComponent 处理组件
  • mountComponent 挂载组件
    • createComponentInstance 创建组件实例
    • setupComponent 创建组件对象

createComponentInstance:

function createComponentInstance(vnode, parent) {
  const instance = {
    vnode,
    parent,
    proxy: null,
    ctx: {},
    props: {},
    attrs: {},
    slots: {},
    setupState: {},
    isMounted: false,
    subTree: null
  }
  return instance
}

实例保存基础信息,还没运行 setup。

6、 setupComponent(初始化组件)

function setupComponent(instance) {
  initProps(instance, vnode.props)
  initSlots(instance, vnode.children)
  setupStatefulComponent(instance)
}

内部会执行:

const { setup } = Component
if (setup) {
  const setupResult = setup(props, ctx)
  handleSetupResult(instance, setupResult)
}

setup 返回值

  • 返回对象 → 作为响应式状态 state
  • 返回函数 → render 函数

最终让组件拥有 instance.render

7、创建响应式状态

Vue3 的响应式来自 reactivity 包:

const state = reactive({ count: 0 })

底层是 Proxy 拦截 getter/setter:

  • getter:收集依赖
  • setter:触发依赖更新

依赖管理核心是 effect / track / trigger

8、 setupRenderEffect 与首次渲染

创建渲染器副作用,并调度组件挂载和异步更新:

function setupRenderEffect(instance, container, anchor) {
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      const subTree = (instance.subTree = instance.render.call(proxy))
      patch(null, subTree, container, instance, anchor)
      instance.isMounted = true
    } else {
      // 更新更新逻辑
    }
  }, {
    scheduler: queueJob
  })
}

这里:

  • 创建一个 响应式 effect
  • 第一次执行 render 得到 subTree
  • patch 子树到 DOM

effect + scheduler 实现异步更新。

9、vnode-> 真实 DOM(DOM mount)

当 patch 到真正的 DOM 时,走的是 element 分支:

function processElement(...) {
  if (!n1) {
    mountElement(vnode, container)
  } else {
    patchElement(n1, n2)
  }
}

mountElement

function mountElement(vnode, container) {
  const el = (vnode.el = hostCreateElement(vnode.type))
  // props
  for (key in props) {
    hostPatchProp(el, key, null, props[key])
  }
  // children
  if (typeof children === 'string') {
    hostSetElementText(el, children)
  } else {
    children.forEach(c => patch(null, c, el))
  }
  hostInsert(el, container)
}

10、更新 & Diff 算法

当响应式状态改变:

state.count++

触发 setter → trigger

  • 将 effect 放入更新队列
  • 异步执行 scheduler
  • 调用 instance.update 再次 patch

更新阶段:

patchElement(n1, n2)

核心逻辑:

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

具体见 patchChildrenpatchKeyedChildren

整体核心对象关系架构

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

响应式依赖结构:

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

面试官 : “ 说一下 Vue 的 8 个生命周期钩子都做了什么 ? ”

一、Vue3 8 个核心生命周期钩子(按执行顺序)

阶段 选项式 API 名称 组合式 API 名称 执行时机 核心作用 & 实战场景
初始化阶段 beforeCreate 无(setup 替代) 实例创建前,数据 / 方法未初始化,this 不可用 Vue2 中用于初始化非响应式数据;Vue3 中逻辑移到 setup 最顶部(无响应式操作)
初始化阶段 created 无(setup 替代) 实例创建完成,数据 / 方法已初始化,DOM 未生成 1. 发起异步请求(接口请求);2. 初始化非 DOM 相关逻辑(如数据格式化)
挂载阶段 beforeMount onBeforeMount 挂载开始前,模板编译完成,DOM 未挂载到页面($el 未生成) 1. 预操作 DOM 结构(如计算 DOM 尺寸,需结合 nextTick);2. 初始化第三方库(挂载前准备)
挂载阶段 mounted onMounted DOM 挂载完成($el 已挂载),页面可见 1. 操作真实 DOM(如初始化 ECharts / 地图);2. 发起依赖 DOM 的异步请求;3. 监听 DOM 事件
更新阶段 beforeUpdate onBeforeUpdate 数据更新后,DOM 重新渲染前 1. 获取更新前的 DOM 状态(如旧输入框值);2. 取消不必要的监听 / 定时器(避免重复执行)
更新阶段 onUpdated onUpdated DOM 重新渲染完成,页面已更新 1. 获取更新后的 DOM 状态;2. 重新计算 DOM 相关数据(如滚动位置重置)
卸载阶段 beforeUnmount onBeforeUnmount 组件卸载前(实例仍可用,DOM 未销毁) 1. 清理副作用(清除定时器 / 事件监听);2. 销毁第三方库实例(如 ECharts 销毁)
卸载阶段 unmounted onUnmounted 组件卸载完成,DOM 销毁,实例失效 1. 最终清理(如取消接口请求);2. 释放内存(清空大型数组 / 对象引用)

二、关键细节(Vue3 核心变化)

1. setup 替代 beforeCreate/created

Vue3 中 setup 执行时机 = beforeCreate + created,这两个钩子在组合式 API 中被废弃,所有初始化逻辑直接写在 setup 中:

以下是 Vue3 生命周期钩子的完整可运行代码示例,包含选项式 API 和组合式 API(<script setup> 推荐写法)  两种风格,附带详细注释和实战场景(如接口请求、DOM 操作、定时器清理等),可直接复制到 Vue3 项目中运行。

三、组合式 API 示例(<script setup> 推荐)

Vue3 生命周期演示

<template>
  <div class="life-cycle-demo">
    <h3>Vue3 生命周期演示(组合式 API)</h3>
    <!-- 绑定响应式数据,触发更新阶段 -->
    <p>当前计数:{{ count }}</p>
    <button @click="count++">点击更新计数(触发更新钩子)</button>
    <!-- 挂载 ECharts 示例 DOM -->
    <div id="chart" style="width: 300px; height: 200px; margin: 20px 0;"></div>
  </div>
</template>

<script setup>
import { 
  ref, 
  onBeforeMount, 
  onMounted, 
  onBeforeUpdate, 
  onUpdated, 
  onBeforeUnmount, 
  unmounted 
} from 'vue';
// 模拟 ECharts(实际需安装:npm install echarts)
import * as echarts from 'echarts';

// 🫱🫱🫱 1. setup 本身替代 beforeCreate + created(初始化阶段)
console.log('===== setup 执行(等价于 beforeCreate + created)=====');
// 响应式数据初始化
const count = ref(0);
// 模拟接口请求(created 阶段核心场景)
const fetchData = async () => {
  try {
    console.log('发起异步接口请求(created 阶段)');
    // 模拟接口延迟
    const res = await new Promise(resolve => {
      setTimeout(() => resolve({ data: '模拟接口返回数据' }), 1000);
    });
    console.log('接口请求完成:', res.data);
  } catch (err) {
    console.error('接口请求失败:', err);
  }
};
// 执行接口请求(等价于 created 中调用)
fetchData();

// 🫱🫱🫱 2. 挂载阶段:beforeMount(DOM 未挂载)
onBeforeMount(() => {
  console.log('===== onBeforeMount 执行 =====');
  console.log('DOM 未挂载,#chart 元素:', document.getElementById('chart')); // null
  // 若需提前操作 DOM,需结合 nextTick
});

// 🫱🫱🫱 3. 挂载阶段:mounted(DOM 已挂载,核心操作 DOM 场景)
let myChart = null;
onMounted(() => {
  console.log('===== onMounted 执行 =====');
  console.log('DOM 已挂载,#chart 元素:', document.getElementById('chart')); // 存在
  // 初始化 ECharts(依赖 DOM 的第三方库)
  myChart = echarts.init(document.getElementById('chart'));
  myChart.setOption({
    title: { text: '生命周期演示图表' },
    xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
    yAxis: { type: 'value' },
    series: [{ data: [120, 200, 150], type: 'bar' }]
  });
  // 模拟定时器(需在卸载阶段清理)
  const timer = setInterval(() => {
    console.log('定时器运行中(count:', count.value, ')');
  }, 1000);
  // 把定时器存到全局,方便卸载时清理
  window.lifeCycleTimer = timer;
});

// 🫱🫱🫱 4. 更新阶段:beforeUpdate(数据更新,DOM 未重新渲染)
onBeforeUpdate(() => {
  console.log('===== onBeforeUpdate 执行 =====');
  console.log('数据已更新(count:', count.value, '),DOM 未刷新');
  // 可获取更新前的 DOM 状态(如旧的图表数据)
});

// 🫱🫱🫱 5. 更新阶段:updated(DOM 已重新渲染)
onUpdated(() => {
  console.log('===== onUpdated 执行 =====');
  console.log('DOM 已更新(count:', count.value, ')');
  // 若数据更新后需重新渲染图表
  if (myChart) {
    myChart.setOption({
      series: [{ data: [120 + count.value * 10, 200 + count.value * 10, 150 + count.value * 10] }]
    });
  }
});

// 🫱🫱🫱 6. 卸载阶段:beforeUnmount(组件即将卸载,清理副作用)
onBeforeUnmount(() => {
  console.log('===== onBeforeUnmount 执行 =====');
  // 清理定时器
  clearInterval(window.lifeCycleTimer);
  // 销毁 ECharts 实例
  if (myChart) {
    myChart.dispose();
    myChart = null;
  }
  console.log('副作用已清理(定时器、ECharts 已销毁)');
});

// 🫱🫱🫱 7. 卸载阶段:unmounted(组件已完全卸载)
unmounted(() => {
  console.log('===== unmounted 执行 =====');
  console.log('组件已卸载,DOM 已销毁,实例失效');
});
</script>

四、选项式 API 示例(兼容 Vue2 写法)

<template>
  <div class="life-cycle-demo">
    <h3>Vue3 生命周期演示(选项式 API)</h3>
    <p>当前计数:{{ count }}</p>
    <button @click="count++">点击更新计数</button>
    <div id="chart" style="width: 300px; height: 200px; margin: 20px 0;"></div>
  </div>
</template>

<script>
import * as echarts from 'echarts';

export default {
  // 响应式数据
  data() {
    return {
      count: 0,
      myChart: null,
      timer: null
    };
  },

  // 🫱🫱🫱 1. 初始化阶段:beforeCreate(实例刚创建,数据/方法未初始化)
  beforeCreate() {
    console.log('===== beforeCreate 执行 =====');
    console.log('数据未初始化:', this.count); // undefined
    console.log('方法未初始化:', this.fetchData); // undefined
  },

  // 🫱🫱🫱 2. 初始化阶段:created(数据/方法已初始化,DOM 未生成)
  created() {
    console.log('===== created 执行 =====');
    console.log('数据已初始化:', this.count); // 0
    // 发起异步请求
    this.fetchData();
  },

  // 🫱🫱🫱 3. 挂载阶段:beforeMount(模板编译完成,DOM 未挂载)
  beforeMount() {
    console.log('===== beforeMount 执行 =====');
    console.log('DOM 未挂载,#chart 元素:', document.getElementById('chart')); // null
  },

  // 🫱🫱🫱 4. 挂载阶段:mounted(DOM 已挂载,可操作真实 DOM)
  mounted() {
    console.log('===== mounted 执行 =====');
    console.log('DOM 已挂载,#chart 元素:', document.getElementById('chart')); // 存在
    // 初始化 ECharts
    this.myChart = echarts.init(document.getElementById('chart'));
    this.myChart.setOption({
      title: { text: '选项式 API 图表' },
      xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
      yAxis: { type: 'value' },
      series: [{ data: [120, 200, 150], type: 'bar' }]
    });
    // 启动定时器
    this.timer = setInterval(() => {
      console.log('定时器运行中(count:', this.count, ')');
    }, 1000);
  },

  // 🫱🫱🫱 5. 更新阶段:beforeUpdate(数据更新,DOM 未重新渲染)
  beforeUpdate() {
    console.log('===== beforeUpdate 执行 =====');
    console.log('数据已更新(count:', this.count, '),DOM 未刷新');
  },

  // 🫱🫱🫱 6. 更新阶段:updated(DOM 已重新渲染)
  updated() {
    console.log('===== updated 执行 =====');
    console.log('DOM 已更新(count:', this.count, ')');
    // 重新渲染图表
    if (this.myChart) {
      this.myChart.setOption({
        series: [{ data: [120 + this.count * 10, 200 + this.count * 10, 150 + this.count * 10] }]
      });
    }
  },

  // 🫱🫱🫱 7. 卸载阶段:beforeUnmount(组件即将卸载,清理副作用)
  beforeUnmount() {
    console.log('===== beforeUnmount 执行 =====');
    // 清理定时器
    clearInterval(this.timer);
    // 销毁 ECharts
    if (this.myChart) {
      this.myChart.dispose();
      this.myChart = null;
    }
  },

  // 🫱🫱🫱 8. 卸载阶段:unmounted(组件已完全卸载)
  unmounted() {
    console.log('===== unmounted 执行 =====');
    console.log('组件已卸载,资源已清理');
  },

  // 自定义方法:模拟接口请求
  methods: {
    async fetchData() {
      try {
        console.log('发起接口请求(created 阶段)');
        const res = await new Promise(resolve => {
          setTimeout(() => resolve({ data: '选项式 API 接口数据' }), 1000);
        });
        console.log('接口请求完成:', res.data);
      } catch (err) {
        console.error('接口请求失败:', err);
      }
    }
  }
};
</script>

五、测试方式(验证生命周期执行)

  1. 挂载阶段:页面加载后,控制台会依次打印 setup/beforeCreatecreatedbeforeMountmounted,同时 ECharts 图表渲染完成,定时器开始运行。

  2. 更新阶段:点击 “点击更新计数” 按钮,触发 beforeUpdateupdated,图表数据随计数更新。

  3. 卸载阶段

    • 若使用路由,跳转到其他页面(组件卸载);
    • 或手动移除组件(如用 v-if 控制),控制台会打印 beforeUnmountunmounted,定时器停止,ECharts 实例销毁。

六、核心注意点

  1. 组合式 API 无 beforeCreate/created:所有初始化逻辑直接写在 <script setup> 顶部,等价于这两个钩子。
  2. 副作用必须清理:定时器、事件监听、第三方库实例(如 ECharts)需在 onBeforeUnmount/beforeUnmount 中清理,避免内存泄漏。
  3. DOM 操作仅在 mounted/updated 中安全beforeMount 中操作 DOM 需结合 nextTick
  4. updated 中避免无限循环:不要在 updated 中直接修改响应式数据(除非加条件判断)。

通过这个示例,你可以直观看到每个生命周期钩子的执行时机和实际用途,覆盖日常开发中 90% 以上的生命周期场景

面试官 : ” 说一下 Vue 中的 setup 中的 props 和 context “

一、props:和 Vue2 核心逻辑完全一致,仅访问方式微调

props 作为 Vue 「父传子」的核心通信方式

Vue3 中 单向数据流、类型校验、默认值 / 必传项 等核心规则和 Vue2 完全一样,唯一区别是「访问方式」:

1. 共性(Vue2/Vue3 通用)

  • 单向数据流:子组件不能直接修改 props,必须通过 emit 通知父组件修改;
  • 支持类型校验( String / Number / Array / Object 等)、默认值、自定义校验规则;
  • 父组件传的属性如果没被 props 声明,会落到 attrs 中(下文会提)。

2. 用法对比(Vue2 选项式 vs Vue3 组合式)

<!-- Vue2 选项式 API -->
<script>
export default {
  props: {
    name: { type: String, default: '默认名' },
    age: { type: Number, required: true }
  },
  mounted() {
    console.log(this.name); // 👉 通过 this 访问
  }
}
</script>

<!-- Vue3 组合式 API(setup) -->
<script>
export default {
  // 👉 props 定义规则和 Vue2 完全一样
  props: {
    name: { type: String, default: '默认名' },
    age: { type: Number, required: true }
  },
  // 👉 props 作为 setup 第一个参数传入,无需 this
  setup(props) {
    console.log(props.name); // 直接访问
    // 注意:props 是响应式的,解构会丢失响应式,需用 toRefs
    const { name } = Vue.toRefs(props);
    console.log(name.value); // ref 需 .value 访问
  }
}
</script>

二、context:Vue3 把 Vue2 的「this 上的通信属性」聚合到上下文

context 是 setup 的第二个参数(非响应式,可直接解构),核心作用是替代 Vue2 中 this 上的「非 props 相关通信能力」,你提到的几个属性对应关系精准,补充用法细节:

context 属性 Vue2 对应写法 核心用法示例
context.emit this.$emit 子传父触发事件:context.emit('change', { id: 1 })(父组件用 @change 接收)
context.slots this.$slots 访问父组件传入的插槽:context.slots.header()(Vue3 插槽是函数,需加 () 调用)
context.attrs this.$attrs 接收父组件未被 props 声明的属性:父传 class="box" 且 props 未声明 → context.attrs.class
context.expose() 无(Vue2 无此能力) 主动暴露子组件内部属性给父组件:context.expose({ fn: () => console.log('暴露的方法') })

核心示例(context 解构使用,更简洁)

<script>
export default {
  setup(props, { emit, slots, attrs, expose }) {
    // 1. 子传父:触发自定义事件
    const handleClick = () => emit('submit', '子组件数据');

    // 2. 访问具名插槽
    console.log(slots.footer()); // 获取父组件传入的 footer 插槽内容

    // 3. 访问未声明的属性
    console.log(attrs['data-id']); // 父传 data-id="123" 且未被 props 声明

    // 4. 暴露内部方法给父组件(父通过 ref 仅能访问暴露的内容)
    const internalFn = () => '内部逻辑';
    expose({ internalFn }); // 父组件 ref.value.internalFn() 可调用

    return { handleClick };
  }
}
</script>

1. 子组件(Child.vue):核心逻辑详解

<template>
  <!-- 点击按钮触发子传父事件 -->
  <button @click="handleClick">点击触发submit事件</button>

  <!-- 渲染父组件传入的footer具名插槽 -->
  <div class="slot-container">
    <slot name="footer"></slot>
  </div>

  <!-- 把attrs中的data-id透传给内部div(演示attrs用法) -->
  <div :data-id="attrs['data-id']">透传父组件未声明的data-id属性</div>
</template>

<script>
// 导入vue的核心方法(按需导入,Vue3组合式API规范)
import { toRefs } from 'vue';

export default {
  // 第一步:声明props(仅声明name,未声明data-id,所以data-id会落到attrs中)
  props: {
    name: {
      type: String,
      default: '默认名称'
    }
  },

  // setup第二个参数解构出:emit(子传父)、slots(插槽)、attrs(透传属性)、expose(暴露内容)
  setup(props, { emit, slots, attrs, expose }) {
    // 👉 1. 子传父:触发自定义事件(核心用法)
    const handleClick = () => {
      // 第一个参数:事件名(父组件用@submit接收);第二个参数:传递给父组件的数据
      emit('submit', {
        msg: '子组件传递的数据',
        name: props.name // 结合props使用,把props数据也传给父组件
      });
    };

    // 👉 2. 访问具名插槽(控制台打印插槽内容,验证是否传入)
    console.log('===== 访问footer插槽 =====');
    // Vue3中slots的每个插槽都是函数,调用后返回VNode数组(插槽的DOM结构)
    if (slots.footer) { // 先判断父组件是否传入了footer插槽,避免报错
      const footerSlotContent = slots.footer();
      console.log('footer插槽的VNode内容:', footerSlotContent);
    } else {
      console.log('父组件未传入footer插槽');
    }

    // 👉 3. 访问父组件未被props声明的属性(attrs)
    console.log('===== 访问attrs =====');
    console.log('父组件传入的data-id:', attrs['data-id']); // 父传的data-id未被props声明,所以在attrs中
    console.log('父组件传入的class(若有):', attrs.class); // class/style会自动透传,也会在attrs中
    // 注意:attrs是非响应式的,若需要响应式,可结合toRefs(但一般attrs无需响应式)

    // 👉 4. 暴露子组件内部方法/属性给父组件(父组件通过ref访问)
    // 定义子组件内部方法(未return也能通过expose暴露)
    const internalFn = () => {
      return `内部方法执行成功!props.name的值是:${props.name}`;
    };
    // 定义内部属性(仅暴露给父组件,模板中无法直接使用,除非return)
    const internalData = '子组件内部私有数据';

    // 主动暴露指定内容(只有这里声明的,父组件才能通过ref访问)
    expose({
      internalFn, // 暴露内部方法
      internalData, // 暴露内部属性
      // 也可以暴露props(方便父组件直接获取props值)
      getPropsName: () => props.name
    });

    // 👉 5. 补充:props的响应式使用(可选)
    // 解构props并保留响应式(若需要单独使用props中的属性)
    const { name } = toRefs(props);
    console.log('===== props使用 =====');
    console.log('props.name的值(响应式):', name.value);

    // 把需要在模板中使用的方法return出去(handleClick在模板中绑定点击事件,必须return)
    return {
      handleClick,
      attrs // 把attrsreturn出去,方便模板中使用(如上面模板中的:data-id="attrs['data-id']")
    };
  }
};
</script>

2. 父组件(Parent.vue):调用子组件并配合使用

<template>
  <div class="parent-container">
    <h3>父组件</h3>
    <!-- 第二步:使用子组件,完成以下操作:
         1. 传props:name="测试名称"
         2. 传未声明的属性:data-id="10086"(会落到子组件attrs中)
         3. 绑定子组件的自定义事件:@submit="handleChildSubmit"
         4. 传入具名插槽:<template #footer>...</template>
         5. 给子组件加ref:childRef(用于访问子组件暴露的内容)
    -->
    <Child
      ref="childRef"
      name="测试名称"
      data-id="10086"
      class="child-component"
      @submit="handleChildSubmit"
    >
      <!-- 传入footer具名插槽(子组件会访问这个插槽) -->
      <template #footer>
        <p>这是父组件传给子组件的footer插槽内容</p>
      </template>
    </Child>

    <!-- 显示子组件传递的数据 -->
    <div class="child-data" v-if="childSubmitData">
      <h4>子组件传递的数据:</h4>
      <p>msg:{{ childSubmitData.msg }}</p>
      <p>name:{{ childSubmitData.name }}</p>
    </div>

    <!-- 点击按钮访问子组件暴露的方法/属性 -->
    <button @click="accessChildExpose">访问子组件暴露的内容</button>
  </div>
</template>

<script>
// 导入子组件
import Child from './Child.vue';
// 导入vue的ref(用于创建子组件的引用)和onMounted(生命周期)
import { ref, onMounted } from 'vue';

export default {
  // 注册子组件
  components: {
    Child
  },

  setup() {
    // 👉 1. 创建子组件的ref引用(用于访问子组件暴露的内容)
    const childRef = ref(null);

    // 👉 2. 接收子组件的自定义事件数据
    const childSubmitData = ref(null);
    const handleChildSubmit = (data) => {
      console.log('父组件接收到子组件的submit事件数据:', data);
      childSubmitData.value = data; // 把数据存到响应式变量中,模板中显示
    };

    // 👉 3. 访问子组件通过expose暴露的内容
    const accessChildExpose = () => {
      // 确保子组件已挂载(避免初始时childRef.value为null)
      if (childRef.value) {
        // 调用子组件暴露的internalFn方法
        const fnResult = childRef.value.internalFn();
        console.log('调用子组件暴露的internalFn结果:', fnResult);

        // 获取子组件暴露的internalData属性
        const internalData = childRef.value.internalData;
        console.log('获取子组件暴露的internalData:', internalData);

        // 调用子组件暴露的getPropsName方法(获取子组件的props.name)
        const propsName = childRef.value.getPropsName();
        console.log('子组件的props.name:', propsName);

        // 注意:子组件未暴露的内容,父组件无法访问(比如子组件的handleClick)
        console.log('访问子组件未暴露的handleClick:', childRef.value.handleClick); // undefined
      }
    };

    // 👉 4. 生命周期:组件挂载后,也可以主动访问子组件暴露的内容
    onMounted(() => {
      console.log('===== 组件挂载后访问子组件暴露内容 =====');
      if (childRef.value) {
        console.log('挂载后获取internalData:', childRef.value.internalData);
      }
    });

    // return需要在模板中使用的变量/方法
    return {
      childRef,
      childSubmitData,
      handleChildSubmit,
      accessChildExpose
    };
  }
};
</script>

三、关键总结

  1. props规则完全继承 Vue2,仅在 Vue3 setup 中需通过第一个参数访问,注意响应式解构(用 toRefs);
  2. context替代 Vue2 中 this 上的通信属性,把 $emit/$slots/$attrs 聚合到 上下文( context ) ,新增 expose() 增强组件封装性(Vue2 父组件通过 ref 能访问子组件所有内容,Vue3 需主动暴露才可见);
  3. 简化记忆:setup(props, context) → 第一个参数管「父传子的 props」,第二个参数管「子传父、插槽、透传属性、暴露内容」。

这种设计既保留了 Vue2 的使用习惯,又让组合式 API 脱离了 this 的束缚,逻辑更聚合,是 Vue3 兼顾「易用性」和「灵活性」的核心设计。

面试官 : “ 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 还给用户。

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

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. 未完待续。。。

吃透 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 事件流的本质 —— 这也是从 “初级前端” 到 “中高级前端” 的必经之路。

面试官 : “ 说一下 Map 和 WeakMap 的区别 ? ”

一、Map vs WeakMap

特性 Map WeakMap
键的类型 任意类型(基本类型 / 引用类型) 仅支持引用类型(对象)
键的引用特性 强引用:键对象不会被 GC 回收 弱引用:键对象无其他引用时,会被 GC 自动回收(键值对随之消失)
遍历性 支持(keys ()/values ()/entries ()/forEach/for...of) 不支持(无遍历方法、无 size 属性)
键的枚举 / 获取 可获取所有键(如 Array.from (map.keys ())) 无法获取 / 枚举所有键(无 API)
常用 API set/get/has/delete/clear/size set/get/has/delete(无 clear/size)
内存占用 键对象未手动删除则一直占用 自动回收无引用的键,内存更友好
使用场景 需遍历 / 枚举、键为基本类型、长期存储键值对 临时关联数据(如 DOM 元素→元数据)、避免内存泄漏

核心差异:弱引用

  • Map 对键是强引用:即使键对象外部无引用,Map 仍持有该对象,GC 不会回收,可能导致内存泄漏;
  • WeakMap 对键是弱引用:键对象仅被 WeakMap 引用时,GC 会回收该对象,同时 WeakMap 中对应的键值对也会被移除(无需手动删除)。

示例

// Map:强引用导致内存泄漏风险
const map = new Map();
let obj = { id: 1 };
map.set(obj, "data");
obj = null; // 手动置空,但map仍引用obj,GC不会回收

// WeakMap:弱引用自动回收
const weakMap = new WeakMap();
let obj2 = { id: 2 };
weakMap.set(obj2, "data");
obj2 = null; // obj2无其他引用,GC回收后,weakMap中该键值对消失

二、Set vs WeakSet

特性 Set WeakSet
值的类型 任意类型(基本类型 / 引用类型) 仅支持引用类型(对象)
值的引用特性 强引用:值对象不会被 GC 回收 弱引用:值对象无其他引用时,会被 GC 自动回收(值随之移除)
遍历性 支持(keys ()/values ()/entries ()/forEach/for...of) 不支持(无遍历方法、无 size 属性)
值的枚举 / 获取 可获取所有值(如 Array.from (set)) 无法获取 / 枚举所有值(无 API)
常用 API add/has/delete/clear/size add/has/delete(无 clear/size)
内存占用 值对象未手动删除则一直占用 自动回收无引用的值,内存更友好
使用场景 需遍历 / 枚举、值为基本类型、存储唯一值集合 存储临时对象(如 DOM 元素集合)、避免内存泄漏

核心差异:弱引用

  • Set 对值是强引用:值对象即使外部无引用,Set 仍持有,GC 不回收;
  • WeakSet 对值是弱引用:值对象仅被 WeakSet 引用时,GC 会回收该对象,WeakSet 中对应的项也会被移除。

示例

// Set:强引用
const set = new Set();
let obj = { id: 1 };
set.add(obj);
obj = null; // set仍引用obj,GC不回收

// WeakSet:弱引用
const weakSet = new WeakSet();
let obj2 = { id: 2 };
weakSet.add(obj2);
obj2 = null; // obj2无其他引用,GC回收后,weakSet中该值消失

三、Map vs Set 区别(补充知识)

Map 和 Set 都是 ES6 新增的有序集合(迭代顺序为插入顺序) ,均为强引用、支持遍历、可存储唯一值,但核心定位和数据结构完全不同,以下是详细对比:

特性 Map Set
核心定位 键值对集合(键→值映射) 值的集合(仅存储唯一值,无键)
存储形式 [key, value] 键值对,键唯一、值可重复 单个值(value),值必须唯一
重复判定规则 键唯一(NaN 视为相同,对象引用不同则视为不同) 值唯一(规则同 Map 键的判定)
核心 API(增) set(key, value):按键存值 add(value):添加值
核心 API(查) get(key):按键取值;has(key):判断键是否存在 has(value):判断值是否存在(无 get
核心 API(删) delete(key):按键删除键值对 delete(value):按值删除项
遍历方式 可遍历键(keys())、值(values())、键值对(entries() 可遍历值(keys()/values() 等价,entries() 返回 [value, value]
长度 / 大小 size 属性:返回键值对数量 size 属性:返回唯一值数量
使用场景 1. 键值映射(如 ID→用户信息)2. 需要通过 “键” 快速查找 “值”3. 存储关联数据 1. 存储不重复的唯一值集合(如去重数组)2. 仅需判断 “值是否存在”3. 过滤重复数据

1. Map:键值对存储与查找

const map = new Map();
// 存:键唯一,值可重复
map.set("id1", { name: "张三" });
map.set("id2", { name: "李四" });
map.set("id1", { name: "张三2" }); // 覆盖id1的旧值

// 查:按键取值
console.log(map.get("id1")); // { name: "张三2" }
console.log(map.has("id2")); // true

// 遍历:键、值、键值对
for (const key of map.keys()) console.log(key); // id1、id2
for (const value of map.values()) console.log(value); // {name: "张三2"}、{name: "李四"}
for (const [k, v] of map.entries()) console.log(k, v);

2. Set:唯一值集合(无键)

const set = new Set();
// 存:值唯一,重复添加无效
set.add(1);
set.add(2);
set.add(1); // 无效果,1已存在

// 查:仅能判断值是否存在,无get
console.log(set.has(2)); // true
// console.log(set.get(2)); // 报错:Set 无get方法

// 遍历:keys/values等价,entries返回[值, 值]
for (const val of set.values()) console.log(val); // 1、2
for (const [v1, v2] of set.entries()) console.log(v1, v2); // 1 1、2 2

// 典型场景:数组去重
const arr = [1, 2, 2, 3];
const uniqueArr = [...new Set(arr)]; // [1,2,3]

3.核心总结

维度 Map Set
数据结构 键值对(字典) 单值集合(集合)
核心操作 按 “键” 存 / 取 / 删 按 “值” 增 / 判 / 删(无取值操作)
重复处理 键唯一(值可重复) 值唯一(无重复)
核心用途 键值映射、关联数据存储 去重、唯一值判断

简单记:

  • 需要 “通过一个标识找对应数据”→ 用 Map;
  • 只需要 “存储不重复的一组值,或判断值是否存在”→ 用 Set。

三、通用总结

类型 核心特点 适用场景
Map/Set 强引用、支持遍历、键 / 值可存任意类型 需持久存储、遍历、键 / 值为基本类型的场景
WeakMap/WeakSet 弱引用、不支持遍历、仅存引用类型 临时关联数据、避免内存泄漏(如 DOM / 临时对象)

关键提醒

WeakMap/WeakSet 无法遍历 / 获取 size,因为其内部数据会被 GC 动态修改,无法保证数据的稳定性;

而 Map/Set 是 “可预测” 的静态集合(除非手动修改)。

彻底搞懂 React useRef:从自动聚焦到非受控表单的完整指南

useRef 详解:从自动聚焦到非受控表单,彻底掌握 React 的“持久引用”

在 React 的世界里,useState 是大家耳熟能详的主角——它负责管理状态、驱动界面更新。但还有一个低调却不可或缺的角色:useRef。它不像 useState 那样会触发重新渲染,却在很多关键场景中默默支撑着应用的正常运行。今天,我们就用生活化的比喻和真实代码,带你彻底理解 useRef 的两大核心用途。


一、什么是 useRef?它和 useState 有什么区别?

✨ 基本定义

const refContainer = useRef(initialValue);
  • useRef 返回一个可变的引用对象,其结构为 { current: initialValue }
  • 这个对象在组件的整个生命周期内保持不变(同一个引用)。
  • 修改 ref.current 不会触发组件重新渲染

与 useState 对比

特性 useState useRef
是否可变 是(通过 setter 更新) 是(直接赋值 ref.current = ...
是否触发重渲染 ✅ 是 ❌ 否
用途 管理需要反映在 UI 上的状态 存储不需要触发更新的值 / 获取 DOM 元素
初始值是否参与依赖 是(用于 useEffect 等) 否(.current 变化不会被 React 感知)

💡
useState 是“公告栏”——内容一变,全村都知道;
useRef 是“私人笔记本”——你写多少字,别人看不见,但你自己随时能查。


🎯 场景一:让输入框“自动聚焦”——挂载后立刻获得焦点

想象一下你打开一个登录页面,光标已经自动停在用户名输入框里,不用你手动点一下——是不是很贴心?这种体验背后,就离不开 useRef

来看这段代码:

import {useRef, useEffect } from 'react';

export default function App() {
  const inputRef = useRef(null); // 创建一个“引用盒子”

  useEffect(() => {
    inputRef.current.focus(); // 页面加载完,立刻聚焦
  }, []);

  return (
    <>
      <input ref={inputRef} type="text" />
    </>
  );
}

运行展示:

未命名的设计 (3).gif

🔍 它是怎么工作的?

  • useRef(null) 创建了一个持久存在的对象,它的 .current 属性初始为 null
  • <input ref={inputRef} /> 被渲染时,React 会自动把真实的 DOM 元素(比如 <input> 标签)赋值给 inputRef.current
  • useEffect(组件挂载后执行)中,我们调用 inputRef.current.focus(),就像对这个输入框说:“嘿,准备好接收输入吧!”

当我们在此基础上添加响应式状态时:

import { 
  useState,
  useRef ,
  useEffect
} from 'react'

export default function App(){
  const [count, setCount] = useState(0) // 响应式状态
  
    const inputRef = useRef(null) //初始值为空
    console.log(inputRef.current);
    console.log(count);
    
    useEffect(() => {
  console.log(inputRef.current);
      inputRef.current.focus()
    }, [])
  return(
    <>
    <input ref={inputRef} type="text" />
    {count}
    <button type="button" onClick={() => setCount(count + 1)}>增加</button>
    </>
  )
}

运行程序:

未命名的设计 (5).gif

我们可以发现,程序先是输出了ref和count的初始值null和0,此时返回 JSX,React 准备将 <input ref={inputRef} /> 挂载到 DOM。

React 将 JSX 渲染为真实 DOM。 此时,<input> 元素被创建,并且 React 自动将该 DOM 元素赋值给 inputRef.current。输出 < input type="text" >

当我们点击增加按钮时,触发重新渲染,程序输出1和< input type="text" >,为什么useRef的.current不是输出null,那是因为useRef.current 一旦被 React 赋值,就会一直保留该值,直到组件卸载或手动修改。

📱 适用场景

  • 登录/注册页的首字段自动聚焦
  • 移动端减少用户点击次数,提升体验
  • 表单弹窗打开后自动定位到第一个输入框

总结

useStateuseRef 的核心区别:useState 管理响应式状态,更新会触发组件重新渲染;而 useRef 创建一个可变且持久的引用对象,其 .current 值在首次渲染时为 null(DOM 尚未挂载),挂载后指向真实 DOM 元素,后续渲染中保持引用不变,且修改它不会引起重渲染。


⏱️ 场景二:存储定时器 ID——避免“失联”的定时任务

再来看一个经典问题:为什么我启动了定时器,却无法停止它?

如果你这样写:

// 此处省去导入
export default function App(){
    let intervalId = null
    const [count,setCount] = useState(0)
    function start(){
        intervalId = setInterval(()=>{
        console.log('tick~~~')
    },1000)
    console.log(intervalId);
}
useEffect(() =>{
 console.log(intervalId);
},[count])
function stop(){
    clearInterval(intervalId)
}

return(
 <>
  <button onClick={start}>开始</button>
  <button onClick={stop}>停止</button>
  {count}
  <button type="button" onClick={() => setCount(count + 1)}>增加</button>
 </>
)

当我们点击开始按钮时:

未命名的设计 (6).gif

定时器开始每秒打印 'tick~~~'此时组件没有重新渲染,点击停止时,clearInterval(id) 成功清除定时器。

而当我们点击增加按钮时,就会出现这种情况:

未命名的设计 (8).gif

tick~~~持续输出,定时器无法清理! 那是因为当我们点击增加按钮时,组件会重新渲染,React 会重新调用 App 组件函数(即重新渲染),intervalId 都会被重置为 nullclearInterval(null) 无效, 真正的定时器 ID 已经“丢失” ,无法被清除 → 'tick~~~' 持续输出!

问题在于:每次 count 变化导致组件重新渲染时,let intervalId = null 会被重新执行,之前的定时器 ID 就“丢失”了。

✅ 正确做法:用 useRef 保存 ID

import { useRef, useState, useEffect } from 'react';

export default function App() {
  const intervalId = useRef(null); // ✅ 持久存储
  const [count, setCount] = useState(0);

  function start() {
    intervalId.current = setInterval(() => {
      console.log('tick~~~');
    }, 1000);
  }

  function stop() {
    clearInterval(intervalId.current);
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      {count}
      <button onClick={() => setCount(count + 1)}>增加</button>
    </>
  );
}

🧩 为什么 useRef 能解决?

  • useRef 返回的对象在整个组件生命周期中始终是同一个对象
  • 即使组件多次重新渲染,timerId.current 依然保留上次的值。
  • 因此,stop() 总能拿到正确的定时器 ID。

上述两个场景体现了useRef 提供一个跨渲染保持不变的可变容器,适合存储 DOM 引用或副作用相关的标识(如定时器 ID),且修改它不会引起组件重新渲染


📝 受控 vs 非受控:表单数据的两种获取方式

React 表单有两种处理思路:受控组件非受控组件。它们的核心区别在于:谁在掌控表单的值

1️⃣ 受控组件(Controlled Component)——“一切尽在掌握”

当表单元素的值由 React 状态(state)驱动时,这个表单元素就是一个受控组件

  • 表单元素的值由 React state 控制。
  • 必须配合 onChange 更新状态。
import { useState } from 'react';

export default function LoginForm() {
  const [form, setForm] = useState({ username: '', password: '' });

  const handleChange = (e) => {
    setForm({
      ...form,
      [e.target.name]: e.target.value // 动态更新字段
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(form); // { username: '...', password: '...' }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
        placeholder="请输入用户名"
      />
      <input
        name="password"
        value={form.password}
        onChange={handleChange}
        placeholder="请输入密码"
        type="password"
      />
      <button type="submit">注册</button>
    </form>
  );
}
受控组件的核心原则

“有 value,必有 onChange。”

否则输入框会被 React “锁住”,用户无法输入

如果没有onChange,会发生什么?

  1. 初始时 form.username = '',输入框为空 ✅
  2. 用户输入 "alice" → 浏览器尝试把输入框值改为 "alice"
  3. 但 React 在渲染时又强制把 value 设回 '' (因为 form.username 没变!)
  4. 结果:输入框“卡住”,用户无法输入任何内容!  ❌

为什么选择受控组件?

  • 数据完全受控,便于校验、格式化、联动(如确认密码)
  • 符合 React 单向数据流理念

2️⃣ 非受控组件(Uncontrolled Component)——“用时再取”

  • 表单元素自己管理值(像传统 HTML)。
  • 通过 useRef 在需要时读取 .current.value
import { useRef } from 'react';

export default function CommentBox() {
  const textareaRef = useRef(null);

  const handleSubmit = () => {
    const comment = textareaRef.current.value;
    if (!comment) return alert('请输入评论');
    console.log(comment);
  };

  return (
    <div>
      <textarea ref={textareaRef} placeholder="输入评论..." />
      <button onClick={handleSubmit}>提交</button>
    </div>
  );
}

  • 优点:性能略高(无状态更新),代码更简洁。
  • 适用场景:评论框、文件上传、一次性提交的简单表单。

核心区别:表单数据由谁“掌控”?

对比维度 受控组件(Controlled) 非受控组件(Uncontrolled)
数据来源 React 的 state DOM 元素自身(原生 HTML 行为)
如何更新值 通过 onChange 同步到 state 用户直接操作 DOM,React 不干预
如何读取值 直接读取 state 通过 ref.current.value 获取
是否需要 value + onChange ✅ 必须配对使用 ❌ 不需要(通常只用 ref
是否触发 re-render 每次输入都触发 无状态变化,不触发
适合场景 需要实时校验、格式化、联动的复杂表单(如登录、注册、设置页) 一次性提交、简单输入(如评论框、搜索框、文件上传)
优点 - 数据流清晰 - 易于验证/转换/联动 - 符合 React 响应式理念 - 代码简洁 - 性能略高(无频繁 setState) - 接近原生 HTML 习惯
缺点 - 代码量稍多 - 频繁输入可能引发多余渲染(可通过防抖优化) - 无法实时响应输入 - 难以实现动态校验或字段联动 - 违背“状态驱动 UI”原则

✨ 总结:useRef 的核心价值

用途 说明 示例
1. 访问 DOM 元素 获取真实 DOM,调用原生方法 .focus(), .scrollIntoView()
2. 持久存储可变值 保存不触发重渲染的数据 定时器 ID、WebSocket 实例
3. 构建非受控组件 一次性读取表单值 评论框、文件上传

记住一句话:

需要界面跟着变?用 useState
只想悄悄存个东西或操作 DOM?用 useRef

useRef 虽不张扬,却是 React 开发中不可或缺的“幕后英雄”。

掌握它,你就能在“优雅的 React”和“灵活的 DOM”之间自由切换,写出既健壮又高效的代码!

面试官 : “ 说一下 localhost 和127.0.0.1 的区别 ? ”

localhost 是主机名(域名) ,属于应用层概念;

127.0.0.1 是IPv4 回环地址,属于网络层概念。

两者都用于访问本机服务,但 localhost 必须通过解析才能映射到具体 IP(默认是 127.0.0.1 或 IPv6 的 ::1),而 127.0.0.1 是直接的网络层标识,无需解析。


一、本质定义与协议层次

概念 localhost 127.0.0.1
本质 互联网标准规定的特殊主机名(RFC 6761 定义) IPv4 协议规定的回环地址(RFC 5735 定义)
协议层次 应用层(DNS 协议解析范畴) 网络层(IP 协议寻址范畴)
归属 属于域名系统(DNS) 属于 IP 地址体系
默认映射 IPv4: 127.0.0.1;IPv6: ::1 仅 IPv4 回环网段(127.0.0.0/8)的第一个地址

关键补充

  1. 127.0.0.0/8 网段:不只是 127.0.0.1,整个 127.x.x.x 网段(共 16777216 个地址)都属于回环地址,访问任何一个都会指向本机。
  2. localhost 的特殊性:它是一个保留主机名,不能被注册为公共域名,且操作系统会优先通过 hosts 文件解析,而非公共 DNS 服务器。

二、解析流程的根本差异

这是两者最核心的区别 ——是否需要解析,以及解析的顺序

1. localhost 的解析流程(应用层 → 网络层)

当你在浏览器输入 http://localhost:3000 时,操作系统会执行以下步骤:

  1. 检查本地 hosts 文件

    • Windows 路径:C:\Windows\System32\drivers\etc\hosts
    • Linux/macOS 路径:/etc/hosts
    • 如果 hosts 文件中有如下映射:127.0.0.1 localhost 或 ::1 localhost,则直接使用对应的 IP。
  2. 若 hosts 文件无映射,查询本地 DNS 缓存

    • 操作系统会检查之前是否解析过 localhost,若有缓存则直接使用。
  3. 若缓存无结果,查询本地 DNS 服务器

    • 但由于 localhost 是保留主机名,公共 DNS 服务器通常也会返回 127.0.0.1 或 ::1
  4. 解析完成后,转换为 IP 地址进行网络请求

    • 此时才进入网络层,使用解析后的 IP 连接本机服务。

2. 127.0.0.1 的访问流程(直接进入网络层)

当你输入 http://127.0.0.1:3000 时,跳过所有解析步骤

  1. 操作系统直接识别这是一个 IPv4 回环地址。
  2. 直接将网络请求发送到本机的网络接口(回环接口,lo 接口)。
  3. 目标服务监听 127.0.0.1 或 0.0.0.0 时,即可响应请求。

三、功能与使用上的具体差异

1. 协议支持差异

  • localhost:支持 IPv4 和 IPv6 双协议

    • 若你的系统开启了 IPv6,localhost 可能优先解析为 ::1(IPv6 回环地址)。
    • 例如:在 Node.js 中,server.listen(3000, 'localhost') 会同时监听 IPv4 的 127.0.0.1:3000 和 IPv6 的 ::1:3000
  • 127.0.0.1仅支持 IPv4

    • 无论系统是否开启 IPv6,使用 127.0.0.1 都只会走 IPv4 协议。
    • 例如:server.listen(3000, '127.0.0.1') 仅监听 IPv4 地址。

2. 性能差异

  • 127.0.0.1 略快:因为跳过了 DNS 解析流程(即使是本地 hosts 文件解析,也需要一次文件读取和匹配)。
  • 差异极小:在开发环境中,这种性能差异几乎可以忽略不计,除非是高频次的请求(如每秒上万次)。

3. 服务监听的差异

服务端程序的监听地址,会影响是否能被 localhost 或 127.0.0.1 访问:

监听地址 能否被 localhost 访问 能否被 127.0.0.1 访问 能否被局域网其他设备访问
localhost ✅(IPv4 解析时)
127.0.0.1 ✅(解析为 127.0.0.1 时)
0.0.0.0 ✅(通过本机局域网 IP)
::1(IPv6) ✅(解析为 ::1 时)

4. 自定义映射的差异

  • localhost 可以被自定义映射

    • 你可以修改 hosts 文件,将 localhost 映射到任意 IP,例如:

      192.168.1.100   localhost
      
    • 此时访问 localhost 会指向局域网的 192.168.1.100,而不是本机。

  • 127.0.0.1 无法被自定义

    • 它是 IPv4 协议规定的回环地址,无论如何修改配置,访问 127.0.0.1 都只会指向本机。

5. 兼容性差异

  • 老旧系统 / 服务:某些非常古老的程序(如早期的 DOS 程序、嵌入式设备程序)可能不识别 localhost 主机名,但一定能识别 127.0.0.1
  • IPv6 专属服务:某些服务仅监听 IPv6 的 ::1,此时只能通过 localhost 访问(解析为 ::1),而 127.0.0.1 无法访问。

四、实际开发中的选择建议

  1. 优先使用 localhost

    • 理由:兼容性更好,支持双协议,符合开发习惯,且无需关心 IPv4/IPv6 配置。
    • 场景:本地开发、测试环境、前端代理配置(如 Vite、Webpack 的 devServer.host: 'localhost')。
  2. 使用 127.0.0.1 的场景

    • 强制使用 IPv4:当服务仅监听 IPv4 地址,或系统 IPv6 配置有问题时。
    • 避免自定义映射:当你怀疑 hosts 文件被修改,localhost 被映射到非本机地址时。
    • 某些工具的特殊要求:部分 CLI 工具或服务(如某些数据库客户端)默认只识别 127.0.0.1
  3. 特殊场景:0.0.0.0

    • 这不是回环地址,而是通配地址,表示监听本机所有网络接口(包括回环接口、局域网接口、公网接口)。
    • 场景:需要让局域网其他设备访问本机服务时(如手机测试前端页面)。

五、验证两者差异的小实验

实验 1:修改 hosts 文件,观察 localhost 映射

  1. 打开 /etc/hosts(Linux/macOS)或 C:\Windows\System32\drivers\etc\hosts(Windows)。
  2. 添加一行:192.168.1.1 localhost
  3. 执行 ping localhost,会发现 ping 的是 192.168.1.1,而非 127.0.0.1
  4. 执行 ping 127.0.0.1,仍然 ping 本机。
  5. 恢复 hosts 文件默认配置:127.0.0.1 localhost 和 ::1 localhost

实验 2:查看服务监听的地址

  1. 在 Node.js 中运行以下代码:

    const http = require('http');
    const server = http.createServer((req, res) => {
      res.end('Hello World!');
    });
    // 监听 localhost
    server.listen(3000, 'localhost', () => {
      console.log('Server running on localhost:3000');
    });
    
  2. 执行 netstat -tulpn | grep 3000(Linux/macOS)或 netstat -ano | findstr 3000(Windows)。

  3. 会发现服务同时监听 127.0.0.1:3000 和 ::1:3000(IPv4 + IPv6)。

  4. 若将监听地址改为 127.0.0.1,则仅监听 127.0.0.1:3000


六、总结:核心区别一览表

对比维度 localhost 127.0.0.1
本质 主机名(域名) IPv4 回环地址
协议层次 应用层(DNS) 网络层(IP)
解析需求 必须解析(hosts → DNS) 无需解析
协议支持 IPv4 + IPv6 仅 IPv4
自定义映射 可通过 hosts 文件修改 不可修改,固定指向本机
服务监听 可同时监听 IPv4/IPv6 仅监听 IPv4
兼容性 现代系统支持,老旧系统可能不支持 所有支持 IPv4 的系统都支持
性能 略慢(解析开销) 略快(无解析开销)

我是千寻, 这期内容到这里就结束了,我们有缘再会😂😂😂 !!!

月哥创业3年,还活着!

说什么呢 18年9月入行,到现在7年多了。。真特码快!粉丝们一步一步看着月哥的成长,感谢大家一直以来的陪伴,和支持!谢谢大家! 写了很多东西,删掉了很多,怕发不出来,思来想去分享一些踩坑经验,和一些浅

2026最新React技术栈梳理,全栈必备

前言 2025年的React生态持续迭代,从核心框架的编译器革新到生态工具的性能优化,都带来了诸多实用特性。对于前端开发者而言,精准把握最新技术栈选型,是提升开发效率、构建高性能应用的关键。

面试官: “ 说一下怎么做到前端图片尺寸的响应式适配 ”

前端开发中,图片的尺寸适配是响应式设计的核心部分之一,需要结合图片类型、容器场景、设备特性来选择方案。以下是常见的图片尺寸策略和多窗口适配方法:

一、先明确:前端常用的图片尺寸场景

不同场景下,图片的 “合适尺寸” 差异很大:

场景 建议尺寸范围 示例
图标 / 小图标 24×24 ~ 128×128(2 倍图) 按钮图标、头像缩略图
列表缩略图 300×200 ~ 600×400(2 倍图) 商品列表、文章封面缩略图
详情页主图 800×600 ~ 1920×1080(2 倍图) 商品详情图、Banner 图
背景图 1920×1080 ~ 3840×2160 全屏背景、页面 Banner
移动端适配图 750×1334(2 倍图)、1242×2208(3 倍图) 移动端页面元素图

二、多窗口适配的核心方法

1. 基础适配:max-width: 100%(通用)

最常用的适配方式,让图片不超过容器宽度,自动缩放高度:

img {
  max-width: 100%; /* 图片宽度不超过父容器 */
  height: auto;    /* 高度自动按比例缩放,避免变形 */
}

✅ 适用场景:大部分内联图片、列表图、详情图。

2. 背景图适配:background-size

针对背景图,通过 CSS 属性控制缩放逻辑:

.bg-img {
  width: 100%;
  height: 300px;
  background: url("bg.jpg") center/cover no-repeat; 
  /* 或单独设置: */
  background-size: cover; /* 覆盖容器,可能裁剪 */
  /* background-size: contain; 完整显示,可能留白 */
}
  • cover:优先覆盖容器,保持比例(常用全屏背景);
  • contain:优先完整显示,保持比例(常用图标背景)。

3. 响应式图片:srcset + sizes(精准加载)

让浏览器根据设备尺寸 / 像素比,自动选择合适的图片(减少加载体积):

<img 
  src="img-800.jpg"  <!-- 默认图 -->
  srcset="
    img-400.jpg 400w,  <!-- 400px宽的图 -->
    img-800.jpg 800w,  <!-- 800px宽的图 -->
    img-1200.jpg 1200w <!-- 1200px宽的图 -->
  "
  sizes="(max-width: 600px) 400px, 800px" <!-- 告诉浏览器容器宽度 -->
  alt="响应式图片"
>

✅ 适用场景:对加载性能要求高的大图(如 Banner、详情主图)。

4. 移动端高清图:2 倍图 / 3 倍图

针对 Retina 屏,提供高分辨率图,避免模糊:

<!-- 方法1:srcset 按像素比适配 -->
<img 
  src="img@2x.png" 
  srcset="
    img@1x.png 1x,  <!-- 普通屏 -->
    img@2x.png 2x,  <!-- Retina屏 -->
    img@3x.png 3x   <!-- 超高清屏 -->
  "
  alt="高清图"
>

<!-- 方法2:CSS 背景图(针对图标) -->
.icon {
  background: url("icon@2x.png") no-repeat;
  background-size: 24px 24px; /* 实际显示尺寸是24×24,图片是48×48 */
  width: 24px;
  height: 24px;
}

5. 容器限制:object-fit(控制图片在容器内的显示方式)

当图片宽高比与容器不一致时,避免变形:

.img-container {
  width: 300px;
  height: 300px;
  overflow: hidden;
}
.img-container img {
  width: 100%;
  height: 100%;
  object-fit: cover; /* 覆盖容器,裁剪多余部分(常用头像、卡片图) */
  /* object-fit: contain; 完整显示,留白 */
  /* object-fit: fill; 拉伸变形(不推荐) */
}

6. 媒体查询:针对特定窗口尺寸切换图片

强制在不同屏幕下使用不同图片(适合差异较大的场景):

/* 移动端用小图 */
@media (max-width: 768px) {
  .banner {
    background-image: url("banner-mobile.jpg");
  }
}
/* 桌面端用大图 */
@media (min-width: 769px) {
  .banner {
    background-image: url("banner-desktop.jpg");
  }
}

三、总结适配思路

  1. 优先用 max-width: 100% + height: auto:覆盖 80% 的基础场景;
  2. 背景图用 background-size: cover/contain
  3. 大图用 srcset + sizes:兼顾性能和清晰度;
  4. 固定容器用 object-fit:避免图片变形;
  5. 移动端用 2 倍 / 3 倍图:保证高清显示。

面试官: “ 请你讲一下 package.json 文件 ? ”

1. package.json 的作用

package.json 是 Node.js/npm 项目的核心配置文件,位于项目根目录,它的作用包括:

  • 描述项目信息:名称、版本、作者、许可证等。
  • 声明依赖:项目运行所需的包(dependencies)和开发所需的包(devDependencies)。
  • 定义脚本命令:通过 scripts 字段,让你可以用 npm run 执行自定义任务(如启动、测试、构建)。
  • 指定元数据:比如入口文件、浏览器兼容性等。

2. 基本结构示例

一个典型的 package.json 可能如下:

{
  "name": "my-project",
  "version": "1.0.0",
  "description": "A sample Node.js project",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest",
    "build": "webpack"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "webpack": "^5.89.0"
  },
  "author": "Your Name",
  "license": "MIT",
  "keywords": ["node", "express", "example"]
}

3. 核心字段说明

3.1 项目信息字段

  • name:项目名称(必须小写,无空格)。
  • version:项目版本,遵循 SemVer(语义化版本),格式为 x.y.z(主版本。次版本。补丁版本)。
  • description:项目的简短描述。
  • author:作者信息,可以是字符串或对象(如 {"name": "xxx", "email": "xxx"})。
  • license:开源许可证类型(如 MITISCGPL)。
  • keywords:项目关键字数组,方便在 npm 上搜索。

3.2 入口与配置字段

  • main:指定项目的入口文件(默认是 index.js)。

  • type:指定模块系统类型:

    • "commonjs"(默认):使用 require() 导入。
    • "module":使用 import/export 语法。
  • files:发布到 npm 时需要包含的文件或目录。

  • repository:项目代码仓库地址。


3.3 依赖字段

  • dependencies:生产环境依赖(项目运行时必需的包),例如:

    "dependencies": {
      "react": "^18.2.0"
    }
    

    版本号前的 ^ 表示兼容当前版本的次版本更新。

  • devDependencies:开发环境依赖(仅开发时使用,比如测试、构建工具),例如:

    "devDependencies": {
      "eslint": "^8.55.0"
    }
    
  • peerDependencies:声明项目运行时需要的外部依赖版本(常用于插件或库)。

  • optionalDependencies:可选依赖,即使安装失败也不会影响项目。


3.4 脚本字段

  • scripts:定义可执行的命令,例如:

    "scripts": {
      "start": "node index.js",
      "dev": "nodemon index.js"
    }
    

    执行方法:

    npm run start
    npm run dev
    

4. package.json 的生成方式

  • 手动创建:直接新建 package.json 文件并写入内容。

  • 使用命令:

    npm init
    

    会通过交互方式生成。

  • 使用默认配置:

    npm init -y
    

    直接生成一个默认的 package.json


5. 与 package-lock.json 的关系

  • package.json:声明依赖的版本范围
  • package-lock.json:锁定安装时的具体版本,确保每次安装的依赖版本一致。

✅ 总结package.json 是项目的 “身份证” 和 “说明书”,它定义了项目的基本信息、依赖关系、可执行脚本等。掌握它的结构和字段,是使用 npm 和 Node.js 开发的基础。

深入防抖与节流:从闭包原理到性能优化实战

前言

在前端开发中,防抖(Debounce)节流(Throttle) 是两种经典的性能优化技术,广泛应用于搜索建议、滚动加载、窗口缩放等高频事件场景。它们能有效减少不必要的函数调用,避免页面卡顿或请求爆炸。

要深入理解其实现原理,你需要掌握以下核心知识点:

闭包(Closure) :用于在函数返回后仍能“记住”并访问内部变量(如定时器 ID 或时间戳)

对于闭包,我写了这两篇文章

柯里化:用闭包编织参数的函数流水线

JavaScript 词法作用域与闭包:从底层原理到实战理解

this 与参数的正确传递:确保被包装的函数在正确上下文中运行。

对于this,有不懂的可以参考这篇文章:

this 不是你想的 this:从作用域迷失到调用栈掌控

本文将结合生活类比、代码实现与真实场景,带你一步步拆解防抖与节流的机制、差异与应用之道。即使你曾觉得它们“有点绕”,读完也会豁然开朗。

一、问题背景:输入框频繁触发事件

全部代码在后面的附录

在 Web 开发中,用户在输入框中打字时,常会绑定 keyup 事件来实时响应输入内容。例如:

// 1.html Lines 17-19
function ajax(content) {
  console.log('ajax request', content);
}
// 1.html Lines 64-66
inputa.addEventListener('keyup', function(e) {
  ajax(e.target.value); // 复杂操作
});

问题:每当用户输入一个字符,就会触发一次 ajax() 调用。若用户输入 “hello”,将产生 5 次请求,造成不必要的网络开销和性能浪费。

image.png


二、防抖(Debounce)机制

想象你站在电梯里,正等着门关上。

可就在这时,一个路人匆匆跑进来,门立刻重新打开;还没等它合拢,又一个人冲了进来……只要不断有人进入,电梯就会一直“耐心”地等下去。

我站在里面心想:“这门到底什么时候才关啊?”

直到最后,整整几秒钟没人再进来——终于,“叮”一声,门缓缓合上,电梯开始运行。

这就像防抖:只要事件还在频繁触发,函数就一直“等”;只有当触发停歇了一段时间,它才真正执行。

这种“按节奏执行”的思想,不仅存在于游戏中,也广泛应用于 Web 交互。

一些AI编辑器 ( 比如Trae Cursor )就是这样

当你在代码框里飞快敲字时,它并不会每按一个键就立刻分析整段逻辑或发起智能补全请求。

那样做不仅浪费资源,还会拖慢输入体验。

相反,它会默默“观察”你的输入节奏:

只要你还在连续打字,它就耐心等待;一旦你停顿半秒,它才迅速介入,给出精准建议

代码实现

// 1.html Lines 21-30
function debounce(fn, delay) {
  let id; // 闭包中的自由变量,用于保存定时器 ID
  return function(...args) {
    if (id) clearTimeout(id); // 清除上一次的定时器
    const that = this;
    id = setTimeout(() => {
      fn.apply(that, args);
    }, delay);
  };
}

关键点解析

防抖函数通过闭包维护一个共享的定时器标识 id,使得多次事件触发都能访问并操作同一个状态。

每当用户触发事件(如键盘输入),函数会先清除之前尚未执行的定时器(如果存在),然后重新启动一个延迟为 delay 毫秒的新定时器

这意味着只要用户持续操作,计时就会不断重置,真实逻辑始终被推迟;只有当用户停止操作并经过指定的等待时间后,目标函数才会真正执行。

delay = 500ms 为例,若用户在 200ms 内快速输入 “hello”,每次按键都会打断之前的倒计时,最终仅在最后一次输入结束 500ms 后调用一次 ajax("hello")。整个过程将原本可能触发 5 次的请求压缩为 1 次,在保证响应合理性的同时,显著降低了系统开销。

image.png

使用示例

// 1.html Lines 58-69
const debounceAjax = debounce(ajax, 500);

inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

三、节流(Throttle)机制

核心思想

在固定时间间隔内,最多执行一次函数。

我正在玩一款FPS游戏,手指死死按住鼠标左键疯狂扫射——

可游戏里的枪根本没跟着我的节奏“突突突”到底。明明我一秒点了十下,它却稳稳地“哒、哒、哒”,每隔固定时间才射出一发子弹。

后来我才明白:这不是卡顿,而是射速限制在起作用。无论我多着急、按得多快,系统都会冷静地按自己的节奏来,既不让火力过猛破坏平衡,也不让我白白浪费弹药。

这就像节流:不管事件触发得多密集,函数都坚持“定时打卡”,不多不少,稳稳执行。

这种设计哲学,同样被现代开发工具所采纳

比如京东等电商平台:鼠标滚动时,页面需要不断判断是否已滑动到商品列表底部,从而决定是否自动加载下一页商品。

如果对每一次滚动事件都立即响应,浏览器会因频繁计算和发起网络请求而卡顿,尤其在低端设备上体验更差。

于是,开发者会使用节流机制——将滚动处理函数限制为每 200~300 毫秒最多执行一次。这样,即使用户快速拖动滚动条,系统也只会在固定间隔“抽样”检查位置,既保证了加载的及时性,又避免了性能过载。

换句话说:我不在乎你滚得多快,我只按自己的节奏干活——这正是节流在真实场景中的价值。

代码实现

// 1.html Lines 32-52
function throttle(fn, delay) {
  let last = 0;       // 上次执行的时间戳
  let deferTimer = null;

  return function(...args) {
    const now = Date.now();
    const that = this;

    if (last && now < last + delay) {
      // 还未到下次执行时间:延迟执行,并确保最后一次能触发
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, args);
      }, delay - (now - last));
    } else {
      // 可立即执行
      last = now;
      fn.apply(that, args);
    }
  };
}

关键点解析

节流函数通过闭包维护两个关键状态:

last 记录上一次实际执行的时间戳,deferTimer 则用于管理可能的延迟执行任务。

每当事件被触发,函数会先获取当前时间,并判断距离上次执行是否已超过设定的间隔 delay

如果尚未到冷却期(即 now < last + delay),它不会立即执行,而是清除之前安排的延迟任务,并根据剩余时间重新设置一个定时器,确保在当前周期结束时至少执行一次;

如果已经过了冷却期,则直接执行函数并更新 last。这种机制既实现了“固定频率执行”的节奏控制,又巧妙地保证了在连续高频触发的末尾仍能响应最后一次操作。

例如,在 delay = 500ms 的配置下,无论用户在短时间内触发多少次事件,函数都会在 0ms、500ms、1000ms 等时间点稳定执行,既避免了过度调用,又不丢失关键的最终状态。

使用示例

// 1.html Lines 59-62
const throttleAjax = throttle(ajax, 500);

inputc.addEventListener('keyup', function(e) {
  throttleAjax(e.target.value);
});

四、典型应用场景

防抖适用场景

防抖最适合那些“只关心最终结果”的交互场景。

例如,在百度或淘宝的搜索框中,用户一边输入一边期待建议词,但如果每敲一个字母就立刻发起请求,不仅会制造大量无意义的网络调用,还可能因中间态(如拼音未完成)返回错误结果。

通过防抖,系统会耐心等到用户停顿片刻(比如 300 毫秒),再以最终输入内容发起一次精准查询。

类似的逻辑也适用于表单字段的验证——只有当用户真正输完并稍作停顿,才触发校验,避免在输入过程中不断弹出错误提示干扰操作。

简言之,防抖在“太快导致资源浪费”和“太慢影响体验”之间找到了最佳平衡点。

节流适用场景

相比之下,节流则适用于需要“持续响应但必须限频”的场景。

比如在京东、掘金等电商或内容平台,用户快速滚动页面时,系统需判断是否已滑到底部以加载更多商品或帖子。若对每一次滚动都立即响应,浏览器将不堪重负。

而通过节流(如每 300 毫秒最多执行一次检查),既能及时感知滚动行为,又避免过度计算。

同样,鼠标移动或元素拖拽过程中,实时更新坐标若不加限制,极易造成界面卡顿;节流能确保 UI 以稳定帧率更新,保持流畅感。甚至在某些对 resize 事件要求实时反馈的场景(如动态调整画布或视频比例),也会采用节流而非防抖,以兼顾响应性与性能。


防抖与节流,看似简单,却是前端性能优化的基石。掌握它们,就掌握了在“响应速度”与“系统负担”之间优雅平衡的艺术。


五、完整示例代码

上面的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>防抖</title>
</head>
<body>
  <div>
    <input type="text" id="undebounce" />
    <br>
    <input type="text" id="debounce" />
    <br>
    <input type="text" id="throttle" />
  </div>
  <script>
  function ajax(content) {
    console.log('ajax request', content);
  }
  // 高阶函数 参数或返回值(闭包)是函数(函数就是对象) 
  function debounce(fn, delay) {
    var id; // 自由变量 
    return function(args) {
      if(id) clearTimeout(id);
      var that = this;
      id = setTimeout(function(){
        fn.call(that, args)
      }, delay);
    }
  }
  // 节流 fn 执行的任务 
  function throttle(fn, delay) {
    let 
      last, 
      deferTimer;
    return function() {
      let that = this; // this 丢失
      let _args = arguments // 类数组对象
      let now = + new Date(); // 类型转换, 毫秒数
      // 上次执行过 还没到执行时间
      if(last && now < last + delay) {
        clearTimeout(deferTimer);
        deferTimer = setTimeout(function(){
          last = now;
          fn.apply(that, _args);
        }, delay - (now - last));
      } else {
        last = now;
        fn.apply(that, _args);
      }
    }
  }
  
  const inputa = document.getElementById('undebounce');
  const inputb = document.getElementById('debounce');
  const inputc = document.getElementById('throttle');

  let debounceAjax = debounce(ajax, 500);
  let throttleAjax = throttle(ajax, 500);
  inputc.addEventListener('keyup', function(e) {
    throttleAjax(e.target.value)
  })
  // 频繁触发
  inputa.addEventListener('keyup', function(e) {
    ajax(e.target.value) // 蛮复杂
  })
  inputb.addEventListener('keyup', function(e) {
    debounceAjax(e.target.value)
  })
  </script>
</body>
</html>

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

防抖与节流:前端性能优化的“双子星”,让你的网页丝滑如德芙!

在现代 Web 开发中,用户交互越来越丰富,事件触发也越来越频繁。无论是搜索框的实时建议、页面滚动加载,还是窗口尺寸调整,这些看似简单的操作背后,都可能隐藏着性能陷阱。如果不加以控制,高频事件会像洪水一样冲垮你的应用——导致卡顿、内存泄漏,甚至服务器崩溃。

幸运的是,前端工程师早已找到了两大利器:防抖(Debounce)节流(Throttle) 。它们如同性能优化领域的“双子星”,一个专注“等你停手”,一个坚持“按节奏来”。今天,我们就深入剖析这两位高手的原理、区别与实战用法,助你写出更高效、更流畅的代码!


一、问题根源:为什么我们需要防抖和节流?

想象一下你在百度搜索框输入“React教程”:

  • 每按下一个键(R → e → a → c → t …),浏览器都会触发一次 keyup 事件;
  • 如果每次事件都立即发送 AJAX 请求,那么短短 6 个字就会发出 6 次网络请求
  • 而实际上,你只关心最终的关键词 “React教程”。

这就是典型的 “高频事件 + 复杂任务” 组合:

  • 事件太密集keyupscrollresize 等事件每秒可触发数十次;
  • 任务太复杂:AJAX 请求、DOM 操作、复杂计算等消耗大量资源。

若不加限制,后果严重:

  • 浪费带宽和服务器资源;
  • 页面卡顿,用户体验差;
  • 可能因请求顺序错乱导致 UI 显示错误(竞态条件)。

于是,防抖节流 应运而生。


二、防抖(Debounce):只执行最后一次

✅ 核心思想

“别急,等用户彻底停手再说!”

防抖的逻辑非常简单:在连续触发事件的过程中,不执行任务;只有当事件停止触发超过指定时间后,才执行一次。

🏠 生活类比:电梯关门

  • 电梯门打开后,等待 5 秒再关闭;
  • 如果第 3 秒有人进来,就重新计时 5 秒
  • 只有连续 5 秒没人进入,门才真正关闭。

💻 代码实现(闭包 + 定时器)

function debounce(fn, delay) {
  let timer; // 闭包变量,保存定时器 ID
  return function (...args) {
    clearTimeout(timer); // 清除上一个定时器
    timer = setTimeout(() => {
      fn.apply(this, args); // 执行原函数
    }, delay);
  };
}
关键点解析:
  • timer 是自由变量,被内部函数通过闭包“记住”;
  • 每次调用返回的函数,都会先 clearTimeout,再 setTimeout
  • 结果:只有最后一次触发后的 delay 毫秒内无新触发,才会执行

🌟 典型应用场景

场景 说明
搜索建议 用户打字时,等他停手再发请求,避免无效搜索
表单校验 输入邮箱/密码后,延迟验证,减少干扰
窗口 resize 保存布局 用户调整完窗口大小再保存,而非过程中反复保存

✅ 一句话总结:防抖适用于“有明确结束点”的操作,关注最终状态。


三、节流(Throttle):固定间隔执行

✅ 核心思想

“别慌,按我的节奏来!”

节流的逻辑是:无论事件触发多频繁,我保证每隔 X 毫秒最多执行一次任务。

🏠 生活类比:FPS 游戏射速

  • 即使你一直按住鼠标左键,枪也只会按照设定的射速(如每秒 10 发)射击;
  • 多余的点击会被忽略。

💻 代码实现(时间戳版)

function throttle(fn, delay) {
  let last = 0; // 上次执行时间
  return function (...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

但你提供的代码更智能——它结合了尾部补偿

function throttle(fn, delay) {
  let last, deferTimer;
  return function () {
    let that = this;
    let _args = arguments;
    let now = +new Date();

    if (last && now < last + delay) {
      // 还在冷却期:清除旧定时器,安排新尾部任务
      clearTimeout(deferTimer);
      deferTimer = setTimeout(() => {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 冷却期结束:立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}
工作流程:
  1. 第一次调用 → 立即执行;
  2. 高频调用期间 → 忽略中间操作,但记录最后一次
  3. 停止触发后 → 在 delay 毫秒后执行最后一次。

⚠️ 注意:这种实现确保了尾部操作不丢失,适合需要“收尾”的场景。

🌟 典型应用场景

场景 说明
页面滚动(scroll) 每 200ms 记录一次滚动位置,避免卡顿
鼠标移动(mousemove) 控制动画或绘图频率
按钮防连点 提交订单后 1 秒内禁止再次点击
无限滚动加载 用户滚动到底部时,定期检查是否需加载新数据

✅ 一句话总结:节流适用于“持续高频”的操作,关注过程节奏。


四、防抖 vs 节流:关键区别一目了然

对比项 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后延迟执行 固定间隔执行
执行次数 N 次触发 → 1 次执行 N 次触发 → ≈ N/delay 次执行
是否保留尾部 是(天然保留) 基础版否,增强版可保留
核心机制 clearTimeout + setTimeout 时间戳判断 或 setTimeout 控制
适用事件 inputkeyup scrollresizemousemove
用户感知 “打完字才响应” “滚动时定期响应”

🔥 记住这个口诀:
“防抖等停手,节流控节奏。”


五、闭包:防抖与节流的“幕后英雄”

你可能注意到,无论是 debounce 还是 throttle,都用到了 闭包

function debounce(fn, delay) {
  let timer; // ← 这个变量被内部函数“记住”
  return function() {
    clearTimeout(timer); // ← 能访问外部的 timer
    // ...
  };
}

为什么必须用闭包?

  • timerlast 等状态需要在多次函数调用之间保持
  • 普通局部变量在函数执行完就销毁;
  • 而闭包让内部函数持续持有对外部变量的引用,形成“私有记忆”。

💡 闭包 = 函数 + 其词法环境。它是实现状态管理的基石。


六、实战建议:如何选择?

你的需求 推荐方案
用户输入搜索词 ✅ 防抖(500ms)
监听窗口 resize ✅ 节流(200ms)
滚动加载更多 ✅ 节流(300ms)
表单自动保存草稿 ✅ 防抖(1000ms)
鼠标拖拽元素 ✅ 节流(16ms ≈ 60fps)

📌 小技巧:

  • 防抖延迟通常 300~500ms(平衡响应与性能);
  • 节流间隔通常 100~300ms(根据场景调整)。

七、结语:优雅地控制频率,是专业前端的标志

防抖与节流,看似只是几行代码,却体现了对用户体验和系统性能的深刻理解。它们不是炫技,而是工程实践中不可或缺的“安全阀”。

下次当你面对高频事件时,不妨问问自己:

  • 我需要的是最终结果,还是过程采样
  • 用户是否希望立刻响应,还是可以稍等片刻

答案将指引你选择防抖或节流。掌握这“双子星”,你的代码将不再“颤抖”,而是如丝般顺滑——这才是真正的前端艺术!

❌