普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月9日掘金 前端

Vercel 收购 NuxtLabs!Nuxt UI Pro 即将免费!

作者 Aatrox
2025年7月9日 14:31

Vercel 收购了 Nuxt 以及背后的核心团队 NuxtLabs !

此时,Vercel 已经同时拥有了 NextNuxt 两个分别由 ReactVue 发展而来的服务端渲染方案!

Nuxt 官方是不是不用再卖课挣钱了😂

收到消息后,刷新了一下 Nuxt 的官方,发现已经置顶了一条消息!

NuxtLabs is joining Vercel

消息的大概内容为:

虽然被收购了,但是还是会专注于 NuxtNitro 的开发,继续遵循 MIT 协议

感谢xxx,感谢 xxx,感谢 xxx

还有三个接下来要发生的重要消息:

  1. 发布 Nuxt UI v4,其中 Nuxt UI Pro 组件以及 Figma Kit 将免费提供给所有人(这就是金钱的力量吗)
  2. 开源 Nuxt Studio 的自托管版本 (我的博客要退休了?)
  3. NuxtHub 独立于其他提供商支持 (Make NuxtHub agnostic to support other providers, integrating with Vercel’s Marketplace offerings like Postgres and Redis will become seamless.)

看到 NuxtLabs 终于找到了金主爸爸,我作为深度使用 Nuxt 生态的一员,也是由衷的开心啊

(有了钱,就不用把一个开源项目掰成两半去卖了!

虽然平时大部分前端接触的是 Nuxt,并且仅仅是用作 SSR,但支持 NuxtNitro 实际上也非常简单易用。搭配 Nuxt ,而不用再去使用 NestJS 来写后端。

不仅是 NitroNitro 使用的(基础库) H3,以及 unjs 这个组织下的很多包都非常好用。

比如我在所有项目内使用的 unjs/changelogen 用来自动生成 CHANGELOG.md,同时发布新版本和推送到 Github

再比如 NuxtImage 默认使用的 unjs/IPX,可以非常简单的搭建一个图片服务,并且支持你使用一个 URL 就能拥有 sharp 的大部分功能来直接转换图片格式、压缩、裁切等

再比如开发命令行工具用到的 unjs/consola 等等等等

🤩🤩🤩

希望 Nuxt 越来越好 ~~


话说有钱了,能不能把官方卖几百刀的课程也免费一下 😃

手把手实现支持百万级数据量、高可用和可扩展性的穿梭框组件

2025年7月9日 14:26

Vue 2 企业级穿梭框组件开发实践

前言

在企业级应用开发中,穿梭框是一个常见的交互组件,用于在两个列表之间进行数据的选择和移动。本文将详细介绍如何开发一个高性能、易用的 Vue 2 穿梭框组件。

需求分析

在开始开发之前,我们需要明确穿梭框组件的核心需求:

功能需求

  1. 基础功能:双列表展示,支持选择和移动
  2. 搜索功能:实时过滤列表项
  3. 全选功能:快速选择所有项
  4. 自定义渲染:支持复杂内容展示
  5. 大数据支持:处理大量数据时保持性能

非功能需求

  1. 性能:支持1000+数据项流畅操作
  2. 易用性:简洁的API设计
  3. 可扩展性:支持自定义样式和行为
  4. 无障碍:符合可访问性标准

技术选型

框架选择

  • Vue 2.7:充分利用 Composition API 特性
  • Vite:快速的开发构建工具

开发策略

  • 组件化:单一职责,易于维护
  • 响应式:数据驱动的交互逻辑
  • 性能优化:虚拟滚动、防抖等技术

核心实现

1. 组件结构设计

<template>
  <div class="transfer-container">
    <!-- 左侧面板 -->
    <div class="transfer-panel">
      <div class="transfer-header">...</div>
      <div class="transfer-body">
        <div class="transfer-search">...</div>
        <div class="transfer-list">...</div>
      </div>
      <div class="transfer-footer">...</div>
    </div>
    
    <!-- 操作按钮 -->
    <div class="transfer-buttons">...</div>
    
    <!-- 右侧面板 -->
    <div class="transfer-panel">...</div>
  </div>
</template>

2. 数据流设计

// 计算属性实现数据分离
computed: {
  leftData() {
    return this.data.filter(item => 
      !this.value.includes(item[this.keyProp])
    )
  },
  rightData() {
    return this.data.filter(item => 
      this.value.includes(item[this.keyProp])
    )
  }
}

3. 搜索功能实现

computed: {
  filteredLeftData() {
    if (!this.filterable || !this.leftFilterText) {
      return this.leftData
    }
    return this.leftData.filter(item => 
      this.renderLabel(item)
        .toLowerCase()
        .includes(this.leftFilterText.toLowerCase())
    )
  }
}

4. 全选功能实现

computed: {
  leftCheckAll: {
    get() {
      return this.leftCheckedCount === this.filteredLeftData.length 
        && this.filteredLeftData.length > 0
    },
    set(val) {
      if (val) {
        this.leftChecked = this.filteredLeftData
          .filter(item => !item.disabled)
          .map(item => item[this.keyProp])
      } else {
        this.leftChecked = []
      }
    }
  }
}

性能优化

1. 计算属性缓存

利用 Vue 的计算属性缓存机制,避免不必要的重复计算:

computed: {
  // 只有当依赖的 data 或 value 变化时才重新计算
  leftData() {
    return this.data.filter(item => 
      !this.value.includes(item[this.keyProp])
    )
  }
}

2. 事件防抖

对搜索功能进行防抖处理:

import { debounce } from 'lodash'

export default {
  data() {
    return {
      searchDebounce: debounce(this.handleSearch, 300)
    }
  }
}

3. 虚拟滚动 (可选)

对于超大数据量,可以实现虚拟滚动:

// 只渲染可见区域的数据
const visibleItems = items.slice(startIndex, endIndex)

用户体验优化

1. 加载状态

<template>
  <div class="transfer-list" v-loading="loading">
    <!-- 列表内容 -->
  </div>
</template>

2. 空状态处理

<template>
  <div class="transfer-empty" v-if="filteredLeftData.length === 0">
    <p>暂无数据</p>
  </div>
</template>

3. 过渡动画

.transfer-item {
  transition: all 0.2s ease;
}

.transfer-item:hover {
  background-color: #f5f7fa;
}

可访问性支持

1. 键盘导航

methods: {
  handleKeydown(event) {
    switch (event.key) {
      case 'ArrowUp':
        this.moveFocusUp()
        break
      case 'ArrowDown':
        this.moveFocusDown()
        break
      case ' ':
      case 'Enter':
        this.toggleSelection()
        break
    }
  }
}

2. ARIA 标签

<template>
  <div
    class="transfer-item"
    role="option"
    :aria-selected="isSelected"
    :aria-disabled="item.disabled"
  >
    <!-- 内容 -->
  </div>
</template>

测试策略

1. 单元测试

describe('Transfer Component', () => {
  test('should render correctly', () => {
    const wrapper = mount(Transfer, {
      propsData: {
        data: mockData,
        value: []
      }
    })
    expect(wrapper.exists()).toBe(true)
  })
  
  test('should handle selection', async () => {
    const wrapper = mount(Transfer, {
      propsData: {
        data: mockData,
        value: []
      }
    })
    
    const checkbox = wrapper.find('.transfer-checkbox')
    await checkbox.trigger('click')
    
    expect(wrapper.emitted().change).toBeTruthy()
  })
})

2. 性能测试

// 大数据量测试
const bigData = Array.from({ length: 10000 }, (_, i) => ({
  key: i,
  label: `Item ${i}`
}))

// 测试渲染性能
const startTime = performance.now()
const wrapper = mount(Transfer, {
  propsData: { data: bigData, value: [] }
})
const endTime = performance.now()
console.log(`渲染时间: ${endTime - startTime}ms`)

最佳实践

1. 数据结构设计

// 推荐的数据结构
const goodData = [
  {
    key: 'unique-id',        // 唯一标识
    label: '显示文本',       // 显示内容
    disabled: false,        // 是否禁用
    category: 'type1'       // 扩展字段
  }
]

// 避免的数据结构
const badData = [
  {
    id: 1,                  // 不一致的键名
    name: '文本',           // 不一致的标签名
    isDisabled: true        // 不一致的禁用字段
  }
]

2. 事件处理

// 推荐:解构参数,明确语义
handleChange(value, direction, movedKeys) {
  console.log('新值:', value)
  console.log('方向:', direction)
  console.log('移动项:', movedKeys)
  
  // 业务逻辑
  this.updateServer(value)
}

// 避免:直接使用事件对象
handleChange(event) {
  // 不清楚 event 的结构
  console.log(event)
}

3. 样式组织

/* 使用 BEM 命名规范 */
.transfer-container {}
.transfer-panel {}
.transfer-panel__header {}
.transfer-panel__body {}
.transfer-panel--disabled {}

/* 使用 CSS 变量实现主题 */
:root {
  --transfer-border-color: #dcdfe6;
  --transfer-bg-color: #fff;
  --transfer-text-color: #303133;
}

使用示例

基础示例

image.png

最简单的使用方式:

<template>
  <div>
    <Transfer
      :data="data"
      v-model="value"
      @change="handleChange"
    />
  </div>
</template>

<script>
import Transfer from './components/Transfer.vue'

export default {
  components: {
    Transfer
  },
  data() {
    return {
      data: [
        { key: 1, label: '选项1' },
        { key: 2, label: '选项2' },
        { key: 3, label: '选项3' }
      ],
      value: []
    }
  },
  methods: {
    handleChange(value, direction, movedKeys) {
      console.log('变化:', { value, direction, movedKeys })
    }
  }
}
</script>

可搜索穿梭框

image.png 启用搜索功能:

<template>
  <Transfer
    :data="data"
    v-model="value"
    :filterable="true"
    filter-placeholder="搜索..."
    left-title="源数据"
    right-title="目标数据"
  />
</template>

<script>
export default {
  data() {
    return {
      data: [
        { key: 'js', label: 'JavaScript' },
        { key: 'vue', label: 'Vue.js' },
        { key: 'react', label: 'React' },
        { key: 'angular', label: 'Angular' },
        { key: 'node', label: 'Node.js' }
      ],
      value: ['vue']
    }
  }
}
</script>

自定义渲染

image.png 使用 render-content 属性自定义显示内容:

<template>
  <Transfer
    :data="userData"
    v-model="selectedUsers"
    :render-content="renderUser"
    :filterable="true"
    left-title="用户列表"
    right-title="已选用户"
  />
</template>

<script>
export default {
  data() {
    return {
      userData: [
        { 
          key: 1, 
          name: '张三', 
          age: 25, 
          department: '技术部',
          position: '前端工程师',
          email: 'zhangsan@example.com'
        },
        { 
          key: 2, 
          name: '李四', 
          age: 30, 
          department: '产品部',
          position: '产品经理',
          email: 'lisi@example.com'
        }
      ],
      selectedUsers: []
    }
  },
  methods: {
    renderUser(user) {
      return `${user.name} (${user.position} - ${user.department})`
    }
  }
}
</script>

禁用状态

image.png 某些项目可以设置为禁用状态:

<template>
  <Transfer
    :data="permissions"
    v-model="userPermissions"
    left-title="所有权限"
    right-title="用户权限"
  />
</template>

<script>
export default {
  data() {
    return {
      permissions: [
        { key: 'read', label: '读取权限' },
        { key: 'write', label: '写入权限' },
        { key: 'delete', label: '删除权限', disabled: true },
        { key: 'admin', label: '管理员权限', disabled: true }
      ],
      userPermissions: ['read']
    }
  }
}
</script>

大数据量处理

image.png 组件支持大数据量的处理:

<template>
  <div>
    <div class="controls">
      <button @click="generateData">生成大量数据</button>
      <button @click="clearData">清空数据</button>
    </div>
    
    <Transfer
      :data="bigData"
      v-model="selected"
      :filterable="true"
      list-height="400px"
      left-title="数据源"
      right-title="已选数据"
      @change="handleChange"
    />
    
    <div class="stats">
      <p>总数据量: {{ bigData.length }}</p>
      <p>已选择: {{ selected.length }}</p>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      bigData: [],
      selected: []
    }
  },
  methods: {
    generateData() {
      const data = []
      for (let i = 1; i <= 10000; i++) {
        data.push({
          key: i,
          label: `数据项 ${i}`,
          disabled: i % 1000 === 0 // 每1000项禁用一个
        })
      }
      this.bigData = data
    },
    
    clearData() {
      this.bigData = []
      this.selected = []
    },
    
    handleChange(value, direction, movedKeys) {
      console.log(`${direction === 'right' ? '选择' : '移除'} ${movedKeys.length} 项`)
    }
  }
}
</script>

异步数据加载

结合异步数据加载:

<template>
  <div>
    <Transfer
      :data="data"
      v-model="selected"
      :filterable="true"
      left-title="远程数据"
      right-title="已选择"
      @change="handleChange"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: [],
      selected: [],
      loading: false
    }
  },
  
  async created() {
    await this.loadData()
  },
  
  methods: {
    async loadData() {
      this.loading = true
      try {
        // 模拟异步数据加载
        const response = await fetch('/api/data')
        const data = await response.json()
        this.data = data.map(item => ({
          key: item.id,
          label: item.name,
          disabled: item.disabled
        }))
      } catch (error) {
        console.error('加载数据失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    handleChange(value, direction, movedKeys) {
      // 可以在这里同步到服务器
      this.syncToServer(value)
    },
    
    async syncToServer(value) {
      try {
        await fetch('/api/selection', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ selected: value })
        })
      } catch (error) {
        console.error('同步失败:', error)
      }
    }
  }
}
</script>

表单集成

在表单中使用穿梭框:

<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label>选择技能:</label>
      <Transfer
        :data="skills"
        v-model="form.selectedSkills"
        :filterable="true"
        left-title="技能列表"
        right-title="已掌握技能"
        @change="validateSkills"
      />
      <div v-if="errors.skills" class="error">
        {{ errors.skills }}
      </div>
    </div>
    
    <div class="form-group">
      <label>选择兴趣:</label>
      <Transfer
        :data="interests"
        v-model="form.selectedInterests"
        :filterable="true"
        left-title="兴趣列表"
        right-title="选择的兴趣"
      />
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      form: {
        selectedSkills: [],
        selectedInterests: []
      },
      skills: [
        { key: 'js', label: 'JavaScript' },
        { key: 'vue', label: 'Vue.js' },
        { key: 'react', label: 'React' },
        { key: 'python', label: 'Python' },
        { key: 'java', label: 'Java' }
      ],
      interests: [
        { key: 'reading', label: '阅读' },
        { key: 'music', label: '音乐' },
        { key: 'sports', label: '运动' },
        { key: 'travel', label: '旅行' }
      ],
      errors: {}
    }
  },
  
  methods: {
    validateSkills(value) {
      if (value.length < 2) {
        this.errors.skills = '至少选择2项技能'
      } else {
        delete this.errors.skills
      }
    },
    
    handleSubmit() {
      if (this.form.selectedSkills.length < 2) {
        this.errors.skills = '至少选择2项技能'
        return
      }
      
      console.log('表单提交:', this.form)
      
      // 提交到服务器
      this.submitForm()
    },
    
    async submitForm() {
      try {
        await fetch('/api/user/profile', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(this.form)
        })
        
        alert('提交成功!')
      } catch (error) {
        console.error('提交失败:', error)
        alert('提交失败,请重试')
      }
    }
  }
}
</script>

<style scoped>
.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
}

.error {
  color: #f56c6c;
  font-size: 12px;
  margin-top: 4px;
}
</style>

高级配置

更多配置选项的使用:

<template>
  <Transfer
    :data="advancedData"
    v-model="selected"
    :filterable="true"
    :show-all-btn="true"
    left-title="高级配置源"
    right-title="高级配置目标"
    filter-placeholder="输入关键词搜索..."
    key-prop="id"
    label-prop="name"
    list-height="350px"
    :render-content="renderAdvanced"
    @change="handleAdvancedChange"
  />
</template>

<script>
export default {
  data() {
    return {
      advancedData: [
        {
          id: 'config1',
          name: '系统配置',
          type: 'system',
          level: 'high',
          description: '系统级别的配置项'
        },
        {
          id: 'config2',
          name: '用户配置',
          type: 'user',
          level: 'medium',
          description: '用户级别的配置项'
        },
        {
          id: 'config3',
          name: '临时配置',
          type: 'temp',
          level: 'low',
          description: '临时性的配置项',
          disabled: true
        }
      ],
      selected: []
    }
  },
  
  methods: {
    renderAdvanced(item) {
      const levelMap = {
        high: '高',
        medium: '中',
        low: '低'
      }
      
      return `${item.name} [${levelMap[item.level]}] - ${item.description}`
    },
    
    handleAdvancedChange(value, direction, movedKeys) {
      console.log('高级配置变化:', {
        value,
        direction,
        movedKeys,
        movedItems: movedKeys.map(key => 
          this.advancedData.find(item => item.id === key)
        )
      })
    }
  }
}
</script>

性能优化示例

针对大数据量的性能优化:

<template>
  <div>
    <div class="performance-controls">
      <button @click="generateLargeData">生成大量数据</button>
      <button @click="measurePerformance">性能测试</button>
      <div v-if="performanceData">
        <p>渲染时间: {{ performanceData.renderTime }}ms</p>
        <p>搜索时间: {{ performanceData.searchTime }}ms</p>
      </div>
    </div>
    
    <Transfer
      ref="transfer"
      :data="largeData"
      v-model="selected"
      :filterable="true"
      list-height="400px"
      left-title="大数据源"
      right-title="已选择"
      @change="handleLargeChange"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      largeData: [],
      selected: [],
      performanceData: null
    }
  },
  
  methods: {
    generateLargeData() {
      const startTime = performance.now()
      
      this.largeData = []
      for (let i = 1; i <= 50000; i++) {
        this.largeData.push({
          key: i,
          label: `数据项 ${i.toString().padStart(6, '0')}`,
          category: `分类${Math.floor(i / 1000) + 1}`,
          disabled: i % 1000 === 0
        })
      }
      
      const endTime = performance.now()
      console.log(`生成${this.largeData.length}条数据用时: ${endTime - startTime}ms`)
    },
    
    measurePerformance() {
      // 测量渲染性能
      const renderStart = performance.now()
      this.$forceUpdate()
      this.$nextTick(() => {
        const renderEnd = performance.now()
        
        // 测量搜索性能
        const searchStart = performance.now()
        // 模拟搜索操作
        const filtered = this.largeData.filter(item => 
          item.label.includes('100')
        )
        const searchEnd = performance.now()
        
        this.performanceData = {
          renderTime: (renderEnd - renderStart).toFixed(2),
          searchTime: (searchEnd - searchStart).toFixed(2)
        }
      })
    },
    
    handleLargeChange(value, direction, movedKeys) {
      console.log(`大数据操作: ${direction}, 移动${movedKeys.length}项`)
    }
  }
}
</script>

这些示例展示了 Vue 2 穿梭框组件的各种使用场景和配置方式,可以根据实际需求进行调整和扩展。

API 文档

Props

参数 说明 类型 默认值
data 数据源 Array []
value / v-model 已选中的数据 Array []
leftTitle 左侧标题 String '待选项'
rightTitle 右侧标题 String '已选项'
filterable 是否可搜索 Boolean false
filterPlaceholder 搜索框占位符 String '请输入搜索内容'
keyProp 数据项的键名 String 'key'
labelProp 数据项的标签名 String 'label'
listHeight 列表高度 String '200px'
showAllBtn 是否显示全选按钮 Boolean true
renderContent 自定义渲染函数 Function null

Events

事件名 说明 参数
change 选中项发生变化时触发 (value, direction, movedKeys)
input v-model 事件 (value)

数据格式

const data = [
  {
    key: 1,           // 必需,唯一标识
    label: '选项1',    // 必需,显示文本
    disabled: false   // 可选,是否禁用
  }
]

自定义渲染

<template>
  <Transfer
    :data="userData"
    v-model="selectedUsers"
    :render-content="renderUser"
  />
</template>

<script>
export default {
  data() {
    return {
      userData: [
        { key: 1, name: '张三', age: 25, department: '技术部' },
        { key: 2, name: '李四', age: 30, department: '产品部' }
      ],
      selectedUsers: []
    }
  },
  methods: {
    renderUser(item) {
      return `${item.name} (${item.age}岁, ${item.department})`
    }
  }
}
</script>

高级用法

大数据量处理

组件经过优化,可以处理大量数据:

// 生成大数据量测试
const bigData = []
for (let i = 1; i <= 10000; i++) {
  bigData.push({
    key: i,
    label: `选项 ${i}`,
    disabled: i % 100 === 0
  })
}

搜索过滤

启用搜索功能,支持实时过滤:

<Transfer
  :data="data"
  v-model="value"
  :filterable="true"
  filter-placeholder="搜索选项..."
/>

事件处理

methods: {
  handleChange(value, direction, movedKeys) {
    console.log('新的值:', value)
    console.log('移动方向:', direction) // 'left' 或 'right'
    console.log('移动的项:', movedKeys)
    
    // 可以在这里进行额外的业务逻辑
    if (direction === 'right') {
      this.onItemsSelected(movedKeys)
    } else {
      this.onItemsDeselected(movedKeys)
    }
  }
}

样式定制

组件提供了丰富的 CSS 类名,可以进行样式定制:

.transfer-container {
  /* 容器样式 */
}

.transfer-panel {
  /* 面板样式 */
}

.transfer-header {
  /* 头部样式 */
}

.transfer-item {
  /* 列表项样式 */
}

.transfer-item:hover {
  /* 悬停样式 */
}

.transfer-item.is-disabled {
  /* 禁用状态样式 */
}

性能优化

虚拟滚动

对于超大数据量,建议结合虚拟滚动:

// 可以考虑分页或虚拟滚动
const pageSize = 50
const currentPage = 1
const displayData = data.slice(
  (currentPage - 1) * pageSize,
  currentPage * pageSize
)

防抖搜索

搜索功能内置了防抖,但也可以自定义:

import { debounce } from 'lodash'

export default {
  data() {
    return {
      searchDebounce: debounce(this.handleSearch, 300)
    }
  },
  methods: {
    handleSearch(keyword) {
      // 搜索逻辑
    }
  }
}

无障碍支持

组件支持键盘导航和屏幕阅读器:

  • Tab 键切换焦点
  • Space 键选择/取消选择
  • Enter 键确认操作
  • 支持 ARIA 标签

兼容性

  • Vue 2.7+
  • 现代浏览器 (Chrome, Firefox, Safari, Edge)
  • IE 11+ (需要 polyfill)

常见问题

Q: 如何处理异步数据?

A: 直接绑定异步数据即可,组件会自动响应数据变化:

async created() {
  this.data = await fetchData()
}

Q: 如何实现服务端搜索?

A: 监听搜索事件,调用服务端 API:

watch: {
  leftFilterText: {
    handler: debounce(async function(keyword) {
      if (keyword) {
        this.data = await searchFromServer(keyword)
      }
    }, 300)
  }
}

Q: 如何验证选择结果?

A: 在 change 事件中进行验证:

handleChange(value, direction, movedKeys) {
  if (value.length > 10) {
    this.$message.warning('最多只能选择10项')
    return false
  }
}

贡献指南

欢迎提交 Issue 和 Pull Request。

许可证

MIT License

总结

通过以上实践,我们成功开发了一个企业级的 Vue 2 穿梭框组件,具备了以下特点:

  1. 高性能:支持大数据量渲染
  2. 易用性:简洁的 API 设计
  3. 可扩展性:支持自定义渲染和样式
  4. 健壮性:完善的错误处理和边界情况
  5. 可访问性:符合无障碍标准

这个组件可以直接用于生产环境,为用户提供流畅的数据选择体验。

后续优化

  1. TypeScript 支持:提供完整的类型定义
  2. 国际化:支持多语言
  3. 主题定制:提供更多样式变量
  4. 插件系统:支持功能扩展
  5. 移动端适配:响应式设计优化

通过不断的迭代和优化,这个组件将成为企业级应用中可靠的基础组件。

不想再写周报了?来看看这个吧!

作者 不见_
2025年7月9日 14:24

分享一个生成 Git 提交记录周报的 CLI 工具 - weekly-git-summary

最近开发了一个 CLI 工具,专门用来生成 Git 提交记录的周报汇总,特别适合需要定期汇报工作进展的开发者。

主要功能

  • 自动扫描 Git 仓库 - 支持多仓库扫描,最大深度 2 层
  • 多种输出格式 - 支持彩色终端、JSON 、Markdown 、HTML 格式
  • 灵活的时间范围 - 可以指定任意时间段,默认本周
  • 跨平台支持 - Windows/macOS/Linux 都可用
  • 零配置使用 - 开箱即用

使用场景

  • 周报/月报生成
  • 项目进展汇总
  • 代码 review 准备
  • 团队工作统计

快速开始

# 全局安装
npm install -g weekly-git-summary

# 或直接使用(推荐)
npx weekly-git-summary

# 常用命令
npx weekly-git-summary --dir ~/projects --since 2023-01-01 --until 2023-01-31
npx weekly-git-summary --author "张三" --md
npx weekly-git-summary --json

输出示例

工作内容 Git 提交记录汇总

统计时间范围: 2023-06-26  2023-07-02
搜索目录: .

📦 my-project (github.com/user/my-project)

📅 2023-07-02
   feat: 添加用户认证功能 (作者: 张三, hash: abc123)
   fix: 修复登录页面样式问题 (作者: 李四, hash: def456)

📅 2023-07-01
   docs: 更新 API 文档 (作者: 王五, hash: ghi789)

主要特性

  • 智能仓库扫描 - 自动发现子目录中的 Git 仓库
  • 多种输出格式 - 终端彩色输出、JSON 、Markdown 、HTML
  • 作者过滤 - 可以只显示特定作者的提交
  • 时间范围灵活 - 支持自定义开始和结束日期
  • Web 可视化 - 还包含一个漂亮的 Web 界面

技术栈

  • TypeScript + Node.js
  • 跨平台架构(自动选择 Bash/PowerShell/Node.js 实现)
  • 使用 Bun 构建
  • 完整的测试覆盖

项目地址: www.npmjs.com/package/wee…

有类似需求的朋友可以试试,欢迎反馈和建议!

JavaScript 事件冒泡与事件捕获

作者 yinke小琪
2025年7月9日 14:23

在讨论冒泡和捕获之前,先看这么一段代码:

<style>
  .bd {
    border: 1px solid #000;
    padding: 8px;
  }
</style>

<div id="container1" class="bd">
  外层
  <div id="container2" class="bd">
    内层
    <div id="container3" class="bd">
      最内层
      <div id="container4" class="bd">
        按钮
      </div>
    </div>
  </div>
</div>

<script>
  (() => {
    const container1 = document.querySelector('#container1')
    const container2 = document.querySelector('#container2')
    const container3 = document.querySelector('#container3')
    const container4 = document.querySelector('#container4')
    container1.addEventListener('click', () => {
      console.log('container1')
    })
    container2.addEventListener('click', () => {
      console.log('container2')
    })
    container3.addEventListener('click', () => {
      console.log('container3')
    })
    container4.addEventListener('click', () => {
      console.log('container4')
    })
  })()
</script>

页面渲染大概长这样:

74-1

点击最里面的 按钮 元素,按照思维惯例,是否应该先执行 container1 的点击事件??毕竟 container1 是最外层,而且也是最先绑定事件的元素。

然而控制台输出结果为:

container4
container3
container2
container1

有点出乎意料是吧,为什么先执行的是 container4 呢?

事件冒泡

JS 绑定的事件默认是冒泡规则,什么意思呢?可以理解为:触发事件后就像水里面有一个泡泡,在水底慢慢的往上冒,从触发事件的目标元素开始,经过一层一层的盒模型,分别触发盒模型身上绑定的事件。

所以上面代码中,在点击 按钮 时,先触发了本身绑定的 click 事件,再一层一层往外传播,最终就打印出了控制台输出的结果。

事件捕获

注意:仅默认状态下,事件是冒泡规则,在绑定事件时候,可以修改第三个配置参数改为由外向内传播,这种传播顺序就是 事件捕获 。

以上面代码为蓝本,仅添加 addEventListener 的第三个参数为 true,就将绑定规则改为了 事件捕获 。如下:

container1.addEventListener('click', () => {
  console.log('container1')
}, true)
container2.addEventListener('click', () => {
  console.log('container2')
}, true)
container3.addEventListener('click', () => {
  console.log('container3')
}, true)
container4.addEventListener('click', () => {
  console.log('container4')
}, true)

还是点击 按钮,上面代码执行结果:

container1
container2
container3
container4

事件捕获还有另一种写法,第三个参数可以传入一个对象,通过对象的 capture 属性设置为事件捕获。

container1.addEventListener('click', () => {
  console.log('container1')
}, {
  // 另一种设置事件捕获方式
  capture: true,
})

冒泡与捕获顺序

既然同一个事件有冒泡与捕获,那么冒泡与捕获的顺序如何?上例子:

container1.addEventListener('click', () => {
  console.log('冒泡:', 'container1')
})
container2.addEventListener('click', () => {
  console.log('冒泡:', 'container2')
})
container3.addEventListener('click', () => {
  console.log('冒泡:', 'container3')
})
container4.addEventListener('click', () => {
  console.log('冒泡:', 'container4')
})
container1.addEventListener('click', () => {
  console.log('捕获:', 'container1')
}, true)
container2.addEventListener('click', () => {
  console.log('捕获:', 'container2')
}, true)
container3.addEventListener('click', () => {
  console.log('捕获:', 'container3')
}, true)
container4.addEventListener('click', () => {
  console.log('捕获:', 'container4')
}, true)

同时给元素绑定两种事件,点击 按钮 执行结果:

捕获: container1
捕获: container2
捕获: container3
捕获: container4
冒泡: container4
冒泡: container3
冒泡: container2
冒泡: container1

到这里已经可以得出结论:JS 的事件传播会经历三个阶段,由 事件捕获 开始,传递到 目标元素 之后,就改为 事件冒泡,冒泡阶段完了之后事件结束。

阻止事件传播

既然事件有传播,那程序就有办法阻止事件传播。所有事件执行时都有一个 event 对象,此对象中有方法可用于阻止事件传播。

示例:

container1.addEventListener('click', () => {
  console.log('冒泡:', 'container1')
})
container2.addEventListener('click', () => {
  console.log('冒泡:', 'container2')
})
container3.addEventListener('click', () => {
  console.log('冒泡:', 'container3')
})
container4.addEventListener('click', () => {
  console.log('冒泡:', 'container4')
})
container1.addEventListener('click', (event) => {
  event.stopPropagation()
  console.log('捕获:', 'container1')
}, true)
container2.addEventListener('click', () => {
  console.log('捕获:', 'container2')
}, true)
container3.addEventListener('click', () => {
  console.log('捕获:', 'container3')
}, true)
container4.addEventListener('click', () => {
  console.log('捕获:', 'container4')
}, true)

注意 event.stopPropagation() 这个方法,此方法是阻止事件传播的关键。

以上代码在 container1 这个元素上就阻止了事件传播,所以点击 按钮 之后,仅 container1 会执行,其他所有元素都不会再触发,结果:

捕获: container1

调用 event.stopPropagation() 就是告诉 JS,事件到此为止,不再继续了。


event 对象其他常用方法和属性:

event.target:触发事件的原始元素。
event.currentTarget:当前绑定事件的元素(等同于 this)。
event.type:事件类型(如 "click")。
event.preventDefault():阻止默认行为(如表单提交、链接跳转、自定义右键菜单)。
event.stopPropagation():阻止事件冒泡。
event.stopImmediatePropagation():阻止同一元素的其他监听器执行。
event.x 和 event.y:鼠标点击位置的坐标。


在事件中要使用 this 获取元素时,必须使用 function 函数,使用箭头函数绑定的事件 this 将会指向外层作用域的 this 指针,如下代码中箭头函数 this 指向的是 Window :

<div id="container4" class="bd">
  按钮
</div>

<script>
  (() => {
    const container4 = document.querySelector('#container4')
    container4.addEventListener('click', () => {
      console.log(this) // Window 对象
    })
    container4.addEventListener('click', function () {
      console.log(this) // div#container4
    })
  })()
</script>

写在最后

编程中的细节问题,总是越挖掘越心惊,学得越来越多,才会发现知道的越来越少。

ES2025新特性详解

2025年7月9日 14:07

ES2025新特性详解

2025年6月25日,第129届Ecma大会正式批准了ECMAScript 2025的语言规范,这标志着JavaScript又迎来了一次重要的升级!

1. Import 属性与 JSON 模块支持

什么是这个特性?

以前我们要加载JSON文件,需要用fetch然后JSON.parse,现在可以直接像导入JS模块一样导入JSON文件了!

怎么用?

// 静态导入JSON文件
import configData from './config.json' with { type: 'json' };

// 动态导入JSON文件
const config = await import('./config.json', {
  with: { type: 'json' }
});

实际应用场景

1. 配置文件加载

// config.json
{
  "apiBaseUrl": "https://api.example.com",
  "theme": "dark",
  "maxRetries": 3
}

// app.js
import config from './config.json' with { type: 'json' };

// 直接使用配置
fetch(`${config.apiBaseUrl}/users`)
  .then(res => res.json())
  .then(console.log);

2. 多语言国际化

// zh.json
{
  "welcome": "欢迎",
  "logout": "退出登录"
}

// en.json
{
  "welcome": "Welcome", 
  "logout": "Log Out"
}

// i18n.js
const lang = navigator.language.startsWith('zh') ? 'zh' : 'en';
const messages = await import(`./${lang}.json`, {
  with: { type: 'json' }
});

document.getElementById('welcome').textContent = messages.default.welcome;

2. 迭代器辅助方法 (Iterator Helpers)

什么是这个特性?

以前我们处理数组要用很多map(), filter()链式调用,现在有了更优雅的迭代器方法,性能更好,语法更清晰!

基本用法

const arr = ['a', '', 'b', '', 'c', '', 'd', '', 'e'];

// 新的迭代器方法
const result = arr.values()
  .filter(x => x.length > 0)  // 过滤空字符串
  .drop(1)                    // 跳过第一个元素
  .take(3)                    // 只取前3个
  .map(x => `=${x}=`)         // 转换格式
  .toArray();                 // 转成数组

console.log(result); // ['=b=', '=c=', '=d=']

所有可用方法

  • 返回迭代器的方法.filter(), .map(), .flatMap()
  • 返回布尔值.some(), .every()
  • 返回值.find(), .reduce()
  • 不返回值.forEach()
  • 特有方法.drop(n), .take(n), .toArray()

实际应用 - 处理分页数据

function getPageLogs(logs, skip = 0, limit = 10) {
  return logs.values()
    .filter(item => item?.trim())    // 过滤空内容
    .drop(skip)                      // 跳过指定数量
    .take(limit)                     // 取指定数量
    .map(line => `[LOG] ${line}`)    // 格式化
    .toArray();                      // 转成数组
}

const logs = [
  '', '系统启动', '', '用户登录', '处理请求', '',
  '发送响应', '记录日志', '完成', '', '关闭连接'
];

console.log(getPageLogs(logs, 2, 5));
// 输出:['[LOG] 用户登录', '[LOG] 处理请求', '[LOG] 发送响应', '[LOG] 记录日志', '[LOG] 完成']

为什么它更好?

  • 惰性执行:按需处理,不会创建中间数组
  • 更省内存:适合处理大数据
  • 更广泛兼容:支持Set、Map等数据结构

3. Set 新增集合操作方法

什么是这个特性?

Set数据结构新增了数学集合运算方法,让集合操作变得超级简单!

基本用法

const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);

// 集合运算
a.union(b);                // 并集 Set {1, 2, 3, 4}
a.intersection(b);         // 交集 Set {2, 3}
a.difference(b);           // 差集 Set {1}
a.symmetricDifference(b);  // 对称差集 Set {1, 4}

// 关系判断
a.isSubsetOf(new Set([1, 2, 3, 4]));  // 是否为子集 true
a.isSupersetOf(new Set([1, 2]));      // 是否为超集 true
a.isDisjointFrom(new Set([5, 6]));    // 是否无交集 true

实际应用 - 用户权限管理

// 用户拥有的权限
const userPermissions = new Set(['read', 'write', 'comment']);

// 页面需要的权限
const pagePermissions = new Set(['read', 'write']);

// 检查用户是否有足够权限
const hasPermission = userPermissions.isSupersetOf(pagePermissions);
console.log(hasPermission); // true

// 找出用户缺少的权限
const requiredPermissions = new Set(['read', 'write', 'admin']);
const missingPermissions = requiredPermissions.difference(userPermissions);
console.log([...missingPermissions]); // ['admin']

4. 正则表达式增强

RegExp.escape() - 字符串转义

解决什么问题? 用户输入的内容可能包含正则特殊字符,直接用会出错。

// 以前这样会报错
const keyword = '.?*';
const re = new RegExp(keyword, 'g'); // ❌ 错误的正则表达式

// 现在可以安全转义
const safeKeyword = RegExp.escape('.?*'); // '\\.\\?\\*'
const safeRe = new RegExp(safeKeyword, 'g'); // ✅ 正确

// 实际应用:安全搜索
function safeSearch(text, keyword) {
  const escapedKeyword = RegExp.escape(keyword);
  const re = new RegExp(escapedKeyword, 'g');
  return text.replaceAll(re, '🔍');
}

safeSearch('这是一个.net程序', '.net'); // '这是一个🔍程序'

内联修饰符

什么是内联修饰符? 可以在正则表达式的某个部分临时启用某些选项。

// 只在部分内容中忽略大小写
/^x(?i:HELLO)x$/.test('xHELLOx'); // true
/^x(?i:HELLO)x$/.test('xhellox'); // true
/^x(?i:HELLO)x$/.test('XhelloX'); // false(开头结尾仍然区分大小写)

命名重复捕获组

解决什么问题? 以前同一个捕获组名称只能用一次,现在可以在不同分支中重复使用。

// 匹配不同格式的值,但用同一个名称
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})|(?<day>\d{2})\/(?<month>\d{2})\/(?<year>\d{4})/;

const result1 = dateRegex.exec('2025-01-15');
console.log(result1.groups); // {year: '2025', month: '01', day: '15'}

const result2 = dateRegex.exec('15/01/2025');
console.log(result2.groups); // {year: '2025', month: '01', day: '15'}

5. Promise.try() - 统一异常处理

什么是这个特性?

以前处理同步和异步混合的代码很麻烦,现在有了Promise.try(),可以统一处理!

以前的痛点

// 混合同步异步代码,异常处理很麻烦
try {
  const val = syncFunction();  // 同步函数
  await asyncFunction(val);    // 异步函数
} catch (err) {
  handleError(err);
}

现在的解决方案

// 用Promise.try()统一处理
Promise.try(() => {
  const val = syncFunction();   // 同步函数
  return asyncFunction(val);    // 异步函数
}).catch(handleError);          // 统一错误处理

实际应用

// 数据处理函数,可能同步也可能异步
function processData(data) {
  if (data.cached) {
    return data.value; // 同步返回
  } else {
    return fetchFromAPI(data.id); // 异步获取
  }
}

// 统一处理
Promise.try(() => processData(userData))
  .then(result => {
    console.log('处理结果:', result);
  })
  .catch(error => {
    console.error('处理失败:', error);
  });

6. 16位浮点数支持

什么是这个特性?

新增了对16位浮点数的支持,主要用于AI模型、图像处理、WebGPU等需要大量浮点计算但对精度要求不高的场景。

新增API

// Float16Array - 16位浮点数组
const f16Array = new Float16Array([1.0, 0.5, 0.25]);
console.log(f16Array); // Float16Array(3) [1, 0.5, 0.25]

// Math.f16round - 转换为16位浮点精度
const precise = Math.f16round(3.141592653589793);
console.log(precise); // 3.140625

// DataView支持
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);

// 写入16位浮点数
view.setFloat16(0, 3.1415);
view.setFloat16(2, 2.7182);

// 读取16位浮点数
console.log(view.getFloat16(0)); // 3.140625
console.log(view.getFloat16(2)); // 2.71875

实际应用场景

1. AI模型权重存储

// 存储神经网络权重,节省一半内存
const modelWeights = new Float16Array([
  0.1, -0.2, 0.35, -0.8, 0.12, 0.95
]);

// 节省内存:Float32Array需要24字节,Float16Array只需要12字节

2. 图像处理

// 处理图像像素值
function processImageData(imageData) {
  const pixels = new Float16Array(imageData.length);
  
  for (let i = 0; i < imageData.length; i++) {
    // 归一化到0-1范围
    pixels[i] = Math.f16round(imageData[i] / 255);
  }
  
  return pixels;
}

总结

ES2025的这些新特性让JavaScript更加强大和易用:

  1. Import JSON - 让配置文件和数据文件导入变得简单
  2. 迭代器辅助 - 提供更优雅的数据处理方式
  3. Set集合运算 - 让集合操作像数学一样直观
  4. 正则增强 - 提供更安全和灵活的文本处理
  5. Promise.try() - 统一同步异步代码的错误处理
  6. 16位浮点数 - 为AI和图形处理提供更好的性能

这些特性不仅提高了代码的可读性和可维护性,还在性能和内存使用方面带来了实质性的改进。现在就开始在你的项目中使用这些新特性吧!

浏览器支持

目前这些特性还在逐步实现中,建议在使用前检查目标浏览器的兼容性。可以使用Babel等工具进行转译,确保在老版本浏览器中也能正常运行。

css生效规则

作者 again432
2025年7月9日 13:58

css生效规则

优先级

浏览器根据优先级决定不同选择器生效。

作用范围越精准,优先级越高

计算优先级规则

  • ID:选择器中包含 ID 选择器则百位得一分。
  • 类:选择器中包含类选择器、属性选择器或者伪类则十位得一分。
  • 元素:选择器中包含元素、伪元素选择器则个位得一分。

内联样式

内联样式,即 style 属性内的样式声明,优先于所有普通的样式,无论其优先级如何。这样的声明没有选择器,但它们的优先级可以理解为 1-0-0-0;即无论选择器中有多少个 ID,它总是比其他任何优先级的权重都要高

!important

特殊的 CSS 可以用来覆盖所有上面所有优先级计算,不过需要很小心的使用——!important。用于修改特定属性的值,能够覆盖普通规则的层叠。

继承

父元素在css上的属性部分可以被子元素继承(width,margin,padding,border等不会继承)

尽管每个 CSS 属性页都列出了属性是否被继承,但我们通常可以通过常识来判断哪些属性属于默认继承。

继承属性

CSS 为控制继承提供了五个特殊的通用属性值。每个 CSS 属性都接收这些值

  • inherit
    • 设置该属性会使子元素属性和父元素相同。实际上,就是“开启继承”。
  • initial
    • 将应用于选定元素的属性值设置为该属性的初始值。
  • revert
    • 将应用于选定元素的属性值重置为浏览器的默认样式,而不是应用于该属性的默认值。在许多情况下,此值的作用类似于 unset。
  • revert-layer
    • 将应用于选定元素的属性值重置为在上一个层叠层中建立的值。
  • unset
    • 将属性重置为自然值,也就是如果属性是自然继承那么就是 inherit,否则和 initial 一样

CSS 的简写属性 all 可以用于同时将这些继承值中的一个应用于(几乎)所有属性。它的值可以是其中任意一个(inherit、initial、unset 或 revert)。这是一种撤销对样式所做更改的简便方法,以便回到之前已知的起点。

层叠

同级别的规则应用到同一个元素,此时写在后方的是实际使用的规则

有三个因素需要考虑,根据重要性排序如下,后面的更重要:

  • 资源顺序
  • 优先级
  • 重要程度

使用CSS选择器选择列表中最后一个子元素的几种情况

作者 Esun_R
2025年7月9日 13:55

1. 情况一:选择列表中最后一个子元素

假设现在有这么一个列表结构:

<style>
.list {
padding: 10px;
margin-bottom: 20px;
background-color: #f0f0f0;
}

.list-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
background-color: #fff;
margin-bottom: 20px;
border: 1px solid #ccc;
}
</style>

<div class="list">
<div class="list-item"></div>
<div class="list-item"></div>
<div class="list-item"></div>
</div>

如果我们希望最后一个 list-item 添加一个 background-color,会很快想到 last-child 选择器,并写出如下的 css:

.list-item:last-child {
background-color: pink;
}

这个选择器的意思表示在文档中最后一个子元素如果是 .list-item 的话,background-color 就设置为 pink。对上面的文档结构是生效的,但是不够严谨,假如页面中还有一个其他的 list 也嵌套了 .list-item 元素,比如:

<div class="list">
<div class="list-item"></div>
<div class="list-item"></div>
<div class="list-item"></div>
</div>
<div class="list2">
<div class="list-item"></div>
<div class="list-item"></div>
<div class="list-item"></div>
</div>

那么 .list2 中的 .list-item 也会被设置为 background-color: pink,因为它是属于 .list2 元素的 last child。

因此为了避免出现这种情况,使用 last-child 伪类的时候我们应该尽可能的指定具体是那个元素下的 last child,对于我们当前的示例来说,就是 .list 的 last child,因此,选择器应该写为:

.list .list-item:last-child {
margin-bottom: 0px;
}

但是如果存在 .list-item 元素是 .list 元素的孙子元素,比如:

<div class="list">
<div class="list-item"></div>
<div class="list-item"></div>
<div class="list-item">
<div class="list-item"></div>
<div class="list-item"></div>
</div>
</div>

.list-item 中嵌套的 .list-item 也被选择器 .list .list-item:last-child 命中:

因此我们一定要再次缩紧选择器的选中范围,这时候就要使用 > 子组合器,明确自定只有 .list 元素下的直接子元素 .list-item 是其最后一个子元素时才命中:

.list .list-item:last-child {
background-color: pink;
}

此外,如果我们对于最后一个元素的 class name 没有要求的话,也可以不用指定 classname,直接写为:

.list > :last-child { /** 等同与 .list > *:last-child */
background-color: pink;
}

就表示选中类表中任意最后一个直接子元素。

2. 情况二:选择列表中某种标签的最后一个子元素

如果想要选择列表中最后某类标签的最后一个子元素,可以使用 last-of-type 选择器:

<div class="list">
<div class="list-item"></div>
<div class="list-item"></div>
<div class="list-item"></div>
<p>This is P element</p>
<p>This is P element</p>
</div>
.list > div:last-of-type {
background-color: pink;
}

表示选中 .list 元素中的直接子元素里的最后一个 div 元素:

这时候不得不提一句 last-of-type 与 classname 相结合的情况:

.list > .list-item:last-of-type {
background-color: pink;
}

.list-item 类选择器与 :last-of-type 结合的时候,首先查看 .list-item 是什么元素,并且查看拥有 .list-item 类名的元素是否是该类元素的最后一个,举例来说:

<div class="list">
<div class="list-item"></div>
<div class="list-item"></div>
<!-- ↓ 会选中该元素 -->
<div class="list-item"></div>

<p class="list-item">This is P element</p>
<!-- ↓ 会选中该元素 -->
<p class="list-item">This is P element</p>

<span class="list-item">This is Span element</p>
<!-- ↓ 不会选中该元素,因为拥有 list-item 类名的元素不是最后一个 span 元素 -->
<span class="list-item">This is Span element</p>
<span>This is Span element</p>
</div>

3. 情况三:选择列表中某个 class name 的最后一个子元素

如果想要选中列表中最后一个 class name 为 .list-item 的元素,比如:

<div class="list">
<div class="list-item"></div>
<div class="list-item"></div>
<!-- ↓ 想选中这个元素 -->
<div class="list-item"></div>
<div>This is Div element</p>
<div>This is Div element</p>
</div>

与上面不同的是列表中 .list-item 绑定的 div 元素并不是 .list 中的最后一个 div,因此不能使用 :last-of-type

这时候我们可以使用 :nth-last-child 结合 of <selector> 语法来选中 .list 中的最后一个 .list-item 元素:

.list > :nth-last-child(1 of .list-item) {
background-color: pink;
}

上面的选择器的意思为:在 .list 的直接直接点中,从后往前数,命中 .list-item 选择器的第一个元素,也就是选取列表中的最后一个 .list-item 元素。

但是需要注意的是,虽然浏览器很早就支持了 nth-childnth-last-child,但是对于 of <selector> 语法是 CSS4 才支持的,Chrome 需大于 111(2023年发布),Safari 需 大于 9(2015年发布),IE 则完全不支持,兼容列表如下:

image.png|700

" 当Base64遇上Blob,图像转换不再神秘,让你的网页瞬间变身魔法画布! "

作者 今禾
2025年7月9日 13:34

在现代前端开发中,HTML5 的 Blob 对象为我们处理二进制数据提供了强大的支持。它不仅能够帮助我们高效地操作文件、图像、音视频等资源,还允许我们在浏览器端进行压缩、切片、转换等底层操作。

本文将带你从零开始,深入理解如何使用 Blob 对象 将一段 Base64 编码的图片数据还原为原始的二进制格式,并最终生成一个可以在网页上直接显示的 URL 地址。整个过程通俗易懂,代码解释详尽,适合初学者和进阶开发者阅读学习。


一、什么是 Blob?

Blob(Binary Large Object) 是 HTML5 中用于表示“不可变的原始二进制数据”的对象。你可以把它理解为一种“类文件”对象,它可以包含任意类型的数据,比如文本、图片、音频、视频等。

主要特点:

  • 支持多种 MIME 类型(如 image/png, application/pdf 等)
  • 可以进行切片(slice)、合并等操作
  • 可以通过 URL.createObjectURL() 创建临时访问地址

二、为什么要用 Blob 处理图像?

传统的图像加载方式通常是通过 <img src="url"> 加载服务器上的图片资源。但在某些场景下,我们需要处理的是 Base64 编码的图片字符串,例如:

  • 图像上传前预览
  • Canvas 导出图像数据
  • 前端生成图像并动态展示
  • 数据传输过程中嵌入图像信息

此时,我们可以利用 Blob 对象将 Base64 数据转换为浏览器可识别的图像资源,从而实现本地渲染或进一步操作。


三、从 Base64 到 Blob 的完整流程

1. 获取 Base64 编码的图像数据

假设你已经拥有一个 Base64 编码的图像字符串,通常是以如下形式出现:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAA...

其中 iVBORw0KG... 是实际的 Base64 编码部分。为了简化演示,我们只保留编码部分:

const base64String = 'iVBORw0KGgoAAAANSUhEUgAAAGQAAAA...'; // 实际应替换为有效Base64

⚠️ 注意:如果你的 Base64 字符串前面带有 data:image/xxx;base64, 这样的头部,请先去掉这部分再传给 atob()


2. 使用 atob 解码 Base64 数据

JavaScript 提供了内置函数 atob(),可以将 Base64 编码的字符串解码为原始的二进制字符串。

const binaryString = atob(base64String);

这个 binaryString 是一个由字节组成的字符串,每个字符代表一个 0~255 之间的数值。


3. 转换为 Uint8Array(二进制数组)

为了更方便地操作这些字节数据,我们将上述字符串转换为 Uint8Array,这是一种专门用来存储无符号 8 位整数(即单个字节)的数组类型。

const byteArray = new Uint8Array(binaryString.length);

for (let i = 0; i < binaryString.length; i++) {
    byteArray[i] = binaryString.charCodeAt(i); // 获取ASCII码值
}

这一步是关键,因为只有 Uint8Array 才能被 Blob 正确接收并解析为图像数据。


4. 构建 Blob 对象

现在我们可以使用 Blob 构造函数创建一个 Blob 对象,并指定其 MIME 类型(例如 image/jpegimage/png):

const blob = new Blob([byteArray], { type: 'image/jpeg' });

说明:

  • [byteArray] 是数据源,是一个数组,可以包含多个 ArrayBufferTypedArray
  • { type: 'image/jpeg' } 指定了该 Blob 的 MIME 类型

5. 生成临时访问 URL

有了 Blob 对象后,我们就可以使用 URL.createObjectURL(blob) 方法生成一个指向它的临时 URL:

const imageUrl = URL.createObjectURL(blob);

这个 URL 形如:

blob:https://yourdomain.com/abcd1234-5678-90ef-ghij

它是浏览器内部维护的一个引用地址,非常适合用于图像预览、下载链接、Canvas 渲染等用途。


6. 在页面中展示图像

最后,我们只需将生成的 URL 设置为 <img> 标签的 src 属性即可:

<img id="blobImage" src="" alt="From Blob" />
document.getElementById('blobImage').src = imageUrl;

这样,你的 Base64 图像就成功地被解析并显示在网页上了!


四、完整代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Blob 图像展示</title>
</head>
<body>

  <h2>使用 Blob 显示 Base64 图像</h2>
  <img id="blobImage" src="" alt="From Blob" />

  <script>
    const base64String = 'iVBORw0KGgoAAAANSUhEUgAAAGQAAAA...'; // 替换为真实Base64数据

    // 1. 解码 Base64 字符串
    const binaryString = atob(base64String);

    // 2. 转换为 Uint8Array
    const byteArray = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
        byteArray[i] = binaryString.charCodeAt(i);
    }

    // 3. 创建 Blob 对象
    const blob = new Blob([byteArray], { type: 'image/jpeg' });

    // 4. 创建临时 URL
    const imageUrl = URL.createObjectURL(blob);

    // 5. 显示图像
    document.getElementById('blobImage').src = imageUrl;
  </script>

</body>
</html>

五、常见问题与注意事项

✅ 如何获取有效的 Base64 图像字符串?

你可以通过以下几种方式获取:

  • 使用在线工具将图片转为 Base64
  • 如: www.sojson.com/image2base6…
  • 使用 <input type="file"> 读取用户上传的图片并通过 FileReader 读取为 Base64
  • 使用 Canvas 的 .toDataURL() 方法导出图像

❗ Base64 开头有 data: 部分怎么办?

例如:

data:image/png;base64,iVBORw0KG...

请先截取逗号后面的部分:

const base64Data = base64String.split(',')[1];

然后再传给 atob()

❗ 出现乱码或图像无法显示怎么办?

可能是以下原因导致:

  • Base64 字符串不完整或损坏
  • MIME 类型与实际图像格式不符(如写成 image/jpeg 但图像是 PNG)
  • 忘记去掉 data:image/xxx;base64, 头部

六、Blob 的其他实用技巧

除了图像处理,Blob 还有很多高级用途:

功能 示例
下载文件 window.open(URL.createObjectURL(blob))
分块处理大文件 使用 blob.slice(start, end)
与服务端交互 可作为 FormData 添加并发送
音频/视频播放 生成 URL 后设置为 <audio><video>src

七、总结

通过本文的学习,你应该已经掌握了以下几个核心知识点:

✅ 如何将 Base64 图像字符串还原为二进制数据
✅ 如何使用 Uint8ArrayBlob 构造图像对象
✅ 如何使用 URL.createObjectURL 生成图像链接并在页面中展示
✅ 掌握了 Blob 在前端图像处理中的强大功能

Blob 是现代 Web 开发中不可或缺的一部分,尤其在处理多媒体资源时非常有用。希望这篇文章能帮助你更好地理解和应用 HTML5 中的 Blob 技术。


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏或分享给更多需要的朋友!🌟

前端 mcp 的使用

作者 在泡泡里
2025年7月9日 13:33

figma AI Bridge

需要同时配置idea 和 figma

idea 配置

{ "mcpServers": { "Figma AI Bridge": { "command": "npx", "args": [ "-y", "figma-developer-mcp", "--stdio" ], "env": { "FIGMA_API_KEY": "xxx" } } } }

figma 配置

配置如上 另外figma 要能开启dev mode

然后再 preferences- enable dev mode mcp server 开启

image.png

开启后就可以通过鼠标右键 copy/paste as => copy link ti selection 进行对AI 提问

image.png

browser-tools

IDE 配置

trae 本身的那个我没有成功过 github.com/AgentDeskAI…

配置

{
  "mcpServers": {
    "Browser Tools": {
      "command": "npx @agentdeskai/browser-tools-mcp@latest",
      "args": [],
      "env": {}
    }
  }
}

浏览器端需要装一个插件

目前没有上架应用市场 要上面的github url 地址里找 image.png

上面的压缩包解压后加载进去就好了 image.png

image.png 成功啦! 然后就是测试下 这个mcp 能否读取控制台信息

目前只感觉这两个有用,有其他的可以一起分享呀

Vue3自定义编程式弹窗

作者 coder_zhx
2025年7月9日 12:46

前端开发中,弹窗是必不可少的重要组件,一般我们会选择使用流行的组件库提供的弹窗组件,比如Element Plusiview UI Ant Design Vue

模板式弹窗

使用这些组件库提供的弹窗,通常我们需要在template中书写Modal的代码,以iview举例:

<template>
  <Button type="primary" @click="modal1 = true">Display dialog box</Button>
  <Modal
    v-model="modal1"
    title="Common Modal dialog box title"
    @on-ok="ok"
    @on-cancel="cancel">
    <p>Content of dialog</p>
    <p>Content of dialog</p>g
    <p>Content of dialog</p>
  </Modal>
</template>
<script>
  export default {
    data () {
      return {
        modal1: false
      }
    },
    methods: {
      ok () {
        this.$Message.info('Clicked ok');
      },
      cancel () {
        this.$Message.info('Clicked cancel');
      }
    }
  }
</script>

模板式弹窗的缺陷

试想,如果我们一个页面需要很多弹窗,那就得写很多这样的模板,不利于阅读和维护。于是我们可能会将Modal抽离到单独的组件中,就变成了这样:

<template>
  <Button type="primary" @click="showAdd = true">新增</Button>
  <Button type="primary" @click="showEdit = true">编辑</Button>
  
  <AddUserModal v-if="showAdd"></AddUserModal>
  <EditUserModal v-if="showEdit"></EditUserModal>
</template>
<script>
  export default {
    data () {
      return {
        showAdd: false,
        showEdit: false
      }
    },
  }
</script>

这样看上去没问题,但是实际运行到时候会发现,弹窗关闭的时候,动画没了...
那我们不用v-if,自己实现一个v-model:show来控制弹窗的显示呗,那就变成这样:

<template>
  <Button type="primary" @click="showAdd = true">新增</Button>
  <Button type="primary" @click="showEdit = true">编辑</Button>
  
  <AddUserModal v-model:show="showAdd"></AddUserModal>
  <EditUserModal v-model:show="showEdit"></EditUserModal>
</template>
<script>
  export default {
    data () {
      return {
        showAdd: false,
        showEdit: false
      }
    },
  }
</script>

嗯,关闭的时候有动画了,但又会发现一个新的问题,弹窗组件的生命周期函数不太好用了,有时候我们可能会在每次弹窗打开的时候做一些初始化工作,比如从后台获取数据

onMounted(() => {
  fetchData()
})

你会发现弹窗显示的时候,fetchData并不会调用。原因是EditUserModal组件已经跟随父组件一起挂载了,所以不会再触发onMounted了,如果你的需求是每次弹窗显示的时候,都要调一次接口的话,就只能监听show的变化了。

watch(show, (val) => {
  if (val) {
    fetchData()
  }
}, { immediate: true})

模板式弹窗除了上面的问题以外,还有一个另我最不能忍受的痛点,就是如果某个弹窗组件需要在多个页面使用的时候,那你每一个页面都要在模板中声明它,并为它绑定show变量。这非常繁琐,而且有些时候,我们控制弹窗显示的逻辑不在vue组件内,去访问show变量可能不太方便。这时候,如果我们能通过函数调用创建弹窗就太好了,比如iview可以这样:

this.$Modal.info({
  title: 'xxx用户协议',
  content: 'xxx内容'
});

这样我们就能不依赖模板声明,直接在任何地方显示弹窗了。但可惜,它的自定义程度不高,仅能传入简单的文本或者render函数,对复杂的弹窗无能为力。Element UI甚至完全不支持这种使用方式。

所以我们需要自定义一个编程式弹窗。

编程式弹窗

编程式弹窗也叫命令式弹窗,即通过函数命令创建的弹窗。像这样:

<script setup lang="ts">
  import UserAgreementComponent from './UserAgreementComponent.vue'
  import Modal from '@/hooks/useModal'
  
  const Modal = useModal()
  
  function onClick() {
    Modal.create({
      title: '用户服务协议',
      content: UserAgreementComponent,
    })
  }
</script>

这样就不受模板限制,可以在任何js执行的地方调用方法创建弹窗,不需要管理show变量,弹窗的复用也很方便,而且弹窗的挂载跟弹窗的显示是同步的。

怎么实现编程式弹窗

首先,我们需要封装一个Modal组件,作为基础的弹窗组件模板,这里可以完全自己实现,也可以使用第三方组件,我这里选择使用Antd的弹窗组件,减少一些代码编写。

<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps({
  title: {
    type: String,
  },
  content: {
    type: Object,
  },
  footer: {
    type: Array,
    default: () => []
  },
  onClosed: {
    type: Function,
  }
})

const show = ref(true)

const contentIsText = computed(() => {
  return typeof props.content === 'string'
})

function close() {
  show.value = false
}

function closed() {
  // 弹窗关闭后,通知Service销毁dom
  props.onClosed?.()
}

// 暴露close方法,便于外部调用
defineExpose({
  close
})
</script>

<template>
  <a-modal v-model:open="show" :title="title" centered @cancel="closed">
    <component v-if="!contentIsText" :is="content" v-bind="componentParams" @close="close"></component>
    <div v-if="contentIsText" v-html="content"></div>
  </a-modal>
</template>

然后,封装一个创建弹窗的服务,Vue3中我们可以用组合式函数

import Modal from '@/components/modal.vue'
import { createVNode, getCurrentInstance, render } from 'vue'

export default function useModal() {
  const appContext = getCurrentInstance()?.appContext

  const create = (props: any) => {

    // 创建弹窗所在的父容器
    const container = document.createElement('div')
    document.body.appendChild(container)

    const destroy = () => {
      // 延迟dom的销毁,不然会影响弹窗的关闭动画
      setTimeout(() => {
        render(null, container)
        document.body.removeChild(container)
      }, 300)
    }

    // 用Modal组件生成虚拟节点
    const vnode = createVNode(Modal, {
      ...props,
      onClosed: () => {
        destroy()
      }
    })

    // 关键:绑定当前上下文,否则无法在Modal中使用当前项目引入的插件、组件
    vnode.appContext = appContext!

    // 将虚拟节点插入dom
    render(vnode, container)

    // 提供一个主动关闭的方法
    const close = () => {
      // 先调用Modal组件暴露的close方法,关闭弹窗
      vnode.component?.exposed?.close()
      // 销毁dom
      destroy()
    }

    return {
      close
    }
  }

  return {
    create
  }
}

最后,就可以在业务组件中使用了

<script setup lang="ts">
  import UserAgreementComponent from './UserAgreementComponent.vue'
  import Modal from '@/hooks/useModal'
  
  const Modal = useModal()
  
  function onClick() {
    const modal = Modal.create({
      title: '用户服务协议',
      content: UserAgreementComponent,
      componentParams: {
        id: 1,
        name: '张三'
      }
    })
    // 主动关闭
    modal.close()
  }

  function onClick2() {
    Modal.create({
      title: '提醒',
      content: '检查到新版本,请更新',
    })
  }
</script>

上面就实现了一个简单的编程式弹窗,在你的业务中,你可以为它提供更多的属性来进行更丰富的细节控制。比如用closable来控制是否显示右上角关闭按钮,使用mask-closable来控制是否允许点击遮罩层关闭弹窗等等。

其中,底部按钮是弹窗重要的组成部分,这里有两种实现方式。

一、在Modal模板中写死按钮,提供一些属性进行微调:

<template>
  <a-modal v-model:open="show" :title="title" centered @cancel="closed">
    <component v-if="!contentIsText" :is="content" v-bind="componentParams" @close="close"></component>
    <div v-if="contentIsText" v-html="content"></div>
    <template #footer>
      <a-button @click="onCancel">{{ cancelText || '取消' }}</a-button>
      <a-button type="primary" @click="onOk">{{ okText || '确定' }}</a-button>
    </template>
  </a-modal>
</template>
<script setup lang="ts">
  ...
  function onClick() {
    const modal = Modal.create({
      title: '用户服务协议',
      content: UserAgreementComponent,
      cancelText: '不同意',
      onCancel: () => {
        modal.close()
      },
      okText: '同意',
      onOk: () => {
        // do something
        modal.close()
      },
    })
  }
</script>

二、从外部传入按钮数组

<template>
  <a-modal v-model:open="show" :title="title" centered @cancel="closed">
    <component v-if="!contentIsText" :is="content" v-bind="componentParams" @close="close"></component>
    <div v-if="contentIsText" v-html="content"></div>
    <template #footer>
      <a-button v-for="item in footer" :key="item.text" :type="item.type" 
        @click="item.onClick()">
        {{ item.text }}
      </a-button>
    </template>
  </a-modal>
</template>
<script setup lang="ts">
  ...
  function onClick() {
    const modal = Modal.create({
      title: '用户服务协议',
      content: UserAgreementComponent,
      footer: [
        {
          text: '不同意',
          type: 'default',
          onClick: () => {
            modal.close()
          },
        },
        {
          text: '同意',
          type: 'primary',
          onClick: () => {
            modal.close()
          },
        },
      ]
    })
  }
</script>

你还可以将footer设置为空数组,在UserAgreementComponent组件内部自定义底部按钮。但是这样的话,你可能需要在UserAgreementComponent内部进行弹窗的关闭。可以这样:

<script setup lang="ts">
const emit = defineEmits(['close'])
function onOk() {
  // do something
  close()
}

function close() {
  emit('close')
}
</script>
<template>
  <div>
    <div>用户协议xxxx</div>
    <div class="footer">
      <a-button @click="close">不同意</a-button>
      <a-button type="primary" @click="onOk">同意</a-button>
    </div>
  </div>
</template>

至此,我们就封装了一个支持高度自定义的弹窗组件,希望它能为你的开发提供一些便利。

从0到1理解JS事件委托:让你的代码性能提升一个level

作者 南方kenny
2025年7月9日 12:46

前言:

最近在学习JS事件机制时,被「事件委托」这个概念搞得晕头转向,看了很多文章还是似懂非懂。直到我动手敲了几个demo,突然就豁然开朗了!今天就用最接地气的方式,带大家一起搞懂事件委托到底是个啥,以及它能解决什么实际问题。

什么是事件流?

我们先从基础说起。当你点击页面上的一个按钮,这个点击事件可不是直接就触发了——它其实经历了三个阶段:

1.捕获阶段 :事件从window开始,像石头沉水底一样一层层往下找目标元素(document → html → body → ... → 目标元素)

2.目标阶段 :事件终于找到并触发在目标元素上

3.冒泡阶段 :事件又像水泡一样往上冒,回到window(目标元素 → ... → body → html → document → window)

举个生活例子:就像快递配送,先从全国总仓(window)发到城市仓(document),再到区仓(body),最后送到你家(目标元素)——这是捕获;然后快递员还要回去交差——这就是冒泡。

举个简单例子:现在有一个大一点的parent父元素块,小一点的child子元素块

    <style>
      #parent {
        width: 200px;
        height: 200px;
        background-color: red;
      }
      #child {
        width: 100px;
        height: 100px;
        background-color: blue;
      }
    </style>
    <...>
    <div id="parent">
      <div id="child"></div>
    </div>

image.png

我们现在为他们绑定一个点击事件:

document.getElementById("parent").addEventListener("click", function (e) {
   console.log("parent");
});
document.getElementById("child").addEventListener("click", function (e) {
   console.log("child");
});

我们发现单独点击红色块时只打印了parent,但是点击蓝色块时打印了两个值,说明点击子元素时父元素绑定的事件也会执行 image.png 为什么会这样呢?我们再看看事件流的三个阶段:捕获阶段->目标阶段->冒泡阶段

当点击蓝色child元素时,监听器是在冒泡阶段响应点击事件,输出 child 后从child元素向上传播到parent元素再次响应点击事件,最后到window对象。

我们可能会想:为什么一定是在冒泡阶段执行呢?不能在捕获阶段吗?当然可以,但是首先得让我们看addEventListener函数的语法:

addEventListener(type,listener,useCapture)

image.png 现在我们清楚了,原来useCapture默认为false,这导致监听器在捕获阶段时不会触发listener,而是向上冒泡时触发,所以我们将useCapture改为true的话就会在捕获阶段执行。

如果想要点击子元素时只执行当前监听器所绑定的事件,只需要阻止事件冒泡即可:

document.getElementById("parent").addEventListener("click", function (e) {
    console.log("parent");
});
document.getElementById("child").addEventListener("click", function (e) {
    // 阻止事件冒泡
    e.stopPropagation();
    console.log("child");
});

为什么需要事件委托?

先看一个反面教材。假设我们有个列表,要给每个列表项添加点击事件:

<ul id="list">
  <li>item1</li>
  <li>item2</li>
  <li>item3</li>
</ul>

那么我们可能就会直接为每个li单独绑定事件:

// 单独绑定
const lis = document.querySelectorAll('#list li');
for (let item of lis) {
  item.addEventListener('click', function(e) {
    console.log(e.target.innerText);
  });
}

我们会发现这种写法有两个致命问题:

性能差 :如果有1000个列表项,就要绑定1000个事件监听,浏览器表示压力山大

动态元素失效 :后来动态添加的li(比如点击按钮新增的)不会有点击事件

所以这个时候就需要我们的事件委托派上用场了。

事件委托:一招解决所有烦恼

什么是事件委托?

简单说就是: 把事件绑定在父元素上,利用事件冒泡机制,让父元素帮子元素处理事件

就像公司里,员工(子元素)不用每个人都直接对接CEO(浏览器),而是通过部门经理(父元素)统一汇报工作。

基本实现

// 委托给父元素ul
document.getElementById('list').addEventListener('click', function(e) {
  // e.target就是实际点击的li
  console.log(e.target.innerText);
});

就这么简单!不管有多少个子元素,我们只需要绑定 一个事件监听。

事件委托实战案例

案例1:处理动态添加的元素

假设我们有个按钮,可以动态添加列表项:

<ul id="myList">
  <li data-item="l1">Item 1</li>
  <li data-item="l2">Item 2</li>
  <li data-item="l3">Item 3</li>
  <li data-item="l4">Item 4</li>
</ul>
<button id="btn">添加节点</button>

用事件委托的话,新增的li自动就有点击事件了:

// 委托给父元素myList
document.getElementById('myList').addEventListener('click', function(e) {
  console.log(e.target.innerText);
});

// 动态添加li
document.getElementById('btn').addEventListener('click', function() {
  const li = document.createElement('li');
  li.appendChild(document.createTextNode('newItem'));
  document.getElementById('myList').appendChild(li);
});

关键点:新增的li不需要再绑定事件,因为事件是委托给父元素的!

案例2:点击外部关闭菜单

这是个非常实用的场景:点击Menu显示菜单,点击菜单内部不关闭,点击其他地方关闭菜单。

  <style>
    #toggleBtn{
      cursor:pointer
    }
    #menu{
      display:none;
      position:absolute;
      top: 50px;
      left: 50px;
      background-color: pink;
      width: 100px;
      height: 100px;
    }
  </style>
  <...code...>
<div id="toggleBtn">Toggle Menu</div>
<div id="menu">
  <p>Menu Context</p>
</div>

实现思路:

  1. 给toggleBtn绑定点击事件,显示菜单
  2. 给菜单绑定点击事件,阻止冒泡(关键!)
  3. 给document绑定点击事件,关闭菜单
// 点击Menu显示/隐藏菜单
 toggleBtn.addEventListener('click', function(e) {
   menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
   e.stopPropagation(); // 阻止冒泡到document
 });

// 点击菜单内部不关闭
menu.addEventListener('click', function(e) {
  e.stopPropagation(); // 关键:阻止事件冒泡
});

// 点击页面其他地方关闭菜单
document.addEventListener('click', function() {
  menu.style.display = 'none';
});

这里的 e.stopPropagation() 就像「拦截快递」,不让事件继续冒泡上去。

MenuTest_converted.gif

事件委托最佳实践

1. 精准判断目标元素

有时候父元素里有多种子元素,我们需要判断点击的是哪种元素:

// 只处理li元素的点击
if(e.target.tagName.toLowerCase() === 'li') {
  console.log('这是列表项:', e.target.innerText);
}

// 或者根据自定义属性判断
if(e.target.dataset.item) {
  console.log('item属性值:', e.target.dataset.item);
}

2. 委托到最近的静态父元素

不要把所有事件都委托到document上,而是委托到 离目标元素最近的、不会被动态删除的父元素 。

3. 注意事件冒泡的影响

如果子元素也有事件监听,要注意事件执行顺序。必要时用 e.stopPropagation() 阻止冒泡,但不要过度使用,可能会影响其他功能。

事件委托优缺点总结

优点

✅ 性能优化 :减少事件监听数量 ✅ 动态友好 :自动支持动态添加的元素 ✅ 代码简洁 :只需写一次事件处理逻辑

缺点

❌ 不适合所有事件:如focus、blur等没有冒泡的事件 ❌ 过度使用可能导致逻辑复杂

什么时候用事件委托

  • 列表项点击事件
  • 动态生成的元素(如评论、商品列表)
  • 页面有大量相似元素需要绑定事件
  • 需要实现点击外部关闭的功能

总结

事件委托就像生活中的「代理模式」,通过父元素代理子元素的事件,既提高了性能,又解决了动态元素的问题。核心就是利用事件冒泡机制,记住这句话: 「事件委托,一劳永逸」 。

希望这篇文章能帮你彻底搞懂事件委托!有问题欢迎在评论区交流!

原生JS与React的事件差异

作者 归于尽
2025年7月9日 12:36

在从原生 JavaScript 转向 React 开发时,我曾对事件处理产生过不少困惑:为什么 React 的事件写法和原生类似,行为却有差异?为什么有时候 e.target 会莫名变成 null?为什么父组件的事件会比子组件先触发?

带着这些疑问,我翻了不少文档逐渐理清了原生JS和React事件中的差异。下面我将成果分享给大家!

原生 JavaScript 事件机制

DOM0 与 DOM2

DOM 标准的发展催生了不同的事件处理模型。DOM0 级事件作为最早的实现方式,通过直接赋值事件属性完成绑定:

// HTML 内联方式
<button onclick="handleClick()">点击</button>

// JS 赋值方式
const btn = document.querySelector('button');
btn.onclick = handleClick;

DOM0 级事件的局限性显而易见:同一事件只能绑定一个处理函数,后续绑定会覆盖前者,且缺乏事件阶段控制能力。

DOM2 级事件通过 addEventListener 方法实现了更完善的事件处理机制,其函数签名为:

target.addEventListener(type, listener, useCapture);

type:事件类型(如 clickkeydown

listener:事件处理函数

useCapture:布尔值,指定事件在捕获阶段(true)还是冒泡阶段(false)触发,默认值为 false

DOM2 级事件支持为同一元素的同一事件绑定多个处理函数,且能通过 removeEventListener 精准移除,解决了 DOM0 级的核心痛点。

为什么没有DOM1级事件?

这是因为DOM1专注文档结构标准化,未定义事件模型。当时浏览器厂商已有各自事件实现(如DOM0的onclick),W3C暂未统一,直到DOM2(2000年)才标准化addEventListener。

事件流

浏览器对事件的处理遵循事件流模型,完整流程包含三个阶段:

  1. 捕获阶段
    事件从最顶层的 document 开始,逐层向下传播至目标元素的父节点,此阶段的目的是让上层节点有机会提前拦截事件。
  2. 目标阶段
    事件到达实际触发的目标元素(event.target),此时无论 useCapture 为何值,都会执行该元素上的事件处理函数。
  3. 冒泡阶段
    事件从目标元素逐层向上传播至 document,这是事件委托机制的基础。

通过以下代码示例可清晰观察事件流执行顺序:

<div class="outer">
  <div class="inner">点击区域</div>
</div>
const outer = document.querySelector('.outer');
const inner = document.querySelector('.inner');

// 捕获阶段触发
outer.addEventListener('click', () => console.log('outer capture'), true);
// 冒泡阶段触发
outer.addEventListener('click', () => console.log('outer bubble'), false);
// 目标元素事件
inner.addEventListener('click', () => console.log('inner target'), false);

点击 inner 元素后,控制台输出顺序为:

outer capture  // 捕获阶段:从外层到内层
inner target   // 目标阶段:触发目标元素事件
outer bubble   // 冒泡阶段:从内层到外层

理解事件流是掌握事件委托、阻止冒泡等高级用法的前提。

事件委托

事件委托(Event Delegation)  利用事件冒泡特性,将子元素的事件处理委托给父元素,通过 event.target 识别具体触发元素。其核心实现如下:

<div id="root">
    <ul id="myList">
        <li data-item="123">Item 1</li>
        <li data-item="456">Item 2</li>
        <li data-item="789">Item 3</li>
        <li data-item="012">Item 4</li>
    </ul>
    <div id="container" data-item="ab">hello</div>
</div>
document.getElementById('root').addEventListener('click', function(event) {
    if (event.target.tagName === 'LI') {
        console.log('Clicked on:', event.target.textContent);
    }   
    if(event.target.dataset.item === 'ab')
    {
        console.log('Clicked on:', event.target.textContent);
        const newLi = document.createElement('li');
        newLi.appendChild(
            document.createTextNode('item-new')
        )
        newLi.addEventListener('click', function() {
            console.log('haha');
        })
        document.getElementById('myList').appendChild(newLi)
    }

});

以上面的代码为例,如果我们要给所有li元素添加点击事件,最直观的做法可能是循环遍历每个li,逐个绑定事件处理函数。但这样做存在明显的隐患:当列表项数量庞大(比如成百上千个)时,每一个li都会创建一个独立的事件处理函数并与 DOM 节点绑定,这不仅会占用大量内存,还会增加页面初始化时的加载时间 —— 毕竟 DOM 操作本身就是性能消耗的重灾区。

而我们采取的方法则巧妙得多:我们只需要给所有li的共同祖先元素root注册一次点击事件,就能实现对所有li的事件监听。这背后的核心原理就是事件委托机制 —— 利用 DOM 事件的冒泡特性,当点击某个li时,事件会从这个li逐级向上冒泡,最终被root元素的事件处理函数捕获。

root的事件处理函数中,我们通过event.target可以精准定位到实际被点击的li元素(event.target始终指向触发事件的最具体节点)。再通过判断event.target.tagName === 'LI',就能确保只有点击li时才执行对应的逻辑,完全等效于给每个li单独绑定事件的效果,但只需要一次事件绑定操作。

这种方式的优势不止于性能优化:当页面存在动态生成的li元素时(比如代码中点击data-item="ab"div后新增的li),这些新元素无需重新绑定事件,因为它们的点击事件会自动冒泡到root,被早已注册好的事件处理函数捕获并处理。这就避免了动态元素频繁绑定 / 解绑事件的繁琐操作,也减少了因忘记解绑事件而导致内存泄漏的风险。

事件委托的技术优势:

  1. 性能优化
    减少事件绑定数量,尤其在列表等包含大量子元素的场景中,可显著降低内存占用与初始化时间。
  2. 动态元素支持
    对于通过 AJAX 动态加载的元素,无需重新绑定事件,父元素的委托机制天然支持新元素的事件处理。
  3. 统一管理
    集中处理同类事件,便于统一添加日志、权限校验等横切逻辑。

还有一点要说的是,事件委托常与 data-* 属性结合使用,通过唯一标识快速定位目标元素,通过上面的代码也能看出,每一个元素都有一个唯一标识

阻止冒泡

<div id="toggleBtn">Toggle Menu</div>
<div id="menu">
    <p>Menu Context</p>
    <a href="www.baidu.com" id="closeInside">Don't close</a>
</div>
const toggleBtn = document.getElementById('toggleBtn');
const menu = document.getElementById('menu');
const closeInside = document.getElementById('closeInside');

toggleBtn.addEventListener('click',function(e){
    e.stopPropagation()
   menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
})

document.addEventListener('click',function(e){
    menu.style.display = 'none';
})
closeInside.addEventListener('click',function(e){
    e.preventDefault()
    e.stopPropagation();
    alert('Menu button onClicked')
})

这段代码实现了一个简单的菜单交互:

  • 点击toggleBtn("Toggle Menu" 按钮),菜单menu会切换显示 / 隐藏状态
  • 点击页面其他区域(除了菜单内部和按钮),菜单会隐藏
  • 点击菜单内的closeInside链接,会弹出提示,但不会跳转,也不会关闭菜单

实现上面的功能有两个关键点

  1. preventDefault():阻止默认行为。

每个 HTML 元素都有其默认行为(比如链接点击会跳转、表单提交会刷新页面)。preventDefault()的作用是取消元素的默认行为,但不会影响事件的冒泡传播。代码中closeInside(菜单内的链接)的点击事件处理函数用到了这个方法,链接<a href="www.baidu.com">的默认行为是点击后跳转到www.baidu.com,加上e.preventDefault()后,点击链接不会跳转,只会执行后续的alert提示,如果去掉这句,点击链接会先弹出提示,然后跳转到百度,这显然不符合 "Don't close" 的预期。

  1. stopPropagation():阻止事件冒泡

事件冒泡是指事件从触发元素开始,逐级向上传播到父元素、根元素的过程。stopPropagation()的作用就是中断这个传播过程,让事件不再向上传递。当我们点击toggleBtn时,会触发按钮的click事件, 如果没有e.stopPropagation(),这个click事件会继续向上冒泡,最终被documentclick事件监听器捕获,而document的事件处理函数会执行menu.style.display = 'none',导致刚打开的菜单立刻被关闭。正因为e.stopPropagation()阻止了事件冒泡,toggleBtn的点击事件才不会传递到document,菜单才能正常切换显示状态。

阻止冒泡的优势

  1. 避免上级事件误触发:防止事件向上传播导致父元素的同类型事件被意外执行(如弹窗内操作不触发外部关闭逻辑)。
  2. 提升处理精准性:在复杂嵌套结构中,确保事件仅作用于目标元素,避免上级逻辑干扰。
  3. 优化性能:减少不必要的事件传播,避免冗余处理函数执行

React 事件机制

React 并未直接使用原生 DOM 事件,而是构建了一套合成事件系统(SyntheticEvent) ,在保持开发体验一致的同时,解决了跨浏览器兼容性问题,并提供了性能优化手段。

为什么需要合成事件?

最核心的原因有两个:

1. 跨浏览器兼容性
不同浏览器的事件实现存在差异(比如 IE 浏览器的 attachEvent 和标准的 addEventListener),React 通过合成事件将这些差异抹平,让开发者不用关心底层浏览器的实现细节。

2. 性能优化
React 会将所有事件委托到顶层容器(在 React 17 之前是 document,之后改为 React 根节点),而不是每个元素单独绑定事件。这种设计大幅减少了 DOM 事件绑定的数量,尤其在大型应用中能显著提升性能。 举个例子,一个包含 1000 个列表项的组件,原生开发可能需要绑定 1000 个 click 事件,而 React 只会在根节点绑定 1 个事件,通过事件委托处理所有列表项的点击。

合成事件的核心特性

1. 与原生事件相似的 API

React 合成事件的接口设计和原生事件几乎一致,比如都有 e.targete.preventDefault()e.stopPropagation() 等,这让开发者能快速上手。

但要注意,合成事件是 React 自己实现的对象,并非原生 DOM 事件对象。不过可以通过 e.nativeEvent 属性获取原生事件:

const handleClick = (e) => {
  console.log(e instanceof SyntheticEvent); // true
  console.log(e.nativeEvent instanceof Event); // true(原生事件对象)
};

2. 事件池(Event Pooling)

为减少内存分配与垃圾回收开销,React 会对合成事件对象进行复用。事件处理函数执行完毕后,事件对象的属性会被清空并放回事件池。这也是为什么在异步操作中直接访问事件属性会得到 null

const handleClick = (e) => {
  console.log(e.target); // 正常输出
  setTimeout(() => {
    console.log(e.target); //  null
  }, 0);
};

第一种解决方案是提前保存需要的属性

const handleClick = (e) => {
  const target = e.target; // 提前保存 e.target
  setTimeout(() => {
    console.log(target); // 正常输出
  }, 0);
};

;第二种解决方案是使用 e.persist() 方法将事件对象从池中取出:

const handleClick = (e) => {
  e.persist(); // 保留事件对象
  setTimeout(() => {
    console.log(e.target); // 正常输出
  }, 0);
};

注意:React 17 及以上版本对事件池机制进行了调整,大部分场景下无需手动调用 persist(),但了解其原理仍有助于理解历史代码。

React 事件的执行流程

  1. 事件捕获
    原生 DOM 事件触发后,冒泡至 React 顶层容器,被 React 事件系统捕获。
  2. 事件分发
    React 内部维护了事件插件系统(Event Plugins),根据事件类型(如 onClickonChange)找到对应的处理插件,生成合成事件对象。
  3. 模拟事件流
    React 会模拟原生事件的捕获与冒泡流程,依次执行组件树中对应的事件处理函数。与原生事件不同的是,React 事件的冒泡仅在虚拟 DOM 层面进行,不会影响原生 DOM 事件流。
  4. 事件处理
    执行用户定义的事件处理函数,传入合成事件对象,该对象与原生事件对象类似,包含 targetpreventDefault() 等常用属性与方法。
import React from 'react';

function EventFlowExample() {
  // 父组件事件处理函数(冒泡阶段)
  const handleParentBubble = (e) => {
    console.log('父冒泡:', e.target.textContent);
  };

  // 父组件事件处理函数(捕获阶段)
  const handleParentCapture = (e) => {
    console.log('父捕获:', e.target.textContent);
  };

  // 子组件事件处理函数(冒泡阶段)
  const handleChildBubble = (e) => {
    console.log('子冒泡:', e.target.textContent);
    // 尝试阻止冒泡(仅影响 React 事件流)
    // e.stopPropagation();
  };

  // 子组件事件处理函数(捕获阶段)
  const handleChildCapture = (e) => {
    console.log('子捕获:', e.target.textContent);
  };

  return (
    <div 
      className="parent" 
      onClick={handleParentBubble}
      onClickCapture={handleParentCapture}
    >
      Parent Element
      <div 
        className="child" 
        onClick={handleChildBubble}
        onClickCapture={handleChildCapture}
      >
        Child Element
      </div>
    </div>
  );
}

输出顺序是

父捕获: Child Element
子捕获: Child Element
子冒泡: Child Element
父冒泡: Child Element

React 事件与原生事件的交互

在实际开发中,难免会遇到 React 事件与原生事件混用的场景,最需要注意的是两者的执行顺序和冒泡行为:

  1. 执行顺序
    原生事件的处理函数会比 React 事件的处理函数先执行。 因为 React 事件依赖原生事件冒泡到根节点才会触发,而原生事件在冒泡过程中就会执行自己的处理函数。
const App = () => {
  useEffect(() => {
    // 原生事件
    document.body.addEventListener('click', () => {
      console.log('原生事件');
    }, false);
  }, []);

  // React 事件
  const handleClick = () => {
    console.log('React 事件');
  };

  return <button onClick={handleClick}>点击</button>;
};

点击按钮后,输出顺序为:原生事件 → React 事件。若在原生事件中添加 e.stopPropagation(),则 React 事件不会触发。

  1. 冒泡阻止
  • 原生事件中调用 e.stopPropagation() 会阻止事件冒泡至顶层容器,导致 React 事件无法触发。

  • React 合成事件中调用 e.stopPropagation() 仅阻止 React 内部的事件冒泡,不会影响原生事件流, 如需同时阻止原生事件,需使用 e.nativeEvent.stopPropagation()

总结

从原生 JS 的事件委托到 React 的事件系统,核心思想是一致的:利用事件冒泡,通过父元素处理子元素的事件,优化性能和扩展性。希望这篇文章能帮助和我代码开发者少走弯路,夯实基础。如果有不对的地方,欢迎大神在评论区指正~

当 React 组件调用自定义 hooks,hooks 又调用其他 hooks 时,状态变化如何传播?

作者 Link1987
2025年7月9日 12:12

当 React 组件调用自定义 hooks,hooks 又调用其他 hooks 时,状态变化如何传播?

案例分析

// 调用链: React App → Component → useQuote → useAnotherQuote
React App {
  return <Component />
}

Component {
  const quoteResult = useQuote();  // 调用自定义hook
}

useQuote() {
  const anotherQuoteRes = useAnotherQuote();  // 调用另一个hook
  const quoteRes = useSWR(key, fetcher);    // 调用useSWR
}

useAnotherQuote() {
  const { data, isLoading } = useSWR(key, fetcher);  // 调用useSWR
}

React Hooks 基本机制

1. Hook 状态存储位置

// Hook 状态绑定到调用组件
Component 组件实例 = {
  hookStates: [
    useQuote中的useSWR状态,
    useAnotherQuote中的useSWR状态,
    // ... 其他hook状态
  ]
}

2. Hook 执行规则

  • Hook 只在 Component 渲染时执行
  • Component 不渲染 = hooks 不执行
  • Hook 产生的状态独立于 hook 函数的执行周期,状态更新会触发 Component 重新渲染

useSWR 全局缓存机制

双层存储架构

// 第一层:React Hook 状态(组件级别)
Component {
  hookStates: [useSWRHookState]  // 绑定到组件生命周期
}

// 第二层:全局缓存(应用级别) 
globalCache = Map {
  'key1': {
    data: {...},
    subscribers: Set([setState1, setState2]),  // 多组件订阅
    timer: setInterval(...),  // 定时器常驻
    fetcher: fetcherFunction
  }
}

Key 复用策略

// 相同 key = 复用缓存
useSWR(['api', 'user', 1], fetcher)  // 创建缓存
useSWR(['api', 'user', 1], fetcher)  // 复用缓存

// 不同 key = 新建缓存
useSWR(['api', 'user', 2], fetcher)  // 新建缓存

状态变化传播机制

核心原理:整体重新渲染

useAnotherQuote内的useSWR状态变化
    ↓
React检测到状态变化 
    ↓
**触发 Component 重新渲染**  // 关键点Component 重新渲染 → 重新执行所有代码
    ↓
重新执行: useQuote() → useAnotherQuote()
    ↓
获取最新状态(从全局缓存)

实际执行时机

触发组件重新渲染的情况

  1. 状态变化: useState, useSWR 等返回新值
  2. 依赖变化: useEffect, useMemo 依赖项变化
  3. 父组件重新渲染: 导致子组件重新渲染
  4. 定时器更新: useSWRrefreshInterval 触发

定时器自动刷新

useSWR(key, fetcher, {
  refreshInterval: 30_000,  // 每30秒自动刷新
  dedupingInterval: 15_000  // 15秒内去重
});

// 执行流程:
// 1. Component 不重新渲染
// 2. useSWR定时器触发数据获取  
// 3. 新数据返回,触发 Component 重新渲染
// 4. 所有hooks重新执行,获取最新数据

Vue.js技术归纳

2025年7月9日 11:55

Vue.js 是一种流行的前端框架,它提供了一种简洁的方式来构建用户界面。了解 Vue.js 的核心技术和原理对于深入掌握框架以及解决复杂问题至关重要。以下是一些重要的技术原理:

1. 响应式系统(Reactivity System)

Vue.js 的核心是它的响应式系统。Vue 使用了基于依赖追踪的观察者模式,允许数据变化时自动更新 DOM。具体来说,Vue 在创建组件时会遍历数据对象的每个属性,并使用 Object.defineProperty 方法将其转化为 getter/setter,从而实现数据劫持。当数据变化时,相关的 watcher 会被通知并触发相应的更新。

2. 虚拟 DOM (Virtual DOM)

Vue 使用虚拟 DOM 来提高性能。当数据发生变更时,Vue 会先将变化应用到虚拟 DOM 上,然后通过高效的算法计算出实际 DOM 的最小差异(diff),最后将这些差异应用到真实的 DOM 上,以减少不必要的重绘和布局。

3. 组件化开发

Vue 提倡组件化的开发模式,允许开发者将 UI 分割成独立的、可复用的组件。每个组件都是一个自包含的功能单元,有自己的作用域、状态和逻辑。这种模块化的方式有助于大型项目的管理和维护。

4. 计算属性(Computed Properties)

Vue 提供了计算属性功能,允许开发者声明式地创建依赖其他数据属性的属性。计算属性具有缓存机制,只有在其依赖的数据发生变化时才会重新计算,从而提高了性能。

5. 指令系统(Directives)

Vue 提供了一系列内置指令(如 v-if, v-for, v-model 等),用于操作 DOM。这些指令提供了丰富的功能,可以方便地控制 DOM 的渲染逻辑。

v-text 元素的 InnerText 属性

v-html 元素的 innerHTML

v-bind 给元素的属性赋值

v-for 列表的循环渲染

v-if 根据表达式的值在 DOM 中生成或移除一个元素

v-show 根据表达式的值通过样式的改变来显示或者隐藏 HTML 元素

v-on 事件绑定

v-model 输入框值双向绑定

v-model 实现原理

用于实现表单输入与应用状态同步的一个指令。它本质上是一个语法糖,简化了开发者在双向数据绑定上的操作。

当你在模板中使用 v-model 绑定一个输入框(如 <input>)到一个数据属性时,Vue 会自动为这个输入框添加 v-bind:valuev-on:input 指令。

  1. 初始化:当 Vue 渲染组件时,它会通过 v-bind:value 将数据属性绑定到输入元素的 value 属性上。这意味着当初始数据改变时,输入框的值也会相应更新。
  2. 监听输入事件:同时,Vue 会在输入元素上添加一个 input 事件监听器。每当用户修改输入框中的值时,就会触发这个事件,并通过 v-on:input 更新对应的响应式数据属性。
  3. 响应式机制:Vue 使用观察者模式来跟踪数据的变化。当数据属性发生变化时,依赖于该属性的所有视图部分都会自动重新渲染。这使得用户界面能够实时反映最新的数据状态。

6. 生命周期钩子(Lifecycle Hooks)

Vue 组件具有明确的生命周期,从创建到销毁经历了一系列的阶段。生命周期钩子允许开发者在特定的时间点执行代码,这对于执行初始化操作、监听 DOM 变化、清理资源等非常有用。

7. 自定义事件(Custom Events)

Vue 支持组件间的通信,尤其是通过自定义事件来传递信息。通过 $emit$on 方法,组件之间可以轻松地发送和接收消息,这对于构建复杂的交互式应用非常重要。

8. 插槽(Slots)

插槽机制允许父组件向子组件传递内容,增强了组件的灵活性。插槽可以是默认插槽、具名插槽或作用域插槽,它们提供了不同的使用场景。

9. Vuex 状态管理

虽然不是 Vue 核心的一部分,但 Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式和库。在构建大型应用时,使用 Vuex 来集中管理应用的状态通常是一个好主意。

理解上述技术原理可以帮助开发者更好地利用 Vue.js 构建高效、可维护的应用程序。

React Hooks驱动的Todo应用:现代函数式组件开发实践与组件化架构深度解析

2025年7月9日 11:54

前言

在现代前端开发生态中,React Hooks的出现标志着函数式组件开发范式的成熟。本文通过一个完整的Todo应用实现,深入探讨React Hooks在实际项目中的应用模式、架构设计思考以及工程化实践。

项目采用React 19.1.0配合Vite构建工具,结合Stylus样式预处理器,构建了一个功能完整的任务管理应用。从组件设计到状态管理,从样式工程化到性能优化,每个技术决策都体现了现代前端开发的最佳实践。

通过对项目代码中丰富注释的深度解读,我们不仅能理解技术实现的表层逻辑,更能洞察开发者的架构思维和设计哲学。这种将理论与实践相结合的分析方式,为深入理解React生态系统提供了宝贵的学习路径。

项目架构与设计理念

组件化架构设计

项目采用层次化的组件结构,体现了React组件化开发的核心思想:

App (根组件)
└── Todos (容器组件)
    ├── TodoForm (表单组件)
    └── TodoList (列表组件)
        └── TodoItem (列表项组件)

这种架构设计遵循了单一职责原则,每个组件都有明确的功能边界。容器组件负责状态管理和业务逻辑,展示组件专注于UI渲染和用户交互。

数据流管理哲学

项目严格遵循React的单向数据流原则:

  • 状态下沉:核心业务状态集中在Todos容器组件中管理
  • 事件上升:子组件通过回调函数向父组件传递用户操作
  • Props传递:数据通过props自上而下流动,确保数据流向的可预测性
  • 职责分离:状态管理与UI展示的清晰分工,提升代码的可维护性

React Hooks核心技术解析

状态管理的函数式编程实践

useState Hook的设计哲学与实现模式

React Hooks代表了函数式编程在前端组件开发中的最佳实践,项目中的注释体现了深刻的架构思考:

// 数据流管理
// 父组件持有管理数据 props 传递数据 子组件通过props 自定义函数
// 通知父组件
const [todos, setTodos] = useState([...]);

架构设计深度解析

  • 数据流管理哲学:注释明确指出"父组件持有管理数据"的设计原则
  • 单向数据流:props向下传递数据,自定义函数向上通知状态变更
  • 职责分离:父组件负责状态管理,子组件专注UI展示和用户交互
  • 函数式编程:注释强调"react 函数式编程 好用的以use开头的函数"
  • 状态更新触发组件重新渲染:实现响应式UI的核心机制

状态更新的不可变性原则

在处理复杂状态时,遵循不可变性原则至关重要:

// 使用扩展运算符创建新数组,避免直接修改原状态
setTodos([...todos, newTodo]);

设计理念解析

  • 使用扩展运算符创建新数组,避免直接修改原状态
  • Date.now()生成唯一ID,确保列表项的唯一性
  • 新增项默认为未完成状态,符合用户预期
  • 状态更新后自动触发相关组件重渲染

组件化设计的工程实践

受控组件模式与编程原则

TodoForm组件展示了React中受控组件的标准实现,代码注释体现了深刻的编程思维:

//props 和 state 都是数据
// props 参数数据 / state 私有的数据 / 单向数据流
const [text, setText] = useState('');

const handleSubmit = (e) => {
    let result = text.trim(); // dry dont repeat yourself
    if(!text.trim()) return;
    onAddTodo(result);
    setText(''); //数据状态和界面状态一致要敏感
};

// jsx 一定得有唯一的最外层元素 树状编译解析jsx
<input value={text} onChange={(e) => setText(e.target.value)} />

编程原则与技术思考深度解析

  • 数据分类思维:注释明确区分"props参数数据"与"state私有数据"
  • DRY原则实践:"dry dont repeat yourself"体现代码复用意识
  • 状态一致性敏感度:"数据状态和界面状态一致要敏感"强调响应式编程核心
  • JSX编译原理:"jsx一定得有唯一的最外层元素 树状编译解析jsx"揭示底层机制
  • 单向数据流架构:注释强调React的核心设计理念
  • 受控组件模式:input的value完全由React状态控制,确保数据流可预测性

列表渲染与性能优化

TodoList组件实现了高效的列表渲染模式:

// 条件渲染与列表映射的核心逻辑
{
    todos.length > 0 ? (
        todos.map((todo) => 
            <TodoItem key={todo.id} todo={todo} onToggle={() => onToggle(todo.id)} />
        )
    ) : (
        <p>没有待办任务</p>
    )
}

性能优化要点

  • key属性:使用唯一ID作为key,优化React的diff算法
  • 条件渲染:空状态时显示友好提示信息
  • 事件处理优化:通过闭包传递特定ID,避免额外的数据查找
  • 组件拆分:将列表项抽离为独立组件,提升可维护性

列表项组件的交互设计

TodoItem组件展现了细粒度组件设计的优势:

// Props解构与状态可视化
const { id, text, isCompleted } = props.todo;

// 动态className切换完成状态
<span className={isCompleted ? 'completed' : ''}>{text}</span>
<input type="checkbox" checked={isCompleted} onChange={onToggle} />

交互设计亮点

  • 状态可视化:通过className动态切换完成状态样式
  • 直观操作:checkbox提供直观的完成/未完成切换
  • 解构赋值:清晰的props解构提升代码可读性
  • 事件委托:父组件统一处理业务逻辑,子组件专注展示

样式工程化与预处理器应用

Stylus预处理器的技术优势

Stylus作为CSS预处理器,提供了更简洁的语法和强大的功能:

// 简化语法:省略分号和大括号
body
    font-family -apple-system, Arial, sans-serif
    padding 2rem

// 嵌套结构支持
.app
    max-width 600px
    margin 0 auto

技术特色解析

  • 简化语法:省略分号和大括号,提升编写效率
  • 嵌套结构:支持CSS嵌套,代码结构更清晰
  • 变量支持:可定义和复用颜色、尺寸等设计token
  • 函数功能:内置函数简化复杂样式计算

响应式设计的相对单位策略与用户体验优化

项目采用了移动优先的响应式设计理念,体现了深刻的用户体验思考:

// 系统字体优先,提升用户体验
body
    font-family -apple-system,Arial,sans-serif
    padding 2rem  // rem相对单位适配移动端

// Flexbox现代布局
.todo-input
    display flex
    gap 1rem

// 嵌套样式与状态切换
.todo-list
    .todo-item
        .completed
            text-decoration line-through

用户体验与技术设计深度解析

字体策略的用户体验考量

  • 系统字体优先:采用-apple-system等系统字体,前端开发中字体选择直接影响用户体验
  • 多字体回退机制:通过设置多个字体选项,确保不同设备的兼容性和跨平台一致性
  • 原生体验优势:系统字体减少加载时间,提供更接近原生应用的视觉体验

相对单位的移动端适配策略

  • rem单位价值:移动端开发中避免使用绝对单位px,采用相对单位确保适配性
  • 多单位组合:rem基于根元素字体大小、vh/vw基于视口、em基于当前元素字体大小
  • 比例关系:em单位相对于自身font-size计算,保持等比例缩放
  • 设备适配:相对单位能够在不同屏幕尺寸的设备上保持一致的视觉效果
  • 现代布局:Flexbox提供自适应不同屏幕尺寸的弹性布局方案

工程化实践与质量保障

数据流设计与组件通信模式

数据流原则与解构优化

// Props:参数数据,由父组件传递
// State:私有数据,组件内部管理
// 单向数据流:数据自上而下传递,事件自下而上冒泡

// Props解构的最佳实践
const {
    todos,    //任务
    onAddTodo //添加
} = props; // 单独结构

Props解构与数据绑定深度解析

解构模式的代码质量提升

  • 直接解构优势:通过解构赋值直接获取所需属性,提升代码可读性和维护性
  • 语义化命名:为解构的变量添加注释说明用途,增强代码的自文档化特性
  • 扁平化访问:避免深层嵌套的属性访问,降低代码复杂度

数据绑定的响应式机制

  • JSX静态特性:JSX本身是静态模板,通过{}语法实现动态数据绑定
  • 数据绑定核心:{}语法是React响应式系统的基础,连接数据与视图
  • 数据驱动架构:界面完全由数据状态驱动,体现现代前端的声明式编程
  • 状态同步:确保数据层与视图层的一致性,保证UI行为的可预测性
  • 响应式更新:数据变化自动触发界面更新,实现真正的响应式用户界面

性能优化与最佳实践

组件渲染优化策略

避免不必要的重渲染

// 优化前:每次渲染都创建新函数
<TodoItem onToggle={() => onToggle(todo.id)} />

// 优化后:使用useCallback缓存函数
const handleToggle = useCallback((id) => {
    setTodos(todos => todos.map(todo => 
        todo.id === id ? {...todo, isCompleted: !todo.isCompleted} : todo
    ));
}, []);

状态更新优化

// 函数式更新,避免闭包陷阱
const addTodo = useCallback((text) => {
    setTodos(prevTodos => [...prevTodos, newTodo]);
}, []);

内存管理与资源优化

组件卸载清理

useEffect(() => {
    const timer = setInterval(() => { /* 定时任务 */ }, 1000);
    // 清理函数:组件卸载时执行
    return () => clearInterval(timer);
}, []);

技术演进与架构思考

React Hooks的设计哲学

函数式编程优势

  • 逻辑复用:自定义Hooks提取可复用逻辑
  • 关注点分离:不同的Hook处理不同的关注点
  • 组合优于继承:通过Hook组合实现复杂功能
  • 测试友好:纯函数特性便于单元测试

与类组件的对比

// 类组件写法
class TodoForm extends Component {
    state = { text: '' };
    handleSubmit = (e) => {
        this.props.onAddTodo(this.state.text);
        this.setState({ text: '' });
    }
}

// Hooks写法:更简洁的函数式组件
const TodoForm = ({onAddTodo}) => {
    const [text, setText] = useState('');
    // 事件处理逻辑...
};

现代前端开发模式

组件化思维的深化

设计原则

  • 单一职责:每个组件只负责一个功能
  • 高内聚低耦合:组件内部逻辑紧密,组件间依赖最小
  • 可组合性:小组件组合成大组件
  • 可预测性:相同输入产生相同输出

实践建议

// 容器组件:负责数据和逻辑
const TodoContainer = () => {
    const [todos, setTodos] = useState([]);
    return <TodoPresentation todos={todos} onAdd={addTodo} />;
};

// 展示组件:负责UI渲染
const TodoPresentation = ({todos, onAdd}) => (
    <div>
        <TodoForm onAddTodo={onAdd} />
        <TodoList todos={todos} />
    </div>
);

Vue与React的技术对比与设计哲学差异

开发体验与学习曲线对比

特性 Vue React
学习曲线 入门友好,API设计直观 偏向原生JavaScript,学习门槛较高
数据绑定 <input v-model="text"/> <input value={text} onChange={()=>setText(text);}/>
状态管理 Vuex/Pinia Redux/Context
模板语法 模板指令 JSX表达式
性能优化 自动依赖追踪 手动优化策略
Hooks支持 Composition API 原生支持

设计哲学的深层差异

Vue的开发友好性

  • API设计理念:Vue注重开发者体验,API设计直观易用,降低学习门槛
  • 双向绑定简洁性v-model提供了更直观的数据绑定方式
  • 模板语法直观性:更接近传统HTML的写法,降低学习成本

React的原生JavaScript倾向

  • 原生JS导向:React更贴近原生JavaScript,要求开发者具备扎实的JS基础
  • 显式数据流value={text} onChange={()=>setText(text);}明确展示数据流向
  • 函数式编程:Hooks体现了函数式编程范式的深度应用
  • 学习曲线特点:虽然入门门槛较高,但提供了更大的灵活性和控制力

技术选型的战略考量

  • 团队技能匹配:React需要更强的JavaScript基础和函数式编程思维
  • 项目复杂度:大型项目React的类型安全和生态成熟度优势明显
  • 开发效率权衡:Vue短期上手快,React长期维护性和扩展性更好
  • 社区生态:两者都有活跃的开源社区,但侧重点不同

代码注释中的技术洞察与开发思维

组件设计的哲学思考

项目代码中的注释不仅是技术说明,更体现了深刻的开发思维和架构洞察:

组件化思维的体现

// App.jsx中的注释洞察
{/*开发的认为你无单位就是组件  */}

组件化思维解析

  • 万物皆组件:在React开发中,任何UI元素都可以抽象为组件,体现了组件化的核心理念
  • 原子化设计:每个UI元素都可以抽象为独立的组件单元
  • 可复用性思维:组件作为最小开发单位,提升代码复用效率

相对单位的视觉设计思考

// 注释中的单位对比实验
{/* fontSize: '12px', width: '5rem' vs fontSize: '14px', width: '3.5714em' */}

单位选择的深度思考

  • rem vs em对比:通过具体数值展示不同相对单位的计算关系
  • 比例关系维护:3.5714em = 5rem ÷ 1.4 (14px/12px),体现em相对于自身字体的特性
  • 视觉一致性:相同视觉效果下不同单位的数值换算
  • 响应式设计:为移动端适配提供技术基础

编程原则的实践体现

DRY原则的代码实践

let result = text.trim(); // dry dont repeat yourself
if(!text.trim()) return;

代码质量意识

  • 避免重复计算:将text.trim()结果存储在变量中,避免重复调用
  • 性能优化思维:减少不必要的字符串处理操作
  • 代码可读性:变量命名result明确表达处理后的结果

状态一致性的敏感度

setText(''); //数据状态和界面状态一致要敏感

响应式编程洞察

  • 状态同步意识:强调数据层与视图层的同步重要性
  • 用户体验考量:表单提交后立即清空,提供即时反馈
  • React哲学:体现了React单向数据流的核心思想

架构设计的深层思考

JSX编译机制的理解

// jsx 一定得有唯一的最外层元素 树状编译解析jsx

底层机制洞察

  • 编译原理理解:JSX需要转换为JavaScript函数调用
  • 树状结构要求:React.createElement需要单一根节点
  • 性能考量:单一根节点简化虚拟DOM的diff算法
  • Fragment解决方案:现代React提供Fragment避免额外DOM节点

数据流管理的系统性思考

// 数据流管理
// 父组件持有管理数据 props 传递数据 子组件通过props 自定义函数
// 通知父组件

架构模式总结

  • 职责分离:明确父子组件的不同职责
  • 数据流向:单向数据流的完整描述
  • 通信机制:props下传,回调上升的标准模式
  • 可维护性:清晰的数据流便于调试和扩展

技术选型的思考深度

函数式编程的认知

// react 函数式编程 好用的以use开头的函数

技术趋势洞察

  • Hooks命名规范:use前缀的设计哲学
  • 函数式优势:相比类组件的简洁性和可组合性
  • 开发体验:Hooks显著提升了React的开发体验和代码简洁性
  • 生态统一:自定义Hooks的命名一致性

这些代码注释展现了开发者对React技术栈的深度理解,不仅关注功能实现,更思考底层原理、性能优化、用户体验和代码质量。这种技术洞察力是高级前端开发者的重要特质,值得深入学习和实践。

核心功能实现完整性

已实现的Todo应用核心功能

项目已完整实现Todo应用的所有核心功能:

// 切换完成状态 - 已实现
const onToggle = (id) => {
    setTodos(todos.map(todo => todo.id === id ? {...todo, isCompleted: !todo.isCompleted} : todo))
}

// 删除任务 - 已实现
const onDelete = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
}

// 添加任务 - 已实现
const addTodo = (text) => {
    setTodos([...todos, {
        id: Date.now(),
        text: text,
        isCompleted: false
    }])
}

功能实现特点

  • 完整的CRUD操作:创建(Create)、读取(Read)、更新(Update)、删除(Delete)
  • 状态管理优化:使用不可变数据更新模式,确保React正确检测状态变化
  • 用户交互完善:checkbox切换、删除按钮、表单提交等完整交互流程
  • 数据持久化基础:使用Date.now()生成唯一ID,为后续数据库集成奠定基础

扩展功能与未来优化

性能优化建议

基于当前实现,可以进一步优化性能:

// 使用useCallback优化事件处理函数
const onToggle = useCallback((id) => {
    setTodos(prevTodos => prevTodos.map(todo => 
        todo.id === id ? {...todo, isCompleted: !todo.isCompleted} : todo
    ));
}, []);

// 使用useMemo优化计算属性
const completedCount = useMemo(() => 
    todos.filter(todo => todo.isCompleted).length, [todos]
);

// 组件拆分优化
const MemoizedTodoItem = memo(TodoItem);

功能增强方向

// 编辑功能
const editTodo = useCallback((id, newText) => {
    setTodos(prevTodos => prevTodos.map(todo => 
        todo.id === id ? {...todo, text: newText} : todo
    ));
}, []);

// 批量操作
const toggleAll = useCallback(() => {
    const allCompleted = todos.every(todo => todo.isCompleted);
    setTodos(prevTodos => prevTodos.map(todo => 
        ({...todo, isCompleted: !allCompleted})
    ));
}, [todos]);

总结与展望

本文深入分析了基于React Hooks的Todo应用开发实践,该项目已完整实现了Todo应用的所有核心功能,包括任务的增删改查、状态切换等完整业务流程。从函数式组件设计到状态管理优化,从Stylus样式工程化到现代构建工具应用,特别是通过对代码注释的深度解读,全面展现了现代前端开发的技术栈、最佳实践和开发思维。

核心技术收获

  • React Hooks精通:掌握useState等核心Hook的使用模式和设计哲学
  • 组件化架构:理解"万物皆组件"的设计思想和实现策略
  • 工程化实践:Vite构建、Stylus预处理、ESLint规范的完整工具链
  • 响应式设计:rem/em相对单位、-apple-system字体的移动端适配策略
  • 编程原则应用:DRY原则、状态一致性、单向数据流的深度实践

技术洞察与思维提升

  • 底层机制理解:JSX编译原理、虚拟DOM树状结构的深层认知
  • 性能优化意识:避免重复计算、状态更新优化的实践经验
  • 用户体验思维:字体选择、相对单位、界面响应的全方位考量
  • 架构设计能力:数据流管理、组件职责分离的系统性思考

实践价值体现

  • 功能完整性:实现了Todo应用的完整业务流程,具备实际应用价值
  • 代码质量:函数式组件配合深度注释思考,提供清晰的代码结构和技术洞察
  • 开发效率:现代工具链与最佳实践大幅提升开发体验
  • 维护性:良好的组件设计和清晰的技术文档便于长期维护
  • 扩展性:模块化架构和深度技术理解支持功能快速迭代
  • 学习价值:完整的项目实现为React Hooks学习提供最佳实践参考

框架对比洞察

  • Vue vs React:从易于上手到偏向原生JavaScript的设计哲学差异
  • 学习路径:理解不同框架的适用场景和技术选型策略
  • 生态发展:Hooks与Composition API的函数式编程趋势

未来发展方向

  • 服务端渲染:Next.js集成,提升SEO和首屏加载性能
  • 状态持久化:localStorage或数据库集成,实现数据永久保存
  • 类型安全:TypeScript集成,提升代码质量和开发体验
  • 测试覆盖:Jest + React Testing Library,确保代码质量
  • PWA功能:离线支持和原生应用体验
  • 微前端架构:组件库抽取,支持多项目复用

通过本项目的完整实现和深度分析,开发者不仅掌握了React Hooks的核心技术,更重要的是建立了现代前端开发的完整思维体系。项目从基础功能到性能优化,从代码质量到架构设计,展现了专业前端开发的全貌。

React Hooks的函数式编程范式,配合深度的技术思考和最佳实践,正在重新定义前端组件开发的标准。通过本文的学习,开发者不仅能够构建高质量的React应用,更能建立完整的前端工程化思维体系,为应对更复杂的业务场景和技术挑战奠定坚实的基础。

浏览器技术原理

2025年7月9日 11:49

1.chrome多进程架构

从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

下面我们来逐个分析下这几个进程的功能。

浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。

GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。

网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。

插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

音频进程:

存储进程:

讲到这里,现在你应该就可以回答文章开头提到的问题了:仅仅打开了 1 个页面,为什么有 4 个进程?因为打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。

不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:

更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。

更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

对于上面这两个问题,Chrome 团队一直在寻求一种弹性方案,既可以解决资源占用高的问题,也可以解决复杂的体系架构的问题。

未来面向服务的架构

为了解决这些问题,在 2016 年,Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。如果你对面向服务的架构感兴趣,你可以去网上搜索下资料,这里就不过多介绍了。

Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome“面向服务的架构”的进程模型图:

目前 Chrome 正处在老的架构向服务化架构过渡阶段,这将是一个漫长的迭代过程。

Chrome 正在逐步构建 Chrome 基础服务(Chrome Foundation Service),如果你认为 Chrome 是“便携式操作系统”,那么 Chrome 基础服务便可以被视为该操作系统的“基础”系统服务层。

同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。

最终 Chrome 团队选择了面向服务架构(SOA)形式,这也是 Chrome 团队现阶段的一个主要任务。

鉴于目前架构的复杂性,要完整过渡到面向服务架构,估计还需要好几年时间才能完成。不过 Chrome 开发是一个渐进的过程,新的特性会一点点加入进来,这也意味着我们随时能看到 Chrome 新的变化。

总体说来,Chrome 是以一个非常快速的速度在进化,越来越多的业务和应用都逐渐转至浏览器来开发,身为开发人员,我们不能坐视不管,而应该紧跟其步伐,收获这波技术红利。

如何分析加载页面启动了几个进程?

默认情况下会启动:

1个网络进程、1个浏览器进程、 1个GPU 进程、1个音频服务进程、1个渲染进程

特殊情况:

1:如果页面里有iframe的话,iframe也会运行在单独的进程中!

2:如果页面里有插件,同样插件也需要开启一个单独的进程!

3:如果你装了扩展的话,扩展也会占用进程

4:如果2个页面属于同一站点的话,并且从a页面中打开的b页面,那么他们会公用一个渲染进程

2.浏览器渲染流程

3.JavaScript代码执行机制

1.变量提升

所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 undefined。

showName()
console.log(myname)
var myname = '极客时间'
function showName() {
  console.log('函数showName被执行');
}

我们可以一行一行来分析上述代码:

第 1 行和第 2 行,由于这两行代码不是声明操作,所以 JavaScript 引擎不会做任何处理;

第 3 行,由于这行是经过 var 声明的,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并使用 undefined 对其初始化;

第 4 行,JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置(不了解堆也没关系,JavaScript 的执行堆和执行栈我会在后续文章中介绍)。

这样就生成了变量环境对象。接下来 JavaScript 引擎会把声明以外的代码编译为字节码,至于字节码的细节,我也会在后面文章中做详细介绍,你可以类比如下的模拟代码:

showName()
console.log(myname)
myname = '极客时间'

JavaScript 代码执行过程中,需要先做变量提升,而之所以需要实现变量提升,是因为 JavaScript 代码在执行之前需要先编译。

在编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined;

在代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的。

2.栈溢出

那为什么会出现这种错误呢?这就涉及到了调用栈的内容。你应该知道 JavaScript 中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构。因此要讲清楚调用栈,你还要先弄明白函数调用栈结构

1.函数调用

就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文。

执行 JavaScript 时,可能会存在多个执行上下文,JavaScript 引擎通过一种叫栈的数据结构来管理

2.为什么容易出现栈溢出

调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。

特别是在你写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:

function division(a,b){
    return division(a,b)
}
console.log(division(1,2))

当执行时,就会抛出栈溢出错误,如下图:

那为什么会出现这个问题呢?

这是因为当 JavaScript 引擎开始执行这段代码时,它首先调用函数 division,并创建执行上下文,压入栈中;

然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。

3.总结:

每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。

如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。

当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。

当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。

3.作用域链、闭包

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链。

现在你知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

要回答这个问题,你还需要知道什么是词法作用域。这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

1.词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

2.闭包

结合下面这段代码来理解什么是闭包:

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

首先我们看看当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况,你可以参考下图:

从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。

之所以是专属背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的闭包。

好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包

那这些闭包是如何使用的呢?当执行到 bar.setName 方法中的myName = "极客邦"这句代码时,JavaScript 引擎会沿着“当前执行上下文–>foo 函数闭包–> 全局执行上下文”的顺序来查找 myName 变量,你可以参考下面的调用栈状态图:

从图中可以看出,setName 的执行上下文中没有 myName 变量,foo 函数的闭包中包含了变量 myName,所以调用 setName 时,会修改 foo 闭包中的 myName 变量的值。

同样的流程,当调用 bar.getName 的时候,所访问的变量 myName 也是位于 foo 函数闭包中的。

你也可以通过“开发者工具”来看看闭包的情况,打开 Chrome 的“开发者工具”,在 bar 函数任意地方打上断点,然后刷新页面,可以看到如下内容:

从图中可以看出来,当调用 bar.getName 的时候,右边 Scope 项就体现出了作用域链的情况:Local 就是当前的 getName 函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从“Local–>Closure(foo)–>Global”就是一个完整的作用域链。所以说,你以后也可以通过 Scope 来查看实际代码作用域链的情况,这样调试代码也会比较方便。

3.闭包回收

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

4.this机制

在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。

通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

当函数作为对象的方法调用时,函数中的 this 就是该对象;

当函数被正常调用时,在严格模式下,this 值是 undefined,非严格模式下 this 指向的是全局对象 window;

嵌套函数中的 this 不会继承外层函数的 this 值。

4.V8引擎的运行原理

一. 认识JavaScript引擎

1.1. 什么是JavaScript引擎

当我们编写JavaScript代码时,它实际上是一种高级语言,这种语言并不是机器语言。

  • 高级语言是设计给开发人员使用的,它包括了更多的抽象和可读性。
  • 但是,计算机的CPU只能理解特定的机器语言,它不理解JavaScript语言。
  • 这意味着,在计算机上执行JavaScript代码之前,必须将其转换为机器语言。

这就是JavaScript引擎的作用:

  • 事实上我们编写的JavaScript无论你交给浏览器或者Node执行,最后都是需要被CPU执行的;
  • 但是CPU只认识自己的指令集,实际上是机器语言,才能被CPU所执行;
  • 所以我们需要JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令来执行;

比较常见的JavaScript引擎有哪些呢?

  • SpiderMonkey:第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者);
  • Chakra:微软开发,用于IT浏览器;
  • JavaScriptCore:WebKit中的JavaScript引擎,Apple公司开发;
  • V8:Google开发的强大JavaScript引擎,也帮助Chrome从众多浏览器中脱颖而出;
  • 等等…

1.2. 浏览器内核和JS引擎关系

我们前面学习了浏览器内核,那么浏览器内核和JavaScript引擎之间是什么样的关系呢?

  • 浏览器内核和JavaScript引擎之间有紧密的关系,因为JavaScript引擎是浏览器内核中的一个组件。
  • 浏览器内核负责渲染网页,并在渲染过程中执行JavaScript代码。
  • JavaScript引擎则是负责解析、编译和执行JavaScript代码的核心组件。

以WebKit为例,它是一种开源的浏览器内核,最初由Apple公司开发,并被用于Safari浏览器中。

  • WebKit包含了一个JavaScript引擎,名为JavaScriptCore,它负责解析、编译和执行JavaScript代码。

WebKit事实上由两部分组成的:

  • WebCore:负责HTML解析、布局、渲染等等相关的工作。
  • JavaScriptCore:解析、执行JavaScript代码。

WebKit内核

看到这里,学过小程序的同学有没有感觉非常的熟悉呢?

  • 在小程序中编写的JavaScript代码就是被JSCore执行的;

小程序的架构设计

另外一个非常强大的JavaScript引擎就是V8引擎,也是我们今天要学习的重点。

二. V8引擎的运行原理

2.1. V8引擎的官方定义

V8引擎是一款Google开源的高性能JavaScript和WebAssembly引擎,它是使用C++编写的。

  • V8引擎的主要目标是提高JavaScript代码的性能和执行速度。
  • V8引擎可以在多种操作系统上运行,包括Windows 7或更高版本、macOS 10.12+以及使用x64、IA-32、ARM或MIPS处理器的Linux系统。

V8引擎可以作为一个独立的应用程序运行,也可以嵌入到其他C++应用程序中,例如Node.js。

  • 由于V8引擎的开源性和高性能,许多现代浏览器都使用了V8引擎或其修改版本,以提供更快、更高效的JavaScript执行体验。

2.2. V8引擎如何工作呢?

2.2.1. V8引擎的工作过程

我这里先给出一副V8引擎的工作图:

  • 后续我们会一点点解析它的工作过程

V8引擎的工作图

整体流程如下:(先简单了解)

  1. 词法分析:
    • 首先,V8引擎将JavaScript代码分成一个个标记或词法单元,这些标记是程序语法的最小单元。
    • 例如,变量名、关键字、运算符等都是词法单元。
    • V8引擎使用词法分析器来完成这个任务。
  1. 语法分析:
    • 在将代码分成标记或词法单元之后,V8引擎将使用语法分析器将这些标记转换为抽象语法树(AST)。
    • 语法树是代码的抽象表示,它捕捉了代码中的结构和关系。
    • V8引擎会检查代码是否符合JavaScript语言规范,并将其转换为抽象语法树。
  1. 字节码生成:
    • 接下来,V8引擎将从语法树生成字节码。
    • 字节码是一种中间代码,它包含了执行代码所需的指令序列。
    • 字节码是一种抽象的机器代码,它比源代码更接近机器语言,但仍需要进一步编译成机器指令。
  1. 机器码生成:
    • 最后,V8引擎将生成机器码,这是一种计算机可以直接执行的二进制代码。
    • V8引擎使用即时编译器(JIT)来将字节码编译成机器码。
    • JIT编译器将字节码分析为代码的热点部分,并生成高效的机器码,以提高代码的性能。
2.2.2. V8引擎的架构设计

V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的:

Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;

  • 如果函数没有被调用,那么是不会被转换成AST的;
  • Parse的V8官方文档:v8.dev/blog/scanne…

Ignition是一个解释器,会将AST转换成ByteCode(字节码)

  • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
  • 如果函数只调用一次,Ignition会执行解释执行ByteCode;
  • Ignition的V8官方文档:v8.dev/blog/igniti…

TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;

  • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
  • 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
  • TurboFan的V8官方文档:v8.dev/blog/turbof…

另外,V8引擎还包括了垃圾回收机制,用于自动管理内存的分配和释放。V8引擎使用了一种名为“分代式垃圾回收”(Generational Garbage Collection)的技术,它将堆区分成新生代和老年代两个部分,分别使用不同的垃圾回收策略,以提高垃圾回收的效率。

  • 内存管理我们后续再单独来讨论学习。

2.3. V8的转化代码过程

比如我们有如下一段代码,V8引擎是如何一步步帮我们转化的呢?

const name = "coderwhy"
console.log(name)

function sayHi(name) {
  console.log("Hi " + name)
}

sayHi(name)

下面是官方给出的一个图解:

官方图例

2.3.1. 词法分析的过程

词法分析是将JavaScript代码转换成一系列标记的过程,它是编译过程的第一步。

  • 在V8引擎中,词法分析器会将JavaScript代码分解成一系列标识符、关键字、操作符和字面量等基本元素,以供后续的语法分析和代码生成等步骤使用。

这里仅仅举一个例子,作为参考即可

Token(type='const', value='const')
Token(type='identifier', value='name')
Token(type='operator', value='=')
Token(type='string', value='"coderwhy"')
Token(type='operator', value=';')
Token(type='console', value='console')
Token(type='operator', value='.')
Token(type='identifier', value='log')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
Token(type='function', value='function')
Token(type='identifier', value='sayHi')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value='{')
Token(type='console', value='console')
Token(type='operator', value='.')
Token(type='identifier', value='log')
Token(type='operator', value='(')
Token(type='string', value='"Hi "')
Token(type='operator', value='+')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
Token(type='operator', value='}')
Token(type='identifier', value='sayHi')
Token(type='operator', value='(')
Token(type='identifier', value='name')
Token(type='operator', value=')')
Token(type='operator', value=';')
2.3.2. 语法分析的过程

接下来我们可以根据上面得到的tokens代码,进行语法分析,生成对应的AST树。

在V8引擎中,语法分析的过程可以分为两个阶段:解析(Parsing)和预处理(Pre-parsing)。

解析阶段是将tokens转换成抽象语法树(AST)的过程,而预处理阶段则是在解析阶段之前进行的,用于预处理一些代码,如函数和变量声明等。

对于你提供的JavaScript代码,V8引擎的解析和预处理过程如下所示:

V8引擎的解析和预处理过程如下所示:

  1. 预处理阶段
  • 在预处理阶段,V8引擎会扫描整个代码,查找函数和变量声明,并将其添加到当前作用域的符号表中。
  • 在这个过程中,V8引擎会同时进行词法分析和语法分析,生成一些中间表示,以便后续使用。
  • 对于我们的代码,预处理阶段不会生成任何AST节点,因为它只包含了一个常量声明和一个函数声明,而没有变量声明(var声明的变量)。
  1. 解析阶段
  • 在解析阶段,V8引擎会将tokens转换成AST节点,生成一棵抽象语法树(AST)。
  • AST是一种树形结构,用于表示程序的语法结构,它包含了多种类型的节点,如表达式节点、语句节点和声明节点等。

转化的AST树代码参考:

Program
 └── VariableDeclaration (const name = "coderwhy")
 └── ExpressionStatement (console.log(name))
 └── FunctionDeclaration (function sayHi(name) { ... })
     └── BlockStatement
         └── ExpressionStatement (console.log("Hi " + name))
 └── ExpressionStatement (sayHi(name))

从AST树中可以看出,整个程序由一个Program节点和三个子节点组成。

  • 其中,第一个子节点是一个VariableDeclaration节点,表示常量声明语句;
  • 第二个子节点是一个ExpressionStatement节点,表示console.log语句;
  • 第三个子节点是一个FunctionDeclaration节点,表示函数声明语句。
    • FunctionDeclaration节点包含一个BlockStatement子节点,表示函数体,其中包含一个ExpressionStatement节点,表示console.log语句。
    • 最后一个子节点是一个ExpressionStatement节点,表示调用函数语句。
2.3.3. 转化的字节码(了解)

根据上面得到的AST树,我们可以将其转换成对应的字节码。在V8引擎中,字节码是一种中间表示,用于表示程序的执行流程和指令序列。

V8引擎会将AST树转换成如下的字节码序列:

// 字节码指令集
[Constant name="coderwhy"]
[SetLocal name]
[GetLocal name]
[LoadProperty console]
[LoadProperty log]
[Call 1]
[Constant Hi ]
[GetLocal name]
[BinaryOperation +]
[Call 1]
[SetLocal sayHi]
[GetLocal name]
[GetLocal sayHi]
[Call 1]
[Return]

根据上面生成的字节码,我们可以看到V8引擎生成的字节码指令集,每个指令都对应了一种操作,如Constant、SetLocal、GetLocal等等。下面是对字节码指令集的解释:

  • Constant:将常量值压入操作数栈中。
  • SetLocal:将操作数栈中的值存储到本地变量中。
  • GetLocal:将本地变量的值压入操作数栈中。
  • LoadProperty:从对象中加载属性值,并将其压入操作数栈中。
  • Call:调用函数,并将返回值压入操作数栈中。
  • BinaryOperation:对两个操作数执行二元运算,并将结果压入操作数栈中。
  • Return:从当前函数中返回,并将返回值压入操作数栈中。

由于字节码是一种中间表示,它可以跨平台运行,在不同的操作系统和硬件平台上都可以执行。这种跨平台的特性,使得V8引擎成为了一款非常流行的JavaScript引擎。

在Node环境中,我们可以通过如下命令查看到字节码:

  • 但是默认Node环境下是打印所有的字节码的,所以内容会非常多(了解即可)
node --print-bytecode test.js
2.3.4. 生成的机器码(了解)

在V8引擎中,机器码是通过即时编译(Just-In-Time Compilation,JIT)技术生成的。

  • JIT编译是一种动态编译技术,它将字节码转换成本地机器码,并将其缓存起来以提高代码的执行速度和性能。
  • JIT编译器可以根据运行时信息对代码进行优化,并且可以根据不同的平台和硬件生成对应的机器码。

在V8引擎中,机器码的生成过程分为两个阶段:

  • 预编译(pre-compilation)和优化(optimization)。
  • 预编译阶段会生成一些简单的机器码,用于快速执行代码;
  • 优化阶段则会根据代码的运行时信息生成更优化的机器码,以提高代码的执行效率和性能。

具体的生成过程如下:

  1. 预编译阶段
  • 在预编译阶段,V8引擎会生成一些简单的机器码,用于快速执行代码。
  • 这些机器码是基于字节码生成的,它们可以直接执行,并且具有一定的优化效果。
  • 在这个阶段,V8引擎会根据代码的运行时信息生成一些简单的机器码,如对象和数组的存取、字符串的拼接、函数的调用等。
  1. 优化阶段
  • 在优化阶段,V8引擎会根据代码的运行时信息生成更优化的机器码,以提高代码的执行效率和性能。
  • 在这个阶段,V8引擎会通过分析代码的执行路径、类型信息、控制流程等,生成一些高效的机器码,并且可以进行多次优化,以获得更高的性能。

在优化阶段,V8引擎会使用TurboFan编译器来生成机器码。

  • TurboFan是一个基于中间表示(Intermediate Representation,IR)的编译器,它可以将字节码转换成高效的机器码,并且可以进行多层次的优化,包括基于类型的优化、内联优化、控制流优化、垃圾回收优化等。

通过机器码的生成过程,我们可以看到V8引擎是如何根据代码的运行时信息生成高效的机器码,并且可以多次优化,以获得更高的性能。

  • 在后续的执行过程中,V8引擎会将机器码缓存起来,以提高代码的执行速度和性能。

三. V8引擎的内存管理

3.1. 认识内存管理

不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。

不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期

  • 第一步:分配申请你需要的内存(申请);
  • 第二步:使用分配的内存(存放一些东西,比如对象等);
  • 第三步:不需要使用时,对其进行释放;

不同的编程语言对于第一步和第三步会有不同的实现:

  • 手动管理内存:比如C、C++,包括早期的OC,都是需要手动来管理内存的申请和释放的(malloc和free函数);
    • 这种方式需要程序员手动管理内存,容易出现内存泄漏和野指针等问题,程序的稳定性和安全性有一定的风险。
  • 自动管理内存:比如Java、JavaScript、Python、Swift、Dart等,它们有自动帮助我们管理内存;
    • 在这些语言中,存在垃圾回收机制来自动回收不再使用的内存空间,程序员只需要正确地使用变量和对象等引用类型数据,垃圾回收器就会自动进行内存管理,释放不再被引用的内存空间。
    • 这种方式可以避免内存泄漏和野指针等问题,提高了程序的稳定性和安全性。

对于开发者来说,JavaScript 的内存管理是自动的、无形的。

  • 我们创建的原始值、对象、函数……这一切都会占用内存;
  • 但是我们并不需要手动来对它们进行管理,JavaScript引擎会帮助我们处理好它;

3.2. JS的内存管理

在JavaScript中,内存分为栈内存和堆内存两种类型。

  • 栈内存用于存储基本数据类型和引用类型的地址,它具有自动分配和自动释放的特点。
  • 堆内存用于存储引用类型的对象和数组等数据结构,它需要手动分配和释放内存。

在JavaScript中,使用var、let和const声明的变量都是存在栈内存中的。

  • 当我们声明一个变量时,JavaScript引擎会在栈内存中为其分配一块空间,并将变量的值存储在该空间中。
  • 当变量不再被引用时,JavaScript引擎会自动将其释放掉,以回收其空间。

在JavaScript中,创建的对象和数组等引用类型数据都是存在堆内存中的。

  • 当我们创建一个对象时,JavaScript引擎会在堆内存中为其分配一块空间,并将其属性存储在该空间中。
  • 当对象不再被引用时,垃圾回收器会自动将其标记为垃圾,并回收其空间。

为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。

在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如free函数:

  • 但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
  • 并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露和野指针的情况;
  • 影响程序的稳定性和安全性,同时也会影响编写逻辑代码的效率;

所以大部分现代的编程语言都是有自己的垃圾回收机制:

  • 垃圾回收的英文是Garbage Collection,简称GC;
  • 对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
  • 而我们的语言运行环境,比如Java的运行环境JVM,JavaScript的运行环境js引擎都会内存 垃圾回收器
  • 垃圾回收器我们也会简称为GC,所以在很多地方你看到GC其实指的是垃圾回收器;

但是这里又出现了另外一个很关键的问题:GC怎么知道哪些对象是不再使用的呢? 这里就要用到GC的实现以及对应的算法;

3.3. 常见的GC算法

3.3.1. 引用计数(Reference counting)

引用计数(Reference counting)是一种常见的垃圾回收算法。

  • 它的基本思想是在对象中添加一个引用计数器。
  • 每当有一个指针引用该对象时,引用计数器就加一。
  • 当指针不再引用该对象时,引用计数器就减一。
  • 当引用计数器的值为0时,表示该对象不再被引用,可以被回收。

引用计数算法的优点是实现简单,垃圾对象的回收及时,可以避免内存泄漏。

但是引用计数算法也有一些缺点。

  • 最大的缺点是很难解决循环引用问题。
  • 如果两个对象相互引用,它们的引用计数器永远不会为0,即使它们已经成为垃圾对象。
  • 这种情况下,引用计数算法就无法回收它们,导致内存泄漏。

循环引用

3.3.2. 标记清除(mark-Sweep)

标记清除(mark-Sweep)是一种常见的垃圾回收算法,其核心思想是可达性(Reachability)。算法的实现过程如下:

  1. 设置一个根对象(root object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象。
  2. 对于每一个找到的对象,标记为可达(mark),表示该对象正在使用中。
  3. 对于所有没有被标记为可达的对象,即不可达对象,就认为是不可用的对象,需要被回收。
  4. 回收不可达对象所占用的内存空间,并将其加入空闲内存池中,以备将来重新分配使用。

标记清除算法可以很好地解决循环引用的问题,因为它只关注可达性,不会被循环引用的对象误判为可用对象。

标记清除算法

但是这种算法也有一些缺点,最主要的是它的效率不高,因为在标记可达对象和回收不可达对象的过程中需要遍历整个对象图。

此外,标记清除算法还会造成内存碎片的问题,因为回收的内存空间不一定是连续的,导致大块的内存无法被分配使用。

3.3.3. 其他算法优化补充

S引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。

标记整理(Mark-Compact)

  • 和“标记-清除”相似;
  • 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;

分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。

  • 许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
  • 那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;

增量收集(Incremental collection)

  • 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
  • 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;

闲时收集(Idle-time collection)

  • 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
  • 这种算法通常用于移动设备或其他资源受限的环境,以确保垃圾收集对用户体验的影响最小。
3.3.4. V8引擎的内存图

事实上,V8引擎为了提供内存的管理效率,对内存进行非常详细的划分。(详细参考视频学习)

这幅图展示了一个堆(heap)的内存结构,下面是对每个内存块的解释:

  • Old Space(老生代):分配的内存较大,存储生命周期较长的对象,比如页面或者浏览器的长时间使用对象;
  • New Space(新生代):分配的内存较小,存储生命周期较短的对象,比如临时变量、函数局部变量等;
  • Large Object Space(大对象):分配的内存较大,存储生命周期较长的大型对象,比如大数组、大字符串等;
  • Code Space(代码空间):存储编译后的函数代码和 JIT 代码;
  • Map Space(映射空间):存储对象的属性信息,比如对象的属性名称、类型等信息;
  • Cell Space(单元格空间):存储对象的一些元信息,比如字符串长度、布尔类型等信息。

这些不同的内存块都有各自的特点和用途,V8 引擎会根据对象的生命周期和大小将它们分配到不同的内存块中,以优化内存的使用效率。

V8引擎的内存图

以一个简单的React应用理解数据绑定的重要性

作者 海底火旺
2025年7月9日 11:48

引言:数据驱动视图的React哲学

在React开发中,数据绑定是构建高效应用的核心机制。通过状态(state)与视图(UI)的精确映射,开发者可以创建响应式、可维护的界面。本文将深入分析一个TodoList应用,从组件结构、数据流设计和性能优化角度,揭示React开发的最佳实践。

组件层级与数据流设计

我们的应用采用经典的"容器组件-展示组件"架构,组件层级关系如下:

App
└── Todos (状态容器)
    ├── TodoForm (输入组件)
    └── TodoList (列表容器)
        └── TodoItem (单项展示)

核心组件实现

状态容器组件 Todos/index.jsx

import { useState } from 'react'
import TodoForm from "./TodoForm"
import TodoList from "./TodoList"

const Todos = () => {
    // 集中管理状态
    const [todos, setTodos] = useState([
        { id: 1, title: '学习React', isComplete: false },
        { id: 2, title: '写技术博客', isComplete: false }
    ])

    // 添加新任务
    const addTodo = (text) => {
        setTodos([...todos, {
            id: Date.now(),
            title: text,
            isComplete: false
        }])
    }

    // 切换任务状态
    const onToggle = (id) => {
        setTodos(todos.map(todo => 
            todo.id === id 
            ? {...todo, isComplete: !todo.isComplete} 
            : todo
        ))
    }

    // 删除任务
    const onDelete = (id) => {
        setTodos(todos.filter(todo => todo.id !== id))
    }

    return (
        <div className='app'>
            <TodoForm onAddTodo={addTodo} />
            <TodoList 
                todos={todos}
                onToggle={onToggle}
                onDelete={onDelete}
            />
        </div>
    )
}

export default Todos

关键:

  • 单一数据源:所有状态集中在父组件管理

     保证纯粹的data binding ,控制控制儿子
    
  • 不可变数据:每次更新都创建新数组/对象

      基于React渲染特性,当我们更新状态时,如果我们直接修改原对象或数组,
      然后调用setState函数,React会认为状态没有变化,因为对象的引用地址没有改变,因此不会触发重新渲染。
    

所以这里的onToggle 函数使用计数循环的写法是:

for(let i=0;i<todos.length;i++){
    if(todos[i].id === id){
        todos[i].isComplete = !todos[i].isComplete
            break
    }
}
setTodos([...todos]) // 使用[]创建一个全新的数组存储数据(上面map会自动返回一个新数组)

表单组件 TodoForm.jsx

import { useState } from 'react'

const TodoForm = ({ onAddTodo }) => {
    const [text, setText] = useState('')

    const handleSubmit = (e) => {
        e.preventDefault()
        const result = text.trim()
        if(!result) return
        
        onAddTodo(result) // 通知父组件
        setText('') // 重置输入框
    }

    return (
        <>
            <h1 className='header'>TodoList</h1>
            <form className="todo-input" onSubmit={handleSubmit}>
                <input 
                    type="text"
                    value={text} // 数据绑定
                    onChange={e => setText(e.target.value)}
                    placeholder='请输入待办事项'
                />
                <button type='submit'>Add</button>
            </form>
        </>
    )
}

export default TodoForm

数据绑定

  1. value={text}将输入框与状态绑定

  2. onChange事件更新状态,状态变化触发重新渲染,更新输入框内容

     页面改变一定是数据改变,数据改变一定要找父组件->爷组件->祖宗组件,谁活着并且最大找谁。
     还有就是子组件可能有自己的数据,私有数据状态下允许存在。
    

列表组件 TodoList.jsx

import TodoItem from "./TodoItem"

const TodoList = ({ todos, onToggle, onDelete }) => {
    return (
        <ul className="todo-list">
            {todos.length > 0 ? (
                todos.map((todo) => (
                    <TodoItem 
                        key={todo.id} 
                        todo={todo} 
                        onToggle={() => onToggle(todo.id)}
                        onDelete={() => onDelete(todo.id)}
                    />
                ))
            ) : (
                <p>暂无待办事项</p>
            )}
        </ul>
    )
}

export default TodoList

性能优化

明明写到TodoList就可以完成整个项目了(直接加上数据展示即可),为什么还要加一个TodoItem?

以细化粒度角度分析:

    我们知道React的虚拟DOM是使用Diff 算法实现,不是那种一层层的树结构,
    貌似Diff算法并不能因为粒度细化而优化树的构建,但是它会减少需要比较的范围
  • 如果某个组件及其子树的状态与 props 没有变化,React 可以直接跳过该子树的 Diff 计算,比如上面的TodoList。

  • 例如,将一个大型组件拆分为多个小组件后,某个小组件的更新只会触发其自身及其子组件的 Diff,而不是整棵树。

      组件化、粒度细化保证我们不会牵一发而动全身,一个小数据的改变导致整个页面都要重新渲染
    

单项展示组件 TodoItem.jsx

const TodoItem = ({ todo, onToggle, onDelete }) => {
    const { title, isComplete } = todo

    return (
        <div className="todo-item">
            <input 
                type="checkbox" 
                checked={isComplete} 
                onChange={onToggle}
            />
            <span className={isComplete ? 'completed' : ''}>
                {title}
            </span>
            <button onClick={onDelete}>Delete</button>
        </div>
    )
}

export default TodoItem
  • 纯展示组件:自身完全无状态,仅依赖props

  • 条件样式:使用className动态切换完成状态样式

  • 最小化props:仅接收必要数据和方法

    简单来说这里就是TodoList的展示框,自身对数据完全没有掌控感,只管把数据摆上页面即可。
    

关键性能优化策略

1. 避免不必要的渲染

在React中,当父组件状态变化时,所有子组件默认都会重新渲染。我们通过以下方式优化:

策略:将状态提升到合理层级,避免状态分散

// 优化前:每个TodoItem管理自己的状态
// 问题:状态分散,难以维护

// 优化后:状态集中在Todos组件
const Todos = () => {
    const [todos, setTodos] = useState([...])
    // 所有状态操作都在这里处理
}

2. 精准数据传递

通过props向下传递要的数据,减少组件依赖:

// TodoList组件
<TodoItem 
    todo={todo} // 只传递当前项数据
    onToggle={() => onToggle(todo.id)} // 精确绑定事件
    onDelete={() => onDelete(todo.id)}
/>

3. 样式性能优化

使用Stylus预处理器编写高效CSS:

// global.styl
.todo-item
    display flex
    justify-content space-between
    align-items center
    padding 0.5rem
    border-bottom 1px solid #ccc
    
    .completed
        text-decoration line-through
        color #aaa

Stylus优势

    css 的超集,以前就是要额外创建一个css绑定styl,但是react能够自动解析stylus,相比css的唯一缺点没有了
    剩下的就是响应式布局和一些渲染了

可维护性设计

1. 组件职责单一

组件 职责
Todos 状态管理、数据操作
TodoForm 用户输入处理
TodoList 列表渲染控制
TodoItem 单项展示与交互

2. 单向数据流

graph TD
    A[TodoForm] -->|调用| B[addTodo]
    B --> C[更新todos状态]
    C --> D[重新渲染TodoList]
    D --> E[更新所有TodoItem]

3. props接口

每个组件都有明确定义的props:

// TodoList组件props定义
TodoList.propTypes = {
    todos: PropTypes.array.isRequired,
    onToggle: PropTypes.func.isRequired,
    onDelete: PropTypes.func.isRequired
}

总结:

  1. 状态管理:集中式状态提升与不可变数据更新
  2. 渲染控制:通过组件拆分和props优化减少不必要的渲染
  3. 响应式设计:使用相对单位和弹性布局适配多设备
  4. 可维护性:清晰的组件边界和单向数据流
  5. 开发体验:Stylus预处理器提升样式编写效率

在React开发中,数据绑定不仅是技术实现,更是一种设计哲学。通过状态与视图的精确映射,我们可以构建出既高效又易于维护的应用。所以对数据敏感是研究react的最佳助力。

跨平台UI自动化-Appium

2025年7月9日 11:41

Appium 简介

Appium旨在支持许多不同平台(移动端、网页端、桌面端等)的UI自动化。不仅如此,它还旨在支持用不同语言(JS、Java、Python等)编写的自动化代码。将所有这些功能结合到一个程序中是一项非常艰巨、甚至不可能的任务!

为了实现这一目标,Appium实际上被分为四个部分:

  • Appium Core - 定义核心API
  • Drivers - 实现与特定平台的连接
  • Clients - 用特定语言实现Appium的API
  • Plugins - 更改或扩展Appium的核心功能

因此,为了开始使用Appium自动化某些内容,你需要:

  • 安装Appium本身
  • 为你的目标平台安装驱动程序
  • 为你的目标编程语言安装客户端库
  • (可选)安装一个或多个插件

这些都是基础!如果你准备好加入,请继续快速入门

如果你想了解有关其运作方式的更多详细信息,请参阅以下页面了解背景材料:

最后,要了解Appium的起源,请查看Appium项目历史

安装 Appium

信息

安装前,请务必检查系统要求.

您可以使用 npm 在全局范围内安装 Appium:

npm i -g appium

注意

目前不支持其他软件包管理器。

安装完成后,您应该可以从命令行运行 Appium:

appium

你应该会看到一些输出结果,开头一行是这样的:

[Appium] 欢迎来到 Appium v2.4.1

为了更新Appiums使用 npm:

npm update -g appium

就是这样!如果你看到这个,说明 Appium 服务器已经启动并运行。按 (Ctrl-C) 继续退出并跳转到到 下一步, 在这里我们将安装一个用于自动运行 Android 应用程序的驱动程序.

安装 UiAutomator2 驱动

如果没有驱动,你几乎无法使用 Appium,驱动是允许 Appium 自动化特定平台的接口。

Info

在本快速入门指南中,我们将自动化一个 Android 平台的应用,因为通过 Appium 进行 Android 自动化的系统要求与 Appium 本身相同(而 iOS 驱动例如,需要你使用 macOS)。

我们将使用的驱动称为 UiAutomator2 驱动。值得访问该驱动的文档并将其加入书签,因为这将是今后不可或缺的参考资料。

设置 Android 自动化要求

根据驱动的要求,除了一个运行中的 Appium 服务器,我们还需要设置以下内容:

Android SDK

  • 设置 Android SDK 要求的最简单方法是下载 Android Studio。 我们需要使用其 SDK 管理器 (设置 -> 语言和框架 -> Android SDK) 来下载以下项目:
    • Android SDK 平台(选择我们想要自动化的任何 Android 平台,例如,API 级别 30)
    • Android SDK 平台工具
  • 如果愿意,你也可以不通过 Android Studio 下载这些项目:
  • 设置 ANDROID_HOME 环境变量,指向安装 Android SDK 的目录。通常可以在 Android Studio 的 SDK 管理器中找到这个目录的路径。它将包含 platform-tools 和其他目录。

Java JDK

  • 安装 Java JDK(对于最新的 Android API 级别,需要 JDK 9,否则需要 JDK 8)。你可以从 OracleAdoptium 下载。 确保下载的是 JDK 而不是 JRE。
  • 设置 JAVA_HOME 环境变量,指向 JDK 的安装目录。它将包含 bininclude 和其他目录。

准备设备

  • 如果使用模拟器,使用 Android Studio 创建并启动一个 Android 虚拟设备 (AVD)。 你可能需要下载你想要创建的模拟器的 API 级别的系统镜像。使用 Android Studio 中的 AVD 创建向导通常是完成所有这些操作的最简单方式。
  • 如果使用真实设备,应该为开发设置并启用 USB 调试
  • 连接模拟器或设备后,你可以运行 adb devices(通过位于 $ANDROID_HOME/platform-tools/adb 的二进制文件)来验证你的设备是否显示为已连接。

一旦你的设备在 adb 中显示为已连接,并且你已验证环境变量设置正确,你就可以开始了!如果在这些步骤中遇到任何问题,请参考驱动文档,或必要时查看各种 Android 或 Java 文档网站。

此外,恭喜你:不管你是否打算,你现在已经在你的系统上设置了 Android 开发者工具链,所以你可以开始制作 Android 应用了!

安装驱动本身

由于 UiAutomator2 驱动是由核心 Appium 团队维护的,它有一个官方的驱动名称,你可以通过 Appium 扩展 CLI 轻松安装:

appium driver install uiautomator2

它应该产生类似下面的输出:

Attempting to find and install driver 'uiautomator2'
✔ Installing 'uiautomator2' using NPM install spec 'appium-uiautomator2-driver'
Driver uiautomator2@2.0.5 successfully installed
- automationName: UiAutomator2
- platformNames: ["Android"]

运行此命令将定位并安装 UiAutomator2 驱动的最新版本,使其可用于自动化。请注意,当安装时,它会告诉你哪些平台它适用(在这种情况下是 Android),以及在 Appium 会话中使用此驱动时必须使用的自动化名称(在这种情况下是 UiAutomator2appium:automationName capability)。

Note

在这个快速入门中,我们使用了 扩展 CLI 来安装 UiAutomator2 驱动,但如果你将 Appium 集成到一个 Node.js 项目中,你可能更喜欢使用 npm 来管理 Appium 及其相关驱动。要了解更多关于这种技术的信息,请访问关于管理 Appium 扩展的指南。

现在,再次启动 Appium 服务器(运行 appium),你应该看到新安装的驱动被列为可用:

[Appium] Available drivers:
[Appium]   - uiautomator2@2.0.5 (automationName 'UiAutomator2')

Android 设置完成并且 UiAutomator2 驱动已安装后,你就可以编写你的第一个测试了!现在选择你喜欢的语言并试一试:

4 个月前

编写一个测试 (JS)

要在 JavaScript(Node.js)中编写 Appium 测试,我们需要选择一个与 Appium 兼容的客户端 库。维护最好的库和 Appium 团队推荐使用的库是 WebdriverIO, 所有就让我们使用它吧。既然我们已经安装了 Appium,我们 已经满足了 Node 和 NPM 的要求。因此,只需在计算机上创建一个新的项目目录 然后在其中初始化一个新的 Node.js 项目:

npm init

您在提示中输入什么内容并不重要,只要您最终得到一个有效的 package.json.

现在,通过 NPM 安装 webdriverio 软件包:

npm i --save-dev webdriverio

完成上述操作后,您的 package.json 文件应包含类似以下内容的部分:

package.json

{
  "devDependencies": {
    "webdriverio": "9.10.1"
  }
}

现在是编写测试本身的时候了。创建一个名为 test.js 的新文件,内容如下:

test.js

const {remote} = require('webdriverio');

const capabilities = {
  platformName: 'Android',
  'appium:automationName': 'UiAutomator2',
  'appium:deviceName': 'Android',
  'appium:appPackage': 'com.android.settings',
  'appium:appActivity': '.Settings',
};

const wdOpts = {
  hostname: process.env.APPIUM_HOST || 'localhost',
  port: parseInt(process.env.APPIUM_PORT, 10) || 4723,
  logLevel: 'info',
  capabilities,
};

async function runTest() {
  const driver = await remote(wdOpts);
  try {
    const batteryItem = await driver.$('//*[@text="Battery"]');
    await batteryItem.click();
  } finally {
    await driver.pause(1000);
    await driver.deleteSession();
  }
}

runTest().catch(console.error);

注意

本指南的范围不包括完整介绍 WebdriverIO 客户端 库或这里发生的一切,因此我们暂且不对代码本身进行详细解释。 所以目前我们暂时不对代码本身进行详细解释。您可能需要特别阅读 Appium 能力, 除了熟悉优秀的 WebdriverIO 文档 来获得更全面的解释, 你还可以看到的各种 API 命令以及用途。

注意

示例代码可从 GitHub Appium repository.

基本上,这段代码正在执行以下操作:

  1. 定义一组 "Capabilities" 能力值(参数),以便 Appium 知道您想自动执行哪种任务。 要自动执行的任务。
  2. 在内置的 Android 设置应用程序上启动 Appium 会话。
  3. 找到 "Battery"列表项并点击它。
  4. 停顿片刻,纯粹是为了观察自动化视觉效果。
  5. 结束 Appium 会话。

就是这样!让我们试一试吧。运行测试前,请确保您的 Appium 服务器 在另一个终端会话中运行,否则会出现一个有关无法连接到 Appium 服务器的错误。 然后,你就可以执行脚本了:

node test.js

如果一切顺利,在应用再次关闭之前,你会看到 "设置 "应用打开并导航到 "Battery "视图

恭喜您,您已经开始了 Appium 之旅!请继续阅读一些 下一步骤 继续探索.

1 年前

深入理解 React useEffect:从基础到实战的全攻略

作者 十盒半价
2025年7月9日 11:31

在 React 的函数组件世界里,useEffect堪称处理副作用的 “瑞士军刀”。它能让我们优雅地实现数据获取、定时器管理、事件监听等操作,还能无缝衔接类组件的生命周期逻辑。今天,我们就来全方位拆解这个核心 Hook,让你从 “会用” 进阶到 “精通”!

一、useEffect 基础:揭开副作用的神秘面纱

1. 什么是副作用?

简单来说,副作用就是组件渲染之外的 “额外操作” ,比如:

  • 发起网络请求获取数据

  • 设置定时器或 Interval

  • 添加 / 移除事件监听

  • 手动操作 DOM

这些操作不能直接写在组件函数里(会阻塞渲染),而useEffect就是 React 提供的 “副作用专属容器”。

2. 基本语法与执行时机

useEffect(() => {
  // 副作用逻辑(如数据获取、事件监听等)
  console.log('副作用执行');

  return () => {
    // 清理函数(组件卸载或更新前执行)
    console.log('清理副作用');
  };
}, [依赖数组]); // 可选,控制副作用何时重新执行
  • 无依赖数组:每次组件渲染(挂载 + 更新)后都会执行,相当于componentDidMount + componentDidUpdate

  • 空依赖数组[] :仅在组件挂载后执行一次,类似componentDidMount

  • 指定依赖项:只有依赖项变化时才执行,比如[count]表示count状态变化时触发

💡 小提醒:React 会在浏览器完成页面渲染后异步执行useEffect,不会阻塞用户界面,这点和useLayoutEffect的同步执行不同哦~

二、生命周期平替:useEffect 的 “三重身份”

1. 挂载阶段:模拟 componentDidMount

当依赖数组为空时,useEffect会在组件首次渲染后执行,适合做初始化操作:

useEffect(() => {
  console.log('组件挂载完成!');
  // 发起初始化数据请求
  fetchData();
}, []);

2. 更新阶段:替代 componentDidUpdate

当依赖数组包含特定状态 / Props 时,只有它们变化才会触发副作用:

const [count, setCount] = useState(0);

useEffect(() => {
  console.log(`count更新为:${count}`);
}, [count]); // 仅count变化时执行

3. 卸载阶段:实现 componentWillUnmount

通过返回清理函数,在组件卸载前执行资源释放操作:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);

  return () => {
    clearInterval(timer); // 清除定时器,避免内存泄漏
    console.log('组件卸载,定时器已清除');
  };
}, []);

🎯 关键点:清理函数会在组件卸载时执行,也会在下次同 effect 执行前执行,确保副作用 “有始有终”。

三、实战场景:用 useEffect 解决真实问题

1. 数据获取:接口请求的正确姿势

❌ 错误示范(直接用 async)

// 警告!useEffect不能直接返回Promise
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

✅ 正确做法(内部定义 async 函数)

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch('https://api.example.com/data');
    const result = await response.json();
    setData(result);
  };

  fetchData(); // 立即执行异步函数
}, []); // 空依赖确保仅挂载时请求

2. 事件监听:动态绑定与解绑

const [windowWidth, setWindowWidth] = useState(window.innerWidth);

useEffect(() => {
  const handleResize = () => {
    setWindowWidth(window.innerWidth);
  };

  window.addEventListener('resize', handleResize); // 挂载时绑定事件

  return () => {
    window.removeEventListener('resize', handleResize); // 卸载时解绑
  };
}, []); // 仅绑定/解绑一次,性能更佳

3. 复杂场景:多个 effect 拆分关注点

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  // 拆分不同副作用,逻辑更清晰
  useEffect(() => {
    // 获取用户信息
    fetchUser(userId).then(setUser);
  }, [userId]);

  useEffect(() => {
    // 获取用户帖子
    fetchPosts(userId).then(setPosts);
  }, [userId]);

  // ... 组件渲染逻辑
}

四、避坑指南:常见问题与最佳实践

1. 依赖数组的 “精准控制”

  • 不要遗漏必要依赖:ESLint 的react-hooks/exhaustive-deps规则能帮你检测缺失的依赖项
  • 避免冗余依赖:如果函数内部没有使用某个状态 / Props,就不要放进依赖数组
  • 使用函数式更新:当副作用依赖前一次状态时(如setCount(prev => prev + 1)),可以省略依赖项

2. 处理异步操作的内存泄漏

在数据请求场景中,组件可能在请求完成前卸载,此时更新状态会导致报错。解决方案:

useEffect(() => {
  let isMounted = true; // 标记组件是否仍挂载

  const fetchData = async () => {
    const data = await fetchData();
    if (isMounted) { // 确保组件未卸载时更新状态
      setData(data);
    }
  };

  fetchData();

  return () => {
    isMounted = false; // 卸载时清除标记
  };
}, []);

3. 避免无限循环

当副作用内更新依赖的状态时,可能触发死循环:

// ❌ 错误:每次effect执行都会更新count,导致无限循环
useEffect(() => {
  setCount(count + 1);
}, [count]);

// ✅ 正确:仅初始化时执行一次
useEffect(() => {
  setCount(0); // 初始值设置,空依赖避免重复执行
}, []);

五、代码示例:完整组件中的 useEffect 应用

父组件 App.js(数据获取 + 组件卸载清理)

import { useState, useEffect } from 'react';
import Timer from './Timer';

function App() {
  const [repos, setRepos] = useState([]);
  const [isTimerOn, setIsTimerOn] = useState(true);

  // 仅在挂载时获取GitHub仓库数据
  useEffect(() => {
    const fetchRepos = async () => {
      const response = await fetch('https://api.github.com/users/shunwuyu/repos');
      const data = await response.json();
      setRepos(data);
    };

    fetchRepos();
  }, []);

  return (
    <div>
      <h2>我的GitHub仓库</h2>
      <ul>
        {repos.map(repo => (
          <li key={repo.id}>{repo.full_name}</li>
        ))}
      </ul>

      <h3>定时器演示</h3>
      {isTimerOn && <Timer />}
      <button onClick={() => setIsTimerOn(!isTimerOn)}>
        切换定时器 {isTimerOn ? '关闭' : '开启'}
      </button>
    </div>
  );
}

export default App;

子组件 Timer.js(定时器清理)

import { useState, useEffect } from 'react';

function Timer() {
  const [time, setTime] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setTime(prev => prev + 1); // 使用函数式更新,避免闭包问题
    }, 1000);

    return () => {
      clearInterval(interval); // 组件卸载时清除定时器
      console.log('定时器已清除,避免内存泄漏~');
    };
  }, []); // 空依赖,仅初始化时启动定时器

  return <div>已运行 {time} 秒</div>;
}

export default Timer;

六、总结:useEffect 的核心价值

  • 统一生命周期:一个 Hook 搞定挂载、更新、卸载三阶段逻辑

  • 精准控制:依赖数组让副作用 “按需执行”,避免不必要的性能损耗

  • 函数式风格:配合useState等 Hook,让函数组件拥有媲美类组件的能力,代码更简洁易维护

下次遇到副作用场景时,记得想想useEffect的三个灵魂拷问:

  1. 这个操作需要在什么时机执行?(挂载 / 更新 / 卸载)

  2. 哪些变量变化会触发这个副作用?(依赖数组如何定义)

  3. 是否需要清理资源?(定时器、事件监听、未完成的请求)

掌握这几点,你就能让useEffect真正成为你的 React 开发好帮手!

❌
❌