普通视图

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

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

作者 Wect
2026年4月5日 13:25

在前端开发中,我们经常会遇到「大量数据渲染卡顿」「频繁事件触发导致性能损耗」「自定义定时逻辑」等问题,今天就来拆解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高频场景详解

作者 Wect
2026年4月5日 11:22

在前端开发中,我们经常会遇到一些重复且基础的需求——比如解析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. 颠倒二进制位:两种解法详解

作者 Wect
2026年4月2日 22:46

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的个数、两数相加等)也能迎刃而解。

❌
❌