阅读视图

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

Vue3 日历组件选型指南:五大主流方案深度解析

在 Vue3 项目开发中,日历组件是日程管理、预约系统、数据可视化等场景的核心组件。不同项目对日历的功能需求差异极大——有的只需基础日期选择,有的需要支持多日程展示、自定义节假日、拖拽调整等复杂功能。本文从「易用性、扩展性、性能」三个维度,深入分析 5 款主流 Vue3 日历组件,并提供选型建议,帮助开发者快速找到适配场景的最佳方案。

一、Vue3 Datepicker:轻量无依赖的基础款

Vue3 Datepicker 是一款纯 Vue3+TypeScript 开发的日历组件,主打轻量与无依赖特性。该组件体积仅约 5KB,却提供了日期范围选择、禁用日期、自定义格式等实用功能。其样式简洁,开发者可通过 CSS 轻松覆盖默认样式,同时完美适配移动端与 PC 端。得益于纯 Vue3 的实现方式,该组件对 Composition API 和 Options API 都有良好的兼容性。

安装命令:

npm install vue3-datepicker --save

使用示例:

<template>
  <div class="basic-calendar">
    <Datepicker
      v-model="selectedDate"
      :disabled-dates="disabledDates"
      format="YYYY-MM-DD"
      placeholder="选择日期"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import Datepicker from 'vue3-datepicker';
import 'vue3-datepicker/dist/index.css';

const selectedDate = ref<Date | null>(null);

const disabledDates = (date: Date) => {
  const day = date.getDay();
  return day === 0 || day === 6;
};
</script>

<style scoped>
.basic-calendar {
  width: 300px;
  margin: 20px;
}
</style>

适用场景:表单中的生日选择、订单日期筛选等轻量级日期选择需求,以及追求极小打包体积的项目。

二、Element Plus Calendar:生态集成的标准化选择

Element Plus Calendar 是饿了么团队出品的企业级日历组件,与 Element Plus 组件库深度集成,视觉风格统一。该组件支持月视图、周视图、日视图三种模式切换,提供日程数据绑定能力,开发者可自定义单元格内容展示。内置国际化功能、日期范围选择、禁用日期等基础能力,并提供完整的 TypeScript 类型定义,可与 Vue3+Vite 开发环境无缝配合。

安装命令:

npm install element-plus --save

使用示例:

<template>
  <div class="el-calendar-demo">
    <el-calendar v-model="currentDate">
      <template #date-cell="{ data }">
        <p :class="data.isSelected ? 'is-selected' : ''">
          {{ data.day.split('-').pop() }}
        </p>
        <span v-if="scheduleMap[data.day]" class="schedule-count">
          {{ scheduleMap[data.day] }}条日程
        </span>
      </template>
    </el-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElCalendar } from 'element-plus';
import 'element-plus/dist/index.css';

const currentDate = ref<Date>(new Date());

const scheduleMap = ref({
  '2026-02-06': 3,
  '2026-02-08': 1,
  '2026-02-10': 2,
});
</script>

<style scoped>
.is-selected {
  color: #409eff;
  font-weight: bold;
}
.schedule-count {
  font-size: 12px;
  color: #f56c6c;
}
</style>

适用场景:使用 Element Plus 组件库的中后台管理系统,需要快速实现标准化日历和日程功能的项目。

三、FullCalendar Vue3:复杂场景的全功能方案

FullCalendar Vue3 是基于业界知名的 FullCalendar 核心库封装的 Vue3 组件,专为复杂日程管理场景设计。该组件支持月视图、周视图、日视图、列表视图、时间轴视图等 10 余种视图类型,提供了日程拖拽、调整时长、重复日程设置、自定义事件渲染等丰富功能。组件兼容 Vue3 的组合式 API,可与 Pinia 或 Vuex 状态管理库无缝集成。此外,还支持 Google 日历和 iCal 导入,具备国际化与时区切换能力。

安装命令:

npm install @fullcalendar/vue3 @fullcalendar/core @fullcalendar/daygrid @fullcalendar/interaction

使用示例:

<template>
  <div class="full-calendar-demo">
    <FullCalendar
      :plugins="calendarPlugins"
      initialView="dayGridMonth"
      :events="calendarEvents"
      editable="true"
      selectable="true"
      @dateClick="handleDateClick"
      @eventClick="handleEventClick"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';

const calendarPlugins = ref([dayGridPlugin, interactionPlugin]);

const calendarEvents = ref([
  { title: '产品评审会', start: '2026-02-06', end: '2026-02-07', color: '#409eff' },
  { title: '版本发布', start: '2026-02-09', color: '#67c23a' },
]);

const handleDateClick = (info: any) => {
  alert(`选择了日期: ${info.dateStr}`);
};

const handleEventClick = (info: any) => {
  alert(`点击了日程: ${info.event.title}`);
};
</script>

<style scoped>
.full-calendar-demo {
  width: 90%;
  margin: 20px auto;
}
</style>

适用场景:企业 OA 系统、会议室预约、课程表管理等复杂日程管理场景,需支持拖拽操作、多视图切换、复杂事件配置的项目。

四、Vant4 Calendar:移动端友好的轻量选择

Vant4 Calendar 是有赞团队出品的移动端日历组件,专为移动端 H5 和小程序场景优化。该组件在交互设计上充分考虑移动端特性,支持滑动切换月份、手势操作等移动端常见交互方式。功能方面支持日期范围选择、快捷日期选择(如近 7 天、近 30 天)、自定义弹窗样式等实用能力。组件体积仅约 8KB,性能表现优异,支持按需引入,与 Vant4 组件库整体风格保持一致。

安装命令:

npm install vant --save

使用示例:

<template>
  <div class="vant-calendar-demo">
    <van-button @click="showCalendar = true">选择日期</van-button>
    <van-calendar
      v-model:show="showCalendar"
      v-model="selectedDate"
      type="range"
      :min-date="minDate"
      :max-date="maxDate"
      @confirm="handleConfirm"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { VanCalendar, VanButton } from 'vant';
import 'vant/lib/index.css';

const showCalendar = ref(false);
const selectedDate = ref<[Date, Date]>([new Date(), new Date()]);
const minDate = ref(new Date('2026-01-01'));
const maxDate = ref(new Date('2026-12-31'));

const handleConfirm = (dates: [Date, Date]) => {
  console.log('选择的日期范围:', dates);
  showCalendar.value = false;
};
</script>

适用场景:移动端 H5 页面、小程序项目,需轻量级、交互友好的日期选择功能。

五、Vue3 Simple Calendar:极简逻辑的定制基石

Vue3 Simple Calendar 是一款独特的日历组件,它不包含任何样式封装,仅提供核心日历逻辑。该组件基于 Vue3 Composition API 开发,体积仅 3KB,没有任何第三方依赖。开发者可以完全自定义 UI 和交互方式,组件只负责处理日历的基本逻辑,如月份切换、日期选中、日期渲染回调等。

安装命令:

npm install vue3-simple-calendar --save

使用示例:

<template>
  <div class="custom-calendar">
    <simple-calendar
      v-model="currentMonth"
      @date-click="handleDateClick"
    >
      <template #header="{ year, month, prevMonth, nextMonth }">
        <div class="calendar-header">
          <button @click="prevMonth">上一月</button>
          <h3>{{ year }}年{{ month }}月</h3>
          <button @click="nextMonth">下一月</button>
        </div>
      </template>
      <template #day="{ date, isToday, isWeekend }">
        <div
          class="day-cell"
          :class="{ today: isToday, weekend: isWeekend, selected: selectedDate === date }"
        >
          {{ date.getDate() }}
        </div>
      </template>
    </simple-calendar>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import SimpleCalendar from 'vue3-simple-calendar';

const currentMonth = ref<Date>(new Date());
const selectedDate = ref<Date | null>(null);

const handleDateClick = (date: Date) => {
  selectedDate.value = date;
  console.log('选中日期:', date);
};
</script>

<style scoped>
.custom-calendar {
  width: 350px;
  margin: 20px;
}
.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
}
.day-cell {
  width: 50px;
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.today {
  background-color: #409eff;
  color: white;
  border-radius: 50%;
}
.weekend {
  color: #f56c6c;
}
.selected {
  border: 2px solid #67c23a;
  border-radius: 50%;
}
</style>

适用场景:需要品牌化设计日历 UI、特殊交互效果的项目,或仅需复用核心日历逻辑的定制化开发场景。

六、选型指南与核心原则

面对多种日历组件选择,开发者需要根据项目实际情况做出判断。以下是各组件的核心对比:

组件类型 核心优势 适用场景 打包体积
Vue3 Datepicker 轻量无依赖、易定制 基础日期选择、表单场景 ~5KB
Element Plus Calendar 大厂背书、生态集成 中后台标准化日程功能 ~15KB
FullCalendar Vue3 全功能、复杂交互 企业 OA、预约系统 ~100KB
Vant4 Calendar 移动端适配、交互友好 移动端 H5、小程序 ~8KB
Vue3 Simple Calendar 完全自定义、极简逻辑 个性化 UI、定制化交互 ~3KB

JS手撕:函数进阶 & 设计模式解析

在 JavaScript 开发中,无论是日常业务开发还是面试考察,有一批高频代码片段始终贯穿其中——它们涵盖函数封装、设计模式、异步处理等核心场景,既能提升开发效率,也是理解 JS 底层逻辑的关键。本文将以「通俗解读+专业拆解」的方式,逐一看懂这些实用代码,帮你吃透背后的原理,做到会用也会讲。

一、函数柯里化(Currying)

通俗理解

柯里化就像「分步点餐」:比如点一杯奶茶,不用一次性说清“中杯、少糖、常温”,可以先选“中杯”,再选“少糖”,最后选“常温”,每一步都记录你的选择,等所有选项凑齐,再最终下单(执行函数)。核心是“把多参数函数拆成单参数(或部分参数)的嵌套函数,逐步收集参数,最终执行”。

专业拆解(附代码解析)

柯里化的核心价值是参数复用、延迟执行,下面这段工具函数是面试中最常考的实现方式,逐行拆解其逻辑:

// 定义柯里化工具函数,接收原函数 fn + 初始参数
function curry(fn) {
  // 1. 校验入参:必须是函数,否则抛出类型错误(健壮性处理)
  if (typeof fn !== "function") throw new TypeError("Expected a function");
  
  // 2. 获取原函数【需要的必填参数个数】(函数的 length 属性 = 形参数量)
  // 比如 fn(a,b,c),fn.length 就是 3,代表需要3个参数才能执行
  const requiredArgsLength = fn.length;
  
  // 3. 截取除了第一个参数(fn)之外的所有【初始参数】
  // arguments 是类数组(不能直接用数组方法),用 slice 转成真正的数组
  const initialArgs = [].slice.call(arguments, 1);

  // 4. 内部柯里化核心函数:接收新传入的参数
  function _curry(...newArgs) {
    // 合并:初始参数 + 本次传入的新参数(收集所有已传入的参数)
    const allArgs = [...initialArgs, ...newArgs];
    
    // 5. 判断:参数是否凑够了原函数需要的数量
    if (allArgs.length >= requiredArgsLength) {
      // ✅ 凑够了:执行原函数,传入所有参数(用 apply 绑定 this,保证上下文正确)
      return fn.apply(this, allArgs);
    } else {
      // ❌ 没凑够:递归调用 curry,继续收集参数(把已收集的 allArgs 作为初始参数传入)
      return curry.call(this, fn, ...allArgs);
    }
  }

  // 6. 返回内部收集参数的函数(不立即执行,延迟到参数凑够后执行)
  return _curry;
}

用法示例

// 原函数:求三个数的和(需要3个参数)
function add(a, b, c) {
  return a + b + c;
}

// 柯里化处理
const curryAdd = curry(add);

// 分步传参(延迟执行)
curryAdd(1)(2)(3); // 6(分步传参,凑够3个执行)
curryAdd(1,2)(3); // 6(部分传参,再补全)
curryAdd(1)(2,3); // 6(任意分步组合)

关键注意点

  • 函数的 length 属性:仅统计“未指定默认值的形参”,如果形参有默认值(如 add(a=0,b)),length 会计算到第一个默认值参数为止(此时 add.length = 0)。

  • 递归收集参数:每次传参不足时,都会返回一个新的 _curry 函数,继续收集参数,直到满足要求。

二、函数组合(Compose)

通俗理解

函数组合就像「流水线作业」:比如生产一瓶饮料,先“加水”,再“加糖”,最后“装瓶”,每个步骤都是一个函数,组合起来就是“加水→加糖→装瓶”的完整流程,前一个函数的输出是后一个函数的输入。核心是“将多个单参数函数组合成一个函数,从右往左依次执行”。

专业拆解(附代码解析)

函数组合是函数式编程的核心技巧,常用于简化多步骤逻辑(如数据处理、中间件),下面是最简洁的实现方式:

function compose(...funcs) {
  // 没有传入函数,直接返回参数本身(边界处理:传入空函数时,不改变输入)
  if (funcs.length === 0) {
    return arg => arg;
  }

  // 只有一个函数,直接返回该函数(边界处理:无需组合,直接执行)
  if (funcs.length === 1) {
    return funcs[0];
  }

  // ✅ 核心:用 reduce 实现函数组合,从右往左执行
  // reduce 遍历 funcs,将前一个函数 a 和当前函数 b 组合成 (args) => a(b(...args))
  // 比如 compose(f1,f2,f3) 最终变成 (args) => f1(f2(f3(...args)))
  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

用法示例

// 步骤1:将数字转为字符串
const toString = num => num + "";
// 步骤2:给字符串加前缀
const addPrefix = str => "num_" + str;
// 步骤3:将字符串转为大写
const toUpperCase = str => str.toUpperCase();

// 组合函数:从右往左执行 → toString → addPrefix → toUpperCase
const transform = compose(toUpperCase, addPrefix, toString);

// 执行:123 → "123" → "num_123" → "NUM_123"
transform(123); // "NUM_123"

关键注意点

  • 执行顺序:从右往左,这是 compose 的默认规则(与 pipe 相反,pipe 是从左往右)。

  • 参数传递:组合后的函数接收的参数,会全部传给最右边的函数,后续函数仅接收前一个函数的返回值,因此建议每个组合的函数都是“单输入、单输出”。

三、模拟 call 方法

通俗理解

call 方法的作用是「给函数换个“主人”」:比如小明有一个“吃饭”函数,小红想借用这个函数(让函数里的 this 指向小红),就可以用 call 实现。核心是“改变函数内部的 this 指向,并立即执行函数”。

专业拆解(附代码解析)

call 是 Function.prototype 上的方法,所有函数都能调用。其底层逻辑是“将函数挂载到目标对象上,作为对象的方法调用(此时 this 指向该对象),执行后删除临时方法,避免污染原对象”,具体实现如下:

Function.prototype.mycall2 = function (thisArg, ...args) {
  // 1. 校验:调用 mycall2 的必须是函数,否则报错(健壮性处理)
  if (typeof this !== "function") {
    throw new TypeError(this + " is not a function");
  }

  // 2. 确定 this 指向:传入 null/undefined 时,this 指向全局对象(浏览器是 window,Node 是 global)
  let context = thisArg == null ? globalThis : Object(thisArg);

  // 3. 创建唯一 Symbol 属性,防止覆盖对象原有属性(比如对象本身就有 fn 方法,避免冲突)
  const fn = Symbol("fn");

  // 4. 把当前函数(this 指向的就是调用 mycall2 的函数)挂载到 context 上
  context[fn] = this; 

  // 5. 执行函数,传入参数,接收执行结果(作为对象方法调用,this 自然指向 context)
  const result = context[fn](...args);

  // 6. 删掉临时挂载的属性,不污染原对象(核心:用完即删,保持对象纯净)
  delete context[fn];

  // 7. 返回函数执行结果(与原生 call 行为一致,返回函数执行后的结果)
  return result
};

用法示例

function sayHi() {
  console.log(`Hi, 我是 ${this.name},年龄 ${this.age}`);
}

const person1 = { name: "张三", age: 20 };
const person2 = { name: "李四", age: 22 };

// 用自定义的 mycall2 改变 this 指向
sayHi.mycall2(person1); // Hi, 我是 张三,年龄 20
sayHi.mycall2(person2, 123); // Hi, 我是 李四,年龄 22(多余参数不影响,函数不接收即可)

关键注意点

  • thisArg 处理:如果传入 null/undefined,this 指向 globalThis(全局对象);如果传入基本类型(如 123、"abc"),会被 Object() 转成对应包装对象(如 Number、String)。

  • Symbol 作用:确保临时属性唯一,避免覆盖目标对象已有的属性,是实现的关键细节。

四、模拟 apply 方法

通俗理解

apply 和 call 几乎一样,都是“改变函数 this 指向并立即执行”,唯一区别是「传参方式」:call 是“逐个传参”(比如 call(obj, 1, 2, 3)),apply 是“数组传参”(比如 apply(obj, [1,2,3])),相当于“批量传参”。

专业拆解(附代码解析)

apply 的实现逻辑和 call 高度一致,核心差异在于“处理参数的方式”,具体实现如下:

Function.prototype.myapply2 = function (thisArg, argsArray) {
  // 1. 必须是函数才能调用(和 call 一致的健壮性校验)
  if (typeof this !== "function") {
    throw new TypeError(this + " is not a function");
  }

  // 2. 处理 this 指向(和 call 完全一致)
  let context = thisArg == null ? globalThis : Object(thisArg);

  // 3. 处理参数:不传 argsArray / 传 null → 默认为空数组(避免解构报错)
  // ?? 是空值合并运算符,只有当 argsArray 为 null/undefined 时,才返回 []
  const args = argsArray ?? [];

  // 4. 唯一 Symbol 防止属性冲突(和 call 一致)
  const fn = Symbol("fn");
  context[fn] = this;

  // 5. 执行函数:用扩展运算符 ... 将数组参数拆成逐个参数,和 call 逻辑一致
  const result = context[fn](...args);

  // 6. 清理临时属性,不污染原对象(和 call 一致)
  delete context[fn];

  return result;
};

用法示例

function sum(a, b, c) {
  return a + b + c;
}

const obj = { name: "测试" };

// 用 myapply2 传参(数组形式)
sum.myapply2(obj, [1, 2, 3]); // 6
sum.myapply2(obj); // 0(args 为空数组,a、b、c 都是 undefined,相加为 0)

关键注意点

  • 参数处理:argsArray 必须是数组(或类数组),如果传入非数组,会报错(原生 apply 也是如此);如果不传,默认按空数组处理。

  • 与 call 的区别:仅传参方式不同,底层执行逻辑完全一致,二者可相互替代(call 能做的,apply 也能做,只是传参麻烦一点)。

五、模拟 bind 方法

通俗理解

bind 和 call、apply 的区别是「不立即执行」:call/apply 是“改变 this 并马上执行”,bind 是“改变 this 并返回一个新函数,后续需要手动调用这个新函数才会执行”,相当于“提前绑定好 this,后续随时可用”。

专业拆解(附代码解析)

bind 的实现比 call/apply 复杂,核心要处理两个点:「参数柯里化」和「new 调用时的 this 指向」,具体实现如下:

Function.prototype.myBind = function(context, ...args) {
  // 1. 调用者必须是函数(健壮性校验)
  if (typeof this !== 'function') {
    throw new TypeError('The bound object must be a function');
  }

  // 2. 保存原函数(关键!因为后续返回的新函数需要执行原函数,this 会被改变,所以提前保存)
  const self = this; 

  // 3. 返回一个新的绑定函数(不立即执行,等待后续调用)
  function boundFunction(...newArgs) {
    // 4. 合并参数(柯里化:bind 时传入的 args + 后续调用新函数时传入的 newArgs)
    const allArgs = args.concat(newArgs);

    // 5. 执行原函数,判断是普通调用还是 new 调用
    // 用 new 调用 boundFunction 时,this 指向 new 出来的实例,此时要忽略之前绑定的 context
    // 否则,this 指向绑定的 context
    return self.apply(
      this instanceof boundFunction ? this : context,
      allArgs
    );
  }

  // 6. 继承原函数的原型,让 new 能正常工作(关键细节)
  // 比如用 new 调用绑定后的函数,实例能访问原函数原型上的属性/方法
  if (this.prototype) {
    function Empty() {} // 空函数作为中间层,避免原型链污染
    Empty.prototype = this.prototype;
    boundFunction.prototype = new Empty();
  }

  return boundFunction;
};

用法示例

function Person(name, age) {
  this.name = name;
  this.age = age;
  console.log(`我是 ${this.name},年龄 ${this.age}`);
}

const obj = { name: "默认名称" };

// 1. 普通绑定:提前绑定 this 和部分参数
const boundPerson = Person.myBind(obj, "张三");
boundPerson(20); // 我是 张三,年龄 20(this 指向 obj,合并参数 ["张三", 20])

// 2. new 调用:忽略绑定的 context,this 指向新实例
const instance = new boundPerson(22); // 我是 undefined,年龄 22(this 指向 instance,name 未赋值)
console.log(instance.age); // 22(实例能访问 age 属性,原型继承生效)

关键注意点

  • new 调用处理:这是 bind 和 call/apply 最大的区别之一,用 new 调用绑定后的函数时,this 会指向新实例,而非绑定的 context。

  • 原型继承:通过空函数中间层继承原函数原型,避免直接赋值原型导致的污染(如果直接 boundFunction.prototype = this.prototype,修改 boundFunction 原型会影响原函数原型)。

六、实现链式调用

通俗理解

链式调用就像「连环操作」:比如买奶茶时,“点单→加珍珠→加冰→付款”,每一步操作完成后,都能继续下一步,不用重复写对象名。核心是“每个方法执行后,返回当前对象(this),让后续方法能继续调用”。

专业拆解(附代码解析)

链式调用在 JS 中非常常见(如 jQuery、Promise),实现逻辑极其简单,核心就是「return this」,具体实现如下:

// 定义一个类(也可以是构造函数)
class class1 {
  constructor() {
    // 可选:初始化一些属性
    this.data = [];
  }
}

// 给类的原型添加方法,每个方法执行后 return this
class1.prototype.method = function (param) {
  console.log("执行方法,参数:", param);
  this.data.push(param); // 可以做一些业务逻辑
  return this; // 必须 return this,才能实现链式调用
};

// 扩展更多方法,同样 return this
class1.prototype.anotherMethod = function (param) {
  console.log("执行另一个方法,参数:", param);
  this.data.push(param);
  return this;
};

// 使用:创建实例后,链式调用方法
const ins = new class1();
ins.method('a').anotherMethod('b').method('c'); 
// 输出:执行方法,参数:a → 执行另一个方法,参数:b → 执行方法,参数:c
console.log(ins.data); // ['a', 'b', 'c'](业务逻辑生效)

关键注意点

  • 核心要求:每个需要链式调用的方法,必须返回 this(当前实例),如果返回其他值,后续链式调用会报错(因为其他值可能没有对应的方法)。

  • 适用场景:常用于封装工具类、组件方法(如表单验证、DOM 操作),简化代码写法。

七、发布订阅模式(EventEmitter)

通俗理解

发布订阅模式就像「公众号订阅」:你(订阅者)关注了一个公众号(发布者),当公众号发布新文章(发布事件)时,所有关注的人都会收到通知(执行订阅的回调)。核心是“解耦发布者和订阅者,二者互不依赖,通过事件仓库传递消息”。

专业拆解(附代码解析)

发布订阅模式是前端常用的设计模式,常用于组件通信、事件监听(如 Vue 的事件总线),下面是完整的 EventEmitter 实现,包含订阅、取消订阅、发布、一次性订阅四个核心方法:

class EventEmitter {
  // 1. 构造函数:初始化事件仓库(存储事件名和对应的回调函数数组)
  constructor() {
    // 用 Map 存储:key=事件名(字符串),value=回调函数数组(一个事件可以有多个订阅者)
    this.events = new Map();
  }

  // 2. 订阅事件:监听一个事件,添加回调函数
  on(eventName, listener) {
    // 如果事件不存在,先创建一个空数组(避免后续 push 报错)
    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    // 把回调函数 push 进数组(一个事件可以订阅多个回调)
    this.events.get(eventName).push(listener);
  }

  // 3. 取消订阅:移除指定事件的指定回调函数
  off(eventName, listener) {
    // 事件不存在,直接返回(无需处理)
    if (!this.events.has(eventName)) return;

    const listeners = this.events.get(eventName);
    // 找到回调函数在数组中的索引
    const index = listeners.indexOf(listener);
    // 找到并删除对应的函数(splice 会修改原数组)
    if (index !== -1) {
      listeners.splice(index, 1);
    }
  }

  // 4. 发布事件:触发指定事件,执行所有订阅的回调函数,并传递参数
  emit(eventName, ...args) {
    // 事件不存在,直接返回(没有订阅者,无需执行)
    if (!this.events.has(eventName)) return;

    const listeners = this.events.get(eventName);
    // 遍历执行所有回调函数,并传入发布时的参数
    listeners.forEach(listener => listener(...args));
  }

  // 5. 只监听一次:订阅事件后,执行一次回调就自动取消订阅
  once(eventName, listener) {
    // 包装一层函数,执行原回调后,立即取消订阅
    const wrappedListener = (...args) => {
      // 先执行原回调函数
      listener(...args);
      // 执行完立刻删除当前包装函数(取消订阅)
      this.off(eventName, wrappedListener);
    };
    // 订阅包装后的函数(而非原函数,确保执行一次后取消)
    this.on(eventName, wrappedListener);
  }
}

用法示例

// 创建 EventEmitter 实例(发布者)
const emitter = new EventEmitter();

// 1. 订阅事件(订阅者1)
function callback1(data) {
  console.log("订阅者1收到消息:", data);
}
emitter.on("message", callback1);

// 2. 订阅事件(订阅者2,只监听一次)
emitter.once("message", (data) => {
  console.log("订阅者2收到消息(只一次):", data);
});

// 3. 发布事件(触发所有订阅者)
emitter.emit("message", "Hello World"); 
// 输出:订阅者1收到消息:Hello World → 订阅者2收到消息(只一次):Hello World

// 4. 再次发布事件(订阅者2已取消订阅,不再执行)
emitter.emit("message", "再次发送消息");
// 输出:订阅者1收到消息:再次发送消息

// 5. 取消订阅者1的订阅
emitter.off("message", callback1);

// 6. 第三次发布事件(没有订阅者,无输出)
emitter.emit("message", "第三次发送消息");

关键注意点

  • 事件仓库:用 Map 存储比对象更灵活,能避免对象属性名的冲突,且能更方便地获取、删除事件。

  • once 实现:核心是“包装回调函数”,执行原回调后立即取消订阅,注意不能直接订阅原函数(否则无法取消)。

  • 取消订阅:必须传入订阅时的同一个回调函数(不能是匿名函数),否则无法找到并删除。

八、单例模式

通俗理解

单例模式就像「公司的 CEO」:整个公司只有一个 CEO,无论你什么时候、在哪里找,找到的都是同一个人。核心是“一个类只能创建一个实例,后续所有创建实例的操作,都返回同一个已存在的实例”。

专业拆解(附代码解析)

单例模式常用于封装全局工具类、数据库连接、全局状态管理等场景,避免重复创建实例造成资源浪费,下面是最简洁的 ES6 实现方式:

class Singleton {
  // 静态属性:存储唯一实例(静态属性属于类,不属于实例,全局唯一)
  static instance = null;

  constructor() {
    // 关键逻辑:如果已经有实例,直接返回旧实例(阻止创建新实例)
    if (Singleton.instance) {
      return Singleton.instance;
    }
    // 没有实例,创建并保存到静态属性中
    Singleton.instance = this;
    // 初始化实例属性(根据业务需求添加)
    this.data = [];
  }

  // 实例方法(业务逻辑):添加数据
  addData(item) {
    this.data.push(item);
  }

  // 实例方法(业务逻辑):获取数据
  getData() {
    return this.data;
  }
}

用法示例

// 多次创建实例
const instance1 = new Singleton();
const instance2 = new Singleton();
const instance3 = new Singleton();

// 验证:所有实例都是同一个
console.log(instance1 === instance2); // true
console.log(instance1 === instance3); // true

// 操作实例1,instance2、instance3 也会受到影响(因为是同一个实例)
instance1.addData("测试数据");
console.log(instance2.getData()); // ["测试数据"]
console.log(instance3.getData()); // ["测试数据"]

关键注意点

  • 静态属性 instance:必须用 static 修饰,确保属于类本身,而非实例,这样才能全局唯一。

  • 构造函数拦截:在 constructor 中判断 instance 是否存在,存在则返回旧实例,阻止新实例创建,这是单例的核心。

  • 适用场景:全局工具类(如日期工具、请求工具)、全局状态管理,避免重复创建实例造成资源浪费。

九、私有变量的实现(闭包+Symbol)

通俗理解

私有变量就像「个人的隐私」:只能自己访问和修改,别人无法直接获取或修改。在 JS 中,没有原生的 private 关键字(ES6 有,但兼容性有限),常用「闭包+Symbol」实现真正的私有变量。

专业拆解(附代码解析)

核心逻辑:用立即执行函数(IIFE)创建闭包,闭包内的 Symbol 变量外部无法访问;类内部用这个 Symbol 作为属性名,实现私有属性,具体实现如下:

const Person = (function() {
  // 1. 闭包内的 Symbol,外部无法访问(真正的私有标识)
  // Symbol 具有唯一性,即使外部也创建同名 Symbol,也和这个不是同一个
  const _name = Symbol('name');

  // 2. 定义类,类内部可以访问闭包内的 _name
  class Person {
    constructor(name) {
      // 3. 用 Symbol 作为属性名,实现私有属性(外部无法通过 obj.name 访问)
      this[_name] = name; 
    }

    // 4. 提供公共方法,供外部间接访问私有属性(可控访问)
    getName() {
      return this[_name];
    }

    // 可选:提供公共方法,供外部间接修改私有属性(可控修改)
    setName(newName) {
      this[_name] = newName;
    }
  }

  // 5. 把类返回出去,外部可以创建实例,但无法访问闭包内的 _name
  return Person;
})();

用法示例

const person = new Person("张三");

// 1. 无法直接访问私有属性(外部没有 _name Symbol,无法获取)
console.log(person.name); // undefined(没有这个公共属性)
console.log(person[_name]); // 报错(_name 是闭包内的变量,外部无法访问)

// 2. 通过公共方法访问和修改私有属性
console.log(person.getName()); // 张三
person.setName("李四");
console.log(person.getName()); // 李四

关键注意点

  • 闭包的作用:隔离作用域,让 _name Symbol 只能在 IIFE 内部访问,外部无法获取,确保私有性。

  • Symbol 的唯一性:即使外部创建 const _name = Symbol('name'),也和闭包内的 _name 不是同一个,无法访问私有属性。

  • 可控访问:通过公共方法(getName、setName)访问和修改私有属性,可以在方法中添加校验逻辑(如判断姓名长度),更安全。

十、函数字符串转成函数(new Function vs eval)

通俗理解

有时候我们会拿到一个「函数字符串」(比如从后端接口获取,或动态拼接),需要把它转成真正的函数才能执行。JS 中有两种常用方式:new Function 和 eval,二者核心区别是「作用域安全」。

专业拆解(附代码解析)

两种方式的实现的逻辑不同,安全性也有差异,下面分别实现并对比:

// 1. 使用 new Function(推荐:作用域独立、更安全)
function stringToFunction(funcStr) {
  try {
    // new Function 接收字符串参数,最后一个参数是函数体,前面是形参
    // 这里用 "return " + funcStr,把函数字符串转成函数表达式,执行后返回函数
    const func = new Function('return ' + funcStr)();
    return func;
  } catch (error) {
    console.error('转换失败:', error);
    return null;
  }
}

// 2. 使用 eval(不推荐:能访问当前作用域、不安全)
function stringToFunctionEval(funcStr) {
  try {
    /**
     * 给函数字符串加括号,转成函数表达式(避免被当作语句执行)
     * 比如 funcStr 是 "function add(){}",加括号后是 "(function add(){})",eval 执行后返回函数
     */
    const func = eval('(' + funcStr + ')');
    return func;
  } catch (error) {
    console.error('转换失败:', error);
    return null;
  }
}

// 测试示例
const funcStr = 'function add(a, b) { return a + b; }';

// 用 new Function 转换
const add1 = stringToFunction(funcStr);
console.log(add1(1, 2)); // 3(转换成功,能正常执行)

// 用 eval 转换
const add2 = stringToFunctionEval(funcStr);
console.log(add2(3, 4)); // 7(转换成功,能正常执行)

核心区别(重点)

方式 作用域 安全性 推荐度
new Function 独立作用域,只能访问全局变量,无法访问当前局部变量 高,不会污染当前作用域,也不会执行恶意代码(相对安全) 推荐
eval 能访问当前作用域的所有变量(局部、全局) 低,可能执行恶意代码,也可能污染当前作用域 不推荐(除非明确知道字符串安全)

关键注意点

  • new Function 转换时,需要给 funcStr 加 "return ",把函数字符串转成函数表达式,否则会返回 undefined。

  • eval 转换时,需要给 funcStr 加括号,避免被 JS 解析器当作语句执行(比如 function add(){} 会被当作函数声明,无法直接返回)。

  • 安全性:如果函数字符串来自不可信来源(如用户输入、未知接口),无论哪种方式都有风险,需先做校验。

十一、模板字符串执行(with + new Function)

通俗理解

有时候我们会有一个「模板字符串」(比如 "a+b,{a+b}, {b}"),需要结合一个对象(比如 {a:1, b:2}),动态替换模板中的变量并执行计算。核心是“用 with 绑定对象作用域,让模板中能直接使用对象的属性”。

专业拆解(附代码解析)

实现逻辑:用 new Function 创建动态函数,结合 with 语句将对象作为作用域,让模板字符串能直接访问对象属性,具体实现两种方式:

// 方式1:使用 with(简洁,兼容性好)
// with 可以把一个对象当作作用域,在代码块里直接用属性名,不用写 对象.属性
const sprintf2 = (template, obj) => {
  // 1. 动态创建函数:参数是 obj,函数体是 with(obj){return `模板字符串`}
  const fn = new Function("obj", `with(obj){return \`${template}\`;}`);
  
  // 2. 执行函数,传入 obj,返回模板执行后的结果
  return fn(obj);
};

// 方式2:使用解构赋值(更安全,避免 with 的副作用)
const sprintf3 = (template, obj) => {
  // 用解构赋值,把 obj 的所有属性变成函数内的局部变量
  // 比如 obj = {a:1,b:2},解构后变成 const {a,b} = obj;
  const fn = new Function(
    "obj",
    `const { ${Object.keys(obj).join(',')} } = obj; return \`${template}\`;`
  );
  return fn(obj);
};

// 测试示例
console.log(sprintf2("a:${a+b},b:${b}", { a: 1, b: 2 }));
// 输出:a:3,b:2(a+b 计算生效,直接使用 obj 的 a、b 属性)

console.log(sprintf3("a:${a*2},b:${b+3}", { a: 1, b: 2 }));
// 输出:a:2,b:5(解构赋值后,直接使用 a、b 变量)

核心区别

  • 方式1(with):简洁高效,但 with 会改变作用域链,可能导致变量查找变慢,且如果模板中使用了未在 obj 中定义的变量,会向上查找全局变量,有一定风险。

  • 方式2(解构赋值):更安全,模板中只能使用 obj 中的属性(未定义的变量会报错),不会向上查找全局变量,推荐使用。

关键注意点

  • 模板字符串转义:动态创建函数时,模板字符串中的 要转义成 \,否则会被 JS 解析器当作函数体的结束。

  • 属性名处理:如果 obj 的属性名包含特殊字符(如 -、空格),解构赋值会报错,需提前处理属性名。

十二、async 优雅处理(错误前置)

通俗理解

async/await 是 JS 处理异步的常用方式,但默认需要用 try/catch 捕获错误,代码会显得繁琐。错误前置的核心是“用一个包装函数,统一捕获异步错误,返回 [错误, 结果] 数组,后续直接判断错误即可,不用写 try/catch”。

专业拆解(附代码解析)

实现逻辑:封装一个异步包装函数,内部用 try/catch 捕获异步函数的错误,成功则返回 [null, 结果],失败则返回 [错误, null],简化错误处理流程:

// 定义一个异步包装函数,接收一个异步函数(或返回 Promise 的函数)
async function errorCaptured(asyncFunc) {
    try {
        // 执行传入的异步函数,等待结果(asyncFunc 是异步函数,用 await 等待)
        let res = await asyncFunc()
        // 成功:返回 [没有错误(null), 执行结果]
        return [null, res]
    } catch(e) {
        // 失败:返回 [错误信息, 没有结果(null)]
        return [e, null]
    }
}

// 模拟一个异步请求(比如接口请求)
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 模拟成功:resolve("成功数据")
      // 模拟失败:reject("网络错误")
      reject("网络错误")
    }, 500)
  })
}

// 使用:无需写 try/catch,直接判断错误
async function demo() {
  // 调用包装函数,解构出错误和结果
  const [err, data] = await errorCaptured(fetchData)

  // 错误判断:有错误则处理,无错误则使用数据
  if (err) {
    console.log("❌ 错误:", err)
    return // 有错误,终止后续逻辑
  }
  console.log("✅ 成功:", data)
}

demo(); // 输出:❌ 错误:网络错误

核心优势

  • 简化代码:不用在每个 async 函数中写 try/catch,统一由包装函数捕获错误,代码更简洁。

  • 错误前置:先判断错误,再处理业务逻辑,逻辑更清晰,避免错误导致后续代码报错。

  • 通用性强:可用于所有异步场景(接口请求、定时器、文件读取等),只需传入异步函数即可。

关键注意点

  • asyncFunc 要求:必须是异步函数(async 修饰)或返回 Promise 的函数,否则 await 无法等待,会直接返回同步结果。

  • 返回值格式:固定返回 [err, data] 数组,err 为 null 表示成功,data 为 null 表示失败,后续使用需严格遵循这个格式。

十三、实现 Promise 任务调度器

通俗理解

Promise 任务调度器就像「餐厅排队取号」:餐厅一次只能接待2桌客人(最大并发数),后面来的客人排队,等前面的客人吃完(任务执行完),再依次接待下一桌。核心是“控制并发任务的数量,避免同时执行过多任务导致资源耗尽”。

专业拆解(附代码解析)

实际开发中,任务调度器常用于控制接口请求并发数(比如同时请求10个接口,控制最多2个并发),下面实现两种常用版本:通用并发调度器(面试常考)和业务实用版并发请求控制:

// ====================
// 1. 通用并发调度器 Scheduler(面试标准版)
// 核心:控制最大并发数,任务排队执行,执行完一个补一个
// ====================
class Scheduler {
  constructor(maxCount = 2) {
    this.maxCount = maxCount; // 最大并发数(默认2)
    this.queue = [];         // 任务队列(存储等待执行的任务)
    this.running = 0;        // 当前运行中的任务数
  }

  // 添加任务:将任务加入队列(不立即执行)
  add(task) {
    this.queue.push(task);
  }

  // 开始执行任务:初始化启动最大并发数的任务
  start() {
    for (let i = 0; i < this.maxCount; i++) {
      this.run(); // 启动任务执行
    }
  }

  // 执行任务核心逻辑:从队列取任务,执行后补充新任务
  run() {
    // 终止条件:队列空了 或 运行中的任务数 >= 最大并发数
    if (!this.queue.length || this.running >= this.maxCount) return;

    this.running++; // 运行中的任务数+1
    const task = this.queue.shift(); // 从队列头部取出一个任务

    // 执行任务(任务是返回 Promise 的函数),执行完后更新状态
    task().finally(() => {
      this.running--; // 任务执行完,运行中的任务数-1
      this.run(); // 递归调用 run,从队列取下一个任务执行
    });
  }
}

// ====================
// 2. 并发请求控制 multiRequest(业务实用版)
// 核心:控制接口请求并发数,收集所有请求结果,最终统一返回
// ====================
function multiRequest(urls, maxNum) {
  const total = urls.length; // 总请求数
  const result = new Array(total).fill(null); // 存储所有请求结果(按顺序)
  let current = 0; // 当前要执行的请求索引
  let finished = 0; // 已完成的请求数

  // 返回 Promise,所有请求完成后 resolve 结果
  return new Promise((resolve) => {
    // 初始启动:启动最大并发数的请求(不超过总请求数)
    for (let i = 0; i < Math.min(maxNum, total); i++) {
      next();
    }

    // 执行下一个请求的逻辑
    function next() {
      if (current >= total) return; // 所有请求都已启动,终止

      const index = current++; // 记录当前请求的索引(确保结果顺序正确)
      // 执行请求(urls 中的每个元素是返回 Promise 的请求函数)
      urls[index]()
        .then((res) => {
          // 请求成功:存储成功结果
          result[index] = { success: true, data: res };
        })
        .catch((err) => {
          // 请求失败:存储失败信息
          result[index] = { success: false, error: err };
        })
        .finally(() => {
          finished++; // 已完成请求数+1
          if (finished === total) {
            resolve(result); // 所有请求完成,返回结果
          }
          next(); // 执行完一个,启动下一个请求
        });
    }
  });
}

// ====================
// 3. 使用 DEMO(可直接运行)
// ====================
// 模拟任务队列(每个任务是返回 Promise 的函数)
const tasks = [
  () => new Promise(r => setTimeout(() => { console.log("任务1"); r(); }, 1000)),
  () => new Promise(r => setTimeout(() => { console.log("任务2"); r(); }, 500)),
  () => new Promise(r => setTimeout(() => { console.log("任务3"); r(); }, 1200)),
  () => new Promise(r => setTimeout(() => { console.log("任务4"); r(); }, 800)),
];

// 测试通用调度器(最大并发数2)
const scheduler = new Scheduler(2);
tasks.forEach(task => scheduler.add(task));
scheduler.start();
// 输出顺序:任务2(500ms)→ 任务1(1000ms)→ 任务4(800ms)→ 任务3(1200ms)

// 模拟请求队列(每个请求是返回 Promise 的函数)
const urls = [
  () => new Promise(resolve => setTimeout(() => resolve("URL1"), 1000)),
  () => new Promise((_, reject) => setTimeout(() => reject("URL2"), 500)),
  () => new Promise(resolve => setTimeout(() => resolve("URL3"), 2000)),
  () => new Promise(resolve => setTimeout(() => resolve("URL4"), 800)),
];

// 测试业务版并发请求控制(最大并发数2)
multiRequest(urls, 2).then(res => {
  console.log("全部请求完成:", res);
  // 输出:[{success:true,data:"URL1"}, {success:false,error:"URL2"}, {success:true,data:"URL3"}, {success:true,data:"URL4"}]
});

关键注意点

  • 通用调度器(Scheduler):适用于所有 Promise 任务(不局限于请求),核心是“队列+递归补充任务”,控制最大并发数。

  • 业务版(multiRequest):专门用于接口请求,会按请求顺序存储结果(即使某个请求先完成,也会存在对应索引位置),最终统一返回所有结果,符合业务需求。

  • 任务要求:无论是调度器还是请求控制,传入的任务/请求必须是「返回 Promise 的函数」,否则无法监听执行完成的状态。

总结

以上13个代码片段,覆盖了 JavaScript 中「函数封装、设计模式、异步处理、作用域控制」等核心场景,既是日常开发的高频工具,也是面试中的重点考察内容。

学习这些片段的关键,不是死记代码,而是理解背后的原理(比如闭包、this 指向、Promise 机制),这样才能灵活运用到实际业务中,甚至根据需求修改优化。建议结合示例代码亲手运行,感受每个细节的作用,加深理解。

前端正则表达式全解:从基础语法到实战应用

本文适合前端初学者、日常开发使用及面试复习,从正则基础到实战场景,全程可直接复制运行

前言

正则表达式(Regular Expression,简称 RegExp)是前端开发中处理字符串的核心利器,无论是表单校验、字符串格式转换、关键词提取、文本分割,还是数据清洗,都离不开正则表达式。相比于传统的循环遍历、字符截取等方式,正则用一套简洁的符号规则,实现高效、优雅的字符串操作。

本文将从正则基础语法讲起,结合连字符转驼峰命名手机号严格校验两大实战场景,深度解析代码逻辑,并补充面试高频实操题,帮助你彻底掌握正则表达式。


一、正则表达式核心基础语法

正则表达式由字面量字符、元字符、字符类、量词、边界、分组、修饰符七大部分组成,是匹配字符串的规则集合。

1. 字面量字符

字面量字符是正则中最基础、无特殊含义的字符,直接匹配自身。

  • 示例:正则 /abc/ 可匹配字符串中连续的 abc
  • 特点:大小写敏感,无特殊语义,仅做精准匹配。

2. 元字符

元字符是正则中具备特殊功能的符号,是正则的核心,不能直接匹配自身,需转义后才能匹配。

常用元字符:

  • .:匹配任意单个字符(换行符除外)
  • *:匹配前一个字符 0 次或多次
  • +:匹配前一个字符 1 次或多次(贪婪匹配)
  • ?:匹配前一个字符 0 次或 1 次
  • ``:转义符,将元字符转为字面量(如匹配 . 需写 .

3. 字符类

字符类用于匹配某一类特定字符,是正则中最常用的匹配规则。

表格

字符 匹配范围 等价写法 示例
\d 任意数字 [0-9] /\d/.test('5') → true
\D 非数字 [^0-9] /\D/.test('a') → true
\w 字母、数字、下划线 [a-zA-Z0-9_] /\w/.test('_') → true
\W 非字母 / 数字 / 下划线 [^a-zA-Z0-9_] /\W/.test('-') → true
\s 空白字符(空格、tab、换行) - /\s/.test(' ') → true
\S 非空白字符 - /\S/.test('a') → true
[] 字符组合,匹配任意一个 - /[a,b]/.test('a') → true

4. 量词

量词用于限定字符的匹配次数,精准控制匹配长度。

表格

量词 含义 示例
{n} 恰好匹配 n 次 /\d{3}/ 匹配 3 位数字
{n,} 匹配 n 次及以上 /\d{2,}/ 匹配 2 位及以上数字
{n,m} 匹配 n~m 次 /\d{2,4}/ 匹配 2-4 位数字
+ 1 次及以上(等价 {1,} /\d+/ 匹配任意长度数字
* 0 次及以上(等价 {0,} /\w*/ 匹配 0 个及以上单词字符
? 0 次或 1 次(等价 {0,1} /\d?/ 匹配 0 个或 1 个数字

5. 边界符

边界符用于限定匹配的位置,避免非目标内容干扰,是严格校验的关键。

  • ^:匹配字符串开头
  • $:匹配字符串结尾
  • \b:匹配单词边界(如单词与空格的交界处)

6. 分组

分组用 () 实现,核心作用是捕获匹配的子内容,方便后续提取或替换。

  • 捕获分组:(\w) 匹配并捕获内容,可通过 $1$2 或回调参数获取
  • 非捕获分组:(?:\w) 仅匹配不捕获,减少性能开销

7. 修饰符

修饰符写在正则末尾,全局控制匹配规则

  • g:全局匹配,匹配所有符合规则的内容(而非仅第一个)
  • i:忽略大小写
  • m:多行匹配,按行匹配 ^$

8. 正则核心方法

正则的使用离不开字符串和正则对象的方法,常用方法如下:

1)RegExp.prototype.test()

  • 作用:检测字符串是否匹配正则规则
  • 返回值:布尔值(true/false
  • 示例:/^1\d{10}$/.test('15766668888') → true

2)String.prototype.match()

  • 作用:提取字符串中匹配正则的内容
  • 返回值:匹配成功返回数组,失败返回 null
  • 示例:'价格10880元'.match(/\d+/) → ['10880']

3)String.prototype.replace()

  • 作用:替换匹配正则的内容,支持字符串 / 回调函数
  • 示例:'a-b-c'.replace(/-(\w)/g, (_, c) => c.toUpperCase()) → 'aBC'

4)String.prototype.split()

  • 作用:按正则规则分割字符串
  • 示例:'a,b c'.split(/[,\s]+/) → ['a','b','c']

二、实战场景一:连字符命名转驼峰命名

1. 需求说明

开发中常遇到 adb-cdf-qwe-try 这类连字符命名,需转换为驼峰命名 adbCdfqweTry,要求:

  • 去除开头的连字符
  • 连字符后的第一个字母转为大写
  • 支持全局替换所有连字符片段

2. 正则规则设计

核心正则:/-(\w)/g

  • -:匹配连字符字面量
  • (\w):分组捕获连字符后的字母 / 数字 / 下划线
  • g:全局修饰符,匹配所有连字符片段

3. 完整代码实现

/**
 * 连字符命名转驼峰命名
 * @param {string} str - 待转换的连字符字符串
 * @returns {string} 驼峰命名字符串
 */
function toCamelCase(str) {
  // 第一步:去除字符串开头的所有连字符
  let result = str.replace(/^-+/, '');
  // 第二步:全局匹配 "-字符",将捕获的字符转大写
  result = result.replace(/-(\w)/g, (match, char) => {
    // match:完整匹配的片段(如 -c)
    // char:分组捕获的字符(如 c)
    return char.toUpperCase();
  });
  return result;
}

// 测试用例
console.log(toCamelCase('adb-cdf')); // adbCdf
console.log(toCamelCase('-qwe-try')); // qweTry
console.log(toCamelCase('background-color')); // backgroundColor
console.log(toCamelCase('-webkit-animation-name')); // webkitAnimationName

4. 代码解析

  • 第一步 /^-+/:匹配开头 1 个及以上连字符,替换为空,解决开头符号问题
  • 第二步 /-(\w)/g:全局匹配所有连字符 + 字符组合,通过回调函数将字符转大写
  • 回调参数:第一个参数是完整匹配内容,第二个是分组捕获内容,无需完整匹配时可用 _ 占位

三、实战场景二:手机号格式严格校验

1. 需求说明

为保证后端数据准确性,需严格校验手机号:

  • 必须是 11 位数字
  • 以数字 1 开头
  • 无任何多余字符(字母、空格、符号)

2. 正则规则设计

核心正则:/^1\d{10}$/

  • ^:限定字符串开头,确保从第一个字符开始匹配
  • 1:匹配手机号开头的数字 1
  • \d{10}:匹配后续 10 位数字,精准控制总长度为 11 位
  • $:限定字符串结尾,确保无多余字符

3. 完整代码实现

// 正则常量复用:仅创建一次正则实例,提升性能
const PHONE_REGEX = /^1\d{10}$/;

/**
 * 手机号格式校验
 * @param {string} phone - 待校验的手机号
 * @returns {boolean} 合法返回 true,否则返回 false
 */
function validatePhone(phone) {
  // 类型校验:排除非字符串输入
  if (typeof phone !== 'string') return false;
  // 正则校验
  return PHONE_REGEX.test(phone);
}

// 测试用例
console.log(validatePhone('15766668888')); // true(合法)
console.log(validatePhone('d15766668888')); // false(含字母)
console.log(validatePhone('1576666888')); // false(长度不足)
console.log(validatePhone('25766668888')); // false(非 1 开头)
console.log(validatePhone('15766668888 ')); // false(含空格)

4. 关键知识点:正则常量复用

正则常量复用:将固定不变的正则表达式,用 const 定义在函数外部,仅创建一次正则实例,函数多次调用时复用该实例。

  • 优势:避免函数每次调用都重新创建正则对象,减少性能开销
  • 适用场景:规则固定的正则(如手机号、邮箱校验)
  • 反例:正则写在函数内部,每次调用都新建实例,造成资源浪费

四、面试高频实操题(含答案)

1. 基础面试题

题目 1:\w\W 的区别?

答案:

  • \w:匹配字母(大小写)、数字、下划线
  • \W\w 的取反,匹配非字母、数字、下划线的字符(如空格、符号、中文)

题目 2:正则中 ^$ 的作用?

答案:

  • ^:匹配字符串开头,防止开头出现多余字符
  • $:匹配字符串结尾,防止结尾出现多余字符
  • 两者结合可实现严格全匹配,是表单校验的核心

题目 3:+* 的区别?

答案:

  • +:匹配前一个字符 1 次或多次,至少匹配 1 次
  • *:匹配前一个字符 0 次或多次,可以匹配 0 次

2. 实操面试题

题目 1:实现下划线 + 连字符混合命名转驼峰

hello_world-testhelloWorldTest

function mixToCamel(str) {
  let result = str.replace(/^[-_]+/, '');
  result = result.replace(/[-_](sslocal://flow/file_open?url=%5Cw&flow_extra=eyJsaW5rX3R5cGUiOiJjb2RlX2ludGVycHJldGVyIn0=)/g, (_, c) => c.toUpperCase());
  return result;
}
console.log(mixToCamel('hello_world-test')); // helloWorldTest

题目 2:支持带分隔符的手机号校验

157-6666-8888157 6666 8888

function validatePhoneWithSymbol(phone) {
  if (typeof phone !== 'string') return false;
  // 先去除所有非数字字符
  const purePhone = phone.replace(/\D/g, '');
  return /^1\d{10}$/.test(purePhone);
}
console.log(validatePhoneWithSymbol('157-6666-8888')); // true

题目 3:提取字符串中所有数字

价格100元,折扣8折['100','8']

function getAllNumbers(str) {
  return str.match(/\d+/g) || [];
}
console.log(getAllNumbers('价格100元,折扣8折')); // ['100','8']

题目 4:用正则分割字符串(按逗号、空格、分号分割)

function splitString(str) {
  return str.split(/[,\s;]+/);
}
console.log(splitString('apple,banana orange;pear')); // ['apple','banana','orange','pear']

五、总结

  1. 正则是前端字符串处理的核心工具,掌握字符类、量词、边界、分组、修饰符五大核心,即可应对 90% 的场景
  2. 实战中,连字符转驼峰/-(\w)/g 全局替换,手机号校验/^1\d{10}$/ 严格匹配
  3. 性能优化:固定规则的正则采用常量复用,避免重复创建实例
  4. 面试重点:分组捕获、边界符、全局修饰符、正则复用、实战转换 / 校验

熟练运用正则,能让你的字符串代码更简洁、高效,是前端工程师必备的核心技能。

万字长文:手撕JS深浅拷贝完全指南

前言

深浅拷贝是 JavaScript 中非常经典且重要的概念。

本文将从三道手撕面试题出发,由浅入深地讲解浅拷贝、简易深拷贝和完整深拷贝的实现原理与代码细节。

三道题目分别覆盖:基础浅拷贝、限定数据类型的简易深拷贝、以及支持特殊对象和循环引用的完整深拷贝。

阅读本文,掌握深浅拷贝的核心知识点和手写实现。


一、题目:FED15 浅拷贝

描述

请补全JavaScript代码,要求实现一个对象参数的浅拷贝并返回拷贝之后的新对象。 注意:

  1. 参数可能包含函数、正则、日期、ES6新对象
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _shallowClone = target => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二、浅拷贝

对象的浅拷贝是属性与拷贝的源对象属性共享相同的引用(指向相同的底层值)的副本。

因此,当你更改源对象或副本时,也可能导致另一个对象发生更改。

与之相比,在深拷贝中,源对象和副本是完全独立的。

形式化地,如果两个对象 o1o2 是浅拷贝,那么:

  1. 它们不是同一个对象(o1 !== o2)。
  2. o1o2 的属性具有相同的名称且顺序相同。
  3. 它们的属性值相等。
  4. 它们的原型链相等。

可能导致另一个对象更改

这一点需要特别注意:并不是修改任何属性都会互相影响,只有修改被共享引用的那层属性才会。

  • 会互相影响的情况:修改 original 中一个引用类型的属性(例如数组、对象)。因为 shallowCopy 的对应属性指向同一个地址,所以 shallowCopy 能看到这个修改。
  • 不会互相影响的情况:直接给 original 的某个属性重新赋一个全新的值。这会断开 original 对该共享地址的引用,但 shallowCopy 的对应属性仍然指向原来的地址,两者不再相关。

对比深拷贝

特性 浅拷贝 (Shallow Copy) 深拷贝 (Deep Copy)
对象本身地址 不同 (新对象) 不同 (新对象)
第一层属性地址 相同 (共享引用) 不同 (递归创建新副本)
修改嵌套引用属性 会互相影响 不会互相影响
独立性 部分独立 (结构独立,深层数据依赖) 完全独立

Object.assign() - JavaScript | MDN

Object.assign() 静态方法将一个或者多个源对象中所有可枚举自有属性复制到目标对象,并返回修改后的目标对象。

如果目标对象与源对象具有相同的键(属性名),则目标对象中的属性将被源对象中的属性覆盖,后面的源对象的属性将类似地覆盖前面的源对象的同名属性。

const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target);
// Expected output: Object { a: 1, b: 4, c: 5 }

console.log(returnedTarget === target);
// Expected output: true

语法

```js Object.assign(target, ...sources)


## [RegExp - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp)

**`RegExp`** 对象用于将文本与一个模式匹配。

有关正则表达式的介绍,请阅读 [JavaScript 指南](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide)中的[正则表达式章节](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Regular_expressions)。

## [Map.prototype.set() - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map/set)

[`Map`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Map) 实例的 **`set()`** 方法会向 `Map` 对象添加或更新一个指定的键值对。

```js
const myMap = new Map();

// 将一个新元素添加到 Map 对象
myMap.set("bar", "foo");
myMap.set(1, "foobar");

// 在 Map 对象中更新某个元素的值
myMap.set("bar", "baz");

Set.prototype.add() - JavaScript | MDN

Set 实例的 add() 方法会在该集合中插入一个具有指定值的新元素,如果该 Set 对象中没有具有相同值的元素。

const mySet = new Set();

mySet.add(1);
mySet.add(5).add("some text"); // 可以链式调用

console.log(mySet);
// Set [1, 5, "some text"]

三、解法:浅拷贝实现

题目要求

  1. 实现浅拷贝(只拷贝第一层属性)
  2. 参数可能包含:函数、正则、日期、ES6新对象(如 MapSet 等)
  3. 返回一个新对象,且 target !== result

普通浅拷贝的问题

通常我们这样做浅拷贝:

// 方法1:扩展运算符
const result = { ...target };

// 方法2:Object.assign
const result = Object.assign({}, target);

但这对特殊对象(Date、RegExp、Map、Set等)会出问题

const date = new Date();
const copy = { ...date };        // 得到 {},不是日期对象
const copy2 = Object.assign({}, date); // 也是 {}

因为扩展运算符和 Object.assign 只拷贝可枚举的自身属性,而 DateRegExp 等对象的实际数据存储在内部槽(internal slots)中,不是普通属性。

内部槽”是 JavaScript 引擎用来存储对象真实核心数据的地方。它不是普通的属性,你不能用 .属性名 的方式直接访问它,也不能通过 Object.keys() 看到它。

const date = new Date();
console.log(Object.keys(date));  // [] ← 没有可枚举的自身属性
console.log({ ...date });        // {} ← 扩展运算符拷贝了个寂寞

也就是这些对象用普通的展开语法,或者点属性调用是没有办法访问到的。

即,普通的展开语法(...)和点属性访问(.)都无法访问到内部槽中的数据。

typeof - JavaScript | MDN

注意在类型判断的时候,typeof 运算符返回一个字符串,表示操作数的类型。

所以写法注意都是要这样写的

typeof target === 'function'

正确实现思路

需要先判断类型,针对不同类型做不同处理:

const _shallowClone = target => {
    // 处理 null 和基本类型
    if (target === null || typeof target !== 'object') {
        return target;
    }
    
    // 处理函数
    if (typeof target === 'function') {
        return target;
    }
    
    // 处理 Date
    if (target instanceof Date) {
        return new Date(target);
    }
    
    // 处理 RegExp(显式传递 flags)
    if (target instanceof RegExp) {
        return new RegExp(target.source, target.flags);
    }
    
    // 处理 Map
    if (target instanceof Map) {
        const newMap = new Map();
        target.forEach((value, key) => {
            newMap.set(key, value);
        });
        return newMap;
    }
    
    // 处理 Set
    if (target instanceof Set) {
        const newSet = new Set();
        target.forEach(value => {
            newSet.add(value);
        });
        return newSet;
    }
    
    // 处理数组
    if (Array.isArray(target)) {
        return [...target];
    }
    
    // 处理普通对象(保留原型链)
    return Object.assign(Object.create(Object.getPrototypeOf(target)), target);
};

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _shallowClone = target => {
                if(target === null || typeof target !== 'object'){
                    return target ;
                }

                if (typeof target === 'function'){
                    return target ;
                }

                if (target instanceof Date ){
                    return new Date(target);
                }

                if (target instanceof RegExp ){
                    return new RegExp(target.source , target.flag);
                }
                
                if (target instanceof Map){
                    const newMap = new Map();
                    target.forEach ((value , key ) => {
                        newMap.set(key ,value);
                    });
                    return newMap ;
                }

                if (target instanceof Set){
                    const newSet = new Set();
                    target.forEach (value => {
                        newSet.add(value);
                    });
                    return newSet ;
                }

                if(Array.isArray(target)){
                    return [...target];
                }

                return Object.assign(Object.create(Object.getPrototypeOf(target)),target);
                
            }
        </script>
    </body>
</html>

四、题目:FED16 简易深拷贝

描述

请补全JavaScript代码,要求实现对象参数的深拷贝并返回拷贝之后的新对象。 注意:

  1. 参数对象和参数对象的每个数据项的数据类型范围仅在数组、普通对象({})、基本数据类型中]
  2. 无需考虑循环引用问题
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _sampleDeepClone = target => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

五、深拷贝

对象的深拷贝是指其属性与其拷贝的源对象的属性不共享相同的引用(指向相同的底层值)的副本。

因此,当你更改源或副本时,可以确保不会导致其他对象也发生更改;也就是说,你不会无意中对源或副本造成意料之外的更改

这种行为与浅拷贝的行为形成对比,在浅拷贝中,对源或副本的更改可能也会导致其他对象的更改(因为两个对象共享相同的引用)。

如果两个对象 o1o2结构等价的,那么它们的观察行为是相同的。这些行为包括:

  1. o1o2 的属性具有相同的名称且顺序相同。
  2. 它们的属性的值是结构等价的。
  3. 它们的原型链是结构等价的(尽管在处理结构等价时,这些对象通常是普通对象,意味着它们都继承自 Object.prototype)。

结构等价的对象可以是同一个对象(o1 === o2)或副本o1 !== o2)。因为等价的原始值总是相等的,所以你无法对它们进行复制。

我们现在可以更正式地定义深拷贝:

  1. 它们不是同一个对象(o1 !== o2)。
  2. o1o2 的属性具有相同的名称且顺序相同。
  3. 它们的属性的值是彼此的深拷贝。
  4. 它们的原型链是结构等价的。

深拷贝可能会或可能不会复制它们的原型链(通常情况下不会)。

但是,具有结构不等价原型链的两个对象(例如,一个是数组,另一个是普通对象)永远不会是彼此的副本。

所有属性都具有原始值的对象的副本符合深拷贝和浅拷贝的定义。然而,讨论这种副本的深度并无意义,因为它没有嵌套属性,而我们通常在改变嵌套属性的上下文中讨论深拷贝。

在 JavaScript 中,标准的内置对象复制操作(展开语法Array.prototype.concat()Array.prototype.slice()Array.from()Object.assign()Object.create())不创建深拷贝(相反,它们创建浅拷贝)。

深拷贝就是创建了一个“全新”的对象,这个新对象跟原对象“长得一模一样”,但彼此独立。你改新对象,不会影响原对象;改原对象,也不会影响新对象。

Object.prototype.hasOwnProperty() - JavaScript | MDN

hasOwnProperty() 方法返回一个布尔值,表示对象自有属性(而不是继承来的属性)中是否具有指定的属性。

六、解法:简易深拷贝实现

好的,题目要求很明确:实现一个简易深拷贝。既然题目已经限定数据类型范围在数组、普通对象、基本数据类型,且无需考虑循环引用,那我们可以用一种清晰直接的方法来实现。

下面我直接给出补全的代码,并附上详细注释,帮助你理解每一行在做什么。

const _sampleDeepClone = target => {
    // 1. 处理基本数据类型 和 null
    if (target === null || typeof target !== 'object') {
        return target;  // 直接返回原值(数字、字符串、布尔、null、undefined等)
    }

    // 2. 根据 target 的类型创建空的容器(数组或对象)
    const newObj = Array.isArray(target) ? [] : {};

    // 3. 遍历原对象/数组的所有属性(包括可枚举的自身属性)
    for (let key in target) {
        // 确保只复制 target 自身的属性,不复制原型链上的属性
        if (target.hasOwnProperty(key)) {
            // 4. 递归调用深拷贝,把属性的值也进行深拷贝
            newObj[key] = _sampleDeepClone(target[key]);
        }
    }

    // 5. 返回新的对象/数组
    return newObj;
};

遍历时注意是 let key in target 为了遍历原对象/数组的所有属性(包括可枚举的自身属性)

特性 for...in for...of
遍历内容 属性名(键) 元素值
适用对象 对象、数组(但不推荐用于数组) 数组、字符串、Map、Set、arguments 等
是否遍历原型链 是(会遍历继承的属性) 否(只遍历可迭代对象自身的元素)
是否保证顺序 不保证(依赖引擎实现) 保证(按可迭代协议的顺序)

为什么这样写就能实现深拷贝?

用一个例子来测试:

const original = {
    name: "小明",
    age: 25,
    address: {
        city: "北京",
        zip: 100000
    },
    hobbies: ["篮球", "编程"]
};

const cloned = _sampleDeepClone(original);

// 修改克隆对象的嵌套属性
cloned.address.city = "上海";
cloned.hobbies.push("阅读");

console.log(original.address.city); // "北京" —— 原对象不受影响
console.log(original.hobbies);      // ["篮球", "编程"] —— 原对象不受影响

执行过程:

  1. target 是对象 → 进入处理逻辑
  2. 创建空对象 newObj = {}
  3. 遍历 name → 基本类型 → 直接复制
  4. 遍历 address → 又是一个对象 → 递归调用自身,再次执行深拷贝逻辑
  5. address 的递归调用中,创建新对象,复制 cityzip
  6. 遍历 hobbies → 是数组 → Array.isArray 检测为真 → 创建空数组 [] → 递归复制每个元素
  7. 最终返回完全独立的新对象

代码关键点说明

代码 作用
typeof target !== 'object' 判断是否是基本数据类型(包括函数,但题目范围没有函数)
target === null 单独处理 null(因为 typeof null === 'object' 是历史遗留问题)
Array.isArray(target) 区分数组和普通对象,保证复制后类型一致
for...in + hasOwnProperty 只复制对象自身的属性,不复制原型链上的
递归调用 处理嵌套结构,确保每一层都是新对象/新数组

对比:这道题为什么不能用 JSON.parse(JSON.stringify())

虽然 JSON 方法在某些场景下可以实现深拷贝,但它有缺点:

  • 无法处理 undefined、函数、Symbol
  • 性能较差
  • 本题要求手写实现,考察递归思想

而上面手写的递归方法更通用,且完全满足本题的条件。

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _sampleDeepClone = target => {
                if (target === null || typeof target !== 'object'){
                    return target ;
                }                

                const newObj = Array.isArray(target) ? [] : {} ;

                for (let key in target){
                    if (target.hasOwnProperty(key)){
                        newObj[key] = _sampleDeepClone(target[key]);
                    }
                }
                return newObj ;
            }
        </script>
    </body>
</html>

这道题的核心就是递归 + 类型判断


七、题目:FED17 深拷贝

描述

请补全JavaScript代码,要求实现对象参数的深拷贝并返回拷贝之后的新对象。 注意:

  1. 需要考虑函数、正则、日期、ES6新对象
  2. 需要考虑循环引用问题
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _completeDeepClone = (target, map = new Map()) => {
                // 补全代码
                
            }
        </script>
    </body>
</html>

八、完整深拷贝

Object.prototype.constructor - JavaScript | MDN

Object 实例的 constructor 数据属性返回一个引用,指向创建该实例对象的构造函数。注意,此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串。

除了 null 原型对象之外,任何对象都会在其 [[Prototype]] 上有一个 constructor 属性。使用字面量创建的对象也会有一个指向该对象构造函数类型的 constructor 属性,例如,数组字面量创建的 Array 对象和对象字面量创建的普通对象。

const o1 = {};
o1.constructor === Object; // true

const o2 = new Object();
o2.constructor === Object; // true

const a1 = [];
a1.constructor === Array; // true

const a2 = new Array();
a2.constructor === Array; // true

const n = 3;
n.constructor === Number; // true

Object.getOwnPropertySymbols() - JavaScript | MDN

const object1 = {};
const a = Symbol("a");
const b = Symbol.for("b");

object1[a] = "localSymbol";
object1[b] = "globalSymbol";

const objectSymbols = Object.getOwnPropertySymbols(object1);

console.log(objectSymbols.length);
// Expected output: 2

Symbol - JavaScript | MDN

symbol 是一种原始数据类型Symbol() 函数会返回 symbol 类型的值,该类型具有静态属性和静态方法。它的静态属性会暴露几个内建的成员对象;它的静态方法会暴露全局的 symbol 注册,且类似于内建对象类,但作为构造函数来说它并不完整,因为它不支持语法:"new Symbol()"。

每个从 Symbol() 返回的 symbol 值都是唯一的。一个 symbol 值能作为对象属性的标识符;这是该数据类型仅有的目的。更进一步的解析见——glossary entry for Symbol

Symbol 是 ES6 引入的一种全新的原始数据类型,它的核心特点是:每个 Symbol 值都是独一无二的

Symbol 用来创建“绝对不重名”的属性名,防止属性名冲突。

看一个实际场景:

// 你写了一个用户管理库
const user = {
    name: "小明",
    age: 18
};

// 别人用你的库时,想添加一个自定义属性
user.name = "小红";  // ❌ 把原来的 name 覆盖了!

问题:普通字符串属性名容易冲突。

用 Symbol 解决:

const user = {
    name: "小明",
    age: 18
};

// 别人添加属性时,用 Symbol
const customKey = Symbol("custom");
user[customKey] = "一些自定义数据";

// 原来的 name 完好无损
console.log(user.name);  // "小明"

// Symbol 属性不会冲突
console.log(user[customKey]);  // "一些自定义数据"

九、解法:完整深拷贝实现

与上一题(简易深拷贝)的核心区别

特性 上一题(简易深拷贝) 本题(完整深拷贝)
数据类型 仅数组、普通对象、基本类型 函数、正则、日期、Map、Set 等
循环引用 不考虑 需要考虑(关键难点)
原型链 不要求 需要保持原型链

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            const _completeDeepClone = (target, map = new Map()) => {
                if (target === null || typeof target !== 'object'){
                    return target ;
                }

                if (map.has(target)){
                    return map.get(target);
                }

                const constructor = target.constructor ;

                if (constructor === Date ){
                    return new Date(target);
                }

                if (constructor === RegExp){
                    return new RegExp(target);
                }

                if (constructor === Map ){
                    const newMap = new Map ();
                    map.set (target , newMap);
                    target.forEach((value , key ) => {
                        newMap.set(
                            _completeDeepClone(key , map),
                            _completeDeepClone(value , map)
                        );
                    });
                    return newMap ;
                }

                if (constructor === Set ){
                    const newSet = new Set();
                    map.set(target , newSet);
                    target.forEach(value => {
                        newSet.add(_completeDeepClone(value , map));
                    });
                    return newSet ;
                }

                const newObj = Array.isArray(target) ? [] : {} ;
                map.set(target , newObj);

                const keys = [...Object.keys(target),...Object.getOwnPropertySymbols(target)];
                for (let key of keys){
                    newObj[key] = _completeDeepClone(target[key] , map );
                }

                return newObj ;
            }
        </script>
    </body>
</html>

解决思路

1. 循环引用问题

当对象有相互引用时,会导致无限递归:

const obj = {};
obj.self = obj;  // 自己引用自己

// 普通递归会死循环 ❌

解决方案:用 Map 缓存已经拷贝过的对象。每次拷贝前先检查,如果拷贝过就直接返回。

2. 各种类型的处理策略

类型 处理方法
基本类型 / null 直接返回
日期 Date new Date(target)
正则 RegExp new RegExp(target)
数组 Array 遍历递归拷贝每个元素
普通对象 Object 遍历递归拷贝每个属性
Map 遍历 map,递归拷贝每个键值对
Set 遍历 set,递归拷贝每个值
函数 Function 直接返回(函数一般不深拷贝,复用即可)

完整代码

const _completeDeepClone = (target, map = new Map()) => {
    // 1. 处理基本类型 和 null
    if (target === null || typeof target !== 'object') {
        return target;
    }

    // 2. 处理循环引用:如果已经拷贝过,直接返回
    if (map.has(target)) {
        return map.get(target);
    }

    // 3. 处理特殊对象类型
    const constructor = target.constructor;
    
    // 日期
    if (constructor === Date) {
        return new Date(target);
    }
    
    // 正则
    if (constructor === RegExp) {
        return new RegExp(target);
    }
    
    // Map
    if (constructor === Map) {
        const newMap = new Map();
        map.set(target, newMap);
        target.forEach((value, key) => {
            newMap.set(
                _completeDeepClone(key, map),
                _completeDeepClone(value, map)
            );
        });
        return newMap;
    }
    
    // Set
    if (constructor === Set) {
        const newSet = new Set();
        map.set(target, newSet);
        target.forEach(value => {
            newSet.add(_completeDeepClone(value, map));
        });
        return newSet;
    }
    
    // 4. 处理数组和普通对象
    const newObj = Array.isArray(target) ? [] : {};
    map.set(target, newObj);
    
    // 遍历所有属性(包括 Symbol 属性)
    const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
    for (let key of keys) {
        newObj[key] = _completeDeepClone(target[key], map);
    }
    
    return newObj;
};

测试代码(验证循环引用)

// 测试循环引用
const obj = {
    name: "test",
    date: new Date(),
    regex: /abc/g,
    map: new Map([['key', 'value']]),
    set: new Set([1, 2, 3])
};
obj.self = obj;  // 循环引用

const cloned = _completeDeepClone(obj);

console.log(cloned !== obj);           // true
console.log(cloned.self === cloned);   // true(循环引用被正确保持)
console.log(cloned.date !== obj.date); // true(日期被正确拷贝)
console.log(cloned.regex !== obj.regex); // true(正则被正确拷贝)

关键点详解

1. map 参数的作用

const _completeDeepClone = (target, map = new Map()) => {
    // 第一次调用时 map 是空的
    // 递归调用时传入同一个 map,用来记录哪些对象已经拷贝过
}

2. 循环引用处理流程

// 步骤1: 检查是否已经拷贝过
if (map.has(target)) {
    return map.get(target);  // 直接返回已拷贝的版本,避免无限递归
}

// 步骤2: 创建新对象后立即存入 map
map.set(target, newObj);
// 这样后续遇到相同引用时就能直接返回

3. 为什么要处理 Symbol 属性?

const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
  • Object.keys() 获取字符串属性名
  • Object.getOwnPropertySymbols() 获取 Symbol 类型的属性名
  • 两者合并,确保所有属性都被拷贝

4. 为什么不拷贝函数?

if (constructor === Function) {
    return target;  // 直接返回原函数
}

函数内部可能依赖外部作用域,深拷贝没有意义,通常复用原函数即可。

注意

  1. Map 和 Set 的拷贝顺序:遍历时要注意先存 map,再递归内部元素
  2. Symbol 属性:普通 for...in 遍历不到,需要专门处理
  3. 正则的 flagsnew RegExp(target) 会自动保留标志位(g、i、m 等)

十、总结

通过三道题目,我们完整地学习了深浅拷贝的各个层次:

  1. 浅拷贝:只复制第一层,使用 Object.assign 或扩展运算符,但需要特殊处理 Date、RegExp、Map、Set 等内置对象。
  2. 简易深拷贝:递归复制所有层级,适用于数组和普通对象,不考虑循环引用。
  3. 完整深拷贝:在简易深拷贝基础上,增加对 Date、RegExp、Map、Set 的支持,并使用 Map 解决循环引用问题,同时处理 Symbol 属性。

深入理解浏览器存储方案:从Cookie到JWT登录认证

前言

在现代Web开发中,用户状态的持久化是一个永恒的话题。

无论是传统的多页应用还是当下的前后端分离架构,开发者都需要在客户端存储用户相关的数据。

Cookie、localStorage和SessionStorage作为浏览器原生提供的三种存储方案,各有特点和适用场景。

而围绕这些存储方案构建的登录认证机制,更是Web安全领域的基础知识。

本文将从原理出发,结合实际代码,带你全面理解这些技术的本质与差异。


一、浏览器存储方案的共性特征

浏览器存储方案的出现,是为了解决HTTP协议无状态带来的用户识别问题。这三种存储方案虽然实现细节不同,但存在几个共同的核心特征。

键值对存储模式:无论是Cookie、localStorage还是SessionStorage,它们都采用最简单的键值对来组织数据。开发者通过设置一个唯一的键(Key),即可存储对应的值(Value)。这种设计降低了使用门槛,使得状态管理变得直观高效。

数据类型限制:这三种存储方案都只能存储字符串类型的数据。当你尝试存储一个JavaScript对象时,实际上会被自动转换为字符串格式。这一限制意味着开发者需要自行处理数据的序列化和反序列化工作,JSON.stringify()和JSON.parse()因此成为前端开发中不可或缺的工具。

同源策略约束:安全性是浏览器存储方案的重要特性。三种存储都严格遵守同源策略,即只有来自相同协议、域名和端口的页面才能访问同一份存储数据。这一机制有效防止了跨站脚本攻击(XSS)和敏感数据的非授权访问。

用户状态管理:它们的根本目的都是保存用户状态,实现Web应用的会话管理。从简单的用户偏好设置到复杂的登录凭证,都依赖这些存储方案来实现状态的持久化。


二、三种存储方案的核心区别

虽然三种方案在上述方面保持一致,但在实际应用中,它们存在着显著的差异,这些差异决定了各自的适用场景。

2.1 存储容量与数据传输

Cookie的最大容量仅为4KB,且每次HTTP请求都会自动携带Cookie数据到服务器。这意味着如果存储过多数据,会显著增加网络带宽的消耗和请求延迟。对于高流量的应用而言,这种开销是不可忽视的。相比之下,localStorage和SessionStorage的存储容量通常在5-10MB左右,完全能够满足大多数前端存储需求,且不会产生任何网络传输负担。

2.2 数据生命周期

Cookie可以通过设置过期时间来实现持久化存储,未设置过期时间的Cookie会在浏览器关闭后自动删除。localStorage的数据除非被手动清除,否则会永久保存在浏览器中。SessionStorage则是一种会话级的存储,其数据仅在当前浏览器标签页或窗口关闭后自动清除,不同标签页之间的SessionStorage无法共享。

2.3 服务端与客户端的交互

这是三种方案最本质的区别。Cookie可以在浏览器端设置,也可以由服务器在HTTP响应头中生成和返回。当服务器收到请求时,可以直接读取和修改Cookie的内容。这种双向交互的特性使得Cookie成为实现Session认证的理想载体。而localStorage和SessionStorage完全由客户端JavaScript控制,服务器无法直接访问这些数据,这一特性使它们更适合存储不需要与服务端共享的纯客户端数据。

2.4 自动化携带机制

Cookie具有一个独特的行为:浏览器会自动将其包含在同源请求的HTTP头中。这种自动化携带机制既带来便利,也带来挑战。便利之处在于开发者无需手动处理请求头的设置,挑战则在于所有请求都会附带Cookie数据,可能导致性能问题,尤其在移动网络环境下更为明显。


三、Cookie与Session的经典登录方案

传统的Web应用普遍采用Cookie与Session结合的方式来实现用户认证。这种方案诞生于Web发展的早期阶段,至今仍被广泛使用,但其局限性在现代分布式系统中日益凸显。

3.1 原理概述

Cookie与Session的协作机制可以用"小饼干找位置"来形象理解。当用户首次登录时,服务器验证用户名和密码后,会生成一个唯一的会话标识符SessionId。这个SessionId本身不包含任何用户敏感信息,只是一个随机的唯一标识。服务器在内存中维护一个Session对象,将用户信息与SessionId关联起来。服务器响应时将SessionId放入Cookie中返回给浏览器。后续请求中,浏览器自动携带Cookie,服务器通过Cookie中的SessionId在内存中查找对应的用户信息,从而完成身份识别。

3.2 核心优势与天然缺陷

这种方案的优势在于安全性较高。用户信息存储在服务器端,客户端只持有SessionId,即使Cookie被截获,攻击者也无法直接获取用户数据。服务器还可以随时销毁Session来强制用户登出。

然而,随着互联网架构的演进,Session机制的缺陷逐渐显现。首先,服务器需要在内存中维护所有用户的Session对象,在高并发场景下会占用大量内存资源。其次,在分布式部署环境中,不同服务器之间无法共享内存中的Session数据,需要借助Redis等外部存储来解决,但增加了系统复杂度。最后,现代移动端应用和前后端分离架构中,Cookie的自动携带机制并不总是适用,跨域请求的处理也变得棘手。

3.3 实战代码:Express实现Cookie+Session登录

下面是一个基于Express框架的完整登录认证实现,演示了Cookie与Session机制的核心逻辑。

const express = require('express');
const cookieParser = require('cookie-parser');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const { error } = require('console');

const app = express();
const PORT = 3000;

// 模拟用户数据库
const usersDB = [
  {id: 1, username:"admin", password: "123", role: "admin"},
  {id: 2, username:"user", password: "123", role: "user"}
];

// Session对象集合,用于存储会话信息
const sessionStore = {};

// 启用中间件
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(cookieParser());
app.use(express.static('public'));

// 登录接口
app.post('/api/login', (req, res) => {
  const { username, password } = req.body;
  console.log(username, password, '-----');
  
  // 验证用户凭证
  const user = usersDB.find(u => u.username === username && u.password === password);
  if(!user){
    return res.status(401).json({error:"用户名或密码错误"});
  }
  
  // 生成唯一的会话ID
  const sessionId = uuidv4();
  
  // 在Session存储中记录会话信息
  sessionStore[sessionId] = {
    id: user.id,
    username: user.username,
    role: user.role,
    loginTime: new Date().toISOString()
  };
  
  // 将SessionId通过Cookie返回给客户端
  res.cookie('sessionId', sessionId, {
    httpOnly: true,  // 防止XSS攻击
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000  // 24小时过期
  });
  
  res.json({
    success: true,
    message: '登录成功',
    user: { id: user.id, username: user.username, role: user.role }
  });
});

// 获取用户信息接口(需要验证登录状态)
app.get('/api/userinfo', (req, res) => {
  const sessionId = req.cookies.sessionId;
  
  if (!sessionId || !sessionStore[sessionId]) {
    return res.status(401).json({ error: '未登录或会话已过期' });
  }
  
  const session = sessionStore[sessionId];
  res.json({ user: session });
});

// 退出登录接口
app.post('/api/logout', (req, res) => {
  const sessionId = req.cookies.sessionId;
  
  if (sessionId && sessionStore[sessionId]) {
    delete sessionStore[sessionId];
  }
  
  res.clearCookie('sessionId');
  res.json({ success: true, message: '已退出登录' });
});

app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

上述代码展示了一个完整的登录认证流程:用户提交登录请求后,服务器验证凭证并生成SessionId,将用户信息存入内存中的sessionStore对象,最后通过Set-Cookie响应头将SessionId返回给浏览器。后续请求中,浏览器自动携带Cookie,服务器通过SessionId查找对应的会话数据完成身份验证。

3.4 前端登录页面实现

一个完整的前端登录界面需要处理用户输入、发送登录请求、保存登录状态以及展示用户信息。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Cookie + Session 登录演示</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .container {
      background: white;
      padding: 2rem;
      border-radius: 12px;
      box-shadow: 0 20px 60px rgba(0,0,0,0.3);
      width: 100%;
      max-width: 400px;
    }
    h1 { text-align: center; color: #333; margin-bottom: 1.5rem; }
    .form-group { margin-bottom: 1rem; }
    label { display: block; margin-bottom: 0.5rem; color: #555; font-weight: 500; }
    input {
      width: 100%;
      padding: 0.75rem;
      border: 2px solid #e1e1e1;
      border-radius: 8px;
      font-size: 1rem;
      transition: border-color 0.3s;
    }
    input:focus { outline: none; border-color: #667eea; }
    button {
      width: 100%;
      padding: 0.75rem;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.3s;
    }
    button:hover { background: #5568d3; }
    button:disabled { background: #ccc; cursor: not-allowed; }
    .message {
      margin-top: 1rem;
      padding: 0.75rem;
      border-radius: 8px;
      text-align: center;
      display: none;
    }
    .message.error { background: #fee; color: #c00; display: block; }
    .message.success { background: #efe; color: #060; display: block; }
    .user-info {
      text-align: center;
      display: none;
    }
    .user-info.show { display: block; }
    .user-avatar {
      width: 80px;
      height: 80px;
      border-radius: 50%;
      background: #667eea;
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 2rem;
      margin: 0 auto 1rem;
    }
  </style>
</head>
<body>
  <div class="container">
    <!-- 登录表单 -->
    <div id="loginForm">
      <h1>用户登录</h1>
      <form id="form">
        <div class="form-group">
          <label for="username">用户名</label>
          <input type="text" id="username" name="username" required placeholder="请输入用户名">
        </div>
        <div class="form-group">
          <label for="password">密码</label>
          <input type="password" id="password" name="password" required placeholder="请输入密码">
        </div>
        <button type="submit" id="submitBtn">登录</button>
      </form>
      <div id="message" class="message"></div>
    </div>
    
    <!-- 用户信息展示 -->
    <div id="userInfo" class="user-info">
      <div class="user-avatar" id="avatar"></div>
      <h2 id="displayUsername"></h2>
      <p id="displayRole" style="color: #666; margin: 0.5rem 0;"></p>
      <p id="loginTime" style="color: #999; font-size: 0.875rem;"></p>
      <button onclick="logout()" style="margin-top: 1.5rem; background: #e1e1e1; color: #333;">
        退出登录
      </button>
    </div>
  </div>

  <script>
    const form = document.getElementById('form');
    const loginForm = document.getElementById('loginForm');
    const userInfoEl = document.getElementById('userInfo');
    const messageEl = document.getElementById('message');
    const submitBtn = document.getElementById('submitBtn');

    // 显示提示消息
    function showMessage(msg, type = 'error') {
      messageEl.textContent = msg;
      messageEl.className = `message ${type}`;
    }

    // 隐藏提示消息
    function hideMessage() {
      messageEl.className = 'message';
    }

    // 检查登录状态
    async function checkLoginStatus() {
      try {
        const res = await fetch('/api/userinfo', { credentials: 'include' });
        if (res.ok) {
          const data = await res.json();
          showUserInfo(data.user);
        }
      } catch (e) {
        console.log('未登录');
      }
    }

    // 显示用户信息
    function showUserInfo(user) {
      loginForm.style.display = 'none';
      userInfoEl.classList.add('show');
      document.getElementById('avatar').textContent = user.username.charAt(0).toUpperCase();
      document.getElementById('displayUsername').textContent = user.username;
      document.getElementById('displayRole').textContent = `角色: ${user.role}`;
      document.getElementById('loginTime').textContent = `登录时间: ${user.loginTime}`;
    }

    // 登录表单提交
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      hideMessage();
      submitBtn.disabled = true;
      submitBtn.textContent = '登录中...';

      const username = document.getElementById('username').value;
      const password = document.getElementById('password').value;

      try {
        const res = await fetch('/api/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          body: JSON.stringify({ username, password })
        });

        const data = await res.json();

        if (res.ok) {
          showUserInfo(data.user);
        } else {
          showMessage(data.error || '登录失败');
          submitBtn.disabled = false;
          submitBtn.textContent = '登录';
        }
      } catch (e) {
        showMessage('网络错误,请重试');
        submitBtn.disabled = false;
        submitBtn.textContent = '登录';
      }
    });

    // 退出登录
    async function logout() {
      try {
        await fetch('/api/logout', { method: 'POST', credentials: 'include' });
        userInfoEl.classList.remove('show');
        loginForm.style.display = 'block';
        form.reset();
        submitBtn.disabled = false;
        submitBtn.textContent = '登录';
      } catch (e) {
        showMessage('退出失败');
      }
    }

    // 页面加载时检查登录状态
    checkLoginStatus();
  </script>
</body>
</html>

这个前端实现包含了完整的用户交互流程:登录表单提交时会显示加载状态,登录成功后隐藏表单并展示用户信息,退出登录则清除会话状态。credentials: 'include'选项确保Cookie能够正确发送。


四、JWT双Token登录:现代前后端分离的解决方案

随着前后端分离架构的普及,传统的Cookie+Session方案逐渐显露出局限性。JWT(JSON Web Token)作为一种自包含的身份凭证,凭借其无状态、可扩展的特性,成为现代Web应用的主流认证方案。

4.1 JWT的核心原理

JWT是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象的形式安全传输信息。这些信息可以被验证和信任,因为它们经过数字签名。JWT由三部分组成:Header(头部)、Payload(负载)和Signature(签名),通过点号分隔形成最终的Token字符串。

与Session不同,JWT本身包含了用户身份信息,服务器无需存储任何会话数据。每一个Token都可以被独立验证和解码,这使得JWT特别适合分布式系统和跨域认证场景。

4.2 双Token机制的工作流程

双Token策略是JWT应用的最佳实践。系统同时颁发Access Token(访问令牌)和Refresh Token(刷新令牌)。Access Token用于接口访问,通常设置较短的过期时间(如15分钟到1小时);Refresh Token用于在Access Token过期后获取新的访问令牌,设置较长的过期时间(如7天到30天)。

当用户登录时,服务器验证凭证后同时生成这两种Token并返回给客户端。客户端使用Access Token访问受保护的资源。当Access Token过期时,客户端使用Refresh Token向服务器申请新的Access Token。如果Refresh Token也过期或被撤销,用户需要重新登录。这种机制在安全性和用户体验之间取得了良好的平衡。

4.3 JWT相比Session的优势

JWT的第一个优势是无状态扩展。服务器不需要存储Token,Token本身包含了所有验证所需的信息。这使得水平扩展变得简单,新加入的服务器节点无需同步会话状态,非常适合微服务架构和容器化部署。

第二个优势是跨域友好。JWT可以存储在localStorage中,通过HTTP请求头传递,不受Cookie的同源限制影响。这使得JWT天然支持移动端应用和第三方API集成。

第三个优势是细粒度控制。开发者可以在Payload中自定义声明,存储用户权限、角色等信息,Token本身就是一个完整的身份胶囊。

4.4 JWT实现示例

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const app = express();
const PORT = 3000;

const SECRET_KEY = crypto.randomBytes(32).toString('hex');
const REFRESH_SECRET = crypto.randomBytes(32).toString('hex');
const ACCESS_TOKEN_EXPIRE = '15m';
const REFRESH_TOKEN_EXPIRE = '7d';

const usersDB = [
  { id: 1, username: 'admin', password: '123', role: 'admin' },
  { id: 2, username: 'user', password: '123', role: 'user' }
];

app.use(express.json());

// 生成Token的辅助函数
function generateTokens(user) {
  const accessToken = jwt.sign(
    { id: user.id, username: user.username, role: user.role },
    SECRET_KEY,
    { expiresIn: ACCESS_TOKEN_EXPIRE }
  );
  
  const refreshToken = jwt.sign(
    { id: user.id, type: 'refresh' },
    REFRESH_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRE }
  );
  
  return { accessToken, refreshToken };
}

// 登录接口
app.post('/api/login', (req, res) => {
  const { username, password } = req.body;
  
  const user = usersDB.find(u => u.username === username && u.password === password);
  if (!user) {
    return res.status(401).json({ error: '用户名或密码错误' });
  }
  
  const tokens = generateTokens(user);
  
  res.json({
    success: true,
    ...tokens,
    user: { id: user.id, username: user.username, role: user.role }
  });
});

// 刷新Token接口
app.post('/api/refresh', (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(400).json({ error: '缺少refreshToken' });
  }
  
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
    
    if (decoded.type !== 'refresh') {
      throw new Error('无效的token类型');
    }
    
    const user = usersDB.find(u => u.id === decoded.id);
    if (!user) {
      throw new Error('用户不存在');
    }
    
    const tokens = generateTokens(user);
    res.json({ success: true, ...tokens });
  } catch (e) {
    res.status(401).json({ error: 'refreshToken已过期,请重新登录' });
  }
});

// 受保护的接口
app.get('/api/userinfo', authenticateToken, (req, res) => {
  res.json({ user: req.user });
});

// Token验证中间件
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: '缺少accessToken' });
  }
  
  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'accessToken已过期' });
    }
    req.user = user;
    next();
  });
}

app.listen(PORT, () => {
  console.log(`JWT服务器运行在 http://localhost:${PORT}`);
});

五、存储方案对比与实践建议

在实际项目中,选择合适的存储方案需要综合考虑多个因素。Cookie适用于需要服务器参与的状态管理场景,如用户认证。localStorage适合存储大量不需要频繁与服务端交互的数据,如用户偏好设置、本地缓存等。SessionStorage则用于会话级别的临时数据存储,如表单草稿、多步引导的中间状态。

对于认证方案的选择,传统的企业级多页应用仍可使用Cookie+Session方案,其成熟度和安全性经过长期验证。对于移动端应用、微服务架构或需要跨域认证的系统,JWT是更合适的选择。在使用JWT时,务必注意Token的安全存储、合理的过期时间设置以及Refresh Token的安全管理。


六、总结

浏览器存储方案和认证机制是Web开发的基础知识,理解它们的原理和差异对于构建安全、高效的应用至关重要。Cookie、localStorage和SessionStorage各有特点,适用于不同的场景。Cookie+Session作为经典的认证方案在传统Web应用中表现稳定,而JWT双Token方案则为现代前后端分离架构提供了更灵活的解决方案。作为开发者,应根据具体业务需求和系统架构选择最适合的技术方案。

超越 useState:掌握 React 进阶状态模式

useState 是 React 状态管理的主力。处理简单场景绰绰有余——一个控制弹窗的布尔值、一个输入框的字符串、一个计数器的数字。但需求稍微复杂一点——你需要上一次渲染的值、想对搜索词做防抖、要写一个既能受控又能非受控的组件——你就会发现自己反反复复写着同样的模板代码。用 ref 存旧值、清理 setTimeout 的 ID、受控和非受控的协调逻辑很快就变成一堆纠缠不清的 useEffect

本文将带你走过七种超越基础 useState 的状态模式。每种模式我们先展示手动实现,让你看清其中的门道,然后用 ReactUse 中专门的 Hook 替换。最后,我们会把七个 Hook 组合进一个交互式设置面板,展示它们如何无缝协作。

1. 受控 vs 非受控组件:useControlled

痛点

可复用的 UI 组件通常需要支持两种模式:受控(父组件持有状态,传入 value + onChange)和非受控(组件自行管理内部状态,可选接受 defaultValue)。同时支持两种模式是 MUI、Radix 等成熟组件库的标配——但实现起来出乎意料地繁琐。

手动实现

import { useCallback, useRef, useState } from "react";

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(defaultValue);

  // 用 ref 始终持有最新的受控值
  const valueRef = useRef(value);
  valueRef.current = value;

  const currentValue = isControlled ? value : internalValue;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      if (!isControlled) {
        setInternalValue(next);
      }
      onChange?.(next);
    },
    [isControlled, onChange]
  );

  return (
    <input
      value={currentValue}
      onChange={handleChange}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

对于一个简单输入框来说够用了。但当受控值从外部变更时(需要同步)、当你要提醒开发者不要在受控和非受控之间切换时、当值是复杂对象而非基本类型时,这套逻辑就越来越复杂。每个需要双模式的组件都在重复同一段代码。

用 useControlled

useControlled 封装了整套受控/非受控协调逻辑,返回一个 [value, setValue] 元组,无论使用者选择哪种模式都能正常工作。

import { useControlled } from "@reactuses/core";

interface CustomInputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

function CustomInput({ value, defaultValue = "", onChange }: CustomInputProps) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue,
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        fontSize: 16,
      }}
    />
  );
}

// 非受控用法——组件自行管理状态
function UncontrolledDemo() {
  return <CustomInput defaultValue="hello" />;
}

// 受控用法——父组件持有状态
function ControlledDemo() {
  const [text, setText] = useState("");
  return <CustomInput value={text} onChange={setText} />;
}

一次 Hook 调用就替代了 ref、isControlled 判断和双路径更新逻辑。组件在两种模式下行为完全一致,即使开发者意外地在受控和非受控之间切换,Hook 也能从容应对。

2. 追踪前一个值:usePrevious

痛点

你经常需要上一次渲染的值——用来比较 prop 是否变化、在新旧值之间做过渡动画、或者显示"从 X 变成了 Y"的 UI 反馈。React 没有内置这个能力。

手动实现

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

function PriceDisplay({ price }: { price: number }) {
  const prevPriceRef = useRef<number | undefined>(undefined);

  useEffect(() => {
    prevPriceRef.current = price;
  });

  const prevPrice = prevPriceRef.current;
  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

ref 加 effect 的技巧能用,但容易出错。如果把 effect 放在渲染逻辑之前(或者不该用 useLayoutEffect 的地方用了),"前值"可能会变成过期或当前的值。而且每个需要变更检测的组件都要复制这段样板代码。

用 usePrevious

usePrevious 返回上一次渲染的值,时机精确——在当前渲染期间你始终看到的是旧值。

import { usePrevious } from "@reactuses/core";

function PriceDisplay({ price }: { price: number }) {
  const prevPrice = usePrevious(price);

  const direction =
    prevPrice === undefined
      ? "neutral"
      : price > prevPrice
        ? "up"
        : price < prevPrice
          ? "down"
          : "neutral";

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <span style={{ fontSize: 32, fontWeight: 700 }}>
        ${price.toFixed(2)}
      </span>
      {direction === "up" && (
        <span style={{ color: "#16a34a", fontSize: 20 }}></span>
      )}
      {direction === "down" && (
        <span style={{ color: "#dc2626", fontSize: 20 }}></span>
      )}
      {prevPrice !== undefined && prevPrice !== price && (
        <span style={{ color: "#6b7280", fontSize: 14 }}>
          之前是 ${prevPrice.toFixed(2)}
        </span>
      )}
    </div>
  );
}

function StockTicker() {
  const [price, setPrice] = useState(142.5);

  return (
    <div style={{ padding: 24 }}>
      <PriceDisplay price={price} />
      <div style={{ marginTop: 16, display: "flex", gap: 8 }}>
        <button onClick={() => setPrice((p) => p + Math.random() * 5)}>
          涨价
        </button>
        <button onClick={() => setPrice((p) => p - Math.random() * 5)}>
          跌价
        </button>
      </div>
    </div>
  );
}

不需要 ref,不需要 effect。一行代码就能拿到前值,并且与 React 的渲染周期精确同步。

3. 防抖状态:useDebounce

痛点

搜索输入框、过滤字段、实时预览编辑器都面临同一个问题:每次按键都更新状态会触发昂贵的操作(API 请求、重渲染、复杂过滤),频率远超必要。防抖——等用户停止输入指定时间后再触发——是标准解决方案。

手动实现

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

function ManualDebouncedSearch() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    timerRef.current = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [query]);

  // 卸载时清理
  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      <p style={{ color: "#9ca3af", fontSize: 14 }}>
        (这个值会触发 API 请求)
      </p>
    </div>
  );
}

两个状态变量、一个存定时器的 ref、一个调度防抖的 effect、另一个处理卸载清理的 effect。能用,但对于一个几十个组件都需要的功能来说,仪式感太重了。

用 useDebounce

useDebounce 返回任意值的防抖版本。你正常更新源状态,Hook 会产出一个滞后的副本,只在指定的静默期之后才更新。

import { useDebounce } from "@reactuses/core";
import { useState } from "react";

function DebouncedSearch() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  return (
    <div style={{ padding: 24 }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        style={{
          padding: "8px 12px",
          border: "1px solid #d1d5db",
          borderRadius: 6,
          width: 300,
          fontSize: 16,
        }}
      />
      <p style={{ color: "#6b7280", marginTop: 8 }}>
        防抖后的值: <strong>{debouncedQuery}</strong>
      </p>
      {query !== debouncedQuery && (
        <p style={{ color: "#f59e0b", fontSize: 14 }}>
          等待输入停止...
        </p>
      )}
    </div>
  );
}

一个 Hook,一行代码。定时器管理、清理和同步全部在内部处理。比较 query !== debouncedQuery 还能免费实现"输入中"指示。

4. 节流状态:useThrottle

痛点

节流是防抖的近亲。不同于等待静默,它确保更新在每个时间间隔内最多触发一次——适用于连续触发的事件,比如滚动位置、鼠标移动或实时数据流,你想要的是稳定的更新频率而非末尾的一次性爆发。

手动实现

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

function ManualThrottledSlider() {
  const [value, setValue] = useState(50);
  const [throttledValue, setThrottledValue] = useState(50);
  const lastRun = useRef(Date.now());
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    const now = Date.now();
    const elapsed = now - lastRun.current;
    const delay = 200;

    if (elapsed >= delay) {
      setThrottledValue(value);
      lastRun.current = now;
    } else {
      timerRef.current = setTimeout(() => {
        setThrottledValue(value);
        lastRun.current = Date.now();
      }, delay - elapsed);
    }

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, [value]);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
    </div>
  );
}

节流逻辑很容易写错。你需要追踪上次执行时间、处理末尾调用(保证最终值不丢失)、清理定时器。而且这只是针对单个值——每个需要节流的状态都得重复全部逻辑。

用 useThrottle

useThrottle 返回值的节流版本,在每个间隔内最多更新一次,同时确保最终值始终被捕获。

import { useThrottle } from "@reactuses/core";
import { useState } from "react";

function ThrottledSlider() {
  const [value, setValue] = useState(50);
  const throttledValue = useThrottle(value, 200);

  return (
    <div style={{ padding: 24 }}>
      <input
        type="range"
        min={0}
        max={100}
        value={value}
        onChange={(e) => setValue(Number(e.target.value))}
        style={{ width: 300 }}
      />
      <div style={{ marginTop: 12 }}>
        <p>原始值: {value}</p>
        <p>节流值: {throttledValue}</p>
      </div>
      <div
        style={{
          marginTop: 16,
          height: 20,
          width: `${throttledValue}%`,
          background: "#4f46e5",
          borderRadius: 4,
          transition: "width 0.1s",
        }}
      />
    </div>
  );
}

进度条以 200ms 的间隔平滑更新,而不是在滑块每移动一个像素时都抖动。一行代码搞定所有时序逻辑。

5. 循环选项:useCycleList

痛点

很多 UI 控件需要在一组固定选项中循环:主题切换(亮色 / 暗色 / 跟随系统)、排序方式(升序 / 降序 / 无序)、视图模式(网格 / 列表 / 紧凑)。常规做法是用一个状态变量加一个手动计算下一个值的函数。

手动实现

import { useState } from "react";

type ViewMode = "grid" | "list" | "compact";
const viewModes: ViewMode[] = ["grid", "list", "compact"];

function ManualViewToggle() {
  const [index, setIndex] = useState(0);
  const mode = viewModes[index];

  const next = () => setIndex((i) => (i + 1) % viewModes.length);
  const prev = () =>
    setIndex((i) => (i - 1 + viewModes.length) % viewModes.length);

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

单个切换够简单了,但取模运算和独立的索引追踪是每个需要循环行为的地方都会出现的样板代码。它也不支持直接跳转到某个值或响应列表变化。

用 useCycleList

useCycleList 管理数组值的循环,提供 nextprev 以及直接跳转的 go 函数,连同当前值和索引。

import { useCycleList } from "@reactuses/core";

type ViewMode = "grid" | "list" | "compact";

function ViewToggle() {
  const [mode, { next, prev }] = useCycleList<ViewMode>(
    ["grid", "list", "compact"]
  );

  const icons: Record<ViewMode, string> = {
    grid: "▦",
    list: "☰",
    compact: "═",
  };

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button onClick={prev} style={{ fontSize: 20, cursor: "pointer" }}></button>
        <div
          style={{
            padding: "8px 16px",
            background: "#f1f5f9",
            borderRadius: 8,
            fontSize: 18,
            minWidth: 120,
            textAlign: "center",
          }}
        >
          {icons[mode]} {mode}
        </div>
        <button onClick={next} style={{ fontSize: 20, cursor: "pointer" }}></button>
      </div>
    </div>
  );
}

不用管索引,不用做取模运算。Hook 直接给你当前值和导航函数。用来做主题切换——点击在亮色、暗色、跟随系统之间循环——特别顺手。

6. 数值状态:useCounter

痛点

计数器无处不在——电商的数量选择器、分页控件、步骤指示器、缩放级别。每个都需要递增、递减、重置,通常还需要最小/最大值钳位。每次从头写这些很乏味。

手动实现

import { useCallback, useState } from "react";

function ManualQuantityPicker() {
  const [count, setCount] = useState(1);
  const min = 1;
  const max = 99;

  const increment = useCallback(
    () => setCount((c) => Math.min(c + 1, max)),
    [max]
  );
  const decrement = useCallback(
    () => setCount((c) => Math.max(c - 1, min)),
    [min]
  );
  const reset = useCallback(() => setCount(1), []);

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={decrement}
          disabled={count <= min}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= min ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= min ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={increment}
          disabled={count >= max}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= max ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= max ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

钳位逻辑、禁用状态、记忆化回调——全是标准样板代码,应用里每个计数器都在重复。

用 useCounter

useCounter 开箱即用地提供 countincdecsetreset,并支持可选的最小/最大值边界。

import { useCounter } from "@reactuses/core";

function QuantityPicker() {
  const [count, { inc, dec, reset }] = useCounter(1, {
    min: 1,
    max: 99,
  });

  return (
    <div style={{ padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <button
          onClick={() => dec()}
          disabled={count <= 1}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count <= 1 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count <= 1 ? "not-allowed" : "pointer",
          }}
        >
          -
        </button>
        <span style={{ fontSize: 24, fontWeight: 600, minWidth: 40, textAlign: "center" }}>
          {count}
        </span>
        <button
          onClick={() => inc()}
          disabled={count >= 99}
          style={{
            width: 36,
            height: 36,
            borderRadius: "50%",
            border: "1px solid #d1d5db",
            background: count >= 99 ? "#f3f4f6" : "#fff",
            fontSize: 18,
            cursor: count >= 99 ? "not-allowed" : "pointer",
          }}
        >
          +
        </button>
        <button onClick={reset} style={{ marginLeft: 12, fontSize: 14, color: "#6b7280" }}>
          重置
        </button>
      </div>
    </div>
  );
}

Hook 在内部处理钳位。你只需传一次 minmax,递增递减时永远不用担心越界。

7. 类组件风格 setState:useSetState

痛点

React 类组件的 setState 有一个很方便的特性:接受一个部分对象,然后合并到已有状态中。但 hooks 里的 useState 是整体替换。如果你的状态是一个多字段对象,每次更新都得展开:setState(prev => ({ ...prev, name: 'new' }))。对于字段很多的复杂表单或设置对象,这种写法既冗长又容易出错(忘了展开会无声地丢失字段)。

手动实现

import { useCallback, useState } from "react";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function ManualSettingsForm() {
  const [state, setFullState] = useState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  // 每次更新都必须展开上一个状态
  const setState = useCallback(
    (patch: Partial<FormState>) =>
      setFullState((prev) => ({ ...prev, ...patch })),
    []
  );

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

你得自己创建合并用的 setState 包装器。如果团队里其他开发者忘了用这个包装器,直接拿部分对象调 setFullState,字段就会无声消失。

用 useSetState

useSetState 的行为和类组件的 setState 一样——传入部分对象,自动合并到已有状态中。

import { useSetState } from "@reactuses/core";

interface FormState {
  name: string;
  email: string;
  role: string;
  notifications: boolean;
}

function SettingsForm() {
  const [state, setState] = useSetState<FormState>({
    name: "",
    email: "",
    role: "viewer",
    notifications: true,
  });

  return (
    <form style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12, maxWidth: 400 }}>
      <input
        value={state.name}
        onChange={(e) => setState({ name: e.target.value })}
        placeholder="姓名"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <input
        value={state.email}
        onChange={(e) => setState({ email: e.target.value })}
        placeholder="邮箱"
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      />
      <select
        value={state.role}
        onChange={(e) => setState({ role: e.target.value })}
        style={{ padding: "8px 12px", border: "1px solid #d1d5db", borderRadius: 6 }}
      >
        <option value="viewer">查看者</option>
        <option value="editor">编辑者</option>
        <option value="admin">管理员</option>
      </select>
      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={state.notifications}
          onChange={(e) => setState({ notifications: e.target.checked })}
        />
        邮件通知
      </label>
      <pre style={{ background: "#f8fafc", padding: 12, borderRadius: 6, fontSize: 13 }}>
        {JSON.stringify(state, null, 2)}
      </pre>
    </form>
  );
}

Hook 返回的 setState 接受部分对象并自动合并。不需要包装函数,不存在意外替换整个状态的风险。

融会贯通:一个设置面板

这些 Hook 天然可组合。下面是一个综合运用全部七个 Hook 的设置面板:

import {
  useControlled,
  usePrevious,
  useDebounce,
  useThrottle,
  useCycleList,
  useCounter,
  useSetState,
} from "@reactuses/core";
import { useState } from "react";

// 一个受控/非受控搜索输入框
function SearchInput({
  value,
  defaultValue,
  onChange,
}: {
  value?: string;
  defaultValue?: string;
  onChange?: (v: string) => void;
}) {
  const [currentValue, setCurrentValue] = useControlled({
    value,
    defaultValue: defaultValue ?? "",
    onChange,
  });

  return (
    <input
      value={currentValue}
      onChange={(e) => setCurrentValue(e.target.value)}
      placeholder="搜索设置..."
      style={{
        padding: "8px 12px",
        border: "1px solid #d1d5db",
        borderRadius: 6,
        width: "100%",
        fontSize: 14,
      }}
    />
  );
}

function SettingsPanel() {
  // 带防抖的搜索
  const [searchQuery, setSearchQuery] = useState("");
  const debouncedSearch = useDebounce(searchQuery, 300);
  const prevSearch = usePrevious(debouncedSearch);

  // 主题循环切换
  const [theme, { next: nextTheme }] = useCycleList([
    "light",
    "dark",
    "system",
  ]);

  // 带计数器的字体大小
  const [fontSize, { inc: fontUp, dec: fontDown, reset: fontReset }] =
    useCounter(16, { min: 12, max: 24 });

  // 节流的实时预览
  const throttledFontSize = useThrottle(fontSize, 150);

  // 合并式表单状态
  const [settings, setSettings] = useSetState({
    username: "",
    email: "",
    notifications: true,
    language: "zh",
  });

  const themeColors: Record<string, { bg: string; text: string }> = {
    light: { bg: "#ffffff", text: "#1e293b" },
    dark: { bg: "#1e293b", text: "#f8fafc" },
    system: { bg: "#f1f5f9", text: "#334155" },
  };

  const allSettings = [
    "username",
    "email",
    "notifications",
    "language",
    "theme",
    "font size",
  ];

  const filtered = debouncedSearch
    ? allSettings.filter((s) =>
        s.toLowerCase().includes(debouncedSearch.toLowerCase())
      )
    : allSettings;

  return (
    <div
      style={{
        padding: 24,
        maxWidth: 500,
        margin: "0 auto",
        background: themeColors[theme].bg,
        color: themeColors[theme].text,
        borderRadius: 12,
        transition: "all 0.3s",
      }}
    >
      <h2 style={{ marginTop: 0 }}>设置</h2>

      {/* 受控搜索输入 */}
      <SearchInput value={searchQuery} onChange={setSearchQuery} />

      {prevSearch && prevSearch !== debouncedSearch && (
        <p style={{ fontSize: 12, opacity: 0.6, margin: "4px 0" }}>
          从 "{prevSearch}" 变为 "{debouncedSearch}"
        </p>
      )}

      <p style={{ fontSize: 12, opacity: 0.6 }}>
        显示 {filtered.length} / {allSettings.length} 项设置
      </p>

      {/* 主题切换 */}
      {filtered.includes("theme") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>主题</span>
          <button
            onClick={nextTheme}
            style={{
              padding: "6px 16px",
              borderRadius: 6,
              border: "1px solid rgba(128,128,128,0.3)",
              background: "transparent",
              color: "inherit",
              cursor: "pointer",
            }}
          >
            {theme}
          </button>
        </div>
      )}

      {/* 字体大小计数器 */}
      {filtered.includes("font size") && (
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid rgba(128,128,128,0.2)",
          }}
        >
          <span>字体大小</span>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <button onClick={() => fontDown()}>-</button>
            <span style={{ fontWeight: 600 }}>{fontSize}px</span>
            <button onClick={() => fontUp()}>+</button>
            <button
              onClick={fontReset}
              style={{ fontSize: 12, color: "inherit", opacity: 0.6 }}
            >
              重置
            </button>
          </div>
        </div>
      )}

      {/* 带节流字体大小的实时预览 */}
      <p
        style={{
          fontSize: throttledFontSize,
          padding: "12px 0",
          transition: "font-size 0.15s",
          borderBottom: "1px solid rgba(128,128,128,0.2)",
        }}
      >
        以 {throttledFontSize}px 预览文本
      </p>

      {/* 合并状态的表单字段 */}
      {filtered.includes("username") && (
        <div style={{ padding: "12px 0" }}>
          <label style={{ display: "block", fontSize: 13, marginBottom: 4 }}>
            用户名
          </label>
          <input
            value={settings.username}
            onChange={(e) => setSettings({ username: e.target.value })}
            style={{
              padding: "6px 10px",
              border: "1px solid rgba(128,128,128,0.3)",
              borderRadius: 4,
              width: "100%",
              background: "transparent",
              color: "inherit",
            }}
          />
        </div>
      )}

      {filtered.includes("notifications") && (
        <label
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            padding: "12px 0",
          }}
        >
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) =>
              setSettings({ notifications: e.target.checked })
            }
          />
          开启通知
        </label>
      )}
    </div>
  );
}

七个 Hook,零冲突。useControlled 驱动搜索输入框,使其在别处也能以非受控方式使用。useDebounce 避免每次按键都执行过滤。usePrevious 展示搜索词的变化历史。useCycleList 处理主题切换。useCounter 管理带边界的字体大小。useThrottle 平滑实时预览的更新。useSetState 将表单字段保持在一个合并式状态对象中。每个 Hook 负责一个关注点,组合时不需要任何额外的胶水代码。

安装

npm i @reactuses/core

相关 Hook

  • useControlled -- 构建同时支持受控和非受控的组件
  • usePrevious -- 获取上一次渲染的值
  • useDebounce -- 对任意值按指定延迟进行防抖
  • useThrottle -- 对任意值按间隔进行节流
  • useCycleList -- 在数组值之间用 next/prev 循环切换
  • useCounter -- 带 inc/dec/reset 和可选 min/max 的数值状态
  • useSetState -- 像类组件 setState 一样合并部分对象到状态中
  • useBoolean -- 带 toggle、setTrue、setFalse 的布尔状态
  • useToggle -- 在两个值之间切换
  • useLocalStorage -- 将状态持久化到 localStorage 并自动序列化

ReactUse 提供了 100 多个 React Hook。浏览全部 →

CSS 模块化演进之路:从隔离到动态的样式革命

CSS 模块化演进之路:从隔离到动态的样式革命

引言:当 CSS 遇到组件化时代

在现代前端开发的浪潮中,组件化已成为构建复杂应用的标准范式。然而,当我们专注于 JavaScript 组件的逻辑封装时,一个长期被忽视的问题逐渐浮出水面:CSS 如何才能真正实现模块化?

想象这样一个场景:你和团队成员同时开发一个大型 React 应用。你精心编写了一个 .button 样式,满怀信心地提交代码。第二天,测试报告指出按钮样式异常——原来另一位开发者也在他的组件中定义了同名的 .button 类,后加载的样式覆盖了你的设计。这不是假设,而是每个前端开发者都经历过的样式冲突噩梦

本文将通过三个真实项目,深入剖析 CSS 模块化的三种主流解决方案,揭示它们如何从不同维度解决样式隔离问题,以及各自的技术哲学和适用场景。


第一章:CSS Modules —— 静态隔离的艺术

1.1 问题的根源

让我们从 css-demo 项目的一个细节说起。项目中存在两个按钮组件:Button.jsxAnotherButton.jsx。如果没有模块化,它们的 CSS 可能长这样:

/* 传统 CSS */
.button {
    background-color: blue;
    color: white;
}

当两个组件都使用 .button 类名时,后定义的样式会覆盖前者。这就是 CSS 全局命名空间带来的命名污染问题。

1.2 CSS Modules 的解决方案

Button.jsx 中,我们看到这样的代码:

import styles from './Button.module.css';

export default function Button() {
    return (
    <>
        <h1 className={styles.txt}>你好,世界!</h1>
        <button className={styles.button}>按钮</button>
    </>
)
}

关键在于 import styles from './Button.module.css'。这不是普通的 CSS 文件导入,而是 CSS Modules 的语法约定。注意文件名的 .module.css 后缀——这是告诉构建工具(如 Vite、Webpack)将其作为 CSS Module 处理。

对应的 Button.module.css 文件:

.button {
    background-color: blue;
    color: white;
    padding: 10px 20px;
}
.txt {
    color: pink;
    font-size: 20px;
    font-weight: bold;
}

1.3 编译时的魔法

当代码被编译时,CSS Modules 会执行以下转换:

  1. 生成唯一类名:将 .button 转换为类似 .Button_button__a7b3c 的哈希类名
  2. 导出映射对象styles 对象变成 { button: 'Button_button__a7b3c', txt: 'Button_txt__d8e9f' }
  3. 自动作用域隔离:每个组件的样式只影响自身

在控制台日志中可以看到(如代码中的 console.log('111styles:',styles)),styles 是一个 JavaScript 对象,其 key 是 CSS 类名,value 是哈希后的唯一类名。

1.4 多人协作的保障

AnotherButton.jsx 展示了这种方案的核心价值:

import styles from './AnotherButton.module.css';

export default function AnotherButton() {
    return <button className={styles.button}>another 按钮</button>
}

尽管两个组件都使用了 .button 类名,但编译后它们会变成完全不同的哈希值:

  • Button.buttonButton_button__a7b3c
  • AnotherButton.buttonAnotherButton_button__x9y8z

正如 App.jsx 中的注释所说:"多人协作的时候就会有这个 bug,我们怎么做能不影响别人,也不受别人的影响"。CSS Modules 通过编译时的静态隔离,完美解决了这个问题。

1.5 技术特点

优势:

  • 零运行时开销:样式在构建时处理,运行时只是普通的 class 应用
  • 工具链友好:与现有 CSS 语法完全兼容,支持所有 CSS 特性
  • 性能优化:自动提取唯一 CSS 文件,支持代码分割
  • 类型安全:可与 TypeScript 结合,提供类名提示

局限:

  • 动态性不足:难以根据 props 动态调整样式
  • 全局样式依赖:仍需通过 :global 处理全局样式
  • 配置依赖:需要构建工具支持(现代工具已默认支持)

第二章:Styled Components —— 动态样式的哲学

2.1 当样式需要"思考"

如果说 CSS Modules 解决了静态隔离问题,那么 styled-component-demo 项目则展示了更进一步的思考:样式能否根据组件的状态动态变化?

App.jsx 中的代码:

import styled from 'styled-components';

const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

2.2 CSS-in-JS 的革命

这里没有单独的 CSS 文件,样式直接写在 JavaScript 中,通过 styled-components 库的 styled.button 方法创建。这种范式被称为 CSS-in-JS

关键创新点:

  1. 样式即组件Button 既是 React 组件,也是样式定义
  2. 动态插值:通过 ${props => ...} 语法,样式可以响应 props 变化
  3. 自动作用域:每个 styled 组件生成唯一类名,天然隔离

2.3 动态样式的力量

两个按钮实例展示了动态能力:

  • <Button>primary 为 false,背景白色,文字蓝色
  • <Button primary>primary 为 true,背景蓝色,文字白色

同样的组件,不同的视觉表现。这在 CSS Modules 中需要额外的状态类名管理,而 styled-components 将其内建为语言特性。

2.4 运行时机制

styled-components 在运行时执行以下步骤:

  1. 解析模板字符串:提取 CSS 规则和动态插值函数
  2. 生成唯一类名:类似 CSS Modules,但发生在运行时
  3. 注入 style 标签:动态创建 <style> 标签注入页面
  4. 响应式更新:当 props 变化时,重新计算样式

查看 package.json,可以看到依赖:

"styled-components": "^6.3.12"

这是整个方案的核心库。

2.5 技术特点

优势:

  • 极致动态性:样式完全由 JavaScript 控制,可实现复杂逻辑
  • 组件封装完整:样式与组件逻辑在同一文件,便于维护
  • 主题支持:内置 Theme Provider,轻松实现主题切换
  • 自动 vendor prefix:自动添加浏览器前缀

局限:

  • 运行时开销:需要在浏览器中解析和注入样式
  • SSR 复杂度:服务端渲染需要额外配置提取样式
  • 学习曲线:需要掌握模板字符串和 styled API
  • 调试难度:生成的类名难以直接对应源码

第三章:Vue Scoped CSS —— 框架集成的优雅

2.6 Vue 的单文件组件哲学

vue-css-demo 项目展示了 Vue 框架的 CSS 模块化方案。看 App.vue

<template>
  <div>
    <h1 class="txt">Hello world in app</h1>
    <HelloWorld />
    <h2 class="txt2">222</h2>
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

关键在于 <style scoped> 属性。这是 Vue 单文件组件(SFC)的内置特性。

2.7 编译时作用域隔离

Vue 的 scoped 样式机制与 CSS Modules 类似,但更简洁:

  1. 自动添加属性选择器:编译时为每个元素添加 data-v-hash 属性
  2. 样式规则重写:将 .txt { } 转换为 .txt[data-v-a7b3c] { }
  3. 作用域限制:样式只影响当前组件的元素

例如,编译后的 HTML 可能变成:

<h1 class="txt" data-v-a7b3c>Hello world in app</h1>

样式规则变为:

.txt[data-v-a7b3c] {
  color: red;
}

2.8 组件间隔离

HelloWorld.vue 展示了子组件的独立作用域:

<template>
  <div>
    <h1 class="txt">你好,世界!</h1>
    <h2 class="txt2">222</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: pink;
}
</style>

尽管父子组件都使用了 .txt 类名,但由于 scoped 机制,它们互不影响:

  • 父组件的 .txt 只影响父组件模板
  • 子组件的 .txt 只影响子组件模板

2.9 框架级集成

Vue 方案的核心优势是框架原生支持。查看 vite.config.js

import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
})

@vitejs/plugin-vue 插件自动处理 .vue 文件的 scoped 样式,无需额外配置。

2.10 技术特点

优势:

  • 零配置:框架内置支持,开箱即用
  • 语法简洁:只需添加 scoped 属性
  • 性能优秀:编译时处理,无运行时开销
  • 局部覆盖:可通过 :deep() 选择器穿透作用域

局限:

  • 框架绑定:仅适用于 Vue 生态
  • 动态性有限:不如 styled-components 灵活
  • 穿透复杂度:深度选择器需要特殊语法

第四章:技术选型与最佳实践

4.1 三种方案的对比

维度 CSS Modules Styled Components Vue Scoped
作用域 编译时哈希 运行时生成 编译时属性
动态性 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
性能 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
学习曲线
框架依赖 React Vue
文件大小 中(需 runtime)

4.2 选型建议

选择 CSS Modules 当:

  • 项目基于 React 且需要轻量级方案
  • 样式相对静态,不需要复杂动态逻辑
  • 团队熟悉传统 CSS,希望平滑过渡
  • 对性能有极致要求

选择 Styled Components 当:

  • 需要高度动态的样式(如主题、状态驱动)
  • 追求样式与逻辑的完全统一
  • 团队 JavaScript 能力强于 CSS
  • 接受一定的运行时开销换取开发体验

选择 Vue Scoped 当:

  • 项目使用 Vue 框架
  • 希望零配置解决样式隔离
  • 需要平衡简洁性和功能性

4.3 混合策略

在实际大型项目中,常常采用混合策略:

// 基础样式用 CSS Modules
import styles from './Button.module.css';

// 动态变体用 styled-components
const VariantButton = styled(Button)`
  background: ${props => props.variant};
`;

或者在 Vue 项目中:

<style scoped>
/* 组件私有样式 */
</style>

<style>
/* 全局共享样式 */
</style>

第五章:CSS 模块化的未来趋势

5.1 CSS 原生作用域

CSS 规范正在引入原生的作用域机制:

@scope (.component) {
  .button { /* 只影响 .component 内的 .button */ }
}

这将使浏览器原生支持样式隔离,减少对构建工具的依赖。

5.2 CSS Houdini

CSS Houdini 允许开发者通过 JavaScript 扩展 CSS 引擎,为样式系统带来编程能力,可能模糊 CSS Modules 和 CSS-in-JS 的边界。

5.3 零运行时 CSS-in-JS

新一代库如 Vanilla Extract、Linaria 尝试结合两者优势:

  • 开发体验:CSS-in-JS 语法
  • 运行时性能:编译为纯 CSS 文件
// Vanilla Extract 示例
import { style } from '@vanilla-extract/css';

export const button = style({
  background: 'blue',
  color: 'white',
});

结语:没有银弹,只有权衡

回顾三个项目,我们看到 CSS 模块化没有唯一正确答案:

技术选型的本质是权衡。理解每种方案的设计哲学、技术实现和适用场景,比盲目追随潮流更重要。

正如组件化思想的核心——关注点分离,CSS 模块化的终极目标不是技术本身,而是让开发者能够专注于创造优秀的用户体验,而不必担心样式冲突的困扰。

当你在下一个项目中面对 CSS 架构选择时,希望这篇文章能为你提供清晰的思考框架。毕竟,最好的技术方案,永远是那个最适合你团队和项目的方案。


搞懂 BFC:一篇文章彻底拿下两列与三列自适应布局

前言

在前端开发中,我们经常会遇到这样的场景:左侧固定宽度做导航,右侧自适应填满剩余空间做内容区;或者左右两侧固定,中间自适应的经典三栏布局。

很多人会直接使用 Flexbox 或 Grid 来解决,这当然没问题。但如果面试官追问:“如果不使用 Flex 和 Grid,仅用传统 CSS,你如何实现?背后的原理是什么?

这时候,就需要请出 CSS 布局中的“定海神针”—— BFC(Block Formatting Context,块级格式化上下文)  了。

一、 到底什么是 BFC?

image.png 官方定义很绕,用人话来说:BFC 就像是一个封闭的、独立的“结界”。
在这个结界里面的元素怎么折腾(比如浮动、塌陷),都不会影响到外面的元素;同理,外面的元素也进不来干扰里面。

它有三个维度的核心规则:怎么创建它、它内部怎么排版、它跟外部怎么相处。

二、 怎么召唤出 BFC?(创建规则)

记住一个前提:普通的块级元素(比如最常见的 div)默认不是 BFC,必须通过特定条件触发。

以下是触发 BFC 的常见方式:

  1. 根元素<html> 标签本身就是最大的 BFC。
  2. 浮动元素:给元素加上 float: left / right(只要不是 none)。
  3. 绝对/固定定位position: absolute / fixed
  4. 行内块元素display: inline-block
  5. 表格相关display: table-cell 等(现在极少用来做布局)。
  6. 🔥 溢出非可见(最常用)overflow: hidden / auto / scroll。(注意:默认的 overflow: visible 不会触发)。
  7. 现代布局display: flex / grid / inline-flex / inline-grid(这会使其成为 Flex/Grid 容器,内部自然形成独立的 BFC 机制)。

三、 BFC 内部布局规则

当一个元素变成了 BFC,它内部就会遵守以下规矩:

  • 垂直排列:BFC 内的块级盒子只能从上到下垂直排布,绝对不可能横向并排。

  • Margin 折叠现象:在同一个 BFC 中,相邻的两个块级元素的垂直外边距会发生“折叠”。比如上面的 margin-bottom: 10px,下面的 margin-top: 20px,两者之间的真实距离是取最大值 20px,而不是相加的 30px

    • 破解之法:把这两个元素放在两个不同的 BFC 结界里,就不会折叠了。
  • 触边对齐(⚠️易错点) :BFC 内部的每个盒子的左外边缘,都会死死贴住这个 BFC 容器的左边缘。

    • 注意:这里说的是“盒子的边缘”贴住容器,但盒子里的“文字内容”是活体,看到浮动元素会主动避让,形成“文字环绕浮动元素”的效果。这也是为什么普通 div 会被浮动覆盖,而文字不会。

四、 BFC 的“对外防御机制”(外部交互规则)

这是 BFC 最核心的价值所在,它拥有两大“超能力”:

  1. 包含内部浮动(解决高度塌陷) :如果 BFC 里面有子元素浮动了,按理说父元素会失去高度(高度塌陷)。但只要父元素触发了 BFC,它就会强行计算并把所有浮动子元素包裹在内
  2. 排除外部浮动(防重叠) :如果一个 BFC 旁边有一个浮动的元素,这个 BFC 绝对不会去跟那个浮动元素重叠,它会自动收缩自己的宽度,给浮动元素让出位置。

正是这第二个超能力,造就了经典的浮动布局!

五、 实战演练:用 BFC 搞定两列与三列布局

1. 经典两列式布局(左固定,右自适应)

需求:左侧导航栏宽 200px,右侧内容区撑满剩余宽度。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>BFC实现两列布局</title>
</head>
<style>
    * {
        margin: 0;
        padding: 0;
    }
    .left {
        width: 200px;
        height: 500px;
        float: left; /* 1. 左侧脱标浮动 */
        background-color: #ccc;
    }
    .right {
        height: 500px;
        overflow: hidden; /* 2. 右侧触发 BFC */
        background-color: #333;
        color: #fff;
        /* 加点内边距让文字不贴边,更好看 */
        padding: 20px; 
        /* 因为加了padding,需要用box-sizing保证不撑大盒子 */
        box-sizing: border-box; 
    }
</style>
<body>
    <!-- 注意:HTML的class属性里不需要加 . 号 -->
    <div class="left">左侧固定区域 (200px)</div>
    
    <!-- 这里使用上面定义好的 .right 类名 -->
    <div class="right">右侧自适应区域 (无论浏览器多宽,我都会填满剩下的空间,且不会覆盖左侧)</div>
</body>
</html>

image.png

原理解析:
左侧 float 起来了,右侧本是一个普通的块级 div,按理说它会无视浮动,占满整行跑到左侧下面去。但我们给右侧加了 overflow: hidden 触发了 BFC。根据 BFC  【排除外部浮动】  的规则,右侧这个“结界”拒绝与左侧的浮动区域重叠,于是它老老实实退缩到了浮动元素的右边,剩余多少宽度它就占多少,完美实现自适应!

2. 经典三列式布局(左右固定,中间自适应)

需求:左侧 200px,右侧 200px,中间自适应。

HTML 结构:

代码生成完成

HTML代码

CSS 代码:

.left {
  width: 200px; height: 500px;
  float: left;
  background: #ccc;
}
.right {
  width: 200px; height: 500px;
  float: right;
  background: #aaa;
}
.center {
  height: 500px;
  overflow: hidden; /* 触发 BFC */
  background: #333;
  color: #fff;
}

image.png原理解析:
左右两侧分别向左向右浮动,脱离了文档流。中间的 div 加上 overflow: hidden 触发 BFC 后,它既要躲避左边的浮动,又要躲避右边的浮动。两头都被挤压,最终只能乖乖夹在中间,剩下的空间全归它所有。

总结

在 Flexbox 和 Grid 横行的今天,我们写业务可能很少再手写 float + BFC 布局了。但是,理解 BFC 绝不是为了写古董代码

当你在页面中遇到莫名的“高度塌陷”、遇到“Margin 合并不生效”、遇到“浮动元素覆盖了后续内容”时,如果你脑子里能瞬间闪过 BFC 的那几条规则,你能在一分钟内定位并解决问题。

BFC 不是过时的技术,而是透视 CSS 底层渲染逻辑的一把钥匙。  掌握了它,你的 CSS 功力才算真正跨入了进阶的大门。

泛型:像“填空”一样写类型,让你的代码从“复制粘贴”中解放

你是不是遇到过这种场景:写了一个函数,处理数字的版本写一遍,处理字符串的版本再写一遍,处理数组的又写一遍……最后代码里全是长得差不多的“双胞胎”。今天我们来学TypeScript的泛型——一个能让你写一次、处处用的“类型模板”。从此告别复制粘贴,做个体面的程序员。

前言

想象一下,你开了一家“万能快递公司”。客户要寄书,你准备书盒;要寄衣服,你准备衣服盒;要寄手机,你准备手机盒……每种物品都要单独设计盒子,累不累?

更好的做法:设计一种可调节大小的盒子,客户说寄什么,你就把盒子调成对应大小。这个“可调节的盒子”,就是泛型

TypeScript的泛型让你在定义函数、类、接口时,先“留个空”,等用的时候再往里填具体类型。这样既保证了类型安全,又避免了重复代码。

一、泛型长啥样?一个简单的例子

先看一个没有泛型的“悲惨世界”:

// 只能处理数字
function identityNumber(arg: number): number {
  return arg;
}
// 只能处理字符串
function identityString(arg: string): string {
  return arg;
}
// 要处理布尔值?再写一个……

用泛型,只需要一个:

function identity<T>(arg: T): T {
  return arg;
}

这里的<T>就像个“占位符”,你调用时可以指定具体类型:

let output1 = identity<string>('hello'); // 类型是 string
let output2 = identity<number>(123);     // 类型是 number

但TS很聪明,能自动推断,所以通常可以省略:

let output = identity('hello'); // TS推断出T为string

二、泛型不只是“传进去又返回来”

你可以约束参数的类型关系。比如,你想让函数接收一个数组,并返回数组的第一个元素:

function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const firstNumber = getFirst([1, 2, 3]);   // 类型 number
const firstString = getFirst(['a', 'b']); // 类型 string

T帮我们保持了“数组元素类型”和“返回值类型”的一致性。

三、泛型约束:给“占位符”画个圈

有时候,你不能让T为所欲为。比如你想写一个函数,打印参数的length属性:

function logLength<T>(arg: T): T {
  console.log(arg.length); // ❌ 报错:T可能没有length
  return arg;
}

因为T可能是numberboolean,它们没有.length。这时候需要约束——告诉TS:T必须是有length属性的类型。

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(arg: T): T {
  console.log(arg.length); // ✅ 安全
  return arg;
}

logLength('hello');    // 字符串有length
logLength([1, 2, 3]);  // 数组有length
logLength(123);        // ❌ 数字没有length,报错

extends关键字在这里不是继承,而是“约束为某个类型的子集”。

四、泛型接口:把接口变成“模具”

接口也可以泛型化,比如定义一个通用的响应结构:

interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

// 使用
type User = { name: string; age: number };
const response: ApiResponse<User> = {
  code: 200,
  message: 'success',
  data: { name: '张三', age: 18 }
};

这样,你就能用一个接口描述所有API返回格式,只需替换T

五、泛型类:像造模具一样造类

类同样可以泛型:

class Queue<T> {
  private data: T[] = [];
  push(item: T) {
    this.data.push(item);
  }
  pop(): T | undefined {
    return this.data.shift();
  }
}

const numberQueue = new Queue<number>();
numberQueue.push(123);
numberQueue.push('456'); // ❌ 报错,只能放数字

六、泛型工具类型:TS内置的“变形金刚”

TS提供了一些内置的泛型工具,能帮你快速转换类型。

1. Partial<T>:把属性都变成可选

interface User {
  name: string;
  age: number;
}
type PartialUser = Partial<User>; // { name?: string; age?: number; }

2. Readonly<T>:把所有属性变成只读

type ReadonlyUser = Readonly<User>; // { readonly name: string; readonly age: number; }

3. Pick<T, K>:从T中挑选部分属性

type UserName = Pick<User, 'name'>; // { name: string; }

4. Omit<T, K>:从T中排除部分属性

type UserWithoutAge = Omit<User, 'age'>; // { name: string; }

还有Record<K, T>ExcludeExtract等,遇到具体场景时再查文档即可。

七、联合类型与交叉类型:不是泛型,但常一起用

联合类型(|):这个或那个

let value: string | number;
value = 'hello'; // OK
value = 123;     // OK
value = true;    // ❌

联合类型适合“不确定具体是哪个,但知道是有限的几种”。

交叉类型(&):既要又要

interface Name { name: string; }
interface Age { age: number; }
type Person = Name & Age; // 同时有name和age属性

const p: Person = { name: '张三', age: 18 };

交叉类型常用来合并多个类型。

八、类型保护:让TS相信你

当你使用联合类型时,TS会限制你只能调用所有类型共有的方法。要调用特定类型的方法,需要类型保护

function printLength(value: string | number) {
  // console.log(value.length); // ❌ 报错,number没有length
  if (typeof value === 'string') {
    console.log(value.length); // ✅ 这里TS知道value是string
  } else {
    console.log(value.toFixed(2));
  }
}

除了typeof,还有instanceofin关键字、自定义类型守卫。

九、实战:用泛型写一个“万能”的缓存函数

interface Cache<T> {
  get(key: string): T | undefined;
  set(key: string, value: T): void;
}

function createCache<T>(): Cache<T> {
  const store: Record<string, T> = {};
  return {
    get(key) { return store[key]; },
    set(key, value) { store[key] = value; }
  };
}

const stringCache = createCache<string>();
stringCache.set('name', '张三');
const name = stringCache.get('name'); // 类型是 string | undefined

const numberCache = createCache<number>();
numberCache.set('age', 18);

看,一套代码同时服务了字符串缓存和数字缓存,类型还完全安全。

十、总结:泛型是“类型编程”的起点

  • 泛型就是“类型的参数”,让组件(函数、类、接口)能适应多种类型,同时保留类型关系。
  • 约束extends限定泛型的范围。
  • 泛型接口/类让数据结构通用。
  • 内置工具类型(Partial、Pick等)简化常见类型转换。
  • 联合类型表示“或”,交叉类型表示“且”,类型保护用来区分联合中的具体类型。

掌握泛型,你就能写出更抽象、更复用、更安全的代码。明天我们将继续TypeScript的高级主题——装饰器,看看这个类似Java注解的特性,如何在TS里玩出花样。

如果你觉得今天的“万能模具”讲得通透,点个赞让更多人看到。明天我们聊聊装饰器——那个在Angular和NestJS里无处不在的黑魔法。我们明天见!

前端开发效率翻倍:15个超级实用的工具函数,直接复制进项目(建议收藏)

大家好,今天给大家整理了一套前端日常开发高频用到的工具函数

没有复杂算法,也没有花里胡哨的封装,全是业务里真正常用的:时间格式化、手机号脱敏、防抖节流、深拷贝、URL参数解析……全部即插即用,复制到项目里就能跑,非常适合放进自己的 utils 工具库。


1. 时间格式化(最常用)

把时间戳 / Date 对象转成 YYYY-MM-DD HH:mm:ss

function formatDate(date, fmt = 'YYYY-MM-DD HH:mm:ss') {
  if (!date) return ''
  date = date instanceof Date ? date : new Date(date)

  const o = {
    'M+': date.getMonth() + 1,
    'D+': date.getDate(),
    'H+': date.getHours(),
    'm+': date.getMinutes(),
    's+': date.getSeconds()
  }

  if (/(Y+)/.test(fmt)) {
    fmt = fmt.replace(RegExp.$1, date.getFullYear().toString().slice(4 - RegExp.$1.length))
  }

  for (const k in o) {
    if (new RegExp(`(${k})`).test(fmt)) {
      fmt = fmt.replace(
        RegExp.$1,
        RegExp.$1.length === 1 ? o[k] : o[k].toString().padStart(2, '0')
      )
    }
  }
  return fmt
}

// 使用
formatDate(new Date()) // 2026-04-09 15:30:20

2. 防抖(输入搜索专用)

function debounce(fn, delay = 300) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

3. 节流(滚动/点击防重复)

function throttle(fn, interval = 500) {
  let last = 0
  return function (...args) {
    const now = Date.now()
    if (now - last >= interval) {
      last = now
      fn.apply(this, args)
    }
  }
}

4. 深拷贝(处理对象/数组)

function deepClone(obj, hash = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj
  if (hash.has(obj)) return hash.get(obj)

  const clone = Array.isArray(obj) ? [] : {}
  hash.set(obj, clone)

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key], hash)
    }
  }
  return clone
}

5. 获取 URL 参数

function getQueryParams(url = location.href) {
  const params = {}
  new URL(url).searchParams.forEach((v, k) => (params[k] = v))
  return params
}

// 使用
getQueryParams('https://xxx.com?id=1&name=test') // { id: '1', name: 'test' }

6. 手机号脱敏

function maskPhone(phone) {
  if (!phone || phone.length !== 11) return phone
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}

// 13812345678 → 138****5678

7. 姓名脱敏

function maskName(name) {
  if (!name) return ''
  if (name.length === 1) return name
  return name[0] + '*'.repeat(name.length - 1)
}

// 张三 → 张*
// 张三丰 → 张**

8. 数字千分位格式化

function formatMoney(num) {
  if (isNaN(num)) return '0'
  return Number(num).toLocaleString()
}

// 1234567 → 1,234,567

9. 存储操作(localStorage 封装)

const storage = {
  set(key, val) {
    localStorage.setItem(key, JSON.stringify(val))
  },
  get(key) {
    const val = localStorage.getItem(key)
    if (!val) return null
    try {
      return JSON.parse(val)
    } catch {
      return val
    }
  },
  remove(key) {
    localStorage.removeItem(key)
  },
  clear() {
    localStorage.clear()
  }
}

10. 判断数据类型

function getType(val) {
  return Object.prototype.toString.call(val).slice(8, -1).toLowerCase()
}

// getType([]) → 'array'
// getType({}) → 'object'
// getType(null) → 'null'

11. 数组去重

function uniqueArr(arr) {
  return [...new Set(arr)]
}

12. 数组扁平化

function flatten(arr) {
  return arr.flat(Infinity)
}

13. 生成随机字符串(ID)

function randomStr(len = 8) {
  const str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
  let res = ''
  for (let i = 0; i < len; i++) {
    res += str[Math.floor(Math.random() * str.length)]
  }
  return res
}

14. 防抖立即执行版(提交按钮专用)

function debounceImmediate(fn, delay = 500) {
  let timer = null
  return function (...args) {
    const first = !timer
    clearTimeout(timer)
    timer = setTimeout(() => (timer = null), delay)
    if (first) fn.apply(this, args)
  }
}

15. 滚动到顶部(平滑)

function scrollToTop() {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}

最后

这 15 个工具函数基本覆盖了80% 前端业务场景,建议直接新建一个 utils.js 全部存起来,以后开发至少快一倍。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端面试通关指南:30个高频手写JS算法,吃透就能拿高薪(附完整代码)

聊前端面试,算法永远是绕不开的坎。很多小伙伴项目经验很丰富,框架用得溜,但一上面试场就被手写算法题卡住,直接导致面试失利。

我整理了一份30个高频手写JS算法清单,覆盖ES6语法、数组操作、链表、二叉树、动态规划等核心考点,从简单到进阶,每道题都附完整可复制代码+考点解析,跟着敲一遍,面试时直接手到擒来!

一、基础语法与数组变换(必拿分,入门级)

这类题考察基础语法功底,难度低、频率高,面试时先搞定这类题,稳拿基础分,给面试官留好第一印象。

1. 深度克隆(Deep Clone)

考察点:引用类型传址、循环引用、基本类型与引用类型区别

function deepClone(obj, map = new WeakMap()) {
  // 基本类型直接返回(null也是基本类型范畴)
  if (obj === null || typeof obj !== 'object') return obj;
  // 处理循环引用(避免无限递归)
  if (map.has(obj)) return map.get(obj);
  
  // 区分数组和对象,创建对应克隆容器
  const clone = Array.isArray(obj) ? [] : {};
  map.set(obj, clone); // 存入map,标记已克隆
  
  // 遍历对象/数组,递归克隆每一项
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) { // 只克隆自身属性,不克隆原型链属性
      clone[key] = deepClone(obj[key], map);
    }
  }
  return clone;
}

// 测试
const obj = { a: 1, b: { c: 2 }, d: [3, 4] };
const cloneObj = deepClone(obj);
obj.b.c = 100;
console.log(cloneObj.b.c); // 2(克隆后互不影响)

2. 数组扁平化(Flatten)

考察点:递归、数组方法(forEach、concat)、ES6新特性

// 解法1:递归(兼容性好,易理解)
function flatten(arr) {
  let result = [];
  arr.forEach(item => {
    // 若当前项是数组,递归扁平化,否则直接加入结果
    result = result.concat(Array.isArray(item) ? flatten(item) : item);
  });
  return result;
}

// 解法2:ES6 flat方法(简洁,实际开发常用)
const flatten = arr => arr.flat(Infinity); // Infinity表示无限层级扁平化

// 解法3:reduce实现(更简洁,面试加分)
const flatten = arr => arr.reduce((prev, curr) => {
  return prev.concat(Array.isArray(curr) ? flatten(curr) : curr);
}, []);

// 测试
console.log(flatten([1, [2, [3, 4], 5]])); // [1,2,3,4,5]

3. 防抖(Debounce)

考察点:高频事件控制、定时器、this指向

// 核心:频繁触发时,只在最后一次触发后延迟执行
function debounce(fn, delay = 500) {
  let timer = null; // 定时器标识,闭包保存
  return function(...args) {
    clearTimeout(timer); // 每次触发,清除上一次定时器
    // 重新设置定时器,延迟执行目标函数
    timer = setTimeout(() => {
      fn.apply(this, args); // 绑定this和参数,适配实际场景
    }, delay);
  };
}

// 用法(搜索框输入示例)
const handleSearch = debounce((val) => {
  console.log('请求搜索接口:', val);
}, 500);

4. 节流(Throttle)

考察点:高频事件控制、时间戳/定时器、性能优化

// 解法1:时间戳版(触发时立即执行,之后固定时间内不执行)
function throttle(fn, interval = 1000) {
  let lastTime = 0; // 上一次执行时间
  return function(...args) {
    const nowTime = Date.now(); // 当前时间
    // 若当前时间 - 上一次执行时间 > 间隔,执行函数
    if (nowTime - lastTime >= interval) {
      fn.apply(this, args);
      lastTime = nowTime; // 更新上一次执行时间
    }
  };
}

// 解法2:定时器版(触发后延迟执行,固定时间内只执行一次)
function throttle2(fn, interval = 1000) {
  let timer = null;
  return function(...args) {
    if (!timer) { // 若定时器不存在,执行函数
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null; // 执行后清空定时器,允许下次执行
      }, interval);
    }
  };
}

// 用法(滚动加载示例)
window.addEventListener('scroll', throttle(() => {
  console.log('滚动加载更多');
}, 1000));

5. 数组去重(Unique)

考察点:数组方法、Set数据结构、兼容性

// 解法1:Set实现(最简洁,ES6+常用)
const unique = arr => [...new Set(arr)];

// 解法2:indexOf实现(兼容性好,适合旧项目)
function unique(arr) {
  const result = [];
  arr.forEach(item => {
    // 若结果数组中没有当前项,加入结果
    if (result.indexOf(item) === -1) {
      result.push(item);
    }
  });
  return result;
}

// 解法3:filter+indexOf(简洁,面试常用)
const unique = arr => arr.filter((item, index) => {
  // 只保留第一次出现的元素(indexOf返回第一个匹配的索引)
  return arr.indexOf(item) === index;
});

// 测试
console.log(unique([1, 2, 2, 3, 3, 3])); // [1,2,3]

6. 数组排序(冒泡排序)

考察点:排序原理、循环逻辑、基础算法思维

// 冒泡排序:相邻元素对比,大的往后移,每次循环确定一个最大值
function bubbleSort(arr) {
  const len = arr.length;
  // 外层循环:控制排序轮次(共len-1轮)
  for (let i = 0; i < len - 1; i++) {
    let flag = false; // 优化:标记是否发生交换,若无则排序完成
    // 内层循环:对比相邻元素,交换位置
    for (let j = 0; j < len - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        // 交换两个元素
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        flag = true;
      }
    }
    if (!flag) break; // 无交换,直接退出循环
  }
  return arr;
}

// 测试
console.log(bubbleSort([3, 1, 4, 1, 5, 9])); // [1,1,3,4,5,9]

7. 数组排序(快速排序)

考察点:分治思想、递归、时间复杂度优化(面试高频)

// 快速排序:分治思想,选一个基准值,将数组分成两部分,递归排序
function quickSort(arr) {
  // 终止条件:数组长度<=1,直接返回
  if (arr.length <= 1) return arr;
  // 选基准值(中间项,避免极端情况)
  const pivot = arr[Math.floor(arr.length / 2)];
  // 分治:小于基准值的放左边,等于的放中间,大于的放右边
  const left = arr.filter(x => x < pivot);
  const middle = arr.filter(x => x === pivot);
  const right = arr.filter(x => x > pivot);
  // 递归排序左右两部分,拼接结果
  return [...quickSort(left), ...middle, ...quickSort(right)];
}

// 测试
console.log(quickSort([3, 1, 4, 1, 5, 9])); // [1,1,3,4,5,9]

8. 实现数组forEach方法

考察点:数组方法原理、this绑定、回调函数

// 模拟数组forEach,接收回调函数和this指向
Array.prototype.myForEach = function(callback, thisArg) {
  // 边界判断:回调必须是函数
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  // 遍历当前数组(this指向调用myForEach的数组)
  for (let i = 0; i < this.length; i++) {
    // 执行回调,传入三个参数:当前项、索引、原数组,绑定thisArg
    callback.call(thisArg, this[i], i, this);
  }
};

// 用法
[1, 2, 3].myForEach((item, index) => {
  console.log(item, index); // 1 0 | 2 1 | 3 2
});

9. 实现数组map方法

考察点:数组方法原理、返回值处理、回调函数

// 模拟数组map,返回新数组,新数组元素是回调函数的返回值
Array.prototype.myMap = function(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  const result = []; // 存储结果的新数组
  for (let i = 0; i < this.length; i++) {
    // 执行回调,将返回值加入结果数组
    result.push(callback.call(thisArg, this[i], i, this));
  }
  return result;
};

// 用法
const newArr = [1, 2, 3].myMap(item => item * 2);
console.log(newArr); // [2,4,6]

10. 实现数组filter方法

考察点:数组方法原理、条件判断、返回值处理

// 模拟数组filter,返回满足条件的元素组成的新数组
Array.prototype.myFilter = function(callback, thisArg) {
  if (typeof callback !== 'function') {
    throw new TypeError('callback must be a function');
  }
  const result = [];
  for (let i = 0; i < this.length; i++) {
    // 回调返回true,将当前项加入结果数组
    if (callback.call(thisArg, this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

// 用法
const evenArr = [1, 2, 3, 4].myFilter(item => item % 2 === 0);
console.log(evenArr); // [2,4]

二、原型与作用域(进阶必问,中层前端考点)

这类题考察对JS底层原理的理解,是区分初级和中级前端的关键,面试时高频出现,必须吃透。

11. 实现new关键字

考察点:原型链、构造函数、this绑定、返回值判断

// myNew:模拟new关键字的作用
function myNew(fn, ...args) {
  // 1. 创建一个空对象,让其原型指向构造函数的prototype
  const obj = Object.create(fn.prototype);
  // 2. 执行构造函数,将this绑定到新创建的对象上
  const result = fn.apply(obj, args);
  // 3. 判断构造函数的返回值:若为对象(非null),则返回该对象;否则返回新创建的obj
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const person = myNew(Person, '张三', 25);
console.log(person.name); // 张三
console.log(person instanceof Person); // true

12. 手写Promise(简易版,支持then链式调用)

考察点:异步编程、状态机、回调队列、链式调用

class MyPromise {
  constructor(exector) {
    // 初始化状态:pending(等待)、fulfilled(成功)、rejected(失败)
    this.status = 'pending';
    this.value = null; // 成功时的返回值
    this.reason = null; // 失败时的原因
    // 存储成功/失败的回调队列(支持多个then绑定)
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    // 成功回调:改变状态,保存值,执行所有成功回调
    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        // 执行所有缓存的成功回调
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    // 失败回调:改变状态,保存原因,执行所有失败回调
    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        // 执行所有缓存的失败回调
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    // 执行 executor,捕获异常,异常时调用reject
    try {
      exector(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  // then方法:支持链式调用,返回新的Promise
  then(onFulfilled, onRejected) {
    // 兼容:若then未传回调,默认透传值/原因
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; };

    // 返回新Promise,实现链式调用
    return new MyPromise((resolve, reject) => {
      // 状态为成功时,执行成功回调
      if (this.status === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          // 回调返回值,传递给下一个then的成功回调
          resolve(result);
        } catch (err) {
          // 回调执行失败,传递给下一个then的失败回调
          reject(err);
        }
      }

      // 状态为失败时,执行失败回调
      if (this.status === 'rejected') {
        try {
          const result = onRejected(this.reason);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      }

      // 状态为等待时,缓存回调
      if (this.status === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          try {
            const result = onFulfilled(this.value);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        });
        this.onRejectedCallbacks.push(() => {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        });
      }
    });
  }
}

// 测试
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('成功');
  }, 1000);
}).then(res => {
  console.log(res); // 成功
  return '下一个then';
}).then(res => {
  console.log(res); // 下一个then
});

13. 实现Promise.all方法

考察点:Promise并发控制、数组遍历、状态判断

// Promise.all:接收一个Promise数组,所有Promise成功才成功,有一个失败则失败
Promise.myAll = function(promises) {
  return new Promise((resolve, reject) => {
    // 边界判断:若传入的不是数组,直接reject
    if (!Array.isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }

    const result = []; // 存储所有Promise的成功结果
    let count = 0; // 记录已完成的Promise数量

    // 若数组为空,直接resolve空数组
    if (promises.length === 0) return resolve(result);

    // 遍历每个Promise
    promises.forEach((promise, index) => {
      // 兼容非Promise值(直接视为成功)
      Promise.resolve(promise).then(res => {
        result[index] = res; // 按原顺序存储结果
        count++;
        // 所有Promise都完成,resolve结果数组
        if (count === promises.length) {
          resolve(result);
        }
      }).catch(err => {
        // 有一个失败,直接reject该错误
        reject(err);
      });
    });
  });
};

// 测试
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
Promise.myAll([p1, p2, p3]).then(res => {
  console.log(res); // [1,2,3]
});

14. 实现Promise.race方法

考察点:Promise并发控制、第一个完成的状态优先

// Promise.race:接收一个Promise数组,第一个完成(成功/失败)的结果作为最终结果
Promise.myRace = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('arguments must be an array'));
    }

    // 遍历每个Promise,第一个完成的直接改变状态
    promises.forEach(promise => {
      Promise.resolve(promise).then(res => {
        resolve(res); // 第一个成功,直接resolve
      }).catch(err => {
        reject(err); // 第一个失败,直接reject
      });
    });
  });
};

// 测试
const p1 = new Promise((resolve) => setTimeout(() => resolve(1), 1000));
const p2 = new Promise((resolve, reject) => setTimeout(() => reject('失败'), 500));
Promise.myRace([p1, p2]).catch(err => {
  console.log(err); // 失败(p2先完成,且失败)
});

15. 函数柯里化(Currying)

考察点:闭包、参数复用、函数式编程

// 柯里化:将多参数函数,转化为单参数函数的链式调用
function curry(fn) {
  // 闭包保存已传入的参数
  return function curried(...args) {
    // 若传入的参数数量 >= 原函数的参数数量,执行原函数
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      // 否则,返回一个新函数,接收剩余参数,递归调用curried
      return function(...args2) {
        return curried.apply(this, [...args, ...args2]);
      };
    }
  };
}

// 用法
const add = (a, b, c) => a + b + c; // 原函数,3个参数
const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3)); // 6(链式调用)
console.log(curriedAdd(1, 2)(3)); // 6(支持部分参数)
console.log(curriedAdd(1, 2, 3)); // 6(支持完整参数)

16. 实现call方法

考察点:this绑定、函数执行、参数传递

// 模拟Function.prototype.call:改变函数this指向,立即执行函数
Function.prototype.myCall = function(context, ...args) {
  // 边界判断:若context为null/undefined,指向window(浏览器环境)
  context = context || window;
  // 给context添加一个临时属性,指向当前函数(this就是调用myCall的函数)
  const fnKey = Symbol('tempFn'); // 用Symbol避免属性冲突
  context[fnKey] = this;
  // 执行函数,传入参数,获取返回值
  const result = context[fnKey](...args);
  // 删除临时属性,避免污染context
  delete context[fnKey];
  // 返回函数执行结果
  return result;
};

// 测试
const obj = { name: '张三' };
function sayHello(age) {
  console.log(`我是${this.name},年龄${age}`);
}
sayHello.myCall(obj, 25); // 我是张三,年龄25

17. 实现apply方法

考察点:this绑定、函数执行、参数传递(与call的区别:参数是数组)

// 模拟Function.prototype.apply:改变this指向,参数以数组形式传递,立即执行
Function.prototype.myApply = function(context, args = []) {
  context = context || window;
  const fnKey = Symbol('tempFn');
  context[fnKey] = this;
  // 执行函数,args是数组,用扩展运算符展开
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

// 测试
const obj = { name: '李四' };
function sayHello(age, gender) {
  console.log(`我是${this.name},年龄${age},性别${gender}`);
}
sayHello.myApply(obj, [28, '男']); // 我是李四,年龄28,性别男

18. 实现bind方法

考察点:this绑定、闭包、函数柯里化、构造函数兼容

// 模拟Function.prototype.bind:改变this指向,返回一个新函数,不立即执行
Function.prototype.myBind = function(context, ...args1) {
  const fn = this; // 保存当前函数(this就是调用myBind的函数)
  // 返回新函数
  const boundFn = function(...args2) {
    // 兼容构造函数:若新函数被new调用,this指向实例,否则指向context
    const isNew = this instanceof boundFn;
    const targetContext = isNew ? this : context;
    // 合并参数,执行原函数
    return fn.apply(targetContext, [...args1, ...args2]);
  };
  // 继承原函数的原型,确保new调用时,实例能访问原函数原型上的属性
  boundFn.prototype = Object.create(fn.prototype);
  return boundFn;
};

// 测试1:普通调用
const obj = { name: '王五' };
function sayHello(age) {
  console.log(`我是${this.name},年龄${age}`);
}
const boundSay = sayHello.myBind(obj, 30);
boundSay(); // 我是王五,年龄30

// 测试2:new调用(构造函数兼容)
function Person(name, age) {
  this.name = name;
  this.age = age;
}
const BoundPerson = Person.myBind(null, '赵六');
const person = new BoundPerson(35);
console.log(person.name); // 赵六
console.log(person.age); // 35

19. 实现防抖+立即执行版

考察点:防抖原理、立即执行逻辑、定时器控制

// 立即执行版防抖:第一次触发立即执行,之后频繁触发不执行,延迟后可再次触发
function debounceImmediate(fn, delay = 500) {
  let timer = null;
  return function(...args) {
    // 若定时器存在,清除定时器(取消延迟执行)
    if (timer) clearTimeout(timer);
    // 判断是否是第一次触发(timer为null)
    const isImmediate = !timer;
    if (isImmediate) {
      fn.apply(this, args); // 立即执行
    }
    // 重置定时器,延迟后清空timer,允许下次立即执行
    timer = setTimeout(() => {
      timer = null;
    }, delay);
  };
}

// 用法(按钮提交示例,避免重复提交,第一次点击立即执行)
const handleSubmit = debounceImmediate(() => {
  console.log('提交表单');
}, 1000);

20. 实现节流+立即执行/延迟执行可选版

考察点:节流原理、参数配置、灵活性优化

// 可选版节流:可配置立即执行(leading)和延迟执行(trailing)
function throttleOpt(fn, interval = 1000, options = { leading: true, trailing: false }) {
  const { leading, trailing } = options;
  let lastTime = 0;
  let timer = null;

  return function(...args) {
    const nowTime = Date.now();
    // 若不允许立即执行,且是第一次触发,重置lastTime
    if (!leading && !lastTime) {
      lastTime = nowTime;
    }

    // 计算剩余时间
    const remainingTime = interval - (nowTime - lastTime);
    // 剩余时间<=0,执行函数
    if (remainingTime <= 0) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(this, args);
      lastTime = nowTime;
    } else if (trailing && !timer) {
      // 允许延迟执行,且无定时器,设置延迟执行
      timer = setTimeout(() => {
        timer = null;
        lastTime = Date.now();
        fn.apply(this, args);
      }, remainingTime);
    }
  };
}

// 用法
// 立即执行,不延迟执行(默认)
const throttle1 = throttleOpt(() => console.log('立即执行'), 1000);
// 不立即执行,延迟执行
const throttle2 = throttleOpt(() => console.log('延迟执行'), 1000, { leading: false, trailing: true });

三、数据结构与算法(大厂高频,高级前端考点)

这类题考察数据结构基础和算法思维,是大厂面试的重点,也是拉开薪资差距的关键,建议重点练习。

21. 两数之和(Two Sum)

考察点:哈希表、时间复杂度优化(从O(n²)优化到O(n))

// 题目:给定一个整数数组和一个目标值,找出数组中和为目标值的两个整数的索引
function twoSum(nums, target) {
  const map = new Map(); // 用Map存储{数值: 索引},快速查找
  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i]; // 互补值
    // 若互补值存在于Map中,返回两个索引
    if (map.has(complement)) {
      return [map.get(complement), i];
    }
    // 否则,将当前数值和索引存入Map
    map.set(nums[i], i);
  }
  return []; // 无匹配项,返回空数组
}

// 测试
console.log(twoSum([2, 7, 11, 15], 9)); // [0,1]

22. LRU缓存机制

考察点:哈希表+双向链表、缓存淘汰策略(Vue3响应式缓存底层类似)

// LRU:最近最少使用,超出容量时,删除最久未使用的元素
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity; // 缓存容量
    this.cache = new Map(); // Map特性:插入顺序就是访问顺序,可快速获取最久未使用的元素
  }

  // 获取元素:访问后,将元素移到最近使用的位置
  get(key) {
    if (!this.cache.has(key)) return -1; // 无该元素,返回-1
    const value = this.cache.get(key);
    this.cache.delete(key); // 删除旧位置
    this.cache.set(key, value); // 重新插入,移到末尾(最近使用)
    return value;
  }

  // 存入元素:超出容量时,删除最久未使用的元素(Map的第一个键)
  put(key, value) {
    // 若元素已存在,先删除(避免覆盖后,位置不变)
    if (this.cache.has(key)) {
      this.cache.delete(key);
    }
    // 超出容量,删除最久未使用的元素
    if (this.cache.size >= this.capacity) {
      const oldestKey = this.cache.keys().next().value; // Map的第一个键是最久未使用的
      this.cache.delete(oldestKey);
    }
    // 存入新元素,移到最近使用的位置
    this.cache.set(key, value);
  }
}

// 测试
const lru = new LRUCache(2);
lru.put(1, 1);
lru.put(2, 2);
console.log(lru.get(1)); // 1(访问后,1变为最近使用)
lru.put(3, 3); // 超出容量,删除最久未使用的2
console.log(lru.get(2)); // -1(已被删除)

23. 发布-订阅模式(EventBus)

考察点:设计模式、组件通信、回调函数管理(Vue EventBus底层原理)

// 发布-订阅模式:实现组件间通信,解耦
class EventEmitter {
  constructor() {
    this.events = new Map(); // 存储事件:{事件名: [回调函数数组]}
  }

  // 订阅事件:绑定回调函数
  on(event, callback) {
    if (!this.events.has(event)) {
      this.events.set(event, []); // 若事件不存在,初始化回调数组
    }
    this.events.get(event).push(callback); // 加入回调数组
  }

  // 发布事件:触发该事件的所有回调函数
  emit(event, ...args) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      // 执行所有回调,传入参数
      callbacks.forEach(cb => cb(...args));
    }
  }

  // 取消订阅:移除指定事件的指定回调
  off(event, callback) {
    const callbacks = this.events.get(event);
    if (callbacks) {
      // 过滤掉要取消的回调,保留其他回调
      this.events.set(event, callbacks.filter(cb => cb !== callback));
      // 若回调数组为空,删除该事件
      if (this.events.get(event).length === 0) {
        this.events.delete(event);
      }
    }
  }

  // 一次性订阅:触发一次后,自动取消订阅
  once(event, callback) {
    // 包装回调,执行后取消订阅
    const wrapCallback = (...args) => {
      callback(...args);
      this.off(event, wrapCallback);
    };
    this.on(event, wrapCallback);
  }
}

// 测试
const bus = new EventEmitter();
const callback = (msg) => console.log('收到消息:', msg);

bus.on('message', callback);
bus.emit('message', 'Hello World'); // 收到消息:Hello World

bus.off('message', callback);
bus.emit('message', 'Hello Again'); // 无输出(已取消订阅)

bus.once('onceEvent', () => console.log('一次性事件'));
bus.emit('onceEvent'); // 一次性事件
bus.emit('onceEvent'); // 无输出(已自动取消)

24. 实现链表反转(单链表)

考察点:链表数据结构、指针操作、递归/迭代思维

// 1. 定义单链表节点
class ListNode {
  constructor(val = 0, next = null) {
    this.val = val;
    this.next = next;
  }
}

// 解法1:迭代法(推荐,空间复杂度O(1))
function reverseList(head) {
  let prev = null; // 前驱节点
  let curr = head; // 当前节点
  while (curr !== null) {
    const next = curr.next; // 保存下一个节点
    curr.next = prev; // 反转当前节点的指针
    prev = curr; // 前驱节点后移
    curr = next; // 当前节点后移
  }
  return prev; // 反转后,prev是新的头节点
}

// 解法2:递归法(易理解,空间复杂度O(n))
function reverseListRecursive(head) {
  // 终止条件:空节点或只有一个节点,直接返回
  if (head === null || head.next === null) return head;
  // 递归反转后续节点
  const newHead = reverseListRecursive(head.next);
  // 反转当前节点和下一个节点的指针
  head.next.next = head;
  head.next = null; // 避免循环
  return newHead;
}

// 测试
const head = new ListNode(1, new ListNode(2, new ListNode(3)));
const reversedHead = reverseList(head);
// 遍历反转后的链表:3 -> 2 -> 1
let curr = reversedHead;
while (curr) {
  console.log(curr.val); // 3 2 1
  curr = curr.next;
}

25. 判断回文链表

考察点:链表操作、双指针、回文判断

// 题目:判断一个单链表是否是回文(正读和反读一样)
// 步骤:1. 找到链表中点;2. 反转后半部分;3. 对比前半部分和反转后的后半部分
function isPalindrome(head) {
  if (head === null || head.next === null) return true; // 空链表或单个节点,是回文

  // 1. 找到链表中点(慢指针走1步,快指针走2步,快指针到末尾时,慢指针到中点)
  let slow = head;
  let fast = head;
  while (fast.next !== null && fast.next.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
  }

  // 2. 反转后半部分链表(从slow.next开始)
  let prev = null;
  let curr = slow.next;
  while (curr !== null) {
    const next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
  }
  slow.next = prev; // 反转后的后半部分链表,头节点是prev

  // 3. 对比前半部分和反转后的后半部分
  let left = head;
  let right = prev;
  while (right !== null) {
    if (left.val !== right.val) return false; // 不相等,不是回文
    left = left.next;
    right = right.next;
  }
  return true; // 全部相等,是回文
}

// 测试
const head1 = new ListNode(1, new ListNode(2, new ListNode(1)));
console.log(isPalindrome(head1)); // true

const head2 = new ListNode(1, new ListNode(2, new ListNode(3)));
console.log(isPalindrome(head2)); // false

26. 二叉树的前序遍历(递归+迭代)

考察点:二叉树数据结构、遍历算法、递归/迭代思维

// 1. 定义二叉树节点
class TreeNode {
  constructor(val = 0, left = null, right = null) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}

// 解法1:递归法(简洁,易理解)
function preorderTraversalRecursive(root, result = []) {
  if (root === null) return result;
  result.push(root.val); // 根节点
  preorderTraversalRecursive(root.left, result); // 左子树
  preorderTraversalRecursive(root.right, result); // 右子树
  return result;
}

// 解法2:迭代法(面试常考,避免递归栈溢出)
function preorderTraversalIterative(root) {
  const result = [];
  if (root === null) return result;
  const stack = [root]; // 用栈存储节点
  while (stack.length > 0) {
    const node = stack.pop(); // 弹出栈顶节点(根节点)
    result.push(node.val);
    // 注意:栈是先进后出,所以先压右子树,再压左子树
    if (node.right !== null) stack.push(node.right);
    if (node.left !== null) stack.push(node.left);
  }
  return result;
}

// 测试
const root = new TreeNode(1, null, new TreeNode(2, new TreeNode(3)));
console.log(preorderTraversalRecursive(root)); // [1,2,3]
console.log(preorderTraversalIterative(root)); // [1,2,3]

27. 二叉树的中序遍历(递归+迭代)

考察点:二叉树遍历、栈的应用

// 解法1:递归法
function inorderTraversalRecursive(root, result = []) {
  if (root === null) return result;
  inorderTraversalRecursive(root.left, result); // 左子树
  result.push(root.val); // 根节点
  inorderTraversalRecursive(root.right, result); // 右子树
  return result;
}

// 解法2:迭代法
function inorderTraversalIterative(root) {
  const result = [];
  const stack = [];
  let curr = root;
  while (curr !== null || stack.length > 0) {
    // 先遍历左子树,所有左节点入栈
    while (curr !== null) {
      stack.push(curr);
      curr = curr.left;
    }
    // 弹出栈顶节点(左子树最底层节点),加入结果
    curr = stack.pop();
    result.push(curr.val);
    // 遍历右子树
    curr = curr.right;
  }
  return result;
}

// 测试
const root = new TreeNode(1, null, new TreeNode(2, new TreeNode(3)));
console.log(inorderTraversalRecursive(root)); // [1,3,2]
console.log(inorderTraversalIterative(root)); // [1,3,2]

28. 斐波那契数列(递归+迭代+优化)

考察点:递归、动态规划、时间/空间复杂度优化

// 题目:求第n个斐波那契数(F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2))

// 解法1:递归法(简单但效率低,时间复杂度O(2ⁿ),有重复计算)
function fibRecursive(n) {
  if (n <= 1) return n;
  return fibRecursive(n - 1) + fibRecursive(n - 2);
}

// 解法2:迭代法(推荐,时间复杂度O(n),空间复杂度O(1))
function fibIterative(n) {
  if (n <= 1) return n;
  let prevPrev = 0; // F(n-2)
  let prev = 1; // F(n-1)
  let curr = 0;
  for (let i = 2; i <= n; i++) {
    curr = prevPrev + prev; // F(n) = F(n-2) + F(n-1)
    prevPrev = prev;
    prev = curr;
  }
  return curr;
}

// 解法3:动态规划(空间复杂度O(n),适合需要保存所有斐波那契数的场景)
function fibDP(n) {
  if (n <= 1) return n;
  const dp = new Array(n + 1);
  dp[0] = 0;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

// 测试
console.log(fibIterative(10)); // 55

29. 最长公共前缀

考察点:字符串操作、遍历对比、边界处理

// 题目:编写一个函数,找出字符串数组中的最长公共前缀
function longestCommonPrefix(strs) {
  // 边界判断:数组为空,返回空字符串
  if (strs.length === 0) return '';
  // 以第一个字符串为基准,逐个字符对比
  let prefix = strs[0];
  for (let i = 1; i < strs.length; i++) {
    // 循环对比当前字符串和基准字符串,直到找到公共前缀
    while (strs[i].indexOf(prefix) !== 0) {
      // 若不匹配,缩短基准字符串(去掉最后一个字符)
      prefix = prefix.slice(0, prefix.length - 1);
      // 若基准字符串为空,说明没有公共前缀,直接返回
      if (prefix === '') return '';
    }
  }
  return prefix;
}

// 测试
console.log(longestCommonPrefix(["flower","flow","flight"])); // "fl"
console.log(longestCommonPrefix(["dog","racecar","car"])); // ""

30. 验证回文串

考察点:字符串处理、正则表达式、双指针

// 题目:验证一个字符串是否是回文串(只考虑字母和数字,忽略大小写)
function isPalindromeStr(s) {
  // 1. 过滤无效字符(只保留字母和数字),并转为小写
  const validStr = s.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
  // 2. 双指针:左指针从开头,右指针从末尾,逐步对比
  let left = 0;
  let right = validStr.length - 1;
  while (left < right) {
    if (validStr[left] !== validStr[right]) {
      return false; // 不相等,不是回文串
    }
    left++;
    right--;
  }
  return true; // 全部相等,是回文串
}

// 测试
console.log(isPalindromeStr("A man, a plan, a canal: Panama")); // true
console.log(isPalindromeStr("race a car")); // false

写在最后

这30个手写JS算法,覆盖了前端面试从基础到进阶的所有高频考点——基础语法、数组操作、原型作用域、数据结构、算法思维,每道题都能直接复制到编辑器调试,吃透这30道题,面试时再遇到手写算法题,就能从容应对。

很多前端同学觉得算法难,其实是没找对方法:不用刷上千道题,重点吃透这些高频题,搞懂每道题的原理和考点,比盲目刷题更有效。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端性能优化实战:从3秒到1秒,我只做了这5件事(全网通用)

做前端的都知道,页面加载速度就是生命线

一个项目做完,代码写得再漂亮、框架选得再先进,只要打开慢个1-2秒,用户流失率直接翻倍。最近我接手了一个老项目,首屏加载要 3.5秒,通过一系列针对性优化,最终压到了 0.8秒

今天把这套通用型性能优化方案分享给大家,不限Vue、React、小程序,只要是前端项目,复制粘贴就能提升加载速度,收藏这一篇,面试、工作都能用!


一、现状复盘:为什么你的页面那么卡?

先别急着写代码,先搞清楚瓶颈在哪里。建议在开发环境打开 Chrome 开发者工具 -> Lighthouse 跑一次测评。

通常前端加载慢,无非逃不过这3点:

  1. 体积过大:打包后的JS/CSS体积太大,网络传输慢;
  2. 请求过多:首屏加载了无关的接口、图片,造成网络拥堵;
  3. 渲染阻塞:JS没加载完,页面就是一片空白,用户体验极差。

下面的5步优化,就是针对这三大痛点,由浅入深,解决80%的性能问题。

二、核心干货:5个必做优化步骤(直接复制)

1. 路由懒加载:只加载当前需要的代码

这是性价比最高的优化!默认情况下,Webpack会把所有路由打包成一个巨大的 app.js。首屏不管去哪个页面,都要把所有代码下载下来。

解决思路: 按路由拆分,只加载当前页面的代码。

Vue3 / Vite 写法

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    // 直接引入,首屏会加载
    component: () => import('@/views/Home.vue') 
  },
  {
    path: '/about',
    name: 'About',
    // 关键:使用箭头函数+import,实现路由懒加载
    // 访问该路由时才会加载对应的Chunk文件
    component: () => import('@/views/About.vue') 
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

React / React Router 写法

// 同样适用React,只需在路由配置处改一下
const About = React.lazy(() => import('@/views/About.vue')); 
// 配合Suspense显示加载中
import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

2. 图片优化:去重+压缩,体积减半

图片通常是项目体积最大的资源。不要直接把UI给的原图丢上去。

3招搞定图片优化:

  1. 使用现代格式:把PNG/JPG转为 WebPAVIF,体积可缩小50%以上,兼容性极好。
  2. 压缩图片:使用 TinyPNG 或 Webpack 插件(如image-webpack-loader)自动压缩。
  3. 懒加载(Lazy Loading):给图片加上 loading="lazy" 属性,滚动到可视区域再加载,首屏请求瞬间减少。

原生写法(所有框架通用)

<!-- 只需添加这个属性,自动实现懒加载 -->
<img src="image.webp" loading="lazy" alt="优化后" />

Vue/React 中使用

在你的UI库(Element/Ant Design)中使用图片组件时,直接添加属性即可:

<el-image 
  src="image.webp" 
  loading="lazy" 
  fallback="fallback.png" <!-- 降级处理 -->
/>

3. 移除console和注释:清掉“垃圾代码”

打包发布时,千万别把 console.logdebugger 和大量注释打包进去。这些不仅增加体积,还会暴露前端代码,存在安全风险。

解决方案: 在Vite/Webpack配置中一键清除。

Vite 配置(vue.config.js 或 vite.config.js)

// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    // 生产环境清除console
    minify: 'ter', // 使用terser进行压缩
    terserOptions: {
      compress: {
        drop_console: true, // 移除所有console
        drop_debugger: true // 移除debugger
      }
    }
  }
})

4. 资源压缩:Gzip / Brotli 终极武器

这一步是服务器端的,但只要是前端开发,必须跟后端同学沟通开启。

开启Gzip或Brotli压缩后,服务器会对传输的JS、CSS、HTML文件进行压缩,传输体积能减少60%-80%

如何配置:

  • Nginx:在配置文件中开启 gzip on;
  • Node.js (Express):使用 compression 中间件
  • 云服务:在阿里云/腾讯云CDN控制台直接开启Brotli压缩

效果对比: 原来100KB的JS文件,压缩后只剩20-30KB,加载速度快得惊人!

5. 核心JS降级与polyfill:告别老旧浏览器拖累

现在的ES6+语法很强大,但如果不转译,老旧浏览器(如IE11,甚至旧版Chrome)无法识别,会被迫重新加载大量polyfill补丁。

解决方案: 使用Babel或ESBuild进行转译。

Babel 配置(.babelrc)

{
  "presets": [
    ["@babel/preset-env", {
      "useBuiltIns": "usage", // 按需引入polyfill
      "corejs": 3 // 指定core-js版本
    }]
  ]
}

作用:自动识别你代码中用到的ES6+语法,只引入必要的补丁,大幅减少打包体积。


三、避坑指南:这2件事千万别做

  1. 不要过度优化:比如把一个小工具库手动从项目中移除,得不偿失。优先优化首屏体积和网络请求,这才是最直观的体验提升。
  2. 不要忽略首屏空白:即使加载快了,如果页面是白屏直到JS加载完才显示,用户体验依然不好。可以在 index.html 中添加简单的骨架屏(Skeleton Screen)。

四、写在最后

性能优化不是一蹴而就的,它是一个持续迭代的过程。今天分享的这5个方法,是前端项目上线前的必做项

你会发现,很多时候不需要引入复杂的库,也不需要重写整个项目,只需要对现有配置做几处微调,性能就能有质的飞跃。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

JS炼化:手写一下promise——用一份外卖,看懂状态机+两个回调篓子

用一份外卖,看懂状态机+两个回调篓子

不少初学者看到完整版Promise手写源码就犯难,繁杂的边界处理和进阶优化让人望而生畏。其实抛开这些锦上添花的拓展逻辑,Promise的核心本质特别简单:一个状态机 + 两个回调篓子,所有功能都由此衍生。手写Promise从来不是盲目造轮子,核心目的就是拆开原生API的黑盒,告别只会调用不懂原理的陌生感,彻底吃透异步运行的底层逻辑。

大家或许对源码逻辑感到晦涩,但一定熟悉点外卖的日常。接下来我就用点外卖的生活化比喻,带你轻松弄懂状态机和回调篓子的核心运作逻辑。

先看逻辑思路

你点了一份外卖 = 发起一个异步任务

1. 状态机 = 外卖订单状态
  •  pending :商家正在做饭(任务进行中)
  •  fulfilled :外卖送到你手上(成功)
  •  rejected :商家没货/取消订单(失败)

状态机干了啥?

  • 告诉你现在能不能吃
  • 保证只会送一次,不会反复送
  • 饭做好了就一直是做好的状态,不会变回“正在做”
  • 结果会永久保存,你什么时候拿都有

(状态机 = 给异步任务定规矩: 只能走一次,走到哪就是哪,结果永久留着。)

2.回调篓子 = 你给外卖员留的“送达通知方式”

你还没拿到外卖时( pending ),你跟外卖员说:

  • 送到了给我打电话
  • 送不成给我发短信

这些“打电话、发短信”,就是你存在  then  里的回调。

回调篓子干了啥?

  • 饭还没好,先把你的要求存起来
  • 不催、不闹、不嵌套
  • 等饭一好,一次性按顺序执行

(回调篓子 = 暂存你的“后续操作”, 异步没跑完,先排队等通知。)

3. 两者合在一起,才是 Promise

流程是这样的:

1. 你下单 → Promise 新建

2. 状态立刻变成  pending → 商家正在做饭

3. 你调用  .then()  留下回调 → 把“打电话/发短信”放进篓子存好

4.饭做好了 →  resolve()

  • 状态变成  fulfilled 
  • 拿出成功篓子里的所有回调,挨个执行 → 挨个打电话通知你

5.饭做不成 →  reject()

  • 状态变成  rejected 
  • 拿出失败篓子里的回调,挨个执行 → 发短信告诉你取消了

再到后面链式调用“骑手”干了什么,“平台”有哪些补救措施

(今天我们就以点外卖的方式: 从0 开始,一小块一小块叠代码,先懂原理再落地实现,彻底撕开 Promise 的黑盒!)

 

第一阶段:逐块拆解手写「纯状态机基础版Promise」

比喻以注释的形式写进代码里方便理解

下单必有初始状态,Promise 创建瞬间也必须固定 pending 态,状态只能单向流转,这是一切的根基。我们从零一块块码

第1块:定义三大核心状态常量(杜绝硬编码写错)

// 对标外卖三种固定状态:做饭中 / 已送达 / 已取消
const STATUS_PENDING = "pending";    // 等待中-正在做饭
const STATUS_FULFILLED = "fulfilled";// 成功-外卖送到
const STATUS_REJECTED = "rejected";  // 失败-订单取消

为什么单独定义常量? 统一管理状态名,全程复用,避免手写字符串拼写错误。(可以理解为有报错提示,同时也能让大家看代码更清晰的行业规范)

第2块:搭建Promise空类骨架(相当于开通下单通道)

// 创建自定义Promise类,开始搭建订单系统外壳
class _Promise {
    // 构造函数:实例化瞬间触发 = 用户点击下单
    constructor(executor = () => {}) {

    }
}

executor 就是商家做饭的核心流程,默认给空函数防止报错。

第3块:构造器初始化核心属性(订单基础信息登记)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 刚下单默认状态:商家正在做饭 pending
        this.status = STATUS_PENDING;
        // 预留位置:存放送到手的外卖成果(成功结果)
        this.value = undefined;
        // 预留位置:存放订单失败的原因(商家没货/超时)
        this.reason = undefined;
    }
}

核心规则:只要Promise一创建,天生固定 pending,绝不允许开局直接成功/失败。

第4块:内部定义resolve函数(外卖送达开关)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 专属开关:调用就代表外卖顺利送达
        const resolve = (value) => {
            // 铁律校验:只有还在做饭中,才能改成已送达
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                // 把到手的外卖存起来
                this.value = value;
            }
        };
    }
}

状态不可逆核心体现:已经送达/取消的订单,再也不能二次修改状态。

第5块:内部追加reject函数(订单取消开关)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        // 专属开关:调用就代表订单作废取消
        const reject = (reason) => {
            // 同样铁律:只有做饭中才能取消订单
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                // 把取消原因记录下来
                this.reason = reason;
            }
        };
    }
}

第6块:立即执行executor做饭流程(下单立刻开工)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        // 下单瞬间直接开火做饭!把两个开关交给外部掌控
        executor(resolve, reject);
    }
}

关键特性:Promise构造器里的执行器是同步立即执行,不会等待延迟。

第7块:追加基础then方法(外卖到了要干啥)

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        executor(resolve, reject);
    }

    // 定制收货操作:成功干啥、失败干啥
    then(onFulfilled, onRejected) {
        // 外卖已经送到,有成功回调就直接执行
        if (this.status === STATUS_FULFILLED && typeof onFulfilled === 'function') {
            onFulfilled(this.value);//这个onFulfilled传进来必须是函数,加判断为了防止报错崩程序
        }
        // 订单已经取消,有失败回调就直接执行
        if (this.status === STATUS_REJECTED && typeof onRejected === 'function') {
            onRejected(this.reason);//这里同理
        }
        // 注意:当前版本暂时处理不了还在做饭中的异步等待场景
    }
}

第一阶段整合完整版(逐块拼装最终成品)

// 1. 定义外卖三大状态常量
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

// 2. 自定义基础Promise类
class _Promise {
    constructor(executor = () => {}) {
        // 3. 初始化订单默认状态和存储容器
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 4. 送达开关逻辑
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
            }
        };

        // 5. 取消开关逻辑
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
            }
        };

        // 6. 立刻启动做饭流程
        executor(resolve, reject);
    }

    // 7. 收货处理then方法
    then(onFulfilled, onRejected) {
        if (this.status === STATUS_FULFILLED && typeof onFulfilled === 'function') {
            onFulfilled(this.value);
        }
        if (this.status === STATUS_REJECTED && typeof onRejected === 'function') {
            onRejected(this.reason);
        }
    }
}

下一阶段我们就给这套基础状态机装上「回调篓子队列」,解决异步等待存任务的问题,完美适配真实延时外卖场景~

第二阶段:加装「回调篓子」完整版(衔接基础状态机,递进改造)

上一版只有状态机,处理不了异步延时——就像外卖要等一会才送到,你提前说了收货要做的事,总得先记下来,这两个数组  resolveQueue/rejectQueue  就是专门存事的「回调篓子」。

第2块:类骨架不变,构造器里新增两个回调篓子属性

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 原有基础状态不变
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // ========== 全新加装:两个回调篓子 ==========
        // 成功篓子:存外卖没送到时,所有收货要做的事
        this.resolveQueue = [];
        // 失败篓子:存订单没取消前,所有失败兜底要做的事
        this.rejectQueue = [];
    }
}

改动说明:凭空新增两个数组,专门排队存待执行的回调函数,对应「先记下来,等送达再办」。

第3块:改造 resolve 函数——送达后自动清空执行成功篓子

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                // ========== 新增逻辑:外卖送到,挨个执行篓子里所有寄存的事 ==========
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };
    }
}

第4块:同步改造 reject 函数——订单取消清空执行失败篓子

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                // ========== 新增逻辑:订单取消,挨个执行失败篓子里寄存的事 ==========
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };
    }
}

第6块:核心改造 then 方法——判断 pending,把回调塞进篓子

这是最关键改动:外卖还在做(pending),不执行,直接把事装篓子里排队

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = reason => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        executor(resolve, reject);
    }

    then(onFulfilled, onRejected) {
        // 情况1:已经送到了,直接办收货的事
        if (this.status === STATUS_FULFILLED ) {
            onFulfilled(this.value);
        }
        // 情况2:已经取消了,直接办兜底的事
        else if (this.status === STATUS_REJECTED ) {
            onRejected(this.reason);
        }
        // ========== 全新核心逻辑:还在做饭 pending → 塞进对应篓子存起来 ==========
        else if (this.status === STATUS_PENDING) {
           this.resolveQueue.push(onFulfilled);
           this.rejectQueue.push(onRejected);
        }
    }
}

加装回调篓子·阶段完整汇总代码

// 外卖三态常量
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 基础状态机属性
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;

        // 两个回调篓子队列
        this.resolveQueue = [];
        this.rejectQueue = [];

        // 送达开关 + 执行成功队列
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        // 取消开关 + 执行失败队列
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        // 立刻执行做饭流程
        executor(resolve, reject);
    }

    // 智能分发:已完成直接执行,pending就装篓子
    then(onFulfilled, onRejected) {
        if (this.status === STATUS_FULFILLED ) {
            onFulfilled(this.value);
        } else if (this.status === STATUS_REJECTED ) {
            onRejected(this.reason);
        } else if (this.status === STATUS_PENDING) {
             this.resolveQueue.push(onFulfilled);
             this.rejectQueue.push(onRejected);
        }
    }
}

第三阶段:刚需进阶版 基础链式调用(无边界裸奔版,核心骨架)

加装核心链式调用真正解决回调函数嵌套地狱

const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        this.status = STATUS_PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.rejectReason = undefined;
        this.resolveQueue = [];
        this.rejectQueue = [];

        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        executor(resolve, reject);
    }

    then(onFulfilled, onRejected) {
        // 核心刚需:返回新实例,实现链式接龙
        return new _Promise((nextResolve, nextReject) => {
            // 封装统一骑手任务:复用逻辑、可立即执行可入篓寄存、中转链式值
             // 封装统一处理函数handleSuccess原因:
            // 1.逻辑抽离复用,不用多处重复写判断/执行逻辑
            // 2.包装成独立函数,既能立即执行,也可直接塞进队列寄存
            // 3.中转结果交给下一个Promise,支撑链式流转
            const handleSuccess = () => {
                const res = onFulfilled(this.value);
                nextResolve(res);
            };
               // 同成功处理逻辑:统一封装复用、可入队列、中转链式结果
            const handleFail = () => {
                const err = onRejected(this.reason);
                nextResolve(err);
            };

            if (this.status === STATUS_FULFILLED) {
                handleSuccess();
            } else if (this.status === STATUS_REJECTED) {
                handleFail();
            } else if (this.status === STATUS_PENDING) {
                this.resolveQueue.push(handleSuccess);
                this.rejectQueue.push(handleFail);
            }
        });
    }
}

刚需裸奔版存在核心问题(对应骑手配送漏洞)

1. 执行器executor报错直接崩程序(后厨做饭出事直接瘫痪,无应急)

2. then乱传非函数、空传省略回调直接报错(招了不会干活的假骑手,配送直接翻车)

3. 无值透传,中间空then直接断链式(中途骑手离岗,外卖没人接力送,链路废掉)   4. then内部回调自己报错无捕获(骑手送餐中途出事,全程没人兜底救援)

分步针对性边界优化(只改对应位置)

优化1:构造器加try/catch 兜底executor全局报错

只改constructor最后一行执行代码,其余全不变:

// 原有执行代码删掉,替换成下面
try {
  executor(resolve, reject); // 正常执行商家出餐
} catch (err) {
  reject(err); // 改动注释:后厨做饭报错直接转订单失败兜底,不崩系统
}

  优化2:加函数校验+值透传 解决乱传/空传骑手断链问题

只改then里handle核心逻辑+队列存入判断:

const handleSuccess = () => {
  // 改动注释:判断是不是正经骑手函数,不是就原值透传接力,不废单
  const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
  nextResolve(res);
};
const handleFail = () => {
  // 改动注释:失败回调同样校验+原因透传,保证坏单也能顺畅接力
  const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
  nextResolve(err);
};

// 底部pending入队也改一行
if (this.status === STATUS_PENDING) {
  // 改动注释:只把正经骑手存进任务篓,假骑手直接拒收不占用队列
  typeof onFulfilled === 'function' && this.resolveQueue.push(handleSuccess);
  typeof onRejected === 'function' && this.rejectQueue.push(handleFail);
}
 

优化3:内层try/catch 兜底骑手送餐中途自身报错(最终闭环)

只再包一层内部捕获:

const handleSuccess = () => {
  // 改动注释:骑手干活中途出错即时救援,报错直接切失败单流转
  try {
    const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
    nextResolve(res);
  } catch (err) {
    nextReject(err);
  }
};
const handleFail = () => {
  try {
    const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
    nextResolve(err);
  } catch (err) { // 改动注释:失败处理出错同样兜底捕获,全链路无死角
    nextReject(err);
  }
};

 

最终完整完善版(直接阅读注释说明看到整个流程)

// 外卖订单三大固定状态:待接单/配送完成/订单拒收取消
const STATUS_PENDING = "pending";
const STATUS_FULFILLED = "fulfilled";
const STATUS_REJECTED = "rejected";

class _Promise {
    constructor(executor = () => {}) {
        // 初始化订单默认状态:全部处于待接单
        this.status = STATUS_PENDING;
        // 存放配送完成的餐品结果
        this.value = undefined;
        // 存放订单拒收的原因备注
        this.reason = undefined;

        // 骑手任务收纳篓:排队等候的配送任务/拒收善后任务
        this.resolveQueue = [];
        this.rejectQueue = [];

        // 配送放行开关:只有待接单能改成完成,批量执行所有等候配送骑手
        const resolve = (value) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_FULFILLED;
                this.value = value;
                this.resolveQueue.forEach(fn => fn(this.value));
            }
        };

        // 订单拒收开关:只有待接单能改成取消,批量执行善后骑手任务
        const reject = (reason) => {
            if (this.status === STATUS_PENDING) {
                this.status = STATUS_REJECTED;
                this.reason = reason;
                this.rejectQueue.forEach(fn => fn(this.reason));
            }
        };

        // 后厨出餐容错:做饭翻车直接判订单失败,不瘫痪整个店铺
        try {
            executor(resolve, reject);
        } catch (err) {
            reject(err);
        }
    }

    then(onFulfilled, onRejected) {
        // 链式核心:每接一单就生成全新订单,实现骑手接力直送,完成异步时间扁平化
        return new _Promise((nextResolve, nextReject) => {
            // 统一封装正规配送骑手任务
            const handleSuccess = () => {
                // 骑手上岗核验+原值代送透传+送餐中途意外兜底
                try {
                    const res = typeof onFulfilled === 'function' ? onFulfilled(this.value) : this.value;
                    nextResolve(res);
                } catch (err) {
                    nextReject(err);
                }
            };
            // 统一封装订单善后退款骑手任务
            const handleFail = () => {
                // 坏单兜底核验+原因透传+善后出错应急保护
                try {
                    const err = typeof onRejected === 'function' ? onRejected(this.reason) : this.reason;
                    nextResolve(err);
                } catch (err) {
                    nextReject(err);
                }
            };

            // 根据订单当前状态调度骑手
            if (this.status === STATUS_FULFILLED) {
                handleSuccess(); // 已出餐直接派送
            } else if (this.status === STATUS_REJECTED) {
                handleFail(); // 已拒收直接善后
            } else if (this.status === STATUS_PENDING) {
                // 只收正经上岗骑手进任务篓,杂牌无效骑手直接拒收
                typeof onFulfilled === 'function' && this.resolveQueue.push(handleSuccess);
                typeof onRejected === 'function' && this.rejectQueue.push(handleFail);
            }
        });
    }
}

整个流程结尾

我们全程用外卖订单+骑手配送的思路走完了手写Promise的主要核心流程:

最初先搭建核心骨架,定好订单三态状态机,搭配存放任务的回调篓,实现了最基础的异步任务寄存能力;

接着核心打通链式调用逻辑,每调用一次then就生成一张全新外卖订单,让骑手接力顺路配送,把嵌套混乱的回调地狱改成线性直行的流程,完成了Promise最关键的异步扁平化;

最后一步步叠加全套边界优化,用try/catch兜底后厨出餐报错、骑手送餐中途异常,用函数筛查过滤不会干活的假骑手,搭配值透传规则让空岗骑手原样代送不丢单,保障整条配送链路永远顺畅不崩。

至此我们就从最简裸奔版,迭代打磨出了逻辑完整、健壮可用的完整版基础Promise。至于 Promise.all 、 Promise.race 这类静态工具方法,只是在这套核心完备的订单系统之上,额外封装的组合派单简易逻辑,属于锦上添花的拓展用法,不改动我们底层状态机、队列调度、链式流转的核心架构,这里就不再额外展开赘述了。

如有理解不当,欢迎大家指正,一起学习进步

iOS Runtime 深度解析

iOS Runtime 深度解析:原理、实战与前沿趋势

在 iOS 开发中,Runtime(运行时)是 Objective-C(以下简称 OC)语言的灵魂,也是区分 iOS 初级开发者与中高级开发者的核心门槛。它赋予 OC 动态特性,让代码在编译期无法确定的逻辑,能在运行时灵活调整、动态扩展。随着 Swift 生态的完善和 Apple 技术的迭代,Runtime 并未过时,反而在组件化、性能优化、逆向开发等场景中发挥着不可替代的作用。本文将从原理、实战、前沿三个维度,带你全面吃透 iOS Runtime,结合代码示例拆解核心用法,助力你在实际开发中灵活运用这门“黑魔法”。

一、Runtime 核心基础:是什么与为什么

1.1 什么是 Runtime

Runtime 本质上是一套用 C 和汇编语言编写的 API 集合,是 OC 语言与底层系统之间的桥梁,负责将 OC 代码转换为底层可执行的机器指令,实现动态类型、动态绑定、动态加载等核心特性。简单来说,OC 是“动态语言”,核心就在于 Runtime——编译期我们写的 OC 方法调用、属性访问,最终都会被转换为 Runtime 的 C 函数调用,直到运行时才真正确定具体执行逻辑。

举个直观的例子:我们调用 [object method] 时,编译器并不会直接确定 method 方法的具体实现,而是在运行时通过 Runtime 查找该方法的实现并执行,这也是 Runtime 与静态语言(如 C++)的核心区别。

1.2 Runtime 的核心价值

  • 动态扩展:无需修改类的源码,即可为类添加方法、属性,突破 OC 语法限制;
  • 解耦优化:在组件化、插件化开发中,通过 Runtime 实现组件间通信,降低耦合度;
  • 底层适配:解决系统 API 兼容、私有方法调用、逆向开发等场景的核心问题;
  • 性能优化:通过方法缓存、动态解析等机制,提升 App 运行效率。

1.3 核心数据结构

Runtime 的所有功能,都围绕以下几个核心结构体展开,理解它们是掌握 Runtime 的基础:

(1)objc_object:对象的本质

OC 中所有对象的底层都是 objc_object 结构体,核心字段是 isa 指针,用于指向对象所属的类。

// objc 对象的底层结构体
struct objc_object {
    Class isa; // 指向类对象的指针,核心字段
};

// OC 对象的本质就是 objc_object 的指针
typedef struct objc_object *id;

(2)objc_class:类的本质

类对象(Class)的底层是 objc_class 结构体,存储着类的元信息(方法列表、属性列表、协议列表等)。

struct objc_class {
    Class isa; // 指向元类(Meta Class),用于存储类方法
    Class super_class; // 指向父类
    const char *name; // 类名
    long instance_size; // 实例对象的内存大小
    struct objc_ivar_list *ivars; // 实例变量列表
    struct objc_method_list **methodLists; // 方法列表(可动态修改)
    struct objc_cache *cache; // 方法缓存(提升查找效率)
    struct objc_protocol_list *protocols; // 协议列表
};

(3)Method、SEL、IMP:方法的三要素

  • SEL:方法选择器,本质是字符串,用于唯一标识一个方法(如 @selector(method:));
  • IMP:函数指针,指向方法的具体实现,是方法执行的核心;
  • Method:方法结构体,封装了 SELIMP 的对应关系。
// 方法结构体
struct objc_method {
    SEL method_name; // 方法选择器
    char *method_types; // 方法类型编码(返回值、参数类型)
    IMP method_imp; // 方法实现的函数指针
};

二、Runtime 核心机制:从原理到实战

Runtime 的核心机制包括消息传递、方法缓存、动态解析、消息转发、方法交换等,其中消息传递是基础,其他机制都是基于消息传递的扩展。以下结合实战代码,拆解每个机制的原理与用法。

2.1 消息传递:OC 方法调用的本质

OC 中所有方法调用,本质上都是 Runtime 的 objc_msgSend 函数调用。当我们写下 [object method:arg] 时,编译器会自动转换为:

objc_msgSend(object, @selector(method:), arg);

消息传递的完整流程

  1. 通过对象的 isa 指针,找到对象所属的类;
  2. 优先在类的 cache(方法缓存)中查找对应 SELIMP
  3. 若缓存未命中,遍历类的 methodLists 查找方法;
  4. 若当前类未找到,沿着 super_class 父类链向上查找,直到找到 NSObject;
  5. 若找到方法,执行 IMP 并将方法加入缓存(提升下次查找效率);
  6. 若未找到方法,进入消息转发流程(后续详解)。

实战:手动调用 objc_msgSend

需导入 Runtime 头文件 #import <objc/runtime.h>,手动调用消息传递函数:

#import <objc/runtime.h>

@interface Person : NSObject
- (void)sayHello:(NSString *)name;
@end

@implementation Person
- (void)sayHello:(NSString *)name {
    NSLog(@"Hello, %@", name);
}
@end

// 调用方式
Person *person = [[Person alloc] init];
// 1. 常规调用
[person sayHello:@"Runtime"];
// 2. 手动调用 objc_msgSend
SEL sel = @selector(sayHello:);
objc_msgSend(person, sel, @"Runtime"); // 输出:Hello, Runtime

2.2 方法缓存:提升消息传递效率

Runtime 为每个类维护了一个 objc_cache(方法缓存),用于存储最近调用过的方法(SEL + IMP)。缓存采用哈希表实现,查找速度远快于遍历方法列表,这是 Runtime 优化性能的核心手段之一。

核心特点:

  • 缓存只存储“最近调用”的方法,避免缓存过大;
  • 每次调用方法后,若缓存未命中,找到 IMP 后会自动加入缓存;
  • 类的缓存会随着方法调用动态更新,优先保留高频调用的方法。

2.3 动态解析与消息转发:方法未找到的“补救机制”

当消息传递流程中未找到方法时,Runtime 不会直接崩溃,而是提供了三层“补救机制”,让我们有机会动态补充方法实现,避免 App 闪退。

(1)动态方法解析(第一层补救)

通过重写 +resolveInstanceMethod:(实例方法)或 +resolveClassMethod:(类方法),动态为未实现的方法添加实现。

@implementation Person
// 动态解析实例方法
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(sayHello:)) {
        // 为 sel 动态添加实现:参数1=类,参数2=SEL,参数3=IMP,参数4=方法类型编码
        class_addMethod(self, sel, (IMP)dynamicSayHello, "v@:@");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

// 动态添加的方法实现(C语言函数)
void dynamicSayHello(id self, SEL _cmd, NSString *name) {
    NSLog(@"动态解析:Hello, %@", name);
}
@end

// 调用未声明的方法(不会崩溃)
Person *person = [[Person alloc] init];
[person sayHello:@"Dynamic Resolve"]; // 输出:动态解析:Hello, Dynamic Resolve

(2)消息转发(第二层+第三层补救)

若动态解析未处理(返回 NO),则进入消息转发流程,分为两步:

  1. 快速转发:通过 -forwardingTargetForSelector:,将消息转发给另一个对象处理;
  2. 完整转发:若快速转发未处理,通过 -methodSignatureForSelector: 获取方法签名,再通过 -forwardInvocation: 手动处理消息。
实战:快速转发
@interface Student : NSObject
- (void)sayHello:(NSString *)name;
@end

@implementation Student
- (void)sayHello:(NSString *)name {
    NSLog(@"Student 打招呼:Hello, %@", name);
}
@end

@implementation Person
// 快速转发:将消息转发给 Student 对象
- (id)forwardingTargetForSelector:(SEL)aSelector {
    if (aSelector == @selector(sayHello:)) {
        return [[Student alloc] init];
    }
    return [super forwardingTargetForSelector:aSelector];
}
@end

// 调用方法,消息会转发给 Student
Person *person = [[Person alloc] init];
[person sayHello:@"Forward"]; // 输出:Student 打招呼:Hello, Forward
实战:完整转发
@implementation Person
// 1. 获取方法签名(必须实现,否则崩溃)
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(sayHello:)) {
        // 方法签名:返回值void(v),参数id(@)、SEL(:)、NSString(@)
        return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
    }
    return [super methodSignatureForSelector:aSelector];
}

// 2. 手动处理消息
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    SEL sel = anInvocation.selector;
    Student *student = [[Student alloc] init];
    if ([student respondsToSelector:sel]) {
        [anInvocation invokeWithTarget:student]; // 转发给 Student
    } else {
        [super forwardInvocation:anInvocation];
    }
}
@end

2.4 方法交换(Method Swizzling):Runtime 黑魔法

Method Swizzling(方法交换)是 Runtime 最常用的实战技巧,通过交换两个方法的 IMP,实现“hook”效果,无需修改原方法源码,即可拦截、扩展原方法的功能(如埋点、日志、性能监控)。

核心原理:交换两个 Method 结构体中的 IMP 指针,让原 SEL 指向新的实现,新 SEL 指向原实现。

实战:拦截 UIViewController 的 viewDidLoad 方法

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

@implementation UIViewController (Swizzling)
// 在 +load 方法中执行方法交换(+load 方法会在类加载时自动调用,且只调用一次)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 1. 获取两个方法
        Class cls = [self class];
        SEL originalSel = @selector(viewDidLoad);
        SEL swizzledSel = @selector(swizzled_viewDidLoad);
        
        Method originalMethod = class_getInstanceMethod(cls, originalSel);
        Method swizzledMethod = class_getInstanceMethod(cls, swizzledSel);
        
        // 2. 交换方法实现
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

// 新的方法实现(拦截 viewDidLoad)
- (void)swizzled_viewDidLoad {
    // 1. 执行原 viewDidLoad 方法(此时 swizzled_viewDidLoad 指向原实现)
    [self swizzled_viewDidLoad];
    
    // 2. 扩展功能(如埋点、日志)
    NSLog(@"拦截到 %@ 的 viewDidLoad", self.class);
}
@end

方法交换的注意事项

  • dispatch_once_t 保证方法交换只执行一次,避免多次交换导致逻辑错乱;
  • 优先在 +load 方法中执行交换(类加载时执行,时机最早),避免在 +initialize 中执行(可能被多次调用);
  • 交换类方法时,需使用 class_getClassMethod 获取方法,而非 class_getInstanceMethod
  • 避免交换系统私有方法,可能导致 App 审核失败或系统崩溃。

2.5 动态添加属性与关联对象

OC 中,分类(Category)默认不能添加实例变量(ivar),但通过 Runtime 的关联对象(Associated Object),可以间接为分类添加“属性”,本质是将属性值存储在外部哈希表中,与对象关联起来。

实战:为 UIButton 分类添加属性

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

@interface UIButton (Extension)
// 声明属性
@property (nonatomic, copy) NSString *customName;
@end

@implementation UIButton (Extension)
// 定义关联对象的 key(唯一标识)
static const void *CustomNameKey = &CustomNameKey;

// 重写 setter 方法
- (void)setCustomName:(NSString *)customName {
    // 关联对象:参数1=对象,参数2=key,参数3=值,参数4=内存管理策略
    objc_setAssociatedObject(self, CustomNameKey, customName, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

// 重写 getter 方法
- (NSString *)customName {
    // 获取关联对象
    return objc_getAssociatedObject(self, CustomNameKey);
}
@end

// 使用
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
button.customName = @"我的按钮";
NSLog(@"按钮名称:%@", button.customName); // 输出:按钮名称:我的按钮

关联对象的内存管理策略

// 对应 OC 属性的内存修饰符
OBJC_ASSOCIATION_ASSIGN; // assign
OBJC_ASSOCIATION_RETAIN_NONATOMIC; // strong, nonatomic
OBJC_ASSOCIATION_COPY_NONATOMIC; // copy, nonatomic
OBJC_ASSOCIATION_RETAIN; // strong, atomic
OBJC_ASSOCIATION_COPY; // copy, atomic

三、Runtime 前沿趋势:适配 Swift 与 Apple 新生态

随着 Swift 成为 iOS 开发的主流语言,以及 Apple 推出的新工具、新框架(如 Xcode 26、基础模型框架),Runtime 的应用场景也在不断扩展,不再局限于 OC 开发,而是与 Swift 生态深度融合,呈现出全新的发展趋势。

3.1 Runtime 与 Swift 的协同发展

Swift 是静态语言,编译期会进行类型检查,但其底层仍然依赖 Runtime(尤其是与 OC 交互时),同时 Swift 也提供了自己的动态特性(如 @dynamicMemberLookup@objc 关键字),与 OC Runtime 形成互补。

  • Swift 中使用 @objc 修饰的方法、属性,会被暴露给 Runtime,可通过 OC Runtime API 调用;
  • Swift 5.0+ 引入的 @dynamicMemberLookup,允许动态访问属性,本质是 Runtime 动态特性的 Swift 封装;
  • 在 Swift 组件化开发中,通过 Runtime 实现跨模块调用(如通过类名字符串创建对象),解决 Swift 静态编译的限制。

实战:Swift 中调用 Runtime API

import ObjectiveC

class Person: NSObject {
    @objc func sayHello(_ name: String) {
        print("Hello, (name)")
    }
}

// 1. 动态创建对象
let className = "RuntimeDemo.Person"
guard let cls = NSClassFromString(className) as? Person.Type else { return }
let person = cls.init()

// 2. 动态调用方法
let sel = NSSelectorFromString("sayHello:")
person.perform(sel, with: "Swift Runtime") // 输出:Hello, Swift Runtime

// 3. 动态添加关联对象
extension UIButton {
    private static let customKey = UnsafeRawPointer(bitPattern: 0x123456)!
    var customName: String? {
        get {
            objc_getAssociatedObject(self, UIButton.customKey) as? String
        }
        set {
            objc_setAssociatedObject(self, UIButton.customKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }
    }
}

3.2 Runtime 在 Apple 新生态中的应用

随着 Apple 发布 Xcode 26、基础模型框架等新工具,Runtime 的应用场景进一步扩展,尤其在智能开发、性能优化、跨平台适配等方面发挥着重要作用:

  1. 智能开发辅助:Xcode 26 集成了大语言模型,可通过 Runtime 分析类的结构、方法列表,自动生成代码、修复错误,提升开发效率;
  2. 隐私保护与性能优化:基础模型框架支持设备端 AI 推理,Runtime 可动态管理模型调用的生命周期,避免敏感数据泄露,同时通过方法缓存优化 AI 推理的响应速度;
  3. 跨平台适配:Swift 6.2 支持 WebAssembly,Runtime 可帮助开发者实现 OC/Swift 代码与 Web 端的交互,动态适配不同平台的 API 差异;
  4. 逆向开发与安全防护:在 App 安全领域,通过 Runtime Hook 系统方法,可拦截敏感操作(如密码输入、网络请求),防止数据泄露;同时,也可通过 Runtime 混淆方法名、类名,提升 App 反逆向能力。

3.3 Runtime 的未来展望

尽管 Swift 生态日益完善,但 Runtime 作为 iOS 底层核心技术,短期内不会被替代,反而会随着 Apple 技术的迭代不断升级:

  • 更高效的方法缓存机制:Apple 可能进一步优化 objc_cache 的哈希算法,提升消息传递效率;
  • 更安全的动态扩展:加强 Runtime API 的权限管理,避免恶意代码通过 Runtime 篡改 App 逻辑;
  • 与 AI 深度融合:通过 Runtime 动态适配 AI 模型的调用,实现更智能的代码生成、性能优化。

四、结语

iOS Runtime 是 OC 语言的灵魂,也是 iOS 开发的“内功”。它不仅能帮助我们理解 iOS 底层原理,更能在实际开发中解决很多常规语法无法解决的问题——从组件化解耦、性能优化,到逆向开发、安全防护,Runtime 都发挥着不可替代的作用。

随着 Swift 与 Apple 新生态的发展,Runtime 的应用场景不断扩展,它不再是“小众黑魔法”,而是中高级 iOS 开发者必须掌握的核心技能。学习 Runtime,不仅是学习一套 API,更是培养一种“底层思维”——跳出上层语法的限制,从底层理解代码的执行逻辑,才能写出更高效、更健壮、更具扩展性的 iOS 应用。

最后,希望本文能帮助你快速吃透 Runtime 的核心原理与实战用法,在实际开发中灵活运用这门技术,突破自身开发瓶颈,成为更优秀的 iOS 开发者。未来,Runtime 还会不断进化,期待我们一起探索它的更多可能性。

前端必看!前端路由守卫这么写,再也不担心权限混乱(Vue/React通用)

所有前端必看!路由守卫看似简单,却藏着很多坑——未登录能直接访问个人中心、管理员页面普通人能进、跳转时数据未加载就渲染。全程实操干货+完整封装,Vue2/Vue3、React 都能用,复制就能实现权限管控

先搞懂:路由守卫到底用来做什么?

不管是 Vue Router 还是 React Router,路由守卫的核心作用只有一个:控制路由的访问权限和跳转逻辑,解决以下高频问题:

  • 未登录用户,禁止访问个人中心、订单页等需要权限的页面;
  • 不同角色(普通用户/管理员),展示不同的路由页面;
  • 页面跳转前,校验数据、确认操作(比如未保存的表单,提示用户);
  • 页面加载前,获取必要数据(比如用户信息),避免页面空白。

重点:路由守卫是前端权限管控的核心。Vue 和 React 用法略有差异,但逻辑一致。本文分别给出完整示例,复制就能适配自己的项目,不用再从零编写。


核心干货:Vue2/Vue3 路由守卫完整封装(直接复制)

Vue 项目用 Vue Router,路由守卫分为 3 类:全局守卫、路由独享守卫、组件内守卫。重点掌握全局守卫,就能解决 80% 的权限问题。

1. Vue3 + Vue Router 4(最常用,推荐)

新建 router/index.js,全局守卫 + 路由配置,一步到位:

// router/index.js(Vue3)
import { createRouter, createWebHistory } from 'vue-router';
import { getStorage } from '@/utils/storage'; // 复用之前封装的 LocalStorage 工具

// 1. 定义路由(区分公开路由和需要权限的路由)
const routes = [
  // 公开路由(无需登录就能访问)
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false } // 标记:无需权限
  },
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: false }
  },
  // 需要权限的路由(必须登录才能访问)
  {
    path: '/user',
    name: 'UserCenter',
    component: () => import('@/views/UserCenter.vue'),
    meta: {
      requiresAuth: true, // 标记:需要权限
      role: 'user' // 角色限制:普通用户即可访问
    }
  },
  // 管理员路由(只有管理员能访问)
  {
    path: '/admin',
    name: 'Admin',
    component: () => import('@/views/Admin.vue'),
    meta: {
      requiresAuth: true,
      role: 'admin' // 角色限制:仅管理员
    }
  },
  // 404页面
  {
    path: '/:pathMatch(.*)*',
    name: '404',
    component: () => import('@/views/404.vue')
  }
];

// 2. 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.VITE_BASE_URL),
  routes
});

// 3. 全局前置守卫(跳转前校验,核心)
router.beforeEach((to, from, next) => {
  // 1. 获取 token(从 LocalStorage 中取)
  const token = getStorage('token');
  // 2. 获取当前用户角色(登录后存储的用户信息)
  const userRole = getStorage('userInfo')?.role || '';

  // 3. 校验逻辑
  if (to.meta.requiresAuth) {
    // 3.1 需要权限的路由:判断是否登录
    if (!token) {
      // 未登录,跳转到登录页,登录后返回当前页面
      return next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      // 已登录,判断角色是否匹配
      if (to.meta.role && to.meta.role !== userRole) {
        // 角色不匹配,跳转到首页(或 403 页面)
        return next({ name: 'Home' });
      }
      // 登录且角色匹配,允许跳转
      next();
    }
  } else {
    // 3.2 公开路由:直接跳转
    next();
  }
});

// 4. 全局后置守卫(跳转后执行,比如修改页面标题)
router.afterEach((to) => {
  // 设置页面标题
  document.title = to.meta.title || '前端路由守卫示例';
});

export default router;

页面中使用(Vue3):

<!-- 登录页面,登录成功后跳转回之前的页面 -->
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { setStorage } from '@/utils/storage';

const router = useRouter();
const route = useRoute();

const login = async () => {
  const res = await loginApi(); // 登录接口
  // 存储 token 和用户信息
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);

  // 跳转回之前的页面(如果有),否则跳首页
  const redirect = route.query.redirect || '/';
  router.push(redirect);
};
</script>

2. Vue2 + Vue Router 3(兼容旧项目)

逻辑和 Vue3 一致,仅语法略有差异,直接复制:

// router/index.js(Vue2)
import Vue from 'vue';
import Router from 'vue-router';
import { getStorage } from '@/utils/storage';

Vue.use(Router);

const routes = [
  // 路由配置和 Vue3 一致
  { path: '/login', name: 'Login', component: () => import('@/views/Login'), meta: { requiresAuth: false } },
  { path: '/user', name: 'UserCenter', component: () => import('@/views/UserCenter'), meta: { requiresAuth: true, role: 'user' } },
  { path: '/admin', name: 'Admin', component: () => import('@/views/Admin'), meta: { requiresAuth: true, role: 'admin' } },
];

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
});

// 全局前置守卫
router.beforeEach((to, from, next) => {
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  if (to.meta.requiresAuth) {
    if (!token) {
      next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      if (to.meta.role && to.meta.role !== userRole) {
        next({ name: 'Home' });
      } else {
        next();
      }
    }
  } else {
    next();
  }
});

export default router;

核心干货:React + React Router 6 路由守卫封装(直接复制)

React Router 6 取消了传统的路由守卫 API,改用「组件封装」的方式实现权限控制,更灵活,适配 React 函数式组件,直接复制就能用。

1. 封装权限守卫组件(utils/PrivateRoute.js

import { Navigate, Outlet } from 'react-router-dom';
import { getStorage } from '@/utils/storage';

/**
 * 权限守卫组件
 * @param {Object} props - 传入的角色限制
 * @param {string} props.role - 允许访问的角色(可选)
 */
export const PrivateRoute = ({ role }) => {
  // 获取 token 和用户角色
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  // 未登录,跳转到登录页
  if (!token) {
    return <Navigate to="/login" replace />;
  }

  // 有角色限制,且当前角色不匹配,跳转到首页
  if (role && role !== userRole) {
    return <Navigate to="/" replace />;
  }

  // 权限通过,渲染子路由(Outlet 对应 Vue 的 router-view)
  return <Outlet />;
};

2. 路由配置(router/index.jsx

import { createBrowserRouter } from 'react-router-dom';
import PrivateRoute from '@/utils/PrivateRoute';
// 引入页面组件
import Login from '@/views/Login';
import Home from '@/views/Home';
import UserCenter from '@/views/UserCenter';
import Admin from '@/views/Admin';
import NotFound from '@/views/404';

// 创建路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />
  },
  {
    path: '/login',
    element: <Login />
  },
  // 需要权限的路由:用 PrivateRoute 包裹
  {
    path: '/user',
    element: <PrivateRoute role="user" />, // 普通用户可访问
    children: [
      { path: '', element: <UserCenter /> } // 子路由,对应 Outlet
    ]
  },
  // 管理员路由:限制角色为 admin
  {
    path: '/admin',
    element: <PrivateRoute role="admin" />,
    children: [
      { path: '', element: <Admin /> }
    ]
  },
  // 404 页面
  {
    path: '*',
    element: <NotFound />
  }
]);

export default router;

3. 入口文件中使用(main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import router from './router';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

4.React 登录页面使用:

import { useNavigate, useLocation } from 'react-router-dom';
import { setStorage } from '@/utils/storage';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  // 获取跳转前的页面地址
  const redirect = new URLSearchParams(location.search).get('redirect') || '/';

  const login = async () => {
    const res = await loginApi();
    setStorage('token', res.data.token, 86400);
    setStorage('userInfo', res.data.user, 86400);
    // 跳转回之前的页面
    navigate(redirect, { replace: true });
  };

  return (
    <button onClick={login}>登录</button>
  );
}

export default Login;

实战避坑:4 个高频坑,新手必避

坑 1:Vue 路由守卫中,忘记调用 next(),导致页面卡死

错误示例:在 beforeEach 中只做了判断,没调用 next(),路由无法跳转,页面卡死。
正确做法:所有分支都必须调用 next(),允许跳转用 next(),重定向用 next({ name: 'Login' })

坑 2:React Router 6 中,用旧版本语法写守卫,导致失效

React Router 6 取消了 beforeEachafterEach 等 API,不要再用旧版本的写法。
正确做法:用「PrivateRoute 组件 + Outlet」的方式实现权限控制,本文示例直接可用。

坑 3:未处理“登录后跳转回原页面”,体验变差
用户未登录访问需要权限的页面,登录后应该跳转回之前的页面,而不是默认首页。
正确做法:跳转登录页时,携带当前页面地址(query 参数),登录成功后跳转回去。

坑 4:角色权限判断不严谨,导致越权访问

只判断是否登录,不判断角色,导致普通用户能访问管理员页面。
正确做法:在路由 meta(Vue)或 PrivateRoute 组件(React)中添加角色限制,登录后校验角色。


进阶技巧:路由守卫高级用法

1. 表单未保存,禁止跳转(组件内守卫 / Vue 专属)

<script setup>
import { onBeforeRouteLeave } from 'vue-router';

// 组件内守卫:离开当前页面时触发
onBeforeRouteLeave((to, from, next) => {
  // 判断表单是否未保存
  if (formIsDirty.value) {
    if (confirm('表单未保存,确定要离开吗?')) {
      next(); // 确认离开
    } else {
      next(false); // 取消离开
    }
  } else {
    next(); // 表单已保存,允许离开
  }
});
</script>

2. 路由跳转时,加载 loading 状态(全局守卫)

// Vue3 全局守卫中添加 loading
import { ref } from 'vue';
export const isLoading = ref(false);

router.beforeEach((to, from, next) => {
  isLoading.value = true; // 跳转前显示 loading
  // 原有校验逻辑...
  next();
});

router.afterEach(() => {
  setTimeout(() => {
    isLoading.value = false; // 跳转后隐藏 loading
  }, 300);
});

结尾:干货总结

路由守卫是前端权限管控的核心。Vue 和 React 用法虽有差异,但逻辑一致——判断登录状态、校验角色、控制跳转。一套封装就能覆盖所有场景,避开 4 个高频坑,复制就能实现权限管控。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端必看!LocalStorage这么用,再也不踩坑(多框架通用,直接复制)

所有前端必看!LocalStorage看似简单,却有90%的人用错——存对象报错、存数组失效、数据污染、内存溢出,甚至导致页面卡顿。全程实操干货+通用封装,Vue/React/Uniapp/小程序/Node都能用,复制就能避免所有坑

先搞懂:LocalStorage核心痛点,你一定踩过

做前端开发,谁没用 LocalStorage 存过 token、用户信息?但大多数人都是裸写 localStorage.setItemlocalStorage.getItem,看似简单,实则全是坑:

  • 只能存字符串:存对象/数组直接报错,或取出来变成 [object Object]
  • 没有过期时间:存的 token、临时数据一直占用内存,导致数据污染;
  • 没有容错处理:取不到数据直接报错,影响页面渲染;
  • 键名混乱:多个页面/组件存数据,容易覆盖、冲突。

重点:LocalStorage 是前端本地存储的基础,Vue、React、Uniapp、小程序、Node(前端渲染)都能用。一套通用封装,彻底解决所有痛点,不用重复写冗余代码。

核心干货:LocalStorage通用封装(直接复制,多框架通用)

新建 utils/storage.js,一次封装,全局使用。支持存字符串、对象、数组,带过期时间、容错处理、键名统一,复制到任何前端项目都能直接用!

/**
 * LocalStorage通用封装(Vue/React/Uniapp/小程序通用)
 * 支持:存字符串、对象、数组 + 过期时间 + 容错处理 + 键名统一
 */
const STORAGE_KEY_PREFIX = 'frontend_'; // 键名前缀,避免冲突

// 1. 存数据(支持过期时间,单位:秒)
export const setStorage = (key, value, expire = 0) => {
  try {
    // 处理对象/数组,转为JSON字符串(LocalStorage只能存字符串)
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0 // 0表示永久有效
    };
    // 键名加前缀,避免和其他项目/插件冲突
    localStorage.setItem(`${STORAGE_KEY_PREFIX}${key}`, JSON.stringify(data));
  } catch (error) {
    console.error('LocalStorage存储失败:', error);
    // 兼容低版本浏览器/隐私模式(LocalStorage不可用)
    alert('浏览器存储不可用,请开启正常模式后重试');
  }
};

// 2. 取数据(自动处理JSON解析,判断过期)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const dataStr = localStorage.getItem(storageKey);
    if (!dataStr) return null;

    const data = JSON.parse(dataStr);
    // 判断是否过期(expire=0表示永久有效)
    if (data.expire > 0 && Date.now() > data.expire) {
      // 过期后自动删除,避免无效数据占用内存
      localStorage.removeItem(storageKey);
      return null;
    }

    // 自动解析JSON(如果存的是对象/数组)
    try {
      return JSON.parse(data.value);
    } catch (e) {
      // 不是JSON格式,直接返回原始值(字符串)
      return data.value;
    }
  } catch (error) {
    console.error('LocalStorage获取失败:', error);
    return null;
  }
};

// 3. 删除单个数据
export const removeStorage = (key) => {
  try {
    localStorage.removeItem(`${STORAGE_KEY_PREFIX}${key}`);
  } catch (error) {
    console.error('LocalStorage删除失败:', error);
  }
};

// 4. 清空所有数据(只清空当前项目的,不影响其他项目)
export const clearStorage = () => {
  try {
    // 只删除带前缀的键,避免清空其他项目的存储
    Object.keys(localStorage).forEach(key => {
      if (key.startsWith(STORAGE_KEY_PREFIX)) {
        localStorage.removeItem(key);
      }
    });
  } catch (error) {
    console.error('LocalStorage清空失败:', error);
  }
};

// 5. 批量存数据
export const setStorageBatch = (obj, expire = 0) => {
  try {
    Object.entries(obj).forEach(([key, value]) => {
      setStorage(key, value, expire);
    });
  } catch (error) {
    console.error('LocalStorage批量存储失败:', error);
  }
};

实战用法:多框架示例,直接复制

不管是 Vue、React、Uniapp,用法完全一致,只需引入封装好的方法,无需额外适配。以下示例覆盖 80% 的使用场景。

1. 基础用法:存/取字符串、对象、数组

// 引入封装的方法(所有框架通用)
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

// 1. 存字符串(比如token)
setStorage('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', 86400); // 过期时间1天(86400秒)

// 2. 存对象(比如用户信息)
const userInfo = { id: 1, name: '张三', age: 25 };
setStorage('userInfo', userInfo, 86400);

// 3. 存数组(比如历史记录)
const historyList = ['Vue', 'React', 'JS'];
setStorage('historyList', historyList); // 不设过期时间,永久有效

// 4. 取数据(自动解析对象/数组,无需手动JSON.parse)
const token = getStorage('token');
const user = getStorage('userInfo'); // 直接拿到对象,无需解析
const history = getStorage('historyList'); // 直接拿到数组

// 5. 删除数据
removeStorage('token'); // 删除单个
// clearStorage(); // 清空当前项目所有存储

2. Vue3/Uniapp页面中使用

<script setup>
import { ref, onMounted } from 'vue';
import { setStorage, getStorage } from '@/utils/storage';

const userInfo = ref({});

// 页面加载时,从LocalStorage取用户信息
onMounted(() => {
  const user = getStorage('userInfo');
  if (user) {
    userInfo.value = user;
  }
});

// 登录成功后,存用户信息和token
const login = async () => {
  const res = await loginApi(); // 登录接口
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);
  userInfo.value = res.data.user;
};
</script>

3. React页面中使用

import { useState, useEffect } from 'react';
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

function UserPage() {
  const [user, setUser] = useState({});

  useEffect(() => {
    // 组件挂载时取数据
    const userInfo = getStorage('userInfo');
    if (userInfo) {
      setUser(userInfo);
    }
  }, []);

  // 退出登录,删除存储
  const logout = () => {
    removeStorage('token');
    removeStorage('userInfo');
    setUser({});
  };

  return (
    <div>
      {user.name}
      <button onClick={logout}>退出登录</button>
    </div>
  );
}

4. 小程序/Uniapp适配(特殊处理)

小程序不支持 window.localStorage,需替换为 wx.setStorageSync 等原生 API,修改封装方法即可,核心逻辑不变:

// 小程序版本封装(utils/storage.js)
const STORAGE_KEY_PREFIX = 'frontend_';

// 存数据
export const setStorage = (key, value, expire = 0) => {
  try {
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0
    };
    wx.setStorageSync(`${STORAGE_KEY_PREFIX}${key}`, data);
  } catch (error) {
    console.error('存储失败:', error);
    wx.showToast({ title: '存储不可用', icon: 'none' });
  }
};

// 取数据(其他方法同理,替换为wx.getStorageSync、wx.removeStorageSync)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const data = wx.getStorageSync(storageKey);
    if (!data) return null;
    if (data.expire > 0 && Date.now() > data.expire) {
      wx.removeStorageSync(storageKey);
      return null;
    }
    try {
      return JSON.parse(data.value);
    } catch (e) {
      return data.value;
    }
  } catch (error) {
    console.error('获取失败:', error);
    return null;
  }
};

实战避坑:5个高频坑,新手必避

坑1:直接存对象/数组,导致报错或解析失败

错误示例localStorage.setItem('user', {name: '张三'}),直接存对象会报错。
正确做法:用封装的 setStorage,自动将对象/数组转为 JSON 字符串,取的时候自动解析。

坑2:不设过期时间,导致数据污染

存 token、临时数据时,不设过期时间,用户退出后数据依然存在,再次登录会出现异常。
正确做法:给敏感数据、临时数据设置过期时间(比如 token 设 1 天)。

坑3:键名不统一,导致覆盖冲突

多个组件/页面存数据,键名都是 “user”“data”,容易互相覆盖。
正确做法:用前缀统一键名(封装中已自带 frontend_ 前缀),避免冲突。

坑4:忽略浏览器兼容性,导致报错

部分低版本浏览器、隐私模式下,LocalStorage 不可用,裸写会报错。
正确做法:封装中添加容错处理,捕获异常并提示用户。

坑5:清空所有存储,影响其他项目

错误示例:直接用 localStorage.clear(),会清空浏览器中所有项目的 LocalStorage。
正确做法:用封装的 clearStorage,只清空当前项目带前缀的存储。

进阶技巧:LocalStorage进阶用法

1. 监听 LocalStorage 变化(跨页面通信)

// 页面A监听存储变化
window.addEventListener('storage', (e) => {
  // 只监听当前项目的存储变化(带前缀)
  if (e.key?.startsWith(STORAGE_KEY_PREFIX)) {
    console.log('存储变化:', e.key, e.newValue);
    // 比如监听token变化,实现跨页面登录状态同步
    if (e.key === `${STORAGE_KEY_PREFIX}token`) {
      // 处理登录状态更新
    }
  }
});

// 页面B修改存储,页面A会触发监听
setStorage('token', 'newToken');

2. 限制存储大小,避免内存溢出

LocalStorage 默认存储上限约 5MB,存大量数据会导致内存溢出。可在封装中添加存储大小校验:

// 新增:校验存储大小
const checkStorageSize = (value) => {
  const valueStr = typeof value === 'object' ? JSON.stringify(value) : value;
  const size = new Blob([valueStr]).size;
  // 限制单条数据不超过1MB
  if (size > 1 * 1024 * 1024) {
    alert('存储数据过大,建议拆分存储');
    return false;
  }
  return true;
};

// 在setStorage中添加校验
export const setStorage = (key, value, expire = 0) => {
  if (!checkStorageSize(value)) return;
  // 原有逻辑...
};

结尾:干货总结

LocalStorage 是前端必备的本地存储工具。一套通用封装,解决存对象、过期时间、冲突、容错等所有痛点,适配所有前端框架,复制就能用,避开 5 个高频坑,再也不用为存储问题头疼。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

面试题里的 Custom Hook 思维:从三道题总结「异步状态管理」通用模式

最近在准备面试,翻到几道关于 Custom Hook 的模拟题。表面上看各不相同——轮询、筛选、防抖搜索——但仔细分析之后,发现它们背后有一套共同的思维框架。这篇文章是我整理这套框架的笔记,希望对同样在备战面试的你有参考价值。


三道题,三个场景

先简单描述一下这三道题在考什么:

  • useRideTracking:行程进行中轮询状态,每 5s 请求一次,页面隐藏时暂停,连续失败 3 次停止
  • useExpenseFilter:报表筛选 Hook,多维联动筛选,需要 useMemo 优化
  • useEmployeeSearch:员工搜索,防抖 500ms + AbortController 取消请求

三个场景,但核心都指向同一个问题:如何在 Hook 里正确管理「副作用」和「派生状态」?


归纳出的通用思维框架

在我看来,一个合格的 Custom Hook 需要从四个维度去思考:

1. 状态层(State)     ── 管什么数据?
2. 副作用层(Effect)  ── 什么时候做什么?
3. 清理层(Cleanup)   ── 离开时怎么收尾?
4. 优化层(Optimization) ── 怎么不做多余的工作?

下面逐层展开,结合题目来理解。


第一层:状态层 — 先想清楚「管什么」

拿到题目,第一步应该问自己:这个 Hook 需要对外暴露哪些状态?

这三道题都有一个共同的「三元组」:

// 几乎所有「异步请求型」Hook 的状态骨架
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

除了这个骨架,每道题还有「额外状态」:

  • useRideTracking:需要 failCount(连续失败次数),但这是内部状态,不对外暴露
  • useExpenseFilter:需要 filters 对象,并对外暴露 setFilter / resetFilters
  • useEmployeeSearch:需要 keyword,并对外暴露 setKeyword

一个实用技巧:区分「对外暴露」和「内部管理」的状态。对外的是接口契约,对内的是实现细节。面试中如果能主动说出这种区分,往往加分。

// useRideTracking 的状态设计示意
// 对外:{ status, loading, error }
// 对内:failCountRef(用 ref 而非 state,因为改变它不需要触发重渲染)
const failCountRef = useRef(0);

第二层:副作用层 — 明确「触发时机」

useEffect 的依赖数组,本质上是在描述「什么变化了我才需要重新执行」。

模式 A:挂载即执行 + 定时触发(useRideTracking)

// 环境:React 18+
// 场景:行程状态轮询

function useRideTracking(rideId) {
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const failCountRef = useRef(0);
  const timerRef = useRef(null);
  const stoppedRef = useRef(false);

  const fetchStatus = async () => {
    if (stoppedRef.current) return;

    setLoading(true);
    try {
      const res = await fetch(`/api/rides/${rideId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      setStatus(data.status);
      setError(null);
      // success: reset fail counter
      failCountRef.current = 0;
    } catch (err) {
      failCountRef.current += 1;
      if (failCountRef.current >= 3) {
        stoppedRef.current = true;
        setError(err);
        setLoading(false);
        clearInterval(timerRef.current);
        return;
      }
    } finally {
      if (!stoppedRef.current) setLoading(false);
    }
  };

  useEffect(() => {
    // fetch immediately on mount
    fetchStatus();
    timerRef.current = setInterval(fetchStatus, 5000);

    const handleVisibility = () => {
      if (document.visibilityState === 'hidden') {
        clearInterval(timerRef.current);
      } else {
        fetchStatus(); // refetch immediately on visible
        timerRef.current = setInterval(fetchStatus, 5000);
      }
    };

    document.addEventListener('visibilitychange', handleVisibility);

    return () => {
      clearInterval(timerRef.current);
      document.removeEventListener('visibilitychange', handleVisibility);
      stoppedRef.current = true;
    };
  }, [rideId]);

  return { status, loading, error };
}

这道题的难点有两个:

  1. visibilitychange 事件——很多人第一反应想不到,但这是真实产品里节省资源的常见做法
  2. 连续失败计数用 ref 还是 state——改变它不需要重渲染,用 ref 更合适

模式 B:受控输入 + 派生计算(useExpenseFilter)

// 环境:React
// 场景:多维度联动筛选

const emptyFilter = {
  departments: [],
  dateRange: null,
  statuses: [],
  amountRange: null,
};

function useExpenseFilter(data) {
  const [filters, setFilters] = useState({ ...emptyFilter });

  const setFilter = useCallback((key, value) => {
    setFilters((prev) => ({ ...prev, [key]: value }));
  }, []);

  const resetFilters = useCallback(() => {
    setFilters({ ...emptyFilter });
  }, []);

  const filteredData = useMemo(() => {
    return data.filter((trip) => {
      if (filters.departments.length && !filters.departments.includes(trip.department)) return false;
      if (filters.statuses.length && !filters.statuses.includes(trip.status)) return false;
      if (filters.amountRange) {
        const [min, max] = filters.amountRange;
        if (trip.amount < min || trip.amount > max) return false;
      }
      if (filters.dateRange) {
        const [start, end] = filters.dateRange;
        if (trip.date < start || trip.date > end) return false;
      }
      return true;
    });
  }, [data, filters]);

  return { filters, setFilter, filteredData, resetFilters };
}

这道题相对直接,但有两个容易踩的坑:

  1. resetFilters 里要用 { ...emptyFilter } 而非直接传引用——否则 emptyFilter 对象可能被意外修改
  2. setFilter 要用 useCallback 包裹——否则每次渲染都会生成新函数,可能导致消费方的 memo 失效

模式 C:防抖 + 请求竞态处理(useEmployeeSearch)

// 环境:React
// 场景:带防抖的搜索请求,需要处理竞态

function useEmployeeSearch() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortControllerRef = useRef(null);

  useEffect(() => {
    const trimmed = keyword.trim();

    // empty keyword: reset state immediately
    if (!trimmed) {
      setResults([]);
      setError(null);
      setLoading(false);
      return;
    }

    const timer = setTimeout(async () => {
      // abort previous pending request
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      const controller = new AbortController();
      abortControllerRef.current = controller;

      setLoading(true);
      setError(null);

      try {
        const res = await fetch(
          `/api/employees/search?q=${encodeURIComponent(trimmed)}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        const data = await res.json();
        setResults(data);
      } catch (err) {
        if (err.name === 'AbortError') return; // ignore abort errors
        setError(err);
      } finally {
        setLoading(false);
      }
    }, 500);

    return () => {
      clearTimeout(timer);
      // abort on cleanup (keyword changed or unmount)
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [keyword]);

  return { keyword, setKeyword, results, loading, error };
}

这道题的核心考点是「竞态条件」(race condition):用户快速输入时,后发出的请求可能比先发出的先返回,导致界面显示旧数据。AbortController 是解决这个问题的标准方案。


第三层:清理层 — 「离开时」的责任感

这是很多初学者写 Hook 时最容易忽略的部分,但在面试中往往是区分「会用」和「理解」的分水岭。

一个简单的清理检查清单:

□ 定时器(setInterval / setTimeout)需要 clearInterval / clearTimeout
□ 事件监听器需要 removeEventListener
□ 进行中的网络请求需要 AbortController.abort()
□ 组件卸载后不应再 setState(会产生 warning)

上面三道题都涉及清理,总结一下各自的清理策略:

Hook 需要清理的东西
useRideTracking clearInterval + removeEventListener + 标记 stoppedRef 防止 setState
useExpenseFilter 无(纯状态计算,无副作用)
useEmployeeSearch clearTimeout + AbortController.abort()

第四层:优化层 — 「不做多余的工作」

优化不是一开始就要做的事,但 Hook 里有几个固定场景需要考虑:

场景一:派生状态用 useMemo

useExpenseFilter 里的 filteredData 是典型案例。如果直接在函数体里 data.filter(...),每次任何状态变化都会重新过滤,即使 datafilters 没有变化。

// 不好:每次渲染都重新计算
const filteredData = getFilterData(data);

// 好:只在 data 或 filters 变化时重新计算
const filteredData = useMemo(() => getFilterData(data), [data, filters]);

场景二:回调函数用 useCallback

暴露给外部的函数,如果作为 props 传递给子组件,或者出现在其他 Hook 的依赖数组里,应该用 useCallback 包裹。

场景三:不需要触发重渲染的值用 useRef

failCountReftimerRefabortControllerRef 都属于这类。它们是「进行中的工作凭证」,改变它们不需要更新 UI。


一个「答题」的思维顺序

整理完这三道题,我发现面试时可以按这个顺序思考:

1. 明确返回值契约
   └── 对外暴露哪些状态和方法?

2. 识别副作用触发时机
   └── 依赖什么变化?立即执行还是延迟?

3. 规划清理策略
   └── 定时器 / 事件 / 请求,哪些要清理?

4. 考虑优化点
   └── 有无派生状态?回调需不需要 useCallback?

这个顺序不是铁律,但至少能保证不遗漏关键点。


延伸思考

整理这几道题时,有几个问题让我觉得值得继续探索:

  • useReducer vs 多个 useStateuseExpenseFilter 里的多个筛选条件,用 useReducer 管理会更清晰吗?什么情况下应该做这个选择?
  • 请求库的抽象层:SWR / React Query 的 revalidateOnFocus 本质上就是 useRideTracking 里的 visibilitychange 逻辑,只是封装层次不同。面试中能提到这层联系,可能会有加分
  • TypeScript 的泛型设计:这几个 Hook 如果要做成通用的,类型怎么设计?这可能是下一篇笔记的方向

小结

Custom Hook 的核心,我理解是「把复杂的副作用逻辑封装成可复用的、有明确接口的黑盒」。面试考这类题,考的其实不只是「能不能写出来」,更是「能不能清晰地描述你在解决什么问题」。

这篇文章是我自己的思考整理,不一定全对,如果你有不同的看法或者更好的方案,欢迎交流讨论。


参考资料

Linux-从0开始-20260408

Linux

一、基础命令

1. Linux 基础命令

问题:Linux 基础命令

答案核心回答:ls、cd、pwd、mkdir、rm、cp、mv 是基础命令。

代码示例

# 目录操作
ls -la                       # 详细列表
ls -lh                       # 人类可读大小
cd /path/to/dir              # 切换目录
pwd                          # 显示当前目录
mkdir -p /path/to/dir        # 创建目录
mkdir -m 755 dir             # 指定权限

# 文件操作
rm -rf dir                   # 删除目录
rm file.txt                   # 删除文件
cp -r source dest            # 复制目录
cp file.txt dest/             # 复制文件
mv oldname newname           # 重命名/移动

# 查看文件
cat file.txt                 # 全文
head -n 20 file.txt          # 前20行
tail -n 20 file.txt          # 后20行
tail -f /var/log/syslog      # 实时查看日志

# 搜索
grep "pattern" file.txt      # 搜索文本
find / -name "filename"     # 查找文件

2. 文件权限

问题:文件权限

答案核心回答:Linux 文件权限分为 owner、group、others 三类,各有 rwx 权限。

代码示例

# 查看权限
ls -l file.txt
# -rw-r--r-- 1 user group 4096 Jan 15 10:00 file.txt
# 权限位: -rw-r--r--
# 类型  owner  group  others
# r=4 w=2 x=1

# 修改权限
chmod 755 file               # 数字形式
chmod u+x file               # 所有者添加执行
chmod g-w file               # 组移除写
chmod o+r file               # 其他添加读
chmod a+x file               # 所有添加执行

# 修改所有者
chown user:group file        # 修改所有者和组
chown user file              # 只修改所有者
chgrp group file             # 只修改组

# 特殊权限
chmod +s file               # SUID/SGID
chmod +t file               # Sticky Bit

二、文本处理

3. 文本处理命令

问题:文本处理命令

答案核心回答:awk、sed、grep 是强大的文本处理工具。

代码示例

# awk - 文本分析
awk '{print $1, $3}' file.txt          # 打印第1、3列
awk -F',' '{print $2}' file.csv       # 指定分隔符
awk '/pattern/ {print $0}' file.txt   # 模式匹配
awk 'NR==5' file.txt                  # 第5行

# sed - 文本替换
sed 's/old/new/g' file.txt           # 全局替换
sed -i 's/old/new/g' file.txt        # 直接修改
sed '1,5d' file.txt                  # 删除1-5行
sed -n '2p' file.txt                 # 打印第2行

# grep - 搜索
grep "pattern" file.txt
grep -r "pattern" dir/               # 递归搜索
grep -i "pattern" file.txt           # 忽略大小写
grep -v "pattern" file.txt           # 反向匹配
grep -E "regex" file.txt             # 扩展正则

# 管道组合
cat file.txt | grep "pattern" | awk '{print $2}' | sort

三、进程管理

4. 进程管理

问题:进程管理命令

答案核心回答:ps、top、kill 用于进程管理。

代码示例

# 查看进程
ps aux                          # 所有进程
ps -ef                          # 详细格式
top                              # 实时监控
htop                             # 增强版 top

# 查找进程
ps aux | grep nginx
pgrep -f "nginx"

# 信号与 kill
kill -l                          # 列出信号
kill -9 pid                      # 强制终止
kill -15 pid                     # 优雅终止(SIGTERM)
killall nginx                     # 按名称终止
pkill -f "nginx"                 # 按模式终止

# 后台进程
nohup command &                  # 后台运行
bg                               # 后台任务
fg                               # 前台任务
jobs                             # 任务列表

四、网络管理

5. 网络命令

问题:网络命令

答案核心回答:ping、curl、wget、netstat、ss 是常用网络命令。

代码示例

# 测试连通性
ping -c 4 example.com           # 发4个包
ping -i 0.5 example.com         # 0.5秒间隔

# 下载
curl https://example.com       # 下载页面
curl -O file.txt               # 下载文件
curl -L url                    #跟随重定向
wget url                        # 下载工具
wget -c url                    # 断点续传

# 网络诊断
netstat -tuln                  # 监听端口
netstat -anp | grep 80         # 查找端口占用
ss -tuln                       # 替代 netstat
netstat -i                      # 网卡信息

# curl 高级用法
curl -X POST url -d "data"    # POST 请求
curl -H "Header: value" url   # 自定义头
curl -v url                    # 详细输出

五、磁盘与内存

6. 磁盘与内存

问题:磁盘与内存命令

答案核心回答:df、du、free 是查看磁盘和内存的常用命令。

代码示例

# 磁盘使用
df -h                          # 人类可读格式
df -i                          # 查看 inode
du -sh *                       # 目录大小
du -sh /path/to/dir            # 指定目录

# 内存使用
free -h                        # 人类可读格式
free -m                        # MB 为单位
cat /proc/meminfo              # 详细内存信息

# 磁盘挂载
mount /dev/sdb1 /mnt/usb      # 挂载
umount /mnt/usb                # 卸载
df -T                           # 显示文件系统类型

大厂都在偷偷用的 Cursor Rules 封装!告别重复 Prompt,AI 编程效率翻倍

还在每天教 AI “失忆实习生”?
每次开新对话都要重讲项目架构、代码规范、命名风格……
而用这套 Rules 配置,Cursor 直接变成“懂你心思的老搭档”——跨会话记忆、自动守规范、团队统一标准,字节/腾讯内部已全面落地

如果你受够了:

  • 写 200 行 Prompt 只为让 AI 记住项目
  • 团队成员各自为战,AI 输出风格五花八门
  • 代码不规范、漏 error、命名混乱,返工到崩溃

那么,这篇经过 GitHub 5w+ 星验证的 Rules 完全指南,就是为你写的——
不用背命令,装完就忘,自然语言写需求,AI 自动按规矩干活


一、先看效果:以前累死,现在躺赢

场景 旧方式 Rules 新方式
开新对话 粘贴 300 行项目背景 自动加载上下文
写 Go 代码 手动提醒“加注释、小驼峰” 自动遵守官方规范
团队协作 每人一套 Prompt,风格乱飞 共享规则包,输出统一
代码审查 人工查漏 自动 lint + 安全扫描

真实收益

  • 每天节省 1~2 小时重复解释
  • 代码返工率下降 70%
  • 新人上手 AI 编程速度提升 3 倍

二、Rules 是什么?

传统 Prompt = 便利贴
贴一次用一次,新开对话就丢。

Rules = 永久工作手册
写一次,全局生效,团队共享,Git 可管。

核心价值
让 Cursor 从“聪明但没规矩” → “专业且守纪律”


三、必装三大神级 Rules(附一键安装)

1. everything-cursor(闭眼装)

GitHub ⭐ 52k+,黑客松冠军,60+ 规则覆盖全开发流程

支持:Go / Java / Python / React / Rust / Next.js
能力:自动格式化、TDD、部署提示、安全检查
命令:/fmt /check /tdd /mem

/rule market add https://github.com/affaan-m/everything-cursor
/rule install everything-cursor@everything-cursor

2. cursor-mem(解决金鱼记忆)

GitHub ⭐ 23k+,跨会话永久记住你的项目

再也不用重复解释:

  • 业务逻辑
  • 技术架构
  • 特殊约束
/rule market add https://github.com/thedotmack/cursor-mem
/rule install cursor-mem@cursor-mem

3. super-rules(工程化最强)

GitHub ⭐ 28k+,让 AI 像资深工程师一样思考

TDD 测试驱动
结构化调试
代码审查分级(Minor/Normal/Critical)

/rule market add https://github.com/obra/cursor-super-rules.git
/rule install super-rules@cursor-super-rules

避坑:装了 everything-cursor 就别装 plan-with-code,指令冲突!


四、Rules 怎么工作?完全无感!

99% 的规则安装后自动生效,无需手动触发

Cursor 会智能判断:

  • 你打开的是 Go 文件 → 自动激活 go-standard
  • 你在写前端 → 自动加载 react-rules
  • 你问架构问题 → 启用 arch-rules

你只需要用自然语言描述需求,剩下的交给 Rules!


五、两种安装方式(推荐第一种)

方式一:市场导入(推荐)

# 添加市场(只需一次)
/rule market add https://github.com/getcursor/cursor-rules-official

# 安装规则包
/rule install go-pack@cursor-rules-official

方式二:本地手动(私有/离线场景)

# macOS/Linux
cp -r my-rule ~/.cursor/rules/

六、新手安装优先级(直接抄作业)

优先级 规则包 理由
第一 everything-cursor 全能王,闭眼装
第二 cursor-mem 解决记忆痛点
第三 super-rules 提升工程质量
4 code-fmt(官方) 自动格式化
5 rule-maker(官方) 自定义规则

七、大厂为什么都在用?

  • 字节:用 cursor-mem 统一 200+ 微服务上下文
  • 腾讯super-rules 接入 CI,代码审查自动化
  • 阿里云:基于 rule-maker 生成内部规范包

Rules 不是玩具,而是 AI 编程的“基础设施”


结语:AI 编程,进入“有纪律”时代

当你不再为重复解释焦头烂额,
当你团队的 AI 输出风格高度统一,
你就知道——Rules,是每个专业开发者必须掌握的生产力核弹

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌