阅读视图

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

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 机制),这样才能灵活运用到实际业务中,甚至根据需求修改优化。建议结合示例代码亲手运行,感受每个细节的作用,加深理解。

JS手撕:性能优化、渲染技巧与定时器实现

在前端开发中,我们经常会遇到「大量数据渲染卡顿」「频繁事件触发导致性能损耗」「自定义定时逻辑」等问题,今天就来拆解7个高频实用的JS代码片段,用「通俗话术+专业解析」的方式,讲懂每一行代码的作用、核心原理和实际应用场景,帮你吃透这些前端必备技能。

一、万条数据渲染优化:避免卡顿的核心技巧

前端渲染大量数据(比如10万条)时,直接一次性插入DOM会导致主线程阻塞,页面出现卡顿、掉帧,甚至浏览器崩溃。这段代码的核心思路是「分批渲染+文档片段+requestAnimationFrame」,从根源上减少DOM操作带来的性能损耗。

核心代码(带详细注释)

// 延迟0ms执行,让DOM先渲染完毕,避免阻塞主线程
// 这里的setTimeout(fn, 0)不是真的延迟,而是把回调放到宏任务队列,等待当前同步代码和DOM渲染完成后执行
setTimeout(() => {
  // 总共需要渲染10万条数据(实际开发中可根据需求调整)
  const total = 100000;
  // 每一批次渲染20条,防止一次性渲染过多导致卡顿
  // 批次大小可优化:一般建议20-50条,过多仍会卡顿,过少则渲染次数过多
  const once = 20;
  // 计算总共需要分多少批次(向上取整,避免最后一批数据不足20条被遗漏)
  const loopCount = Math.ceil(total / once);
  // 记录当前已经渲染了多少批次,用于判断是否渲染完成
  let countOfRender = 0;
  // 获取页面中的ul容器,所有li都会插入到这个容器中
  const ul = document.querySelector('ul');

  // 每一批次添加DOM的函数:核心是「减少DOM操作次数」
  function add() {
    // 创建文档片段(DocumentFragment),临时存放当前批次的li
    // 重点:fragment不属于页面DOM树,向它添加子元素不会触发页面重排重绘,相当于“临时仓库”
    const fragment = document.createDocumentFragment();

    // 循环生成当前批次的20个li
    for (let i = 0; i < once; i++) {
      const li = document.createElement('li');
      // 给li填充随机数字(实际开发中可替换为真实业务数据,比如列表项内容)
      li.innerText = Math.floor(Math.random() * total);
      // 先放入片段中,此时不触发任何页面渲染
      fragment.appendChild(li);
    }

    // 一次性将20条li插入ul,只触发一次DOM重排(关键优化点)
    // 对比:如果每次循环都appendChild到ul,会触发20次重排,性能极差
    ul.appendChild(fragment);
    // 已渲染批次+1,更新渲染进度
    countOfRender += 1;

    // 继续执行下一批渲染
    loop();
  }

  // 控制渲染节奏:使用requestAnimationFrame,跟随浏览器刷新频率执行
  // 浏览器每秒刷新约60次(16.67ms/帧),requestAnimationFrame会在每帧开始时执行回调
  // 好处:避免渲染操作与浏览器刷新冲突,保证页面流畅不卡顿
  function loop() {
    // 如果还没渲染完所有批次,就继续下一批
    if (countOfRender < loopCount) {
      window.requestAnimationFrame(add);
    }
  }

  // 启动第一轮渲染
  loop();
}, 0);

关键知识点解析

  • setTimeout(fn, 0):不是延迟执行,而是将回调推入宏任务队列,确保当前同步代码和DOM初始化完成后再执行,避免DOM未挂载时查询不到ul容器。

  • 分批渲染:将10万条数据拆分为5000批次(100000/20),每次只渲染20条,降低单次DOM操作的压力。

  • DocumentFragment:前端性能优化神器,作为临时DOM容器,所有操作都在内存中完成,最后一次性插入页面,只触发1次重排重绘,比直接操作真实DOM性能提升10倍以上。

  • requestAnimationFrame:与setTimeout相比,它能跟随浏览器刷新频率执行,避免“掉帧”,尤其适合大量DOM渲染、动画等场景,保证页面流畅度。

实际应用场景

大数据列表渲染(如后台管理系统的订单列表、日志列表)、长列表滚动加载(结合滚动事件,滚动到底部时加载下一批),避免一次性渲染大量数据导致页面卡死。

二、手撕防抖:解决频繁触发的“性能杀手”

防抖(Debounce)的核心逻辑:频繁触发同一事件时,只在最后一次触发后,延迟指定时间执行回调函数。比如搜索框输入、窗口resize、滚动事件,频繁触发会导致性能损耗,防抖能有效“合并”触发次数。

1. 基础版防抖(延迟执行)

// 防抖:频繁触发时,只在**最后一次触发后延迟执行**
function debounce(callback, wait) {
  // 定时器标识:用闭包保存,避免污染全局变量,且能在多次调用中共享状态
  let timer = null;

  // 返回一个可调用的包装函数,接收原函数的参数
  return function (...args) {
    // 保存原this(解决事件回调中this指向window的问题,比如btn点击事件中this应指向btn)
    const context = this;

    // 再次触发时,清除之前的定时器 → 重新计时(核心:取消上一次的延迟执行)
    if (timer) clearTimeout(timer);

    // 新建定时器:延迟 wait 毫秒后执行回调
    timer = setTimeout(() => {
      // 恢复原函数的this指向和参数,保证回调函数执行时上下文正确
      callback.apply(context, args);
      // 执行完清空timer,方便垃圾回收,避免内存泄漏
      timer = null;
    }, wait);
  };
}

2. 完整版防抖(支持立即执行/延迟执行)

基础版防抖是“延迟执行”(触发后等待wait时间再执行),但实际开发中有时需要“立即执行”(第一次触发就执行,之后频繁触发不执行,直到wait时间后才可再次执行),比如按钮点击防重复提交。

// 完整版防抖:支持 立即执行(immediate=true) / 延迟执行(immediate=false)
function debounce(callback, wait, immediate) {
  let timer = null; // 闭包缓存定时器,共享状态

  return function () {
    // 每次进入先清除上一次定时器 → 重新计时(无论立即还是延迟,都要取消上一次)
    if (timer) clearTimeout(timer);

    // ========== 立即执行模式 ==========
    if (immediate) {
      // timer为null时表示可以立即执行(首次触发或wait时间已过)
      const callNow = !timer;

      // 设置定时器:wait时间后把timer置空,解锁“立即执行”权限
      // 作用:这段时间内再次触发,callNow会为false,不会执行回调
      timer = setTimeout(() => {
        timer = null;
      }, wait);

      // 满足立即执行条件时,调用原函数,恢复this和参数
      if (callNow) {
        callback.apply(this, arguments);
      }

    // ========== 常规延迟执行模式 ==========
    } else {
      // 延迟wait时间执行,每次触发都重置定时器
      timer = setTimeout(() => {
        callback.apply(this, arguments);
        timer = null; // 执行后清空,垃圾回收
      }, wait);
    }
  };
}

关键知识点解析

  • 闭包的作用:保存timer变量,让多次触发的事件能共享同一个定时器标识,实现“清除上一次定时器”的逻辑,这是防抖的核心。

  • this指向修复:事件回调中this默认指向window(如addEventListener中的回调),通过context = this + apply(context, args),让原函数this指向正确的元素(如按钮、输入框)。

  • 立即执行vs延迟执行

    • 立即执行(immediate=true):适合防重复提交(按钮点击后立即执行,wait时间内不可再次点击);

    • 延迟执行(immediate=false):适合搜索框联想(输入停止后wait时间,再发送请求,避免频繁请求接口)。

实际应用场景

搜索框输入联想、窗口resize事件(调整窗口大小时,避免频繁计算布局)、滚动事件(滚动到底部加载更多,避免频繁触发)、按钮防重复提交。

三、手撕节流:固定频率执行,避免过度触发

节流(Throttle)的核心逻辑:频繁触发同一事件时,按照固定的时间间隔执行回调函数,无论触发多少次,都不会超过这个频率。和防抖的区别:防抖是“最后一次触发后执行”,节流是“固定频率持续执行”。

1. 立即触发版节流(停止触发后不执行最后一次)

// 节流:固定频率执行,立即触发,停止触发后不执行最后一次
function throttle(callback, wait) {
  // 上一次执行回调的时间戳(初始为0,确保第一次触发能立即执行)
  let previous = 0;

  return function(...args) {
    // 获取当前时间戳
    const now = Date.now();
    // 核心逻辑:当前时间 - 上一次执行时间 >= 等待时间,才执行回调
    if (now - previous >= wait) {
      // 执行回调,恢复this和参数
      callback.apply(this, args);
      // 更新上一次执行时间戳为当前时间,开始下一个周期
      previous = now;
    }
  };
}

2. 延迟触发版节流(停止触发后仍执行最后一次)

立即触发版节流的问题:如果停止触发时,距离上一次执行已经超过wait时间,不会执行最后一次触发的回调。延迟触发版可以解决这个问题,适合需要“收尾”的场景(如滚动加载,即使停止滚动,也要执行最后一次加载逻辑)。

// 节流:固定频率执行,延迟触发,停止触发后仍执行最后一次
function throttle(callback, wait) {
  let timer = null; // 用定时器控制延迟执行

  return function(...args) {
    const context = this;
    // 核心逻辑:没有定时器才创建,不重置计时(保证固定频率)
    if (!timer) {
      timer = setTimeout(() => {
        // 延迟wait时间执行回调
        callback.apply(context, args);
        // 执行后清空定时器,允许下一次创建
        timer = null;
      }, wait);
    }
  };
}

关键知识点解析

  • 时间戳版(立即触发):通过对比当前时间和上一次执行时间,控制执行频率,优点是简单高效,缺点是停止触发后不会执行最后一次。

  • 定时器版(延迟触发):通过定时器控制执行时机,优点是停止触发后仍会执行最后一次,缺点是首次触发会延迟wait时间才执行。

  • 防抖vs节流

    • 防抖:合并多次触发,只执行最后一次(比如搜索输入);

    • 节流:控制触发频率,固定间隔执行(比如滚动加载、鼠标移动绘制)。

实际应用场景

滚动事件(监听滚动位置,固定频率更新导航栏状态)、鼠标移动事件(绘制canvas,避免频繁重绘)、resize事件(固定频率调整页面布局)、高频点击事件(如游戏中的攻击按钮,控制点击频率)。

四、自定义定时器:可递增延迟的MySetInterval

原生setInterval的缺点:间隔时间固定,无法实现“每次执行延迟递增”的需求(比如第一次延迟100ms,第二次延迟200ms,第三次延迟300ms...)。这段代码通过类封装,实现了“基础延迟+递增步长”的自定义定时器,灵活满足复杂定时需求。

核心代码(带详细注释)

class MySetInterval {
  /**
   * @param {Function} fn 要执行的函数(回调函数)
   * @param {number} base 基础延迟 a(第一次执行的延迟时间)
   * @param {number} step 每次递增 b(每次执行的延迟比上一次多b ms)
   * @param  {...any} args 传递给 fn 的参数(可选)
   */
  constructor(fn, base, step, ...args) {
    this.fn = fn;         // 要执行的回调函数
    this.base = base;     // 基础延迟时间(ms)
    this.step = step;     // 延迟递增步长(ms)
    this.args = args;     // 传递给回调函数的参数
    this.count = 0;       // 记录执行次数(用于计算当前延迟)
    this.timer = null;    // 定时器ID,用于停止定时器
  }

  // 启动定时器
  start() {
    // 计算当前批次的延迟:a + count * b(第一次count=0,延迟a;第二次count=1,延迟a+b,以此类推)
    const delay = this.base + this.count * this.step;

    // 用setTimeout模拟递归执行,实现“递增延迟”
    this.timer = setTimeout(() => {
      // 执行用户传入的回调函数,并传递参数
      this.fn(...this.args);
      // 执行次数+1,为下一次延迟计算做准备
      this.count++;
      // 递归调用start,启动下一次定时执行
      this.start();
    }, delay);
  }

  // 停止定时器(必须有,避免内存泄漏)
  stop() {
    // 清除当前定时器
    clearTimeout(this.timer);
    // 清空timer,方便垃圾回收,也避免重复停止
    this.timer = null;
  }
}

// 使用示例
const timer = new MySetInterval(() => {
  console.log('自定义定时器执行');
}, 100, 50); // 第一次延迟100ms,第二次150ms,第三次200ms...
timer.start(); // 启动
// timer.stop(); // 停止(需要时调用)

关键知识点解析

  • 类封装优势:通过class封装,将定时器的状态(count、timer、base等)挂载到实例上,避免全局变量污染,同时方便调用start和stop方法,逻辑更清晰。

  • 递增延迟实现:通过count记录执行次数,每次执行后count+1,下一次延迟 = 基础延迟 + count * 递增步长,实现“每次延迟递增”的效果。

  • 递归setTimeout:没有使用原生setInterval,而是用setTimeout递归调用start方法,避免setInterval可能出现的“时间漂移”(比如回调执行时间过长,导致下一次执行延迟偏差)。

实际应用场景

倒计时递增(比如活动倒计时,后期每秒增加延迟,营造紧迫感)、轮播图渐变(每次切换的延迟递增,实现慢放效果)、接口重试(失败后重试,每次重试延迟递增,避免频繁请求接口)。

五、重写setTimeout:用requestAnimationFrame模拟

原生setTimeout的缺点:执行时间不精确,受主线程阻塞影响(比如主线程有耗时操作,setTimeout的回调会延迟执行)。而requestAnimationFrame(rAF)会跟随浏览器刷新频率执行(16.67ms/帧),用它模拟setTimeout,能让延迟执行更精确,同时避免主线程阻塞导致的偏差。

核心代码(带详细注释)

// 用 requestAnimationFrame 模拟 setTimeout,提升执行精度
let setTimeout = (fn, timeout, ...args) => {
  const start = Date.now(); // 记录定时器启动的时间戳
  let timer;               // 保存rAF的标识,用于取消定时器

  // 循环执行函数,每帧检查是否达到设定的延迟时间
  const loop = () => {
    // 注册下一次rAF回调,保证循环执行,跟随浏览器刷新频率
    timer = window.requestAnimationFrame(loop);
    // 获取当前时间戳
    const now = Date.now();

    // 核心逻辑:当前时间 - 启动时间 >= 设定的延迟时间,执行回调
    if (now - start >= timeout) {
      // 执行用户传入的回调函数,恢复this和参数
      fn.apply(this, args);
      // 执行完成后,取消rAF循环,避免无限执行
      window.cancelAnimationFrame(timer);
    }
  };

  // 启动rAF循环,开始计时
  window.requestAnimationFrame(loop);
};

// 使用示例(和原生setTimeout用法一致)
setTimeout(() => {
  console.log('用rAF模拟的setTimeout执行');
}, 1000);

关键知识点解析

  • 精度提升原理:原生setTimeout的延迟是“最小延迟”,如果主线程忙碌,回调会被推迟;而rAF每帧(16.67ms)执行一次loop,每次都检查时间差,一旦达到设定延迟就执行回调,精度更高。

  • 循环终止:通过cancelAnimationFrame(timer)取消rAF循环,避免回调执行后仍继续循环,造成性能损耗。

  • 用法兼容:模拟后的setTimeout用法和原生一致,无需修改现有代码,直接替换即可提升执行精度。

实际应用场景

需要精确延迟执行的场景(如动画同步、定时更新DOM)、避免主线程阻塞导致延迟偏差的场景(如复杂页面中的定时任务)。

六、模拟sleep函数:让代码“暂停”指定时间

JS中没有原生的sleep函数(即“暂停代码执行指定时间,再继续执行后面的代码”),但可以通过Promise+setTimeout模拟。核心思路:返回一个Promise,在setTimeout延迟后resolve,通过await等待Promise完成,实现代码“暂停”效果。

核心代码(带详细注释)

// 休眠函数:等待 time 毫秒后,再继续执行后面的代码
// 核心:通过Promise包裹setTimeout,用resolve触发后续代码执行
function sleep(time) {
  // 返回一个Promise对象,pending状态表示“正在休眠”
  return new Promise(function (resolve) {
    // 定时 time 毫秒后,执行resolve(),让Promise变为fulfilled状态
    // resolve()无参数,仅用于通知“休眠结束”
    setTimeout(resolve, time);
  });
}

// 使用示例(必须配合async/await,因为await只能在async函数中使用)
async function test() {
  console.log('开始执行');
  await sleep(2000); // 暂停2000ms(2秒)
  console.log('2秒后执行'); // 2秒后才会打印这句话
  await sleep(1000); // 再暂停1秒
  console.log('再1秒后执行');
}

关键知识点解析

  • Promise的作用:用Promise包裹setTimeout,将“延迟执行”转化为“异步等待”,配合await使用,实现代码的“线性暂停”,避免回调地狱。

  • async/await依赖:sleep函数返回Promise,必须在async函数中用await调用,才能实现“暂停”效果;如果不用await,代码会继续执行,不会暂停。

  • 非阻塞特性:sleep是异步暂停,不会阻塞主线程,其他异步任务(如接口请求、DOM渲染)可以在sleep期间正常执行,避免页面卡顿。

实际应用场景

代码分步执行(如引导页步骤切换,每步间隔1秒)、接口请求重试(失败后sleep1秒再重试)、模拟加载动画(sleep指定时间后隐藏加载框)。

七、版本号对比:实现语义化版本排序

在前端开发中,经常需要对版本号进行排序(如npm包版本、项目版本),版本号格式通常为“x.y.z”(如1.0.0、2.3.4、1.10.2),直接字符串排序会出现错误(如1.10.2会排在1.2.0前面),这段代码能正确对比版本号大小并排序。

核心代码(带详细注释)

// 版本号对比排序:接收版本号数组,返回按升序排列的数组
var compareVersion = function (versions) {
  // 用数组的sort方法排序,核心是自定义排序规则
  return versions.sort((version1, version2) => {
    // 1. 将版本号按“.”分割,转为数字数组(如"1.10.2" → [1,10,2])
    // map(Number):将分割后的字符串转为数字,避免字符串比较的误差
    let s1 = version1.split('.').map(Number);
    let s2 = version2.split('.').map(Number);

    // 2. 逐位对比版本号(从左到右,依次对比主版本、次版本、修订版本)
    // 循环次数取两个版本号数组的最大长度,不足的位补0(如1.0 → [1,0,0])
    for (let i = 0; i < s1.length || i < s2.length; i++) {
      const v1 = s1[i] || 0; // 版本1当前位的值,无则补0
      const v2 = s2[i] || 0; // 版本2当前位的值,无则补0
      // 若当前位不相等,直接返回差值(正数表示v1>v2,负数表示v1<v2)
      if (v1 !== v2) {
        return v1 - v2;
      }
    }

    // 3. 若所有位都相等,按原字符串排序(处理版本号格式完全一致的情况)
    return version1.localeCompare(version2);
  })
};

// 使用示例
const versions = ['1.10.2', '1.2.0', '2.3.4', '1.0.0', '1.5'];
console.log(compareVersion(versions)); 
// 输出:["1.0.0", "1.2.0", "1.5", "1.10.2", "2.3.4"]

关键知识点解析

  • 版本号分割与转数字:split('.')将版本号分割为数组,map(Number)转为数字数组,避免“10”作为字符串比“2”小的问题(字符串比较是按字符编码,'10' < '2')。

  • 逐位对比逻辑:从左到右对比每一位版本号,先对比主版本(第一位),主版本大的版本号更大;主版本相等则对比次版本(第二位),以此类推;不足的位补0(如1.5 → 1.5.0)。

  • 兜底逻辑:若所有位都相等(如1.0.0和1.0.0),用localeCompare按字符串排序,保证排序的稳定性。

实际应用场景

npm包版本排序、后台管理系统的版本日志排序、APP版本更新提示(对比当前版本和最新版本,判断是否需要更新)。

总结

以上7个代码片段,覆盖了前端开发中「性能优化」「事件处理」「定时任务」「版本对比」四大核心场景,每段代码都包含“核心逻辑+详细注释+知识点解析+实际应用”,既能直接复制使用,也能帮你理解背后的原理。

重点记住:前端性能优化的核心是「减少DOM操作」「避免频繁触发事件」「合理利用异步」,而防抖、节流、分批渲染、rAF都是实现这些目标的关键手段;自定义定时器、sleep、版本对比则是解决实际业务场景的实用工具,掌握这些,能让你的代码更高效、更健壮。

JS手撕:DOM操作 & 浏览器API高频场景详解

在前端开发中,我们经常会遇到一些重复且基础的需求——比如解析URL参数、给大量元素绑定点击事件、实现图片懒加载等。这些功能看似简单,但写得不够严谨就容易出现bug(比如中文参数乱码、事件绑定冗余、滚动加载卡顿)。

今天就整理了7个前端高频实用JS功能,用“通俗话+专业解析”的方式,把每个功能的原理、代码细节和使用场景讲透,还附上了可直接复制使用的优化版代码,新手也能快速套用。

一、URL参数解析:把URL里的参数“拆”成可直接用的对象

通俗解读

我们经常会看到这样的URL:https://xxx.com/list?page=1&size=10&keyword=前端,里面的page、size、keyword就是参数。这个功能就是把这些参数“拆出来”,变成一个对象(比如{page:1, size:10, keyword:"前端"}),不用我们手动去切割字符串,省心又不易错。

专业解析

核心利用浏览器原生的URL对象,它能自动解析URL的协议(http/https)、主机(xxx.com)、路径(/list)和查询参数(?后面的内容);再配合URLSearchParams处理查询参数,同时解决中文乱码(用decodeURIComponent解码)、空值、重复key、数字类型转换等常见问题。

完整代码(可直接复制)

function parseParam(url) {
  // 创建URL对象,自动解析协议、域名、路径、参数(原生API,无需手动切割)
  const urlObj = new URL(url);
  // 获取查询参数部分(即?后面的内容,不含?)
  const queryParams = new URLSearchParams(urlObj.search);
  const paramsObj = {};

  // 遍历所有查询参数,处理各种边界情况
  for (let [key, value] of queryParams.entries()) {
    // 空值处理:如果参数值是空字符串或null,统一赋值为true(比如?flag&name=xxx,flag对应true)
    if (value === '' || value == null) {
      paramsObj[key] = true;
    } else {
      // 解码参数:处理中文、特殊字符(比如%E5%89%8D%E7%AB%AF解码为“前端”)
      let val = decodeURIComponent(value);
      // 纯数字字符串转为数字类型(比如"123"转为123,避免后续使用时还要手动转换)
      val = /^\d+$/.test(val) ? parseFloat(val) : val;

      // 重复key处理:如果参数有多个相同key(比如?tag=js&tag=html),转为数组形式
      if (paramsObj.hasOwnProperty(key)) {
        paramsObj[key] = [].concat(paramsObj[key], val);
      } else {
        paramsObj[key] = val;
      }
    }
  }

  return paramsObj;
}

// 示例使用
const url = "https://api.example.com/data?id=123&name=张三&page=2&tag=js&tag=html&flag";
const params = parseParam(url);
console.log(params);
// 输出:{id: 123, name: "张三", page: 2, tag: ["js", "html"], flag: true}

关键注意点

  • URL对象是浏览器原生API,无需引入第三方库,兼容现代浏览器(IE不支持,需兼容IE可额外处理);

  • 中文参数必须用decodeURIComponent解码,因为URL中中文会被自动编码为%开头的字符;

  • 重复key(如?tag=js&tag=html)处理为数组,避免后面的参数覆盖前面的。

二、事件委托:一次绑定,搞定所有子元素的事件

通俗解读

如果页面有100个按钮,给每个按钮都绑定点击事件,会占用很多内存,而且新增按钮还得重新绑定。事件委托就是“找一个父容器”,只给父容器绑定一次事件,不管里面有多少个子元素(哪怕是后来新增的),点击子元素时都会触发父容器的事件,再通过判断点击的是哪个子元素,执行对应逻辑。

专业解析

利用DOM事件的“冒泡机制”(子元素的事件会向上传递给父元素),将事件绑定在父容器上,通过event.target获取真正被点击的子元素,再通过matches()方法匹配目标元素选择器,实现“一次绑定,多元素复用”,优化性能并简化代码。

完整代码(可直接复制)

/**
 * 事件委托(代理)
 * @param {string} eventType 事件类型 click/input 等(比如"click"、"input")
 * @param {string|Element} elDelegate 委托父元素(选择器字符串或DOM对象)
 * @param {string} selector 真正要触发的目标元素选择器(比如"#btn"、".item")
 * @param {Function} fn 触发的回调函数(this指向目标元素)
 */
function on(eventType, elDelegate, selector, fn) {
  // 1. 处理委托父元素:如果传入的是选择器字符串,自动转为DOM对象
  if (!(elDelegate instanceof Element) && typeof elDelegate === 'string') {
    elDelegate = document.querySelector(elDelegate);
  }

  // 安全判断:如果没找到父元素,直接退出,避免报错
  if (!elDelegate) return null;

  // 2. 给父元素绑定事件,利用事件冒泡机制
  elDelegate.addEventListener(eventType, (e) => {
    let el = e.target; // 真正被点击/触发的元素(子元素)

    // 3. 向上查找匹配selector的元素(防止点击的是子元素的子节点)
    while (el && !el.matches(selector)) {
      if (el === elDelegate) { // 查到委托父元素还没匹配到,说明不是目标元素,停止查找
        el = null;
        break;
      }
      el = el.parentNode; // 向上查找父级节点
    }

    // 4. 如果找到目标元素,执行回调,this指向目标元素
    el && fn.call(el, e, el);
  });

  return elDelegate;
}

// HTML示例
/*

*/

// 示例使用1:单个目标元素
on('click', '#box', '#btn', function(e, el){
  console.log('点击成功!');
  console.log(this); // this 指向 #btn(目标元素)
  console.log(el); // el 也是目标元素,和this一致
});

// 示例使用2:多个目标元素(.item)
on('click', '#box', '.item', function(e, el){
  console.log('点击了item按钮:', el.innerText);
});

关键注意点

  • 委托父元素必须是目标元素的祖先节点(比如按钮的父div、body、document);

  • 不要阻止事件冒泡(e.stopPropagation()),否则事件无法传递到父元素,委托失效;

  • 新增的子元素(比如通过JS动态添加的按钮),无需重新绑定事件,会自动触发委托的事件。

三、滚动加载:滚动到底部自动加载更多内容

通俗解读

我们刷朋友圈、逛电商列表时,往下滚动页面,到底部后会自动加载更多内容,这就是滚动加载。核心就是“判断页面是否滚动到底部”,如果到了,就执行加载数据的逻辑。

专业解析

通过监听window的scroll事件,获取三个关键高度:可视区域高度(屏幕能看到的页面高度)、滚动条已滚动距离、页面总高度(包括看不见的部分)。核心判断公式:可视区域高度 + 已滚动距离 ≥ 页面总高度,满足该条件即表示滚动到底部。

完整代码(可直接复制)

// 监听滚动事件
window.addEventListener('scroll', function() {
    // 1. 可视区域高度(屏幕能看到的高度,不同设备可能不同)
    const clientHeight = document.documentElement.clientHeight;
    // 2. 滚动条卷上去的高度(已滚动的距离,兼容不同浏览器)
    const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
    // 3. 整个页面总高度(包括看不见的部分,即页面完整高度)
    const scrollHeight = document.documentElement.scrollHeight;

    // 核心判断:可视高度 + 已滚动距离 ≥ 总高度 → 滚动到底部(加10是为了提前加载,优化体验)
    if (clientHeight + scrollTop >= scrollHeight - 10) {
        console.log("滚动到底部啦!");
        // 这里写加载更多逻辑(比如请求接口、渲染列表)
        // loadMoreData(); // 假设这是加载更多数据的函数
    }
}, false);

// 优化建议:滚动事件会频繁触发,可结合节流函数(参考后面的图片懒加载),避免性能消耗
// 比如:window.addEventListener('scroll', throttle(handleScroll, 300), false);

关键注意点

  • scroll事件会频繁触发(滚动过程中每秒触发几十次),建议结合节流函数(后面会讲),减少函数执行次数,优化性能;

  • 滚动到底部的判断可加一个小偏移量(比如-10),让加载提前触发,避免用户看到底部空白再加载;

  • 加载数据时,建议添加“加载中”状态,防止用户多次触发加载。

四、图片懒加载:减少页面加载时间,提升体验

通俗解读

页面有很多图片时,如果一打开就加载所有图片,会导致页面加载变慢、卡顿。图片懒加载就是“只加载屏幕能看到的图片”,用户往下滚动页面,图片进入视野后再加载,既节省带宽,又提升页面加载速度。

专业解析

核心思路:先给图片设置自定义属性(比如data-src)存储真实图片地址,src属性设为占位图(或空);监听scroll事件,判断图片是否进入可视区域,若进入,则将data-src的值赋给src,实现图片加载。同时用节流函数限制scroll事件触发频率,避免性能消耗。

完整代码(可直接复制)

// 节流函数:限制函数在指定时间内只能执行一次(优化scroll事件频繁触发)
function throttle(fn, delay) {
  let timer = null;
  return function (...args) {
    if (!timer) {
      fn.apply(this, args); // 执行函数
      timer = setTimeout(() => {
        timer = null; // 延迟后重置timer,允许下次执行
      }, delay);
    }
  };
}

// 图片懒加载核心函数
function lazyload() {
  const imgs = document.getElementsByTagName('img'); // 获取所有图片元素(注意加s,避免报错)
  const viewHeight = document.documentElement.clientHeight; // 可视区域高度
  // 滚动距离(兼容不同浏览器)
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  // 遍历所有图片,判断是否进入可视区域
  for (let i = 0; i < imgs.length; i++) {
    const img = imgs[i];
    const offsetTop = img.offsetTop; // 图片到页面顶部的距离

    // 判断:图片顶部距离 ≤ 可视区域高度 + 滚动距离 → 图片进入可视区域
    if (offsetTop < viewHeight + scrollTop) {
      // 优化:已经加载过的图片跳过,避免重复赋值(防止scroll事件重复触发导致的冗余操作)
      if (img.src === img.dataset.src) continue;

      // 开始加载图片:将data-src(真实地址)赋给src
      img.src = img.dataset.src;
    }
  }
}

// 监听scroll事件,用节流函数限制触发频率(300ms执行一次)
window.addEventListener('scroll', throttle(lazyload, 300));

// 页面刚打开时,执行一次懒加载(加载可视区域内的图片)
window.addEventListener('load', lazyload);

// HTML示例
/*
<!-- 占位图用1x1透明图,或loading图片,data-src存储真实图片地址 -->

*

关键注意点

  • 图片必须设置src属性(可设为占位图),否则会出现图片占位空白;

  • 节流函数的延迟时间(300ms)可根据需求调整,延迟太短达不到优化效果,太长会影响体验;

  • 页面加载完成后(window.load)必须执行一次lazyload,避免可视区域内的图片无法加载。

五、统计HTML页面标签:快速了解页面结构

通俗解读

有时候我们需要知道一个页面用了多少种标签、每种标签用了多少次(比如做页面优化、排查冗余标签),这个功能就能自动统计,不用手动一个个数,还能按使用次数排序。

专业解析

利用document.getElementsByTagName('*')获取页面所有元素,转为数组后提取每个元素的标签名;用Set统计标签种类(Set自动去重),用reduce统计每种标签的数量,最后用sort排序,返回标签种类数和各标签数量(从多到少)。

完整代码(可直接复制)

function countTagsOnPage() {
  // 1. 获取页面所有元素(*表示匹配所有标签)
  const allTags = document.getElementsByTagName('*');
  // 2. 转为数组,提取每个元素的标签名,并转为大写(统一格式,避免div和DIV重复统计)
  const tagNames = [...allTags].map(el => el.tagName.toUpperCase());
  
  // 3. 统计标签种类(Set自动去重,size就是种类数)
  const totalTagTypes = new Set(tagNames).size;

  // 4. 统计每种标签的数量(用reduce累加)
  const tagCount = tagNames.reduce((acc, tag) => {
    acc[tag] = (acc[tag] || 0) + 1; // 有则加1,无则初始化为1
    return acc;
  }, {});

  // 5. 按标签数量从多到少排序(将对象转为数组,再排序)
  const sorted = Object.entries(tagCount).sort((a, b) => b[1] - a[1]);

  // 返回统计结果:标签种类数、排序后的标签数量
  return {
    totalTagTypes,
    tagCounts: sorted
  };
}

// 使用并打印结果
const res = countTagsOnPage();
console.log('页面标签种类:', res.totalTagTypes);
console.log('各标签数量(从多到少):', res.tagCounts);
// 示例输出:
// 页面标签种类:8
// 各标签数量(从多到少):[["DIV", 12], ["SPAN", 8], ["IMG", 5], ["BUTTON", 3], ...]

关键注意点

  • tagName返回的是大写标签名(比如div返回DIV),统一转为大写可避免大小写重复统计;

  • document.getElementsByTagName('*')会获取所有元素,包括head、body、script等隐藏元素,若需统计可见元素,可添加筛选条件;

  • 排序后的结果是二维数组,每个元素的第一个值是标签名,第二个值是数量。

六、点击打印HTML标签名:快速定位元素标签

通俗解读

开发时,我们经常需要知道点击的元素是什么标签(比如排查样式问题、调试事件绑定),这个功能就是“点击页面任意元素,自动打印该元素的标签名”,不用手动去开发者工具里查看。

专业解析

利用事件委托(前面讲过的知识点),在document上绑定一次click事件,通过event.target获取被点击的具体元素,再用tagName获取该元素的标签名,最后用console.log打印(也可改为弹窗显示)。

完整代码(可直接复制)

// 利用事件委托,在document上绑定一次click事件,处理所有元素的点击
document.addEventListener('click', function(event) {
    // event.target 指向被点击的具体元素(最底层子元素)
    const clickedElement = event.target;
    
    // 获取标签名(tagName返回大写形式,如'DIV'、'SPAN'、'IMG')
    const tagName = clickedElement.tagName;
    
    // 打印标签名(默认控制台打印,可改为弹窗)
    console.log(`点击的元素标签名:${tagName}`);
    // 如需弹窗显示,可取消下面这行的注释
    // alert(`点击的元素标签名:${tagName}`);
});

// 示例:点击页面上的div、span、按钮,控制台会分别打印 DIV、SPAN、BUTTON

关键注意点

  • 点击的是元素的子节点(比如span里的文本),event.target会指向文本节点的父元素(span),不影响标签名获取;

  • 可根据需求修改打印方式(控制台打印/弹窗),弹窗适合非开发环境快速查看;

  • 若只想打印特定元素的标签名,可添加筛选条件(比如只打印按钮标签:if(tagName === 'BUTTON') { ... })。

七、模拟JSONP:解决跨域请求问题

通俗解读

前端请求接口时,经常会遇到“跨域”报错(比如前端域名是a.com,接口域名是b.com),JSONP是一种简单的跨域解决方案。核心就是“通过创建script标签,加载接口地址,利用script标签不受跨域限制的特性,获取后端返回的数据”。

注意:结合你提供的报错信息 link hit security strategy(链接触发安全策略),若使用JSONP时出现该报错,大概率是后端接口的安全策略限制了该请求(比如不允许JSONP请求、域名白名单限制),需联系后端调整安全策略。

专业解析

JSONP的核心原理:script标签的src属性不受同源策略限制,可加载任意域名的资源。前端生成唯一回调函数名,拼接在接口URL中;后端接收请求后,返回“回调函数名(数据)”的格式;前端通过全局回调函数,接收并处理后端返回的数据,最后删除临时创建的script标签和全局函数,避免冗余。

完整代码(可直接复制)

function JSONP(url, _params = {}) {
  // 1. 生成唯一回调函数名(防止多个JSONP请求冲突,默认用jsonp_+时间戳)
  const callbackName = _params.callback || "jsonp_" + Date.now();
  
  // 2. 处理请求参数(排除callback,因为要单独拼接)
  const params = [];
  for (let key in _params) {
    if (key !== "callback") {
      // 编码参数值,处理中文/特殊字符
      params.push(`${key}=${encodeURIComponent(_params[key])}`);
    }
  }
  // 3. 拼接callback参数(JSONP核心:后端会根据该参数返回对应的回调函数调用)
  params.push(`callback=${callbackName}`);

  // 4. 创建script标签,用于加载接口(script不受跨域限制)
  const script = document.createElement("script");
  // 拼接接口URL和参数(url?key1=value1&key2=value2&callback=xxx)
  script.src = `${url}?${params.join("&")}`;

  // 5. 返回Promise,方便用then/catch处理结果
  return new Promise((resolve, reject) => {
    
    // 6. 挂载全局回调函数(必须在script加载前定义,否则后端返回时函数还不存在)
    window[callbackName] = (result) => {
      try {
        resolve(result); // 成功:将后端返回的数据传入resolve
      } catch (err) {
        reject(err); // 失败:捕获异常并传入reject
      } finally {
        // 7. 清理工作:删除script标签和全局回调函数,避免内存泄漏
        document.body.removeChild(script);
        delete window[callbackName];
      }
    };

    // 8. 处理脚本加载失败(比如网络错误、接口不存在)
    script.onerror = () => {
      reject(new Error("JSONP 请求失败"));
      // 失败也需要清理
      document.body.removeChild(script);
      delete window[callbackName];
    };

    // 9. 把script插入页面(最后执行,确保回调函数已定义)
    document.body.appendChild(script);
  });
}

// 示例使用(结合你提供的URL)
JSONP("https://api.example.com/data", {
  id: 123,
  callback: "getData" // 可选:指定后端约定的回调名,不指定则自动生成
}).then(res => {
  console.log("拿到数据:", res);
}).catch(err => {
  console.log("出错:", err);
  // 若出现 "link hit security strategy" 报错,需检查后端安全策略
});

// 后端返回格式(必须是回调函数调用的形式)
// getData({ "name": "张三", "id": 123 })
// 前端会通过window.getData接收该数据,并传入then的回调函数

关键注意点

  • JSONP只支持GET请求,不支持POST请求(因为script标签的src只能发起GET请求);

  • 回调函数名必须唯一,避免多个JSONP请求冲突(代码中用时间戳保证唯一性);

  • 若出现 link hit security strategy 报错,不是前端代码问题,而是后端接口的安全策略限制了该JSONP请求,需联系后端调整(比如添加前端域名到白名单、允许JSONP请求);

  • 请求完成后必须清理script标签和全局函数,避免内存泄漏。

总结

以上7个JS功能,覆盖了前端开发中URL处理、事件绑定、性能优化、跨域请求等高频场景,代码均经过优化,可直接复制到项目中使用。

重点提醒:使用JSONP时若遇到 link hit security strategy 报错,需排查后端安全策略,而非前端代码;另外,事件绑定和滚动相关功能,建议结合节流函数优化性能,避免频繁触发函数导致页面卡顿。

LeetCode 190. 颠倒二进制位:两种解法详解

LeetCode 上一道经典的位运算题目——190. 颠倒二进制位。这道题看似简单,实则藏着位运算的核心技巧,尤其是第二种“分治颠倒”的思路,非常值得深入理解,既能巩固位运算基础,也能锻炼逻辑思维。

先明确题目要求:给定一个 32 位有符号整数,将其二进制位全部颠倒,返回颠倒后的整数。比如输入二进制 00000010100101000001111010011100,输出就是 00111001011110000010100101000000

解法一:逐位颠倒(基础易懂,适合入门)

这是最直观的思路:从原数字的最低位(最右边)开始,依次取出每一位二进制数,然后将其放到结果的对应高位(最左边),循环 32 次(因为是 32 位整数),最终得到颠倒后的结果。

先看完整代码:

function reverseBits_1(n: number): number {
    let rev = 0; // 存储颠倒后的结果,初始为0(二进制全0)
    // 循环32次(覆盖32位),若n提前变为0,可提前退出(优化效率)
    for (let i = 0; i < 32 && n !== 0; ++i) {
        // 1. 取出n的最低位:n & 1(二进制中,只有最低位是1时结果为1,否则为0)
        // 2. 将取出的最低位移到对应高位:<< (31 - i)(第i次循环,对应31-i位)
        // 3. 用或运算(|)将该位存入rev,不影响已存入的高位
        rev |= (n & 1) << (31 - i);
        // 4. n右移1位(无符号右移>>>),丢弃已处理的最低位,准备处理下一位
        n >>>= 1; 
    }
    // 无符号右移0位,确保结果是32位无符号整数(避免符号位干扰)
    return rev >>> 0; 
}

关键细节解析(必看)

  • n & 1:这是位运算中“取最低位”的经典操作。比如 n=5(二进制 101),n&1=1(取最低位1);n=4(100),n&1=0(取最低位0)。

  • << (31 - i):循环第 i 次(从0开始),我们取出的是原数字的第 i 位(从右数),需要放到结果的第 31 - i 位(从右数,即从左数的对应位置)。比如 i=0 时,取最低位,放到最高位(31位);i=31时,取最高位,放到最低位(0位)。

  • n >>>= 1:这里必须用无符号右移(>>>),而不是有符号右移(>>)。因为如果是有符号整数,右移时符号位会补1,导致处理负数时出错;无符号右移会在高位补0,符合32位无符号整数的处理逻辑。

  • return rev >>> 0:同样是为了确保结果是32位无符号整数。在TypeScript/JavaScript中,整数可能会有符号位,无符号右移0位可以将其转为无符号数,避免因符号位导致的结果错误。

解法一总结

优点:思路简单,容易理解,代码量少,适合新手入门位运算。

缺点:循环32次,时间复杂度是 O(32) = O(1)(固定循环次数,属于常数时间),效率其实不低,但还有更优的“分治”思路,可以减少位运算的次数。

解法二:分治颠倒(进阶技巧,高效简洁)

这种思路借鉴了“分而治之”的思想:将32位二进制数拆分成更小的单元(2位、4位、8位、16位),先颠倒每个小单元内部,再将颠倒后的小单元整体颠倒,最终实现整个32位的颠倒。

核心原理:利用位掩码(mask)分离出不同长度的单元,通过“右移+与掩码”“左移+与掩码”的组合,实现单元内部的颠倒,再合并单元。

完整代码:

function reverseBits_2(n: number): number {
    // 定义4个位掩码,用于分离不同长度的二进制单元
    const M1 = 0x55555555; // 01010101 01010101 01010101 01010101(每2位一组,01交替)
    const M2 = 0x33333333; // 00110011 00110011 00110011 00110011(每4位一组,0011交替)
    const M4 = 0x0f0f0f0f; // 00001111 00001111 00001111 00001111(每8位一组,00001111交替)
    const M8 = 0x00ff00ff; // 00000000 11111111 00000000 11111111(每16位一组,0000000011111111交替)
    
    let result: number = n;
    
    // 第一步:颠倒每2位(比如 01 → 10,10 → 01)
    result = ((result >>> 1) & M1) | ((result & M1) << 1);
    // 第二步:颠倒每4位(比如 0011 → 1100,0101 → 1010)
    result = ((result >>> 2) & M2) | ((result & M2) << 2);
    // 第三步:颠倒每8位
    result = ((result >>> 4) & M4) | ((result & M4) << 4);
    // 第四步:颠倒每16位
    result = ((result >>> 8) & M8) | ((result & M8) << 8);
    
    // 最后:颠倒整个32位(前16位和后16位互换),并转为无符号整数
    return ((result >>> 16) | (result << 16)) >>> 0;
}

分治步骤拆解(以8位二进制为例,便于理解)

假设我们有8位二进制数 11001010,用分治思路颠倒的过程如下:

  1. 每2位颠倒:拆分为 11、00、10、10,颠倒后为 11、00、01、01,合并为 11000101

  2. 每4位颠倒:拆分为 1100、0101,颠倒后为 0011、1010,合并为00111010

  3. 每8位颠倒:拆分为 0011、1010(此时8位拆分为两个4位),颠倒后为 10100011,即最终颠倒结果。

32位的逻辑和8位完全一致,只是拆分的单元更长,通过4个掩码逐步实现“从小单元到整体”的颠倒。

关键细节解析

  • 位掩码的作用:比如 M1(0x55555555),二进制每2位为一组,每组是01,用它和result做“与运算”,可以只保留result的奇数位(第1、3、5...31位);同理,M2保留每4位的前2位,M4保留每8位的前4位,M8保留每16位的前8位。

  • 颠倒单元的核心操作:以 ((result >>> 1) & M1) | ((result & M1) << 1) 为例:

    • (result >>> 1) & M1:将result右移1位,再和M1做与运算,得到“原奇数位右移1位”的结果(即原奇数位变成偶数位);

    • (result & M1) << 1:将result和M1做与运算,得到原奇数位,再左移1位(即原奇数位变成偶数位);

    • 两者用或运算(|)合并,就实现了“每2位颠倒”。

  • 效率优势:整个过程只需要5次位运算(4次单元颠倒+1次整体颠倒),无论输入是什么,都不需要循环,时间复杂度依然是 O(1),但实际运算次数比解法一更少,效率更高。

解法二总结

优点:高效简洁,位运算技巧性强,适合深入理解位掩码和分治思想,在面试中写出这种解法,能体现对位运算的熟练掌握。

缺点:思路相对抽象,需要理解位掩码的作用和分治的拆分逻辑,新手可能需要多琢磨几遍。

两种解法对比 & 实战建议

解法 时间复杂度 空间复杂度 特点 适用场景
逐位颠倒(解法一) O(1) O(1) 思路简单,易理解,循环32次 新手入门、快速解题、面试中快速写出正确代码
分治颠倒(解法二) O(1) O(1) 技巧性强,运算次数少,效率高 深入理解位运算、面试加分、追求代码简洁高效

常见易错点提醒

  • 忘记用无符号右移(>>>):无论是处理n还是结果,都必须用无符号右移,否则符号位会干扰,导致负数处理出错。

  • 循环次数不足32次:即使n提前变为0,也需要循环32次(或者最后用rev >>> 0补全32位),否则会导致高位补0不完整,结果错误。

  • 位掩码记错:解法二中的4个掩码是固定的,记错掩码会导致拆分单元错误,最终结果出错,建议记住这4个常用掩码(对应2、4、8、16位拆分)。

最后总结

LeetCode 190题是位运算的经典入门题,两种解法各有优势:解法一胜在易懂,解法二胜在高效。建议新手先掌握解法一,理解“逐位取数、逐位放置”的核心逻辑,再深入研究解法二的分治思想和位掩码技巧。

其实位运算的核心就是“操作二进制的每一位”,多练习这类题目,就能慢慢掌握各种位运算技巧(比如取位、移位、掩码、或/与/异或运算),后续遇到更复杂的位运算题目(如位1的个数、两数相加等)也能迎刃而解。

❌