普通视图

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

从零开始:用Vue 3和Coze工作流打造AI冰球运动员生成器

2026年2月19日 20:03

# 从零开始:用Vue 3和Coze工作流打造AI冰球运动员生成器

之前我使用coze生成了工作流内容是:为冰球协会开发一个AI应用,让会员上传自己的照片,一键生成酷炫的冰球运动员形象。用户觉得有趣还可以分享到朋友圈,达到传播效果。 但我每次想使用它时都得打开coze使用,这非常麻烦,现在我想把它完善一些,让我们在浏览器就能直接调用它,现在我就使用vue让我们再浏览器端就能直接使用了,接下来我将与大家分析我的代码历程

关于Coze工作流的具体配置,可以参考我之前发表的文章:《保姆级教程:用 Coze 打造宠物→冰球运动员拟人化工作流》。链接我放在了文章末尾

第一章:认识我们的项目

1.1 项目故事

想象一下,你是冰球协会的网站开发者。协会想做一个有趣的活动:会员上传自己的照片,AI就能生成一张冰球运动员的形象照。用户觉得好玩就会分享到朋友圈,为协会带来更多关注。

1.2 项目功能

我们的应用需要实现:

  1. 上传照片:用户选择自己的照片
  2. 预览照片:立即看到选中的照片
  3. 选择参数:队服颜色、号码、位置等
  4. AI生成:调用Coze平台的工作流
  5. 显示结果:展示生成的冰球运动员形象

1.3 最终效果预览

image.png

第二章:Vue基础概念小课堂

在开始写代码前,我先给大家讲几个重要的概念。这些是Vue的基石,一定要理解透彻。

2.1 什么是响应式数据?

场景引入:想象你有一个计分板,比分变化时,所有人都能看到新的比分。在传统JavaScript中,你需要手动更新每个显示比分的地方:

// 传统方式:手动更新
let score = 0;
document.getElementById('score').innerHTML = score;

// 比分变了
score = 1;
document.getElementById('score').innerHTML = score; // 又要手动更新
// 如果页面上有10处显示比分,就要更新10次!

Vue的解决方式

// Vue方式:数据是响应式的
const score = ref(0); // 创建响应式数据

// 比分变了
score.value = 1; // 页面上所有显示score的地方自动更新!

解释ref就像一个魔法盒子,把普通数据包装起来。Vue会时刻监视这个盒子里面的变化,一旦变化就自动更新页面。

2.2 模板语法三兄弟

Vue的模板里有三种重要的语法,我们通过对比来理解:

<!-- 1. 文本插值 {{}}:专门显示文字内容 -->
<div>用户姓名:{{ username }}</div>
<p>年龄:{{ age }}岁</p>
<h1>欢迎,{{ fullName }}!</h1>

<!-- 2. 属性绑定 ::动态设置标签属性 -->
<img :src="avatarUrl" />
<a :href="linkUrl">点击访问</a>
<div :class="boxClass"></div>

<!-- 3. 条件渲染 v-if:控制显示隐藏 -->
<div v-if="isLoading">加载中...</div>
<div v-else>加载完成</div>

2.3 重要知识点:为什么不能用{{}}绑定src?

错误示范

<!-- 同学A的错误写法 -->
<img src="{{ imageUrl }}" />

<!-- 同学B的错误写法 -->
<img src="imageUrl" />

正确写法

<!-- 正确的写法 -->
<img :src="imageUrl" />

我使用生活例子解释

想象你在填写一份表格:

  • {{}} 就像表格的内容区域,你可以在里面填写文字
  • : 就像表格的选项框,你需要勾选或填写特定的选项
  • HTML属性就像表格的标题栏,是固定的
<!-- 类比理解 -->
<div>姓名:{{ name }}</div>     <!-- 这是内容区域,可以填文字 -->
<img src="logo.png" />          <!-- 这是固定标题,不能动态 -->
<img :src="userPhoto" />        <!-- 这是选项框,可以动态选择 -->

技术原理解释

  1. 浏览器解析HTML时,src="xxx"期望得到一个字符串
  2. 如果写src="{{url}}",浏览器看到的是字符串"{{url}}"
  3. :是Vue的特殊标记,告诉Vue:"这个属性的值要从JavaScript变量获取"

第三章:开始写代码 - 搭建页面结构

3.1 创建项目

打开终端,跟着我一步步操作:

# 创建项目
npm create vue@latest ice-hockey-ai

# 进入项目目录
cd ice-hockey-ai

# 安装依赖
npm install

# 启动开发服务器
npm run dev

3.2 编写页面模板

打开 src/App.vue,我们先搭建页面结构:

<template>
  <div class="container">
    <!-- 左侧:输入区域 -->
    <div class="input-panel">
      <h2>上传你的照片</h2>
      
      <!-- 文件上传 -->
      <div class="upload-area">
        <input 
          type="file" 
          ref="fileInput"
          accept="image/*"
          @change="handleFileSelect"
        />
        <p>支持jpg、png格式</p>
      </div>
      
      <!-- 预览图区域 -->
      <div class="preview" v-if="previewUrl">
        <img :src="previewUrl" alt="预览图" />
      </div>
      
      <!-- 参数设置 -->
      <div class="settings">
        <h3>选择参数:</h3>
        
        <div class="setting-item">
          <label>队服号码:</label>
          <input type="number" v-model="number" />
        </div>
        
        <div class="setting-item">
          <label>队服颜色:</label>
          <select v-model="color">
            <option value="红">红色</option>
            <option value="蓝">蓝色</option>
            <option value="白">白色</option>
          </select>
        </div>
        
        <div class="setting-item">
          <label>位置:</label>
          <select v-model="position">
            <option value="0">守门员</option>
            <option value="1">前锋</option>
            <option value="2">后卫</option>
          </select>
        </div>
        
        <div class="setting-item">
          <label>持杆手:</label>
          <select v-model="hand">
            <option value="0">左手</option>
            <option value="1">右手</option>
          </select>
        </div>
        
        <div class="setting-item">
          <label>风格:</label>
          <select v-model="style">
            <option value="写实">写实</option>
            <option value="乐高">乐高</option>
            <option value="国漫">国漫</option>
          </select>
        </div>
      </div>
      
      <!-- 生成按钮 -->
      <button @click="generate" :disabled="isGenerating">
        {{ isGenerating ? '生成中...' : '生成形象' }}
      </button>
    </div>
    
    <!-- 右侧:输出区域 -->
    <div class="output-panel">
      <h2>生成结果</h2>
      <div class="result-box">
        <img :src="resultUrl" v-if="resultUrl" />
        <div v-else-if="status" class="status">{{ status }}</div>
        <div v-else class="placeholder">点击生成按钮开始</div>
      </div>
    </div>
  </div>
</template>

3.3 添加基础样式

<style scoped>
.container {
  display: flex;
  min-height: 100vh;
  font-family: 'Microsoft YaHei', sans-serif;
}

.input-panel {
  width: 350px;
  padding: 20px;
  background: #f5f5f5;
  border-right: 1px solid #ddd;
}

.output-panel {
  flex: 1;
  padding: 20px;
}

.upload-area {
  margin: 20px 0;
  padding: 20px;
  background: white;
  border: 2px dashed #ccc;
  text-align: center;
}

.preview {
  margin: 20px 0;
}

.preview img {
  width: 100%;
  max-height: 200px;
  object-fit: contain;
}

.settings {
  margin: 20px 0;
}

.setting-item {
  margin: 10px 0;
  display: flex;
  align-items: center;
}

.setting-item label {
  width: 80px;
}

.setting-item input,
.setting-item select {
  flex: 1;
  padding: 5px;
}

button {
  width: 100%;
  padding: 10px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.result-box {
  width: 400px;
  height: 400px;
  border: 2px solid #ddd;
  display: flex;
  justify-content: center;
  align-items: center;
}

.result-box img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.status {
  color: #007bff;
}

.placeholder {
  color: #999;
}
</style>

第四章:JavaScript部分 - 让页面动起来

4.1 导入和定义数据

<script setup>
import { ref } from 'vue'

// ref是Vue 3中创建响应式数据的函数
// 什么是响应式?数据变化,页面自动更新

// 1. 用户选择的参数
const number = ref(10)      // 队服号码,默认10号
const color = ref('红')      // 队服颜色,默认红色
const position = ref('0')    // 位置,默认守门员
const hand = ref('0')        // 持杆手,默认左手
const style = ref('写实')     // 风格,默认写实

// 2. UI状态
const fileInput = ref(null)  // 引用文件输入框
const previewUrl = ref('')    // 预览图地址
const resultUrl = ref('')     // 生成结果图地址
const status = ref('')        // 状态信息
const isGenerating = ref(false) // 是否正在生成
</script>

4.2 实现图片预览

这是很多人觉得难的地方,我一步步讲解:

<script setup>
// ... 前面的代码

// 处理文件选择
const handleFileSelect = () => {
  // 1. 获取文件输入元素
  const input = fileInput.value
  
  // 2. 检查用户是否真的选了文件
  if (!input.files || input.files.length === 0) {
    return // 没选文件就退出
  }
  
  // 3. 获取选中的文件
  const file = input.files[0]
  console.log('选中的文件:', file)
  
  // 4. 创建FileReader对象读取文件
  const reader = new FileReader()
  
  // 5. 告诉FileReader我们要把文件读成DataURL格式
  // DataURL是什么?就是把图片变成一长串字符,可以直接用在img标签的src上
  reader.readAsDataURL(file)
  
  // 6. 设置读取完成后的处理函数
  reader.onload = (e) => {
    // e.target.result 就是读取到的DataURL
    previewUrl.value = e.target.result
    console.log('预览图已生成')
  }
}
</script>

4.3 深入讲解:为什么是files[0]?

你可能有疑惑:为什么用files[0],不是files[1]或别的?

接下来我给你解答

// 假设你选择了文件,在控制台看看
console.log(input.files)

// 输出结果:
FileList {
  0: File {name: "myphoto.jpg", size: 12345},
  length: 1
}

// 看到没?files是一个类数组对象,里面只有一个元素
// 所以用[0]获取第一个(也是唯一一个)文件

// 如果想支持多选,HTML要加multiple属性
<input type="file" multiple />
// 这时files里可能有多个文件
for(let i = 0; i < input.files.length; i++) {
  console.log(`第${i+1}个文件:`, input.files[i])
}

4.4 添加API配置

<script setup>
// ... 前面的代码

// 老师讲解:这些配置要放在.env文件里,不要直接写在代码中
// 创建.env文件,写上:
// VITE_PAT_TOKEN=你的token
// VITE_WORKFLOW_ID=你的工作流ID

const patToken = import.meta.env.VITE_PAT_TOKEN
const workflowId = import.meta.env.VITE_WORKFLOW_ID

// Coze平台的接口地址
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'
</script>

4.5 实现文件上传功能

<script setup>
// ... 前面的代码

// 上传文件到Coze平台
const uploadFile = async () => {
  // 1. 创建FormData对象
  // FormData是浏览器自带的,用于模拟表单提交
  const formData = new FormData()
  
  // 2. 获取文件
  const input = fileInput.value
  if (!input.files || input.files.length === 0) {
    throw new Error('请先选择图片')
  }
  
  // 3. 把文件添加到FormData
  // 'file'是字段名,要和Coze平台要求的保持一致
  formData.append('file', input.files[0])
  
  // 4. 发送上传请求
  // fetch是现代浏览器内置的发送HTTP请求的方法
  const response = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}` // 身份验证
    },
    body: formData // 把表单数据作为请求体
  })
  
  // 5. 解析返回的JSON
  const result = await response.json()
  console.log('上传结果:', result)
  
  // 6. 检查是否成功
  // Coze API规范:code为0表示成功
  if (result.code !== 0) {
    throw new Error(result.msg || '上传失败')
  }
  
  // 7. 返回文件ID,后续要用
  return result.data.id
}
</script>

4.6 实现主要生成功能

<script setup>
// ... 前面的代码

// 主要的生成函数
const generate = async () => {
  try {
    // 防止重复点击
    if (isGenerating.value) return
    
    // 开始生成
    isGenerating.value = true
    status.value = '正在上传图片...'
    
    // 第一步:上传图片获取file_id
    const fileId = await uploadFile()
    status.value = '图片上传成功,AI正在绘制...'
    
    // 第二步:准备工作流参数
    const parameters = {
      // Coze要求picture字段是JSON字符串
      picture: JSON.stringify({
        file_id: fileId
      }),
      style: style.value,
      uniform_color: color.value,
      uniform_number: number.value,
      position: position.value,
      shooting_hand: hand.value
    }
    
    console.log('工作流参数:', parameters)
    
    // 第三步:调用工作流
    const response = await fetch(workflowUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${patToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        workflow_id: workflowId,
        parameters: parameters
      })
    })
    
    const result = await response.json()
    console.log('工作流结果:', result)
    
    // 第四步:检查结果
    if (result.code !== 0) {
      throw new Error(result.msg || '生成失败')
    }
    
    // 第五步:解析数据
    // 注意:result.data是JSON字符串,需要解析
    const data = JSON.parse(result.data)
    console.log('解析后的数据:', data)
    
    // 第六步:显示结果
    status.value = ''
    resultUrl.value = data.ouput // 注意字段名是ouput
    
  } catch (error) {
    // 出错时显示错误信息
    status.value = '错误:' + error.message
    console.error('生成失败:', error)
  } finally {
    // 不管成功失败,都要结束生成状态
    isGenerating.value = false
  }
}
</script>

第五章:我来讲解异步编程

5.1 为什么要用async/await?

可能有人疑惑:为什么代码里到处都是async/await,这是什么意思?

你可以想象你去餐厅吃饭

// 同步方式(不用await):就像你自己做饭
function cook() {
  // 必须一步一步来,不能同时做别的
  washVegetables()  // 洗菜
  cutVegetables()   // 切菜  
  cookVegetables()  // 炒菜
  // 做完才能吃饭
}

// 异步方式(用await):就像去餐厅点菜
async function eat() {
  // 点完菜就可以玩手机
  await order() // 服务员去后厨做菜
  // await表示"等待",等菜好了服务员会端上来
  eat() // 菜来了就可以吃
}

5.2 async/await的实际应用

// 不用await的写法(回调地狱)
function uploadFile() {
  fetch(url)
    .then(response => response.json())
    .then(data => {
      fetch(workflowUrl)
        .then(response => response.json())
        .then(result => {
          console.log('最终结果:', result)
        })
    })
}

// 用await的写法(清晰易懂)
async function uploadFile() {
  const response = await fetch(url)
  const data = await response.json()
  const result = await fetch(workflowUrl)
  const final = await result.json()
  console.log('最终结果:', final)
}

第六章:重要知识点

6.1 关于v-model

v-model是Vue提供的一个语法糖,让表单处理变得简单:

<!-- v-model的写法 -->
<input v-model="username" />

<!-- 等价于 -->
<input 
  :value="username" 
  @input="username = $event.target.value"
/>

<!-- 所以v-model其实是两件事: -->
<!-- 1. 把数据绑定到输入框(显示) -->
<!-- 2. 监听输入事件更新数据(收集) -->

6.2 关于ref

ref为什么有时候是数据,有时候是DOM元素?

// ref两种用途:

// 1. 创建响应式数据
const count = ref(0) // count是个响应式数据
count.value = 1 // 修改数据

// 2. 获取DOM元素
const inputRef = ref(null) // 初始null
// 在模板中:<input ref="inputRef" />
// 组件挂载后,inputRef.value就是那个input元素

// 为什么两种用法?
// 因为ref就像一个"万能引用":
// - 可以引用数据(响应式数据)
// - 可以引用DOM元素(DOM引用)
// - 可以引用组件(组件实例)

第七章:完整代码整合

<template>
  <div class="container">
    <!-- 左侧面板 -->
    <div class="input-panel">
      <h2>⚡ AI冰球运动员生成器</h2>
      
      <!-- 上传区域 -->
      <div class="upload-area">
        <input 
          type="file" 
          ref="fileInput"
          accept="image/*"
          @change="handleFileSelect"
        />
        <p>支持jpg、png格式</p>
      </div>
      
      <!-- 预览图 -->
      <div class="preview" v-if="previewUrl">
        <img :src="previewUrl" alt="预览图" />
      </div>
      
      <!-- 参数设置 -->
      <div class="settings">
        <h3>选择参数:</h3>
        
        <div class="setting-item">
          <label>队服号码:</label>
          <input type="number" v-model="number" />
        </div>
        
        <div class="setting-item">
          <label>队服颜色:</label>
          <select v-model="color">
            <option value="红">红色</option>
            <option value="蓝">蓝色</option>
            <option value="白">白色</option>
            <option value="黑">黑色</option>
          </select>
        </div>
        
        <div class="setting-item">
          <label>位置:</label>
          <select v-model="position">
            <option value="0">守门员</option>
            <option value="1">前锋</option>
            <option value="2">后卫</option>
          </select>
        </div>
        
        <div class="setting-item">
          <label>持杆手:</label>
          <select v-model="hand">
            <option value="0">左手</option>
            <option value="1">右手</option>
          </select>
        </div>
        
        <div class="setting-item">
          <label>风格:</label>
          <select v-model="style">
            <option value="写实">写实</option>
            <option value="乐高">乐高</option>
            <option value="国漫">国漫</option>
            <option value="日漫">日漫</option>
          </select>
        </div>
      </div>
      
      <!-- 生成按钮 -->
      <button 
        @click="generate" 
        :disabled="isGenerating"
        :class="{ generating: isGenerating }"
      >
        {{ isGenerating ? '🎨 绘制中...' : '✨ 生成形象' }}
      </button>
    </div>
    
    <!-- 右侧面板 -->
    <div class="output-panel">
      <h2>生成结果</h2>
      <div class="result-box">
        <img :src="resultUrl" v-if="resultUrl" />
        <div v-else-if="status" class="status">{{ status }}</div>
        <div v-else class="placeholder">
          👆 上传照片,选择参数,点击生成
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// ========== 响应式数据 ==========
const number = ref(10)
const color = ref('红')
const position = ref('0')
const hand = ref('0')
const style = ref('写实')

const fileInput = ref(null)
const previewUrl = ref('')
const resultUrl = ref('')
const status = ref('')
const isGenerating = ref(false)

// ========== API配置 ==========
const patToken = import.meta.env.VITE_PAT_TOKEN
const workflowId = import.meta.env.VITE_WORKFLOW_ID
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'

// ========== 方法 ==========
const handleFileSelect = () => {
  const input = fileInput.value
  if (!input.files || input.files.length === 0) return
  
  const file = input.files[0]
  const reader = new FileReader()
  
  reader.onload = (e) => {
    previewUrl.value = e.target.result
  }
  
  reader.readAsDataURL(file)
}

const uploadFile = async () => {
  const formData = new FormData()
  const input = fileInput.value
  
  if (!input.files || input.files.length === 0) {
    throw new Error('请先选择图片')
  }
  
  formData.append('file', input.files[0])
  
  const response = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  })
  
  const result = await response.json()
  
  if (result.code !== 0) {
    throw new Error(result.msg || '上传失败')
  }
  
  return result.data.id
}

const generate = async () => {
  try {
    if (isGenerating.value) return
    
    isGenerating.value = true
    status.value = '正在上传图片...'
    
    const fileId = await uploadFile()
    status.value = '图片上传成功,AI正在绘制...'
    
    const parameters = {
      picture: JSON.stringify({ file_id: fileId }),
      style: style.value,
      uniform_color: color.value,
      uniform_number: number.value,
      position: position.value,
      shooting_hand: hand.value
    }
    
    const response = await fetch(workflowUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${patToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        workflow_id: workflowId,
        parameters: parameters
      })
    })
    
    const result = await response.json()
    
    if (result.code !== 0) {
      throw new Error(result.msg || '生成失败')
    }
    
    const data = JSON.parse(result.data)
    status.value = ''
    resultUrl.value = data.ouput
    
  } catch (error) {
    status.value = '错误:' + error.message
    console.error('生成失败:', error)
  } finally {
    isGenerating.value = false
  }
}
</script>

<style scoped>
/* 样式代码同上,为了节省篇幅就不重复了 */
/* 在实际项目中,把前面的样式复制到这里 */
</style>
  • 效果图

屏幕截图 2026-02-19 195155.png

第八章:常见错误与解决方法

错误1:忘记写.value

// 错误
const count = ref(0)
count++ // ❌ 不行,count是对象

// 正确
count.value++ // ✅ 要操作.value

错误2:v-model绑定错误

<!-- 错误 -->
<input v-model="ref(10)" /> <!-- ❌ v-model要绑定变量,不是值 -->

<!-- 正确 -->
<input v-model="number" /> <!-- ✅ 绑定定义的变量 -->

错误3:忘记await

// 错误
const fileId = uploadFile() // ❌ 返回Promise,不是结果

// 正确
const fileId = await uploadFile() // ✅ 等待结果

总结

整个项目虽然简单,但涵盖了Vue开发的核心概念,希望能帮助初学者快速上手。

如果你对Coze工作流的配置感兴趣,可以参考我的以前文章:《 保姆级教程:用 Coze 打造宠物→冰球运动员拟人化工作流》

昨天以前首页

面试必考:如何优雅地将列表转换为树形结构?

2026年2月17日 19:41

面试必考:如何优雅地将列表转换为树形结构?

前言

在前端开发中,我们经常会遇到这样一个场景:后端返回的是一个扁平的列表数据,但前端需要渲染成树形结构。比如:

  • 省市区三级联动
  • 组织架构树
  • 权限菜单树
  • 商品分类树

今天我们就来深入探讨这个经典面试题,从递归到优化,一步步掌握列表转树的精髓。

第一章:理解数据结构

1.1 什么是扁平列表?

想象一下,你有一张Excel表格,每一行都是一个独立的数据,它们之间通过某个字段(比如parentId)来表明谁是谁的“爸爸”:

// 这是一个扁平的列表
const list = [
    {id: 1, name: 'A', parentId: 0},  // A是根节点(parentId为0表示没有父节点)
    {id: 2, name: 'B', parentId: 1},  // B的爸爸是A(parentId=1)
    {id: 3, name: 'C', parentId: 1},  // C的爸爸也是A
    {id: 4, name: 'D', parentId: 2}   // D的爸爸是B
]

这种数据的特点:

  • 每条数据都有一个唯一的 id(就像每个人的身份证号)
  • 通过 parentId 来表示父子关系(就像你知道你爸爸的身份证号)
  • parentId: 0 表示根节点(没有爸爸,或者爸爸是“虚拟”的根)

1.2 什么是树形结构?

树形结构就像你家的家族族谱:爷爷下面有爸爸和叔叔,爸爸下面有你和你兄弟姐妹:

// 我们希望转换成的树形结构
[
  {
    id: 1,
    name: 'A',
    parentId: 0,
    children: [  // children表示“孩子”们
      {
        id: 2,
        name: 'B',
        parentId: 1,
        children: [
          { id: 4, name: 'D', parentId: 2 }  // D是B的孩子
        ]
      },
      { id: 3, name: 'C', parentId: 1 }  // C是A的孩子,但没有自己的孩子
    ]
  }
]

1.3 为什么要转换?

后端为什么给扁平列表?因为存数据方便(只需要一张表)。 前端为什么要树结构?因为展示数据方便(树形菜单、级联选择器等)。

第二章:递归法(最直观的思路)

2.1 什么是递归?

递归就像俄罗斯套娃:一个大娃娃里面套着一个小娃娃,小娃娃里面还套着更小的娃娃...用程序的话说,就是函数调用自身

2.2 思路分析

想象你在整理家族族谱:

  1. 先找到所有没有爸爸的人(parentId: 0),他们是第一代人
  2. 然后为每个人找他的孩子:遍历整个列表,找到所有爸爸ID等于这个人ID的人
  3. 对每个孩子重复第2步(递归!)

2.3 基础版代码实现(逐行解释)

function listToTree(list, parentId = 0) {
    // result用来存放最终的结果
    // 比如第一次调用时,它用来存放所有根节点
    const result = []
    
    // 遍历列表中的每一项
    list.forEach(item => {
        // 检查当前项的父亲是不是我们要找的那个父亲
        // 比如parentId=0时,我们就在找所有根节点
        if (item.parentId === parentId) {
            
            // ★ 关键递归:找当前项的孩子
            // 把当前项的id作为新的parentId,去找它的孩子
            const children = listToTree(list, item.id)
            
            // 如果找到了孩子(children数组不为空)
            if (children.length) {
                // 给当前项添加一个children属性,把孩子们放进去
                item.children = children
            }
            
            // 把处理好的当前项放进结果数组
            result.push(item)
        }
    })
    
    // 返回这一层找到的所有人
    return result
}

2.4 代码执行过程演示

假设我们有这样的数据:

const list = [
    {id: 1, name: 'A', parentId: 0},  // 爷爷
    {id: 2, name: 'B', parentId: 1},  // 爸爸
    {id: 3, name: 'C', parentId: 1},  // 叔叔
    {id: 4, name: 'D', parentId: 2}   // 孙子
]

第一次调用listToTree(list, 0)

  • 找爸爸ID为0的人 → 找到A(id=1)
  • 调用listToTree(list, 1)找A的孩子

第二次调用listToTree(list, 1)

  • 找爸爸ID为1的人 → 找到B(id=2)和C(id=3)
  • 先处理B:调用listToTree(list, 2)找B的孩子
  • 再处理C:调用listToTree(list, 3)找C的孩子

第三次调用listToTree(list, 2)

  • 找爸爸ID为2的人 → 找到D(id=4)
  • 调用listToTree(list, 4)找D的孩子(没找到)
  • 返回[D],作为B的children

第四次调用listToTree(list, 3)

  • 找爸爸ID为3的人 → 没找到
  • 返回[],作为C的children(所以C没有children属性)

2.5 进阶版:使用ES6简化(逐行解释)

function listToTree(list, parentId = 0) {
    // 1. 先用filter过滤出当前层的所有节点
    // 比如找所有parentId等于0的根节点
    return list
        .filter(item => item.parentId === parentId)
        
        // 2. 然后用map对每个节点进行处理
        .map(item => ({
            // 这里用了三个点,后面会详细解释
            ...item,
            
            // 3. 递归找当前节点的孩子
            children: listToTree(list, item.id)
        }))
}

这段代码虽然简洁,但做了三件事:

  1. filter:从列表中筛选出符合条件的节点(比如所有根节点)
  2. map:对每个筛选出的节点进行处理
  3. 递归:为每个节点找它的孩子

2.6 递归法的优缺点

优点

  • 逻辑清晰,容易理解
  • 代码简洁优雅
  • 符合人的思维习惯

缺点

  • 时间复杂度 O(n²) - 每个节点都需要遍历整个列表
  • 列表越长,性能越差
  • 可能造成栈溢出(数据量极大时)

第三章:深入理解 ...item 的作用

3.1 如果不使用 ...item 会怎样?

很多初学者可能会这样写:

// 错误示例 ❌
map[item.id] = item
map[item.id].children = []  // 这样会修改原始数据!

3.2 为什么不能直接使用原对象?

让我们用一个生活例子来理解:

假设你有一张原始的家族成员名单

const originalList = [
    {id: 1, name: '爷爷'}
]

情况1:直接使用原对象(坏的做法)

const map = {}
map[1] = originalList[0]  // 把爷爷的原始记录放进map
map[1].children = ['孙子']  // 在原始记录上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷', children: ['孙子']}
// 哎呀!原始名单被修改了!

情况2:使用 ...item 复制(好的做法)

const map = {}
map[1] = { ...originalList[0] }  // 复制一份爷爷的记录
map[1].children = ['孙子']  // 在**副本**上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷'}
// 太好了!原始名单完好无损!

3.3 ...item 到底在做什么?

... 是JavaScript的扩展运算符,它的作用就像复印机:

const 原件 = { name: '张三', age: 18 }

// 用...复制一份
const 复印件 = { ...原件 }

// 现在原件和复印件是两份独立的数据
复印件.age = 19

console.log(原件.age)    // 18(没变)
console.log(复印件.age)  // 19(变了)

3.4 在列表转树中的应用

在我们的代码中:

map[item.id] = {
    ...item,        // 把item的所有属性复制过来
    children: []    // 再添加一个新的children属性
}

这相当于:

// 如果item是 {id: 1, name: 'A', parentId: 0}
// 那么新对象就是:
{
    id: 1,           // 从item复制来的
    name: 'A',       // 从item复制来的
    parentId: 0,     // 从item复制来的
    children: []     // 新添加的
}

3.5 什么时候必须用 ...item

必须用的场景:当你不想修改原始数据时

// 场景1:后端返回的数据,你不想污染它
const dataFromServer = [...]
const myCopy = { ...dataFromServer[0] }

// 场景2:多次使用同一份数据
const baseConfig = { theme: 'dark' }
const user1Config = { ...baseConfig, name: '张三' }
const user2Config = { ...baseConfig, name: '李四' }
// user1Config和user2Config互不影响

// 场景3:需要添加新属性,又不影响原对象
const original = { x: 1, y: 2 }
const enhanced = { ...original, z: 3 }
// original还是{x:1,y:2},enhanced是{x:1,y:2,z:3}

第四章:Map优化法(空间换时间)

4.1 为什么要优化?

递归法虽然好理解,但有个严重的问题:太慢了

想象一下:

  • 100个节点:递归法要做100×100=10000次操作
  • 1000个节点:要做1000×1000=1000000次操作
  • 10000个节点:...算了,太可怕了!

这就是我们常说的时间复杂度O(n²),数据量越大越慢。

4.2 优化思路

就像你去图书馆找书:

  • 递归法:每次找一本书都要把整个图书馆逛一遍
  • 优化法:先做一个索引表,想看什么书直接查索引

4.3 基础版代码实现(逐行解释)

function listToTree(list) {
    // 1. 第一步:创建"索引表"(map)
    // 这个map就像一个电话簿,通过id能直接找到对应的人
    const map = {}
    
    // 2. 第二步:存放最终结果(根节点们)
    const result = []
    
    // 3. 第一次遍历:把所有人都放进"电话簿"
    list.forEach(item => {
        // 对每个人,都做一份复印件(用...item复制)
        // 并且给复印件加一个空的"孩子名单"(children数组)
        map[item.id] = {
            ...item,        // 复印个人信息
            children: []    // 准备一个空的孩子名单
        }
    })
    
    // 4. 第二次遍历:建立父子关系
    list.forEach(item => {
        // 判断:这个人是不是根节点(没有爸爸)?
        if (item.parentId === 0) {
            // 是根节点:直接放进最终结果
            result.push(map[item.id])
        } else {
            // 不是根节点:找到他爸爸,把自己加入爸爸的孩子名单
            
            // map[item.parentId] 通过爸爸的ID找到爸爸
            // ?. 是可选链操作符,意思是:如果爸爸存在,就执行后面的操作
            // .children.push() 把自己加入爸爸的孩子名单
            map[item.parentId]?.children.push(map[item.id])
        }
    })
    
    // 5. 返回最终结果
    return result
}

4.4 图解Map优化法

假设有这样的数据:

原始列表:
[  {id:1, parentId:0, name:'A'},  // 根节点  {id:2, parentId:1, name:'B'},  // A的孩子  {id:3, parentId:1, name:'C'}   // A的孩子]

第一次遍历后(建立索引表):
map = {
  1: {id:1, name:'A', children:[]},
  2: {id:2, name:'B', children:[]},
  3: {id:3, name:'C', children:[]}
}

第二次遍历(建立关系):
- 处理item1: parentId=0 → result = [map[1]]
- 处理item2: parentId=1 → map[1].children.push(map[2])
- 处理item3: parentId=1 → map[1].children.push(map[3])

最终result:
[{
  id:1, name:'A',
  children: [
    {id:2, name:'B', children:[]},
    {id:3, name:'C', children:[]}
  ]
}]

4.5 使用ES6 Map版本(更专业的写法)

function listToTree(list) {
    // 使用ES6的Map数据结构代替普通对象
    // Map相比普通对象有更多优点:键可以是任何类型,有size属性等
    const nodeMap = new Map()
    const tree = []
    
    // 第一次遍历:初始化所有节点
    list.forEach(item => {
        nodeMap.set(item.id, {
            ...item,
            children: []
        })
    })
    
    // 第二次遍历:构建树结构
    list.forEach(item => {
        if (item.parentId === 0) {
            // 根节点直接加入树
            tree.push(nodeMap.get(item.id))
        } else {
            // 非根节点找爸爸
            const parentNode = nodeMap.get(item.parentId)
            if (parentNode) {
                // 把自己加入爸爸的孩子名单
                parentNode.children.push(nodeMap.get(item.id))
            }
        }
    })
    
    return tree
}

4.6 为什么返回result就是返回所有树的元素?

这是一个非常关键的知识点!很多初学者会疑惑:为什么最后返回result就能得到完整的树?

让我们用一个比喻来理解:

想象你是一个班主任,要整理全校学生的家族关系:

  1. 你有一张全校学生名单(list
  2. 你做了一个索引表(map),通过学号能快速找到每个学生
  3. 你有一个空的花名册(result),用来放每个家族的"族长"(根节点)

关键理解:当你把"族长"放进result时,这个"族长"已经通过索引表关联了所有的子孙后代!

// 实际内存中的关系
map[1] = { id:1, name:'A', children: [] }  // 族长A
map[2] = { id:2, name:'B', children: [] }  // A的儿子B
map[3] = { id:3, name:'C', children: [] }  // A的儿子C

// 建立关系后
map[1].children.push(map[2])  // 现在 map[1].children 里有 map[2] 的引用
map[1].children.push(map[3])  // 现在 map[1].children 里有 map[3] 的引用

// 把map[1]放入result
result.push(map[1])

// 此时的map[1]长这样:
{
    id: 1,
    name: 'A',
    children: [
        { id:2, name:'B', children:[] },  // 注意:这里是完整的B对象
        { id:3, name:'C', children:[] }   // 注意:这里是完整的C对象
    ]
}

重点来了:虽然我们只把A放进了result,但是A的children数组里直接存储了B和C对象的引用。也就是说:

  • result[0] 就是 A
  • result[0].children[0] 就是 B
  • result[0].children[1] 就是 C

所以通过result,我们就能访问到整棵树的所有节点!

如果有多个根节点:

// 假设还有另一个家族:D是族长,E是D的儿子
map[4] = { id:4, name:'D', children: [] }
map[5] = { id:5, name:'E', children: [] }
map[4].children.push(map[5])

// 把map[4]也放入result
result.push(map[4])

// 最终result:
[
    {  // 第一棵树
        id: 1, name:'A',
        children: [ { id:2, name:'B' }, { id:3, name:'C' } ]
    },
    {  // 第二棵树
        id: 4, name:'D',
        children: [ { id:5, name:'E' } ]
    }
]

所以返回result就是返回了所有的树,因为:

  1. 每个根节点都包含了它的所有子孙节点(通过引用)
  2. result数组收集了所有的根节点
  3. 通过这些根节点,我们可以访问到整个森林的所有节点

4.7 为什么说"空间换时间"?

  • 递归法:速度快吗?慢!占内存吗?少!(时间多,空间少)
  • Map优化法:速度快吗?快!占内存吗?多!(时间少,空间多)

就像搬家:

  • 递归法:每次需要什么都临时去买(耗时但省地方)
  • Map优化法:先把所有东西都买好放仓库(费地方但省时间)

第五章:两种方法的详细对比

对比维度 递归法 Map优化法 通俗解释
时间复杂度 O(n²) O(n) 100个数据:递归法要查10000次,Map法只要查200次
空间复杂度 O(1) O(n) 递归法基本不占额外内存,Map法需要建一个索引表
代码长度 短(3-5行) 稍长(10-15行) 递归法更简洁
可读性 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 递归法更容易理解
适用场景 小数据量(<100条) 大数据量(>100条) 根据数据量选择

第六章:实际应用场景(详细版)

6.1 省市区三级联动

// 实际开发中,后端通常只返回扁平列表
const areas = [
    {id: 1, parentId: 0, name: '中国'},
    {id: 2, parentId: 1, name: '北京'},
    {id: 3, parentId: 1, name: '上海'},
    {id: 4, parentId: 2, name: '东城区'},
    {id: 5, parentId: 2, name: '西城区'},
    {id: 6, parentId: 3, name: '黄浦区'}
]

// 转换后,就可以方便地实现三级联动选择器
const areaTree = listToTree(areas)
// 用户选择"中国"后,自动显示"北京"、"上海"
// 选择"北京"后,自动显示"东城区"、"西城区"

6.2 组织架构树

// 公司人员列表
const employees = [
    {id: 1, parentId: 0, name: '张总', position: 'CEO'},
    {id: 2, parentId: 1, name: '李经理', position: '技术总监'},
    {id: 3, parentId: 1, name: '王经理', position: '市场总监'},
    {id: 4, parentId: 2, name: '赵前端', position: '前端工程师'},
    {id: 5, parentId: 2, name: '钱后端', position: '后端工程师'},
    {id: 6, parentId: 3, name: '孙策划', position: '市场专员'}
]

// 转换后可以渲染出组织架构图
const orgTree = listToTree(employees)

6.3 权限菜单树

// 后台管理系统的菜单
const menus = [
    {id: 1, parentId: 0, name: '系统管理', icon: '⚙️'},
    {id: 2, parentId: 1, name: '用户管理', icon: '👤'},
    {id: 3, parentId: 1, name: '角色管理', icon: '🔑'},
    {id: 4, parentId: 2, name: '新增用户', permission: 'user:add'},
    {id: 5, parentId: 2, name: '编辑用户', permission: 'user:edit'}
]

// 转换后可以方便地渲染侧边栏菜单
const menuTree = listToTree(menus)

第七章:常见问题解答(FAQ)

Q1: 如果数据中有多个根节点怎么办?

A: 没问题!result 数组会包含所有根节点,形成一个"森林"(多棵树)。每个根节点都带着自己的子树。

Q2: 如果数据中有循环引用(A的爸爸是B,B的爸爸是A)怎么办?

A: 这会导致无限递归!需要先做数据校验,或者设置最大递归深度。

Q3: 什么情况下用递归法,什么情况下用Map法?

A:

  • 数据量小(<100条):用递归法,简单易懂
  • 数据量大(>100条):用Map法,性能好
  • 面试时:先说递归法展示思路,再说Map法展示优化能力

Q4: 为什么 map[item.parentId]?.children 要加问号?

A: 问号是可选链操作符,意思是:如果 map[item.parentId] 存在,才执行后面的 .children.push。防止出现"找不到爸爸"的错误。

Q5: 为什么返回result就能得到完整的树?

A: 因为每个根节点的children数组里存储的是子节点的引用,而不是复制品。当你通过result访问根节点时,实际上可以通过引用链访问到所有后代节点。

第八章:面试技巧

当面试官问到这个问题时,可以这样回答:

  1. 第一层(基础):"我可以用递归实现,先找根节点,再递归找每个节点的子节点。"

  2. 第二层(优化):"但递归的时间复杂度是O(n²),数据量大时会很慢。我可以用Map优化到O(n)。"

  3. 第三层(细节):"在实现时要注意用...item复制对象,避免修改原始数据。还要处理边界情况,比如找不到父节点的情况。"

  4. 第四层(原理):"Map优化法的核心是空间换时间,通过索引表实现O(1)查找。返回result之所以能得到完整的树,是因为JavaScript的对象引用机制——根节点的children里存储的是子节点的引用。"

  5. 第五层(应用):"这个算法在实际开发中很常用,比如我在做省市区选择器、后台管理系统菜单时都用到了。"

第九章:总结与思考

通过这篇文章,我们学习了:

  1. 什么是列表转树:把扁平数据变成树形结构
  2. 递归法:直观但性能较差
  3. ...item的作用:复制对象,避免修改原始数据
  4. Map优化法:性能好但稍微复杂
  5. 返回结果的原理:通过引用机制,根节点包含所有子孙节点
  6. 实际应用场景:省市区联动、组织架构、权限菜单等

掌握了这个算法,你不仅能应对面试,在实际开发中也能游刃有余地处理各种树形结构数据。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区提出你的问题!

从零开始学 React Hooks:useState 与 useEffect 核心解析

2026年2月15日 21:47

从零开始学 React Hooks:useState 与 useEffect 核心解析

作为 React 官方主推的语法,Hooks 让函数组件拥有了状态管理和生命周期的能力,彻底摆脱了类组件的繁琐语法,让 React 代码更贴近原生 JS。本文从纯函数与副作用的基础概念出发,由浅入深讲解useStateuseEffect两个核心 Hooks 的使用,适合 JS 初学者快速上手,所有案例均基于实战代码拆解,易懂易练。

一、前置基础:纯函数与副作用

在学习 Hooks 前,必须先理解纯函数副作用这两个核心概念,它们是 Hooks 设计的底层逻辑,也是 React 组件设计的重要原则。

1.1 纯函数

纯函数是相同输入始终返回相同输出,且无任何副作用的同步函数,这是纯函数的三大核心特征:

  1. 输入确定,输出确定:不会因外部变量、环境变化改变返回结果
  2. 无副作用:不修改函数外部的变量、不操作 DOM、不发起网络请求等
  3. 必须同步:不包含异步操作(异步会导致返回结果不确定)

纯函数示例

// 纯函数:输入x和y,输出固定的和,无任何外部影响
const add = (x, y) => x + y;
// React中useState的初始值计算函数也是纯函数
const getInitNum = () => {
  const a = 1 + 2;
  const b = 2 + 3;
  return a + b; // 输入固定,返回值永远是8
};

1.2 副作用

副作用是指函数执行过程中,对函数外部环境产生的一切影响,简单来说:非纯函数的操作,基本都是副作用

常见的副作用场景:

  • 修改函数外部的变量、数组、对象(如给数组 push 元素)
  • 发起网络请求(fetch/axios)、定时器 / 延时器(setTimeout/setInterval
  • 操作 DOM、本地存储(localStorage
  • 订阅 / 取消订阅事件

副作用示例

// 有副作用:修改了外部的nums2数组
function add(nums2) {
  nums2.push(3); // 改变外部变量,副作用
  return nums2.reduce((pre, cur) => pre + cur, 0);
}
const nums2 = [1, 2];
add(nums2);
console.log(nums2); // [1,2,3],原数组被修改

// 有副作用:包含网络请求(不确定操作)
const add2 = (x, y) => {
  fetch('https://www.baidu.com'); // 网络请求,副作用
  return x + y;
};

1.3 组件与纯函数的关系

React 函数组件的核心逻辑应该是纯函数:输入 props/state,输出固定的 JSX,不包含副作用。而所有的副作用操作,都需要交给专门的 Hooks 来处理(如useEffect),这是 React 的设计规范,能保证组件的可预测性和稳定性。

二、useState:让函数组件拥有响应式状态

useState是 React 最基础的 Hooks,作用是为函数组件添加响应式状态,并提供修改状态的方法。状态(state)就是组件中会变化的数据,也是组件的核心,状态变化时,组件会自动重新渲染,更新页面内容。

2.1 基本使用

语法

import { useState } from 'react';
// 解构赋值:state为当前状态值,setState为修改状态的方法
const [state, setState] = useState(initialValue);
  • initialValue:状态的初始值,可以是任意 JS 类型(数字、字符串、数组、对象等)
  • state:获取当前的状态值
  • setState:修改状态的方法,调用后会更新 state 并触发组件重新渲染

基础示例

import { useState } from 'react'
export default function App(){
  // 初始化数字状态,初始值为1
  const [num, setNum] = useState(1);
  return (
    // 点击div,修改num状态
    <div onClick={() => setNum(num + 1)}>
      当前数字:{num}
    </div>
  )
}

点击页面中的 div,数字会逐次加 1,页面自动更新,这就是响应式状态的核心效果。

2.2 高级用法 1:函数式初始化

如果状态的初始值需要复杂计算(如多个变量运算、循环处理),直接传值会导致每次组件渲染都重复计算,造成性能浪费。此时可以使用函数式初始化,该函数只会在组件首次挂载时执行一次,后续渲染不再执行。

语法

// 传入纯函数,返回值作为初始值
const [state, setState] = useState(() => {
  // 复杂的同步计算逻辑(纯函数,无异步、无副作用)
  return 计算后的初始值;
});

实战示例

import { useState } from 'react'
export default function App(){
  // 函数式初始化:仅首次挂载执行,计算初始值为8
  const [num, setNum] = useState(() => {
    const num1 = 1 + 2;
    const num2 = 2 + 3;
    return num1 + num2;
  });
  return (
    <div onClick={() => setNum(num + 1)}>
      初始值计算后:{num}
    </div>
  )
}

⚠️ 注意:初始化的函数必须是纯函数,不能包含异步操作(如setTimeout、网络请求),因为异步会导致初始值不确定,而 React 要求状态的初始值必须是确定的。

2.3 高级用法 2:函数式更新状态

修改状态时,setState不仅可以直接传入新值,还可以传入一个函数,该函数的参数是上一次的状态值,返回值为新的状态值。

适用场景:当新的状态值依赖于上一次的状态值时,推荐使用函数式更新,能避免因 React 状态更新的异步性导致的取值错误。

语法

setState(preState => {
  // preState:上一次的状态值(React自动传入)
  return 新的状态值;
});

实战示例

import { useState } from 'react'
export default function App(){
  const [num, setNum] = useState(1);
  return (
    // 函数式更新:preNum为上一次的num值
    <div onClick={() => setNum((preNum) => {
      console.log('上一次的数字:', preNum);
      return preNum + 1; // 返回新值
    })}>
      当前数字:{num}
    </div>
  )
}

点击 div 时,会先打印上一次的数字,再返回新值,确保状态更新的准确性。

2.4 核心注意点

  1. useState必须在函数组件的顶层调用,不能在 if、for、嵌套函数中使用(React 通过调用顺序识别 Hooks)
  2. setState异步操作,调用后不能立即获取到新的状态值
  3. 状态更新是不可变的:如果状态是对象 / 数组,不能直接修改原数据,需返回新的对象 / 数组(如setArr(pre => [...pre, newItem])

三、useEffect:处理组件的所有副作用

useEffect是 React 处理副作用的核心 Hooks,作用是在函数组件中执行副作用操作,同时它还能模拟类组件的生命周期(如挂载、更新、卸载),让函数组件拥有了生命周期的能力。

3.1 基本概念

  • useEffect的直译是副作用效果,专门用来包裹组件中的所有副作用代码
  • 组件的核心逻辑(纯函数)负责渲染 JSX,副作用逻辑(请求、定时器、DOM 操作)全部放在useEffect
  • useEffect接收两个参数:副作用函数依赖项数组

3.2 基本语法

import { useEffect } from 'react';
useEffect(() => {
  // 副作用函数:执行所有副作用操作(请求、定时器、DOM操作等)
  // 可选:返回一个清理函数
  return () => {
    // 清理函数:清除副作用(如清除定时器、取消订阅、关闭请求)
  };
}, [deps]); // 依赖项数组:控制useEffect的执行时机

3.3 三种使用场景(核心)

useEffect的执行时机完全由第二个参数(依赖项数组) 控制,分为三种核心场景,对应组件的不同生命周期阶段,这是useEffect的重点,一定要掌握!

场景 1:无依赖项数组 → 每次渲染都执行
useEffect(() => {
  console.log('每次渲染/更新都会执行');
});
  • 组件首次挂载时执行一次
  • 组件每次状态更新 / 重新渲染时都会再次执行
  • 适用场景:需要实时响应组件所有变化的副作用(较少使用,注意性能)
场景 2:空依赖项数组 [] → 仅组件挂载时执行一次
useEffect(() => {
  console.log('仅挂载时执行,模拟onMounted');
  // 示例:挂载时发起异步请求
  queryData().then(data => setNum(data));
}, []);
  • 仅在组件首次挂载到 DOM时执行一次,后续无论状态如何更新,都不会再执行
  • 对应类组件的componentDidMount生命周期,是最常用的场景
  • 适用场景:初始化请求数据、初始化定时器、添加全局事件监听等
场景 3:有依赖项的数组 [state1, state2] → 依赖项变化时执行
const [num, setNum] = useState(0);
useEffect(() => {
  console.log('num变化时执行', num);
}, [num]); // 依赖项为num
  • 组件首次挂载时执行一次
  • 只有当依赖项数组中的值发生变化时,才会再次执行
  • 对应类组件的componentDidUpdate生命周期
  • 适用场景:依赖某个 / 某些状态的副作用(如状态变化时更新定时器、重新请求数据)

3.4 清理函数:清除副作用(避免内存泄漏)

useEffect的副作用函数可以返回一个清理函数,这是 React 的重要设计,用于清除副作用,避免内存泄漏。

清理函数的执行时机
  1. 当组件重新渲染,且useEffect即将再次执行时,先执行上一次的清理函数
  2. 当组件从 DOM 中卸载时,执行清理函数
核心使用场景:清除定时器 / 延时器

定时器是最常见的副作用,如果不及时清除,组件卸载后定时器仍会运行,导致内存泄漏,useEffect的清理函数完美解决这个问题。

实战示例

import { useState, useEffect } from 'react'
export default function App() {
  const [num, setNum] = useState(0);
  useEffect(() => {
    console.log('num更新,创建新定时器');
    // 创建定时器:每秒打印当前num
    const timer = setInterval(() => {
      console.log(num);
    }, 1000);
    // 返回清理函数:清除上一次的定时器
    return () => {
      console.log('清除定时器');
      clearInterval(timer);
    };
  }, [num]); // 依赖num,num变化时执行

  return (
    <div onClick={() => setNum(pre => pre + 1)}>
      点击修改num:{num}
    </div>
  )
}

执行效果

  1. 组件挂载时,创建定时器,每秒打印 num
  2. 点击 div 修改 num,useEffect先执行清理函数清除旧定时器,再创建新定时器
  3. 组件卸载时,执行清理函数清除定时器,避免内存泄漏
其他清理场景
  • 取消网络请求(如 AbortController)
  • 移除全局事件监听(如window.removeEventListener
  • 取消订阅(如 Redux 订阅、WebSocket 订阅)

3.5 实战:结合 useEffect 实现异步请求初始化数据

前面提到,useState的函数式初始化不支持异步,因此组件挂载时的异步请求数据,需要结合useEffect(空依赖)实现,这是项目中的高频用法。

实战示例

import { useState, useEffect } from 'react'
// 模拟异步请求接口
async function queryData() {
  const data = await new Promise(resolve => {
    setTimeout(() => {
      resolve(666); // 模拟接口返回数据
    }, 2000);
  });
  return data;
}
export default function App() {
  const [num, setNum] = useState(0);
  // 空依赖:仅挂载时请求数据
  useEffect(() => {
    queryData().then(data => {
      setNum(data); // 请求成功后修改状态,更新页面
    });
  }, []);

  return <div>接口返回数据:{num}</div>;
}

组件挂载后,发起异步请求,请求成功后修改num状态,页面自动更新为接口返回的 666。

四、Hooks 的通用使用规则

除了useStateuseEffect,React 所有的 Hooks(包括自定义 Hooks)都遵循以下两条核心规则,这是 React 官方强制要求的,违反会导致组件运行异常:

4.1 只能在函数组件 / 自定义 Hooks 中调用

Hooks 只能在React 函数组件的顶层,或者自定义 Hooks中调用,不能在普通 JS 函数、类组件中使用。

4.2 只能在顶层调用,不能嵌套

Hooks 不能在 if、for、while、嵌套函数(如 useEffect 的副作用函数)中调用,必须在函数组件的顶层作用域调用。因为 React 通过调用顺序来识别和管理每个 Hooks 的状态,如果嵌套调用,会导致调用顺序混乱,Hooks 状态失效。

五、实战综合案例:条件渲染 + 副作用清理

结合useState的状态管理、useEffect的副作用处理、React 的条件渲染,实现一个完整的小案例,覆盖本文所有核心知识点:

  1. 点击页面修改数字状态,数字为偶数时渲染Demo组件,奇数时卸载
  2. Demo组件挂载时创建定时器,卸载时清除定时器
  3. 主组件的数字变化时,更新定时器并实时打印

主组件 App.jsx

import { useState, useEffect } from 'react'
import Demo from './Demo';
export default function App() {
  const [num, setNum] = useState(0);
  // 依赖num的副作用,处理定时器
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前num:', num);
    }, 1000);
    return () => clearInterval(timer);
  }, [num]);

  // 条件渲染:num为偶数时渲染Demo组件
  return (
    <div onClick={() => setNum(pre => pre + 1)} style={{ fontSize: '24px' }}>
      点击修改数字:{num}
      {num % 2 === 0 && <Demo />}
    </div>
  )
}

子组件 Demo.jsx

import { useEffect } from 'react'
export default function Demo() {
  // 空依赖:仅挂载时创建定时器,卸载时清除
  useEffect(()=>{
      console.log('Demo组件挂载');
      const timer=setInterval(()=>{
          console.log('Demo组件的定时器');
      },1000)
      // 组件卸载时执行,清除定时器
      return ()=>{
          console.log('Demo组件卸载,清除定时器');
          clearInterval(timer)
      }
  },[])
  return <div style={{ marginTop: '20px' }}>我是偶数时显示的Demo组件</div>
}

案例效果

  1. 初始 num=0(偶数),渲染 Demo 组件,Demo 挂载并创建定时器
  2. 点击一次 num=1(奇数),卸载 Demo 组件,执行 Demo 的清理函数清除定时器
  3. 每次点击修改 num,主组件的useEffect都会先清除旧定时器,再创建新定时器
  4. 组件卸载时,所有定时器都会被清除,无内存泄漏

六、总结

本文从基础的纯函数与副作用出发,讲解了 React 中最核心的两个 Hooks,核心知识点总结如下:

  1. 纯函数:相同输入返回相同输出,无副作用、同步执行;副作用:修改外部变量、请求、定时器等对外部环境的操作
  2. useState:为函数组件添加响应式状态,支持函数式初始化(复杂计算)和函数式更新(依赖上一次状态)
  3. useEffect:处理所有副作用,通过依赖项数组控制执行时机,返回清理函数清除副作用,避免内存泄漏
  4. Hooks 通用规则:仅在函数组件 / 自定义 Hooks 的顶层调用
  5. 异步请求初始化数据:使用useEffect空依赖实现,而非useState的初始化函数

useStateuseEffect是 React Hooks 的基础,掌握这两个 Hooks,就能实现大部分函数组件的开发需求。后续可以继续学习useRefuseContextuseReducer等进阶 Hooks,以及自定义 Hooks 的封装,让 React 代码更简洁、更高效。

最后:建议大家跟着本文的案例手动敲一遍代码,体会状态更新和副作用执行的时机,只有实战才能真正掌握 Hooks 的核心逻辑!

从原理到实践:JavaScript中的this指向,一篇就够了

2026年2月12日 21:54

从原理到实践:JavaScript中的this指向,一篇就够了

前言

最近在复习JavaScript的this指向问题,写了几个小例子来加深理解。很多同学觉得this难,其实是因为this是在函数执行时确定的,而不是定义时。这个特性导致了this指向的“善变”。

今天,就让我通过这几个代码例子,带你由浅入深掌握JavaScript中的this指向。


第一章:基础概念 - this的默认绑定

1.1 全局环境下的this

在浏览器全局环境中,this指向window对象:

var name = "windowName"; // 全局变量
var func1 = function() {
  console.log('func1');
}

console.log(this.name); // "windowName"
console.log(window.name); // "windowName"

核心知识点:

  • 全局作用域下,this === window
  • var声明的全局变量会自动挂载到window对象上

第二章:谁调用我,我就指向谁

2.1 对象方法调用

var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  }
}

a.func1(); // "Cherry"

关键理解: 这里的this指向了对象a。因为func1是由a调用的。

2.2 经典面试题 - 定时器中的this

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里会报错!
    }, 3000)
  }
}

a.func2(); // TypeError: this.func1 is not a function

为什么报错?

定时器的回调函数是由定时器内部调用的,此时this指向了window对象。而window对象上并没有func1方法(虽然有全局变量func1,但这里调用的是对象方法)。

验证一下:

var a = {
  // ... 同上
  func2: function() {
    setTimeout(function() {
      console.log(this); // window
    }, 3000)
  }
}

第三章:解决this丢失的三种方案

3.1 方案一:保存this(that = this)

// 3.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    var that = this; // 保存外层的this
    setTimeout(function() {
      that.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

原理: 利用闭包的特性,内部函数可以访问外部函数的变量。that保存了正确的this引用。

3.2 方案二:bind绑定

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); 
    }.bind(a), 3000) // bind永久绑定this为a
  }
}

a.func2(); // 3秒后输出 "Cherry" ✅

重要区别:

  • bind()不会立即执行,返回一个新函数,永久绑定this
  • call()/apply()立即执行函数,临时绑定this
// 对比演示
a.func1.call(a); // 立即执行
const boundFunc = a.func1.bind(a); // 返回绑定后的函数,不执行
boundFunc(); // 执行时this已经绑定为a

3.3 方案三:箭头函数

// 4.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(() => {
      console.log(this); // a对象 ✅
      this.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

箭头函数的特点:

  • 没有自己的this
  • 继承定义时所在作用域的this
  • this是静态的,不会改变

第四章:深入理解箭头函数

4.1 箭头函数没有自己的this

// 5.html 中的例子
const func = () => {
  console.log(this); // window(在浏览器环境)
}

func(); // window

4.2 箭头函数不能作为构造函数

const func = () => {
  console.log(this);
}

new func(); // TypeError: func is not a constructor ❌

为什么? 箭头函数没有自己的this,也没有prototype属性,无法进行实例化。

4.3 关于arguments对象

const func = () => {
  console.log(arguments); // ReferenceError ❌
}

// 箭头函数也没有自己的arguments对象
// 但可以这样获取参数
const func2 = (...args) => {
  console.log(args); // [1, 2, 3] ✅
}

func2(1, 2, 3);

第五章:综合实践 - 分析一段复杂代码

让我们来分析1.html中的代码,它包含了多个知识点的综合运用:

var name = "windowName";
var func1 = function() {
  console.log('func1');
}
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里原本有问题
      return function() {
        console.log('hahaha');
      }
    }.call(a), 3000) // ⚠️ 注意:这里用了call
  }
}

这里有个坑:

setTimeout的第一个参数应该是函数,但.call(a)立即执行这个函数,并把返回值作为第一个参数传给setTimeout。这里返回的是undefined,相当于:

// 实际执行效果
setTimeout(undefined, 3000)

正确写法:

// 使用bind(不会立即执行)
setTimeout(function() {
  this.func1();
}.bind(a), 3000)

// 或者使用箭头函数
setTimeout(() => {
  this.func1(); // 这里的this继承自func2
}, 3000)

第六章:面试题精选

6.1 经典组合题

var name = 'window';

var obj = {
  name: 'obj',
  fn1: function() {
    console.log(this.name);
  },
  fn2: () => {
    console.log(this.name);
  },
  fn3: function() {
    return function() {
      console.log(this.name);
    }
  },
  fn4: function() {
    return () => {
      console.log(this.name);
    }
  }
}

obj.fn1();      // 'obj' - 对象方法调用
obj.fn2();      // 'window' - 箭头函数,this指向外层window
obj.fn3()();    // 'window' - 独立函数调用
obj.fn4()();    // 'obj' - 箭头函数,this继承自fn4的this

6.2 优先级问题

function foo() {
  console.log(this.name);
}

const obj1 = { name: 'obj1', foo };
const obj2 = { name: 'obj2' };

obj1.foo();                 // 'obj1'
obj1.foo.call(obj2);       // 'obj2' - call优先于隐式绑定
const bound = foo.bind(obj1);
bound.call(obj2);          // 'obj1' - bind绑定后,call无法改变

this绑定优先级:

  1. new绑定(最高)
  2. call/apply/bind显式绑定
  3. 对象方法调用(隐式绑定)
  4. 默认绑定(独立函数调用,最低)

总结

this的指向规律其实很简单,记住这几点:

  1. 函数被调用时才能确定this指向
  2. 普通函数:谁调用我,我指向谁
  3. 箭头函数:我在哪里定义,this就跟谁一样
  4. 可以通过bind永久绑定this,call/apply临时绑定this

理解了这些,JavaScript的this问题就迎刃而解了。


练习题

// 尝试分析下面的输出
const obj = {
  name: 'obj',
  say: function() {
    setTimeout(() => {
      console.log(this.name);
    }, 100);
  }
}

obj.say(); // 输出什么?

const say = obj.say;
say(); // 输出什么?

答案和解析欢迎在评论区讨论!


如果你觉得这篇文章对你有帮助,请点赞👍收藏⭐,让更多的小伙伴看到!我们下期再见!

❌
❌