普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月28日首页

图片标签用 img 还是 picture?很多人彻底弄混了!

作者 刘大华
2025年11月28日 14:21

在网页开发中,图片处理是每个前端开发者都会遇到的基础任务。面对 <img><picture> 这两个标签,很多人存在误解:要么认为它们是互相替代的关系,要么在不合适的场景下使用了复杂的解决方案。今天,我们来彻底理清这两个标签的真正用途。

<img> 标签

<img> 是 HTML 中最基础且强大的图片标签,但它远比很多人想象的要智能。

基本语法:

<img src="image.jpg" alt="图片描述">

核心属性:

  • src:图片路径(必需)
  • alt:替代文本(无障碍必需)
  • srcset:提供多分辨率图片源
  • sizes:定义图片显示尺寸
  • loading:懒加载控制

<img> 的响应式能力被低估了

很多人认为 <img> 不具备响应式能力,这是错误的认知:

<img 
  src="image-800w.jpg"
  srcset="image-320w.jpg 320w,
          image-480w.jpg 480w,
          image-800w.jpg 800w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1200px) 50vw,
         33vw"
  alt="响应式图片示例"
>

这种写法的优势:

  • 浏览器自动选择最适合当前屏幕分辨率的图片
  • 根据视口大小动态调整加载的图片尺寸
  • 代码简洁,性能优秀

<picture> 标签

<picture> 不是为了替代 <img>,而是为了解决 <img> 无法处理的特定场景。

<picture> 解决的三大核心问题

1. 艺术指导(Art Direction) 在不同设备上显示不同构图或裁剪的图片:

<picture>
  <!-- 桌面端:宽屏全景 -->
  <source media="(min-width: 1200px)" srcset="hero-desktop.jpg">
  <!-- 平板端:适中裁剪 -->
  <source media="(min-width: 768px)" srcset="hero-tablet.jpg">
  <!-- 移动端:竖版特写 -->
  <img src="hero-mobile.jpg" alt="产品展示">
</picture>

2. 现代格式降级 优先使用高效格式,同时兼容老旧浏览器:

<picture>
  <source type="image/avif" srcset="image.avif">
  <source type="image/webp" srcset="image.webp">
  <img src="image.jpg" alt="格式优化示例">
</picture>

3. 复杂条件组合 同时考虑屏幕尺寸和图片格式:

<picture>
  <!-- 大屏 + AVIF -->
  <source media="(min-width: 1200px)" type="image/avif" srcset="large.avif">
  <!-- 大屏 + WebP -->
  <source media="(min-width: 1200px)" type="image/webp" srcset="large.webp">
  <!-- 大屏降级 -->
  <source media="(min-width: 1200px)" srcset="large.jpg">
  
  <!-- 移动端方案 -->
  <img src="small.jpg" alt="复杂条件图片">
</picture>

关键区别与选择指南

场景 推荐方案 原因
同一图片,不同分辨率 <img> + srcset + sizes 代码简洁,浏览器自动优化
不同构图或裁剪 <picture> 艺术指导必需
现代格式兼容 <picture> 格式降级必需
简单静态图片 <img> 无需复杂功能
兼容老旧浏览器 <img> 最广泛支持

常见误区纠正

误区一:<picture> 用于响应式图片

  • 事实: <img> 配合 srcsetsizes 已经能处理大多数响应式需求
  • 真相: <picture> 主要用于艺术指导和格式降级

误区二:<picture> 更现代,应该优先使用

  • 事实: 在不需要艺术指导或格式降级的场景下,<img> 是更好的选择
  • 真相: 合适的工具用在合适的场景才是最佳实践

误区三:响应式图片一定要用 <picture>

  • 事实: 很多响应式场景用 <img> + srcset 更合适
  • 真相: 评估需求,选择最简单的解决方案

场景分析

应该使用 <img> 的场景

网站Logo:

<img src="logo.svg" alt="公司Logo" width="120" height="60">

用户头像:

<img 
  src="avatar.jpg"
  srcset="avatar.jpg 1x, avatar@2x.jpg 2x"
  alt="用户头像"
  width="80" 
  height="80"
>

文章配图:

<img 
  src="article-image.jpg"
  srcset="article-image-600w.jpg 600w,
          article-image-1200w.jpg 1200w"
  sizes="(max-width: 768px) 100vw, 600px"
  alt="文章插图"
  loading="lazy"
>

应该使用 <picture> 的场景

英雄横幅(不同裁剪):

<picture>
  <source media="(min-width: 1024px)" srcset="hero-wide.jpg">
  <source media="(min-width: 768px)" srcset="hero-square.jpg">
  <img src="hero-mobile.jpg" alt="产品横幅" loading="eager">
</picture>

产品展示(格式优化):

<picture>
  <source type="image/avif" srcset="product.avif">
  <source type="image/webp" srcset="product.webp">
  <img src="product.jpg" alt="产品详情" loading="lazy">
</picture>

最佳实践

1. 始终遵循的规则

<!-- 正确:始终提供 alt 属性 -->
<img src="photo.jpg" alt="描述文本">

<!-- 错误:缺少 alt 属性 -->
<img src="photo.jpg">

<!-- 装饰性图片使用空 alt -->
<img src="decoration.jpg" alt="">

2. 性能优化策略

<!-- 优先加载关键图片 -->
<img src="hero.jpg" alt="重要图片" loading="eager" fetchpriority="high">

<!-- 非关键图片延迟加载 -->
<img src="content-image.jpg" alt="内容图片" loading="lazy">

<!-- 指定尺寸避免布局偏移 -->
<img src="product.jpg" alt="商品" width="400" height="300">

3. 现代图片格式策略

<picture>
  <!-- 优先使用AVIF,压缩率最高 -->
  <source type="image/avif" srcset="image.avif">
  <!-- 其次WebP,广泛支持 -->
  <source type="image/webp" srcset="image.webp">
  <!-- 最终回退到JPEG -->
  <img src="image.jpg" alt="现代格式示例">
</picture>

总结

<img><picture> 不是竞争关系,而是互补的工具:

  • <img>:处理大多数日常图片需求,特别是分辨率适配
  • <picture>:解决特定复杂场景,如艺术指导和格式降级

核心建议:

  1. 从最简单的 <img> 开始,只在必要时升级到 <picture>
  2. 充分利用 <img>srcsetsizes 属性
  3. 为关键图片使用 <picture> 进行格式优化
  4. 始终考虑性能和用户体验

掌握这两个标签的正确用法,你就能在各种场景下都做出最合适的技术选择,既保证用户体验,又避免过度工程化。

希望这篇指南能帮助你彻底理解这两个重要的HTML标签!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

还在用 WebSocket 做实时通信?SSE 可能更简单

作者 刘大华
2025年11月26日 11:55

大家好,我是大华!在现代的Web开发中,实时通信需求越来越普遍。比如在线聊天、实时数据监控、消息推送等场景。

面对这些需求,我们通常有两种选择:SSEWebSocket。它们都能实现实时通信,但设计理念和适用场景却有很大不同。

什么是 SSE?

SSE(Server-Sent Events)是一种基于 HTTP 的服务器推送技术。它的核心特点是:单向通信,只能由服务器向客户端发送数据。

SSE 的核心特点

  • 基于 HTTP 协议:使用标准的 HTTP/1.1 协议
  • 单向通信:服务器 → 客户端
  • 自动重连:浏览器内置重连机制
  • 简单易用:API 设计简洁直观
  • 文本传输:主要支持 UTF-8 文本数据

SSE 使用示例

客户端JS代码:

// 创建 SSE 连接
const eventSource = new EventSource('/api/real-time-data');

// 监听服务器推送的消息
eventSource.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log('收到实时数据:', data);
  updateUI(data); // 更新界面
};

// 监听自定义事件类型
eventSource.addEventListener('systemAlert', function(event) {
  const alertData = JSON.parse(event.data);
  showAlert(alertData.message);
});

// 错误处理 - 自动重连是内置的
eventSource.onerror = function(event) {
  console.log('连接异常,正在自动重连...');
};

服务器端代码(Node.js + Express):

app.get('/api/real-time-data', (req, res) => {
  // 设置 SSE 必需的响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*'
  });
  
  // 发送初始连接确认
  res.write('data: {"status": "connected"}\n\n');
  
  // 模拟实时数据推送
  let count = 0;
  const interval = setInterval(() => {
    const data = {
      id: count++,
      timestamp: new Date().toISOString(),
      value: Math.random() * 100
    };
    
    // SSE 标准格式:data: 开头,两个换行符结尾
    res.write(`data: ${JSON.stringify(data)}\n\n`);
    
    // 每10秒发送一次系统状态
    if (count % 10 === 0) {
      res.write('event: systemAlert\n');
      res.write(`data: {"message": "系统运行正常"}\n\n`);
    }
  }, 1000);
  
  // 客户端断开连接时清理资源
  req.on('close', () => {
    clearInterval(interval);
    console.log('客户端断开连接');
  });
});

什么是 WebSocket?

WebSocket 是一种真正的全双工通信协议,允许服务器和客户端之间建立持久连接,进行双向实时通信。

WebSocket 的核心特点

  • 独立协议:基于 TCP 的独立协议(ws:// 或 wss://)
  • 双向通信:服务器 ↔ 客户端
  • 低延迟:建立连接后开销极小
  • 数据多样:支持文本和二进制数据
  • 手动管理:需要手动处理连接状态

WebSocket 使用示例

客户端JS代码:

class ChatClient {
  constructor() {
    this.socket = null;
    this.isConnected = false;
  }
  
  connect() {
    this.socket = new WebSocket('wss://api.example.com/chat');
    
    this.socket.onopen = () => {
      this.isConnected = true;
      console.log('WebSocket 连接已建立');
      this.send({ type: 'join', username: '小明' });
    };
    
    this.socket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.handleMessage(data);
    };
    
    this.socket.onclose = () => {
      this.isConnected = false;
      console.log('连接已断开');
      this.attemptReconnect();
    };
    
    this.socket.onerror = (error) => {
      console.error('WebSocket 错误:', error);
    };
  }
  
  sendMessage(content) {
    if (this.isConnected) {
      this.send({
        type: 'message',
        content: content,
        timestamp: Date.now()
      });
    }
  }
  
  send(data) {
    this.socket.send(JSON.stringify(data));
  }
  
  handleMessage(data) {
    switch (data.type) {
      case 'chat':
        this.displayMessage(data);
        break;
      case 'userJoin':
        this.showUserJoin(data.username);
        break;
    }
  }
  
  attemptReconnect() {
    setTimeout(() => {
      console.log('尝试重新连接...');
      this.connect();
    }, 3000);
  }
}

服务器端代码(Node.js + ws 库):

const WebSocket = require('ws');
const wss = new WebSocket.Server({ 
  port: 8080,
  perMessageDeflate: false
});

// 存储连接的用户
const connectedUsers = new Map();

wss.on('connection', (ws, request) => {
  console.log('新的客户端连接');
  
  let currentUser = null;
  
  ws.on('message', (rawData) => {
    try {
      const data = JSON.parse(rawData);
      
      switch (data.type) {
        case 'join':
          currentUser = data.username;
          connectedUsers.set(ws, currentUser);
          
          // 广播用户加入消息
          broadcast({
            type: 'userJoin',
            username: currentUser,
            time: new Date().toISOString()
          }, ws);
          
          // 发送欢迎消息
          ws.send(JSON.stringify({
            type: 'system',
            message: `欢迎 ${currentUser} 加入聊天室!`
          }));
          break;
          
        case 'message':
          // 广播聊天消息
          broadcast({
            type: 'chat',
            username: currentUser,
            message: data.content,
            timestamp: data.timestamp
          });
          break;
      }
    } catch (error) {
      console.error('消息解析错误:', error);
      ws.send(JSON.stringify({
        type: 'error',
        message: '消息格式错误'
      }));
    }
  });
  
  ws.on('close', () => {
    if (currentUser) {
      connectedUsers.delete(ws);
      // 广播用户离开
      broadcast({
        type: 'userLeave',
        username: currentUser,
        time: new Date().toISOString()
      });
    }
    console.log('客户端断开连接');
  });
  
  // 心跳检测
  const heartbeat = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'ping' }));
    }
  }, 30000);
  
  ws.on('close', () => {
    clearInterval(heartbeat);
  });
});

function broadcast(data, excludeWs = null) {
  wss.clients.forEach((client) => {
    if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(data));
    }
  });
}

区别对比

特性 SSE WebSocket
通信模式 单向(服务器推客户端) 双向(全双工通信)
协议基础 HTTP/1.1 独立的 WebSocket 协议
连接建立 普通 HTTP 请求 HTTP 升级握手
数据格式 文本(事件流格式) 文本和二进制帧
重连机制 浏览器自动处理 需要手动实现
头部开销 每次消息带 HTTP 头 建立后极小帧头
兼容性 良好(除 IE) 优秀(IE10+)
开发复杂度 简单直观 相对复杂

适用场景

推荐使用 SSE 的场景

1. 实时数据监控面板 2. 实时消息通知 3. 实时数据流展示

比如:股票价格实时更新、体育比赛比分直播、物流订单状态跟踪和服务器日志实时显示等。

推荐使用 WebSocket 的场景

1. 实时交互应用 2. 实时游戏应用 3. 实时音视频通信

比如:视频会议系统、在线客服聊天和实时协作编辑文档等

如何选择?

选择 SSE:

  • 只需要服务器向客户端推送数据
  • 希望快速实现、简单维护
  • 项目对移动端兼容性要求高
  • 数据更新频率适中(秒级)
  • 不需要传输二进制数据

选择 WebSocket:

  • 需要真正的双向实时通信
  • 数据传输频率很高(毫秒级)
  • 需要传输二进制数据(如图片、音频)
  • 构建实时交互应用(游戏、协作工具)
  • 对延迟极其敏感的场景

混合使用策略

在一些复杂应用中,可以同时使用两者:

class HybridApp {
  constructor() {
    // 使用 SSE 接收通知和广播消息
    this.notificationSource = new EventSource('/api/notifications');
    
    // 使用 WebSocket 进行实时交互
    this.interactionSocket = new WebSocket('wss://api.example.com/interact');
    
    this.setupEventHandlers();
  }
  
  setupEventHandlers() {
    // SSE 处理广播类消息
    this.notificationSource.onmessage = (event) => {
      this.handleBroadcastMessage(JSON.parse(event.data));
    };
    
    // WebSocket 处理交互类消息
    this.interactionSocket.onmessage = (event) => {
      this.handleInteractionMessage(JSON.parse(event.data));
    };
  }
}

总结

SSE 的优势在于简单易用、自动重连、与 HTTP 基础设施完美集成,适合服务器向客户端的单向数据推送场景。

WebSocket 的优势在于真正的双向通信、低延迟、支持二进制数据,适合需要高频双向交互的复杂应用。

在实际项目中,可以根据具体的业务需求、性能要求来做出合理的技术选型。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3 + ElementPlus 动态菜单实现:一套代码完美适配多角色权限系统》

昨天以前首页

你真的懂递归吗?没那么复杂,但也没那么简单

作者 刘大华
2025年11月25日 19:25

大家好,我是大华。 很多初学者都觉得简单的递归还可以看得懂,稍微复杂些的复杂就觉得很难,甚至有些工作几年的同事也对其避而远之。 其实,只要掌握了正确的方法,递归并没有那么可怕!

一、什么是递归?

打个比方:想象一下,你站在一排长长的队伍里,你想知道你前面有几个人。 但你只能看到你前面那个人,看不到更前面的人。怎么办? 你问前面那个人:“兄弟,你前面有几个人?” 他也不知道,于是他又问更前面的人:“兄弟,你前面有几个人?” 就这样一直往前问…… 直到问到排在最前面的那个人,他说:“我前面没人,是0个。” 然后,这个答案开始往回传:

最前面的人说:“0个” 他后面的人说:“我前面有1个(就是他)” 再后面的人说:“我前面有2个”… 最后传到你这里:“你前面有 N 个” 这个过程,就是递归!

递归的本质就是: 把一个大问题,拆解成相同的小问题,直到遇到最简单的情况(边界),然后从最简单的情况开始,一层层把结果返回回去,最终解决大问题。

二、递归的两大核心要素

任何正确的递归函数,都必须包含两个关键部分:

1. 递归终止条件(Base Case)

这是递归的“刹车”,防止无限循环。

当问题小到不能再拆时,直接返回结果。

没有它,程序就会无限调用自己,最终导致栈的溢出(Stack Overflow)

2. 递归调用(Recursive Case)

函数调用自己,但传入的参数是更小规模的问题。

每次调用都在向终止条件靠近。

三、从经典例子开始:计算阶乘

先看最简单的阶乘:5! = 5 × 4 × 3 × 2 × 1

/**
 * 计算阶乘的递归函数
 * @param {number} n - 要计算阶乘的数字
 * @returns {number} - n的阶乘结果
 */
function factorial(n) {
    // 1. 基准条件:0的阶乘是1,1的阶乘也是1
    if (n === 0 || n === 1) {
        console.log(`到达基准条件:factorial(${n}) = 1`);
        return 1;
    }
    
    // 2. 递归条件:n! = n × (n-1)!
    // 3. 递归调用:问题规模从n变成n-1
    console.log(`计算 factorial(${n}) = ${n} × factorial(${n - 1})`);
    const result = n * factorial(n - 1);
    console.log(`得到结果:factorial(${n}) = ${result}`);
    
    return result;
}

// 测试
console.log("最终结果:5的阶乘 =", factorial(5));

运行结果:

计算 factorial(5) = 5 × factorial(4)
计算 factorial(4) = 4 × factorial(3)
计算 factorial(3) = 3 × factorial(2)
计算 factorial(2) = 2 × factorial(1)
到达基准条件:factorial(1) = 1
得到结果:factorial(2) = 2
得到结果:factorial(3) = 6
得到结果:factorial(4) = 24
得到结果:factorial(5) = 120
最终结果:5的阶乘 = 120

看到这个调用过程,是不是对递归有了直观感受?

四、理解递归的关键:调用栈

要真正理解递归,必须明白调用栈的概念。

调用栈就像叠汉堡:每次函数调用就加一片面包,函数返回就拿走一片。

/**
 * 演示递归调用栈
 */
function understandCallStack() {
    function recursiveDemo(level, maxLevel) {
        // 打印当前栈深度
        const indent = "  ".repeat(level);
        console.log(`${indent}进入第 ${level} 层`);
        
        // 基准条件:达到最大深度时停止
        if (level >= maxLevel) {
            console.log(`${indent}${level} 层:到达基准条件,开始返回`);
            return;
        }
        
        // 递归调用
        recursiveDemo(level + 1, maxLevel);
        
        console.log(`${indent}离开第 ${level} 层`);
    }
    
    console.log("=== 递归调用栈演示 ===");
    recursiveDemo(0, 3);
}

understandCallStack();

运行结果:

=== 递归调用栈演示 ===
进入第 0 层
  进入第 1 层
    进入第 2 层
      进入第 3 层
      第 3 层:到达基准条件,开始返回
    离开第 2 层
  离开第 1 层
离开第 0 层

这就是为什么递归深度太大会"栈溢出"——汉堡叠得太高,倒掉了!

五、实际应用:文件系统遍历

递归在实际开发中非常实用,比如遍历文件夹:

/**
 * 模拟文件系统结构
 */
const fileSystem = {
    name: "根目录",
    type: "folder",
    children: [
        {
            name: "文档",
            type: "folder",
            children: [
                { name: "简历.pdf", type: "file" },
                { name: "报告.docx", type: "file" }
            ]
        },
        {
            name: "图片", 
            type: "folder",
            children: [
                { 
                    name: "旅行照片", 
                    type: "folder", 
                    children: [
                        { name: "海滩.jpg", type: "file" }
                    ]
                },
                { name: "头像.png", type: "file" }
            ]
        },
        { name: "README.txt", type: "file" }
    ]
};

/**
 * 递归遍历文件系统
 * @param {object} node - 当前节点
 * @param {string} indent - 缩进字符串
 */
function traverseFileSystem(node, indent = "") {
    // 基准条件:空节点直接返回
    if (!node) return;
    
    // 打印当前节点
    const icon = node.type === 'folder' ? '📁' : '📄';
    console.log(`${indent}${icon} ${node.name}`);
    
    // 递归条件:如果是文件夹且有子节点,递归遍历
    if (node.type === 'folder' && node.children) {
        node.children.forEach(child => {
            traverseFileSystem(child, indent + "  ");
        });
    }
}

console.log("=== 文件系统遍历 ===");
traverseFileSystem(fileSystem);

运行结果:

=== 文件系统遍历 ===
📁 根目录
  📁 文档
    📄 简历.pdf
    📄 报告.docx
  📁 图片
    📁 旅行照片
      📄 海滩.jpg
    📄 头像.png
  📄 README.txt

六、递归的适用场景

1. 树形结构操作

  • 文件系统遍历
  • DOM树操作
  • 组织架构图
  • 菜单导航

2. 数学问题

  • 阶乘计算
  • 斐波那契数列
  • 汉诺塔问题

3. 分治算法

  • 归并排序
  • 快速排序

4. 回溯算法

  • 迷宫求解
  • 数独解题

七、递归的优缺点

优点:

  • 代码简洁:复杂问题简单化
  • 思路清晰:符合人类思维方式
  • 数学表达直接:数学公式容易转换

缺点:

  • 性能开销:函数调用有成本
  • 栈溢出风险:递归太深会崩溃
  • 调试困难:调用链长难跟踪

八、重要改进:避免重复计算

我们来看斐波那契数列的例子,并解决性能问题:

/**
 * 斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13...
 * 规律:每个数是前两个数之和
 */

// 原始版本:性能很差,有大量重复计算
function fibonacciSlow(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    return fibonacciSlow(n - 1) + fibonacciSlow(n - 2);
}

// 优化版本:使用备忘录避免重复计算
function fibonacciMemo(n, memo = {}) {
    if (n in memo) return memo[n];
    if (n === 0) return 0;
    if (n === 1) return 1;
    
    memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
    return memo[n];
}

// 迭代版本:性能最好,不会栈溢出
function fibonacciIterative(n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    
    let prev = 0;
    let curr = 1;
    
    for (let i = 2; i <= n; i++) {
        const next = prev + curr;
        prev = curr;
        curr = next;
    }
    
    return curr;
}

// 性能测试
console.log("斐波那契数列第10项:");
console.log("慢速版本:", fibonacciSlow(10));
console.log("备忘录版本:", fibonacciMemo(10));
console.log("迭代版本:", fibonacciIterative(10));

九、常见错误和解决方案

错误1:忘记基准条件

// 错误:无限递归!
function infiniteRecursion(n) {
    return n * infiniteRecursion(n - 1); // 没有停止条件!
}

// 正确:必须有基准条件
function correctRecursion(n) {
    if (n <= 1) return 1; // 基准条件
    return n * correctRecursion(n - 1);
}

错误2:问题规模没有减小

// 错误:问题规模没有变小
function wrongRecursion(n) {
    if (n <= 1) return 1;
    return n * wrongRecursion(n); // 还是n,没有减小!
}

//  正确:每次递归问题规模都要减小
function correctRecursion(n) {
    if (n <= 1) return 1;
    return n * correctRecursion(n - 1); // n-1,问题规模减小
}

十、调试技巧:

  1. 打印日志:跟踪递归过程
  2. 使用调试器:观察调用栈变化
  3. 先写基准条件:确保不会无限递归
  4. 小数据测试:先用小数据验证正确性

十一、什么时候该用递归?

适合用递归的情况:

  • 问题可以分解为相似的子问题
  • 数据结构本身是递归的(如树、图)
  • 解决方案需要回溯

不适合用递归的情况:

  • 性能要求极高
  • 递归深度可能很大
  • 可以用简单循环解决

十二、实际例子:计算数组深度

让我们用递归解决一个实际问题:

/**
 * 计算嵌套数组的深度
 * 例如:[1, [2, [3, [4]]]] 的深度是4
 */
function calculateDepth(arr) {
    // 基准条件:如果不是数组,深度为0
    if (!Array.isArray(arr)) {
        return 0;
    }
    
    // 基准条件:空数组深度为1
    if (arr.length === 0) {
        return 1;
    }
    
    // 递归条件:深度 = 1 + 子元素的最大深度
    let maxChildDepth = 0;
    for (const item of arr) {
        const childDepth = calculateDepth(item);
        if (childDepth > maxChildDepth) {
            maxChildDepth = childDepth;
        }
    }
    
    return 1 + maxChildDepth;
}

// 测试
const testArrays = [
    [1, 2, 3],                   // 深度1
    [1, [2, 3]],                 // 深度2  
    [1, [2, [3, [4]]]],          // 深度4
    []                           // 深度1
];

testArrays.forEach((arr, index) => {
    console.log(`数组${index + 1}:`, JSON.stringify(arr));
    console.log(`深度:`, calculateDepth(arr));
    console.log("---");
});

总结

递归的核心思想:把大问题分解成相似的小问题

三个关键点:

  1. 基准条件 - 知道什么时候停止
  2. 递归条件 - 知道如何分解问题
  3. 递归调用 - 自己调用自己

使用建议

  • 先确定基准条件
  • 确保每次递归问题规模都减小
  • 注意性能,必要时改用迭代
  • 复杂递归考虑使用备忘录优化

递归就像剥洋葱,一层一层往里剥,直到找到核心。掌握了这个方法,你就能优雅地解决很多复杂问题了!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3 + ElementPlus 动态菜单实现:一套代码完美适配多角色权限系统》

❌
❌