普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月26日首页

TS 基础扫盲:类型、接口、类型别名在业务代码里的最小集合

作者 SuperEugene
2026年2月25日 21:19

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇:为什么要关心 TS 类型?

日常业务里经常会遇到:

  • 类型报错Object is possibly 'undefined'Type 'string' is not assignable to type 'number'
  • 不知道选什么anyunknowninterfacetype 什么时候用?
  • 写了很多 TS 却像在写 JS:到处用 any,类型形同虚设

TypeScript 的核心是「类型约束」,把很多问题在编译期暴露出来。但很多人要么写太多类型最后变玄学,要么只会 any,形同 JS。

这篇文章不讲特别深的底层原理,而是围绕:平时写业务时该怎么选、为什么这么选、容易踩哪些坑。从基础类型 → 接口 → 类型别名 → 实战选型 → 踩坑,一次性理清。

二、基础类型扫盲

先把日常最常用的 5 个类型搞清楚。

类型 含义 典型用途
string 字符串 文案、id、枚举值
number 数字(含整数、浮点、NaN) 数量、金额、分页
boolean 布尔 开关、状态
any 任意类型,不做检查 兼容老代码、临时兜底
unknown 任意类型,但必须先检查再用 比 any 更安全的兜底

2.1 string / number / boolean

这三个是基础原始类型,和 JS 里的用法一致,只是加了一层类型标注:

// 变量声明时标注类型
const name: string = '张三';
const age: number = 25;
const isActive: boolean = true;

// 函数参数和返回值
function greet(name: string): string {
  return `你好,${name}`;
}

function add(a: number, b: number): number {
  return a + b;
}

业务里怎么用:接口返回值、表单字段、状态开关,优先用这三个而不是 any

2.2 any:最自由也最危险

any 表示「任意类型」,TS 不再做类型检查。

let data: any = 'hello';
data = 123;        // OK
data = { a: 1 };   // OK
data.toUpperCase(); // 编译通过,但运行时报错!data 实际是 number

问题any 会关闭类型检查,等于回到裸写 JS,很容易在运行时才发现错误。

适用场景

  • 临时接入老接口、第三方库,还没时间写类型
  • 快速迁移 JS 项目到 TS 时的过渡
  • 已经用 try-catch 等做了安全兜底

建议:能不用就不用,用的话尽量加注释说明原因。

2.3 unknown:比 any 更安全的兜底

unknown 也表示「任意类型」,但使用时必须先「收窄」类型,否则不能直接用。

let data: unknown = getFromApi(); // 不知道接口返回什么

// 直接调用会报错
// data.toString();  // Error: 'data' is of type 'unknown'

// 先判断类型再使用
if (typeof data === 'string') {
  console.log(data.toUpperCase()); // OK
} else if (typeof data === 'object' && data !== null && 'name' in data) {
  console.log((data as { name: string }).name); // 收窄后可安全使用
}

和 any 的对比

特性 any unknown
可直接调用方法 ❌ 必须先收窄
可赋给任意类型
类型安全 有(需检查后才用)

建议:拿不到确切类型时,用 unknown 代替 any,通过 typeofin、类型守卫等方式收窄后再用。

三、interface:描述对象形状

interface 用来描述「对象长什么样」:有哪些属性、什么类型、哪些可选。

3.1 基本用法

// 定义用户接口
interface User {
  id: number;
  name: string;
  age?: number;  // 可选属性
}

// 使用
const user: User = {
  id: 1,
  name: '张三'
  // age 可省略
};

3.2 可选属性、只读属性

interface Config {
  readonly apiUrl: string;  // 只读,不能改
  timeout?: number;         // 可选
}

const config: Config = { apiUrl: 'https://api.example.com' };
// config.apiUrl = 'xxx';  // Error: 只读

3.3 继承

interface BaseUser {
  id: number;
  name: string;
}

interface AdminUser extends BaseUser {
  role: 'admin';
  permissions: string[];
}

const admin: AdminUser = {
  id: 1,
  name: '管理员',
  role: 'admin',
  permissions: ['read', 'write']
};

3.4 索引签名(动态属性)

// 属性名是 string,值是 number
interface StringMap {
  [key: string]: number;
}

const map: StringMap = {
  a: 1,
  b: 2
};

业务场景:后端返回的用户、列表项、配置对象等,用 interface 描述结构最合适。

四、type 类型别名:给类型起个名字

type 用来给任意类型起别名,可以是基础类型、对象、联合类型、函数等。

4.1 基本用法

// 基础类型别名
type UserId = number;
type UserName = string;

// 对象类型
type User = {
  id: UserId;
  name: UserName;
};

// 联合类型(常见于业务)
type Status = 'pending' | 'success' | 'error';
type Theme = 'light' | 'dark';

4.2 联合类型、交叉类型

// 联合:A 或 B
type Result = { success: true; data: any } | { success: false; error: string };

// 交叉:A 且 B 的属性合并
type WithTimestamp = User & { createdAt: Date };

4.3 函数类型

type OnChange = (value: string) => void;
type FetchUser = (id: number) => Promise<User>;

业务场景:状态枚举、回调类型、联合类型等,用 type 更合适。

五、interface vs type:怎么选?

这是问得最多的一个问题,先看核心区别:

特性 interface type
声明合并 ✅ 同名可合并 ❌ 同名会报错
继承 extends & 交叉类型
适用对象 对象结构 任意类型
扩展对象 容易 容易
联合/交叉 不常用 常用

5.1 声明合并(interface 独有)

// interface 同名会合并
interface User {
  name: string;
}
interface User {
  age: number;
}
// 等价于 { name: string; age: number }

// type 同名会报错
type User = { name: string };
type User = { age: number };  // Error: 重复声明

业务含义:写插件、扩展第三方类型定义时,用 interface 可以多次补充属性;而 type 只能定义一次。

5.2 选型建议

用 interface

  • 描述对象结构(用户、配置、接口返回值等)
  • 有继承需求(如 extends BaseUser
  • 可能被第三方或插件扩展(依赖声明合并)

用 type

  • 联合类型:'pending' | 'success' | 'error'
  • 交叉类型:User & { role: string }
  • 函数类型:(id: number) => Promise<User>
  • 元组、复杂组合类型

实践中:对象结构优先 interface,其它复杂类型用 type。两者都能描述对象时,很多团队会统一用 interface,可读性更好。

六、实战场景:该怎么写

6.1 接口返回值

// 用 interface 描述
interface UserListItem {
  id: number;
  name: string;
  avatar?: string;
  status: 'active' | 'inactive';
}

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

// 使用
async function fetchUserList(): Promise<ApiResponse<UserListItem[]>> {
  const res = await axios.get('/api/users');
  return res.data;
}

6.2 表单、状态枚举

// 用 type 做联合
type FormStatus = 'draft' | 'submitting' | 'success' | 'error';
type SortOrder = 'asc' | 'desc';

interface FilterState {
  status: FormStatus;
  sortBy: string;
  sortOrder: SortOrder;
}

6.3 事件回调

type OnSearch = (keyword: string) => void;
type OnPageChange = (page: number, size: number) => void;

interface TableProps {
  onSearch: OnSearch;
  onPageChange: OnPageChange;
}

6.4 拿不准类型时用 unknown

async function fetchData(url: string): Promise<unknown> {
  const res = await fetch(url);
  return res.json();
}

// 使用时必须收窄
const data = await fetchData('/api/config');
if (data && typeof data === 'object' && 'theme' in data) {
  const theme = (data as { theme: string }).theme;
  // 安全使用
}

七、踩坑指南

原因 建议
到处用 any,类型失效 any 关闭类型检查 尽量用 unknown,或用具体类型
Object is possibly 'undefined' 可能为 undefined 却直接访问 可选链 obj?.propif 判断、! 断言
interface 和 type 混用一团 团队没约定 对象用 interface,联合/函数用 type
对象字面量多了属性报错 多余属性检查 用变量接收再传入,或加索引签名
第三方库没有类型 老库、非 TS 编写 .d.ts@ts-ignore,标注原因

7.1 多余属性检查

interface User {
  id: number;
  name: string;
}

// 直接传字面量时,多了属性会报错
// createUser({ id: 1, name: '张三', age: 18 });  // Error

// 用变量接收再传则不会(会按结构兼容)
const user = { id: 1, name: '张三', age: 18 };
createUser(user);  // OK

7.2 类型断言要谨慎

// as 断言:你说它是什么,TS 就信
const data = getData() as User;  // 若实际不是 User,运行时可能崩

// 更安全的做法:用类型守卫
function isUser(obj: unknown): obj is User {
  return obj !== null && typeof obj === 'object' && 'id' in obj && 'name' in obj;
}

八、小结

概念 一句话 典型场景
string/number/boolean 基础类型,优先用 接口字段、函数参数、状态
any 任意类型,无检查 临时兜底、兼容老代码,少用
unknown 任意类型,用前须收窄 拿不准类型时的安全选择
interface 描述对象结构 用户、配置、接口返回值
type 类型别名,可联合/交叉 状态枚举、函数类型、复杂组合

记住三点:

  1. 能用具体类型就不用 any,拿不准就用 unknown 再收窄。
  2. 对象结构用 interface,联合/函数/复杂类型用 type
  3. 业务里够用就行,不必一开始就追求完美,先让类型系统帮你兜住大部分错误。

把类型选对,编译期就能发现很多问题,后面的重构和维护都会轻松很多。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

昨天以前首页

常见设计模式在 JS 里的轻量用法:单例、发布订阅、策略

作者 SuperEugene
2026年2月25日 09:26

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇:为什么要学设计模式?

你可能会想:我写业务能跑就行,为什么要管设计模式?

实际工作中常见这些情况:

  • 权限控制:路由守卫、按钮权限、接口权限各处都在写,逻辑重复、难维护。
  • 消息通知:多个模块要弹 Toast,互相耦合,改一处影响一片。
  • 表单校验:不同字段用不同规则,全写在一个大 if-else 里,难扩展。

这些问题都可以用少量设计模式来简化。下面用单例、发布订阅、策略三种模式,搭配权限、通知、表单校验三个场景,讲清楚:日常怎么写、为什么这么写、容易踩哪些坑

二、概念扫盲

先把三个模式用一句话说清楚:

模式 一句话 适合场景
单例 全局只存在一个实例,多次调用拿到同一个对象 全局唯一的东西:权限管理器、通知中心、全局配置
发布订阅 发布者发事件,订阅者监听,彼此解耦 一对多、多对多通知:消息通知、事件总线
策略 把多种“算法/规则”封装成可替换的策略 同种操作多种规则:表单校验、支付方式选择

下面按「概念 → 代码 → 实战」来展开。

三、单例模式:权限控制里的“唯一管家”

3.1 是什么?

单例保证:不管你怎么 new、怎么调用,拿到的一定是同一个实例。

3.2 最简单的实现

// 懒汉式单例:用到的时候才创建
function createPermissionManager() {
  let instance = null;

  return function () {
    if (!instance) {
      instance = {
        role: 'guest',
        permissions: [],
        check(perm) {
          return this.permissions.includes(perm);
        },
        setRole(role, perms) {
          this.role = role;
          this.permissions = perms;
        }
      };
    }
    return instance;
  };
}

const getPermissionManager = createPermissionManager();
const pm1 = getPermissionManager();
const pm2 = getPermissionManager();
console.log(pm1 === pm2);  // true

3.3 用 class 实现(更贴近日常写法)

class PermissionManager {
  static instance = null;

  constructor() {
    if (PermissionManager.instance) {
      return PermissionManager.instance;
    }
    this.role = 'guest';
    this.permissions = [];
    PermissionManager.instance = this;
  }

  check(perm) {
    return this.permissions.includes(perm);
  }

  setRole(role, perms) {
    this.role = role;
    this.permissions = perms;
  }
}

// 无论怎么 new,都是同一个
const pm1 = new PermissionManager();
const pm2 = new PermissionManager();
console.log(pm1 === pm2);  // true

3.4 实际用法:按钮权限、路由守卫

// permission.js - 全局唯一的权限管理器
class PermissionManager {
  static instance = null;
  constructor() {
    if (PermissionManager.instance) return PermissionManager.instance;
    this.permissions = [];
    PermissionManager.instance = this;
  }

  init(perms) {
    this.permissions = perms;
  }

  has(perm) {
    return this.permissions.includes(perm);
  }

  // 用于 v-if 指令:<button v-if="permission.has('user:delete')">
  hasPermission(perm) {
    return () => this.has(perm);
  }
}

export const permission = new PermissionManager();

// 在路由守卫里用
// router.beforeEach((to, from, next) => {
//   if (to.meta.perm && !permission.has(to.meta.perm)) {
//     next('/403');
//     return;
//   }
//   next();
// });

要点:路由、按钮、接口都用同一个 permission 实例,权限数据统一维护,避免到处复制逻辑。

3.5 单例的坑

原因 建议
测试时状态残留 单例在测试间共享 提供 reset() 或在测试前 permission.permissions = []
滥用单例 不是全局唯一的东西也做成单例 只对真正“全局唯一”的用单例
忘了初始化 直接用 check 但没 init 在登录成功后统一 permission.init(perms)

四、发布订阅模式:消息通知解耦

4.1 是什么?

发布者发事件,订阅者订阅事件,彼此不直接依赖。发布者不关心谁在监听,订阅者不关心谁在发。

4.2 核心:EventBus

class EventBus {
  constructor() {
    this.events = {};  // { eventName: [fn1, fn2, ...] }
  }

  on(eventName, fn) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(fn);
  }

  off(eventName, fn) {
    if (!this.events[eventName]) return;
    this.events[eventName] = this.events[eventName].filter(cb => cb !== fn);
  }

  emit(eventName, ...args) {
    if (!this.events[eventName]) return;
    this.events[eventName].forEach(fn => fn(...args));
  }
}

4.3 实战:消息通知中心

// 通知中心:单例 + 发布订阅
class NotificationCenter {
  static instance = null;

  constructor() {
    if (NotificationCenter.instance) return NotificationCenter.instance;
    this.events = {};
    NotificationCenter.instance = this;
  }

  // 订阅某种类型的通知
  on(type, handler) {
    if (!this.events[type]) this.events[type] = [];
    this.events[type].push(handler);
  }

  off(type, handler) {
    if (!this.events[type]) return;
    this.events[type] = this.events[type].filter(h => h !== handler);
  }

  // 发布:触发所有订阅者
  emit(type, payload) {
    if (!this.events[type]) return;
    this.events[type].forEach(handler => handler(payload));
  }
}

const notify = new NotificationCenter();

// 业务 A:订单成功
notify.on('orderSuccess', (orderId) => {
  Toast.success(`订单 ${orderId} 创建成功`);
  // 可能还要更新购物车、统计等
});

// 业务 B:支付成功
notify.on('paymentSuccess', (data) => {
  Toast.success('支付成功');
  router.push('/orders');
});

// 某处触发
notify.emit('orderSuccess', 'ORD123');

4.4 完整示例:登录后多处联动

// 用户登录成功后,多个模块要同时反应
notify.on('loginSuccess', (user) => {
  // 模块 1:更新 header 头像
  header.updateAvatar(user.avatar);
});
notify.on('loginSuccess', (user) => {
  // 模块 2:拉取用户权限
  permission.init(user.permissions);
});
notify.on('loginSuccess', () => {
  // 模块 3:刷新待办数量
  todoBadge.refresh();
});

// 登录接口成功后,只发一次
loginApi().then(user => {
  notify.emit('loginSuccess', user);
});

发布者只管 emit,订阅者各自处理,互不依赖,修改一处不影响其他模块。

4.5 发布订阅的坑

原因 建议
内存泄漏 组件销毁后没 off beforeUnmount 里统一 off
事件名魔法字符串 到处写 'orderSuccess' 易 typo 抽成常量 EVENTS.ORDER_SUCCESS
过度解耦 简单父子通信也用 EventBus 能用 props/emit 就用,只在跨层、多对多时用
回调地狱 用事件代替 Promise 异步流程优先用 async/await,事件只做“通知”

五、策略模式:表单校验规则可插拔

5.1 是什么?

把不同校验规则封装成独立策略,用配置或映射表选择执行哪个,避免大段 if-else

5.2 没策略时:容易变成“面条码”

// 反面教材:每加一个规则就要改这里
function validate(value, rule) {
  if (rule === 'required') {
    return value !== '' && value != null;
  }
  if (rule === 'email') {
    return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value);
  }
  if (rule === 'phone') {
    return /^1\d{10}$/.test(value);
  }
  if (rule === 'minLength') {
    return value.length >= 6;
  }
  // 越加越多...
}

5.3 用策略重构

// 策略对象:每个规则是独立函数
const strategies = {
  required(value) {
    return value !== '' && value != null && String(value).trim() !== '';
  },
  email(value) {
    return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value);
  },
  phone(value) {
    return /^1\d{10}$/.test(value);
  },
  minLength(value, min) {
    return (value || '').length >= min;
  },
  maxLength(value, max) {
    return (value || '').length <= max;
  }
};

// 校验器:根据规则名调用对应策略
function validate(value, ruleName, ...ruleArgs) {
  const fn = strategies[ruleName];
  if (!fn) return true;  // 未知规则默认通过
  return fn(value, ...ruleArgs);
}

// 使用
validate('', 'required');           // false
validate('a@b.com', 'email');       // true
validate('12345', 'minLength', 6);  // false

5.4 实战:表单校验配置化

// 表单校验配置
const formRules = {
  username: [
    { strategy: 'required', message: '用户名不能为空' },
    { strategy: 'minLength', params: [3], message: '至少 3 个字符' }
  ],
  email: [
    { strategy: 'required', message: '邮箱不能为空' },
    { strategy: 'email', message: '邮箱格式不正确' }
  ],
  phone: [
    { strategy: 'phone', message: '手机号格式不正确' }
  ]
};

function validateForm(formData) {
  const errors = {};
  for (const [field, rules] of Object.entries(formRules)) {
    for (const rule of rules) {
      const { strategy, params = [], message } = rule;
      const value = formData[field];
      const valid = validate(value, strategy, ...params);
      if (!valid) {
        errors[field] = message;
        break;  // 一个字段只保留第一个错误
      }
    }
  }
  return { valid: Object.keys(errors).length === 0, errors };
}

// 使用
const result = validateForm({
  username: 'ab',
  email: 'invalid',
  phone: '13800138000'
});
console.log(result);
// { valid: false, errors: { username: '至少 3 个字符', email: '邮箱格式不正确' } }

新增字段或规则时,只改配置,不改 validate 核心逻辑。

5.5 策略的坑

原因 建议
策略与业务混在一起 策略里写请求、跳转 策略只做“规则判断”,返回 boolean
规则参数传错 minLength 要数字,传了字符串 做参数校验或封装成 createMinLength(6)
和单例搞混 策略不需要全局唯一 策略是无状态的纯函数,不共享实例

六、三者怎么选?

场景 推荐模式 理由
全局唯一:权限、配置、通知中心 单例 避免多处实例、状态不一致
一对多/多对多:登录联动、订单状态 发布订阅 解耦,扩展新监听者不改原有逻辑
多种规则:表单校验、支付方式、折扣计算 策略 规则可插拔,易维护和扩展

可以组合用,例如:单例的通知中心 + 发布订阅,或 策略模式 + 单例的校验器

七、小结

模式 一句话 典型用法
单例 全局只一个实例 权限管理器、通知中心、全局配置
发布订阅 发布事件、订阅处理,彼此解耦 消息通知、登录后联动、跨模块通信
策略 多种规则封装成可替换策略 表单校验、支付方式、折扣规则

记住三点:

  1. 单例只给“真正全局唯一”的东西用,并注意测试时重置。
  2. 发布订阅适合跨模块通知,简单通信优先用 props/emit,用完记得 off
  3. 策略模式把规则抽成独立函数,用配置驱动,方便扩展。

设计模式不是炫技,而是让代码更好改、更好测、更少 bug 的工具。先能用、再好用,逐步引入即可。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

手把手写几种常用工具函数:深拷贝、去重、扁平化

作者 SuperEugene
2026年2月24日 10:41

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

1. 开篇:有库可用,为什么还要自己写?

lodashramda 等库已经提供这些工具函数,但在面试、基础补强、和「读懂库源码」的场景里,手写一遍很有价值:

  • 搞清概念:什么算「深拷贝」、什么算「去重」
  • 踩一遍坑:循环引用、NaNDateRegExpSymbol
  • 形成习惯:知道什么时候用浅拷贝、什么时候必须深拷贝

下面按「深拷贝 → 去重 → 扁平化」的顺序,每种都给出可直接用的实现和说明。

2. 深拷贝

2.1 浅拷贝 vs 深拷贝,怎么选?

场景 推荐方式 原因
只改最外层、不改嵌套对象 浅拷贝({...obj}Object.assign 实现简单、性能好
需要改嵌套对象且不想影响原数据 深拷贝 避免引用共享
对象里有 DateRegExp、函数等 深拷贝时需特殊处理 否则会丢失类型或行为

一句话:只要会改到「嵌套对象/数组」,就考虑深拷贝。

2.2 常见坑

  1. 循环引用obj.a = obj,递归会栈溢出
  2. 特殊类型DateRegExpMapSetSymbol 不能只靠遍历属性复制
  3. Symbol 做 keyObject.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols

2.3 实现示例(含循环引用与特殊类型处理)

function deepClone(obj, cache = new WeakMap()) {
  // 1. 基本类型、null、函数 直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 2. 循环引用:用 WeakMap 缓存已拷贝对象
  if (cache.has(obj)) {
    return cache.get(obj);
  }

  // 3. 特殊对象类型
  if (obj instanceof Date) return new Date(obj.getTime());
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  if (obj instanceof Map) {
    const mapCopy = new Map();
    cache.set(obj, mapCopy);
    obj.forEach((v, k) => mapCopy.set(deepClone(k, cache), deepClone(v, cache)));
    return mapCopy;
  }
  if (obj instanceof Set) {
    const setCopy = new Set();
    cache.set(obj, setCopy);
    obj.forEach(v => setCopy.add(deepClone(v, cache)));
    return setCopy;
  }

  // 4. 普通对象 / 数组
  const clone = Array.isArray(obj) ? [] : {};
  cache.set(obj, clone);

  // 包含 Symbol 作为 key
  const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];
  keys.forEach(key => {
    clone[key] = deepClone(obj[key], cache);
  });

  return clone;
}

// 使用示例
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
original.self = original; // 循环引用
const cloned = deepClone(original);
cloned.b.c = 999;
console.log(original.b.c); // 2,原对象未被修改

要点:WeakMap 解决循环引用,Date/RegExp/Map/Set 单独分支,Object.getOwnPropertySymbols 保证 Symbol key 不丢失。

3. 去重

3.1 场景与选型

场景 方法 说明
基本类型数组(数字、字符串) Set 写法简单、性能好
需要兼容 NaN 自己写遍历逻辑 NaN !== NaNSet 能去重 NaN,但逻辑要显式写清楚
对象数组、按某字段去重 Mapfilter 用唯一字段做 key

3.2 几种实现

1)简单数组去重(含 NaN)

// 方式一:Set(ES6 最常用)
function uniqueBySet(arr) {
  return [...new Set(arr)];
}

// 方式二:filter + indexOf(兼容性更好,但 NaN 会出问题)
function uniqueByFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 方式三:兼容 NaN 的版本
function unique(arr) {
  const result = [];
  const seenNaN = false; // 用 flag 标记是否已经加入过 NaN
  for (const item of arr) {
    if (item !== item) { // NaN !== NaN
      if (!seenNaN) {
        result.push(item);
        seenNaN = true; // 这里需要闭包,下面用修正版
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

// 修正:用变量
function uniqueWithNaN(arr) {
  const result = [];
  let hasNaN = false;
  for (const item of arr) {
    if (Number.isNaN(item)) {
      if (!hasNaN) {
        result.push(NaN);
        hasNaN = true;
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

注意:Set 本身对 NaN 是去重的(ES2015 规范),所以 [...new Set([1, NaN, 2, NaN])] 结果正确。需要兼容 NaN 的,多是旧环境或面试题场景。

2)对象数组按某字段去重

function uniqueByKey(arr, key) {
  const seen = new Map();
  return arr.filter(item => {
    const k = item[key];
    if (seen.has(k)) return false;
    seen.set(k, true);
    return true;
  });
}

// 使用
const users = [
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 1, name: '张三2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]

4. 扁平化

4.1 场景

  • [1, [2, [3, 4]]] 变成 [1, 2, 3, 4]
  • 有时候需要「只扁平一层」或「扁平到指定层数」

4.2 实现

1)递归全扁平

function flatten(arr) {
  const result = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      result.push(...flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flatten([1, [2, [3, 4], 5]])); // [1, 2, 3, 4, 5]

2)指定深度扁平(如 Array.prototype.flat)

function flattenDepth(arr, depth = 1) {
  if (depth <= 0) return arr;

  const result = [];
  for (const item of arr) {
    if (Array.isArray(item) && depth > 0) {
      result.push(...flattenDepth(item, depth - 1));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flattenDepth([1, [2, [3, 4]]], 1)); // [1, 2, [3, 4]]
console.log(flattenDepth([1, [2, [3, 4]]], 2)); // [1, 2, 3, 4]

3)用 reduce 递归写法(另一种常见写法)

function flattenByReduce(arr) {
  return arr.reduce((acc, cur) => {
    return acc.concat(Array.isArray(cur) ? flattenByReduce(cur) : cur);
  }, []);
}

5. 小结:日常怎么选

函数 生产环境 面试 / 巩固基础
深拷贝 优先用 structuredClone(支持循环引用)或 lodash cloneDeep 自己实现,要处理循环引用和特殊类型
去重 基本类型用 [...new Set(arr)],对象用 Map 按 key 去重 要能解释 NaNindexOf 等细节
扁平化 用原生 arr.flat(Infinity) 手写递归或 reduce 版本

自己写一遍的价值在于:搞清楚边界情况、循环引用、特殊类型,以后选库或读源码时心里有数。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

字符串处理实战:模板字符串、split/join、正则的 80% 用法

作者 SuperEugene
2026年2月22日 23:05

前言

前端里接口参数拼接、搜索条件、富文本简单处理,几乎都绕不开字符串:拼 URL、拆 query替换/截断文案。很多人习惯用 + 拼到眼花,或者到处 indexOf/substring,写多了难维护也容易出 bug。
用**模板字符串split/join正则**这三类能力,可以把「替换/匹配」写得更短、更稳。本文用 10 个左右常见场景,把日常该怎么选、为什么这么选、容易踩的坑讲清楚,只讲 80% 会用到的部分,不求覆盖所有正则语法。

适合读者:

  • 会写 JS,但对模板字符串/正则什么时候用、怎么写有点模糊
  • 刚学 JS,希望一开始就养成清晰的字符串写法
  • 有经验的前端,想统一团队里的 URL 拼接、搜索条件、简单富文本处理

一、先搞清楚:模板字符串split/join正则分别在干什么

能力 在干什么 典型用法
模板字符串 `${} 把变量嵌进字符串,支持换行 URL、拼文案、多行字符串
split 按分隔符把字符串拆成数组 query 拆成键值对、按逗号/换行拆列表
join 把数组用分隔符拼成字符串 把参数数组拼成 query、把标签数组拼成文案
正则 按模式匹配、替换、提取 替换占位符、校验格式、简单富文本处理
// 传统 + 拼接:多参数时很难看
const url = baseUrl + '/api/user?id=' + id + '&name=' + encodeURIComponent(name);

// 模板字符串:一眼看出「URL 长什么样」
const url = `${baseUrl}/api/user?id=${id}&name=${encodeURIComponent(name)}`;

记住一点:能一眼看出「最终长什么样」就用模板字符串;要「按规则拆开或拼起来」就用 split/join;要「按模式匹配或替换」就用正则

二、模板字符串的常见用法

1. 接口 URLquery 拼接(模板字符串 + 一层编码)

应用场景

  • 你要调一个列表接口(比如商品列表、用户列表),需要把「搜索关键词」「页码」「每页显示多少条」这些信息拼在接口地址后面,比如拼出 ?keyword=张三&page=1&pageSize=10 这种格式。

先搞懂一个核心问题:为啥不能直接拼?

  • 就像咱们寄快递要写规范的地址(省 - 市 - 区 - 街道),URL(接口地址)也有自己的「书写规范」—— 有些字符(比如中文、空格、&、=)直接写进去,服务器会 “看不懂”,甚至理解错意思。

举个最直白的例子

你要搜「用户 输入」(关键词里有空格),如果直接拼地址:/api/list?keyword=用户 输入&page=1

服务器会把「空格」当成 “参数分隔符”,以为「keyword = 用户」是一个参数,「输入 & page=1」是另一个参数,直接解析错了!

const baseUrl = '/api/list'; // 接口基础地址
const params = {
  keyword: '用户 输入',  // 要搜索的关键词(有中文+空格,是“违规字符”)
  page: 1,              // 第1页
  pageSize: 10          // 每页显示10条
};

// ✅ 推荐写法:用 URLSearchParams 当“翻译官”(自动处理违规字符)
// 你可以把 URLSearchParams 理解成:专门处理URL参数的“小工具”
const query = new URLSearchParams({
  keyword: params.keyword,
  page: String(params.page),    // 这个小工具只认字符串,数字要转一下
  pageSize: String(params.pageSize),
}).toString(); // 把处理好的参数转成字符串

// 用模板字符串拼最终地址,结构一眼能看懂
const url = `${baseUrl}?${query}`;
console.log('自动处理后的地址:', url);
// 输出:/api/list?keyword=%E7%94%A8%E6%88%B7+%E8%BE%93%E5%85%A5&page=1&pageSize=10
// 你看:“用户 输入”被翻译成了 %E7%94%A8%E6%88%B7+%E8%BE%93%E5%85%A5,服务器能看懂了!

// ❌ 反面示例:直接拼(不翻译违规字符)—— 服务器看不懂
const badUrl1 = `${baseUrl}?keyword=${params.keyword}&page=${params.page}&pageSize=${params.pageSize}`;
console.log('直接拼的错误地址:', badUrl1);
// 输出:/api/list?keyword=用户 输入&page=1&pageSize=10(空格、中文没翻译,服务器解析错)

// ⚠️ 手动翻译写法(麻烦,容易漏):
// encodeURIComponent 就是“单个字符翻译器”,只能翻译一个参数值
const encodedKeyword = encodeURIComponent(params.keyword); // 只翻译关键词
const encodedPage = encodeURIComponent(params.page);       // 翻译页码
const encodedPageSize = encodeURIComponent(params.pageSize); // 翻译每页条数
const goodUrlByHand = `${baseUrl}?keyword=${encodedKeyword}&page=${encodedPage}&pageSize=${encodedPageSize}`;
console.log('手动翻译的正确地址:', goodUrlByHand);
// 输出和自动处理的一样,但要写3次 encodeURIComponent,参数多了容易漏!

更直观的表格说明

名词 小白版解释 什么时候用
encodeURIComponent 单个 URL 参数的 “翻译器”:把中文、空格这些服务器看不懂的字符,翻译成服务器能懂的 “编码”(比如把 “用户” 译成 % E7%94% A8% E6%88% B7 手动拼接 URL 参数时,给每个参数值单独翻译
URLSearchParams 批量处理 URL 参数的 “智能翻译机”:你把所有参数丢给它,它会自动调用 encodeURIComponent 给每个参数翻译,还能拼成规范的参数串 推荐优先用!不管参数多少,一次搞定,不翻车

关键注意点(小白必看)

  1. 只要参数里有中文空格&= 这些字符,就必须 “翻译”,否则接口会调失败 / 返回错误数据;
  2. URLSearchParams 是 “懒人神器”:不用记 encodeURIComponent 怎么写,不用怕漏翻译某个参数,丢进去就自动处理;
  3. 小细节:URLSearchParams 只认字符串,所以数字类型的参数(比如 page:1)要转成 String (page),否则会报错。

2. 搜索条件:有值才带参数(过滤掉空值再拼)

场景: 只有 keyword 有值才带 keyword,只有 status 有值才带 status,避免 ?keyword=&status= 这种无意义参数。

const baseUrl = '/api/search';
const search = {
  keyword: '张三',  // 有实际值
  status: '',       // 空值(无意义)
  type: '1',        // 有实际值
};

// 第一步:筛选出非空的参数(去掉空字符串、全空格、null/undefined)
// Object.entries:把对象拆成[key, value]的数组,方便批量检查
// filter:筛选器,只留满足条件的参数
// trim():去掉字符串前后空格(比如用户只输空格也算空值)
const filtered = Object.fromEntries(
  Object.entries(search).filter(([_, value]) => {
    // 条件:值不是null/undefined,且去掉空格后不是空字符串
    return value != null && String(value).trim() !== '';
  })
);

// 第二步:用URLSearchParams自动编码参数,转成query字符串
const query = new URLSearchParams(filtered).toString();

// 第三步:拼接最终URL(有参数加?,没参数直接用基础地址)
const url = query ? `${baseUrl}?${query}` : baseUrl;
// 最终结果:/api/search?keyword=%E5%BC%A0%E4%B8%89&type=1
// 对比:如果没过滤,会是 /api/search?keyword=%E5%BC%A0%E4%B8%89&status=&type=1(多了无用的status=)
);
const query = new URLSearchParams(filtered).toString();
const url = query ? `${baseUrl}?${query}` : baseUrl;
// /api/search?keyword=%E5%BC%A0%E4%B8%89&type=1

核心名词小白解释:

代码片段 通俗理解
Object.entries(search) {keyword:'张三', status:'', type:'1'}拆成[['keyword','张三'], ['status',''], ['type','1']],方便逐个检查值是否为空
Object.fromEntries(数组) 把筛选后的数组(比如[['keyword','张三'], ['type','1']])还原成对象{keyword:'张三', type:'1'}
value.trim() 去掉字符串前后的空格,比如' 张三 '变'张三',' '变空字符串(避免 “只输空格” 被当成有效值)
filter(...) 只保留 “非空” 的参数,把status:''这种空值过滤掉

适用: 列表筛选项、搜索表单、任何「按条件带参」的接口。


3. 多行字符串、拼文案(模板字符串天然支持换行)

场景: 弹窗文案、邮件正文、多行提示。

const userName = '李四';
const count = 3;

const message = `尊敬的 ${userName}:
您有 ${count} 条待处理消息,请及时查看。`;
// 换行、变量都保留,不用 \n 和 + 拼

三、split / join 的常见用法

1. 把 URL 上的 search 拆成对象(split + 一次遍历)

场景:?id=1&name=test 得到 { id: '1', name: 'test' }

const search = '?id=1&name=test';

// 推荐:直接用 URLSearchParams 解析(和上面「拼」对应)
const params = Object.fromEntries(new URLSearchParams(search));
// { id: '1', name: 'test' }

// 若不能用地道 API,再用 split
const params2 = search
  .replace(/^\?/, '')
  .split('&')
  .reduce((acc, pair) => {
    const [key, value] = pair.split('=');
    acc[decodeURIComponent(key)] = decodeURIComponent(value ?? '');
    return acc;
  }, {});

注意: 值里可能带 =,所以「按第一个 = 拆」更稳,这里用 split('=') 只适合简单 value;复杂 query 建议统一用 URLSearchParams


2. 把「逗号分隔的 id」拆成数组,再拼回去(split + join)

场景: 接口返回 ids: "1,2,3",要转成数组处理;提交时再拼成 "1,2,3"

const idsStr = '1,2,3';

const ids = idsStr.split(',').map((id) => id.trim()).filter(Boolean);
// ['1', '2', '3']

// 提交时再拼回去
const idsStrAgain = ids.join(',');
// '1,2,3'

注意: split(',') 后习惯加 .map(s => s.trim()).filter(Boolean),避免空串和前后空格。


3. 按换行拆成数组(split('\n'))

场景: 用户输入多行标签、多行关键词,一行一个。

const input = '  tag1  \ntag2\n  tag3  ';
const tags = input.split('\n').map((s) => s.trim()).filter(Boolean);
// ['tag1', 'tag2', 'tag3']

四、正则的 80% 用法(小白友好版:从基础到实战)

先搞懂:正则的 “基础积木”(小白版) 先记住这几个最常用的符号,就像搭积木一样,组合起来就能实现大部分匹配 / 替换需求:

符号 / 语法 小白版解释 举例子
/内容/ 正则的 “容器”,所有匹配规则都写在两个/之间 /abc/ 表示匹配字符串里的 abc
/内容/g g = global(全局),表示匹配所有符合规则的内容,不是只匹配第一个 'aaa'.replace(/a/g, 'b')bbb(不加 g 只替换第一个 a,变成 baa
\w 匹配「字母、数字、下划线」(简单记:匹配 “单词字符”) /\w+/能匹配 name123order_001
\d 匹配「单个数字」(0-9) /\d/匹配 5/\d\d/ 匹配88
+ 表示 “前面的规则至少出现 1 次” /\d+/ 匹配 1 个或多个数字(比如 1123
* 表示 “前面的规则出现 0 次或多次”(用得少,优先记+ /\d*/ 能匹配空字符串1123
{n} 表示 “前面的规则正好出现 n 次” /\d{10}/匹配正好 10 个数字
^ 匹配 “字符串的开头”(锚定开头) /^1/只匹配以 1开头的字符串(比如 1380000 能匹配,a138 不能)
$ 匹配 “字符串的结尾”(锚定结尾) /\d$/ 只匹配以数字结尾的字符串
[^>] [] 表示 “匹配其中任意一个字符”,^ [] 里表示 “排除” /[^>]+/匹配 “除了 > 之外的任意字符,至少 1 个”
() 捕获组:把匹配到的内容 “抓出来”,后续能用到 /\{(\w+)\}/ 里的 (\w+) 会把 {name} 里的 name 抓出来
有没有同学看不懂 /\{(\w+)\}/ 的?

看这里:

  • \ 是转义符:正则里想匹配 {} / [] / () 等特殊符号本身时,必须加\
  • /\{(\w+)\}/ 的核心是匹配 {xxx} 格式的字符串,其中:
    • \{ / \} 匹配普通的{}
    • (\w+) 抓出 {} 中间的字母 / 数字 / 下划线(比如 name);
  • 新手写正则时,只要想匹配 “特殊符号本身”,先加 \ 转义,就不会出错。

用法 1:占位符替换(把 {name} 换成真实值)

场景:服务端返回模板 " 您好,{name},您的订单{orderId}已发货 ",前端替换成当前用户和订单。 步骤拆解(小白能懂):

1. 规则/\{(\w+)\}/g 解析:

  • \{:匹配左大括号 {(因为 { 是正则特殊符号,要加\ 转义,告诉正则 “这就是普通的 {”);
  • (\w+):捕获组,匹配字母 / 数字 / 下划线(比如 nameorderId),并把匹配结果存起来;
  • \}:匹配右大括号 }
  • g:全局匹配,把所有 {xxx} 都找出来。

2. replace 回调函数:(_, key) => data[key] ?? ''

  • 第一个参数 _:表示整个匹配的内容(比如 {name}),用不到就用 _ 占位;
  • 第二个参数 key:就是捕获组 (\w+) 抓到的内容(比如 name);
  • data[key] ?? '':从 data 里取对应的值,没有就用空串填充。
const template = '您好,{name},您的订单{orderId}已发货';
const data = { name: '王五', orderId: 'ORD001' };

// 核心代码
const result = template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? '');
console.log(result); // 输出:'您好,王五,您的订单ORD001已发货'

// 小白试错:如果不加g,只会替换第一个占位符
const badResult = template.replace(/\{(\w+)\}/, (_, key) => data[key] ?? '');
console.log(badResult); // 输出:'您好,王五,您的订单{orderId}已发货'

用法 2:富文本简单处理:去掉 HTML 标签只留纯文本

**场景:**列表摘要只展示纯文本,需要把 <p>xxx</p> 里的 xxx 拿出来,或去掉所有标签。 规则 /<[^>]+>/g 解析

  • <:匹配左尖括号;
  • [^>]+:匹配 “除了>之外的任意字符,至少 1 个”(比如 pstrongdiv class="title");
  • >:匹配右尖括号;
  • g:全局替换,把所有标签都去掉。
const html = '<p>这是一段<strong>加粗</strong>的文字&nbsp;还有空格</p>';

// 第一步:去掉所有HTML标签
const textWithoutTag = html.replace(/<[^>]+>/g, '');
console.log(textWithoutTag); // 输出:'这是一段加粗的文字&nbsp;还有空格'

// 第二步:还原常见的HTML实体(比如&nbsp;换成空格)
const text = textWithoutTag
  .replace(/&nbsp;/g, ' ')  // 空格实体转空格
  .replace(/&lt;/g, '<')    // < 实体转 <
  .replace(/&gt;/g, '>');   // > 实体转 >
console.log(text); // 输出:'这是一段加粗的文字 还有空格'

⚠️ 重要提醒:这个规则只适合「简单、可控」的富文本(比如自己系统生成的短文本)。如果是复杂 HTML(比如带注释、<script>标签、属性里有>的),正则会失效,建议用 DOM 或专业库(如 cheerio)处理。

用法 3:富文本简单处理:限制摘要长度(截断 + 省略号)

场景:列表里摘要最多显示 20 个字符,超出用 ...。 (先去标签再截断,避免截到标签中间,比如把<p>这是一段很长的文字</p>截成 <p>这是一段很长的文,导致标签不闭合)

// 封装成通用函数,小白直接用
const getSummary = (html, maxLen = 20) => {
  // 第一步:先去标签和还原实体
  const pureText = html
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>');
  // 第二步:判断长度,截断加省略号
  if (pureText.length > maxLen) {
    return pureText.slice(0, maxLen) + '...';
  }
  return pureText;
};

// 测试
const longHtml = '<div>这是一段非常非常长的富文本内容,需要截断显示</div>';
console.log(getSummary(longHtml, 10)); // 输出:'这是一段非常非常长...'

用法 4:简单格式校验(手机号、纯数字)

场景:表单里「手机号」「纯数字」的简单校验,用 正则.test(要校验的字符串),返回 true/false

1. 手机号校验

规则 /^1\d{10}$/ 解析:

  • ^:字符串开头;
  • 1:第一个字符必须是 1(手机号开头都是 1);
  • \d{10}:后面跟正好 10 个数字;
  • $:字符串结尾; → 整体表示:整个字符串必须是 “1 + 10 个数字”,长度正好 11 位。
// 封装手机号校验函数
const isPhoneValid = (phone) => {
  // 先排除空值、非字符串情况
  if (!phone || typeof phone !== 'string') return false;
  return /^1\d{10}$/.test(phone);
};

// 测试
console.log(isPhoneValid('13800138000')); // true(正确手机号)
console.log(isPhoneValid('1380013800'));  // false(只有10位)
console.log(isPhoneValid('12345678901')); // false(开头不是1)
console.log(isPhoneValid('1380013800a')); // false(包含字母)

2. 纯数字校验

规则 /^1\d{10}$/ 解析:

  • ^:开头;
  • \d+:至少 1 个数字;
  • \d{10}:后面跟正好 10 个数字;
  • $:结尾; → 整体表示:整个字符串只能是数字,不能有其他字符,且不能为空。
// 封装纯数字校验函数
const isPureNumber = (str) => {
  if (!str) return false; // 空串返回false
  return /^\d+$/.test(str);
};

// 测试
console.log(isPureNumber('12345')); // true
console.log(isPureNumber('123a5')); // false(含字母)
console.log(isPureNumber(''));      // false(空串)
console.log(isPureNumber('0'));     // true(单个0也符合)

用法总结

  1. 正则小白不用记所有语法,先掌握 /内容/g\w/\d+/{n}^/$() 这几个核心符号,就能搞定大部分场景;
  2. 正则的核心用法分 3 类:替换(replace)、校验(test)、提取(match),其中替换和校验是日常用得最多的;
  3. 写正则时,先拆解 “要匹配什么 / 排除什么”,再用基础符号组合,优先加 g(全局)、^/``$`(整串匹配)避免漏匹配 / 错匹配;
  4. 复杂 HTML 处理别用正则,优先用 DOM 或专业库,正则只适合简单片段。

五、容易踩的坑

1. 模板字符串里要算表达式,用 ${} 包起来

const a = 1, b = 2;
const wrong = `${a} + ${b} = a + b`;   // '1 + 2 = a + b'
const right = `${a} + ${b} = ${a + b}`; // '1 + 2 = 3'

2. query 里的中文、空格、特殊字符必须编码

const name = '张 三';
const bad = `/api?name=${name}`;  // 空格和中文会破坏 URL
const good = `/api?name=${encodeURIComponent(name)}`;
// 或统一用 URLSearchParams

3. split 不传参时按每个字符拆

'abc'.split();   // ['abc']
'abc'.split(''); // ['a','b','c']

要按「分隔符」拆就明确传参,例如 split(',')split('\n')


4. 空字符串 split 得到的是 ['']

''.split(',');   // ['']
''.split(',').filter(Boolean); // []

拼 query、拼列表前若可能为空,先判断或 filter(Boolean),避免出现 ?key= 或末尾多余逗号。


5. 正则「去标签」不能覆盖所有 HTML 情况

// 像 <div class="a"> 这种可以匹配
// 但 <script>...</script>、注释、属性里的 > 等,正则容易出错

仅用于「自己能控制的、结构简单的」富文本片段;其它用 DOM 或专业库。

六、实战推荐写法模板

接口 GET 参数拼接(带空值过滤):

const baseUrl = '/api/list';
const params = { keyword: '...', page: 1, pageSize: 10, status: '' };
const query = new URLSearchParams(
  Object.fromEntries(
    Object.entries(params).filter(([_, v]) => v != null && String(v).trim() !== '')
  )
).toString();
const url = query ? `${baseUrl}?${query}` : baseUrl;

从当前页 search 取参数:

const params = Object.fromEntries(new URLSearchParams(location.search));
const keyword = params.keyword ?? '';

逗号分隔字符串 ↔ 数组:

const toIds = (s) => (s ?? '').split(',').map((id) => id.trim()).filter(Boolean);
const toStr = (arr) => (arr ?? []).filter(Boolean).join(',');

简单占位符替换:

const fillTemplate = (template, data) =>
  template.replace(/\{(\w+)\}/g, (_, key) => data[key] ?? '');

富文本摘要(去标签 + 截断):

const toSummary = (html, maxLen = 20) => {
  const text = html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ');
  return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
};

七、小结

场景 推荐写法
拼 URL、拼文案、多行字符串 模板字符串 `${base}?${query}`
拼/解析 query URLSearchParams + 模板字符串 或 split/reduce
有值才带参 filterURLSearchParams,再拼到 URL
逗号/换行拆成数组 split(',') / split('\n') + trim + filter(Boolean)
数组拼成字符串 join(',')
占位符替换 {key} replace(/\{(\w+)\}/g, (_, key) => data[key])
简单去 HTML 标签 replace(/<[^>]+>/g, '')(仅简单片段)
摘要截断 先去标签再 slice(0, len) + '...'
简单格式校验 /^1\d{10}$/.test(phone)

记住:拼用模板字符串 + URLSearchParams,拆用 split/URLSearchParams,替换/匹配用正则。日常写接口参数、搜索条件、简单富文本时,先想清楚是「拼、拆、还是替换/校验」,再选对应方式,代码会清晰很多,也少踩编码和空值的坑。

特别提醒:

  • query 里的中文和特殊字符一定要编码(URLSearchParamsencodeURIComponent)。
  • 空数组/空字符串在 split/join 时要考虑 filter(Boolean) 和「是否带问号」。
  • 正则只用于简单、可控的富文本;复杂 HTML 用 DOM 或专门库。

以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

对象数组的排序与分组:sort / localeCompare / 自定义 compare

作者 SuperEugene
2026年2月18日 13:56

日常开发里,列表、表格、统计几乎都绕不开「对象数组」的排序和分组。本文不讲底层原理,只讲怎么选、为什么选、容易踩哪些坑。适合会写 JS 但概念有点混的同学,也适合想补齐基础的前端老手。

一、Array.sort 到底在干什么

1.1 三个关键点

要点 说明
原地排序 sort() 会直接修改原数组,不会返回新数组
默认行为 不传比较函数时,按字符串逐个字符比较
compare 返回值 负数:a 排前面;0:不变;正数:b 排前面

有没有同学会有这样的疑问:compare 返回值?这是啥? 解释:

  • 这里的 compare 指的是 Array.sort() 方法中传入的比较函数(也就是你后面写的 (a, b) => a - b 这种形式)。
  • 简单说:当你用sort()排序时,传入的这个函数就是 compare,它的作用是告诉 sort() 两个元素(ab)该怎么排,返回值直接决定排序结果,和表格里的说明完全对应。
  • 比如 nums.sort((a, b) => a - b) 中,(a, b) => a - b 就是 compare 比较函数。

1.2 第一个坑:数字数组直接用 sort

const nums = [10, 2, 1];
nums.sort(); // 这一步已经把原数组 nums 改了!以为会得到 [1, 2, 10]
console.log(nums); // 打印的是被修改后的原数组,不是初始值。实际得到 [1, 10, 2] —— 按字符串 "10"、"2"、"1" 比较了!
// ✅ 正确写法
nums.sort((a, b) => a - b);   // 升序 [1, 2, 10]
nums.sort((a, b) => b - a);   // 降序 [10, 2, 1]

:为什么按字符串比较会得到 [1, 10, 2]? sort() 默认的字符串比较规则是「逐字符按 Unicode 码点比较」,不是看数字大小,步骤拆解如下:

  1. 先把数组里的数字都转成字符串:10→"10"、2→"2"、1→"1";
  2. 从第一个字符开始比,字符的 Unicode 码点:"1"(码点 49)< "2"(码点 50);
  3. 具体比较过程:
    • 比较 "1" 和 "10":第一个字符都是 "1"(码点相同),但 "1" 没有第二个字符,所以 "1" < "10";
    • 比较 "10" 和 "2":第一个字符 "1" < "2",所以 "10" < "2"

1.3 第二个坑:原数组被改了

const original = [3, 1, 2];
const sorted = original.sort((a, b) => a - b);

console.log(sorted);   // [1, 2, 3]
console.log(original); // [1, 2, 3] —— 原数组也被改了!

// ✅ 需要保留原数组时,先浅拷贝再排序
const sorted2 = [...original].sort((a, b) => a - b);

二、对象数组按不同字段排序

2.1 按数字排序

const users = [
  { name: '张三', age: 25 },
  { name: '李四', age: 18 },
  { name: '王五', age: 30 }
];

// 按 age 升序
users.sort((a, b) => a.age - b.age);
// 结果:李四(18) → 张三(25) → 王五(30)

// 按 age 降序
users.sort((a, b) => b.age - a.age);

写法记忆:升序 a - b,降序 b - a

2.2 按字符串排序

// 按 name 字母/拼音顺序
users.sort((a, b) => a.name.localeCompare(b.name));

直接用 a.name > b.name ? 1 : -1 可以工作,但遇到中文、大小写、多语言时容易出问题,所以更推荐 localeCompare,后面会细讲。

2.3 按日期排序

日期有两种常见形式:字符串和时间戳。

const orders = [
  { id: 1, date: '2025-02-15' },
  { id: 2, date: '2025-01-20' },
  { id: 3, date: '2025-02-10' }
];

// 方式一:YYYY-MM-DD 格式的字符串可以直接用 localeCompare
orders.sort((a, b) => a.date.localeCompare(b.date));

// 方式二:转时间戳(适用各种日期格式)
orders.sort((a, b) => new Date(a.date) - new Date(b.date));

建议:后端返回的日期如果是 YYYY-MM-DD,用 localeCompare 即可;格式不统一时,统一用 new Date() 转时间戳再比较。

2.4 多字段排序

先按 A 排序,A 相同再按 B 排序,可以用 || 链式比较:

users.sort((a, b) => {
  if (a.age !== b.age) return a.age - b.age;  // 先按年龄
  return a.name.localeCompare(b.name);        // 年龄相同再按姓名
});

// 更简洁的写法
users.sort((a, b) => a.age - b.age || a.name.localeCompare(b.name));

原理a.age - b.age 为 0 时,0 || xxx 会取后面的 localeCompare 结果。

三、localeCompare:字符串排序的正确姿势

3.1 为什么不用 >、< 比较字符串?

const arr = ['张三', '李四', '王五', 'apple', 'Apple'];
arr.sort((a, b) => a > b ? 1 : -1);  // 按 Unicode 比较,中文结果不符合直觉
arr.sort((a, b) => a.localeCompare(b));  // 按语言规则,更符合人类习惯

localeCompare 可以:

  • 中文按拼音
  • 控制大小写敏感
  • 数字按数值比较(如 "10" 在 "2" 后面)

3.2 常用用法

// 指定语言(中文按拼音)
'张三'.localeCompare('李四', 'zh-CN');  // 负数,张在李后面

// 忽略大小写
'apple'.localeCompare('Apple', undefined, { sensitivity: 'base' });  // 0,视为相等

// 数字按数值比较
['10', '2', '1'].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
// 结果:['1', '2', '10']

3.3 兼容性说明

现代浏览器和 Node 都支持 localeCompare。带 options 配置的 localeCompare 写法,在老环境(旧浏览器 / 旧 Node 版本)中可能表现不一致,生产环境建议先小范围验证。

// 忽略大小写(options:{ sensitivity: 'base' })
'apple'.localeCompare('Apple', undefined, { sensitivity: 'base' });
// 数字按数值比较(options:{ numeric: true })
['10','2'].sort((a,b) => a.localeCompare(b, undefined, { numeric: true }));

老环境问题:像旧版 IE、低版本 Node(比如 Node.js 10 以下),对这些options配置支持不完善(比如不识别numeric: true),导致排序结果出错,所以生产环境要先小范围验证。

3.4 补充localeCompareoptions写法 老环境兼容技巧

核心兼容思路:降级处理——先判断环境是否支持localeCompareoptions配置,支持则用带options的简洁写法,不支持则降级为基础写法,保证排序效果一致,且代码简单可直接套用(无需额外引入兼容库)。

场景1:忽略大小写排序(对应options: { sensitivity: 'base' })音标:/sensəˈtɪvəti/

老环境兼容写法(适配旧IE、低版本Node):

// 兼容函数:忽略大小写比较两个字符串
function compareIgnoreCase(a, b) {
  // 先统一转小写,再用基础localeCompare(老环境均支持无options写法)
  const lowerA = a.toLowerCase();
  const lowerB = b.toLowerCase();
  return lowerA.localeCompare(lowerB, 'zh-CN'); // 中文场景可加语言标识
}

// 用法(和带options写法效果一致)
const arr = ['apple', 'Apple', 'Banana', 'banana'];
arr.sort(compareIgnoreCase); // 结果:['apple', 'Apple', 'Banana', 'banana']

场景2:数字字符串按数值排序(对应options: { numeric: true }

老环境兼容写法(避免老环境不识别numeric 音标:/njuːˈmerɪk/ 配置导致排序错乱):

// 兼容函数:数字字符串按数值排序
function compareNumericStr(a, b) {
  // 降级思路:转成数字比较(贴合原文数字排序逻辑,老环境完全支持)
  const numA = Number(a);
  const numB = Number(b);
  return numA - numB; // 升序,降序则改为numB - numA
}

// 用法(和带options写法效果一致)
const arr = ['10', '2', '1', '25'];
arr.sort(compareNumericStr); // 结果:['1', '2', '10', '25']

关键注意点

  • 无需判断环境:上述兼容写法兼容所有环境(老环境正常运行,新环境也不影响效果),不用额外写环境判断代码,简化开发。

  • 生产环境验证:如果老环境占比极低,可直接用带options写法,上线前用老环境(如IE11、Node.js 8)简单测试1个排序案例即可。

四、分组统计:从排序到 groupBy 【分组】

排序和分组是两个不同操作:

  • 排序:改变顺序,不拆分数组
  • 分组:按某个字段把数组拆成多组

JS 没有内置 groupBy,可以用 reduce 实现:

const orders = [
  { id: 1, status: 'paid', amount: 100 },
  { id: 2, status: 'pending', amount: 50 },
  { id: 3, status: 'paid', amount: 200 }
];

const byStatus = orders.reduce((acc, item) => {
  const key = item.status;
  if (!acc[key]) acc[key] = [];
  acc[key].push(item);
  return acc;
}, {});

// 结果:
// {
//   paid: [{ id: 1, ... }, { id: 3, ... }],
//   pending: [{ id: 2, ... }]
// }

分组后再排序

分组后,如果每组内部还要排序:

Object.keys(byStatus).forEach(key => {
  byStatus[key].sort((a, b) => b.amount - a.amount);  // 每组按金额降序
});

分组 + 统计

需要同时统计每组数量或汇总值时:

const stats = orders.reduce((acc, item) => {
  const key = item.status;
  if (!acc[key]) {
    acc[key] = { list: [], total: 0, count: 0 };
  }
  acc[key].list.push(item);
  acc[key].total += item.amount;
  acc[key].count += 1;
  return acc;
}, {});

// 结果示例:{ paid: { list: [...], total: 300, count: 2 }, ... }

五、踩坑速查表

坑点 错误表现 正确写法
数字数组排序错乱 [10, 2, 1].sort()[1, 10, 2] arr.sort((a, b) => a - b)
原数组被修改 排序后原数组也变了 [...arr].sort(...)
中文排序不对 直接用 >< 比较 a.localeCompare(b, 'zh-CN')
多字段排序只写了一层 只按第一个字段排 a.age - b.age || a.name.localeCompare(b.name)
日期格式不统一 字符串比较出错 new Date(a.date) - new Date(b.date)

六、小结

  1. 数字排序:用 (a, b) => a - bb - a,不要用默认 sort()
  2. 字符串排序:优先用 localeCompare,尤其是中文和多语言场景。
  3. 日期排序YYYY-MM-DDlocaleCompare,其他格式用时间戳。
  4. 多字段排序:用 || 串联多个比较。
  5. 分组:用 reducegroupBy,再按需对每组排序或统计。
  6. 保留原数组:排序前先 [...arr] 浅拷贝。

这些写法足够覆盖大部分日常需求,记住上面的速查表,可以少踩很多坑。


以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

数组查找与判断:find / some / every / includes 的正确用法

作者 SuperEugene
2026年2月17日 09:31

今天是2026年2月17日农历正月初一,在2026 愿大家:身体健康无病痛,收入翻番钱包鼓! 代码 0 Error 0 Warning,需求一次过,上线零回滚!策马扬鞭,从小白进阶专家,新年一路 “狂飙”!🧧🐎 给大家拜年啦~

前言

前端里权限判断、表单校验、勾选状态,几乎都要判断「数组里有没有某个值」或「是否全部满足条件」。很多人习惯用 for 循环 + if 一把梭,或者 indexOf 判断,写多了既啰嗦又容易漏边界情况。
find / some / every / includes 这四个方法,可以把「查找 → 判断 → 校验」写得更短、更语义化,也更好处理边界情况。本文用 10 个常见场景,把日常该怎么选、为什么这么选、容易踩的坑讲清楚。

适合读者:

  • 会写 JS,但对 find/some/every/includes 用哪个、什么时候用有点模糊
  • 刚学 JS,希望一开始就养成清晰的数组判断写法
  • 有经验的前端,想统一团队里的权限/校验/状态判断写法

一、先搞清楚:find / some / every / includes 在干什么

这四个方法都不是黑魔法,本质是:在不动原数组的前提下,用一次遍历完成「查找 / 判断是否存在 / 判断是否全部满足」

方法 在干什么 返回值 什么时候停
find 找第一个符合条件的元素 找到的元素,找不到返回 undefined 找到第一个就停
some 判断是否至少有一个满足条件 truefalse 找到第一个就停(短路)
every 判断是否全部满足条件 truefalse 遇到第一个不满足就停(短路)
includes 判断数组里是否包含某个值(严格相等) truefalse 遍历完或找到就停
// 传统 for:意图分散,还要自己管 break
let found = null;
for (let i = 0; i < users.length; i++) {
  if (users[i].id === targetId) {
    found = users[i];
    break;
  }
}

// find:一眼看出「找第一个 id 匹配的」
const found = users.find((u) => u.id === targetId);

记住一点:能用语义化方法就不用循环,用 find/some/every/includes 把「要查什么、要判断什么」写清楚,比「怎么循环、怎么 break」更重要。

二、数组查找与判断的 10 个常用场景

假设接口返回的数据类似:

const users = [
  { id: 1, name: '张三', role: 'admin', status: 'active' },
  { id: 2, name: '李四', role: 'user', status: 'active' },
  { id: 3, name: '王五', role: 'user', status: 'inactive' },
];

const permissions = ['read', 'write', 'delete'];
const selectedIds = [1, 2];

下面 10 个写法,覆盖权限判断、表单校验、勾选状态等真实场景。

场景 1:找第一个符合条件的对象(find

const admin = users.find((user) => user.role === 'admin');
// { id: 1, name: '张三', role: 'admin', status: 'active' }

// 找不到返回 undefined
const superAdmin = users.find((user) => user.role === 'superAdmin');
// undefined

适用: 默认选中第一项、取第一个有效配置、根据 id 找对象等。
注意: find 找不到返回 undefined,后续解构或访问属性要处理,用 ?? 给默认值。

场景 2:判断是否至少有一个满足条件(some

const hasAdmin = users.some((user) => user.role === 'admin');
// true

const hasInactive = users.some((user) => user.status === 'inactive');
// true

适用: 权限判断「是否有任一管理员」、表单校验「是否有错误项」、状态判断「是否有未完成项」等。
注意: 空数组时 some 返回 false,业务上要结合「空列表算通过还是不算」处理。

场景 3:判断是否全部满足条件(every

const allActive = users.every((user) => user.status === 'active');
// false(因为有王五是 inactive)

const allHaveId = users.every((user) => user.id != null);
// true

适用: 表单校验「是否全部勾选」、权限判断「是否全部有权限」、状态判断「是否全部完成」等。
注意: 空数组时 every 返回 true(空真),业务上要结合「空列表算通过还是不算」处理。

场景 4:判断数组是否包含某个值(includes

const hasRead = permissions.includes('read');
// true

const hasExecute = permissions.includes('execute');
// false

适用: 简单值数组的包含判断、权限列表判断、标签列表判断等。
注意: includes 底层用 严格相等=== 做比较,这对「简单值(string / number / boolean)」很友好,但对「对象 / 数组」这类引用类型完全不适用,因为===比较的是内存地址而非内容。

场景 5:权限判断:是否有某个权限(some + includes

const userPermissions = ['read', 'write'];
const requiredPermission = 'delete';

const hasPermission = userPermissions.includes(requiredPermission);
// false

// 或判断多个权限中是否有任一
const requiredPermissions = ['delete', 'admin'];
const hasAnyPermission = requiredPermissions.some((perm) => 
  userPermissions.includes(perm)
);
// false

适用: 按钮权限控制、路由权限控制、功能权限判断等。
推荐: 简单值用 includes,复杂条件用 some + 回调。

场景 6:表单校验:是否全部必填项已填(every

const formFields = [
  { name: 'username', value: '张三', required: true },
  { name: 'email', value: '', required: true },
  { name: 'phone', value: '13800138000', required: false },
];

const allRequiredFilled = formFields
  .filter((field) => field.required)
  .every((field) => field.value.trim() !== '');
// false(email 为空)

适用: 表单提交前校验、批量操作前校验、多步骤流程校验等。
推荐:filter 筛出必填项,再用 every 判断是否全部有值。

场景 7:勾选状态:是否全部选中(every

const checkboxes = [
  { id: 1, checked: true },
  { id: 2, checked: true },
  { id: 3, checked: false },
];

const allChecked = checkboxes.every((item) => item.checked);
// false

const hasChecked = checkboxes.some((item) => item.checked);
// true

适用: 全选/反选功能、批量操作按钮状态、表格多选状态等。
推荐: every 判断全选,some 判断是否有选中项。

场景 8:找第一个并给默认值(find+ ??

const defaultUser = users.find((user) => user.role === 'admin') ?? {
  id: 0,
  name: '默认用户',
  role: 'guest',
};

适用: 默认选中第一项、取第一个有效配置、兜底默认值等。
注意: find 找不到返回 undefined,用 ?? 可以统一成默认对象,避免后面解构报错。

场景 9:对象数组是否包含某个 id(some

const targetId = 2;
const exists = users.some((user) => user.id === targetId);
// true

// 或判断多个 id 中是否有任一存在
const targetIds = [2, 5];
const hasAny = targetIds.some((id) => users.some((user) => user.id === id));
// true(2 存在)

适用: 判断选中项是否在列表里、判断 id 是否已存在、去重前判断等。
注意: 对象数组不能用 includes,要用 some + 条件判断。

场景 10:组合判断:全部满足 A 且至少一个满足 B(every +some

const allActive = users.every((user) => user.status === 'active');
const hasAdmin = users.some((user) => user.role === 'admin');

// 业务逻辑:全部激活 且 有管理员
const canOperate = allActive && hasAdmin;
// false(因为有 inactive 的)

适用: 复杂业务规则判断、多条件组合校验、权限组合判断等。
推荐: 把每个条件拆成变量,用名字表达「这一步在判断什么」,可读性和调试都会好很多。

三、容易踩的坑

1. find 找不到返回 undefined,直接解构会报错

const user = users.find((u) => u.id === 999);
const { name } = user; // TypeError: Cannot read property 'name' of undefined

正确:?? 给默认值,或先判断再解构。

const user = users.find((u) => u.id === 999) ?? { name: '未知' };
// 或
const user = users.find((u) => u.id === 999);
if (user) {
  const { name } = user;
}

2. 空数组时 every 返回 truesome 返回 false

[].every((x) => x > 0); // true(空真)
[].some((x) => x > 0);  // false

业务上要结合「空列表算通过还是不算」处理。例如表单校验,空列表可能应该算「未填写」而不是「通过」。

const fields = [];
const allFilled = fields.length > 0 && fields.every((f) => f.value);
// 先判断长度,再 every

3. includes 只能判断简单值,对象数组要用 some

const users = [{ id: 1 }, { id: 2 }];
users.includes({ id: 1 }); // false(对象引用不同)

// 正确:用 some + 条件判断
users.some((user) => user.id === 1); // true

4. findfilter 的区别:find 只找第一个,filter 找全部

const firstAdmin = users.find((u) => u.role === 'admin');
// 返回第一个对象或 undefined

const allAdmins = users.filter((u) => u.role === 'admin');
// 返回数组,可能为空数组 []

要「第一个」用 find,要「全部」用 filter,别混用。

5. someevery 的短路特性:找到就停

const users = [
  { id: 1, role: 'admin' },
  { id: 2, role: 'user' },
  { id: 3, role: 'admin' },
];

// some:找到第一个 admin 就停,不会继续遍历
users.some((u) => {
  console.log(u.id); // 只打印 1
  return u.role === 'admin';
});

// every:遇到第一个不是 admin 就停
users.every((u) => {
  console.log(u.id); // 打印 1, 2(遇到 user 就停)
  return u.role === 'admin';
});

性能上这是好事,但如果有副作用(如打印、修改外部变量),要注意只执行到第一个匹配项。

四、实战推荐写法模板

权限判断(是否有某个权限):

const userPermissions = response?.data?.permissions ?? [];
const canDelete = userPermissions.includes('delete');

// 或判断多个权限中是否有任一
const canManage = ['delete', 'admin'].some((perm) => 
  userPermissions.includes(perm)
);

表单校验(是否全部必填项已填):

const fields = formData?.fields ?? [];
const isValid = fields
  .filter((field) => field.required)
  .every((field) => field.value?.trim() !== '');

// 或更严格的校验
const isValid = fields.length > 0 && 
  fields.filter((f) => f.required).every((f) => f.value?.trim() !== '');

勾选状态(全选/部分选中):

const items = tableData ?? [];
const allChecked = items.length > 0 && items.every((item) => item.checked);
const hasChecked = items.some((item) => item.checked);

// 全选按钮状态
const selectAllDisabled = items.length === 0;
const selectAllChecked = allChecked;

找第一个并给默认值:

const defaultItem = (response?.data?.list ?? []).find(
  (item) => item.isDefault
) ?? {
  id: 0,
  name: '默认选项',
  value: '',
};

对象数组是否包含某个 id:

const selectedIds = [1, 2, 3];
const targetId = 2;
const isSelected = selectedIds.includes(targetId);

// 对象数组
const users = response?.data?.users ?? [];
const targetId = 2;
const exists = users.some((user) => user.id === targetId);

五、小结

场景 推荐写法 返回值
找第一个符合条件的对象 list.find(item => ...) 对象或 undefined
判断是否至少有一个满足 list.some(item => ...) truefalse
判断是否全部满足 list.every(item => ...) truefalse
判断是否包含某个值(简单值) list.includes(value) truefalse
找第一个并给默认值 list.find(...) ?? 默认值 对象或默认值
对象数组是否包含某个 id list.some(item => item.id === id) truefalse
表单校验:全部必填已填 list.filter(...).every(...) truefalse
勾选状态:全部选中 list.every(item => item.checked) truefalse

记住:find 负责「找」,some 负责「至少一个」,every 负责「全部」,includes 负责「简单值包含」。日常写权限、校验、状态判断时,先想清楚是要找对象、判断存在、判断全部,还是简单值包含,再选方法,代码会干净很多,也少踩坑。

特别提醒:

  • find 找不到返回 undefined,记得用 ?? 给默认值
  • 空数组时 everytruesomefalse,业务上要结合长度判断
  • 对象数组不能用 includes,要用 some + 条件判断

文章到这里结束。如果你日常写权限判断、表单校验、勾选状态时经常纠结用哪个方法,希望这篇能帮你定个型。

以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

《对象与解构赋值:接口数据解包的 10 个常见写法》

作者 SuperEugene
2026年2月15日 19:14

前言

后台接口返回的数据,常常是嵌套对象或数组,很多人习惯一层层 data.user.name 这样写,既啰嗦又容易在某一层是 undefined 时直接报错。
解构赋值 + 默认值,可以把取数写得又短又安全。本文用 10 个常见写法,帮你把「接口数据解包」这件事理清楚。

适合读者:

  • 已经会写 JS,但对解构、默认值组合用法不熟
  • 刚学 JS,希望一开始就养成规范写法
  • 有一定经验,想统一团队里的接口数据处理方式

一、先搞清楚:解构在干什么

解构不是黑魔法,本质是按结构从对象/数组中「拆包」出变量,语法更短,逻辑更直观。

// 传统写法:手动挨个取值
const user = { name: '张三', age: 28, city: '北京' };
const name = user.name;
const age = user.age;

// 解构写法:一次性拆出来
const { name, age } = user;

如果接口返回的 user 某天变成 null,传统写法会在 user.name 直接报错,解构可以配合默认值一起用,后面会展开。

二、接口数据解包的 10 个常见写法

假设后台返回结构类似:

{
  code: 200,
  data: {
    user: {
      id: 1,
      name: '李四',
      profile: {
        avatar: 'https://xxx/avatar.png',
        bio: '前端工程师'
      }
    },
    list: [
      { id: 1, title: '文章1' },
      { id: 2, title: '文章2' }
    ]
  }
}

下面 10 个写法,都是日常会用到的。

写法 1:只解构第一层,其余用 rest 收走

const { user, list, ...rest } = response.data;
// user、list 单独用,其他字段在 rest 里

适用: 只需要其中几个字段,但不想丢掉其他字段。
注意: rest 不会包含已解构的 userlist

写法 2:解构 + 默认值,防止 undefined

const { user = {}, list = [] } = response.data || {};

适用: 接口可能返回 datanullundefined,或字段缺失。
注意: 默认值只在值为 undefined 时生效,null 不会触发默认值。

写法 3:多层嵌套一次解构

const { user: { profile: { avatar, bio } = {} } = {} } = response.data || {};

适用: 需要深层字段,不想写 data.user.profile.avatar
踩坑: 每一层都要给默认值 = {},否则中间某层是 undefined 会报错。

写法 4:解构时重命名,避免变量冲突

const { user: currentUser, list: articleList } = response.data || {};

适用: 接口字段名不直观,或和已有变量重名。
语法: 原属性名: 新变量名

写法 5:解构 + 默认值 + 重命名一起用

const { user: currentUser = {}, list: articleList = [] } = response.data || {};

适用: 既要改名,又要防缺。
推荐: 作为接口数据解包的常规写法,可读性和安全性都较好。

写法 6:数组解构取首项

const [firstItem] = response.data?.list || [];

适用: 列表只关心第一项(例如「最新一条」)。
注意: 用可选链 ?.|| [] 避免 listnull/undefined 时报错。

ps· 如果你不知道可选链请点击这里,一文让你轻松了解

写法 7:解构数组元素并设默认值

const [first = {}, second = {}] = response.data?.list || [];

适用: 需要前几项,且要保证拿到的一定是对象。
注意: 空数组时 firstsecond 都是 {}

写法 8:在 map 中解构,简化遍历

const titles = (response.data?.list || []).map(({ id, title }) => title);

适用: 列表只需部分字段,不想写 item.iditem.title
好处: 代码短,意图清晰。

写法 9:解构函数参数,配合默认值

function renderUser({ name = '游客', avatar = '/default.png' } = {}) {
  // 函数内部直接用 name、avatar
}
renderUser(response.data?.user); // 即使传入 undefined 也不报错

适用: 组件、工具函数接收配置对象时。
双重默认值:

  • = {}:整个参数缺失时
  • name = '游客'name 缺失时

写法 10:安全取出深层字段的「一层层解构」写法

const { data } = response || {};
const { user } = data || {};
const { profile } = user || {};
const { avatar } = profile || {};

// 或者一行(每层都要默认值)
const avatar = ((response || {}).data || {}).user?.profile?.avatar ?? '默认头像';

适用: 接口结构不稳定,或经常变更。
建议: 优先用可选链 ?. 和空值合并 ??,逻辑更简洁。

三、容易踩的坑

1. 默认值只对 undefined 生效

const { name = '默认' } = { name: null };
// name 是 null,不是 '默认'

需要兼容 null 时,用空值合并运算符 ??

const name = (obj.name ?? '默认');

2. 嵌套解构少了中间层的默认值

const { user: { profile } } = response.data;  // 若 user 为 undefined,直接报错
const { user: { profile } = {} } = response.data;  // 依然可能报错,user 本身可能 undefined
const { user: { profile } = {} } = response.data || {};  // 正确:两层都要有兜底

3. 解构赋值和变量声明混在一起

const obj = { a: 10 };

// ✅ 正确:声明+解构一步完成({}是声明语法的一部分,解析器认解构)
let {a} = obj; 

let b;
// {b} = obj; // ❌ 报错:语句开头的{}被解析为“块级作用域”,而非解构
({b} = obj);  // ✅ 正确:括号让{}变成表达式,解析器认解构

4. 把 rest 用在已解构过的属性上

const obj = { a: 1, b: 2, c: 3 };
// 解构:单独取出a,剩余属性打包到rest
const { a, ...rest } = obj;

console.log(a);    // 输出:1(单独提取的a)
console.log(rest); // 输出:{ b: 2, c: 3 }(rest不含已解构的a)

四、实战推荐写法模板

通用接口解包:

const response = {
  code: 200,
  msg: "请求成功",
  data: {
    user: {
      name: "张三",
      age: 25,
      profile: {
        avatar: "https://example.com/avatar.jpg"
      }
    },
    list: [
      { id: 1, title: "文章1", content: "内容1" },
      { id: 2, title: "文章2", content: "内容2" }
    ]
  }
};

// 1. 最外层兜底:避免response/null/undefined导致解构报错
const { data = {} } = response || {};
// 2. 解构data层:给user/List设默认值,避免属性不存在
const { user = {}, list = [] } = data;

// 3. 深层解构user:给profile兜底,避免profile为undefined时报错
const { name, profile: { avatar } = {} } = user;

// 4. 列表解构:只提取需要的id/title,过滤无用字段
const items = list.map(({ id, title }) => ({ id, title }));

// 输出结果(验证解构效果)
console.log(name);   // 张三
console.log(avatar); // https://example.com/avatar.jpg
console.log(items);  // [{id:1,title:"文章1"}, {id:2,title:"文章2"}]

封装成工具函数:

function parseUserResponse(response) {
  const { data: { user = {} } = {} } = response || {};
  const { name = '未知', profile: { avatar = '/default.png' } = {} } = user;
  return { name, avatar };
}

五、小结

场景 推荐写法
防缺 const { a = {} } = obj || {}
嵌套解构 每一层都写 = {} 兜底
需要改名 const { a: newName } = obj
取列表首项 const [first] = list || []
列表 map list.map(({ id, title }) => ...)
函数参数 ({ a = 1 } = {}) 双重默认值

记住一点:解构是语法糖,默认值是兜底,把两者结合起来,接口数据处理会干净很多,也更容易排查问题。


以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

❌
❌