普通视图

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

从零到一打造 Vue3 响应式系统 Day 5 - 核心概念:单向链表、双向链表

作者 我是日安
2025年9月14日 09:19

ZuB1M1H.png

在昨天,我们建立了响应式的基本运作模式。在继续深入之前,要先了解 Vue 内部用来优化性能的一个核心概念:数据结构。Vue 3 的响应式系统之所以效率高,其内部对数据结构的选择是关键。

一个理想的数据结构需要能有效处理以下操作:

  • 动态关联:effect 与数据之间的依赖关系是能动态建立与解除的。
  • 快速增删:当依赖关系变化时,需要快速地执行新增或移除操作。

为了满足这些高性能要求,Vue 选择了链表 (Linked List) 作为解决方案。本文将深入探讨其运作原理。

单向链表

  • 类型是对象
  • 第一个节点是头节点、最后一个节点称为尾节点
  • 所有节点都通过 next 属性连接起来。

day05-01.png

// 头节点是 head
let head = { value: 1, next: undefined }
const node2 = { value: 2, next: undefined }
const node3 = { value: 3, next: undefined }
const node4 = { value: 4, next: undefined }

// 建立链表之间的关系
head.next = node2
node2.next = node3
node3.next = node4

删除中间节点

假设我们要删除 node3,但在单向链表中,仅凭 node3 本身的引用是无法直接进行操作的,因为我们无法访问到它的前一个节点 (node2) 。因此,我们必须从头节点 (head) 开始遍历,直到找到 node2 为止:

const node3 = { value: 3, next: undefined }

let current = head
while (current) {
  // 找到 node3 的上一个节点
  if (current.next === node3) {
    // 把 node3 的上一个节点指向 node3 的下一个节点
    current.next = node3.next
    break
  }
  current = current.next
}

console.log(head) // 输出新的链表 1->2->4

双向链表

  • 每个节点都有:

    • value: 存储的值
    • next: 指向下一个节点
    • prev: 指向上一个节点
  • 双向链表中,通常头节点没有 prev,尾节点没有 next

它最大的优势在于,从任何一个节点出发,都能够双向遍历,这使得在特定节点前后进行新增或删除操作都非常快速。

// 假设链表的头节点是 head
let head = { value: 1, next: undefined, prev: undefined }
const node2 = { value: 2, next: undefined, prev: undefined }
const node3 = { value: 3, next: undefined, prev: undefined }
const node4 = { value: 4, next: undefined, prev: undefined }

// 建立链表之间的关系
head.next = node2
// node2 的上一个节点指向 head
node2.prev = head
// node2 的下一个节点指向 node3
node2.next = node3
// node3 的上一个节点指向 node2
node3.prev = node2
// node3 的下一个节点指向 node4
node3.next = node4
// node4 的上一个节点指向 node3
node4.prev = node3

删除中间节点

假设我们现在手上有中间节点 node3 要删除,该怎么做:

const node3 = { value: 3, next: undefined, prev: undefined }

// 如果 node3 有上一个节点,就把上一个节点的 next 指向 node3 的下一个节点
if (node3.prev) {
  node3.prev.next = node3.next
} else {
  // 如果 node3 没有上一个节点,说明它是头节点
  head = node3.next
}

// 如果 node3 有下一个节点,就把下一个节点的 prev 指向 node3 的上一个节点
if (node3.next) {
  node3.next.prev = node3.prev
}
console.log(head) // 输出新的链表 1->2->4

可以看到,在已知目标节点的前提下,执行删除操作完全不需要从头遍历,时间复杂度为 O(1)。

单向链表与双向链表比较

现在我们要在 C 节点之前新增一个 X 节点。

单向链表

day05-02.png

  • 时间复杂度:O(n)
  • 原因:需要遍历才能找到前一个节点。

执行步骤

步骤 1:从头节点开始遍历查找。

步骤 2:检查节点 A,不是目标节点的前一个,继续遍历。

步骤 3:找到目标节点 C 的前一个节点 B(因为 B 的 next 属性是 C)。

步骤 4:创建新节点 X。

步骤 5:设置 X.next = C

步骤 6:设置 B.next = X

双向链表

day05-03.png

  • 时间复杂度:O(1)
  • 原因:直接通过 prev 指针访问前一个节点。

执行步骤

步骤 1:直接通过目标节点的 prev 指针找到前一个节点 B。

步骤 2:创建新节点 X。

步骤 3:设置 X.next = C, X.prev = B

步骤 4:设置 B.next = X, C.prev = X


我们可以发现:

  • 单向链表:结构简单,适合只需要向前遍历的场景。
  • 双向链表:更灵活但占用更多内存,适合需要双向操作的场景。

到目前为止,我们已经了解了链表的原理。然而在许多可以用来存储数据集合的结构中,为什么 Vue 的响应式系统会选择链表,而不是我们更常用的数组 (Array) 呢?

链表与数组的比较

特性

数组 (Array) 最大的优点是读取性能极佳。由于内存空间是连续的,我们可以通过索引 [i] 直接定位到任何元素,时间复杂度为 O(1)。

const arr = ['a', 'b', 'c', 'd'] // a=>0  b=>1  c=>2  d=>3

// 删除数组的第一项
arr.shift()

console.log(arr) // ['b', 'c', 'd'] b=>0  c=>1  d=>2

链表:新增、删除元素更快 (O(1)),但查找元素需要遍历整个链表(O(n))。

// 头节点是 head
let head = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4, 
        next: null
      }
    }
  }
}
// 删除链表第一个节点
head = head.next // 将头节点指向下一个节点 node2
console.log(head)
// 输出新的头节点 [2, 3, 4]

删除头、尾项

数组

  • 新增操作(如 unshift)需要移动后续所有元素,可能导致性能下降(O(n))。
  • 删除操作(如 shift)同样需要移动后续所有元素,性能也为(O(n))。

链表

  • 新增操作只需修改指针,性能为 O(1)。
  • 删除操作也只需修改指针,性能为 O(1)。

总的来说,虽然双向链表在内存占用上略高于单向链表,但它提供的 O(1) 复杂度的新增与删除方法,对于需要频繁操作依赖集合的响应式系统来说,是非常重要的。

我们理解了链表的运作原理后,明天我们会继续在 ref 的实现中,结合今天学到的链表知识来改造响应式系统。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

Vue3 后台分页写腻了?我用 1 个 Hook 删掉 90% 重复代码(附源码)

2025年9月14日 09:08

还在为每个列表页写重复的分页代码而烦恼吗? 还在复制粘贴 currentPage、pageSize、loading 等状态吗? 一个 Hook 帮你解决所有分页痛点,减少90%重复代码

背景与痛点

在后台管理系统开发中,分页列表查询非常常见,我们通常需要处理:

  • 当前页、页大小、总数等分页状态
  • 加载中、错误处理等请求状态
  • 搜索、刷新、翻页等分页操作
  • 数据缓存和重复请求处理

这些重复逻辑分散在各个组件中,维护起来很麻烦。

为了解决这个烦恼,我专门封装了分页数据管理 Hook。现在只需要几行代码,就能轻松实现分页查询,省时又高效,减少了大量重复劳动

使用前提 - 接口格式约定

查询接口返回的数据格式:

{
  list: [        // 当前页数据数组
    { id: 1, name: 'user1' },
    { id: 2, name: 'user2' }
  ],
  total: 100     // 数据总条数
}

先看效果:分页查询只需几行代码!

import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user'        // 引入请求用户列表的 API 方法

// 使用 usePageFetch Hook 实现分页数据管理
const {
  currentPage,      // 当前页码
  pageSize,         // 每页条数
  total,            // 数据总数
  data,             // 当前页数据列表
  isFetching,       // 加载状态,用于控制 loading 效果
  search,           // 搜索方法
  onSizeChange,     // 页大小改变事件处理方法
  onCurrentChange   // 页码改变事件处理方法
} = usePageFetch(
  getUserList,  // 查询API
  { initFetch: false }  // 是否自动请求一次(组件挂载时自动拉取第一页数据)     
)

这样子每次分页查询只需要引入hook,然后传入查询接口就好了,减少了大量重复劳动

解决方案

我设计了两个相互配合的 Hook:

  • useFetch:基础请求封装,处理请求状态和缓存
  • usePageFetch:分页逻辑封装,专门处理分页相关的状态和操作
usePageFetch (分页业务层)
├── 管理 page / pageSize / total 状态
├── 处理搜索、刷新、翻页逻辑  
├── 统一错误处理和用户提示
└── 调用 useFetch (请求基础层)
    ├── 管理 loading / data / error 状态
    ├── 可选缓存机制(避免重复请求)
    └── 成功回调适配不同接口格式

核心实现

useFetch - 基础请求封装

// hooks/useFetch.js
import { ref } from 'vue'

const Cache = new Map()

/**
 * 基础请求 Hook
 * @param {Function} fn - 请求函数
 * @param {Object} options - 配置选项
 * @param {*} options.initValue - 初始值
 * @param {string|Function} options.cache - 缓存配置
 * @param {Function} options.onSuccess - 成功回调
 */
function useFetch(fn, options = {}) {
  const isFetching = ref(false)
  const data = ref()
  const error = ref()

  // 设置初始值
  if (options.initValue !== undefined) {
    data.value = options.initValue
  }

  function fetch(...args) {
    isFetching.value = true
    let promise

    if (options.cache) {
      const cacheKey = typeof options.cache === 'function'
        ? options.cache(...args)
        : options.cache || `${fn.name}_${args.join('_')}`

      promise = Cache.get(cacheKey) || fn(...args)
      Cache.set(cacheKey, promise)
    } else {
      promise = fn(...args)
    }

    // 成功回调处理
    if (options.onSuccess) {
      promise = promise.then(options.onSuccess)
    }

    return promise
      .then(res => {
        data.value = res
        isFetching.value = false
        error.value = undefined
        return res
      })
      .catch(err => {
        isFetching.value = false
        error.value = err
        return Promise.reject(err)
      })
  }

  return {
    fetch,
    isFetching,
    data,
    error
  }
}

export default useFetch

usePageFetch - 分页逻辑封装

// hooks/usePageFetch.js
import { ref, onMounted, toRaw, watch } from 'vue'
import useFetch from './useFetch' // 即上面的hook ---> useFetch 
import { ElMessage } from 'element-plus'

/**
 * 分页数据管理 Hook
 * @param {Function} fn - 请求函数
 * @param {Object} options - 配置选项
 * @param {Object} options.params - 默认参数
 * @param {boolean} options.initFetch - 是否自动初始化请求
 * @param {Ref} options.formRef - 表单引用
 */
function usePageFetch(fn, options = {}) {
  // 分页状态
  const page = ref(1)
  const pageSize = ref(10)
  const total = ref(0)
  const data = ref([])
  const params = ref()
  const pendingCount = ref(0)

  // 初始化参数
  params.value = options.params

  //  使用基础请求 Hook
  const { isFetching, fetch: fetchFn, error, data: originalData } = useFetch(fn)

  //  核心请求方法
  const fetch = async (searchParams, pageNo, size) => {
    try {
      // 更新分页状态
      page.value = pageNo
      pageSize.value = size
      params.value = searchParams

      // 发起请求
      await fetchFn({
        page: pageNo,
        pageSize: size,
        // 使用 toRaw 避免响应式对象问题
        ...(searchParams ? toRaw(searchParams) : {})
      })

      // 处理响应数据
      data.value = originalData.value?.list || []
      total.value = originalData.value?.total || 0
      pendingCount.value = originalData.value?.pendingCounts || 0
    } catch (e) {
      console.error('usePageFetch error:', e)
      ElMessage.error(e?.msg || e?.message || '请求出错')
      // 清空数据,提供更好的用户体验
      data.value = []
      total.value = 0
    }
  }

  //  搜索 - 重置到第一页
  const search = async (searchParams) => {
    await fetch(searchParams, 1, pageSize.value)
  }

  // 刷新当前页
  const refresh = async () => {
    await fetch(params.value, page.value, pageSize.value)
  }

  // 改变页大小
  const onSizeChange = async (size) => {
    await fetch(params.value, 1, size) // 重置到第一页
  }

  // 切换页码
  const onCurrentChange = async (pageNo) => {
    await fetch(params.value, pageNo, pageSize.value)
  }

  // 组件挂载时自动请求
  onMounted(() => {
    if (options.initFetch !== false) {
      search(params.value)
    }
  })

  // 监听表单引用变化(可选功能)
  watch(
    () => options.formRef,
    (formRef) => {
      if (formRef) {
        console.log('Form ref updated:', formRef)
      }
    }
  )

  return {
    // 分页状态
    currentPage: page,
    pageSize,
    total,
    pendingCount,
    
    // 数据状态
    data,
    originalData,
    isFetching,
    error,
    
    // 操作方法
    search,
    refresh,
    onSizeChange,
    onCurrentChange
  }
}

export default usePageFetch

完整使用示例

用element ui举例

<template>
    <el-form :model="searchForm" >
      <el-form-item label="用户名">
        <el-input v-model="searchForm.username" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
      </el-form-item>
    </el-form>
  <!-- 表格数据展示,绑定 data 和 loading 状态 -->
  <el-table :data="data" v-loading="isFetching">
    <!-- ...表格列定义... -->
  </el-table>

  <!-- 分页组件,绑定当前页、页大小、总数,并响应切换事件 -->
  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :total="total"
    @size-change="onSizeChange"
    @current-change="onCurrentChange"
  />
</template>
<script setup>
import { ref } from 'vue'
import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user'        // 引入请求用户列表的 API 方法

// 搜索表单数据,响应式声明
const searchForm = ref({
  username: ''
})

// 使用 usePageFetch Hook 实现分页数据管理
const {
  currentPage,      // 当前页码
  pageSize,         // 每页条数
  total,            // 数据总数
  data,             // 当前页数据列表
  isFetching,       // 加载状态,用于控制 loading 效果
  search,           // 搜索方法
  onSizeChange,     // 页大小改变事件处理方法
  onCurrentChange   // 页码改变事件处理方法
} = usePageFetch(
  getUserList, 
  { initFetch: false }  // 是否自动请求一次(组件挂载时自动拉取第一页数据)     
)

/**
* 处理搜索操作
*/
const handleSearch = () => {
  search({ username: searchForm.value.username })
}

</script>

高级用法

带缓存

const {
  data,
  isFetching,
  search
} = usePageFetch(getUserList, {
  cache: (params) => `user-list-${JSON.stringify(params)}` // 自定义缓存 key
})

设计思路解析

  • 职责分离:useFetch 专注请求状态管理,usePageFetch 专注分页逻辑
  • 统一错误处理:在 usePageFetch 层统一处理错误
  • 智能缓存机制:支持多种缓存策略
  • 生命周期集成:自动在组件挂载时请求数据

总结

这套分页管理 Hook 的优势:

  • 开发效率高,减少90%的重复代码,新增列表页从 30 分钟缩短到 5 分钟
  • 状态管理完善,自动处理加载、错误、数据状态
  • 缓存机制,避免重复请求
  • 错误处理统一,用户体验一致
  • 易于扩展,支持自定义配置和回调

如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!

Vue3 的 ref 和 reactive 到底用哪个?90% 的开发者都选错了

作者 刘大华
2025年9月14日 00:09

前言

refreactiveVue3Composition API里最常用的两个核心成员,专门用来创建响应式数据的。 啥叫响应式呢?就是数据一变,页面自动跟着更新,不用你手动刷新。 其实它们干的是一件事就是:让Vue能监听到你的数据变化

但它们用起来不一样,适合的场景也不一样。很多人在用的时候都是凭感觉选,到底该用哪个?一直都觉得很模糊。

先简单认识一下:

ref:可以包装任何类型的值,包括基本类型(数字、字符串等)和对象类型。使用时需要通过.value来访问和修改值。 reactive:只能包装对象类型(包括数组)。使用时直接访问属性即可,不需要.value

b0ca79428871de1434b721295ba9daef.jpg

下面咱们通过实际例子来看一下。

一、区别

// ref 啥都能包
const count = ref(0)          // 数字
const name = ref('小明')       // 字符串  
const isActive = ref(false)   // 布尔值
const user = ref({ age: 18 }) // 对象
const list = ref([1, 2, 3])   // 数组

// reactive 只接对象/数组
const user = reactive({ age: 18 }) // 对象
const list = reactive([1, 2, 3])   // 数组
const count = reactive(0)         // 报错

二、优缺点

ref

优点:
  1. 什么都能包:基本类型、对象、数组,来者不拒
  2. 赋值简单:直接 xxx.value = newValue
  3. 解构安全:不用怕响应式丢失
  4. 类型推导好TypeScript支持完美
缺点:
  1. 总要写 .value:有点烦人
  2. 模板中也要 .value:不过 Vue 会自动解包

reactive

优点:
  1. 不用写 .value:直接访问属性
  2. 相关联数据组织性好:类似Vue2data
缺点:
  1. 解构大坑:直接解构容易丢失响应式
  2. 赋值限制:不能整个重新赋值
  3. 类型推导有时抽风:TS环境下偶尔会出现问题

三、watch监听中的差异

这里是最容易踩坑的地方!

监听 ref

const count = ref(0)
const user = ref({ name: '小明', age: 18 })

// 监听基本类型 ref
watch(count, (newVal, oldVal) => {
  console.log('count变化:', newVal, oldVal)
})

// 监听对象 ref - 需要深度监听
watch(user, (newVal, oldVal) => {
  console.log('user变化:', newVal, oldVal)
}, { deep: true }) // 必须加 deep!

// 监听对象 ref 的特定属性
watch(() => user.value.name, (newVal, oldVal) => {
  console.log('name变化:', newVal, oldVal)
})

监听 reactive

const state = reactive({
  count: 0,
  user: { name: '小明', age: 18 }
})

// 自动深度监听,不需要 deep: true
watch(state, (newVal, oldVal) => {
  console.log('state变化:', newVal, oldVal)
}) 

// 推荐:监听特定属性
watch(() => state.count, (newVal) => {
  console.log('count变了', newVal)
})

// 监听嵌套属性
watch(() => state.user.name, (newVal) => {
  console.log('名字变了', newVal)
})

// 监听多个属性
watch([() => state.count, () => state.user.name], ([newCount, newName]) => {
  console.log('count或name变了', newCount, newName)
})

watch 的重要区别

1.深度监听:

  • ref对象需要手动{ deep: true }
  • reactive自动深度监听

2.旧值获取:

  • reactive的旧值和新值相同(Proxy特性)
  • ref的旧值正常

3.性能影响:

  • reactive自动深度监听,可能影响性能
  • ref可以精确控制监听深度

四、案例

案例1:表单处理 - 推荐 reactive

// 相关联的表单数据,用 reactive 更合适
const form = reactive({
  username: '',
  password: '',
  remember: false,
  errors: {}
})

// 验证函数
const validateForm = () => {
  form.errors = {}
  if (!form.username) {
    form.errors.username = '用户名不能为空'
  }
  // ...其他验证
}

案例2:API 数据加载 - 推荐 ref

// API 返回的数据,经常需要重新赋值,用 ref
const data = ref(null)
const loading = ref(false)
const error = ref(null)

const fetchData = async () => {
  loading.value = true
  try {
    const response = await fetch('/api/data')
    data.value = await response.json() // 直接赋值,美滋滋
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

案例3:组件状态管理 - 看情况

// 方案1:用 reactive(状态相关联)
const modal = reactive({
  isOpen: false,
  title: '',
  content: '',
  loading: false
})

// 方案2:用多个 ref(状态相对独立)
const isModalOpen = ref(false)
const modalTitle = ref('')
const modalContent = ref('')
const modalLoading = ref(false)

案例4:列表操作 - 强烈推荐 ref

const list = ref([])

// 添加项目
const addItem = (item) => {
  list.value = [...list.value, item] // 重新赋值,安全
}

// 删除项目
const removeItem = (id) => {
  list.value = list.value.filter(item => item.id !== id)
}

// 清空列表
const clearList = () => {
  list.value = [] // 直接赋值,不会丢失响应式
}

如果用reactive来做列表:

const list = reactive([])

// 添加项目 - 只能用 push
const addItem = (item) => {
  list.push(item) // 可以,但不够直观
}

// 删除项目 - 需要找到索引
const removeItem = (id) => {
  const index = list.findIndex(item => item.id === id)
  if (index !== -1) {
    list.splice(index, 1) // 有点麻烦
  }
}

// 清空列表 - 只能修改长度
const clearList = () => {
  list.length = 0 // 能工作,但有点 hack
}

五、组合式函数中的选择

这是决定用哪个的关键场景!

返回多个 ref:灵活好用

function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const double = computed(() => count.value * 2)
  const increment = () => count.value++

  return { count, double, increment }
}

// 使用时:
const { count, double, increment } = useCounter()

返回 reactive:结构固定

function useUser() {
  const state = reactive({
    user: null,
    loading: false,
    error: null
  })

  const fetchUser = async (id) => {
    state.loading = true
    try {
      state.user = await fetchUserById(id)
    } catch (err) {
      state.error = err.message
    } finally {
      state.loading = false
    }
  }

  return { ...toRefs(state), fetchUser } // 必须用 toRefs!
}

// 使用时:
const { user, loading, error, fetchUser } = useUser()

明显看到,返回多个ref更简单直接。

六、混合使用模式(推荐)

// 基本类型和需要重新赋值的用 ref
const loading = ref(false)
const error = ref(null)
const data = ref(null)

// 相关联的数据用 reactive
const form = reactive({
  username: '',
  password: '',
  remember: false
})

// 这样写,既清晰又安全

七、性能

其实在大多数情况下,性能差异可以忽略不计,推荐如下:

  1. 大量基本类型:用ref,因为reactive需要创建Proxy对象
  2. 大对象但只关心部分属性:用多个ref,避免不必要的深度监听
  3. 频繁重新赋值:用refreactive不能直接重新赋值

总结

优先使用ref的场景: -基本类型(数字、字符串、布尔值) -DOM 引用和组件引用 -需要频繁重新赋值的变量 -API 返回的数据 -组合式函数的返回值 -列表数据

考虑使用reactive的场景: -相关联的表单数据 -复杂的组件状态 -配置对象 -不需要重新赋值的对象

希望这篇分析对你有帮助。如果有不同意见,欢迎在评论区理性讨论!

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

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《Java 订单超时未支付,如何自动关闭?掌握这 3 种方案,轻松拿 offer!》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

Vue移动端开发的适配方案与性能优化技巧

作者 鹏多多
2025年9月12日 09:08

1. 移动端适配方案

1.1. 视口适配

在Vue项目中设置viewport的最佳实践:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

通过插件自动生成viewport配置:

// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.plugin('html').tap(args => {
      args[0].meta = {
        viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
      }
      return args
    })
  }
}

1.2. 基于rem/em的适配方案

使用postcss-pxtorem自动转换px为rem:

// postcss.config.js
module.exports = {
  plugins: {
    'postcss-pxtorem': {
      rootValue: 37.5, // 设计稿宽度的1/10
      propList: ['*'],
      selectorBlackList: ['.ignore', '.hairlines']
    }
  }
}

1.3. vw/vh视口单位适配

结合postcss-px-to-viewport实现px自动转换:

// postcss.config.js
module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      unitToConvert: 'px',
      viewportWidth: 375,
      unitPrecision: 5,
      propList: ['*'],
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      selectorBlackList: [],
      minPixelValue: 1,
      mediaQuery: false,
      replace: true,
      exclude: undefined,
      include: undefined,
      landscape: false,
      landscapeUnit: 'vw',
      landscapeWidth: 568
    }
  }
}

1.4. 移动端UI组件库适配

推荐使用适配移动端的Vue组件库:

在项目中集成Vant组件库:

npm i vant -S

按需引入组件:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { Button, Cell, CellGroup } from 'vant';
import 'vant/lib/index.css';

const app = createApp(App);

app.use(Button)
   .use(Cell)
   .use(CellGroup);

app.mount('#app')

2. 移动端性能优化技巧

2.1. 虚拟列表实现长列表优化

可以使用vue-virtual-scroller实现高性能列表:

npm install vue-virtual-scroller --save
<template>
  <RecycleScroller
    class="items-container"
    :items="items"
    :item-size="32"
    key-field="id"
  >
    <template #item="{ item }">
      <div class="item">{{ item.text }}</div>
    </template>
  </RecycleScroller>
</template>

<script>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

export default {
  components: {
    RecycleScroller
  },
  data() {
    return {
      items: Array.from({ length: 10000 }).map((_, i) => ({
        id: i,
        text: `Item ${i}`
      }))
    }
  }
}
</script>

2.2. 图片懒加载与优化

使用vueuse的useIntersectionObserver实现图片懒加载:

npm i @vueuse/core
<template>
  <img 
    v-for="item in imageList" 
    :key="item.id"
    :src="item.loaded ? item.src : placeholder"
    @load="handleImageLoad(item)"
    class="lazy-image"
  >
</template>

<script>
import { ref, onMounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

export default {
  setup() {
    const imageList = ref([
      { id: 1, src: 'https://example.com/image1.jpg', loaded: false },
      { id: 2, src: 'https://example.com/image2.jpg', loaded: false },
      // 更多图片...
    ])
    const placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
    
    const handleImageLoad = (item) => {
      item.loaded = true
    }
    
    onMounted(() => {
      imageList.value.forEach(item => {
        const el = ref(null)
        const { stop } = useIntersectionObserver(
          el,
          ([{ isIntersecting }]) => {
            if (isIntersecting) {
              item.loaded = true
              stop()
            }
          }
        )
      })
    })
    
    return {
      imageList,
      placeholder,
      handleImageLoad
    }
  }
}
</script>

2.3. 减少首屏加载时间

使用Vue的异步组件和路由懒加载:

// 路由配置
const routes = [
  {
    path: '/home',
    name: 'Home',
    component: () => import(/* webpackChunkName: "home" */ '../views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

使用CDN加载外部资源:

<!-- index.html -->
<head>
  <!-- 加载Vue -->
  <script src="https://cdn.tailwindcss.com"></script>
  <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
</head>

2.4. 事件节流与防抖

使用lodash的throttle和debounce函数:

npm install lodash --save
<template>
  <div>
    <input v-model="searchText" @input="debouncedSearch" placeholder="搜索...">
  </div>
</template>

<script>
import { debounce } from 'lodash'

export default {
  data() {
    return {
      searchText: '',
      debouncedSearch: null
    }
  },
  created() {
    this.debouncedSearch = debounce(this.handleSearch, 300)
  },
  methods: {
    handleSearch() {
      // 执行搜索操作
      console.log('Searching with:', this.searchText)
    }
  }
}
</script>

3. 移动端常见问题解决方案

3.1. 移动端300ms点击延迟问题

使用fastclick库解决:

npm install fastclick --save
// main.js
import FastClick from 'fastclick'

FastClick.attach(document.body)

3.2. 滚动卡顿问题

优化滚动性能:

.scroll-container {
  -webkit-overflow-scrolling: touch; /* 开启硬件加速 */
  overflow-y: auto;
}

3.3. 移动端适配iOS安全区域

/* 适配iOS安全区域 */
body {
  padding-top: constant(safe-area-inset-top);
  padding-top: env(safe-area-inset-top);
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

3.4. 解决1px边框问题

/* 0.5px边框 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
  .border-bottom {
    border-bottom: 0.5px solid #e5e5e5;
  }
}

4. 性能监控与分析

使用Lighthouse进行性能评估:

npm install -g lighthouse
lighthouse https://your-vue-app.com --view

使用Vue DevTools进行性能分析:

  1. 在Chrome浏览器中安装Vue DevTools扩展
  2. 在Vue项目中启用性能模式:
// main.js
const app = createApp(App)

if (process.env.NODE_ENV !== 'production') {
  app.config.performance = true
}

app.mount('#app')

5. 实战案例:开发响应式移动端应用

5.1. 项目初始化

npm init vite@latest my-mobile-app -- --template vue-ts
cd my-mobile-app
npm install

5.2. 配置适配方案

集成postcss-px-to-viewport:

npm install postcss-px-to-viewport --save-dev

配置postcss.config.js:

module.exports = {
  plugins: {
    'postcss-px-to-viewport': {
      unitToConvert: 'px',
      viewportWidth: 375,
      unitPrecision: 5,
      propList: ['*'],
      viewportUnit: 'vw',
      fontViewportUnit: 'vw',
      selectorBlackList: [],
      minPixelValue: 1,
      mediaQuery: false,
      replace: true,
      exclude: /node_modules/i
    }
  }
}

5.3. 集成Vant组件库

npm install vant --save

配置按需引入:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [VantResolver()],
    }),
  ],
})

5.4. 实现响应式布局

<template>
  <div class="container">
    <van-nav-bar title="我的应用" left-arrow @click-left="onClickLeft" />
    
    <van-swipe class="banner" :autoplay="3000" indicator-color="white">
      <van-swipe-item v-for="(item, index) in banners" :key="index">
        <img :src="item" alt="Banner" />
      </van-swipe-item>
    </van-swipe>
    
    <van-grid :columns-num="4">
      <van-grid-item v-for="(item, index) in gridItems" :key="index" :text="item.text" :icon="item.icon" />
    </van-grid>
    
    <van-list
      v-model:loading="loading"
      :finished="finished"
      finished-text="没有更多了"
      @load="onLoad"
    >
      <van-cell v-for="(item, index) in list" :key="index" :title="item.title" :value="item.value" is-link />
    </van-list>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue'

export default defineComponent({
  setup() {
    const banners = ref([
      'https://picsum.photos/600/200?random=1',
      'https://picsum.photos/600/200?random=2',
      'https://picsum.photos/600/200?random=3'
    ])
    
    const gridItems = ref([
      { icon: 'photo-o', text: '图片' },
      { icon: 'video-o', text: '视频' },
      { icon: 'music-o', text: '音乐' },
      { icon: 'friends-o', text: '社交' }
    ])
    
    const list = ref([])
    const loading = ref(false)
    const finished = ref(false)
    
    const onLoad = () => {
      // 模拟加载数据
      setTimeout(() => {
        for (let i = 0; i < 10; i++) {
          list.value.push({
            title: `标题 ${list.value.length + 1}`,
            value: '内容'
          })
        }
        
        // 加载状态结束
        loading.value = false
        
        // 数据全部加载完成
        if (list.value.length >= 50) {
          finished.value = true
        }
      }, 1000)
    }
    
    onMounted(() => {
      onLoad()
    })
    
    const onClickLeft = () => {
      console.log('返回')
    }
    
    return {
      banners,
      gridItems,
      list,
      loading,
      finished,
      onLoad,
      onClickLeft
    }
  }
})
</script>

<style scoped>
.banner img {
  width: 100%;
  height: 180px;
  object-fit: cover;
}
</style>

通过以上步骤,我们可以开发出一个适配移动端的Vue应用。


本次分享就到这儿啦,我是鹏多多,如果看了觉得有帮助的,欢迎 点赞 关注 评论,在此谢过道友;

往期文章

昨天以前首页

前端文件下载的三种方式:a标签、Blob、ArrayBuffer

作者 CassieHuu
2025年9月13日 13:43

引言

在现代 Web 应用中,前端文件下载是常见的需求。根据不同的业务场景和技术要求,我们可以选择多种下载策略,主要包括直接触发下载、通过 Blob 对象下载以及通过 arrayBuffer 对象下载。理解它们的差异和适用场景对于构建高效、灵活的文件下载功能至关重要。

一. 直接触发下载

工作原理:

这是最简单直接的下载方式。前端通过创建 <a> 标签并设置其 href 属性为文件下载链接,然后模拟点击该链接来触发浏览器下载。

<a href="http://example.com/api/download/file.pdf" download="document.pdf">下载文件</a>

或使用 JavaScript:

function directDownload(url, filename) {
    const link = document.createElement('a');
    link.href = url;
    link.download = filename || 'download'; // filename 是可选的,如果服务器没有提供
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}

// 示例
// directDownload('http://example.com/api/download/file.pdf', 'myDocument.pdf');

适用场景:

  • 简单文件下载: 当下载的文件不需要前端进行任何处理,且服务器能够直接提供可访问的下载链接时。
  • 公共文件: 下载的文件不涉及用户认证或授权,或者认证信息可以通过 URL 参数或 Cookie 自动携带。
  • 后端直接控制文件名: 服务器可以直接通过 Content-Disposition 响应头指定下载的文件名。

使用差异:

  • 优点: 实现简单,浏览器处理机制成熟,通常由浏览器完成进度管理和错误处理。
  • 缺点:
    • 无法处理认证: 对于需要认证才能下载的文件,如果认证信息不能通过 URL 或 Cookie 传递,直接下载会比较困难(例如,需要携带请求头 Authorization 的情况)。
    • 无法获取下载进度: 前端无法直接监控下载进度。
    • 文件名控制受限: 如果后端不设置 Content-Disposition,文件名可能不可控,或者浏览器会使用 URL 的最后一部分作为文件名。
    • 跨域限制: 如果下载链接与当前页面不同源,可能存在跨域问题。
    • 无法对文件内容进行前端处理: 在下载前或下载后无法对文件内容进行修改、预览等操作。

二. 通过 Blob 对象下载

工作原理:

Blob(Binary Large Object)对象表示一个不可变的、原始数据的类文件对象。通过 XMLHttpRequestFetch APIresponseType: 'blob' 请求二进制数据,获取到 Blob 对象后,可以利用 URL.createObjectURL() 方法创建一个临时的 URL,然后通过 <a> 标签触发下载。

async function downloadWithBlob(url, filename) {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const blob = await response.blob(); // 获取 Blob 对象

        const urlObject = window.URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = urlObject;
        link.download = filename; // 由前端指定文件名,也可以从响应头中获取
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        window.URL.revokeObjectURL(urlObject); // 释放 URL 对象

    } catch (error) {
        console.error('Download failed:', error);
    }
}

// 示例
// downloadWithBlob('http://example.com/api/getFileData', 'report.xlsx');

适用场景:

  • 需要认证的下载: 当下载文件需要自定义请求头(如 Authorization token)进行身份认证时,fetchXMLHttpRequest 可以很好地支持。
  • 前端处理文件内容: 在下载前需要对文件内容进行预览、压缩、解密、格式转换等操作时,Blob 是非常方便的中间数据格式。
  • 生成文件: 前端动态生成文件内容(例如 CSV、图片、PDF),然后将其打包成 Blob 进行下载。
  • 获取下载进度: fetchXMLHttpRequest 可以监听下载进度事件。
  • 后端只返回二进制数据,前端控制文件名: 当后端只返回纯粹的二进制数据,文件类型和文件名需要前端来判断或指定时。

使用差异:

  • 优点:
    • 支持认证和自定义请求头: 能够处理复杂的认证场景。
    • 前端可控性高: 可以在下载前对文件内容进行处理,支持动态生成文件。
    • 可获取下载进度: 通过 fetchXMLHttpRequest 可以监听下载进度。
    • 前端可定义文件名和文件类型: 可以灵活指定下载文件的名称和 MIME 类型。
  • 缺点:
    • 内存占用: 对于非常大的文件,将整个文件内容加载到内存中可能会占用较多内存。
    • 兼容性: 较老的浏览器可能不支持 Blob 或相关 API。
    • 需要手动释放 URL: URL.createObjectURL() 创建的 URL 需要通过 URL.revokeObjectURL() 手动释放,否则可能导致内存泄漏。

三. 通过 ArrayBuffer 对象下载

工作原理:

ArrayBuffer 对象用于表示通用的、固定长度的原始二进制数据缓冲区。与 Blob 类似,通过 XMLHttpRequestFetch APIresponseType: 'arraybuffer' 请求二进制数据,获取到 ArrayBuffer 后,通常会将其转换为 Blob,然后再通过 URL.createObjectURL() 触发下载。

async function downloadWithArrayBuffer(url, filename, mimeType = 'application/octet-stream') {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const arrayBuffer = await response.arrayBuffer(); // 获取 ArrayBuffer 对象

        // 将 ArrayBuffer 转换为 Blob
        const blob = new Blob([arrayBuffer], { type: mimeType });

        const urlObject = window.URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = urlObject;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        window.URL.revokeObjectURL(urlObject);

    } catch (error) {
        console.error('Download failed:', error);
    }
}

// 示例
// downloadWithArrayBuffer('http://example.com/api/getBinaryData', 'image.png', 'image/png');

适用场景:

  • 需要对二进制数据进行底层操作: 当需要直接访问和操作文件的原始字节数据时,ArrayBuffer 是理想的选择。例如,加密/解密文件内容、解析文件头信息、进行位操作等。
  • 与其他二进制 API 交互: ArrayBufferTypedArray(如 Uint8ArrayInt32Array 等)和 DataView 紧密结合,方便进行二进制数据的读写。
  • 需要认证的下载:Blob 类似,fetchXMLHttpRequest 支持认证请求。
  • 获取下载进度: 同样可以通过 fetchXMLHttpRequest 监听下载进度。
  • 后端返回的文件格式和文件名称是后端定义的: arrayBuffer 同样可以接收这种响应,然后由前端根据响应头或业务逻辑确定文件名和MIME类型。

使用差异:

  • 优点:
    • 底层数据访问: 能够直接操作原始二进制数据,适用于更底层的处理需求。
    • 支持认证和自定义请求头:Blob
    • 可获取下载进度:Blob
  • 缺点:
    • 需要转换为 Blob 才能下载: ArrayBuffer 本身不能直接创建 URL 进行下载,通常需要先转换为 Blob
    • 内存占用:Blob
    • 需要手动释放 URL:Blob

总结对比

特性 直接触发下载 Blob 对象下载 ArrayBuffer 对象下载
认证/请求头 困难(依赖 Cookie/URL) 容易(通过 Fetch/XHR) 容易(通过 Fetch/XHR)
前端处理 无法处理 方便(预览、压缩、生成等) 方便(底层二进制操作,需转 Blob 下载)
下载进度 无法获取 可获取 可获取
文件名控制 依赖后端 Content-Disposition 或 URL 前端可定义,或从响应头获取 前端可定义,或从响应头获取
内存占用 浏览器管理,前端感知较少 较高(整个文件加载到内存) 较高(整个文件加载到内存)
实现复杂度 中等 中等偏高(通常需转 Blob)
适用场景 简单文件、公共文件 需认证、前端处理、动态生成文件、前端控制文件名 需认证、底层二进制操作、前端控制文件名
跨域支持 浏览器处理(可能受 CORS 影响) 完全支持(通过 CORS) 完全支持(通过 CORS)

从零到一打造 Vue3 响应式系统 Day 4 - 核心概念:收集依赖、触发更新

作者 我是日安
2025年9月13日 10:08

ZuB1M1H.png

前言

const count = ref(0)

effect(() => {
  console.log('count.value ==>', count.value);
})

setTimeout(() => {
  count.value++
}, 1000)

昨天我们的目标是让一段简单的 refeffect 代码能够自动响应。

  1. 进入页面输出 count.value ==> 0
  2. 一秒后自动输出 count.value ==> 1

然而,我们初次实现时遇到了问题:无法正确取值 (undefined),也无法在值变更后触发更新。

为了解决这个问题,我们要思考 ref 需要做些什么:

  1. 当获取值时,ref 要怎么知道是谁在读取它?
  2. 当触发更新后,ref 又要怎么知道该通知谁?

让 Ref 知道谁在读取

// 原始代码
class RefImpl {
  _value;
  constructor(value){
    this._value = value
  }
}

现在要加入 getter 和 setter,让 count.value 能正常运作:

class RefImpl {
  _value;
  
  constructor(value){
    this._value = value
  }
  
  // 新增 getter:读取 value 时触发
  get value(){
    console.log('有人读取了 value!')
    return this._value
  }
  
  // 新增 setter:设置 value 时触发
  set value(newValue){
    console.log('有人修改了 value!')
    this._value = newValue
  }
}

day4-01.png

现在 count.value 看起来可以正常返回值了,但此时它还是不知道是谁在读取、需要通知谁。

Effect 函数

export function effect(fn){
  fn()
}

这时候我们需要一个地方来存储当前正在执行的 effect 函数。

// effect.ts
// 用于保存当前正在执行的 effect 函数
export let activeSub;

export function effect(fn){
  activeSub = fn
  activeSub()
  activeSub = undefined
}

这个新版的 effect 函数做了三件事:

  1. 注册副作用: 在执行传入的函数 fn 之前,先将它赋值给全局变量 activeSub
  2. 执行副作用: 立即执行 fn()。如果在执行过程中读取了某个 ref.value,这个 ref 就能通过 activeSub 知道是谁在读取它。
  3. 清除副作用: 执行完毕后,必须将 activeSub 清空 (设为 undefined)。这一点非常重要,它能确保只有在 effect 的执行期间,读取 ref 的行为才会被视为依赖收集。

收集依赖实现

现在我们要让 ref 能够:

  1. 在被读取时,记录是谁在读取(依赖收集)
  2. 在被修改时,通知所有读取者(触发更新)

我们可以在 getter 读取值的时候,判断 activeSub 是否存在,来确认当前情况是否需要收集依赖。

// ref.ts
import { activeSub } from './effect'

class RefImpl {
  _value;
  subs;  // 新增:用于存储订阅者
  
  constructor(value){
    this._value = value
  }
  
  // 新增 getter:读取 value 时触发
  get value(){
    // 依赖收集:如果存在 activeSub,就记录下来
    if(activeSub){
      this.subs = activeSub
    }
    return this._value
  }
  
  // 新增 setter:设置 value 时触发
  set value(newValue){
    // 触发更新:如果存在订阅者,就执行它
    if(this.subs){
      this.subs()  // 重新执行 effect
    } // 可简写为 this.subs?.()
  }
}

为了方便在后续的系统中判断一个变量是否为 ref 对象,我们可以新增一个辅助函数 isRef 和一个内部标记:

enum ReactiveFlags {
  IS_REF = '__v_isRef'
}

class RefImpl {
  _value;
  subs;  // 新增:用于存储订阅者
  [ReactiveFlags.IS_REF] = true
  
  ...
}

export function isRef(value){
  return !!(value && value[ReactiveFlags.IS_REF])
}

现在,让我们将所有部分串联起来,完整地模拟一遍执行流程。


完整执行流程

页面初始化与依赖收集

刚开始进入页面。

import { ref, effect } from '../dist/reactivity.esm.js'

const count = ref(0)

程序执行:const count = ref(0)

  • 执行 ref(0),创建一个 RefImpl 实例。

  • 此时 count 实例的内部状态为:

    • _value: 0
    • 没有任何订阅者:subs: undefined
    • 带有一个内部标记:__v_isRef: true

调用 effect 函数,并传入匿名函数 fn 作为参数。

effect(() => {
  console.log('effect', count.value)
})

进入 effect 函数内部

export let activeSub;

export function effect(fn){
  activeSub = fn
  activeSub()
  activeSub = undefined
}
  1. 设置 activeSub activeSub 被赋值为 fn,即 activeSub = fn

  2. 立即执行 fn()

    1. 执行 console.log('effect', count.value)

    2. 这触发了 count 实例的 get value()

    3. 进入 getter 内部:

      • if(activeSub) 条件成立,因为 activeSub 正是我们的 fn

        JavaScript

        if(activeSub){
           this.subs = activeSub
        }
        
    4. 执行“依赖收集”this.subs = activeSub

    5. 现在 count 实例通过 subs 属性,记住了是 fn 在依赖它。

    6. getter 返回 this._value (也就是 0)。

    7. console.log 输出:effect 0

  3. activeSub = undefined (执行完毕后清空,表示当前没有正在执行的 effect)。

此时:

  1. count.subs 就是传入 effect 的那个函数。
  2. 依赖关系建立:counteffect(fn)

一秒之后

  • set value(newValue) 被调用,this._value = 1

  • this.subs?.() 被执行,即:如果存在订阅者,就调用它(这里就是前面存起来的 effect 函数)。

  • 触发更新: effect 函数再次执行。

    • console.log('effect', count.value) → 再次读取 getter。
    • 此时 activeSubundefined,所以不会重复收集依赖。
    • 这次是直接执行 effect 函数的本体,而不是再次经过 effect(fn) 的包装流程,所以第二次之后执行 effectactiveSubundefined
    • console.log 输出:effect 1

这样,我们就完成了响应式依赖收集的最小可行版本。


完整代码

ref.ts

import { activeSub } from './effect'

enum ReactiveFlags {
  IS_REF = '__v_isRef'
}

class RefImpl {
  _value; // 保存实际值
   // ref 标记,证明这是一个 ref 对象
  [ReactiveFlags.IS_REF] = true

  subs
  constructor(value){
    this._value = value
  }

  // 收集依赖
  get value(){ 
    // 当有人访问时,可以获取 activeSub
    if(activeSub){
      // 当存在 activeSub 时存储它,以便更新后触发
      this.subs = activeSub
    }
    return this._value
  }

  // 触发更新
  set value(newValue){ 
    this._value = newValue
    // 通知 effect 重新执行,获取最新的 value
    this.subs?.()
  }
}

export function ref(value){
  return new RefImpl(value)
}

export function isRef(value){
  return !!(value && value[ReactiveFlags.IS_REF])
}

effect.ts

// 用于保存当前正在执行的 effect 函数
export let activeSub;

export function effect(fn){
  activeSub = fn
  activeSub()
  activeSub = undefined
}

想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

偷偷收藏!前端老鸟绝不外传的150个JS插件,让你效率翻3倍…

作者 微芒不朽
2025年9月12日 17:23

🖼️ 1. UI组件库(UI Component Libraries)

快速构建美观、响应式、可访问的用户界面

  1. React —— Facebook 出品的 UI 构建库

   react.dev

  1. Vue.js —— 渐进式 JavaScript 框架

   vuejs.org

  1. Svelte —— 编译时框架,无虚拟 DOM

   svelte.dev

  1. Solid.js —— React-like 语法,极致性能

   www.solidjs.com

  1. Qwik —— 可恢复性框架,超快首屏加载

   qwik.builder.io

  1. Lit —— 轻量级 Web Components 库(Google)

   lit.dev

  1. Alpine.js —— 轻量级“Tailwind for JS”

   alpinejs.dev

  1. Preact —— React 的轻量替代(3kB)

   preactjs.com

  1. Mithril.js —— 超小、超快、全功能框架

   mithril.js.org

  1. Stimulus —— Basecamp 出品,HTML 驱动交互

    stimulus.hotwired.dev


🧠 2. 状态管理(State Management)

管理复杂应用的数据流与状态同步

  1. Redux —— 可预测状态容器

   redux.js.org

  1. Zustand —— 轻量、简单、无模板的状态管理

   zustand-demo.pmnd.rs

  1. Jotai —— 原子化状态管理(React 专用)

   jotai.org

  1. Recoil —— Facebook 实验性原子状态库

   recoiljs.org

  1. MobX —— 响应式状态管理

   mobx.js.org

  1. Valtio —— 代理驱动的简单状态管理

   valtio.pmnd.rs

  1. XState —— 状态机与状态图管理

   xstate.js.org

  1. Effector —— 事件驱动状态管理

   effector.dev

  1. Signals —— Preact 官方状态响应系统

   preactjs.com/guide/v10/s…

  1. Hookstate —— 基于 Hook 的全局状态管理

    hookstate.js.org


📊 3. 数据可视化(Data Visualization)

将数据转化为图表、地图、仪表盘

  1. D3.js —— 数据驱动文档的底层可视化库

   d3js.org

  1. Chart.js —— 简单易用的 HTML5 图表库

   www.chartjs.org

  1. ECharts —— 百度出品,企业级图表库

   echarts.apache.org

  1. ApexCharts —— 现代、交互式 SVG 图表

   apexcharts.com

  1. Victory —— React 专用图表库

   formidable.com/open-source…

  1. Recharts —— 基于 React + D3 的组合式图表

   recharts.org

  1. Nivo —— 精美、响应式数据可视化组件

   nivo.rocks

  1. Visx —— Airbnb 出品,D3 的 React 友好封装

   airbnb.io/visx

  1. Plotly.js —— 科学计算与交互式图表

   plotly.com/javascript

  1. Frappe Charts —— GitHub 风格的轻量图表

    frappe.io/charts


🎞️ 4. 动画库(Animation Libraries)

让界面动起来,提升用户体验

  1. GSAP —— 专业级动画引擎

   greensock.com/gsap

  1. Framer Motion —— React 专属动画库

   www.framer.com/motion

  1. Anime.js —— 轻量级 JavaScript 动画引擎

   animejs.com

  1. Motion One —— 高性能 Web 动画库

   motion.dev

  1. Popmotion —— 灵活的动画与手势库

   popmotion.io

  1. Mo.js —— 专为网页动效设计的库

   mojs.github.io

  1. Vivus —— SVG 路径动画绘制

   maxwellito.github.io/vivus

  1. ScrollReveal —— 滚动触发动画

   scrollrevealjs.org

  1. AOS (Animate On Scroll) —— 简单滚动动画

   michalsnik.github.io/aos

  1. Tippy.js —— 工具提示动画库(带过渡)

    atomiks.github.io/tippyjs


🧰 5. 工具函数库(Utility Libraries)

日常开发高频使用的工具函数集合

  1. Lodash —— JavaScript 实用工具库

   lodash.com

  1. Ramda —— 函数式编程工具库

   ramdajs.com

  1. date-fns —— 现代日期处理库

   date-fns.org

  1. Day.js —— 轻量级 Moment.js 替代

   day.js.org

  1. Zod —— TypeScript 优先的 schema 校验

   zod.dev

  1. Yup —— 表单校验 schema 库

   github.com/jquense/yup

  1. Nano ID —— 短、安全、URL 友好 ID 生成器

   github.com/ai/nanoid

  1. clsx —— 条件化 className 工具

   github.com/lukeed/clsx

  1. SWR —— React 数据请求与缓存

   swr.vercel.app

  1. React Query —— 强大的数据同步与状态管理

    tanstack.com/query


📝 6. 表单处理(Form Handling)

构建复杂、校验严格、高性能表单

  1. React Hook Form —— 高性能 React 表单

   react-hook-form.com

  1. Formik —— React 表单状态管理

   formik.org

  1. Final Form —— 高性能、可扩展表单库

   final-form.org

  1. VeeValidate —— Vue 专用表单校验

   vee-validate.logaretm.com

  1. Zod + React Hook Form —— TypeScript 校验组合拳

   react-hook-form.com/ts + https:…

  1. Yup + Formik —— 经典校验组合

   formik.org/docs/guides…

  1. Uniforms —— Schema 驱动表单生成器

   uniforms.tools

  1. React JSON Schema Form —— JSON Schema → 表单

   rjsf-team.github.io/react-jsons…

  1. SurveyJS —— 动态问卷/调查表单

   surveyjs.io

  1. Formily —— 阿里出品企业级表单解决方案

    formilyjs.org


✅ 7. 测试工具(Testing Tools)

保障代码质量,实现自动化测试

  1. Jest —— Facebook 出品的全能测试框架

   jestjs.io

  1. Vitest —— Vite 生态的极速测试工具

   vitest.dev

  1. Cypress —— 端到端测试神器

   www.cypress.io

  1. Playwright —— 微软出品,跨浏览器自动化测试

   playwright.dev

  1. Testing Library —— React/Vue/Angular 专用测试工具

   testing-library.com

  1. Mocha —— 灵活、可扩展的测试框架

   mochajs.org

  1. Chai —— BDD/TDD 断言库(常配 Mocha)

   www.chaijs.com

  1. Sinon.js —— 间谍、存根、模拟函数

   sinonjs.org

  1. MSW (Mock Service Worker) —— API 模拟神器

   mswjs.io

  1. Puppeteer —— Headless Chrome Node.js API

    pptr.dev


🛠️ 8. 构建与打包(Build & Bundlers)

优化、压缩、打包你的前端资源

  1. Vite —— 下一代前端构建工具

   vitejs.dev

  1. Webpack —— 老牌模块打包器

   webpack.js.org

  1. Rollup —— 库打包首选

   rollupjs.org

  1. Parcel —— 零配置打包工具

   parceljs.org

  1. esbuild —— 极速 JS 打包/压缩工具

   esbuild.github.io

  1. SWC (Speedy Web Compiler) —— Rust 编写的转译器

   swc.rs

  1. Turbopack —— Webpack 作者新作(Next.js 默认)

   turbo.build/pack

  1. Snowpack —— O(1) 构建速度的打包器

   www.snowpack.dev(已归档,但仍有参考价值)

  1. Rspack —— 字节跳动出品,类 Webpack API,Rust 驱动

   www.rspack.dev

  1. Farm —— 新一代 Rust 构建工具(挑战 Vite)

    farmfe.org


📱 9. 移动端开发(Mobile Development)

构建跨平台或高性能移动 Web 应用

  1. React Native —— 使用 React 构建原生 App

   reactnative.dev

  1. Ionic —— 基于 Web 技术的跨平台框架

   ionicframework.com

  1. Capacitor —— Web 应用 → 原生 App(Ionic 出品)

   capacitorjs.com

  1. NativeScript —— 直接调用原生 API

   nativescript.org

  1. Quasar Framework —— 一套代码构建 Web/移动/桌面/Electron

   quasar.dev

  1. Framework7 —— 专为 iOS/Android 设计的 UI 框架

   framework7.io

  1. Onsen UI —— 与 Vue/React/Angular 集成的移动端 UI

   onsen.io

  1. Flutter Web —— Dart 编写,编译到 Web(Google)

   flutter.dev/web

  1. H5 Plus (DCloud) —— 国内流行 HTML5+ 原生能力扩展

   www.dcloud.io/h5p.html

  1. Taro —— 一套代码多端运行(React 语法)

    taro.zone


💻 10. 桌面端开发(Desktop Apps)

使用 Web 技术构建跨平台桌面应用

  1. Electron —— GitHub 出品,最流行桌面框架

   www.electronjs.org

  1. Tauri —— Rust + WebView,轻量级替代 Electron

   tauri.app

  1. Neutralinojs —— 超轻量桌面应用框架

   neutralino.js.org

  1. NW.js —— Node + WebKit 桌面应用框架

   nwjs.io

  1. Proton Native —— React 语法写原生桌面应用

   proton-native.js.org(社区维护中)

  1. DeskGap —— 轻量级 Node + WebView 框架

   deskgap.com

  1. NodeGui —— 使用 Qt 绑定构建原生 UI

   nodegui.org

  1. Flutter Desktop —— 一套代码构建多端(含桌面)

   docs.flutter.dev/desktop

  1. Beeware (Toga) —— Python 编写跨平台 GUI

   beeware.org

  1. Wails —— Go + Web 前端构建桌面应用

    wails.io


🕶️ 11. 3D与游戏(3D & Game Development)

在浏览器中创建 3D 场景和游戏

  1. Three.js —— 最流行的 WebGL 3D 库

   threejs.org

  1. Babylon.js —— 微软出品,功能完整的游戏引擎

   www.babylonjs.com

  1. PlayCanvas —— 云端协作的 3D 引擎

   playcanvas.com

  1. Cannon.js —— 3D 物理引擎(配 Three.js)

   schteppe.github.io/cannon.js

  1. Ammo.js —— Bullet Physics 的 JS 移植版

   github.com/kripken/amm…

  1. PixiJS —— 2D WebGL 渲染引擎(游戏/动画)

   pixijs.com

  1. Phaser —— HTML5 游戏框架

   phaser.io

  1. Zdog —— 伪 3D 扁平化渲染引擎

   zzz.dog

  1. Troika 3D Text —— Three.js 3D 文字渲染

   protectwise.github.io/troika

  1. Drei —— Three.js 的 React 组件封装库

    github.com/pmndrs/drei


🗺️ 12. 地图与GIS(Maps & GIS)

在应用中集成地图、定位、地理信息

  1. Leaflet —— 轻量级开源地图库

   leafletjs.com

  1. Mapbox GL JS —— 高性能矢量地图渲染

   docs.mapbox.com/mapbox-gl-j…

  1. OpenLayers —— 企业级地图解决方案

   openlayers.org

  1. Google Maps Platform JS API —— 谷歌官方地图

   developers.google.com/maps/docume…

  1. ArcGIS API for JavaScript —— Esri 企业 GIS 平台

   developers.arcgis.com/javascript

  1. Deck.gl —— Uber 出品,大数据可视化地图层

   deck.gl

  1. CesiumJS —— 3D 地球与地图引擎

   cesium.com/platform/ce…

  1. React-Leaflet —— React 封装的 Leaflet

   react-leaflet.js.org

  1. Vue2Leaflet / Vue3Leaflet —— Vue 封装 Leaflet

   vue2-leaflet.netlify.app

  1. MapLibre GL JS —— Mapbox GL 的开源分支

    maplibre.org


🎧 13. 音视频处理(Audio & Video)

处理、播放、录制音视频内容

  1. Howler.js —— Web Audio API 封装,游戏音效首选

   howlerjs.com

  1. WaveSurfer.js —— 音频波形可视化

   wavesurfer.xyz

  1. Video.js —— 通用 HTML5 视频播放器

   videojs.com

  1. hls.js —— 在浏览器中播放 HLS 流

   github.com/video-dev/h…

  1. MediaRecorder API 封装库 —— 如 RecordRTC

   recordrtc.org

  1. Tone.js —— Web 音乐创作框架

   tonejs.github.io

  1. Plyr —— 简洁美观的 HTML5 媒体播放器

   plyr.io

  1. Shaka Player —— Google 出品,支持 DASH/DRM

   shaka-player-demo.appspot.com

  1. Web Audio API 原生 + 库 —— 如 WAAPI + Tuna.js(音频效果器)

   github.com/Theodeus/tu…

  1. Mux.js —— 视频转封装/转码工具库(B站等使用)

    github.com/videojs/mux…


🌍 14. 国际化与本地化(i18n & L10n)

支持多语言、多地区、多文化

  1. i18next —— 功能完整、插件丰富

   www.i18next.com

  1. react-i18next —— React 专用 i18next 封装

   react.i18next.com

  1. Vue I18n —— Vue 官方国际化插件

   vue-i18n.intlify.dev

  1. FormatJS (react-intl) —— Yahoo 出品,支持 ICU

   formatjs.io

  1. Polyglot.js —— Airbnb 轻量级方案

   airbnb.io/polyglot.js

  1. LinguiJS —— 开发者友好的 React 国际化

   lingui.dev

  1. Fluent.js —— Mozilla 出品,语法强大

   projectfluent.org

  1. Rosetta —— 超轻量(1kB)国际化库

   github.com/lukeed/rose…

  1. Svelte-i18n —— Svelte 专用国际化

   github.com/sveltekit/i…

  1. next-intl —— Next.js 专用国际化方案

    next-intl-docs.vercel.app


⚡ 15. 性能优化与监控(Performance & Monitoring)

提升加载速度、运行效率、错误追踪

  1. Lighthouse —— Google 官方性能审计工具

   developer.chrome.com/docs/lighth…

  1. Web Vitals —— 核心用户体验指标库

   web.dev/vitals

  1. Sentry —— 错误监控与性能追踪

   sentry.io

  1. LogRocket —— 录屏 + 日志 + 性能分析

   logrocket.com

  1. BundlePhobia —— 查看 npm 包体积影响

   bundlephobia.com

  1. Import Cost —— VS Code 插件,实时显示导入包大小

   marketplace.visualstudio.com/items?itemN…

  1. React DevTools Profiler —— React 性能分析器

   react.dev/learn/devto…

  1. Why Did You Render —— 监控 React 不必要重渲染

   github.com/welldone-so…

  1. Perfume.js —— 轻量级 Web 性能监控

   zizzamia.github.io/perfume

  1. SpeedCurve / Calibre / Treo —— 企业级性能监控平台(商业)

    speedcurve.com | calibreapp.com


Vue3 开发态轻量组件文档方案:基于动态路由 + Markdown

作者 streaker303
2025年9月12日 17:00

🚩 背景

在 Vue3 业务项目中,常见做法是将复用组件集中放到 src/components 目录。但随着多人并行开发,逐渐出现以下痛点:

  • 🤷‍♂️ 不知道已有封装(重复造轮子)
  • 🧪 组件封装质量参差不齐,缺乏复用指引
  • 📄 大量组件无使用文档 / 无交互示例
  • 🔍 逐个打开文件效率低
  • 🗣️ 口头沟通成本高,给人添麻烦

引入独立组件库(例如 storybook / docs site)成本过高,不符合仅为“项目内业务组件”做快速可见化的诉求,因此需要一个“足够轻”且“低侵入”的内部文档解决方案。

🎯 目标(Design Goals)

目标 说明
低侵入 不新增独立入口,不污染生产包体
零上手成本 开发者只需新增/维护 .md 文件
自动化收集 自动扫描 components 下 Markdown 文档
支持热更新 开发态修改文档立即生效
支持组件示例 Markdown 内可内联 Vue 组件预览
平滑演进 未来可拓展“示例 + 源码复制 + 搜索”等功能

🧩 方案概述

核心思想:仅在开发环境动态注入一个内部路由 /playDoc,该页面会:

  1. 使用 import.meta.glob 递归扫描 src/components/**/*.md
  2. 借助 unplugin-vue-markdown.md 编译为 Vue 组件
  3. 将 Markdown 渲染为动态组件并支持切换
  4. 后续扩展:内联示例、源码折叠、预览/复制等

✅ 优势:无需建立二次入口、无需新开端口、无需发布,生产环境自动剔除。

最初的想法是做成多入口文件,单独启动预览,实践中发现有点复杂,除了要加一套入口文件和项目配置外,有的依赖包必须要在 vite.config.dev.ts 中导入,否则影响构建,改动较多所以放弃了。


🏗️ 实现步骤

1. 安装依赖

pnpm add -D unplugin-vue-markdown

2. Vite 插件配置

import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import Markdown from "unplugin-vue-markdown/vite";

export default defineConfig(({ mode }) => {
  const isDev = mode === "development";

  return {
    plugins: [
      vueJsx(),
      vue({
        include: [/\.vue$/, /\.md$/], // 让 .md 也走 Vue 编译
      }),
      isDev &&
        Markdown({
          // 最简单就是什么都不配置,也可根据文档按需扩展 markdown-it 插件
          // headEnabled: false,
          // wrapperClasses: "md-doc-body",
          // markdownItSetup(md) {
            // 示例:支持 :::tip 容器、目录、task list 等
            // md.use(require("markdown-it-anchor")).use(require("markdown-it-task-lists"));
          },
        }),
    ]
  };
});

3. 类型声明

src/types/shims.d.ts

declare module "*.vue" {
  import type { Component } from "vue";
  const component: Component;
  export default component;
}

declare module "*.md" {
  import type { Component } from "vue";
  const component: Component;
  export default component;
}

4. 动态开发路由注入

import type { RouteRecordRaw } from "vue-router";

const baseRoutes: RouteRecordRaw[] = [
  // ...你的真实业务路由
];

const devDocRoute: RouteRecordRaw[] =
  import.meta.env.DEV
    ? [
        {
          path: "/playDoc",
          name: "PlayDoc",
          component: () => import("@/components/PlayDoc.vue"),
          meta: { hidden: true, title: "组件文档" },
        },
      ]
    : [];

export default [...baseRoutes, ...devDocRoute];

5. 文档页面组件(核心实现)

创建 src/components/PlayDoc.vue,组件内容借助 AI 实现。(简单示例)

<template>
  <div class="play-doc">
    <div class="sidebar">
      <h3>组件文档</h3>
      <ul class="doc-list">
        <li
          v-for="doc in docFiles"
          :key="doc.path"
          :class="{ active: currentDoc === doc.path }"
          @click="loadDoc(doc)"
        >
          {{ doc.name }}
        </li>
      </ul>
    </div>
    <div class="content">
      <div v-if="currentDocComponent" class="doc-content">
        <component :is="currentDocComponent" />
      </div>
      <div v-else class="empty">选择一个文档查看</div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import "element-plus/dist/index.css";

interface DocFile {
  name: string;
  path: string;
  module: () => Promise<any>;
}

const docFiles = ref<DocFile[]>([]);
const currentDoc = ref<string>("");
const currentDocComponent = ref<any>(null);

// 动态获取 components 目录下的 md 文件
const getDocFiles = () => {
  const modules = import.meta.glob("/src/components/**/*.md");
  console.log(modules, "modules");

  const files: DocFile[] = [];
  Object.entries(modules).forEach(([path, moduleLoader]) => {
    const name = path.split("/").pop()?.replace(".md", "") || "";
    files.push({
      name,
      path,
      module: moduleLoader as () => Promise<any>,
    });
  });

  docFiles.value = files;
  if (files.length > 0) {
    loadDoc(files[0]); // 默认加载第一个文档
  }
};

const loadDoc = async (doc: DocFile) => {
  try {
    currentDoc.value = doc.path;
    const module = await doc.module();
    currentDocComponent.value = module.default;
  } catch (error) {
    console.error("加载文档失败:", error);
  }
};

onMounted(() => {
  getDocFiles();
});
</script>

6. 示例组件文档(开发者需要编写的 .md)

注意 unplugin-vue-markdown 插件的作用,一个是将 md 文件转成 vue 组件使用;另一个是能够在 md 文件中使用 vue 组件。

# SearchForm 搜索表单组件

<SearchForm
v-model="searchForm"
:form-config="formConfig"
@search="handleSearch"
>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" @click="handleExport">导出</el-button>
</SearchForm>

<script setup>
import { ref } from 'vue'
import SearchForm from './index.vue'
import { ElButton } from 'element-plus'

const searchForm = ref({})

const formConfig = [
  {
    type: 'input',
    label: '用户名',
    prop: 'username',
    placeholder: '请输入用户名'
  },
  {
    type: 'select',
    label: '状态',
    prop: 'status',
    placeholder: '请选择状态',
    options: [
      { label: '启用', value: 1 },
      { label: '禁用', value: 0 }
    ]
  },
  {
    type: 'daterange',
    label: '创建时间',
    prop: 'createTime'
  }
]

function handleSearch(formData) {
  console.log('搜索参数:', formData)
}

function handleReset() {
  searchForm.value = {}
}

function handleExport() {
  console.log('导出逻辑')
}
</script>

image.png

📂 目录结构

src/
  components/
    PlayDoc.vue          # 文档入口(仅开发态路由引用)
    FancyButton/
      index.vue
      FancyButton.md     # 组件文档
    UserAvatar/
      index.vue
      UserAvatar.md
    charts/
      BarChart.vue
      BarChart.md

命名规范:

  • 每个“可复用业务组件”目录下放置同名 .md
  • 无文档的组件会在后续统计中提示(可扩展自动检测)

注意事项和拓展:

说明
生产环境剔除 路由通过 import.meta.env.DEV 条件控制
风格隔离 PlayDoc.vue 设置样式时,不要影响到引入的子组件
Markdown 能力 集成其他插件,增强代码高亮、预览等

✅ 总结

该方案通过“开发态路由 + Markdown 编译为 Vue 组件”实现了一个:

  • 不额外开启端口
  • 不改变生产构建
  • 几乎零上手成本
  • 可持续迭代增强

的内部组件文档系统。适合业务项目在“尚未抽象到组件库层级”的组件复用与提效。

🚀 先让文档“存在且可见”,再逐步“结构化 + 自动化”。

后续继续补充......

Vue3.4版本新特性,更优雅的处理组件间的数据双向绑定

2025年9月12日 16:57

前言

本文主要描述Vue3.4新API defineModel,它在实际开发中使用场景非常广泛。主要替代了传统的prop + emit的方式,用更简短的代码来处理父子组件中的数据双向绑定。

写法对比

.sync修饰符
/* 父组件 */
<template>
  <div>
    <Child-component :count.sync="count" />
  </div>
</template>

<script>
export default {
  components: {
    'ChildComponent': () => import('@/components/ChildComponent.vue')
  },
  data() {
    return {
      count: 0
    }
  }
}
</script>
/* 子组件 */
<script>
export default {
  props: {
    count: {
      type: Number,
      default: 0
    }
  },  
  methods: {
    handleCountChange() {
    this.count = 1
    }
  }
}
</script>
emit()
/* 父组件 */
<template>
  <div>
    <Child-component :count="count" />
  </div>
</template>

<script>
export default {
  components: {
    'ChildComponent': () => import('@/components/ChildComponent.vue')
  },
  data() {
    return {
      count: 0
    }
  }
}
</script>
/* 子组件 */
<script>
export default {
  props: {
    count: {
      type: Number,
      default: 0
    }
  },  
  methods: {
    handleCountChange() {
      this.$emit('update:count',1)
    }
  }
}
</script>
defineModel()
/* 父组件 */
<template>
  <div>
    <Child-component v-model="count" />
  </div>
</template>

<script setup>
const count = ref(0)
</script>
/* 子组件 */
<script setup>
const count = defineModel()
const handleCountChange = () => {
count.value ++
}
</script>

修饰符

当遇到需要使用修饰符时,需要解构defineModel()的返回值,这里官方文档提供了示例

/* 父组件 */
<script setup>
const [modelValue, modelModifiers] = defineModel()
// 为 true 时说明父组件使用了这个修饰符
if(modelModifiers.trim) {
// 需要手写处理trim
}
</script>
/* 子组件 */
<script setup>
const [modelValue, modelModifiers] = defineModel({
  // get() 省略了,因为这里不需要它
  set(value) {
    // 如果使用了 .trim 修饰符,则返回裁剪过后的值
    if (modelModifiers.trim) {
      return value.trim()
    }
    // 否则,原样返回
    return value
  }
})
</script>

绑定多个数据

/* 父组件 */
<template>
  <Child-component
    v-model="count" 
v-model:visible="visible" 
v-model:arr="arr"
  />
</template>

<script setup>
const count = ref(0)
const visible = ref(false)
const arr = ref([])
</script>
/* 子组件 */
<script setup>
const count = defineModel()
const visible = defineModel("visible", { default: false })
const arr= defineModel("arr", { default:() => [] })
</script>

typescript写法

<script setup lang="ts">
const count = defineModel<number>()
const visible = defineModel<boolean>("visible", { default: false })
const arr= defineModel<[]>("arr", { default:() => [] })
</script>

vue的监听属性watch的详解

作者 鹏多多
2025年9月3日 09:51

1. 概述

watch 是一个对象,键是需要观察的表达式,用于观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数的参数是新值和旧值。值也可以是方法名,或者包含选项的对象。Vue 实例将会在实例化时调用 $watch(),遍历 watch 对象的每一个 property。

2. 常规用法

watch 监听有两个形参,第一个是新值,第二个是旧值。如下例子:使用 watch 监听了 total 的值,当 total 的值改变时,控制台会打印出旧值和新值。

<template>
  <div class="home_box">
    <h1>{{ total }}</h1>
    <button @click="handleAddTotal">增加</button>
  </div>
</template>

<script>
export default {
  name: 'Home',
  watch: {
    total(newValue, oldValue) {
      console.log('旧值:', oldValue)
      console.log('新值:', newValue)
    }
  },
  data() {
    return {
      total: 0
    }
  },
  methods: {
    handleAddTotal() {
      this.total++
    }
  }
}
</script>

3. 监听对象和route变化

watch监听的目标,可以是基本类型,也可以是对象,也可以是对象里的一个值。而监听目标的属性,可以是一个函数,也可以是一个包含handler(回调函数),immediate(是否初始化后立即执行一次)和deep(是否开启深度监听)的对象。

<script>
export default {
  name: 'Home',
  watch: {
    // 监听基本类型
    aaa(newValue, oldValue) {
      console.log('旧值:', oldValue)
      console.log('新值:', newValue)
    },
    // 监听基本类型,并且回调函数写在methods里,且初始化加载立即执行一次
    bbb: {
      handler: 'handleBBB',
      immediate: true
    },
    // 监听对象类型,需要开启深度监听
    ccc: {
      handler: (newValue, oldValue) {
        console.log('旧值:', oldValue)
        console.log('新值:', newValue)
      },
      deep: true
    },
    // 监听对象里的某个值
    'ddd.d2.d21': {
      handler: (newValue, oldValue) {
        console.log('旧值:', oldValue)
        console.log('新值:', newValue)
      }
    },
    // 监听route变化
    '$route': {
      handler: (newValue, oldValue) {
        console.log('旧值:', oldValue)
        console.log('新值:', newValue)
      }
    }
  },
  data() {
    return {
      aaa: 0,
      bbb: 0,
      ccc: {
        c1: 0,
        c2: 0
      },
      ddd: {
        d1: 0,
        d2: {
          d21: 0
        }
      }
    }
  },
  methods: {
    handleBBB() {
      this.bbb++
    }
  }
}
</script>

4. 使用场景

watch监听属性使用场景很多。比如:

  1. 即时表单验证
  2. 搜索
  3. 监听数据变化,做出相应改变
  4. ......

如下例子,监听 keyword 的值,实时打印出来。

<template>
  <div class="home_box">
    <input type="text" v-model="keyword">
  </div>
</template>

<script>
export default {
  name: 'Home',
  watch: {
    keyword: {
      handler: 'handleKeywordChange'
    }
  },
  data() {
    return {
      keyword: '',
    }
  },
  methods: {
    handleKeywordChange(newValue, oldValue) {
      console.log(newValue, oldValue)
    }
  }
}
</script>

本次分享就到这儿啦,我是鹏多多,如果看了觉得有帮助的,欢迎 点赞 关注 评论,在此谢过道友;

往期文章

仿豆包 H5应用核心模板:用Vue快速复刻大模型对话体验

2025年9月12日 10:35

想开发一款像豆包一样流畅的大模型H5应用?但又被流式接口处理、复杂Markdown渲染、移动端适配这些问题困住?

现在,这套「仿豆包 H5应用核心模板」能帮你跳过90%的重复开发——基于@lesliechueng/stream-fetch-manageagent-markdown-vue深度集成,完美复刻豆包式的对话交互体验,让你专注业务逻辑,30分钟即可启动一个功能完整的大模型H5应用。

image.png

image.png

模板核心优势:为大模型场景量身打造

1. 即开即用的流式对话框架

模板内置完整的聊天交互界面(src/pages/index.vue),从用户提问到AI流式回复的全流程已封装完毕:

  • 基于StreamFetchClient实现流式请求管理,支持超时重试、多类型错误捕获(连接错误、解析错误等),无需手写复杂状态逻辑;
  • 聊天记录自动按「用户/AI」角色区分展示,样式适配移动端,用户内容右对齐、AI内容左对齐,符合H5交互习惯;
  • 可直接复用llmResponse状态管理逻辑,快速扩展消息发送、历史记录存储等功能。
<!-- 模板内置的聊天界面核心代码 -->
<div class="chat-container" v-for="item in llmResponse" :key="item.type">
  <div v-if="item.type === 'user'" class="user">
    <div class="user-content">{{ item.content }}</div> <!-- 用户提问样式 -->
  </div>
  <div v-else class="assistant">
    <Markdown :llmResponse="item.content" /> <!-- AI回复的Markdown渲染 -->
  </div>
</div>

2. 媲美豆包的Markdown渲染:格式再复杂也能完美展示

豆包能展示的富文本格式,这个模板全支持,且针对移动端优化:

  • 完整格式兼容:代码高亮(JavaScript/Python等多语言)、mermaid流程图(如饼图/时序图)、表格横向滚动、公式(行内E=mc2E=mc²和块级公式)、嵌套引用块等,和豆包的展示效果一致;
  • 自定义扩展元素:支持豆包式“引用标签”(如<span data-type="quote">)和“代码卡片”(带标题和时间戳),hover时还有细腻的动画效果;
  • 移动端适配:图片自适应宽度、表格溢出滚动、代码块换行处理,在手机上的阅读体验和豆包一样流畅。
<!-- 模板中仿豆包的Markdown渲染组件 -->
<AgentMarkdown :content="llmResponse">
  <!-- 豆包式代码块:带语言标识和复制按钮 -->
  <template #code="{ lang, rawCode }">
    <div class="custom-code-block">
      <div class="code-block-header">
        <span>{{ lang }}</span>
        <div class="copy-btn">复制</div> <!-- 仿豆包的复制功能 -->
      </div>
      <pre><code v-html="setCodeStyle(rawCode, lang)"></code></pre>
    </div>
  </template>
  <!-- 支持豆包常见的mermaid图表 -->
  <template #mermaid="{ rawCode }">
    <VMermaid :content="rawCode" /> <!-- 流畅渲染流程图 -->
  </template>
</AgentMarkdown>

3. 开箱即用的Vue H5技术栈

模板基于Vue 3 + TypeScript构建,集成了成熟的H5开发工具链,无需从零配置:

  • 响应式适配:通过vite.config.ts中的postcss-px-to-viewport自动将设计稿px转为vw,适配375px基准的移动端屏幕;
  • 路由与状态管理:内置Vue Router和Pinia,支持页面缓存(App.vue中的keep-alive配置),轻松扩展多页面场景;
  • 工程化工具:集成ESLint、Prettier、TypeScript类型检查,代码规范一键校验,降低团队协作成本。

3步启动开发,效率拉满

  1. 克隆模板,安装依赖

    git clone https://github.com/ttLeslie/llm-template-vue-h5.git
    cd llm-template-vue-h5
    pnpm install
    
  2. 启动本地服务,实时预览

    pnpm dev  # 访问http://localhost:5001即可看到带示例数据的聊天界面
    
  3. 替换接口,对接业务
    修改src/pages/index.vue中的StreamFetchClient配置,替换baseUrl为你的大模型接口地址,即可实现真实流式交互:

    const streamFetch = new StreamFetchClient<IContent>({
      baseUrl: '你的大模型流式接口地址', // 替换为实际接口
      headers: { 'Content-Type': 'application/json' }
    }, {
      onMessage: (data) => { /* 处理流式消息 */ }
    })
    

为什么选择这套模板?

  • 专注大模型场景:从流式请求到Markdown渲染,所有功能均针对大模型H5应用设计,避免冗余代码;
  • 移动端优先:样式、布局、交互逻辑全适配手机端,无需额外调试;
  • 完全开源可扩展:核心工具@lesliechueng/stream-fetch-manageGitHub)和agent-markdown-vueGitHub)源码开放,可按需定制。

如果你正在开发大模型相关的Vue H5应用,这套模板能帮你节省80%的基础开发时间。立即上手,让你的应用快速落地!

👉模板项目仓库地址

👉请求工具库地址

👉Markdown组件地址

Vue3+Vite 现代化前端框架👊打破 Chrome 83 内核限制

作者 程序张
2025年9月12日 10:09

在现代前端开发中,我们经常会遇到这样的场景:项目在最新的浏览器中运行良好,但在桌面应用内嵌的 WebView 或老版本浏览器中却出现兼容性问题。本文将详细记录一次完整的浏览器内核兼容性解决方案实施过程。

1. 问题背景

1.1 项目技术栈

我们的项目采用了现代化的前端技术栈:

  • Vue 3.2.21 - 使用 Composition API
  • TypeScript 4.4.4 - 强类型支持
  • Vite 2.6.13 - 现代化构建工具
  • Ant Design Vue 2.2.8 - 企业级 UI 组件库
  • 构建目标: ES2015

1.2 遇到的问题

项目在 Chrome 100+版本的浏览器中运行正常,但在桌面程序内嵌的 iframe 中出现问题:

  • 内核版本: Chrome 83
  • 问题现象: ES6 语法和现代特性不兼容
  • 错误原因: Chrome 83 不支持可选链操作符(?.)、空值合并操作符(??)等 ES2020+特性

2. 兼容性分析

2.1 Chrome 版本支持策略

Chrome 版本 支持状态 说明
Chrome 90+ ✅ 完全支持 最佳体验,支持所有现代特性
Chrome 80-89 ⚠️ 基本支持 可能需要 polyfill 某些特性
Chrome 51-79 ⚠️ 有限支持 需要启用 Legacy 模式
Chrome <51 ❌ 不支持 建议升级浏览器

2.2 特性兼容性对照表

特性 Chrome 版本 对应内核版本
ES2015 (ES6) Chrome 51+ V8 5.1+
ES2017 (async/await) Chrome 55+ V8 5.5+
ES2018 (Object spread) Chrome 60+ V8 6.0+
ES2019 (Optional catch) Chrome 66+ V8 6.6+
ES2020 (Optional chaining) Chrome 80+ V8 8.0+
ES2021 (Logical assignment) Chrome 85+ V8 8.5+

3. 解决方案实施

3.1 方案选择与演进

经过深入实践,我们发现Vite Legacy 插件对 Chrome 83 的支持存在局限性。最终采用了多层次兼容性解 决方案

方案演进历程:

  1. 初始方案: Vite Legacy 插件 ❌

    • 问题:Chrome 83 支持 ES 模块,不会触发 Legacy 模式
    • 现象:仍然加载现代版本,Object.hasOwn等特性报错
  2. 改进方案: 调整 targets 配置 ❌

    • 问题:无法强制 Chrome 83 使用 Legacy 版本
    • 现象:Network 面板仍然只有polyfills-modern.js
  3. 最终方案: 源码级 polyfill 注入 ✅

    • 直接在 main.ts 中注入 polyfills
    • 绕过 Vite 构建时检测机制
    • 运行时动态处理兼容性

3.2 核心解决方案:源码级 Polyfill 注入

关键发现:Chrome 83 的特殊性

Chrome 83 是一个"半现代"浏览器:

  • 支持 ES 模块 - 会加载<script type="module">
  • 不支持 ES2020+特性 - 缺少Object.hasOwnString.replaceAll
  • 🔄 Vite Legacy 插件失效 - 因为支持 ES 模块,不会触发 Legacy 模式

最终解决方案:直接 polyfill 注入

1. 创建专门的 polyfill 文件

// src/polyfills/chrome83.ts
(function () {
  'use strict';

  // 检测Chrome版本 - 使用ES5兼容语法
  const chromeMatch = /Chrome\/(\d+)/.exec(navigator.userAgent);
  const chromeVersion = chromeMatch ? parseInt(chromeMatch[1]) : 0;

  // 只为Chrome 83加载polyfills
  if (chromeVersion !== 83) {
    return;
  }

  console.log('%c🔧 Chrome 83 Polyfills 开始加载', 'color: #ffc107; font-weight: bold;');

  // Object.hasOwn polyfill
  if (!Object.hasOwn) {
    (Object as any).hasOwn = function (obj: any, prop: string | symbol) {
      if (obj == null) {
        throw new TypeError('Object.hasOwn called on null or undefined');
      }
      return Object.prototype.hasOwnProperty.call(Object(obj), prop);
    };
    console.log('✅ Object.hasOwn polyfill 已加载');
  }

  // String.replaceAll polyfill
  if (!(String.prototype as any).replaceAll) {
    (String.prototype as any).replaceAll = function (search: string | RegExp, replace: string) {
      if (search instanceof RegExp) {
        if (!search.global) {
          throw new TypeError(
            'String.prototype.replaceAll called with a non-global RegExp argument',
          );
        }
        return this.replace(search, replace);
      }
      return this.split(search).join(replace);
    };
    console.log('✅ String.replaceAll polyfill 已加载');
  }

  // Array.at polyfill
  if (!(Array.prototype as any).at) {
    (Array.prototype as any).at = function (index: number) {
      const len = this.length;
      const relativeIndex = index < 0 ? len + index : index;
      return relativeIndex >= 0 && relativeIndex < len ? this[relativeIndex] : undefined;
    };
    console.log('✅ Array.at polyfill 已加载');
  }

  // 标记polyfills已加载
  (window as any).__CHROME83_POLYFILLS_LOADED__ = true;
  console.log('🎯 Chrome 83 Polyfills 加载完成');
})();

2. 在 main.ts 中优先导入

// src/main.ts
// Chrome 83兼容性polyfills - 必须在所有其他导入之前
import '/@/polyfills/chrome83';

import '/@/design/index.less';
// ... 其他导入

3.3 环境配置更新

为了确保所有环境都支持 Chrome 83,我们更新了环境配置:

# .env.production (外网生产环境)
VITE_LEGACY = true  # 新增

# .env.development (外网开发环境)
VITE_LEGACY = true  # 新增
VITE_COMPAT_CHECK = true  # 新增

# .env.intranet (内网生产环境)
VITE_LEGACY = true  # 已有

# .env.development.intranet (内网开发环境)
VITE_LEGACY = true  # 已有
VITE_COMPAT_CHECK = true  # 已有

3.4 兼容性检测器增强

更新兼容性检测器以识别手动加载的 polyfills:

// src/utils/compatibilityChecker.ts
private detectFeatureSupport(): FeatureSupport {
  // 检查是否已加载Chrome 83 polyfills
  const hasChrome83Polyfills = !!(window as any).__CHROME83_POLYFILLS_LOADED__;

  return {
    optionalChaining: this.testOptionalChaining(),
    nullishCoalescing: this.testNullishCoalescing(),
    // 如果已加载Chrome 83 polyfills,这些特性应该被认为是支持的
    objectHasOwn: typeof Object.hasOwn === 'function' || hasChrome83Polyfills,
    stringReplaceAll: typeof String.prototype.replaceAll === 'function' || hasChrome83Polyfills,
    arrayAt: typeof Array.prototype.at === 'function' || hasChrome83Polyfills,
    // ... 其他特性检测
  };
}

4. 开发调试工具

4.1 兼容性检测工具

为了更好地监控和调试兼容性问题,我们开发了一套完整的检测工具:

// src/utils/compatibilityChecker.ts
class CompatibilityChecker {
  // 检测浏览器信息和特性支持
  private detectBrowserInfo(): BrowserInfo {
    // 基于实际特性支持判断,而非硬编码版本号
    const features = this.detectFeatureSupport();
    const isLegacyMode = this.detectLegacyMode(features);
    const supportsModernJS = this.detectModernJSSupport(features, chromeVersion);

    return {
      /* ... */
    };
  }

  // 在控制台打印详细的兼容性报告
  public printCompatibilityInfo(): void {
    // 动态生成兼容性报告
  }
}

4.2 开发脚本

创建专门的 Legacy 开发调试脚本:

// scripts/dev-legacy.js
const { spawn } = require('child_process');

// 启动Legacy兼容性开发服务器
const devServer = spawn('vite', ['--mode', 'development.intranet'], {
  stdio: 'inherit',
  shell: true,
  env: {
    ...process.env,
    VITE_LEGACY: 'true',
  },
});

添加 npm 脚本:

{
  "scripts": {
    "dev:legacy": "cross-env VITE_LEGACY=true vite --mode development.intranet"
  }
}

4.3 兼容性检查页面

创建详细的兼容性检测页面:

<!-- public/compat-check.html -->
<script>
  function getBrowserInfo() {
    const ua = navigator.userAgent;
    const chromeMatch = /Chrome\/(\d+)/.exec(ua);
    const chromeVersion = chromeMatch ? parseInt(chromeMatch[1], 10) : 0;

    return {
      chromeVersion,
      isChrome83: chromeVersion === 83,
      supportsOptionalChaining: (() => {
        try {
          const test = {}?.test;
          return true;
        } catch (e) {
          return false;
        }
      })(),
      // ... 更多特性检测
    };
  }
</script>

5. 实时监控系统

5.1 页面刷新时自动检测

集成到应用启动流程:

// src/main.ts
import { compatibilityChecker } from '/@/utils/compatibilityChecker';

async function bootstrap() {
  // 🔧 兼容性检测 - 在应用启动前进行检测
  if (import.meta.env.DEV || import.meta.env.VITE_LEGACY) {
    compatibilityChecker.printCompatibilityInfo();

    // 检查关键兼容性问题
    const summary = compatibilityChecker.getCompatibilitySummary();
    if (!summary.isCompatible) {
      console.warn('⚠️ 检测到兼容性问题:', summary.criticalIssues);
    }
  }

  // ... 应用初始化
}

5.2 控制台输出示例

Chrome 83 + 手动 Polyfill 注入成功:

🔧 Chrome 83 Polyfills 开始加载
✅ Object.hasOwn polyfill 已加载
✅ String.replaceAll polyfill 已加载
✅ Array.at polyfill 已加载
🎯 Chrome 83 Polyfills 加载完成

🔧 浏览器兼容性检测报告
==================================================
📱 浏览器信息:
  Chrome版本: 83 [目标版本]

⚙️ 运行模式:
  ✅ Legacy兼容模式
  📋 Chrome 83内核 + 手动polyfills支持

🔍 特性支持检测:
  关键特性:
    ✅ 可选链操作符 (?.)
    ✅ 空值合并操作符 (??)
    ✅ PromiseLocalStorage
  现代特性:
    ✅ Object.hasOwn (polyfill)
    ✅ String.replaceAll (polyfill)
    ✅ Array.at (polyfill)

💡 兼容性建议:
  🎯 Chrome 83内核检测成功!
  📋 这是内网桌面应用的目标内核版本
  ✅ 手动polyfills已激活,Chrome 83完全兼容
  📋 所有ES2020+特性通过polyfill支持
==================================================

6. 构建产物分析

6.1 Legacy 模式构建结果

启用 Legacy 后的文件结构:

dist/
├── assets/
│   ├── app.12345678.js          # 现代版本 (ES2015+)
│   ├── app-legacy.87654321.js   # 兼容版本 (ES5 + polyfills)
│   ├── polyfills-legacy.js      # polyfill库
│   └── vendor.js                # 第三方库
└── index.html                   # 自动注入加载逻辑

6.2 自动加载机制

<!-- 自动生成的HTML加载逻辑 -->
<!-- Legacy浏览器加载 -->
<script nomodule crossorigin src="/assets/polyfills-legacy.js"></script>
<script nomodule crossorigin data-src="/assets/app-legacy.js">
  System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'));
</script>

<!-- 现代浏览器加载 -->
<script type="module" crossorigin src="/assets/app.js"></script>

7. 性能影响评估

7.1 包体积变化

  • 现代版本: 无变化
  • Legacy 版本: +20-30%(polyfills)
  • 总体积: 约增加 40-50%

7.2 加载性能

  • Chrome 100+: 无影响(仍加载现代版本)
  • Chrome 83: 稍慢(需要 polyfills),但功能完整

7.3 运行性能

  • Chrome 100+: 最优性能
  • Chrome 83: 可接受性能(polyfill 开销)

8. 部署和验证

8.1 构建命令

# 内网生产构建(已启用Legacy)
npm run build:intranet

# 内网部署
npm run deploy:intranet

8.2 验证步骤

  1. 启动开发服务器
npm run dev:legacy
  1. 访问兼容性检查页面
http://localhost:3100/compat-check.html
  1. 检查 Network 面板
  • 查找 *-legacy.js 文件
  • 验证 polyfill 加载
  1. 控制台验证
// 检查Legacy模式
console.log('Legacy mode:', window.System ? 'YES' : 'NO');

9. 问题排查和优化

9.1 关键问题与解决方案

问题 1:Vite Legacy 插件对 Chrome 83 失效

现象:

  • 兼容性检测显示"Legacy 兼容模式已激活"
  • 但 Network 面板只有polyfills-modern.js,没有polyfills-legacy.js
  • 仍然报错:Object.hasOwn is not a function

根本原因: Chrome 83 支持 ES 模块,所以会加载<script type="module">而不是<script nomodule>

解决方案: 放弃依赖 Vite Legacy 插件的自动检测,改用手动 polyfill 注入

问题 2:HTML 注入 polyfill 失效

现象:

  • 在 Vite 插件中通过transformIndexHtml注入 polyfill
  • 但 polyfill 脚本没有执行

根本原因: 注入的 polyfill 代码使用了 Chrome 83 不支持的语法(如可选链?.

解决方案:

// 错误的写法(Chrome 83不支持)
const chromeVersion = /Chrome\\/(\\d+)/.exec(navigator.userAgent)?.[1];

// 正确的写法(ES5兼容)
var chromeMatch = /Chrome\\/(\\d+)/.exec(navigator.userAgent);
var chromeVersion = chromeMatch ? chromeMatch[1] : null;

问题 3:执行时机问题

现象:

  • polyfill 在兼容性检测之后执行
  • 检测结果显示特性不支持

解决方案:

  • main.ts第一行导入 polyfill
  • 确保在所有其他代码之前执行

9.2 最佳实践总结

1. 避免在 polyfill 中使用现代语法

// ❌ 错误:使用了可选链
const version = /Chrome\/(\d+)/.exec(navigator.userAgent)?.[1];

// ✅ 正确:使用ES5兼容语法
const match = /Chrome\/(\d+)/.exec(navigator.userAgent);
const version = match ? match[1] : null;

2. 确保执行顺序

// main.ts 文件结构
import '/@/polyfills/chrome83'; // 第一行:polyfill
import '/@/design/index.less'; // 第二行:样式
import { createApp } from 'vue'; // 第三行:其他导入

3. 设置检测标记

// 在polyfill中设置标记
(window as any).__CHROME83_POLYFILLS_LOADED__ = true;

// 在兼容性检测中使用标记
const hasChrome83Polyfills = !!(window as any).__CHROME83_POLYFILLS_LOADED__;

10. 总结

10.1 解决方案效果

通过实施源码级 polyfill 注入方案,我们成功解决了 Chrome 83 内核的兼容性问题:

  • Chrome 83: 完全兼容,Object.hasOwn等特性正常工作
  • Chrome 100+: 性能无影响(polyfill 只在 Chrome 83 中执行)
  • 包体积影响: 极小(只增加几 KB 的 polyfill 代码)
  • 开发体验: 实时监控,问题可见,调试友好
  • 维护成本: 低(polyfill 代码简单,易于维护)

10.2 核心经验总结

1. Chrome 83 的特殊性认知

  • 半现代浏览器:支持 ES 模块但缺少 ES2020+特性
  • Vite Legacy 插件局限性:无法处理这种特殊情况
  • 需要定制化解决方案:不能依赖通用工具

2. 技术方案选择

  • 避免过度工程化:直接 polyfill 比复杂的构建配置更可靠
  • 运行时解决:比构建时解决更灵活
  • 源码级注入:比 HTML 注入更可控

3. 开发调试要点

  • 执行顺序至关重要:polyfill 必须最先执行
  • 语法兼容性:polyfill 本身不能使用现代语法
  • 检测标记机制:便于调试和状态确认

10.3 最佳实践指南

  1. 优先级排序: 源码级 polyfill > HTML 注入 > 构建时处理
  2. 兼容性优先: 在 polyfill 中使用最保守的语法
  3. 执行时机: 在 main.ts 第一行导入 polyfill
  4. 状态标记: 设置全局标记便于检测和调试
  5. 环境覆盖: 确保所有环境都启用兼容性支持

10.4 后续优化方向

  1. 扩展 polyfill 库: 根据需要添加更多 ES2020+特性支持
  2. 自动化检测: 开发工具自动检测缺失的 polyfill
  3. 性能监控: 监控 polyfill 对性能的影响
  4. 版本升级策略: 制定 WebView 升级计划

10.5 关键收获

这次兼容性问题的解决过程让我们深刻认识到:

  • 通用工具的局限性:不是所有问题都能用现成工具解决
  • 深入理解的重要性:只有理解问题本质才能找到最佳方案
  • 简单方案的价值:有时最直接的方案就是最好的方案
  • 测试验证的必要性:理论方案必须经过实际验证

希望这个完整的实践记录能够帮助遇到类似问题的开发者,特别是那些需要支持特定版本 WebView 的桌面应用开 发场景。

附录

A. 相关文件清单

项目根目录/
├── .env.production                         # 外网生产环境配置 (新增VITE_LEGACY=true)
├── .env.development                        # 外网开发环境配置 (新增VITE_LEGACY=true)
├── .env.intranet                           # 内网生产环境配置
├── .env.development.intranet               # 内网开发环境配置
├── src/polyfills/chrome83.ts               # Chrome 83专用polyfill (核心文件)
├── src/main.ts                             # 应用入口 (第一行导入polyfill)
├── src/utils/compatibilityChecker.ts       # 兼容性检测工具 (增强版)
├── src/utils/compatibilityPlugin.ts        # Vue兼容性插件
├── build/vite/plugin/legacy.ts             # Legacy插件配置 (保留但非核心)
└── public/compat-check.html                # 兼容性检查页面

B. 关键命令速查

# 启动Legacy开发服务器 (内网)
npm run dev:legacy

# 启动Legacy开发服务器 (外网)
npm run dev:legacy:external

# 构建各环境版本
npm run build              # 外网生产 (已启用Legacy)
npm run build:intranet     # 内网生产 (已启用Legacy)

# 检查兼容性
访问: http://localhost:3100/compat-check.html

# 验证polyfill加载
console.log('Chrome 83 Polyfills:', window.__CHROME83_POLYFILLS_LOADED__ ? 'YES' : 'NO');
console.log('Object.hasOwn:', typeof Object.hasOwn === 'function' ? 'YES' : 'NO');

C. 环境变量说明

变量名 作用 取值
VITE_LEGACY 启用 Legacy 模式 true/false
VITE_COMPAT_CHECK 启用兼容性检查 true/false
NODE_ENV 环境模式 development/production
MODE Vite 模式 development.intranet/intranet

【教程】Vue中级转React终极指南-理解Vue和React的差异

作者 拜无忧
2025年9月12日 09:37

Vue开发者精通React终极指南

引言:从Vue到React,一座需要用心搭建的桥梁

对于一位经验丰富的中级Vue开发者而言,您已经掌握了现代前端开发的精髓:组件化、响应式状态管理以及声明式UI。踏上学习React的旅程,并非从零开始,而是将您已有的深厚功底,转换到一个新的、同样强大的范式中。本指南旨在成为您跨越Vue与React之间鸿沟的最坚实桥梁,它不仅仅是一份语法对照表,更是一份思维模式的迁移手册。

PixPin_2025-09-12_09-35-52.png

核心哲学分野:模板驱动 vs. JavaScript驱动

要真正理解Vue和React的差异,首先必须把握它们最根本的哲学分歧。这是几乎所有语法和实践差异的根源 1。

  • Vue:以HTML为中心(Template-centric) 。Vue的核心思想是“渐进式框架”,它以我们熟悉的HTML为基础,通过特殊的指令(如 v-if, v-for, v-model)和语法糖(如 @click)来增强HTML的能力,使其具备数据绑定和逻辑处理的功能 1。您可以将Vue看作是让HTML变得“更聪明”的工具。这种方式使得代码结构清晰,将模板(结构)、脚本(逻辑)和样式(表现)明确分离在同一个

    .vue文件中,对有传统Web开发背景的开发者非常友好 2。

  • React:以JavaScript为中心(JavaScript-centric) 。React的定位是“一个用于构建用户界面的JavaScript库”。它的核心理念是,UI的结构、逻辑和状态本质上是紧密耦合的,因此应当用同一种语言——JavaScript——来统一描述。为此,React引入了JSX(JavaScript XML),一种JavaScript的语法扩展,允许开发者在JavaScript代码中直接编写类似HTML的结构 1。在React的世界里,您不是在HTML中嵌入JS,而是在JS中构建HTML。这赋予了开发者JavaScript语言的全部能力来构建UI,例如直接使用数组的

    .map() 方法进行列表渲染,或使用三元运算符进行条件判断 1。

这个核心差异导致了两种截然不同的开发体验。Vue通过指令提供了高度封装的便利性,而React则通过拥抱纯粹的JavaScript提供了极致的灵活性和可组合性。理解这一点,将帮助您在后续的学习中,不再仅仅是“记忆”React的语法,而是“理解”其背后的设计动机。

快速参考:Vue核心概念与React等价物对照表

为了给您一个直观的全局印象,下表总结了本指南将深入探讨的核心概念在两大生态中的对应关系。这不仅是一份语法速查表,更是一张指引您思维转换的路线图。

关注点 / 概念 Vue.js 实现方式 React 实现方式
组件结构 单文件组件 (.vue) 函数式组件与JSX (.jsx/.tsx)
本地状态 ref(), reactive() useState() Hook
派生状态 computed 计算属性 useMemo() Hook
副作用 / 侦听器 watch, watchEffect, 生命周期钩子 useEffect() Hook
条件渲染 v-if, v-else, v-show 三元运算符 (? :), 逻辑与 (&&)
列表渲染 v-for 指令 .map() 方法在JSX中使用
事件处理 @click, @submit onClick, onSubmit
父传子数据 defineProps Props作为函数参数
子传父通信 defineEmits, $emit 回调函数作为Props传递
跨层级状态 (简单) provide / inject createContext / useContext() Hook
路由 Vue Router React Router DOM
全局状态 (复杂) Pinia Redux Toolkit, Zustand 等

这张表的背后,隐藏着两大框架设计哲学的深刻影响。例如,React之所以使用 .map() 而非指令来进行列表渲染,是因为 .map() 是原生JavaScript数组方法,完美契合其“JS驱动”的理念。同样,React需要 useMemo 来显式地缓存计算结果,而Vue的 computed 却是自动缓存的,这是因为React默认的渲染机制是“状态变更后重新执行整个组件函数”,因此性能优化(如缓存)需要开发者主动选择;而Vue基于依赖追踪的精细化响应式系统,使得缓存成为一种默认且高效的行为 6。

带着这些宏观的理解,让我们正式开始这段激动人心的旅程,从搭建第一个React项目开始,逐步解构并重建您的前端知识体系。

第一部分:环境搭建与项目结构剖析

在开始编码之前,我们首先需要搭建一个熟悉的开发环境。幸运的是,如果您习惯于使用Vite来创建Vue项目,那么切换到React的过程将会非常平滑,因为Vite本身就是一个与框架无关的现代化构建工具 8。

create-vuecreate-vite:共同的起点

Vite由Vue的创造者尤雨溪开发,最初是为了服务Vue生态,但它凭借其极速的开发服务器启动和热模块更新(HMR)体验,迅速成为了众多前端框架的首选构建工具,包括React 9。

  • 创建Vue项目 (回顾):

    您可能非常熟悉使用官方脚手架 create-vue 来初始化一个基于Vite的Vue项目 9。

    Bash

    npm create vue@latest
    
  • 创建React项目 (新起点):

    同样地,我们可以使用 create-vite 命令,并通过 --template 标志来指定React模板,从而快速搭建一个React项目 8。

    Bash

    npm create vite@latest my-react-app -- --template react
    

    执行此命令后,Vite会为您生成一个预配置好的、可立即运行的React开发环境。进入项目目录并安装依赖,即可启动开发服务器:

    Bash

    cd my-react-app
    npm install
    npm run dev
    

项目解剖:Vue与React结构并排比较

尽管都由Vite生成,Vue和React项目的默认目录结构反映了它们各自的生态惯例和核心思想。

Vue项目结构 (由 create-vue 生成) React项目结构 (由 create-vite 生成) 说明
public/ public/ 存放不会被构建处理的静态资源。
src/ src/ 应用程序的核心源代码目录。
src/assets/ src/assets/ 存放会被构建处理的静态资源(如图片、字体)。
src/components/ (无) Vue脚手架推荐用于存放可复用、非页面级的组件。
src/views/ (无) Vue脚手架推荐用于存放页面级组件。
src/router/ (无) Vue Router的配置文件目录。
src/stores/ (无) Pinia状态管理文件的目录。
src/App.vue src/App.jsx 应用程序的根组件。
src/main.js src/main.jsx 应用程序的入口文件。
index.html index.html 应用程序的HTML主页面。
vite.config.js vite.config.js Vite的配置文件。
package.json package.json 项目元数据和依赖管理文件。

这种结构上的差异并非偶然,它深刻地揭示了两个生态系统的哲学。Vue作为一个“框架”,其官方脚手架更具“指导性”或“约定性”(opinionated),它会为您预设好路由、状态管理等常用功能的目录结构,引导开发者遵循一种推荐的最佳实践 2。这有助于团队协作和项目维护的一致性。

相比之下,React作为一个“库”,其Vite模板则显得更为“极简”和“无约束”(unopinionated) 10。它只提供了一个最基础的运行骨架,将目录结构的组织方式完全交由开发者决定。您可以根据项目规模和团队偏好,自由选择组织方式,例如按功能(feature-based)组织,或者采用原子设计(atomic design)等模式 13。

入口文件详解:main.js vs. main.jsx

让我们深入对比一下应用程序的启动过程。

  • Vue (src/main.js) :

    JavaScript

    import { createApp } from 'vue'
    import App from './App.vue'
    
    createApp(App).mount('#app')
    

    这段代码的逻辑是:导入createApp函数和根组件App.vue,然后创建一个Vue应用实例,并将其挂载到index.html中ID为app的DOM元素上。

  • React (src/main.jsx) :

    JavaScript

    import React from 'react'
    import ReactDOM from 'react-dom/client'
    import App from './App.jsx'
    
    ReactDOM.createRoot(document.getElementById('root')).render(
      <React.StrictMode>
        <App />
      </React.StrictMode>
    )
    

    这段代码的逻辑略有不同 10:

    1. 导入React核心库、ReactDOM(负责DOM操作)和根组件App.jsx
    2. 使用ReactDOM.createRoot()方法,以index.html中ID为root的DOM元素为根容器,创建一个React应用的根。
    3. 调用根的render()方法,将<App />组件渲染到该容器中。<React.StrictMode>是一个辅助组件,用于在开发模式下检查应用中潜在的问题。

两者都依赖于一个位于项目根目录的index.html文件作为应用程序的“外壳” 8。Vite会将这个HTML文件作为模块图的入口,并自动处理其中的

<script type="module" src="...">标签,注入必要的脚本。

总而言之,从项目创建到启动的整个流程,对于有Vite经验的Vue开发者来说,几乎没有学习成本。真正的挑战和乐趣,在于接下来我们将要深入探讨的组件模型和响应式系统的差异。

第二部分:组件模型 - 从SFC到JSX的范式转移

组件是现代前端开发的基石。在Vue中,您已经非常熟悉单文件组件(Single-File Component, SFC)的优雅结构。现在,我们将进入React的世界,探索其基于JSX和函数式组件的核心理念。

Vue的单文件组件(SFC) - 快速回顾

让我们先回顾一个标准的Vue SFC,它将模板、逻辑和样式完美地封装在一个.vue文件中,实现了高度的内聚和关注点分离 5。

代码段

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

const count = ref(0)
</script>

<template>
  <div class="counter">
    <p>Count: {{ count }}</p>
    <button @click="count++">Increment</button>
  </div>
</template>

<style scoped>
.counter {
  padding: 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
</style>

React的函数式组件与JSX

React组件的现代化范式是函数式组件(Functional Component)。顾名思义,一个React组件本质上就是一个JavaScript函数。这个函数接收一个名为props的对象作为参数,并返回一段描述UI的结构。

而这段“描述UI的结构”,就是通过JSX来编写的。需要再次强调,JSX不是模板语言,它是一种JavaScript的语法扩展 1。在构建过程中,JSX会被Babel或类似的工具转换为常规的JavaScript函数调用,通常是

React.createElement()

这意味着,您可以在JSX中无缝地使用JavaScript的全部功能:

  • 嵌入表达式:任何在{}中的内容都会被当作JavaScript表达式来执行。
  • 函数调用:可以直接在{}中调用函数。
  • 逻辑运算:可以使用三元运算符或逻辑与(&&)来进行条件渲染。
代码深度解析:并排对比"Hello World"

让我们将Vue的SFC与React的函数式组件并排比较,以直观感受差异。

  • Vue: HelloWorld.vue

    代码段

    <script setup>
    import { ref } from 'vue'
    const msg = ref('Hello Vue Developer!')
    </script>
    
    <template>
      <h1>{{ msg }}</h1>
    </template>
    
  • React: HelloWorld.jsx

    JavaScript

    import React, { useState } from 'react';
    
    function HelloWorld() {
      // useState是React中用于管理组件状态的Hook,我们将在下一部分详细讲解
      const [msg, setMsg] = useState('Welcome to React!');
    
      // 组件返回JSX,描述了它应该渲染成什么样子
      return <h1>{msg}</h1>;
    }
    
    export default HelloWorld;
    

    观察React版本,您会发现:

    1. 没有<template>标签,UI结构直接在return语句中用JSX编写。
    2. 数据绑定使用单大括号{},而非Vue的双大括号{{}}
    3. 整个组件就是一个标准的JavaScript函数。

React中的样式处理:一个重大的转变

从Vue迁移过来,最需要适应的变化之一就是样式的处理方式。React本身并没有提供类似<style scoped>的内置样式方案。开发者需要从社区提供的多种方案中进行选择,最常见的有以下几种:

  1. 普通CSS与CSS Modules:这是最直接的方式。您可以创建一个.css文件,然后在组件的.jsx文件中导入它。

    CSS

    /* App.css */
    

.title {

color: blue;

font-size: 24px;

}

jsx

// App.jsx

import React from 'react';

import './App.css'; // 导入CSS文件

function App() {
  // 使用 'className' 属性,而不是 'class',因为 'class' 是JS的保留关键字
  return <h1 className="title">Hello React with CSS</h1>;
}

export default App;
```
为了解决全局CSS可能导致的命名冲突问题(类似Vue中不加`scoped`的情况),Vite等现代构建工具原生支持**CSS Modules**。只需将文件名改为`.module.css`,导入的对象就会包含所有类名的映射,从而实现局部作用域。

2. CSS-in-JS:这是一种更进一步的模式,允许您完全在JavaScript中编写CSS。流行的库有styled-componentsEmotion。这种方式提供了完整的JS能力(如变量、函数)来创建动态样式,并自动处理作用域。 3. 原子化/功能优先CSS:以Tailwind CSS为代表,这种方法在Vue和React社区都非常流行。它通过提供大量预设的功能性类名来快速构建UI,而无需编写自定义CSS。

动态类名与样式绑定

在Vue中,通过:class:style指令可以非常方便地动态绑定类名和内联样式 16。在React中,由于一切皆为JavaScript,我们需要用JS的方式来实现同样的效果。

  • 动态类名 (className)

    className属性接收一个字符串。因此,我们可以使用任何JS字符串操作方法来构建这个字符串,最常用的是模板字符串 18。

    Vue示例:

    代码段

    <template>
      <div :class="{ active: isActive, 'text-danger': hasError }">...</div>
    </template>
    <script setup>
    import { ref } from 'vue'
    const isActive = ref(true)
    const hasError = ref(true)
    </script>
    

    React等效实现:

    JavaScript

    import React, { useState } from 'react';
    
    function DynamicClassComponent() {
      const [isActive, setIsActive] = useState(true);
      const [hasError, setHasError] = useState(true);
    
      // 使用模板字符串和三元运算符构建类名字符串
      const divClassName = `base-class ${isActive? 'active' : ''} ${hasError? 'text-danger' : ''}`;
    
      return <div className={divClassName}>...</div>;
    }
    

    为了处理更复杂的条件,社区通常使用一个名为classnames的小工具库,它可以极大地简化类名的拼接逻辑。

    VSCode代码片段 (React动态类名)

    为了提高效率,您可以将以下代码片段添加到您的VSCode用户代码片段中,通过输入dclass快速生成动态类名结构。

    JSON

    {
      "React Dynamic Class": {
        "prefix": "dclass",
        "body": [
          "<div className={`base-class ${${1:condition}? '${2:active-class}' : ''}`}>",
          "  $0",
          "</div>"
        ],
        "description": "Creates a div with a dynamic class based on a condition"
      }
    }
    
  • 动态内联样式 (style)

    React的style属性接收的不是字符串,而是一个JavaScript对象 20。CSS属性名需要写成驼峰式(camelCase),例如

    font-size要写成fontSize

    Vue示例:

    代码段

    <template>
      <div :style="{ color: activeColor, fontSize: fontSize + 'px' }">...</div>
    </template>
    <script setup>
    import { ref } from 'vue'
    const activeColor = ref('red')
    const fontSize = ref(16)
    </script>
    

    React等效实现:

    JavaScript

    import React, { useState } from 'react';
    
    function DynamicStyleComponent() {
      const [activeColor, setActiveColor] = useState('red');
      const = useState(16);
    
      // 创建一个样式对象
      const divStyle = {
        color: activeColor,
        fontSize: `${fontSize}px`, // 或者直接 fontSize: 16
      };
    
      return <div style={divStyle}>...</div>;
    }
    

这种从“指令驱动”到“JavaScript驱动”的转变,体现了React的核心权衡:它牺牲了Vue指令带来的一些便利性,换取了使用标准JavaScript语言全部能力的灵活性和强大功能 2。初看之下,React的方式可能显得更为“手动”和繁琐,但当您习惯之后,会发现这种方式在处理复杂逻辑时更加直观和强大,因为它没有引入额外的、需要学习的“魔法”语法。

第三部分:响应式核心 - 状态管理的思维重塑

状态管理是任何现代UI框架的灵魂。在这一部分,我们将深入探讨Vue和React在响应式系统和状态管理上的根本性差异。这是从Vue转向React时最关键、也最具挑战性的思维模式转变。

根本性的心智模型转变:精细化追踪 vs. 重新渲染

要理解React的Hooks,必须首先理解其渲染模型,这与Vue截然不同。

  • Vue的响应式模型:基于观察者模式,并利用JavaScript的Proxy(在Vue 3中)来实现。当您创建一个响应式引用(如refreactive)时,Vue会“代理”这个数据。当组件首次渲染时,Vue会精确地追踪模板中访问了哪些响应式数据的哪些属性。当这些数据发生变化时(例如,您修改了count.value),Vue能够精准地知道哪些DOM节点依赖于这个数据,并只更新这些受影响的部分 3。这种方式非常高效,更新是“外科手术式”的。
  • React的渲染模型:相比之下,React的模型要简单得多,也更“暴力”一些。其核心原则是:当一个组件的状态(state)或属性(props)发生变化时,该组件会重新渲染。这里的“重新渲染”意味着整个组件的函数体会被重新执行一遍 7。React会生成一个新的虚拟DOM树,然后通过其高效的Diffing算法,与旧的虚拟DOM树进行比较,最后只将差异部分更新到真实的DOM上。

这个“重新执行函数”的概念是理解React所有Hooks(useState, useMemo, useEffect等)的钥匙。Hooks就是为了在这种不断重复执行的函数环境中,能够“钩入”React的特性(如状态保持、副作用处理等)而设计的。

组件本地状态:ref() vs. useState()

让我们通过一个经典的计数器例子,来具体感受这两种模型的差异 21。

  • Vue (ref) :

    代码段

    <script setup>
    import { ref } from 'vue'
    // `ref(0)` 创建一个响应式对象,其值存储在.value 属性中
    const count = ref(0)
    
    function increment() {
      // 直接修改.value 属性,Vue的响应式系统会捕获这个变化
      count.value++ 
    }
    console.log('Vue script setup runs only once per component instance');
    </script>
    <template>
      <button @click="increment">{{ count }}</button>
    </template>
    

    在Vue中,<script setup>部分的代码在组件实例创建时只执行一次。increment函数直接修改了count对象,Vue的响应式系统负责后续的UI更新。

  • React (useState) :

    JavaScript

    import React, { useState } from 'react';
    
    function Counter() {
      // useState(0) 在组件首次渲染时初始化状态为0
      // 它返回一个数组:[当前状态值, 更新该状态的函数]
      const [count, setCount] = useState(0);
    
      function increment() {
        // 调用setCount函数,并传入新的状态值
        // 这会“请求”React安排一次重新渲染
        setCount(count + 1); 
      }
    
      console.log('React component function runs on every render');
      return <button onClick={increment}>{count}</button>;
    }
    

    在React中,每次increment函数被调用并执行setCount(count + 1)时,React会:

    1. 计划一次对Counter组件的重新渲染。
    2. 在下一次渲染时,Counter函数会再次从头到尾执行。
    3. 当执行到const [count, setCount] = useState(0);这一行时,React会返回更新后的状态值(例如,1)。
    4. 函数继续执行,返回新的JSX,其中{count}的值就是1
不可变性(Immutability)的重要性

从上面的例子可以看出,React状态更新的一个核心原则是不可变性。您永远不应该直接修改状态变量,比如count++或者对于数组使用array.push()。您必须通过调用set函数,并提供一个全新的值(对于对象或数组,则是一个全新的引用)来触发更新。这是因为React通过比较新旧值的引用(使用Object.is)来决定是否需要重新渲染。如果直接修改原对象,引用不变,React可能无法检测到变化。

派生状态:computed vs. useMemo()

当一个状态依赖于另一个状态时,我们就需要派生状态。在Vue中,computed属性是处理这种情况的利器。

  • Vue (computed) :计算属性是基于它们的响应式依赖进行缓存的。只有在相关依赖发生改变时,它们才会重新求值。Vue会自动追踪依赖,无需手动声明 25。
  • React (useMemo) :由于React组件在每次渲染时都会重新执行,任何在组件内部的计算(比如过滤一个大列表)也会被重复执行。为了避免不必要的性能开销,React提供了useMemo Hook。useMemo会“记住”一个计算的结果,并且只有在其依赖项发生变化时,才会重新进行计算 7。

让我们通过一个过滤列表的例子来对比:

Vue (computed) 示例:

代码段

<script setup>
import { ref, computed } from 'vue';

const todos = ref();
const hideCompleted = ref(false);

// `visibleTodos` 会在 `todos` 或 `hideCompleted` 变化时自动重新计算
const visibleTodos = computed(() => {
  return hideCompleted.value
   ? todos.value.filter(t =>!t.completed)
    : todos.value;
});
</script>

React (useMemo) 示例:

JavaScript

import React, { useState, useMemo } from 'react';

function TodoList() {
  const = useState();
  const [hideCompleted, setHideCompleted] = useState(false);

  // `useMemo` 接收一个计算函数和一个依赖数组
  const visibleTodos = useMemo(() => {
    console.log('Recalculating visibleTodos...');
    return hideCompleted
     ? todos.filter(t =>!t.completed)
      : todos;
  }, [todos, hideCompleted]); // 只有当 `todos` 或 `hideCompleted` 变化时,计算函数才会重新执行

  //... render logic using visibleTodos
}

这里的关键是useMemo的第二个参数——依赖数组 28。您必须明确地告诉React,这个记忆化的值依赖于哪些变量。如果依赖数组中的任何一个值在两次渲染之间发生了变化,

useMemo就会重新执行第一个参数(计算函数)并返回新的值。如果依赖项没有变化,它会直接返回上一次缓存的值,从而避免了昂贵的计算。

复杂状态逻辑:useReducer()入门

当组件的状态逻辑变得复杂,例如一个状态依赖于多个其他状态,或者下一个状态依赖于前一个状态时,使用多个useState可能会让代码变得混乱。此时,React提供了useReducer Hook,它是一种更强大、更结构化的状态管理模式,其灵感来源于Redux 29。

useReducer接收一个reducer函数初始状态,返回当前状态和一个dispatch函数。

  • Reducer函数:一个纯函数,形如(state, action) => newState。它接收当前的状态和一个描述“发生了什么”的action对象,然后返回计算出的新状态。
  • dispatch函数:您通过调用dispatch(action)来触发状态更新。

使用useReducer管理Todo列表状态的示例 29:

JavaScript

import React, { useReducer } from 'react';

const initialState = { todos: };

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return { todos: };
    case 'TOGGLE_TODO':
      return {
        todos: state.todos.map(todo =>
          todo.id === action.payload? {...todo, completed:!todo.completed } : todo
        ),
      };
    default:
      throw new Error();
  }
}

function Todos() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <>
      {/*... */}
      <button onClick={() => dispatch({ type: 'ADD_TODO', payload: 'New Todo' })}>
        Add Todo
      </button>
      {/*... */}
    </>
  );
}

使用useReducer的好处是,它将更新逻辑(在reducer函数中)与触发更新的意图(在组件中调用dispatch)分离开来,使得组件代码更简洁,状态变更的逻辑更集中、可预测和易于测试。

React的渲染模型是其所有状态管理工具(Hooks)存在的根本原因。useState是为了在重复执行的函数中保持状态,useMemo是为了在重复执行中缓存昂贵的计算,而useReducer则是为了在复杂的状态更新逻辑中提供结构和可预测性。对于Vue开发者来说,理解这个从“精细化响应式”到“渲染驱动”的转变,是掌握React状态管理精髓的不二法门。

第四部分:生命周期与副作用:useEffect的统一之道

在Vue中,我们习惯于使用一系列语义明确的生命周期钩子(如onMounted, onUpdated, onUnmounted)来在组件的不同阶段执行代码,例如发起API请求或清理定时器 33。React则采取了一种不同的、更为统一的方式:通过一个名为

useEffect的Hook来处理所有与组件渲染无关的“副作用”(Side Effects) 35。

从多个钩子到一个:useEffect的威力

副作用是指在组件渲染过程中,与外部世界发生的任何交互,包括:

  • 数据获取(Fetching data from an API)
  • 设置订阅(Setting up a subscription)
  • 手动更改DOM(Manually changing the DOM)
  • 设置定时器(setTimeout, setInterval

useEffect的设计允许您将这些副作用逻辑与组件的渲染逻辑分离开来。它接收两个参数:一个effect函数和一个可选的依赖数组。这个依赖数组是控制useEffect行为的关键。

掌握useEffect的依赖数组

依赖数组决定了effect函数何时执行,这是从Vue生命周期钩子迁移过来时最需要掌握的核心概念 35。

  1. useEffect(() => {... },) (空依赖数组)

    • 行为:effect函数仅在组件首次渲染后执行一次。
    • Vue等价物onMounted
    • 用途:这是执行一次性设置操作的理想场所,比如初始化数据获取、设置事件监听器等。
  2. useEffect(() => {... }, [dep1, dep2]) (包含依赖项)

    • 行为:effect函数会在首次渲染后执行,并且在任何一个依赖项(dep1dep2)发生变化后的下一次渲染时再次执行。
    • Vue等价物watchwatchEffect,以及 onUpdated 的特定场景。
    • 用途:当副作用依赖于某些props或state时使用。例如,当用户ID改变时重新获取用户信息。
  3. useEffect(() => {... }) (无依赖数组)

    • 行为:effect函数在每次组件渲染后都会执行。
    • Vue等价物onUpdated(但会更频繁地触发)加上onMounted
    • 用途:这种用法相对较少,因为它很容易导致性能问题或无限循环。通常只在副作用确实需要在每次渲染后都运行时才使用。
清理副作用:useEffect的返回函数

effect函数可以返回一个清理函数(cleanup function) 。这个清理函数会在以下两个时机执行:

  1. 在组件卸载时。
  2. 在下一次effect函数重新执行之前
  • Vue等价物onUnmounted
  • 用途:这是清理副作用的必要机制,例如取消网络请求、移除事件监听器、清除定时器,以防止内存泄漏。

代码深度解析:并排对比数据获取

让我们通过一个常见的数据获取场景,来直观地对比Vue和React的实现方式 35。

  • Vue: 使用 onMounted

    代码段

    <script setup>
    import { ref, onMounted } from 'vue'
    
    const data = ref(null)
    const error = ref(null)
    
    // onMounted钩子在组件挂载到DOM后执行
    onMounted(async () => {
      try {
        const res = await fetch('https://api.example.com/data')
        if (!res.ok) throw new Error('Network response was not ok')
        data.value = await res.json()
      } catch (e) {
        error.value = e.message
      }
    })
    </script>
    
    <template>
      <div v-if="error">Error: {{ error }}</div>
      <div v-else-if="data">{{ data.title }}</div>
      <div v-else>Loading...</div>
    </template>
    
  • React: 使用 useEffect

    JavaScript

    import React, { useState, useEffect } from 'react';
    
    function DataFetcher() {
      const = useState(null);
      const [error, setError] = useState(null);
      const [loading, setLoading] = useState(true);
    
      // useEffect的effect函数在组件首次渲染后执行
      useEffect(() => {
        // 使用AbortController来处理组件卸载时的请求取消
        const controller = new AbortController();
        const signal = controller.signal;
    
        async function fetchData() {
          try {
            const res = await fetch('https://api.example.com/data', { signal });
            if (!res.ok) throw new Error('Network response was not ok');
            const json = await res.json();
            setData(json);
          } catch (e) {
            if (e.name!== 'AbortError') {
              setError(e.message);
            }
          } finally {
            setLoading(false);
          }
        }
    
        fetchData();
    
        // 返回一个清理函数
        return () => {
          // 在组件卸载时,中止fetch请求
          controller.abort();
        };
      },); // 空依赖数组确保effect只运行一次
    
      if (loading) return <div>Loading...</div>;
      if (error) return <div>Error: {error}</div>;
    
      return <div>{data?.title}</div>;
    }
    

    在React的例子中,我们不仅使用了空依赖数组来模拟onMounted,还返回了一个清理函数,在组件卸载时通过AbortController来取消可能仍在进行中的网络请求,这是一个更健壮的实践。

watch vs. useEffect 深度对比

当需要响应特定数据的变化来执行副作用时,Vue使用watch,而React使用带依赖项的useEffect 36。

场景:当userId prop改变时,重新获取用户数据。

  • Vue (watch) :

    代码段

    <script setup>
    import { ref, watch } from 'vue';
    const props = defineProps(['userId']);
    const userData = ref(null);
    
    watch(
      () => props.userId, // 源:要侦听的数据
      async (newUserId) => { // 回调函数
        if (newUserId) {
          const res = await fetch(`https://api.example.com/users/${newUserId}`);
          userData.value = await res.json();
        }
      },
      { immediate: true } // 选项:在初始时立即执行一次
    );
    </script>
    
  • React (useEffect) :

    JavaScript

    import React, { useState, useEffect } from 'react';
    
    function UserProfile({ userId }) {
      const = useState(null);
    
      useEffect(() => {
        if (!userId) return;
    
        async function fetchUserData() {
          const res = await fetch(`https://api.example.com/users/${userId}`);
          const data = await res.json();
          setUserData(data);
        }
    
        fetchUserData();
      }, [userId]); // 依赖数组:当userId变化时,重新执行effect
    
      //...
    }
    

一个关键区别在于React的依赖明确性。React的ESLint插件通常会配置一条exhaustive-deps规则,它会静态检查useEffect函数体内部用到的所有响应式值(props和state),并强制您将它们添加到依赖数组中。这避免了一类常见的bug:effect函数使用了某个值的旧版本(形成了“陈旧闭包”),因为它没有被声明为依赖,导致在值更新后effect没有重新执行。

Vue的响应式系统是自动追踪依赖的,所以watch不需要手动声明回调函数内部的所有依赖。而React的渲染模型决定了它必须依赖开发者明确地提供这个依赖数组。这再次体现了React的核心哲学:用明确性换取可预测性。虽然这增加了一些“负担”,但它使得组件的副作用行为变得非常清晰和易于推理:只需查看依赖数组,就能确切知道什么变化会触发这个副作用。

第五部分:数据流与通信:组件间的对话方式

组件化开发的核心之一就是如何有效地在组件之间传递数据和进行通信。Vue和React都遵循单向数据流的原则,即数据从父组件流向子组件,但它们实现子组件向父组件通信的方式有所不同。

父传子(Props):defineProps vs. 函数参数

将数据从父组件传递到子组件是两种框架中最相似的操作。

  • Vue (defineProps) :在Vue的<script setup>中,您使用defineProps宏来声明一个组件期望接收的props。这不仅定义了数据通道,还可以进行类型验证 41。

    代码段

    <template>
      <ChildComponent message="Hello from Parent" />
    </template>
    <script setup>
    import ChildComponent from './ChildComponent.vue';
    </script>
    
    <script setup>
    // 声明一个名为'message'的prop
    const props = defineProps({
      message: String
    });
    </script>
    <template>
      <p>{{ props.message }}</p>
    </template>
    
  • React (函数参数) :在React中,props的传递就像给函数传递参数一样自然。父组件传递的所有props会被收集到一个对象中,作为子组件函数的第一个参数。通常,我们会使用ES6的解构语法直接获取所需的prop 41。

    JavaScript

    // ParentComponent.jsx
    import React from 'react';
    import ChildComponent from './ChildComponent';
    
    function ParentComponent() {
      return <ChildComponent message="Hello from Parent" />;
    }
    
    // ChildComponent.jsx
    import React from 'react';
    
    // props对象作为函数的第一个参数,这里直接解构出message
    function ChildComponent({ message }) {
      return <p>{message}</p>;
    }
    

    在React中,props的类型检查通常通过TypeScript或一个名为prop-types的库来完成,而不是框架内置的功能。

子传父(Events):$emit vs. 回调Props

这是Vue和React在组件通信上的一个核心差异。

  • Vue ($emit) :Vue提供了一个内置的事件系统。子组件通过调用$emit方法来“触发”一个自定义事件,并可以附带数据。父组件则通过@语法来“监听”这个事件,并执行一个方法 1。

    代码段

    <script setup>
    const emit = defineEmits(['notifyParent']);
    
    function handleClick() {
      emit('notifyParent', 'Message from child');
    }
    </script>
    <template>
      <button @click="handleClick">Notify Parent</button>
    </template>
    
    <template>
      <ChildComponent @notifyParent="handleNotification" />
    </template>
    <script setup>
    function handleNotification(payload) {
      console.log(payload); // "Message from child"
    }
    </script>
    
  • React (回调Props) :React没有内置的事件系统。它的哲学是“函数即数据”。子组件向父组件通信的方式是:父组件将一个函数作为prop传递给子组件,子组件在需要的时候调用这个函数,并将数据作为参数传入 15。

    JavaScript

    // ChildComponent.jsx
    import React from 'react';
    
    // 接收一个名为onNotify的函数prop
    function ChildComponent({ onNotify }) {
      function handleClick() {
        // 调用从父组件传来的函数
        onNotify('Message from child');
      }
      return <button onClick={handleClick}>Notify Parent</button>;
    }
    
    // ParentComponent.jsx
    import React from 'react';
    import ChildComponent from './ChildComponent';
    
    function ParentComponent() {
      function handleNotification(payload) {
        console.log(payload); // "Message from child"
      }
      // 将handleNotification函数作为名为onNotify的prop传递下去
      return <ChildComponent onNotify={handleNotification} />;
    }
    

    这种“回调prop”的模式初看可能比$emit繁琐,但它完全遵循了React的“一切皆为JavaScript”的理念。在React中,数据和函数没有本质区别,都可以通过props向下传递。这种方式使得组件的依赖关系非常明确:一个组件的所有输入(包括数据和回调)都清晰地定义在其props中,这增强了组件的封装性和可复用性。

跨层级状态(避免Prop Drilling):provide/inject vs. useContext

当需要将数据从一个高层级组件传递给一个深层嵌套的子组件时,如果层层通过props传递,会非常繁琐,这种情况被称为“Prop Drilling”。Vue和React都提供了解决此问题的方案。

  • Vue (provide/inject) :父组件通过provide提供数据,任何后代组件都可以通过inject来注入并使用这些数据,无论它们嵌套多深。

    代码段

    <script setup>
    import { provide, ref } from 'vue';
    provide('theme', ref('dark'));
    </script>
    
    <script setup>
    import { inject } from 'vue';
    const theme = inject('theme');
    </script>
    <template>
      <p>Current theme is: {{ theme }}</p>
    </template>
    
  • React (Context API) :React的Context API提供了类似的功能,它包含三个主要部分:

    1. createContext:创建一个Context对象。
    2. Context.Provider:一个组件,用于将其value prop提供给其所有后代组件。
    3. useContext Hook:一个Hook,用于在函数组件中读取和订阅Context的值。

    使用Context API实现主题切换的示例 44:

    JavaScript

    // 1. 创建Context (ThemeContext.js)
    import { createContext } from 'react';
    export const ThemeContext = createContext('light'); // 提供一个默认值
    
    // 2. 在顶层组件提供Context (App.js)
    import React, { useState } from 'react';
    import { ThemeContext } from './ThemeContext';
    import DeepChild from './DeepChild';
    
    function App() {
      const = useState('dark');
    
      return (
        // 使用Provider包裹需要访问该Context的组件树
        <ThemeContext.Provider value={theme}>
          <DeepChild />
        </ThemeContext.Provider>
      );
    }
    
    // 3. 在深层子组件中消费Context (DeepChild.js)
    import React, { useContext } from 'react';
    import { ThemeContext } from './ThemeContext';
    
    function DeepChild() {
      // 使用useContext Hook来获取最近的Provider提供的value
      const theme = useContext(ThemeContext);
      return <p>Current theme is: {theme}</p>;
    }
    

    React的Context API在功能上与Vue的provide/inject非常相似,都是为了解决跨层级数据传递的问题。

总结来说,React的数据流和通信机制更加统一和纯粹。无论是数据还是行为(函数),都通过props这一个通道自上而下地流动。这种简单而强大的模式,虽然在某些场景下(如子传父)比Vue的事件系统显得更“手动”,但它也使得组件的接口更加清晰,数据来源更加可追溯,这在构建大型、复杂应用时是一个显著的优势。

第六部分:UI渲染 - 条件、列表与事件的React之道

在掌握了组件和状态的基础后,我们来关注日常开发中最常见的任务:根据数据动态地渲染UI。在Vue中,我们依赖于功能强大的模板指令,如v-ifv-for。在React中,我们将回归JavaScript的本源,使用原生的语言特性来完成这些任务。

条件渲染:v-if vs. 三元运算符 & &&

在Vue中,v-if, v-else-if, v-else指令提供了一种直观、类似HTML的条件渲染方式 1。

  • Vue (v-if) :

    代码段

    <template>
      <div v-if="isLoggedIn">Welcome, User!</div>
      <div v-else>Please log in.</div>
    </template>
    

由于JSX本质上是JavaScript,我们不能在其中直接使用if...else语句,因为它们是语句(statement)而不是表达式(expression)。JSX中只能嵌入表达式。因此,React开发者通常使用JavaScript中能够返回值的语法来进行条件渲染 48。

  1. 三元条件运算符 (? :)

    这是if...else在JSX中最直接的等价物,因为它是一个表达式。

    JavaScript

    import React, { useState } from 'react';
    
    function AuthStatus() {
      const [isLoggedIn, setIsLoggedIn] = useState(false);
    
      return (
        <div>
          {isLoggedIn? <div>Welcome, User!</div> : <div>Please log in.</div>}
        </div>
      );
    }
    
  2. 逻辑与运算符 (&&)

    当您只想在某个条件为真时渲染一个元素,否则什么都不渲染时(相当于只有v-if没有v-else),&&运算符是一个非常方便的捷径。这是利用了JavaScript中true && expression总是返回expression,而false && expression总是返回false的短路特性。React在渲染时会忽略false、null、undefined等值。

    JavaScript

    import React from 'react';
    
    function Mailbox({ unreadMessages }) {
      return (
        <div>
          <h1>Hello!</h1>
          {unreadMessages.length > 0 && (
            <h2>
              You have {unreadMessages.length} unread messages.
            </h2>
          )}
        </div>
      );
    }
    

    这段代码的含义是:如果unreadMessages.length > 0为真,则渲染<h2>元素。

列表渲染:v-for vs. .map()

在Vue中,v-for指令是渲染列表的标准方式,语法简洁明了 49。

  • Vue (v-for) :

    代码段

    <template>
      <ul>
        <li v-for="item in items" :key="item.id">
          {{ item.name }}
        </li>
      </ul>
    </template>
    

在React中,列表渲染回归到JavaScript的数组操作。我们使用数组的.map()方法,它会遍历数组的每一项,并返回一个由JSX元素组成的新数组。React会自动将这个数组渲染为一系列的DOM节点 51。

  • React (.map()) :

    JavaScript

    import React from 'react';
    
    function ItemList({ items }) {
      return (
        <ul>
          {items.map(item => (
            <li key={item.id}>
              {item.name}
            </li>
          ))}
        </ul>
      );
    }
    

    这里有几个关键点需要注意:

    • key属性:与Vue一样,React也需要一个稳定且唯一的key属性来帮助它识别列表中的每一项,从而在数据更新时高效地进行DOM diff和更新。key对于列表的性能和状态保持至关重要 49。
    • 灵活性:使用.map()意味着您可以使用JavaScript数组的所有能力。例如,您可以在.map()之前先.filter()来渲染一个过滤后的列表,或者.slice()来只渲染部分列表,所有这些都可以在一个链式调用中完成,非常灵活。

事件处理:@click vs. onClick

事件处理的语法在Vue和React中非常相似,但细节上体现了它们各自的哲学。

  • Vue (@click & 修饰符) :Vue使用v-on指令(简写为@)来监听DOM事件。一个非常便利的特性是事件修饰符,如.prevent.stop,它们可以让我们在模板中以声明式的方式处理常见的事件操作 52。

    代码段

    <template>
      <form @submit.prevent="handleSubmit">
        <button @click="handleClick">Click me</button>
      </form>
    </template>
    
  • React (onClick & 手动处理) :React的事件绑定属性遵循驼峰命名法(onClick, onSubmit等)。事件处理器是一个函数。对于像阻止默认行为这样的操作,需要在事件处理函数内部,通过访问事件对象e并手动调用e.preventDefault()来完成 53。

    JavaScript

    import React from 'react';
    
    function EventForm() {
      function handleClick(e) {
        console.log('Button was clicked!');
      }
    
      function handleSubmit(e) {
        // 必须手动调用preventDefault
        e.preventDefault();
        console.log('Form submitted!');
      }
    
      return (
        <form onSubmit={handleSubmit}>
          <button onClick={handleClick}>Click me</button>
        </form>
      );
    }
    

    这种差异再次凸显了Vue的“便利性优先”与React的“JavaScript原生优先”的对比。Vue的修饰符减少了模板逻辑的样板代码,而React则要求开发者在JavaScript函数中明确地处理这些逻辑,这使得行为更加显式和可控。

从UI渲染的这些方面可以看出,从Vue到React的转变,本质上是从一个“增强的HTML”环境,迁移到一个“嵌入了HTML的JavaScript”环境。起初,您可能会怀念Vue指令的简洁,但随着对React模式的深入理解,您会逐渐欣赏到直接在渲染逻辑中使用JavaScript全部能力的自由与强大。

第七部分:生态系统巡礼 - 路由与状态管理

一个框架或库的强大与否,不仅取决于其核心功能,更在于其周边生态的成熟度。对于路由和全局状态管理这两个构建单页应用(SPA)的刚需,Vue和React都提供了成熟的解决方案,但它们的组织方式和社区选择上有所不同。

路由管理:Vue Router vs. React Router DOM

  • Vue Router:作为Vue的官方路由管理器,Vue Router与Vue核心库深度集成,提供了统一且“开箱即用”的体验。当您使用create-vue创建项目时,可以直接选择集成Vue Router,脚手架会自动完成所有配置 55。

    • 核心概念:通过createRouter创建路由实例,定义routes数组来映射路径和组件,使用<router-link>进行声明式导航,<router-view>作为路由出口,以及通过useRouter进行编程式导航 57。
  • React Router DOM:React本身不包含路由功能。React Router是社区中最流行、事实上的标准路由解决方案。它同样遵循React的组件化和Hooks理念 59。

    • 安装:需要手动将其添加到项目中:

      Bash

      npm install react-router-dom
      
    • 核心概念与设置 59:

      1. 创建路由:使用createBrowserRouter函数定义路由配置,这是一个对象数组,类似于Vue Router。
      2. 提供路由:在应用的根部,使用<RouterProvider>组件来包裹您的应用,并将创建的路由实例传入。
      3. 声明式导航:使用<Link to="/path">组件来创建导航链接,它会被渲染成<a>标签,但会阻止页面刷新。
      4. 路由出口:在React Router v6+中,嵌套路由的出口由<Outlet />组件表示,其作用等同于Vue Router的<router-view>
      5. 编程式导航:使用useNavigate Hook来获取一个navigate函数,通过调用navigate('/path')来实现程序化的页面跳转。

    React Router快速上手示例:

    JavaScript

    // main.jsx
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import {
      createBrowserRouter,
      RouterProvider,
    } from "react-router-dom";
    import Root from "./routes/root";
    import Contact from "./routes/contact";
    
    const router = createBrowserRouter(,
      },
    ]);
    
    ReactDOM.createRoot(document.getElementById('root')).render(
      <React.StrictMode>
        <RouterProvider router={router} />
      </React.StrictMode>
    );
    

    JavaScript

    // routes/root.jsx
    import { Outlet, Link, useNavigate } from "react-router-dom";
    
    export default function Root() {
      const navigate = useNavigate();
      return (
        <>
          <nav>
            <ul>
              <li><Link to={`/`}>Home</Link></li>
              <li><Link to={`/contacts/1`}>Your Name</Link></li>
            </ul>
            <button onClick={() => navigate(-1)}>Go Back</button>
          </nav>
          <div id="detail">
            <Outlet /> {/* 子路由组件将在这里渲染 */}
          </div>
        </>
      );
    }
    

全局状态管理:Pinia vs. React生态的多样选择

  • Pinia:作为Vuex的继任者,Pinia现在是Vue官方推荐的状态管理库。它以其极简的API、出色的TypeScript支持和模块化的设计赢得了开发者的喜爱 61。Pinia的设计与Vue 3的Composition API完美契合,定义一个store就像定义一个组合式函数一样简单直观 62。

  • React生态的多样性:React的核心库不包含全局状态管理方案,这催生了一个庞大而多样的生态系统。开发者可以根据项目需求和团队偏好自由选择 2。

    • Context API (内置) :对于简单的全局状态,可以直接使用React内置的Context API(如前文所述),但它在处理频繁更新或复杂状态时可能会有性能问题。

    • Redux Toolkit (RTK) :是目前官方推荐的、也是行业内使用最广泛的Redux使用方式。它通过createSlice等API极大地减少了传统Redux的样板代码,并内置了Immer来实现“可变式”的不可变更新,以及Thunk来处理异步逻辑。RTK非常适合需要严格、可预测状态流的大型复杂应用 64。

      Redux Toolkit 快速上手:

      JavaScript

      // features/counter/counterSlice.js
      import { createSlice } from '@reduxjs/toolkit'
      
      export const counterSlice = createSlice({
        name: 'counter',
        initialState: { value: 0 },
        reducers: {
          increment: state => { state.value += 1 },
          decrement: state => { state.value -= 1 },
        }
      })
      export const { increment, decrement } = counterSlice.actions
      export default counterSlice.reducer
      
    • Zustand:一个轻量、快速、不拘一格的状态管理库,因其API简洁、无需Provider包裹、基于Hooks的舒适体验而备受青睐 67。对于从Pinia迁移过来的Vue开发者来说,Zustand的API和心智模型会感觉非常亲切和易于上手 69。

      Zustand 快速上手:

      JavaScript

      import { create } from 'zustand'
      
      const useBearStore = create((set) => ({
        bears: 0,
        increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
        removeAllBears: () => set({ bears: 0 }),
      }))
      
      function BearCounter() {
        const bears = useBearStore((state) => state.bears)
        return <h1>{bears} bears</h1>
      }
      

这种“官方钦定”与“百花齐放”的对比,再次反映了Vue和React的哲学差异。Vue倾向于提供一个“全家桶”式的、经过精心策划的解决方案,降低了开发者的选择成本,保证了生态的一致性 1。而React则将选择权交给了开发者,虽然这可能带来“选择困难症”,但也促进了社区的创新,使得开发者总能为特定问题找到最合适的工具 70。对于初学者,从Zustand开始,可以平滑地过渡到React的全局状态管理,待项目变得复杂后再考虑引入结构更强的Redux Toolkit。

第八部分:实战项目 - 用React构建一个TodoMVC应用

理论学习的最终目的是付诸实践。在本部分,我们将把前面学到的所有React概念——组件、JSX、状态、Props、事件处理和Hooks——融会贯通,从零开始构建一个功能完整的TodoMVC应用。这将是您巩固知识、建立信心的最佳方式 71。

我们将遵循自上而下的组件拆分思路,逐步构建应用。

第一步:项目初始化与主组件搭建

首先,使用Vite创建一个新的React项目:

Bash

npm create vite@latest react-todomvc -- --template react
cd react-todomvc
npm install
npm run dev

接下来,我们来规划App.jsx组件。它将作为我们应用的根组件,负责管理所有待办事项的核心状态

JavaScript

// src/App.jsx
import React, { useState } from 'react';
import './App.css'; // 我们将在这里添加一些基本样式

function App() {
  // 使用useState来存储整个todos列表
  const = useState();

  return (
    <div className="todoapp">
      <header className="header">
        <h1>todos</h1>
        {/* TodoForm组件将在这里 */}
      </header>
      <section className="main">
        {/* TodoList组件将在这里 */}
      </section>
      <footer className="footer">
        {/* Footer/Filter组件将在这里 */}
      </footer>
    </div>
  );
}

export default App;

第二步:列表渲染 - TodoListTodoItem

现在,我们需要创建组件来显示待办事项列表。

  1. TodoList.jsx 组件

这个组件负责接收todos数组作为prop,并使用.map()方法遍历它,为每个todo项渲染一个TodoItem组件。

JavaScript

// src/components/TodoList.jsx
import React from 'react';
import TodoItem from './TodoItem';

function TodoList({ todos }) {
  return (
    <ul className="todo-list">
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

2. TodoItem.jsx 组件

这个组件负责显示单个待办事项,包括其文本、一个用于标记完成的复选框和一个删除按钮。

JavaScript

// src/components/TodoItem.jsx
import React from 'react';

function TodoItem({ todo }) {
  return (
    <li className={todo.completed? 'completed' : ''}>
      <div className="view">
        <input
          className="toggle"
          type="checkbox"
          checked={todo.completed}
        />
        <label>{todo.text}</label>
        <button className="destroy"></button>
      </div>
    </li>
  );
}

export default TodoItem;

3. 整合到 App.jsx

现在,在App.jsx中导入并使用TodoList组件,将todos状态传递给它。

JavaScript

// src/App.jsx
import React, { useState } from 'react';
import TodoList from './components/TodoList'; // 导入
import './App.css';

function App() {
  const = useState([/*... */]);

  return (
    <div className="todoapp">
      {/*... */}
      <section className="main">
        {/* 使用TodoList并传递todos */}
        <TodoList todos={todos} />
      </section>
      {/*... */}
    </div>
  );
}

export default App;

第三步:子组件通信 - 实现删除和状态切换

TodoItem需要能够通知App组件删除自己或切换自己的完成状态。我们将使用回调props模式来实现。

1. 在 App.jsx 中定义处理函数

JavaScript

// src/App.jsx
function App() {
  const = useState([/*... */]);

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id? {...todo, completed:!todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id!== id));
  };

  //...
}

2. 将函数作为props传递下去

JavaScript

// src/App.jsx
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />

// src/components/TodoList.jsx
function TodoList({ todos, onToggle, onDelete }) {
  return (
    <ul className="todo-list">
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

3. 在 TodoItem.jsx 中调用回调

JavaScript

// src/components/TodoItem.jsx
function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li className={todo.completed? 'completed' : ''}>
      <div className="view">
        <input
          className="toggle"
          type="checkbox"
          checked={todo.completed}
          onChange={() => onToggle(todo.id)} // 调用onToggle回调
        />
        <label>{todo.text}</label>
        <button
          className="destroy"
          onClick={() => onDelete(todo.id)} // 调用onDelete回调
        ></button>
      </div>
    </li>
  );
}

现在,您的应用已经具备了核心的显示、删除和状态切换功能!

第四步:表单处理 - 添加新的待办事项

我们需要一个表单组件来创建新的todo。

  1. 创建 TodoForm.jsx

这个组件将包含一个输入框。我们将使用useState来管理输入框自己的状态(一个“受控组件”)。

JavaScript

// src/components/TodoForm.jsx
import React, { useState } from 'react';

function TodoForm({ onAddTodo }) {
  const = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (newTodoText.trim()) {
      onAddTodo(newTodoText.trim());
      setNewTodoText(''); // 提交后清空输入框
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        className="new-todo"
        placeholder="What needs to be done?"
        autoFocus
        value={newTodoText}
        onChange={(e) => setNewTodoText(e.target.value)}
      />
    </form>
  );
}

export default TodoForm;

2. 在 App.jsx 中集成并处理添加逻辑

JavaScript

// src/App.jsx
import TodoForm from './components/TodoForm'; // 导入

function App() {
  const = useState([/*... */]);

  const addTodo = (text) => {
    const newTodo = {
      id: Date.now(), // 简单起见,使用时间戳作为ID
      text: text,
      completed: false
    };
    setTodos();
  };

  return (
    <div className="todoapp">
      <header className="header">
        <h1>todos</h1>
        <TodoForm onAddTodo={addTodo} />
      </header>
      {/*... */}
    </div>
  );
}

第五步:派生状态与副作用 - 实现筛选功能

最后,我们来实现“All”, “Active”, “Completed”的筛选功能。

  1. 在 App.jsx 中添加筛选状态和派生逻辑

我们将使用useState来存储当前的筛选器状态,并使用useMemo来高效地计算出需要显示的todo列表。

JavaScript

// src/App.jsx
import React, { useState, useMemo } from 'react'; // 导入useMemo

function App() {
  const = useState([/*... */]);
  const [filter, setFilter] = useState('all'); // 'all', 'active', 'completed'

  const filteredTodos = useMemo(() => {
    switch (filter) {
      case 'active':
        return todos.filter(todo =>!todo.completed);
      case 'completed':
        return todos.filter(todo => todo.completed);
      default:
        return todos;
    }
  }, [todos, filter]); // 依赖于todos和filter

  //...
  
  return (
    <div className="todoapp">
      {/*... */}
      <section className="main">
        {/* 传递过滤后的列表 */}
        <TodoList todos={filteredTodos} onToggle={toggleTodo} onDelete={deleteTodo} />
      </section>
      <footer className="footer">
        {/* 在这里添加Filter组件,并传递setFilter函数 */}
      </footer>
    </div>
  );
}

2. (可选) useReducer 重构

当应用逻辑变得更复杂时(例如,添加“全部完成”、“清除已完成”等功能),将App.jsx中的多个useState调用重构为一个useReducer会使状态管理更加清晰。

JavaScript

// src/App.jsx (使用useReducer重构)
import React, { useReducer, useMemo } from 'react';

const initialState = {
  todos: [/*... */],
  filter: 'all'
};

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      //...返回新状态
    case 'TOGGLE_TODO':
      //...返回新状态
    case 'DELETE_TODO':
      //...返回新状态
    case 'SET_FILTER':
      return {...state, filter: action.payload };
    default:
      return state;
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { todos, filter } = state;

  const filteredTodos = useMemo(() => { /*... */ }, [todos, filter]);

  //... 在处理函数中调用 dispatch({ type: '...', payload:... })
}

通过这个实战项目,您已经亲手实践了React的核心模式。您会发现,虽然语法不同,但组件化、单向数据流、状态驱动视图等核心思想是与Vue相通的。React的方式更加依赖于纯粹的JavaScript,这既是挑战,也是其强大之处。

第九部分:开发者体验与工具链

高效的开发离不开强大的工具支持。在这一部分,我们将介绍一些能够显著提升您React开发体验的工具和技巧,包括VSCode代码片段、浏览器开发者工具以及后续学习的建议。

React开发必备的VSCode代码片段

手动编写React函数组件的样板代码是一件重复性的工作。通过自定义VSCode代码片段,您可以极大地提高效率。

创建自定义代码片段

  1. 在VSCode中,通过 文件 > 首选项 > 配置用户代码片段 (或 Code > Preferences > Configure User Snippets) 打开命令面板。

  2. 选择 javascriptreacttypescriptreact(或者创建一个新的全局代码片段文件)。

  3. 将以下JSON配置粘贴到文件中:

    rfc - 快速创建React函数式组件 74

    JSON

    {
      "React Functional Component": {
        "prefix": "rfc",
        "body":,
        "description": "Creates a React Functional Component"
      }
    }
    

    现在,在一个.jsx文件中,只需输入rfc并按Tab键,就会自动生成一个完整的函数组件骨架,光标会首先定位在ComponentName处供您命名。

推荐的VSCode扩展:

为了获得更全面的代码片段支持,强烈推荐安装社区中广受欢迎的 "ES7+ React/Redux/React-Native snippets" 扩展 75。它提供了大量有用的快捷方式,例如:

  • rfce: 创建并导出一个函数组件。
  • useState: 快速生成一个useState Hook。
  • useEffect: 快速生成一个useEffect Hook。
  • useMemo: 快速生成一个useMemo Hook。

React开发者工具

与Vue Devtools类似,React也有一套官方的浏览器扩展程序,名为React Developer Tools。它是调试React应用的必备工具。安装后,在浏览器开发者工具中会新增"Components"和"Profiler"两个选项卡。

  • Components选项卡

    • 组件树检查:您可以像检查DOM树一样,检查React应用的组件树结构。
    • Props和State查看:选中一个组件,可以实时查看其接收的props和内部的state(包括Hooks的状态)。您甚至可以动态地修改这些值来测试组件在不同数据下的表现。
    • 追溯渲染来源:找出是哪个组件触发了当前的渲染。
  • Profiler选项卡

    • 性能分析:这是一个强大的性能分析工具。您可以记录一次交互过程(如点击按钮、输入文字),Profiler会生成火焰图,显示每个组件的渲染耗时,帮助您定位性能瓶颈和不必要的渲染。

最终建议与后续学习路径

恭喜您!通过本指南的学习和实践,您已经成功地将Vue的知识体系映射到了React之上,并掌握了React的核心思想和开发模式。您现在已经具备了独立开始构建真实React应用的能力。

核心思维模式总结

  • 从模板到JSX:拥抱在JavaScript中编写UI的模式,利用JS的全部能力。
  • 从精细化响应式到重新渲染:理解“状态变更,组件重跑”是React的核心,Hooks是服务于此模型的工具集。
  • 从指令到原生JS:习惯于用原生JS逻辑(三元运算、.map()e.preventDefault())替代Vue的便利指令。
  • 从事件总线到回调Props:将子传父通信视为传递一个特殊的函数prop。

下一步该学什么?

  1. 深入React Hooks:探索更多高级Hooks,如useCallback(用于记忆化函数,防止不必要的子组件渲染)、useRef(用于访问DOM节点或存储不触发渲染的可变值)和自定义Hooks(将组件逻辑提取到可复用的函数中)。
  2. 学习测试:掌握使用Jest和React Testing Library(RTL)为您的组件编写单元测试和集成测试。RTL鼓励您像用户一样去测试组件,这是一种非常强大的测试理念。
  3. 探索全栈框架:当您准备好构建更复杂的、需要服务端渲染(SSR)或静态站点生成(SSG)的应用时,可以开始学习Next.js 76。Next.js是基于React的生产级框架,它提供了文件系统路由、API路由、图片优化等一系列强大功能,是React生态中的“Nuxt.js”。

从Vue到React的旅程,是一次从一个优秀生态到另一个优秀生态的探索。它们解决问题的思路不同,但最终目标都是构建卓越的用户体验。希望本指南能为您扫清障碍,让您在React的世界里游刃有余,开启新的技术篇章。

听歌体验直接拉满!推荐一款高颜值音乐播放器!

作者 Java陈序员
2025年9月12日 09:31

大家好,我是 Java陈序员

你是否也曾遇到过这样的困扰:喜欢的音乐播放器要么颜值不够能打,界面好看的功能又太过简陋;在线听歌得忍受满屏广告和冗余功能,而且受版权限制,本地音乐管理又杂乱无章...

今天给大家推荐一款一款高颜值音乐播放器,听歌体验直接拉满!

项目介绍

SPlayer —— 一个简约的音乐播放器,基于 Vue3 + TypeScript + Naïve UI + Electron 技术栈打造,兼顾了美观的界面和流畅的体验。

功能特色

  • 多平台适配:支持网页端和 Windows 桌面客户端,移动端也做了基础适配
  • 沉浸式听歌体验:支持桌面歌词,封面主题色自适应;全站色调随歌曲封面变化,视觉感拉满;支持 Light/Dark/Auto 模式自动切换,白天黑夜都舒适
  • 本地+在线双模式:支持本地歌曲管理及分类,并提供每日推荐、私人 FM、歌单收藏,发现更多好音乐等,同时支持播放部分无版权歌曲
  • 其他细节功能:音乐频谱动态显示,跟着节奏律动;歌曲渐入渐出效果,切换更自然;支持云盘音乐管理,上传、播放、纠正一键操作;支持 MV 与视频播放、评论区互动

快速上手

桌面端

SPlayer 提供了 Windows 版本的安装包,可直接下载进行安装。

1、打开下载地址

https://github.com/imsyy/SPlayer/releases

2、下载安装包

3、双击运行安装包,一键安装

Web 端

SPlayer 支持 Docker 部署,可使用 Docker 快速部署 Web 端。

1、拉取镜像

# 从 Docker Hub 拉取
docker pull ghcr.io/imsyy/splayer:latest

# 或者从 GitHub ghcr 拉取
docker pull ghcr.io/imsyy/splayer:latest

2、运行容器

docker run -d --name SPlayer -p 25884:25884 imsyy/splayer:latest

3、浏览器访问

http://{ip/域名}:25884

功能体验

  • 明亮主题

  • 深色主题

  • 发现音乐

  • 播客电台

  • 最近播放

  • 歌曲播放

  • 热门评论

  • 歌词设置

本地开发

SPlayer 开发依赖 Node.js, 需提前安装好最新稳定版本的 Node.js 环境。

1、克隆或下载源码

git clone https://github.com/imsyy/SPlayer.git

2、进入项目目录并安装依赖

cd SPlayer

pnpm install

# 推荐使用 pnpm, 如未安装可使用如下命令安装
npm install -g pnpm

3、复制 /.env.example 文件并重命名为 /.env 并修改配置

4、运行启动

pnpm run dev

5、可根据操作系统类型进行打包部署,打包成功后,会输出安装包或可执行文件在 /dist 目录中

## Windows
pnpm build:win

## Linux
pnpm build:linux

## MacOS
pnpm build:mac

## Web
pnpm build:web

如果你是音乐爱好者,追求高颜值和实用功能,SPlayer 绝对值得一试!赶快去体验吧~

项目地址:https://github.com/imsyy/SPlayer

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


深入剖析 Vue 响应式系统:从零实现一个精简版

作者 Slice_cy
2025年9月11日 17:01

Vue 的响应式系统是其核心魅力之一,它能够在你改变数据时,自动更新依赖这些数据的视图。本文将从零开始,带你深入剖析 Vue 响应式系统的核心机制,逐步构建一个精简版的响应式系统。

一、什么是副作用函数(Effect Function)?

在编程世界中,副作用函数(effect function)是指那些会间接或直接改变外部状态的函数。在 Vue 的响应式系统中,它扮演着至关重要的角色,特指那些“依赖于响应式数据,并在数据变化时会自动重新执行”的函数。

举个例子,下面的 effect 函数就是一个典型的副作用函数。它通过读取响应式对象 obj.title 的值,从而改变了外部的 document.body.innerHTML

JavaScript

const obj = {
  title: "hello world",
};

// 这是一个副作用函数,因为它依赖于 obj.title 并改变了外部的 DOM
function effect() {
  document.body.innerHTML = obj.title;
}

// 另一个函数,它的执行结果间接依赖于 effect 的执行
function showResult() {
  console.log(document.body.innerHTML);
}

effect(); // 执行 effect,此时 document.body.innerHTML 被设置为 "hello world"
showResult(); // 输出 "hello world"

二、初步实现响应式系统雏形

为了实现“当数据变化时,依赖于该数据的副作用函数能自动重新执行”的神奇效果,Vue 的响应式系统遵循两大核心步骤:依赖收集依赖触发

  • 依赖收集(Track) :当副作用函数执行时,它会访问响应式数据。此时,系统就像一个“侦探”,默默追踪这个访问行为,并将该副作用函数“记住”下来,作为该数据的依赖
  • 依赖触发(Trigger) :当响应式数据发生变化时,系统会“通知”所有之前收集到的依赖(也就是那些副作用函数),让它们重新执行,从而更新视图或执行其他操作。

下面,我们使用 Proxy 来实现一个简化的响应式系统,模拟这个过程。

const obj = {
  title: "hello world",
};

// 存储所有副作用函数的桶(依赖集合)
const bucket = new Set();

function effect() {
  document.body.innerHTML = obj.title;
}

// 使用 Proxy 创建响应式对象
const proxyObj = new Proxy(obj, {
  // get 拦截器:进行依赖收集
  get(target, key) {
    // 将 effect 函数添加到依赖桶中
    bucket.add(effect);
    return target[key];
  },

  // set 拦截器:进行依赖触发
  set(target, key, value) {
    // 设置新值
    target[key] = value;
    // 遍历依赖桶,执行所有副作用函数
    bucket.forEach((fn) => fn());
  },
});

// 在 1 秒后修改数据,这会触发依赖更新
setTimeout(() => {
  proxyObj.title = "hello vue";
  // 此时,document.body.innerHTML 将自动更新为 "hello vue"
}, 1000);

三、硬编码与不精准触发的优化

在上面的初步实现中,我们遇到了两个明显的问题:

  1. 副作用函数硬编码bucket.add(effect) 这种写法将副作用函数 effect 的名称写死了,这无法灵活处理多个副作用函数。
  2. 不精准的依赖触发set 拦截器会无差别地执行 bucket 中的所有副作用函数,即使修改的属性与它们无关,这会造成不必要的性能开销。

优化后的数据结构

为了解决这些问题,我们需要对存储依赖的数据结构进行升级。我们将用一个多层嵌套的数据结构来存储依赖关系,就像一个精心组织的档案库:

  • WeakMap ( bucket ) :最顶层的结构,它的键是响应式对象target)。使用 WeakMap 是一个聪明的选择,因为它的键是弱引用,当对象没有其他引用时,垃圾回收器会自动清理它,有效防止内存泄漏。
  • Map ( depsMap ) :中层结构,它的键是属性名key),值是一个 Set
  • Set ( deps ) :最底层结构,它存储了所有依赖于该属性的副作用函数。使用 Set 可以确保每个副作用函数只被存储一次,避免重复。

通过这种结构,我们实现了精准的依赖触发。当你只修改 proxyObj.age 时,trigger 函数会因为 depsMap.get('age') 返回 undefined 而直接返回,effect 函数将不会被触发。只有当你修改了 proxyObj.title 时,才会精准地执行与它关联的副作用函数,这解决了之前不精准触发的问题,实现了更高效、健壮的响应式系统。

const bucket = new WeakMap();
const obj = {
  title: "hello world",
};

let activeEffect = null;

function effect(fn) {
  activeEffect = fn;
  fn();
}

const proxyObj = new Proxy(obj, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);
  },
});

// 依赖收集
function track(target, key) {
  // 如果没有正在执行的副作用函数,则直接返回
  if (!activeEffect) return;
  // 获取 depsMap
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 获取 deps 集合
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 将当前 activeEffect 添加到 deps 集合中
  deps.add(activeEffect);
}

// 触发依赖
function trigger(target, key) {
  // 获取 depsMap
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  // 获取 deps 集合
  let deps = depsMap.get(key);
  // 遍历并执行所有副作用函数
  deps && deps.forEach((fn) => fn());
}

effect(() => {
  document.body.innerHTML = proxyObj.title;
  console.log("trigger effect");
});

setTimeout(() => {
  // 修改一个不相关的属性,不会触发 effect
  proxyObj.age = 18;
}, 1000);

四、解决“分支”导致的依赖遗留问题

想象一下这样的场景:

  • 第一次执行proxyObj.checktrueeffect 函数会读取 proxyObj.checkproxyObj.title。此时,proxyObj.checkproxyObj.title 都收集了该 effect 函数作为依赖。
  • 修改数据:当 proxyObj.check 被修改为 false 时,trigger 函数会执行 effect
  • 第二次执行effect 函数再次执行,由于 proxyObj.checkfalse,它现在只访问 proxyObj.check,而不再访问 proxyObj.title。然而,proxyObj.title 的依赖集合中仍然残留着这个 effect 函数。
effect(() => {
  document.body.innerHTML = proxyObj.check ? proxyObj.title : "hahaha";
});

这会导致一个“幽灵依赖”:当 title 改变时,这个本不应再执行的函数却被错误地触发了。

解决方案:先清理,再收集

为了解决这个问题,我们引入一个核心思想:在每次执行副作用函数之前,先将它从所有旧的依赖集合中移除,然后再重新收集新的依赖

为此,我们引入了两个关键机制:

  • effectFn.deps 数组:在 effect 函数内部,我们为每一个副作用函数实例 effectFn 创建一个 deps 数组,用来存储它所关联的所有依赖集合(Set)。这样,我们就能反向追踪该函数都存在于哪些依赖集合中。
  • clean 函数:在副作用函数重新执行前,clean 函数会遍历 effectFn.deps 数组,将该副作用函数从所有它关联的依赖集合中移除,并清空 deps 数组。
function effect(fn) {
  function effectFn() {
    clean(effectFn); // 对所有依赖集合中抹除该副作用函数
    activeEffect = effectFn; // 依赖收集需要通过 activeEffect 拿到副作用函数
    fn(); // 执行函数体
  }
  effectFn.deps = []; // 初始化 deps
  effectFn();
}

function clean(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    deps.delete(effectFn);
  }
  effectFn.deps.length = 0; // delete 不会改变 length,需要手动处理
}

// 依赖收集
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  activeEffect.deps.push(deps); // 将属性值的依赖集合添加到 activeEffect 中,用于反向追踪
}

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  // 新建一个 Set 是为了避免在遍历时因副作用函数执行而导致 Set 改变
  const newSet = new Set(deps);
  newSet && newSet.forEach((fn) => fn());
}

五、处理“嵌套”的 Effect 与 Effect 栈问题

在之前的实现中,我们使用一个全局变量 activeEffect 来存储当前正在执行的副作用函数。当存在 Effect 嵌套(例如,一个组件内部渲染另一个子组件)时,这会导致一个严重的问题:内部的 Effect 可能会覆盖 activeEffect,导致外部的 Effect 无法正确收集到依赖。

// 假设这是外部组件的渲染函数
effect(() => {
  // 假设这是内部组件的渲染函数
  effect(() => {
    document.body.innerHTML = proxyObj.title; // 内部 effect 读取 title
  });
  // 此时,activeEffect 已经被内部 effect 覆盖
  // 如果这里有读取操作,比如 proxyObj.someOtherProp,它将错误地被收集到内部 effect 中
  console.log(proxyObj.check); // 外部 effect 读取 check
});

解决方案:引入 Effect 栈

为了解决这个问题,我们需要一个副作用函数栈 ( effectStack )

  • 执行时:将当前副作用函数压入栈中。
  • 执行后:将其从栈中弹出。
  • activeEffect:始终指向栈顶的副作用函数。

通过这种方式,我们可以保证每一个响应式数据只会收集直接读取它的副作用函数,避免了相互干扰,让依赖关系变得清晰而精准。

const bucket = new WeakMap();
const obj = {
  title: "hello world",
  check: true,
};

let activeEffect = null;
const effectStack = [];

function effect(fn) {
  function effectFn() {
    clean(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  effectFn();
}

六、解决“读写”自身属性导致的栈溢出

如果在一个副作用函数内部,我们同时读取写入同一个响应式属性,就会陷入一个无限递归的死循环:

  1. 读取属性get 拦截器触发 track,将当前副作用函数收集到“桶”中。
  2. 写入属性set 拦截器触发 trigger,从“桶”中取出副作用函数并执行。

问题在于,这个副作用函数正在执行中,但又被 trigger 再次调用,这会导致它无限递归地调用自己,最终引发栈溢出

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      // 避免无限递归调用(读写同一个属性)
      if (fn !== activeEffect) {
        fn();
      }
    });
}

effect(() => {
  proxyObj.title += "2222"; // 读和写发生在同一个副作用函数中
});

为了解决这个问题,在 trigger 函数中,我们增加一个判断:当要执行的副作用函数与当前正在执行的副作用函数是同一个时,就跳过本次执行。


七、可调度性(Scheduler)

可调度性是响应式系统非常重要的特性。它赋予我们决定副作用函数执行时机、次数以及方式的能力。

通过引入调度器scheduler),我们可以将副作用函数的执行权交给用户。例如,我们可以设置在数据变化时,不是立即执行副作用函数,而是将它放入一个任务队列中,等待下一个“tick”再执行,从而实现批量更新,提高性能。

function effect(fn, options = {}) {
  function effectFn() {
    clean(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  }
  effectFn.deps = [];
  effectFn.options = options; // 存储用户传入的 options
  effectFn();
}

// 触发依赖
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      if (fn !== activeEffect) {
        // 如果有调度器,则调用调度器
        if (fn.options.scheduler) {
          fn.options.scheduler(fn);
        } else {
          // 否则直接执行副作用函数
          fn();
        }
      }
    });
}

总结

至此,我们已经构建了一个功能强大、健壮、且支持嵌套的响应式系统雏形。它能够处理复杂的依赖关系,解决循环引用和依赖遗留问题,并通过调度器提供了高度的可控性。以下是完整的代码实现,你可以直接复制运行。

const bucket = new WeakMap();

const obj = {
  title: "hello world",
  check: true,
};

let activeEffect = null;
const effectStack = [];

function effect(fn, options = {}) {
  function effectFn() {
    // 1. 在执行前,先清理旧的依赖
    clean(effectFn);
    // 2. 将当前 effectFn 设置为 activeEffect
    activeEffect = effectFn;
    // 3. 将当前 effectFn 压入栈
    fn();
    // 4. 执行完毕后,将当前 effectFn 弹出栈
    effectStack.pop();
    // 5. 恢复 activeEffect 为栈顶的 effectFn
    activeEffect = effectStack[effectStack.length - 1];
  }
  // 在 effectFn 上添加一个数组,用于反向存储它所在的 deps 集合
  effectFn.deps = [];
  // 存储用户传入的 options
  effectFn.options = options;
  // 首次执行
  effectFn();
}

/**
 * 清理副作用函数的所有依赖
 */
function clean(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i];
    // 从每个依赖集合中移除 effectFn
    deps.delete(effectFn);
  }
  // 清空 effectFn 的依赖数组
  effectFn.deps.length = 0;
}

const proxyObj = new Proxy(obj, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    trigger(target, key);
  },
});

/**
 * 依赖收集
 */
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  deps.add(activeEffect);
  activeEffect.deps.push(deps);
}

/**
 * 触发依赖
 */
function trigger(target, key) {
  let depsMap = bucket.get(target);
  if (!depsMap) return;
  let deps = depsMap.get(key);
  const newSet = new Set(deps);
  newSet &&
    newSet.forEach((fn) => {
      if (fn !== activeEffect) {
        if (fn.options.scheduler) {
          fn.options.scheduler(fn);
        } else {
          fn();
        }
      }
    });
}

// 示例:触发响应式
effect(() => {
  proxyObj.title += "2222";
});

setTimeout(() => {
  console.log(bucket);
}, 1000);

前端项目eslint配置选项详细解析

作者 鹏多多
2025年9月10日 09:38

1. 前言

ESLint 是一款可高度配置的 JavaScript 代码检查工具,其核心价值在于帮助开发者在编码阶段主动发现并修复问题,而非等到运行时才暴露错误。它不仅能检测语法错误,还能规范代码风格、规避潜在逻辑风险,是现代前端 / Node.js 项目中保障代码质量的核心工具之一。

1.1 核心功能

ESLint 的核心能力围绕 “静态分析” 与 “灵活配置” 展开,主要包括:

  • 静态代码分析:无需运行代码,即可扫描出语法错误(如未定义变量)、逻辑漏洞(如条件中使用常量表达式)、风格不一致(如混用单双引号)等问题。
  • 自动修复支持:对格式错误(如缩进、分号)、简单风格问题(如引号转换)等,可通过 eslint --fix 命令自动修复,减少手动调整成本。
  • 规则可配置性:支持启用 / 禁用单个规则、调整规则严格级别,还可继承社区成熟配置(如 eslint:recommended),快速适配项目需求。

1.2 实际价值

在团队协作与工程化流程中,ESLint 的作用尤为突出:

  • 统一代码规范:消除因个人编码习惯差异导致的 “风格冲突”(如缩进用 2 空格还是 4 空格),让代码看起来像 “一个人写的”。
  • 降低错误风险:提前拦截低级错误(如未使用的变量、遗漏的分号),减少线上运行时故障。
  • 提升可维护性:规范的代码结构更易阅读、修改,降低新成员接入项目的学习成本。
  • 集成工程化流程:可嵌入 CI/CD 流水线(如 Jenkins、GitHub Actions),实现 “代码提交即检查”,阻止不规范代码合并入主分支。

2、错误级别

ESLint 为每条规则定义了 3 个严格级别,通过 “代号” 或 “别称” 配置,不同级别对应不同的检查行为,具体说明如下:

代号 别称 含义 描述
0 'off' 忽略 关闭规则
1 'warn' 警告 打开规则,并且将规则视为一个警告,检查通过
2 'error' 错误 打开规则,并且将规则视为一个错误 检查不通过,退出码为 1

注意:实际配置中,“代号” 与 “别称” 效果一致,可按需选择。例如 no-console: 0 与 no-console: 'off' 等价。

3、常用规则

ESLint 内置数百条规则,按功能可分为 “逻辑错误”“最佳实践”“变量声明” 等类别。以下整理项目中高频使用的规则,每条规则均标注 “作用”,帮助快速理解其价值。

规则详情可参考:ESLint 官方规则文档(比第三方链接更权威、更新及时)

3.1 逻辑错误类(规避代码运行风险)

此类规则用于拦截可能导致运行时错误或逻辑异常的代码,是保障代码 “正确性” 的核心:

    no-cond-assign          // 禁止条件表达式中出现模棱两可的赋值操作符 
    no-console              // 禁用console 
    no-constant-condition   // 禁止在条件中使用常量表达式 
    no-debugger             // 禁用 debugger 
    no-dupe-args            // 禁止 function 定义中出现重名参数 
    no-dupe-keys            // 禁止对象字面量中出现重复的 key 
    no-duplicate-case       // 禁止出现重复的 case 标签 
    no-empty                // 禁止出现空语句块 
    no-ex-assign            // 禁止对 catch 子句的参数重新赋值 
    no-extra-boolean-cast   // 禁止不必要的布尔转换 
    no-extra-parens         // 禁止不必要的括号 
    no-extra-semi           // 禁止不必要的分号 
    no-func-assign          // 禁止对 function 声明重新赋值 
    no-inner-declarations   // 禁止在嵌套的块中出现变量声明或 function 声明 
    no-irregular-whitespace // 禁止在字符串和注释之外不规则的空白 
    no-obj-calls            // 禁止把全局对象作为函数调用 
    no-sparse-arrays        // 禁用稀疏数组 
    no-prototype-builtins   // 禁止直接使用Object.prototypes 的内置属性 
    no-unexpected-multiline // 禁止出现令人困惑的多行表达式 
    no-unreachable          // 禁止在return、throw、continue 和 break语句之后出现不可达代码 
    use-isnan               // 要求使用 isNaN() 检查 NaN 
    valid-typeof            // 强制 typeof 表达式与有效的字符串进行比较

3.2 最佳实践类(提升代码健壮性与可维护性)

此类规则不直接阻断运行,但遵循可减少潜在问题、提升代码可读性:

    array-callback-return   // 强制数组方法的回调函数中有 return 语句 
    block-scoped-var        // 强制把变量的使用限制在其定义的作用域范围内 
    complexity              // 指定程序中允许的最大环路复杂度 
    consistent-return       // 要求 return 语句要么总是指定返回的值,要么不指定 
    curly                   // 强制所有控制语句使用一致的括号风格 
    default-case            // 要求 switch 语句中有 default 分支 
    dot-location            // 强制在点号之前和之后一致的换行 
    dot-notation            // 强制在任何允许的时候使用点号 
    eqeqeq                  // 要求使用 === 和 !== 
    guard-for-in            // 要求 for-in 循环中有一个 if 语句 
    no-alert                // 禁用 alert、confirm 和 prompt 
    no-case-declarations    // 不允许在 case 子句中使用词法声明 
    no-else-return          // 禁止 if 语句中有 return 之后有 else 
    no-empty-function       // 禁止出现空函数 
    no-eq-null              // 禁止在没有类型检查操作符的情况下与 null 进行比较 
    no-eval                 // 禁用 eval() 
    no-extra-bind           // 禁止不必要的 .bind() 调用 
    no-fallthrough          // 禁止 case 语句落空 
    no-floating-decimal     // 禁止数字字面量中使用前导和末尾小数点 
    no-implicit-coercion    // 禁止使用短符号进行类型转换 
    no-implicit-globals     // 禁止在全局范围内使用 var 和命名的 function 声明 
    no-invalid-this         // 禁止 this 关键字出现在类和类对象之外 
    no-lone-blocks          // 禁用不必要的嵌套块 
    no-loop-func            // 禁止在循环中出现 function 声明和表达式 
    no-magic-numbers        // 禁用魔术数字 
    no-multi-spaces         // 禁止使用多个空格 
    no-multi-str            // 禁止使用多行字符串 
    no-new                  // 禁止在非赋值或条件语句中使用 new 操作符 
    no-new-func             // 禁止对 Function 对象使用 new 操作符 
    no-new-wrappers         // 禁止对 String,Number 和 Boolean 使用 new 操作符 
    no-param-reassign       // 不允许对 function 的参数进行重新赋值 
    no-redeclare            // 禁止使用 var 多次声明同一变量 
    no-return-assign        // 禁止在 return 语句中使用赋值语句 
    no-script-url           // 禁止使用 javascript: url 
    no-self-assign          // 禁止自我赋值 
    no-self-compare         // 禁止自身比较 
    no-sequences            // 禁用逗号操作符 
    no-unmodified-loop-condition   // 禁用一成不变的循环条件 
    no-unused-expressions   // 禁止出现未使用过的表达式 
    no-useless-call         // 禁止不必要的 .call() 和 .apply() 
    no-useless-concat       // 禁止不必要的字符串字面量或模板字面量的连接 
    vars-on-top             // 要求所有的 var 声明出现在它们所在的作用域顶部

3.3 变量声明类(规范变量定义与使用)

此类规则聚焦变量的声明、初始化与作用域,减少变量污染与未定义错误:

    init-declarations     // 要求或禁止 var 声明中的初始化 
    no-catch-shadow       // 不允许 catch 子句的参数与外层作用域中的变量同名 
    no-restricted-globals // 禁用特定的全局变量 
    no-shadow             // 禁止 var 声明 与外层作用域的变量同名 
    no-undef              // 禁用未声明的变量,除非它们在 /global / 注释中被提到 
    no-undef-init         // 禁止将变量初始化为 undefined 
    no-unused-vars        // 禁止出现未使用过的变量 
    no-use-before-define  // 不允许在变量定义之前使用它们

3.4 CommonJS 模块类(规范 Node.js 模块写法)

此类规则针对 Node.js 环境的 CommonJS 模块(require/module.exports),确保模块代码规范:

    global-require        // 要求 require() 出现在顶层模块作用域中 
    handle-callback-err   // 要求回调函数中有容错处理 
    no-mixed-requires     // 禁止混合常规 var 声明和 require 调用 
    no-new-require        // 禁止调用 require 时使用 new 操作符 
    no-path-concat        // 禁止对 dirname 和 filename进行字符串连接 
    no-restricted-modules // 禁用指定的通过 require 加载的模块

3.5 风格指南类(统一代码格式,提升可读性)

此类规则纯 “风格层面”,不影响代码功能,但统一后可减少团队协作中的 “格式争议”:

   array-bracket-spacing           // 强制数组方括号中使用一致的空格 
   block-spacing                   // 强制在单行代码块中使用一致的空格 
   brace-style                     // 强制在代码块中使用一致的大括号风格 
   camelcase                       // 强制使用骆驼拼写法命名约定 
   comma-spacing                   // 强制在逗号前后使用一致的空格 
   comma-style                     // 强制使用一致的逗号风格 
   computed-property-spacing       // 强制在计算的属性的方括号中使用一致的空格 
   eol-last                        // 强制文件末尾至少保留一行空行 
   func-names                      // 强制使用命名的 function 表达式 
   func-style                      // 强制一致地使用函数声明或函数表达式 
   indent                          // 强制使用一致的缩进 
   jsx-quotes                      // 强制在 JSX 属性中一致地使用双引号或单引号 
   key-spacing                     // 强制在对象字面量的属性中键和值之间使用一致的间距 
   keyword-spacing                 // 强制在关键字前后使用空格,比如if else 
   linebreak-style                 // 强制使用一致的换行风格 
   lines-around-comment            // 要求在注释周围有空行 
   max-depth                       // 强制可嵌套的块的最大深度 
   max-len                         // 强制一行的最大长度 
   max-lines                       // 强制最大行数 
   max-nested-callbacks            // 强制回调函数最大嵌套深度 
   max-params                      // 强制 function 定义中最多允许的参数数量 
   max-statements                  // 强制 function 块最多允许的的语句数量 
   max-statements-per-line         // 强制每一行中所允许的最大语句数量 
   new-cap                         // 要求构造函数首字母大写 
   new-parens                      // 要求调用无参构造函数时有圆括号 
   newline-after-var               // 要求或禁止 var 声明语句后有一行空行 
   newline-before-return           // 要求 return 语句之前有一空行 
   newline-per-chained-call        // 要求方法链中每个调用都有一个换行符 
   no-array-constructor            // 禁止使用 Array 构造函数 
   no-continue                     // 禁用 continue 语句 
   no-inline-comments              // 禁止在代码行后使用内联注释 
   no-lonely-if                    // 禁止 if 作为唯一的语句出现在 else 语句中 
   no-mixed-spaces-and-tabs        // 不允许空格和 tab 混合缩进 
   no-multiple-empty-lines         // 不允许多个空行 
   no-negated-condition            // 不允许否定的表达式 
   no-plusplus                     // 禁止使用一元操作符 ++ 和 – 
   no-spaced-func                  // 禁止 function 标识符和括号之间出现空格 
   no-trailing-spaces              // 禁用行尾空格 
   no-whitespace-before-property   // 禁止属性前有空白 
   object-curly-newline            // 强制花括号内换行符的一致性 
   object-curly-spacing            // 强制在花括号中使用一致的空格 
   object-property-newline         // 强制将对象的属性放在不同的行上 
   one-var                         // 强制函数中的变量要么一起声明要么分开声明 
   one-var-declaration-per-line    // 要求或禁止在 var 声明周围换行 
   operator-assignment             // 要求或禁止在可能的情况下要求使用简化的赋值操作符 
   operator-linebreak              // 强制操作符使用一致的换行符 
   quote-props                     // 要求对象字面量属性名称用引号括起来 
   quotes                          // 强制使用一致的反勾号、双引号或单引号 
   require-jsdoc                   // 要求使用 JSDoc 注释 
   semi                            // 要求或禁止使用分号而不是 ASI 
   semi-spacing                    // 强制分号之前和之后使用一致的空格 
   sort-vars                       // 要求同一个声明块中的变量按顺序排列 
   space-before-blocks             // 强制在块之前使用一致的空格 
   space-before-function-paren     // 强制在 function的左括号之前使用一致的空格 
   space-in-parens                 // 强制在圆括号内使用一致的空格 
   space-infix-ops                 // 要求操作符周围有空格 
   space-unary-ops                 // 强制在一元操作符前后使用一致的空格 
   spaced-comment                  // 强制在注释中 // 或 /* 使用一致的空格
  • typeScript
@typescript-eslint/no-non-null-assertion // 是否禁止非空断言!

4. 项目实战:.eslintrc.js 配置解析

以下是适配 Vue 3 + TypeScript 项目的 ESLint 配置文件(/.eslintrc.js),结合项目实际需求调整规则,注释中已说明关键配置的目的与逻辑:

module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
globals: {
TAny: true,
TDict: true,
TFunc: true,
TDialogButtonOption: true,
THttpResponse: true,
NodeJS: 'readonly',
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly'
},
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
sourceType: 'module'
},
extends: ['plugin:vue/vue3-essential', 'plugin:vue/essential', 'eslint:recommended'],
plugins: ['vue', '@typescript-eslint'],
rules: {
'@type-eslint/ban-ts-ignore': 'off', // 配置禁用 @ts-ignore 注释
'@type-eslint/explicit-function-return-type': 'off', // 要求函数和类方法上显式返回类型
'@type-eslint/no-explicit-any': 'off', // 不允许any类型
'@typescript-eslint/no-explicit-any': 'warn', // any不能乱用
'@type-eslint/no-var-requires': 'off', // 禁止require语句,import语句除外
'@type-eslint/no-empty-function': 'off', // 禁止空函数
'@type-eslint/no-use-before-define': 'off', // 禁止在定义变量之前使用变量
'@type-eslint/ban-ts-comment': 'off', // 禁止@ts-<指令>注释或要求指令后面有描述。
'@type-eslint/ban-types': 'off', // 禁止某些类型
'@type-eslint/no-non-null-assertion': 'off', // 不允许使用非空断言!后缀运算符
'@type-eslint/explicit-module-boundary-types': 'off', // 对导出函数和类的公共类方法要求显式的返回和参数类型
'vue/no-v-for-template-key': 0, // 不允许template上有key
semi: ['error', 'never'], // 使用分号
'comma-dangle': [
// 语句后面是否使用逗号
'error',
{
arrays: 'never',
objects: 'never',
imports: 'never',
exports: 'never',
functions: 'never'
}
],
'vue/custom-event-name-casing': 'off', // 为自定义事件名强制执行特定的大小写
'vue/attributes-order': 'off', // 强制属性的顺序
'vue/one-component-per-file': 'off', // 强制每个组件应该在它自己的文件中
'vue/html-closing-bracket-newline': 'off', // 要求或禁止在标记的右括号前换行
'vue/max-attributes-per-line': 'off', // 强制规定每行的最大属性数
'vue/multiline-html-element-content-newline': 'off', // 要求在多行元素的内容之前和之后使用换行符
'vue/singleline-html-element-content-newline': 'off', // 要求在单行元素的内容前后使用换行符
'vue/attribute-hyphenation': 'off', // 对模板中的自定义组件强制实施属性命名样式
'vue/html-self-closing': 'off', // 强制实施自动关闭样式
'vue/no-multiple-template-root': 'off', // template中只允许模板里存在一个根节点
'vue/require-default-prop': 'off', // props需要默认值
'vue/no-v-model-argument': 'off', // 检查自定义组件上是否没有参数
'vue/no-arrow-functions-in-watch': 'off', // 禁止使用箭头函数定义watch
'vue/no-template-key': 'off', // 不允许template上有key
'vue/no-v-html': 'off', // 禁止使用 V-HTML 来防止 XSS 攻击
'vue/comment-directive': 'off', // 支持注释指令
'vue/no-parsing-error': 'off', // 报告语法错误
'vue/no-deprecated-v-on-native-modifier': 'off', // 弃用修饰符 ondirective @xxx.native
'vue/multi-word-component-names': 'off', // 组件名称始终是多字的
'no-useless-escape': 'off', // 禁用不必要的转义
'no-sparse-arrays': 'off', // 禁用稀疏数组
'no-prototype-builtins': 'off', // 禁止直接使用Object.prototypes 的内置属性
'no-constant-condition': 'off', // 禁止在条件中使用常量表达式
'no-use-before-define': 'off', // 不允许在变量定义之前使用它们
'no-restricted-globals': 'off', // 禁用特定的全局变量
'no-restricted-syntax': 'off', // 禁止使用特定的语法
'generator-star-spacing': 'off', // 强制 generator 函数中 * 号周围有空格
'no-unreachable': 'off', // 禁止在return、throw、continue 和 break语句之后出现不可达代码
'no-unused-vars': ['error', { varsIgnorePattern: '.*', args: 'none' }], // 禁止出现未使用过的变量
'no-case-declarations': 'off', // 不允许在 case 子句中使用词法声明
'no-console': 'off', // 禁用console
'arrow-parens': 'off', // 箭头函数一个参数可以不要括号
'no-eq-null': 2, // 禁止对null使用==或!=运算符
quotes: [1, 'single'], // 引号类型
'prefer-const': 0, // 首选const
eqeqeq: 2, // 必须使用全等
'default-case': 2, // switch语句最后必须有default
'no-var': 0, // 禁用var,用let和const代替
'no-trailing-spaces': 1 // 一行结束后面不要有空格
}
}

本次分享就到这儿啦,我是鹏多多,如果看了觉得有帮助的,欢迎 点赞 关注 评论,在此谢过道友;

往期文章

前端面试高频题:18个经典技术难点深度解析与解决方案

作者 张海潮
2025年9月11日 15:56

前言

作为前端开发者,你是否经历过面试时被问及某个技术难点,却只能回答表面用法而无法深入原理的场景?比如面试官问"如何在项目中解决接口未就绪的开发问题",你只能回答"用Mock数据",却无法展开具体实施方案;或者讨论性能优化时,只知代码分割而不知如何权衡与实现。

本文将系统梳理前端面试中最常被问及的18个经典技术难点,结合面试官视角考察点,提供从问题本质到解决方案的完整思路。通过学习这些内容,你不仅能掌握应对面试的技巧,还能在实际项目中从容面对各类技术挑战。

核心概念

要深入理解这18个技术难点,我们需要先明确几个核心概念:

技术选型思维:在面对具体技术挑战时,不能只停留在"能实现"的层面,而应思考"为什么选择这个技术"、"它解决了什么核心问题"、"与其他方案相比优劣如何"

系统化思考能力:前端问题往往不是孤立的,需要从开发到部署的全链路视角去看待,理解问题各环节的关联性

工程化思维:针对每个问题,解决方案需符合可维护性、可扩展性、可复用性等工程原则

权衡取舍意识:技术方案没有绝对完美,需要在性能、开发效率、兼容性等多方面找到平衡点

一、开发阶段技术难点

1. 接口联调问题

问题描述: 前后端并行开发时,接口未就绪导致前端开发受阻

解决方案:

  • Mock 数据方案: 使用 Mock.js、MSW、json-server 或 Apifox
  • 接口约定规范: 提前确定 OpenAPI/Swagger 文档
  • 代理配置: webpack-dev-server 或 Vite 的 proxy 配置
// vite.config.js 代理配置
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

2. 浏览器兼容性问题

问题描述: 用户使用低版本浏览器导致页面异常或功能失效

解决方案:

  • Polyfill 处理: 使用 Babel、core-js 自动添加兼容代码
  • graceful degradation: 渐进增强,优雅降级
  • 用户提示: 检测浏览器版本并提供升级建议
// Browserslist 配置
// .browserslistrc
> 1%
last 2 versions
not dead
not ie 11

3. 项目环境管理

问题描述: 不同项目需要不同 Node.js 版本,老项目无法启动

解决方案:

  • 版本管理: 使用 nvm、fnm 管理 Node.js 版本
  • 项目配置: .nvmrc 文件指定版本
  • 依赖更新: node-sass 替换为 dart-sass
# .nvmrc 文件
16.14.0

# 项目启动前
nvm use
npm install

二、部署上线技术难点

4. SPA 路由刷新问题

问题描述: History 模式下直接访问子路由或刷新页面出现 404

解决方案:

  • 服务器配置: Nginx/Apache 配置重定向规则
  • 备用方案: Hash 路由模式
# Nginx 配置
location / {
  try_files $uri $uri/ /index.html;
}

5. 跨域问题

问题描述: 生产环境出现跨域限制,开发环境正常

解决方案:

  • 反向代理: Nginx 代理后端接口
  • CORS 配置: 后端设置正确的 CORS headers
  • 同域部署: 前后端部署在同一域名下
# Nginx 反向代理
location /api/ {
  proxy_pass http://backend-server/;
  add_header Access-Control-Allow-Origin *;
}

6. 线上问题排查

问题描述: 生产环境问题难以定位和调试

解决方案:

  • 错误监控: 集成 Sentry、Bugsnag 等服务
  • Source Map: 保留映射文件用于错误定位
  • 日志系统: 前端埋点和日志上报
  • 性能监控: Core Web Vitals 监控
// 错误捕获
window.addEventListener('error', (event) => {
  // 上报错误信息
  fetch('/api/error', {
    method: 'POST',
    body: JSON.stringify({
      message: event.error.message,
      stack: event.error.stack,
      url: window.location.href
    })
  });
});

三、团队协作技术难点

7. Git 版本管理

问题描述: 多人开发代码冲突,分支管理混乱

解决方案:

  • 分支策略: Git Flow 或 GitHub Flow
  • 代码规范: ESLint + Prettier + husky
  • 提交规范: Conventional Commits
  • 冲突处理: git stash、rebase 等技巧
# Git Flow 常用操作
git stash push -m "临时保存"
git pull origin develop
git stash pop

# 解决冲突后提交
git add .
git commit -m "fix: 解决合并冲突"

8. 文档处理

问题描述: Word、PDF、Excel 文件的预览、导入、导出

解决方案:

  • PDF: pdf.js、react-pdf 实现预览
  • Word: mammoth.js 转换为 HTML
  • Excel: SheetJS 处理读写操作
  • 在线预览: 集成第三方文档服务
// Excel 处理示例
import * as XLSX from 'xlsx';

// 读取 Excel 文件
function readExcel(file) {
  const reader = new FileReader();
  reader.onload = (e) => {
    const workbook = XLSX.read(e.target.result, { type: 'binary' });
    const sheet = workbook.Sheets[workbook.SheetNames[0]];
    const data = XLSX.utils.sheet_to_json(sheet);
    console.log(data);
  };
  reader.readAsBinaryString(file);
}

// 导出 Excel 文件
function exportExcel(data) {
  const worksheet = XLSX.utils.json_to_sheet(data);
  const workbook = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
  XLSX.writeFile(workbook, 'export.xlsx');
}

四、架构设计技术难点

9. 微前端架构

问题描述: 大型应用拆分,多团队协作,技术栈统一

解决方案:

  • 技术选型: qiankun、single-spa、Module Federation
  • 应用隔离: 样式隔离、JavaScript 沙箱
  • 通信机制: 全局状态管理、事件总线
  • 部署策略: 独立部署、版本管理
// qiankun 主应用配置
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'vue-app',
    entry: '//localhost:8080',
    container: '#vue-container',
    activeRule: '/vue',
    props: { data: { token: localStorage.getItem('token') } }
  },
  {
    name: 'react-app',
    entry: '//localhost:3000',
    container: '#react-container',
    activeRule: '/react'
  }
]);

start({
  sandbox: { strictStyleIsolation: true }
});

10. 组件库设计

问题描述: 多项目组件复用,设计系统统一

解决方案:

  • 设计规范: 统一的设计语言和组件规范
  • 技术架构: Monorepo 管理,独立发布
  • 文档建设: Storybook、dumi 组件文档
  • 测试保障: 单元测试、视觉回归测试
// 组件库项目结构
packages/
├── components/          # 组件源码
├── icons/              # 图标库
├── themes/             # 主题包
├── utils/              # 工具函数
└── docs/               # 文档网站

// lerna.json 配置
{
  "version": "independent",
  "packages": ["packages/*"],
  "command": {
    "publish": {
      "conventionalCommits": true
    }
  }
}

五、性能优化技术难点

11. 首屏加载优化

问题描述: 应用初始加载时间长,用户体验差

解决方案:

  • 代码分割: 路由懒加载、组件动态导入
  • 资源优化: 图片压缩、WebP 格式、CDN 加速
  • 预加载: preload、prefetch 关键资源
  • 缓存策略: HTTP 缓存、Service Worker
// 路由懒加载
const routes = [
  {
    path: '/home',
    component: () => import('./views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('./views/About.vue')
  }
];

// 资源预加载
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch" href="/js/secondary.js">

12. 大数据量处理

问题描述: 长列表、大表格渲染卡顿,内存占用高

解决方案:

  • 虚拟滚动: react-window、vue-virtual-scroll-list
  • 分页策略: 无限滚动、分批加载
  • Web Worker: 大数据计算异步处理
  • Canvas 渲染: 超大数据量使用 Canvas
// React 虚拟滚动
import { FixedSizeList as List } from 'react-window';

const VirtualList = ({ items }) => (
  <List
    height={400}
    itemCount={items.length}
    itemSize={50}
    itemData={items}
  >
    {({ index, style, data }) => (
      <div style={style}>
        {data[index].name}
      </div>
    )}
  </List>
);

六、移动端技术难点

13. 移动端适配

问题描述: 多设备尺寸适配,高清屏显示

解决方案:

  • 响应式设计: rem、vw/vh、媒体查询
  • 高清适配: 2x/3x 图片、1px 边框问题
  • 交互优化: 300ms 点击延迟、触摸反馈
  • 性能优化: 懒加载、图片压缩
/* rem 适配方案 */
html {
  font-size: calc(100vw / 375 * 16);
}

/* 1px 边框解决方案 */
.border-1px {
  position: relative;
}

.border-1px::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 1px;
  background: #e5e5e5;
  transform: scaleY(0.5);
  transform-origin: 0 0;
}

14. 跨端开发

问题描述: H5、小程序、App 多端开发维护成本高

解决方案:

  • 跨端框架: Taro、uni-app、React Native
  • 条件编译: 平台差异化处理
  • API 统一: 统一的接口封装层
  • 组件抽象: 跨平台组件设计
// Taro 跨端开发
import Taro from '@tarojs/taro'

// 条件编译
// #ifdef H5
const request = axios.create({})
// #endif

// #ifdef MP-WEIXIN
const request = {
  get: (url, config) => Taro.request({ url, ...config })
}
// #endif

// 平台判断
if (process.env.TARO_ENV === 'weapp') {
  // 微信小程序特有逻辑
} else if (process.env.TARO_ENV === 'h5') {
  // H5 特有逻辑
}

七、前沿技术难点

15. PWA 应用

问题描述: Web 应用需要原生应用体验,离线访问能力

解决方案:

  • Service Worker: 缓存管理、离线访问
  • Web App Manifest: 应用安装、启动配置
  • Push API: 消息推送功能
  • Background Sync: 后台数据同步
// Service Worker 注册
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => {
      console.log('SW registered:', registration);
    });
}

// sw.js 缓存策略
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/')) {
    // 网络优先策略
    event.respondWith(
      fetch(event.request)
        .catch(() => caches.match(event.request))
    );
  } else {
    // 缓存优先策略
    event.respondWith(
      caches.match(event.request)
        .then(response => response || fetch(event.request))
    );
  }
});

16. WebAssembly 应用

问题描述: JavaScript 性能不足,需要高性能计算

解决方案:

  • 编译工具: Emscripten 将 C/C++ 编译为 WASM
  • Rust 支持: wasm-pack 工具链
  • 性能优化: 流式编译、模块缓存
  • 数据交换: JS 与 WASM 的数据传递
// WASM 模块加载和使用
async function loadWasm() {
  const wasmModule = await WebAssembly.instantiateStreaming(
    fetch('/math.wasm')
  );
  
  const { add, multiply } = wasmModule.instance.exports;
  
  // 使用 WASM 函数
  console.log(add(10, 20)); // 30
  console.log(multiply(5, 6)); // 30
}

八、工程化技术难点

17. 构建优化

问题描述: 项目构建时间长,打包体积大

解决方案:

  • 并行构建: thread-loader、parallel-webpack
  • 增量构建: webpack 5 持久化缓存
  • Tree Shaking: 移除未使用代码
  • 代码分割: 按需加载,减少首屏体积
// webpack 优化配置
module.exports = {
  // 持久化缓存
  cache: {
    type: 'filesystem',
    cacheDirectory: path.resolve(__dirname, '.webpack_cache')
  },
  
  // 代码分割
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  },
  
  // 并行处理
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          'thread-loader',
          'babel-loader'
        ]
      }
    ]
  }
};

18. 监控体系

问题描述: 线上问题发现滞后,性能问题定位困难

解决方案:

  • 错误监控: 异常捕获、错误上报、告警通知
  • 性能监控: Core Web Vitals、用户体验指标
  • 业务监控: 关键路径埋点、转化率统计
  • 日志分析: ELK 栈、日志可视化
// 性能监控实现
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  fetch('/analytics', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      id: metric.id,
      url: window.location.href,
      timestamp: Date.now()
    })
  });
}

// 监控 Core Web Vitals
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);

面试回答策略

不同经验层级重点

初级前端 (0-2年):

  • 重点准备:问题 1-6(基础开发问题)
  • 展示:解决问题的思路和学习能力
  • 深入 2-3 个问题,能说出具体实施过程

中级前端 (2-4年):

  • 重点准备:问题 1-12(开发 + 架构基础)
  • 展示:技术选型能力和系统性思考
  • 能对比不同方案的优缺点

高级前端 (4年以上):

  • 重点准备:问题 9-18(架构 + 工程化)
  • 展示:技术领导力和团队影响力
  • 能从业务角度分析技术决策

回答模板

STAR 法则:

  • Situation(情况): 项目背景和遇到的问题
  • Task(任务): 需要解决的具体任务
  • Action(行动): 采取的解决方案和实施步骤
  • Result(结果): 最终效果和收获总结

示例回答: "在我们的电商项目中,用户反馈首页加载很慢,影响了转化率。通过分析发现主要是 JavaScript 包体积过大导致的。我采用了路由懒加载和第三方库按需引入的方案,将原来 2MB 的 bundle 拆分成多个小包,首屏 JS 体积降低到 500KB,加载时间从 8 秒降低到 3 秒,用户留存率提升了 15%。这次优化让我深刻理解了性能优化的重要性。"

在 Web 前端实现流式 TTS 播放

作者 晚星star
2025年9月11日 15:37

🧠 在 Web 前端实现流式 TTS 播放:从卡顿杂音到丝滑顺畅的演进之路

在做前端实时语音合成(TTS)时,很多人都会遇到同样的问题:

  • 播放出来的语音一顿一顿的,很卡顿
  • 声音中夹杂“咔嗒”声、杂音、断裂
  • 明明音频格式是 MP3,也无法做到“接收到就播放”

本文将带你走一遍真实的排坑过程,最终用一种优雅的方式在浏览器中实现 低延迟、不卡顿、无杂音 的流式 TTS 播放。


💥 问题的起点:AudioBufferSourceNode 方案

一开始我们采用最直观的方式:

  1. 后端流式返回 Base64 MP3 块
  2. 前端每收到一块:
    • Base64 → ArrayBuffer
    • decodeAudioData() 解码成 PCM
    • AudioBufferSourceNode 播放

听起来没什么问题,但结果是:

  • 频繁卡顿:每次解码都要等主线程空闲,播放中途就被打断
  • 杂音爆音:每块是独立的 AudioNode,时间轴无法无缝拼接
  • 延迟明显:必须解码完成才能播,没法“边下边播”

这是绝大多数开发者第一次尝试流式 TTS 时会踩的坑。


🚀 真正流畅的做法:MediaSource + SourceBuffer

后来我们换成浏览器原生支持的 MediaSource Extensions (MSE) 技术:

  • 创建 MediaSource 作为音频流容器
  • mediaSource.addSourceBuffer('audio/mpeg') 声明要接收 MP3 流
  • 每收到一块 Base64 MP3:
    • 转为 ArrayBuffer
    • sourceBuffer.appendBuffer(buffer) 追加到播放流
  • 浏览器底层会自动解码 + 缓冲 + 拼接播放

结果立刻变得丝滑:

✅ 接收即播,低延迟
✅ 无缝拼接,无杂音
✅ 不再卡顿,性能极佳
✅ 兼容所有现代浏览器(Chrome / Edge / Firefox / Safari)


🧩 最终实现:StreamingTTSPlayer

下面是一份可直接使用的封装类,只需传入 Base64 MP3 数据块,即可实现流式播放:

/**
 * StreamingTTSPlayer.ts
 * 
 * 一个用于播放「流式 Base64 MP3」音频的播放器。
 * 使用 MediaSource + SourceBuffer 实现边接收边播放,不卡顿无杂音。
 */

export interface StreamingTTSPlayerOptions {
  /** 用于监听播放器状态(ready、error 等)的回调 */
  onEvent?: (event: string, data?: any) => void;
}

export class StreamingTTSPlayer {
  private audio: HTMLAudioElement;           // 播放用的 <audio> 元素
  private mediaSource: MediaSource;           // 媒体源(支持流式拼接)
  private sourceBuffer: SourceBuffer | null = null; // 用于接收音频块的缓冲区
  private queue: ArrayBuffer[] = [];          // 等待写入 SourceBuffer 的音频块队列
  private isBufferUpdating = false;            // 是否正在写入数据(避免并发)
  private onEvent?: (event: string, data?: any) => void; // 事件回调

  constructor(options?: StreamingTTSPlayerOptions) {
    this.onEvent = options?.onEvent;

    // 1. 创建 HTMLAudioElement
    this.audio = new Audio();

    // 2. 创建 MediaSource 并挂载到 audio 元素
    this.mediaSource = new MediaSource();
    this.audio.src = URL.createObjectURL(this.mediaSource);

    // 3. 等待 mediaSource 初始化完成
    this.mediaSource.addEventListener("sourceopen", () => {
      try {
        // 4. 创建一个 MP3 类型的 SourceBuffer,用于接收音频块
        this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');

        // 5. 设置拼接模式为 sequence(自动按顺序拼接)
        this.sourceBuffer.mode = 'sequence';

        // 6. 每次 appendBuffer 完成后触发 updateend,继续处理队列
        this.sourceBuffer.addEventListener('updateend', () => this.feedQueue());

        this.emit("ready");
      } catch (err) {
        console.error("Failed to add sourceBuffer:", err);
        this.emit("error", err);
      }
    });

    // 监听 audio 元素播放错误
    this.audio.addEventListener("error", (e) => {
      this.emit("error", e);
    });
  }

  /**
   * 接收一段 base64 MP3 数据块并放入播放队列
   * @param base64 base64 编码的 MP3 数据块
   * @param autoPlay 是否自动开始播放(默认 true)
   */
  receiveBase64(base64: string, autoPlay = true) {
    try {
      const buffer = this.base64ToArrayBuffer(base64);
      this.queue.push(buffer);
      this.feedQueue(); // 立即尝试送入 SourceBuffer
      if (autoPlay) this.play();
    } catch (err) {
      console.error("TTS decode error:", err);
      this.emit("error", err);
    }
  }

  /** 播放(如果已暂停) */
  play() {
    if (this.audio.paused) {
      this.audio.play().catch(() => {});
    }
  }

  /** 暂停播放 */
  pause() {
    if (!this.audio.paused) {
      this.audio.pause();
    }
  }

  /**
   * 停止播放并清空缓冲
   * (会丢弃所有未播放的数据)
   */
  stop() {
    this.pause();
    this.queue = [];
    if (this.mediaSource.readyState === "open" && this.sourceBuffer && !this.sourceBuffer.updating) {
      try {
        this.sourceBuffer.abort(); // 终止当前的缓冲区写入
      } catch {}
    }
    this.audio.currentTime = 0;
  }

  /**
   * 内部方法:尝试把队列中的数据 append 到 SourceBuffer
   */
  private feedQueue() {
    // 没有 SourceBuffer 或正在写入时不处理
    if (!this.sourceBuffer || this.isBufferUpdating) return;
    if (this.queue.length === 0) return;

    if (!this.sourceBuffer.updating) {
      const chunk = this.queue.shift()!;
      try {
        this.isBufferUpdating = true;
        this.sourceBuffer.appendBuffer(chunk); // 核心:追加 MP3 数据到播放流
        this.isBufferUpdating = false;
      } catch (err) {
        console.error("Failed to append buffer:", err);
        this.emit("error", err);
      }
    }
  }

  /**
   * Base64 -> ArrayBuffer 转换工具
   */
  private base64ToArrayBuffer(base64: string): ArrayBuffer {
    const binary = atob(base64.replace(/^data:audio\/\w+;base64,/, ""));
    const len = binary.length;
    const buffer = new Uint8Array(len);
    for (let i = 0; i < len; i++) {
      buffer[i] = binary.charCodeAt(i);
    }
    return buffer.buffer;
  }

  /** 触发事件回调 */
  private emit(event: string, data?: any) {
    this.onEvent?.(event, data);
  }
}

使用

const player = new StreamingTTSPlayer();

// 每收到一块 TTS 音频数据就塞进去
ws.onmessage = (e) => {
  const data = JSON.parse(e.data);
  if (data.audio) player.receiveBase64(data.audio);
};

❌
❌