阅读视图

发现新文章,点击刷新页面。

工作中的Ai工具汇总

背景

生活在AI的今天,coding可选择提效的大模型有很多,用对了事半功倍,下面分场景介绍下目前工作中的提效工具

vscode插件形式

GitHub Copilot

image.png

image.png

类别 具体内容
优点 1. 代码生成能力强:2. 多语言支持:覆盖 100 多种编程语言3. IDE 集成完善:支持 VS Code、等主流开发工具,无缝融入开发环境4. 提高开发效率:减少重复性代码编写,节省时间,提升编码速度
缺点 1. 存在隐私风险:2.需要翻墙 3.免费版本效率慢且无法使用calude模型

Lingma - Alibaba Cloud AI Coding Assistant

image.png

类别 具体内容
优点 1. 对中文支持更优:针对中文注释、中文语境的理解更精准,符合国内内开发者使用习惯 2. 本地化服务:无需翻墙即可使用,3.提供免费版本:基础功能免费
缺点 1.训练数据中开源代码占比相对较少,对部分国际主流框架的支持精度略逊 2.高级功能需订阅付费,

除此之外 你可以使用 百度等推出的vscode插件,对于问答形式,agent模型均较好的支持

cursor trae 等AI编辑器

image.png

Cursor是一款专注于 AI 辅助编程的编辑器,优势在于全局上下文和大项目支持

类别 具体内容
优点 1. 深度 AI 集成:作为原生 AI 驱动的编辑器,2.AI 功能与编辑体验深度融合,支持实时代码生成、重构建议和上下文对话式编程,交互流畅度高 2上下文理解强:能更好地结合整个项目文件结构和代码上下文生成建议 3.多模型支持
缺点 1.需要付费 且需要翻墙 2.非付费效率会排队较慢

deepwiki 辅助文档

DeepWiki 是由 Cognition Labs 基于其产品 Devin 开发的一款 AI 驱动的 GitHub 源码阅读与分析工具。它旨在帮助开发者更高效地阅读、理解和分析 GitHub 上的源码

使用方式如下 直接找到github地址练级 比如 https://github.com/vuejs/vue 直接将 github 替换为 deepwiki 比如 https://deepwiki.com/vuejs/vue 即可生成文档,可以当作一个rag进行提问,对于你了解项目 或者深入细节有很大提速

image.png

image.png

UI设计图转前端代码 v0

v0.app 是 Vercel 推出的一款 AI 驱动的低代码开发工具,核心是通过自然语言 prompt 快速生成可直接使用的网页界面,无需手动编写代码。

尝试了很多的图片转代码的工具 v0的效果是不错的 尤其是于tailwindcss 和 react的代码生成,

我常用的 prompt如下

你是前端开发工程师,你擅长于react 技术栈,且很擅长tailwindcss原子化css,请基于我给你的图片,生成前端代码

image.png

image.png

生成的效果如下,大致文档结构是ok的,细节处需要自己优化,对于付费模型 可以传入figma设计图地址,效果应该更好,我这里没有尝试付费

image.png

国内编程大模型地址

豆包www.doubao.com/

通义灵码lingma.aliyun.com/

deepseekdeepseek.com/

自测豆包对于AI代码编程支持较好,泛化能力也强,支持你出传入链接、图片、github仓库、代码文件夹等,对于基础代码建议使用豆包就可以

国外编程大模型推荐

claudeclaude.ai/new

geminigemini.google.com/

chatgptchatgpt.com/

国外这些大模型整体效果更高一点,尤其是 claude对于完整代码库、长文档解析,适合复杂项目的代码逻辑梳理 GitHub Copilot cursor 付费模式均支持 claude大模型,生成效果比较好

gemini的多模态生成比较好,尤其在 “根据截图生成前端代码”“识别图表并生成数据分析代码” 等场景表现突出,当然你得 图生代码完全可以交给他来搞

image.png

结尾

以上就用AI提效的流程汇总,可酌情取用

react项目开发—关于代码架构/规范探讨

社区一直讨论的一个主题,到底是react好,还是vue好?

我的答案

本人对这个问题的答案是这样的:

1、react给了我们更大的自由度,我们可以以任意的方式,组建我们的代码结构,我们可以操控的代码细节更多,也就能在更多的细节上面,对我们的代码进行更细致的优化。

2、react在书写的过程中,驱动我们对数据、UI有更清晰的认知。我们必须对他们的运行细节,ui变化,数据流向,有明确的认知边界,才能对我们的项目,进行清晰的掌控。

3、vue2,它是死板的,明确的定义了数据的申明在哪里,生命周期在哪里书写,函数和事件在哪里书写,我们几乎很少有可发挥的空间。

4、vue2,数据的双向绑定,让我们可以忽视数据内部真正的变化边界,我们需要的时候,直接无脑赋值就能得到我们想要的结果。

导致的结果

这是这两个框架,不同的api,给我们的客观印象。同时因为团队的差异,它们又导致了一些问题。

1、react框架的项目开发,它注重细节,注重每一个数据的驱动,这就导致了,用这个框架的前端团队必定要有一定的极客精神。它的过度自由,导致了我们甚至可以随意定义我们数据的管理规范、组件的划分、甚至任意的代码结构。

但是,这正是一个团队最可怕的东西。它导致了不可控。

2、vue2的项目开发,它是简单的,容易上手的,并且它就是一个固定的写法规范。

  • 它的vue文件,就是由标准的html、js、css三部分构成。
  • js里面,数据定义,生命周期,函数和事件,都有固定的地方。

这是vue2作为一个框架的缺点,代码可以控制的比react少,书写代码的细节上,性能可控性小。但是,作为团队视角,它提供了一个团队最重要的东西——相对简单、相对可控。

React项目,我们可以从哪些方面提升代码的水准

评价一个项目的代码水平到底好不好,有这么几个方面。

1、代码性能和质量。

代码性能和质量,有些是项目工程层面的内容,有些是代码实现方面的内容。

项目工程层面:代码包的体积大小、首次加载的效率、首屏渲染时间、用户可操作时间。

代码实现方面:渲染效率、是否卡顿、大数据量的处理、虚拟列表、图片加载、分块渲染等等。

2、代码整体的层级划分

目前,现阶段的大多数前端,能做到的基本的目录结构划分,也就是我们的src下面,有这么几个主体目录:

  • index.js/index.tsx,整个应用的入口文件。
  • router,整个应用的路由配置文件。
  • pages,整个应用的页面层级组件,一般router中的一个path,就对应这里的一个组件。
  • components,全局的基础组件。
  • service,api请求相关的服务封装。
  • redux,整个页面的数据管理存储。
  • utils,全局某些通用能力/配置,放置在里面。
  • assets,全局资源文件(图片资源、静态js库、svg、字体文件等等)

以上基本上是每一个前端工程的共识,但是除此之外,我们应该还有其他的共识。

1、页面pages,尽可能的组装业务,进行集中的资源调度。

也就是,我们尽可能的把业务相关的东西,都往pages层次的组件进行集成。

当我们从路由中,得到这路由对应的pages,往往预示着,它是一块相对独立的业务。比如:文件列表页、文件详情页等等。而对于人类而言,在一处地方看代码,比在多处看代码,更容易。

2、页面pages,尽可能的进行统一的数据管理。

在pages层面,进行统一的数据管理,数据管理往往是这么几种情况,从redux接入全局数据进行管理,向pages调度的子组件,通过props传递数据,或者针对Provider全局数据的注入的使用。(至于为什么,可以继续看下面数据管理部分)

3、页面pages,在拆分组件的时候,层级不应该过于多。

在pages的组件层面,很有可能,我们会遇到非常复杂的业务,导致我们的pages层面的组件,变得非常臃肿,这个时候,我们需要进行组件拆分,但是我建议,再只多拆分1层业务组件,不要拆分多个不同层级的组件。组件的层级过多,数据通信的复杂度就会提升,代码的可读性就会降低,如果这个时候,再配合不好的数据管理习惯,屎山代码,就已经形成了。(具体是为什么,可以继续看下面的组件拆分部分)

4、 每一个单元应具备原子性

pages层面,调度的每一个单元,应该具备一定的特性—原子性。

一个函数,只完成一件事情,比如事件响应,数据处理。

一个组件,它是纯粹的,它只和传递给它的props有关,和其他无关。

一个业务组件,也就是在pages过于复杂的情况下拆分出去的组件,虽然它具备业务属性,但是对于pages来说,它也是相对独立的。

3、组件的层级划分

个人比较推崇的组件层级划分方式:

  • pages层,pages层很简单,就是代表页面的意思,每一个路由path,它都对应一个page。
  • 业务组件层,一个page,很有可能很复杂,我们需要一定的设计,把这个页面分成几个块,然后由pages统一调度,实现我们的业务。
  • components层,也就是纯粹的UI组件,它只和props有关系,和其他无关。全局任意的地方,都可以调度。

为什么这么拆分呢?其实这是这么多年,针对真实业务场景,综合思考下来,得到的比较好的实践方式,形成这个组件划分的原则,是基于以下几个方面的思考。

1、辨识度高。

每一个路由,对应一个pages组件。

每一个components中的组件,都有与业务无关的纯UI组件。

根据业务的复杂度,中间产生了一层业务组件。

每一种组件,各司其职,边界清晰,方便我们看到一个组件,就知道这个组件是干嘛的的一种标识。

pages层的组件,进行统一的调度,数据管理,对接redux,组件拼接,事件交互等等。一个文件中的代码,阅读起来,也更加的容易。

业务组件,当pages层的组件,过于复杂的时候,我们把业务相对独立的单元,拆分出来,形成我们的业务组件。业务组件是可以对接redux,也可以有自己的数据状态,各种事件交互。(业务组件的核心,在于如何巧妙的进行边界设计,具体请参考下面的业务组件层的拆分思路)

components层的UI组件,纯粹的UI组件,与业务无关,在全局可复用。

2、结合业务拆分、代码可读性、复用性的一个综合结果。

多数情况下,我们可能没有一个清晰的思路,去做组件的划分工作。

遇到复杂的业务,我们本能的就进行组件的拆分,当时写的时候,没有考虑太多,但是写到一半,会发现,这个组件的变动,可能会引发其他组件的数据变动,我们就遇水搭桥,见招拆招,有些用redux解决,有些用props父子传递数据进行通信。

保持这样的习惯,我们可能拆分一个又一个组件,props传递了一层又一层,等过一段时间一看,自己都看不懂,自己写的代码是啥。

这是本能的,不想思考的,总想简单化的把项目完成。但是往往导致的结果是:本来简单的项目,代码写的越来越复杂!!

pages层面,负责数据的整体管理,组件调度,那么我们的通信就会比较方便,相当于pages层就是这个页面相关的业务的通信中心,我们基本上,能够通过props父子组件传递消息。

props父子组件,传递消息,注定了组件的层级不能过多,过多就会导致,数据就像套娃一样,一层又一层,导致可读性降低。

业务组件的出现,是为了解决,过于复杂的页面业务,我们可能需要进行拆分,把能够单独拆分出去的结构,独立出来,这样不仅从业务上进行了模块的拆分,也能提高不同模块的代码可读性。

那么,业务组件层的拆分思路是什么呢?

3、业务组件层的拆分思路

其实原则上,这里需要我们进行深入思考,哪些模块是独立的单元?页面中的哪些模块,和业务主体通信较少?

这其实就是核心,代码倒逼我们进行设计,进行深入思考,进行深入的业务理解。

思考点1:某一个模块,是不是相对独立的UI模块?

思考点2:某个模块是不是单独的业务单元?

思考点3:某个模块拆分出去,通信的代价到底大不大?

思考清楚这些,其实本质上,你思考的是,你的代码架构问题,你以什么样的视角,来解读你的UI、数据、业务的关联关系。

有了这些思考,你一定可以拆分相对合理的业务组件层。

4、数据管理的划分

多数前端,写代码的时候,并不会有数据中心,数据流向这些概念。

很多人还停留在,完成UI,渲染数据的层面。

如果是这样的思路,无论是组件划分,还是数据管理,注定做的一塌糊涂,代码成屎山是必然的。

组件划分的思路,其实也是一种数据管理的思路。

1、pages层,从天然的业务视角来看,他天然就是一块业务的集合,所以pages层作为一块单独的业务数据中心,它天然合适。

2、components层,我们定义了它,只和props相关,和其他无关,它被其他组件调度,天生注定了它通过props传递数据的行为模式。

3、业务层组件,我们定义了它是pages下面的一块单独业务,我们有必要对它进行合理的设计,它与pages组件的关系,是主模块与子模块的关系。可以通过props通信,也可以接入redux通信。

4、redux,大多数情况下,我们其实不需要用它,当数据的通信,不满足业务场景的时候,redux就是我们的解决方案。它真正的业务价值,在于跨pages的通信。比如,某个页面状态的更改,另一个页面状态,也跟着更改。

redux另一种常见用法:

redux的特性,在umi或者其他框架中,把redux作为数据管理中心来使用,所有的页面状态,业务相关的数据,都定义在redux中,所有的接口请求,用户行为,都是通过redux的dispath进行触发行为,来更改数据,数据通过props流入各个组件中。

这种方式,其实也是各种推崇的一种方式,但是这种方式对人的要求也高,表明了我们团队中的每一个人,都要熟悉并且接受这种数据管理的方式,才能写出相对一致,可读性高的代码。

只要其中的一部分人,不接受这样的数据管理方式,代码的管理它就会变得混乱,有些初始化数据,可能在pages组件中,有些可能发生在redux层,作为阅读者,很难排查代码执行的路径。

现实场景说明:

我们大多数情况下,对于redux的使用,并不清晰,redux的使用,是混乱的。

本不必要的数据,可能放在redux中管理。

本不必须接入redux的组件,非要接入redux。

redux中的dispath,调用的地方,千奇百怪。

数据的流向定义,完全杂乱无章。

这些东西,对于一个项目,都是灾难性的影响,它会导致,我们代码的迭代难度,急剧上升。

Vue 3 的组合式 API和传统选项式 API区别(vue2转vue3,两者差异)

选项式 API vs 组合式 API 深度对比

除了写法不同,选项式 API 和组合式 API 在设计理念、逻辑组织、类型支持、复用能力等方面都有本质区别。

1. 设计理念和思维模式的不同

选项式 API:基于"选项"的分类思维

// 选项式 API - 按功能类型分类
export default {
  // 数据相关放一起
  data() {
    return {
      users: [],
      loading: false,
      searchQuery: ''
    }
  },
  
  // 计算属性放一起
  computed: {
    filteredUsers() {
      return this.users.filter(user => 
        user.name.includes(this.searchQuery)
      )
    }
  },
  
  // 方法放一起
  methods: {
    async fetchUsers() {
      this.loading = true
      this.users = await api.getUsers()
      this.loading = false
    },
    
    updateQuery(query) {
      this.searchQuery = query
    }
  },
  
  // 生命周期放一起
  mounted() {
    this.fetchUsers()
  }
}

组合式 API:基于"逻辑关注点"的聚合思维

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

// 用户搜索逻辑 - 相关的代码放在一起
const searchQuery = ref('')
const users = ref([])
const loading = ref(false)

const filteredUsers = computed(() => {
  return users.value.filter(user => 
    user.name.includes(searchQuery.value)
  )
})

const fetchUsers = async () => {
  loading.value = true
  users.value = await api.getUsers()
  loading.value = false
}

const updateQuery = (query) => {
  searchQuery.value = query
}

// 生命周期也跟相关逻辑放在一起
onMounted(() => {
  fetchUsers()
})

// 另一个独立的逻辑关注点可以放在下面
const otherFeature = () => {
  // 相关状态和方法都在一起
}
</script>

2. 逻辑组织和复用能力的本质区别

选项式 API 的逻辑复用问题

// mixins/userMixin.js - 混入方式(容易冲突)
export default {
  data() {
    return {
      users: [],
      userLoading: false
    }
  },
  methods: {
    async fetchUsers() {
      this.userLoading = true
      this.users = await api.getUsers()
      this.userLoading = false
    }
  },
  mounted() {
    this.fetchUsers()
  }
}

// ComponentA.vue - 使用混入
export default {
  mixins: [userMixin],
  data() {
    return {
      // 可能跟混入中的 users 冲突
      products: [] 
    }
  },
  // 逻辑分散在不同选项中,难以追踪
}

组合式 API 的逻辑复用优势

// composables/useUsers.js - 组合式函数
export function useUsers() {
  const users = ref([])
  const loading = ref(false)
  
  const fetchUsers = async () => {
    loading.value = true
    users.value = await api.getUsers()
    loading.value = false
  }
  
  onMounted(() => {
    fetchUsers()
  })
  
  return {
    users,
    loading,
    fetchUsers
  }
}

// ComponentA.vue - 使用组合式函数
<script setup>
import { useUsers } from '@/composables/useUsers'

const { users, loading, fetchUsers } = useUsers()

// 可以同时使用多个组合式函数,不会冲突
const { products, fetchProducts } = useProducts()
</script>

3. 响应式系统的使用差异

选项式 API 的响应式

export default {
  data() {
    return {
      user: {
        name: 'John',
        profile: {
          age: 25
        }
      },
      items: [1, 2, 3]
    }
  },
  methods: {
    updateUser() {
      // Vue 2 中需要特殊处理数组和对象
      this.user.profile.age = 26 // 响应式更新
      this.items[0] = 999 // Vue 2 中不是响应式的!
      
      // Vue 2 的正确做法
      this.$set(this.items, 0, 999)
      this.items.splice(0, 1, 999)
    }
  }
}

组合式 API 的响应式

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

const user = reactive({
  name: 'John',
  profile: {
    age: 25
  }
})

const items = ref([1, 2, 3])

const updateUser = () => {
  user.profile.age = 26 // 响应式更新
  items.value[0] = 999 // 响应式更新
  
  // 更灵活的响应式操作
  const newItem = ref(100)
  items.value.push(newItem.value)
}
</script>

4. TypeScript 支持程度的巨大差异

选项式 API 的 TypeScript 支持有限

import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 0, // 类型可以推断为 number
      user: null as User | null // 需要类型断言
    }
  },
  
  computed: {
    // 计算属性的类型声明比较麻烦
    doubleCount(): number {
      return this.count * 2
    }
  },
  
  methods: {
    // 方法参数和返回值的类型声明
    updateUser(user: User): void {
      this.user = user
    }
  },
  
  // 生命周期钩子没有很好的类型提示
  mounted() {
    // this.$ 上的属性类型支持有限
  }
})

组合式 API 的完整 TypeScript 支持

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

interface User {
  id: number
  name: string
  email: string
}

// 完整的类型推断
const count = ref(0) // 自动推断为 Ref<number>
const user = ref<User | null>(null) // 明确的类型

// 计算属性的完整类型支持
const doubleCount = computed(() => count.value * 2) // 自动推断为 ComputedRef<number>

// 函数的完整类型支持
const updateUser = (newUser: User) => {
  user.value = newUser
}

// 自动补全和类型检查
user.value?.name // 完整的智能提示
</script>

5. 代码组织和维护性的对比

复杂组件在选项式 API 中的问题

export default {
  data() {
    return {
      // 多个功能的变量混在一起
      users: [],
      products: [], 
      orders: [],
      userLoading: false,
      productLoading: false,
      orderLoading: false,
      searchQuery: '',
      filterStatus: '',
      pagination: { page: 1, limit: 20 }
    }
  },
  
  computed: {
    // 多个功能的计算属性混在一起
    filteredUsers() { /* ... */ },
    filteredProducts() { /* ... */ },
    filteredOrders() { /* ... */ },
    paginatedUsers() { /* ... */ },
    paginatedProducts() { /* ... */ }
  },
  
  methods: {
    // 多个功能的方法混在一起
    fetchUsers() { /* ... */ },
    fetchProducts() { /* ... */ },
    fetchOrders() { /* ... */ },
    searchUsers() { /* ... */ },
    searchProducts() { /* ... */ }
  },
  
  mounted() {
    // 多个功能的初始化混在一起
    this.fetchUsers()
    this.fetchProducts()
    this.fetchOrders()
  }
}

组合式 API 的逻辑分离优势

<script setup>
import { useUsers } from './composables/useUsers'
import { useProducts } from './composables/useProducts'
import { useOrders } from './composables/useOrders'

// 每个功能独立,清晰分离
//从 `useUsers` 函数的返回值中提取出 `users`、`userLoading`、`filteredUsers`、`fetchUsers`、`searchUsers` 这些属性
const {
  users,
  userLoading,
  filteredUsers,
  fetchUsers,
  searchUsers
} = useUsers()

const {
  products,
  productLoading, 
  filteredProducts,
  fetchProducts,
  searchProducts
} = useProducts()

const {
  orders,
  orderLoading,
  filteredOrders, 
  fetchOrders
} = useOrders()

// 初始化各个功能
onMounted(() => {
  fetchUsers()
  fetchProducts()
  fetchOrders()
})
</script>

6. 学习曲线和心智模型

选项式 API 的学习曲线

// 相对平缓,符合传统 OOP 思维
export default {
  props: ['message'],     // 输入
  data() {               // 状态
    return { count: 0 }
  },
  computed: {            // 派生状态
    double() { return this.count * 2 }
  },
  methods: {             // 方法
    increment() { this.count++ }
  },
  watch: {               // 副作用
    count(newVal) { console.log(newVal) }
  },
  mounted() {            // 生命周期
    console.log('组件挂载')
  }
}

组合式 API 的学习曲线

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

// 需要理解响应式基础
const count = ref(0)
const double = computed(() => count.value * 2)

// 需要理解作用域和闭包
const increment = () => {
  count.value++
}

// 需要理解生命周期注册
onMounted(() => {
  console.log('组件挂载')
})

// 需要理解侦听器机制
watch(count, (newVal) => {
  console.log(newVal)
})
</script>

7. 性能优化的差异

选项式 API 的性能优化

export default {
  data() {
    return {
      largeList: [] // 整个组件重新渲染
    }
  },
  methods: {
    updateItem(index, newValue) {
      // 需要特殊优化手段
      this.$set(this.largeList, index, newValue)
    }
  }
}

组合式 API 的性能优化

<script setup>
import { ref, shallowRef, markRaw } from 'vue'

// 更细粒度的响应式控制
const largeList = shallowRef([]) // 浅层响应式
const heavyObject = markRaw({     // 非响应式
  // 大型静态数据
})

// 更精确的更新控制
const updateItem = (index, newValue) => {
  const newList = [...largeList.value]
  newList[index] = newValue
  largeList.value = newList
}
</script>

8. 与第三方库集成的差异

选项式 API 的集成

import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState(['user', 'settings']),
    localComputed() { /* ... */ }
  },
  methods: {
    ...mapActions(['login', 'logout']),
    localMethod() { /* ... */ }
  }
  // 混合了 Vuex 和本地逻辑,难以区分
}

组合式 API 的集成

<script setup>
import { useStore } from 'vuex'
import { useLocalLogic } from './composables/useLocalLogic'

// 清晰的分离
const store = useStore()
const user = computed(() => store.state.user)
const login = () => store.dispatch('login')

// 本地逻辑
const { localData, localMethod } = useLocalLogic()
</script>

9. 实际项目中的选择建议

适合选项式 API 的场景

// 1. 简单的展示组件
export default {
  props: ['title', 'content'],
  template: `
    <div class="card">
      <h3>{{ title }}</h3>
      <p>{{ content }}</p>
    </div>
  `
}

// 2. 迁移 Vue 2 项目
// 3. 团队对选项式 API 更熟悉
// 4. 不需要复杂逻辑复用

适合组合式 API 的场景

<script setup>
// 1. 复杂的业务组件
const { 
  data, 
  pagination, 
  filters, 
  search, 
  loadData 
} = useDataTable()

const { 
  form, 
  validation, 
  submit, 
  reset 
} = useFormHandler()

// 2. 需要逻辑复用的场景
// 3. TypeScript 项目
// 4. 大型应用开发
</script>

总结

特性 选项式 API 组合式 API
设计理念 按选项分类 按逻辑关注点聚合
逻辑复用 Mixins(有冲突风险) 组合式函数(无冲突)
TypeScript 有限支持 完整支持
代码组织 功能分散在不同选项 相关逻辑集中
响应式 自动处理,但有限制 显式声明,更灵活
学习曲线 平缓 较陡峭
性能优化 组件级别 更细粒度控制
适用场景 简单组件、Vue 2 迁移 复杂组件、大型项目

核心区别: 选项式 API 是"怎么做",组合式 API 是"做什么"。选择哪种取决于项目复杂度、团队习惯和具体需求。

【vue篇】Vue 模板编译原理:从 Template 到 DOM 的翻译官

在 Vue 项目中,你写的:

<template>
  <div class="user" v-if="loggedIn">
    Hello, {{ name }}!
  </div>
</template>

最终变成了浏览器能执行的 JavaScript 函数。
这背后,就是 Vue 模板编译器 在默默工作。

本文将深入解析 Vue 模板编译的三大核心阶段parseoptimizegenerate,带你揭开 .vue 文件如何变成可执行代码的神秘面纱。


一、为什么需要模板编译?

🎯 浏览器不认识 <template>

<!-- 你写的 -->
<template>
  <div v-if="user.loggedIn">{{ user.name }}</div>
</template>

<!-- 浏览器看到的 -->
Unknown tag: template → 忽略 or 报错

✅ 解决方案:编译成 render 函数

// 编译后生成的 render 函数
render(h) {
  return this.user.loggedIn 
    ? h('div', { class: 'user' }, `Hello, ${this.user.name}!`)
    : null;
}

💡 render 函数返回的是 虚拟 DOM (VNode),Vue 拿它来高效更新真实 DOM。


二、模板编译三部曲

Template String 
     ↓ parse
   AST (抽象语法树)
     ↓ optimize
   优化后的 AST
     ↓ generate
   Render Function

第一步:🔍 解析(Parse)—— 构建 AST

目标:将 HTML 字符串转为 AST(Abstract Syntax Tree)

示例输入:

<div id="app" class="container">
  <p v-if="show">Hello {{ name }}</p>
</div>

输出 AST 结构:

{
  "type": 1,
  "tag": "div",
  "attrsList": [...],
  "children": [
    {
      "type": 1,
      "tag": "p",
      "if": "show",           // 指令被解析
      "children": [
        {
          "type": 3,
          "text": "Hello ",
          "static": false
        },
        {
          "type": 2,
          "expression": "_s(name)",  // {{ name }} 被编译
          "text": "{{ name }}"
        }
      ]
    }
  ]
}

🛠️ 如何实现?正则 + 状态机

编译器使用多个正则表达式匹配:

匹配内容 正则示例
标签开始 /<([^\s>/]+)/
属性 /(\w+)(?:=)(?:"([^"]*)")/
插值表达式 /{{\s*([\s\S]*?)\s*}}/
指令 /v-(\w+):?(\w*)/?

⚠️ 注意:Vue 的 parser 是一个递归下降解析器,比简单正则复杂得多,但原理类似。


第二步:⚡ 优化(Optimize)—— 标记静态节点

目标:提升运行时性能,跳过不必要的 diff

什么是静态节点?

  • 不包含动态绑定;
  • 内容不会改变;
  • 如:<p>纯文本</p><img src="/logo.png">

优化过程:

  1. 遍历 AST,标记静态根节点和静态子节点;
  2. 添加 static: truestaticRoot: true 标志。
{
  "tag": "p",
  "static": true,
  "staticRoot": true,
  "children": [
    { "type": 3, "text": "这是静态文本", "static": true }
  ]
}

运行时收益:

// patch 过程中
if (vnode.static && oldVnode.static) {
  // 直接复用,跳过 diff!
  vnode.componentInstance = oldVnode.componentInstance;
  return;
}

💥 对于大量静态内容(如文档页面),性能提升可达 30%+


第三步:🎯 生成(Generate)—— 输出 render 函数

目标:将优化后的 AST 转为可执行的 render 函数字符串

输入:优化后的 AST

输出:JavaScript 代码字符串

with(this) {
  return _c('div',
    { attrs: { "id": "app", "class": "container" } },
    [ (show) ?
      _c('p', [_v("Hello "+_s(name))]) :
      _e()
    ]
  )
}

🔤 代码生成规则

AST 节点 生成代码
元素标签 _c(tag, data, children)
文本节点 _v(text)
表达式 {{ }} _s(expression)
条件渲染 v-if (condition) ? renderTrue : renderFalse
静态节点 _m(index)(从 $options.staticRenderFns 中取)

💡 _c = createElement, _v = createTextVNode, _s = toString


三、完整流程图解

          Template
             │
             ▼
       [ HTML Parser ]
             │
             ▼
         AST (未优化)
             │
             ▼
      [ 静态节点检测与标记 ]
             │
             ▼
         AST (已优化)
             │
             ▼
     [ Codegen (生成器) ]
             │
             ▼
     Render Function String
             │
             ▼
     new Function(renderStr)
             │
             ▼
       可执行的 render()
             │
             ▼
        Virtual DOM
             │
             ▼
        Real DOM (渲染)

四、Vue 2 vs Vue 3 编译器对比

特性 Vue 2 Vue 3
编译目标 render 函数 render 函数
模板语法限制 较多(如必须单根) 更灵活(Fragment 支持多根)
静态提升 ✅✅ 更强的 hoist 静态节点
Patch Flag 动态节点标记,diff 更快
编译时优化 基础静态标记 Tree-shaking 友好,死代码消除
源码位置 src/compiler/ @vue/compiler-dom

💥 Vue 3 的编译器更智能,生成的代码更小、更快。


五、手写一个极简模板编译器(玩具版)

function compile(template) {
  // Step 1: Parse (简化版)
  const tags = template.match(/<(\w+)[^>]*>(.*?)<\/\1>/);
  if (!tags) return;

  const tag = tags[1];
  const content = tags[2];

  // Step 2: Optimize (判断是否静态)
  const isStatic = !content.includes('{{');

  // Step 3: Generate
  const renderCode = `
    function render() {
      return ${isStatic 
        ? `_v("${content}")` 
        : `_c("${tag}", {}, [ _v( _s(${content.slice(2,-2)})) ])`
      };
    }
  `;

  return renderCode;
}

// 使用
const code = compile('<p>{{ msg }}</p>');
console.log(code);
// 输出:function render() { return _c("p", {}, [ _v( _s(msg)) ]); }

🎉 这就是一个最简化的“编译器”雏形!


💡 结语

“Vue 模板编译器,是连接声明式模板与命令式 DOM 操作的桥梁。”

阶段 作用 输出
Parse 解析 HTML 字符串 AST
Optimize 标记静态节点 优化后的 AST
Generate 生成 JS 代码 render 函数

掌握编译原理,你就能:

✅ 理解 Vue 模板的底层机制;
✅ 写出更高效的模板(减少动态绑定);
✅ 调试编译错误更得心应手;
✅ 为学习其他框架(React JSX)打下基础。

【vue篇】Vue Mixin:可复用功能的“乐高积木”

在开发多个 Vue 组件时,你是否遇到过这样的问题:

“这几个组件都有相同的 loading 逻辑,要复制粘贴?” “如何共享通用的错误处理方法?” “有没有像‘插件’一样的功能可以注入?”

答案就是:Mixin(混入)

本文将全面解析 Vue Mixin 的核心概念使用场景潜在风险


一、什么是 Mixin?

Mixin 是一个包含 Vue 组件选项的对象,可以被“混入”到多个组件中,实现功能复用。

🎯 核心价值

  • 代码复用:避免重复编写相同逻辑;
  • 逻辑分离:将通用功能(如 loading、权限)抽离;
  • 渐进增强:为组件动态添加功能。

二、快速上手:一个 Loading Mixin 示例

场景:多个组件需要“加载中”状态

Step 1:创建 loading.mixin.js

// mixins/loading.mixin.js
export const loadingMixin = {
  data() {
    return {
      loading: false,
      errorMessage: null
    };
  },

  methods: {
    async withLoading(asyncFn) {
      this.loading = true;
      this.errorMessage = null;
      try {
        await asyncFn();
      } catch (err) {
        this.errorMessage = err.message;
      } finally {
        this.loading = false;
      }
    }
  },

  // 生命周期钩子
  created() {
    console.log('【Mixin】组件创建,初始化 loading 状态');
  }
};

Step 2:在组件中使用

<!-- UserProfile.vue -->
<script>
import { loadingMixin } from '@/mixins/loading.mixin';

export default {
  mixins: [loadingMixin],

  async created() {
    // 使用 mixin 提供的方法
    await this.withLoading(() => this.fetchUser());
  },

  methods: {
    async fetchUser() {
      // 模拟 API 调用
      await new Promise(r => setTimeout(r, 1000));
      this.user = { name: 'Alice' };
    }
  }
};
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="errorMessage">错误:{{ errorMessage }}</div>
  <div v-else>用户:{{ user.name }}</div>
</template>

✅ 效果:UserProfile 组件自动拥有了 loadingerrorMessagewithLoading 方法。


三、Mixin 合并规则:当名字冲突了怎么办?

当 Mixin 和组件定义了同名选项,Vue 会按规则合并:

选项类型 合并策略
data 函数返回对象合并(浅合并)
methods / computed / props 组件优先,Mixin 的会被覆盖
生命周期钩子 两者都执行,Mixin 的先执行
watch 同名 watcher 都会执行
computed 组件优先

🎯 生命周期执行顺序

const myMixin = {
  created() {
    console.log('1. Mixin created');
  }
};

export default {
  mixins: [myMixin],
  created() {
    console.log('2. Component created'); // 后执行
  }
}

输出:

1. Mixin created
2. Component created

💥 Mixin 的生命周期永远先于组件自身执行


四、实战应用场景

✅ 场景 1:表单验证逻辑复用

// mixins/validation.mixin.js
export const validationMixin = {
  data() {
    return {
      errors: {}
    };
  },
  methods: {
    validateEmail(email) {
      const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!re.test(email)) {
        this.errors.email = '邮箱格式不正确';
      } else {
        delete this.errors.email;
      }
    }
  }
};

✅ 场景 2:权限控制

// mixins/permission.mixin.js
export const permissionMixin = {
  mounted() {
    if (!this.$store.getters.hasPermission(this.requiredPermission)) {
      this.$router.push('/403');
    }
  }
};

// 组件中
export default {
  mixins: [permissionMixin],
  data() {
    return {
      requiredPermission: 'user:edit'
    };
  }
};

✅ 场景 3:第三方 SDK 集成

// mixins/analytics.mixin.js
export const analyticsMixin = {
  mounted() {
    this.$analytics.pageView(); // 记录页面访问
  },
  methods: {
    trackEvent(event, props) {
      this.$analytics.track(event, props);
    }
  }
};

五、Mixin 的“黑暗面”:潜在问题

❌ 问题 1:命名冲突(Name Collision)

// mixin 定义了 fetchData
const apiMixin = {
  methods: {
    fetchData() { /* ... */ }
  }
};

// 组件也定义了 fetchData
export default {
  mixins: [apiMixin],
  methods: {
    fetchData() { /* 覆盖了 mixin 的方法!*/ }
  }
}

⚠️ 组件的方法会覆盖 Mixin 的,可能导致逻辑丢失。


❌ 问题 2:隐式依赖(Implicit Dependency)

// mixin 依赖组件必须提供 `userId`
const userMixin = {
  async created() {
    this.userData = await fetch(`/api/users/${this.userId}`);
  }
};

如果组件没有定义 userId,就会报错,但没有明显提示


❌ 问题 3:来源不清晰(Source Ambiguity)

<template>
  <!-- 这个 `loading` 是哪来的? -->
  <div v-if="loading">加载中...</div>
</template>

🔍 开发者无法从模板直接看出 loading 是来自 Mixin 还是组件自身。


六、Vue 3 的替代方案:Composition API

// composables/useLoading.js
import { ref } from 'vue';

export function useLoading() {
  const loading = ref(false);
  const errorMessage = ref(null);

  const withLoading = async (asyncFn) => {
    loading.value = true;
    errorMessage.value = null;
    try {
      await asyncFn();
    } catch (err) {
      errorMessage.value = err.message;
    } finally {
      loading.value = false;
    }
  };

  return { loading, errorMessage, withLoading };
}
<!-- UserProfile.vue -->
<script setup>
import { useLoading } from '@/composables/useLoading';

const { loading, withLoading } = useLoading();

async function loadUser() {
  await withLoading(fetchUser);
}
</script>

✅ Composition API 的优势:

特性 Mixin Composition API
命名冲突 ❌ 易发生 ✅ 通过解构重命名
源头追踪 ❌ 困难 useXxx() 清晰可见
类型推导 ❌ 弱 ✅ TypeScript 友好
逻辑复用 ✅ 更灵活

💡 结语

“Mixin 是一把双刃剑:用得好,提升效率;用不好,制造混乱。”

方案 适用场景
Mixin Vue 2 项目、简单逻辑复用
Composition API Vue 3 项目、复杂逻辑、TypeScript

🚀 最佳实践建议:

  1. 优先使用 Composition API(Vue 3);
  2. ✅ 如果用 Mixin,命名清晰(如 useLoadingMixin);
  3. ✅ 避免在 Mixin 中引入隐式依赖
  4. ✅ 文档化 Mixin 的输入/输出

掌握 Mixin,你就能写出更 DRY(Don't Repeat Yourself)的代码。

【vue篇】Vue 2 响应式“盲区”破解:如何监听对象/数组属性变化

在 Vue 开发中,你是否遇到过这样的诡异问题:

“我明明改了 this.user.name,为什么页面没更新?” “this.arr[0] = 'new',视图怎么不动?” “Vue 不是响应式的吗?”

本文将彻底解析 Vue 2 的响应式限制,并提供五种解决方案,让你彻底告别“数据变了,视图没变”的坑。


一、核心问题:Vue 2 的响应式“盲区”

🎯 为什么直接赋值不触发更新?

// ❌ 无效:视图不更新
this.user.name = 'John';      // 对象新增属性
this.users[0] = 'Alice';      // 数组索引赋值

🔍 根本原因:Object.defineProperty 的限制

Vue 2 使用 Object.defineProperty 拦截:

  • ✅ 能监听 已有属性的修改
  • 不能监听
    • 对象新增属性
    • 数组索引直接赋值arr[0] = x);
    • 数组长度修改arr.length = 0)。

💥 Vue 无法“感知”这些操作,所以不会触发视图更新。


二、解决方案:五种正确姿势

✅ 方案 1:this.$set() —— Vue 官方推荐

// ✅ 对象新增属性
this.$set(this.user, 'name', 'John');

// ✅ 数组索引赋值
this.$set(this.users, 0, 'Alice');

// ✅ 等价于
Vue.set(this.user, 'name', 'John');

🎯 this.$set 的内部原理

function $set(target, key, val) {
  // 1. 如果是数组 → 用 splice 触发响应式
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1, val);
    return val;
  }
  
  // 2. 如果是对象
  const ob = target.__ob__;
  if (key in target) {
    // 已有属性 → 直接赋值(已有 getter/setter)
    target[key] = val;
  } else {
    // 新增属性 → 动态添加响应式
    defineReactive(target, key, val);
    ob.dep.notify(); // 手动派发更新
  }
  return val;
}

💡 $set = 智能判断 + 自动响应式处理


✅ 方案 2:数组专用方法 —— splice

// ✅ 修改数组某一项
this.users.splice(0, 1, 'Alice'); // 索引0,删除1个,插入'Alice'

// ✅ 新增元素
this.users.splice(1, 0, 'Bob'); // 在索引1前插入

// ✅ 删除元素
this.users.splice(0, 1); // 删除第一项

🎯 为什么 splice 可以?

Vue 2 重写了数组的 7 个方法

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

// 重写后,调用这些方法时会:
// 1. 执行原生方法
// 2. dep.notify() → 触发视图更新

✅ 这些方法是“响应式安全”的。


✅ 方案 3:对象整体替换

// ✅ 对象新增属性
this.user = { ...this.user, name: 'John' };

// ✅ 或
this.user = Object.assign({}, this.user, { name: 'John' });
  • ✅ 原理:重新赋值 → 触发 setter → 视图更新;
  • ❌ 缺点:失去响应式连接(如果 user 被深层嵌套)。

✅ 方案 4:初始化时声明属性

data() {
  return {
    user: {
      name: '',    // 提前声明
      age: null,
      email: ''    // 避免运行时新增
    }
  };
}

💡 最佳实践:data 中定义所有可能用到的属性


✅ 方案 5:使用 Vue.observable + computed

const state = Vue.observable({
  user: { name: 'Tom' }
});

// 在组件中
computed: {
  userName() {
    return state.user.name; // 自动依赖收集
  }
}
  • ✅ 适合全局状态;
  • ❌ 不推荐用于组件局部状态。

三、Vue 3 的革命性改进:Proxy 无所不能

import { reactive } from 'vue';

const state = reactive({
  user: {},
  users: []
});

// ✅ Vue 3 中,以下操作全部响应式!
state.user.name = 'John';        // 新增属性
state.users[0] = 'Alice';        // 索引赋值
state.users.length = 0;          // 修改长度
delete state.user.name;          // 删除属性

💥 Vue 3 使用 Proxy,能拦截 getsetdeleteProperty 等所有操作,彻底解决 Vue 2 的响应式盲区。


四、最佳实践清单

场景 推荐方案
Vue 2:对象新增属性 this.$set(obj, key, val)
Vue 2:数组索引赋值 this.$set(arr, index, val)arr.splice(index, 1, val)
Vue 2:批量更新数组 splice / push / pop
Vue 2:避免问题 初始化时声明所有属性
Vue 3:任何操作 直接赋值,Proxy 全部拦截

五、常见误区

❌ 误区 1:this.$set 只用于对象

// ❌ 错误:认为数组不需要 $set
this.users[0] = 'new'; // 不响应

// ✅ 正确
this.$set(this.users, 0, 'new');

❌ 误区 2:pushsplice 更好

// ✅ `splice` 更通用
this.users.splice(1, 0, 'Bob'); // 在中间插入
this.users.push('Bob');         // 只能在末尾

✅ 推荐:splice 是数组操作的“瑞士军刀”


💡 结语

“在 Vue 2 中,永远不要直接操作数组索引或对象新增属性。”

方法 是否响应式 适用场景
obj.key = val (已有) 修改已有属性
obj.newKey = val $set
arr[i] = val $setsplice
this.$set() 通用解决方案
splice() 数组操作首选

掌握这些技巧,你就能:

✅ 避免响应式失效的 bug;
✅ 写出更可靠的 Vue 代码;
✅ 理解 Vue 响应式的核心原理。

【vue篇】Vue.delete vs delete:数组删除的“陷阱”与正确姿势

在 Vue 开发中,你是否遇到过这样的问题:

“用 delete 删除数组项,视图为什么没更新?” “Vue.delete 和原生 delete 有什么区别?” “如何安全地删除数组元素?”

本文将彻底解析 deleteVue.delete删除数组时的根本差异。


一、核心结论:一个“打洞”,一个“重排”

操作 结果 响应式 视图更新
delete arr[index] 元素变 empty长度不变 ❌ 不响应 ❌ 不更新
Vue.delete(arr, index) 直接删除,长度改变 ✅ 响应式 ✅ 自动更新

💥 delete 只是“打了个洞”,而 Vue.delete 是真正的“移除”。


二、实战演示:同一个操作,两种结果

场景:删除数组第二项

const vm = new Vue({
  data: {
    users: ['Alice', 'Bob', 'Charlie']
  }
});

方式一:delete(错误方式)

delete vm.users[1];
console.log(vm.users); 
// ['Alice', empty, 'Charlie'] → 长度仍为 3!
  • 内存中users[1] 变为 empty slot
  • DOM 中:视图不会更新

⚠️ 控制台警告:

[Vue warn]: A value is trying to be set on a non-existent property...

方式二:Vue.delete(正确方式)

Vue.delete(vm.users, 1);
// 或 this.$delete(vm.users, 1)
console.log(vm.users); 
// ['Alice', 'Charlie'] → 长度变为 2!

✅ 视图自动更新,完美!


三、深入原理:为什么 delete 不行?

🔍 1. delete 的本质

let arr = ['a', 'b', 'c'];
delete arr[1];

// 等价于
arr[1] = undefined; // ❌ 错误理解
// 实际是:
Object.defineProperty(arr, 1, { configurable: true });
delete arr[1]; // 移除属性,但保留索引“空位”
索引:   0     1     2
值:   'a'   empty  'c'
  • 数组长度 不变
  • for...in 会跳过 empty 项;
  • Array.prototype 方法(如 map, filter)会跳过 empty

🔍 2. Vue 响应式的限制

Vue 2 使用 Object.defineProperty 拦截:

  • ✅ 能监听 arr[1] = newValue(赋值);
  • 不能监听 delete arr[1](删除属性)

💡 Vue 无法检测到“属性被删除”,所以不会触发视图更新。


🔍 3. Vue.delete 的内部实现

Vue.delete = function (target, key) {
  // 1. 执行原生 delete
  delete target[key];
  
  // 2. 手动触发依赖更新
  if (target.__ob__) {
    target.__ob__.dep.notify(); // 强制通知 watcher
  }
}

Vue.delete = delete + 手动派发更新


四、其他删除数组的方法(推荐)

✅ 1. splice() —— 最常用

vm.users.splice(1, 1); // 从索引1开始,删除1个
// ['Alice', 'Charlie']
  • ✅ 响应式(Vue 重写了 splice);
  • ✅ 支持删除多个元素;
  • ✅ 返回被删除的元素。

✅ 2. filter() —— 函数式编程

vm.users = vm.users.filter((user, index) => index !== 1);
// 或根据条件删除
vm.users = vm.users.filter(user => user !== 'Bob');
  • ✅ 不修改原数组,返回新数组;
  • ✅ 适合复杂条件删除;
  • ✅ 响应式(因为重新赋值)。

✅ 3. slice() + 解构

vm.users = [
  ...vm.users.slice(0, 1),
  ...vm.users.slice(2)
]; // 删除索引1
  • ✅ 函数式,不可变数据;
  • ✅ 适合组合多个片段。

五、Vue 3 的改进:Proxy 无所不能

import { reactive } from 'vue';

const state = reactive({
  users: ['Alice', 'Bob', 'Charlie']
});

// Vue 3 中,delete 也能触发更新!
delete state.users[1]; // ✅ 视图自动更新

💥 Vue 3 使用 Proxy,能拦截 deleteProperty,因此原生 delete 也响应式!


六、最佳实践清单

场景 推荐方法
删除指定索引 splice(index, 1)
删除满足条件的元素 filter(condition)
需要兼容 Vue 2 Vue.delete(array, index)
Vue 3 项目 delete array[index]
性能敏感场景 splice(原地修改)

💡 结语

“在 Vue 2 中,永远不要用 delete 删除数组!”

方法 是否响应式 是否推荐
delete arr[i] ❌ 绝对避免
Vue.delete(arr, i) ✅ Vue 2 推荐
arr.splice(i, 1) ✅ 首选
arr.filter(...) ✅ 函数式首选

掌握这些删除技巧,你就能:

✅ 避免视图不更新的 bug;
✅ 写出更健壮的 Vue 代码;
✅ 顺利过渡到 Vue 3 的响应式系统。

【vue篇】Vue 项目中的静态资源管理:assets vs static 终极指南

在 Vue 项目中,你是否遇到过这样的困惑:

assetsstatic 文件夹有什么区别?” “图片到底该放哪个文件夹?” “为什么有的资源路径变了,有的没变?”

本文将彻底解析 assetsstatic核心差异使用场景最佳实践


一、核心结论:一句话总结

assets 走构建流程(可处理),static 直接拷贝(不处理)。

维度 assets static
是否参与构建 ✅ 是 ❌ 否
是否被 webpack 处理 ✅ 是 ❌ 否
是否支持模块化导入 ✅ 是 ❌ 否
是否会被重命名(hash) ✅ 是 ❌ 否
是否支持 Tree-shaking ✅ 是 ❌ 否

二、详细对比:从构建流程说起

🔄 1. assets:构建流程的“参与者”

src/assets/logo.png
     ↓
  webpack 处理
     ↓
  压缩、转 base64、生成 hash 名
     ↓
dist/static/img/logo.2f1f87g.png

assets 的特点:

  • 参与构建:被 webpack 处理;
  • 优化处理
    • 图片压缩(image-webpack-loader);
    • 小图转 base64(减少 HTTP 请求);
    • 文件名加 hash(缓存优化);
  • 支持模块化导入
import logo from '@/assets/logo.png';
console.log(logo); // /static/img/logo.abc123.png
  • 路径动态化:路径由构建工具生成,不可预测

🔄 2. static:构建流程的“旁观者”

static/favicon.ico
     ↓
  直接拷贝
     ↓
dist/favicon.ico

static 的特点:

  • 不参与构建:原封不动拷贝到 dist
  • 无优化:不压缩、不转码、不加 hash;
  • 路径固定:访问路径 = / + 文件名
  • 适合“即插即用”资源
<!-- 直接通过绝对路径访问 -->
<link rel="icon" href="/favicon.ico">
<script src="/js/third-party.js"></script>

三、实战演示:同一个图片的不同命运

场景:项目中使用 logo.png

方式一:放在 assets

<template>
  <img :src="logo" alt="Logo">
</template>

<script>
import logo from '@/assets/logo.png';
// logo = "/static/img/logo.abc123.png"
</script>

优势

  • 图片被压缩,体积更小;
  • 文件名加 hash,缓存友好;
  • 支持按需加载。

方式二:放在 static

<template>
  <img src="/static/logo.png" alt="Logo">
</template>

优势

  • 构建速度快(跳过处理);
  • 路径固定,适合第三方脚本引用。

劣势

  • 图片未压缩,体积大;
  • 无 hash,缓存更新困难。

四、何时使用 assets?何时使用 static

✅ 推荐使用 assets 的场景:

资源类型 示例
项目自用图片 logo、banner、icon
CSS/SCSS 文件 @import '@/assets/styles/main.scss'
字体文件 .woff, .ttf(可被 hash)
SVG 图标 可被 svg-sprite-loader 处理
需要按需引入的 JS 工具函数、配置文件

💡 原则:项目源码中直接引用的资源 → 放 assets


✅ 推荐使用 static 的场景:

资源类型 示例
第三方库 static/js/jquery.min.js
Favicon favicon.ico
Robots.txt SEO 爬虫规则
大型静态文件 PDF、视频(避免 webpack 处理)
CND 回退文件 当 CDN 失败时本地加载
<!-- 第三方库回退 -->
<script src="https://cdn.example.com/vue.js"></script>
<script>window.Vue || document.write('<script src="/static/js/vue.min.js"><\/script>')</script>

💡 原则:不希望被构建工具处理的资源 → 放 static


五、Vue CLI 项目结构示例

my-project/
├── public/               # Vue CLI 中 static 的新名字
│   ├── favicon.ico
│   ├── robots.txt
│   └── static/
│       └── js/
│           └── analytics.js
├── src/
│   ├── assets/           # 所有需要构建的资源
│   │   ├── images/
│   │   ├── fonts/
│   │   └── styles/
│   └── components/
└── package.json

⚠️ 注意:在 Vue CLI 3+ 中,static 文件夹已更名为 public


六、常见误区与最佳实践

❌ 误区 1:所有图片都放 static

<!-- 错误:大图未压缩,无 hash -->
<img src="/static/banner.jpg">

✅ 正确做法:

import banner from '@/assets/banner.jpg';
<img :src="banner">

❌ 误区 2:在 assets 中放第三方库

// ❌ 错误:第三方库应放 public
import 'jquery'; // 来自 node_modules 或 assets

✅ 正确做法:

<!-- 放 public,通过 script 标签引入 -->
<script src="/static/js/jquery.min.js"></script>

✅ 最佳实践清单

实践 说明
✅ 小图放 assets 转 base64,减少请求
✅ 大图放 assets 压缩,但不转 base64
✅ 第三方库放 public 避免重复打包
✅ 使用 require 动态加载 :src="require('@/assets/dynamic.png')"
✅ 配置 publicPath 部署到子目录时设置

💡 结语

assets 是你的‘智能资源库’,staticpublic)是你的‘原始文件仓库’。”

选择 使用场景
assets 项目源码引用、需要优化、支持 hash
static / public 第三方资源、固定路径、避免构建

掌握这一原则,你就能:

✅ 优化项目性能;
✅ 减少打包体积;
✅ 提升缓存效率;
✅ 避免资源加载错误。

Node.js + vue3 大文件-切片上传全流程(视频文件)

Node.js + vue3 大文件-切片上传全流程(视频文件)

这个业务场景是在参与一个AI智能混剪视频切片的项目中碰到的,当时的第一版需求是视频文件直接上传,当时是考虑到视频切片不会很大,就默认用户直接上传,但后续需求调整,切片时长扩大且画质也许会有所提高,导致文件会很大。解决方案考虑过是否可以通过压缩来解决,但混剪视频需求,用户是极其在意画质的,因此就放弃这种方案,只能选择市面通用的方案,切片上传。

功能简述

  1. 支持手动上传、拖动上传。

  2. 支持切片上传,且上传时带有进度条。

    切片格式限制:Mp4,大小限制: 20M

  3. 支持断点续传(后续再添加...)

服务端(node.js)

Install

pnpm install express multer fluent-ffmpeg body-parser cors fs-extra

环境配置:由于多个切片需要合并成一个视频,因此本地机器需要配置 ffmpeg

# 验证是否安装了 ffmpeg
ffmpeg -v

文件目录结构

your-project-name
├─ index.js
├─ cache
├─ output
├─ utils
│  ├─ multer.js
├─ public
├─ dist

multer 配置

const multer = require('multer')
const fse = require('fs-extra')

/**
 * multer 配置
 * @param { string } path 上传文件的目录
 * @param { function } fileFilter 文件过滤
 * @returns { multer } multer 实例
 */
module.exports = (path, fileFilter) => {
  /**
   * 上传文件的目录
   */
  const storage = (path) => {
    return multer.diskStorage({
      // 上传文件的目录
      destination: (req, file, cb) => {
        cb(null, path)
      },
      // 上传文件的名称
      filename: (req, file, cb) => {
        const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8')
        cb(null, fileName)
      }
    })
  }
  const config = {
    storage: storage(path)
  }
  /**
   * 文件过滤
   */
  if (fileFilter) {
    config.fileFilter = fileFilter
  }
  /**
   * 上传配置
   */
  return multer(config)
}

创建服务

const express = require('express')
const fse = require('fs-extra')
const fs = require('fs')
const multer = require('./utils/multer.js')
const { sep, resolve } = require('path')
const app = express()
const router = express.Router()
// multer 配置
const multerOption = multer(resolve(__dirname, `.${sep}cache`))

/**
 * 处理静态文件
 * 静态资源 token 校验
 */
express.static(resolve(__dirname,`.${sep}public`)))
app.use(express.static(resolve(__dirname, `.${sep}public`)))
app.use(express.static(resolve(__dirname, `.${sep}dist`)))
/**
 * 跨域
 */
app.use(cors())
/**
 * 请求参数
 */
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())

/**
 * 上传切片
 */
router.post('/upload/chunk', multerOption.single('file'), (req, res) => {
try {
    const { file } = req
    const { chunkIndex, name: fileName } = req.body
    const cachePath = resolve(__dirname, `.${sep}cache`)
    const filePath = resolve(cachePath, `.${sep}${fileName}`)
    // 创建hash目录
    createFolder(filePath)
    // 移动chunk到指定文件目录
    fs.renameSync(resolve(cachePath, `.${sep}${file.originalname}`), resolve(filePath, `.${sep}${file.originalname}`))
  } catch (e) {
    console.log('e', e)
    throw new Error(e.message)
  }
})

/**
 * 合并切片
 */
router.post('/upload/chunk', (req, res) => {
  try {
    const { name: fileName, tagIds } = req.bodyd
    const filePath = resolve(__dirname, `.${sep}cache${sep}${fileName}`)
    const outputPath = resolve(__dirname, `.${sep}output`)
    // 获取 分片 文件
    const chunks = fs.readdirSync(hashPath)
    // 排序分片
    chunks.sort((a, b) => {
      const numA = parseInt(a)
      const numB = parseInt(b)
      return numA - numB
    })
    // 合并分片
    chunks.map(chunkPath => {
      fs.appendFileSync(
        resolve(filePath, `.${sep}${fileName}.mp4`),
        fs.readFileSync(resolve(filePath, `.${sep}${chunkPath}`))
      )
    })
    // 移动视频到指定目录
    fs.renameSync(resolve(filePath, `.${sep}${fileName}.mp4`), resolve(outputPath, `.{sep}${fileName}.mp4`))
    // 删除分片
    chunks.map(chunkPath => {
      fs.unlinkSync(resolve(filePath, `.${sep}${chunkPath}`))
    })
    // 删除hash目录
    fs.rmdirSync(filePath)
  } catch (e) {
    throw new Error(e.message)
  }
})

/**
 * 创建文件夹
 * @param {String} path 文件夹路径
 */
createFolder(path) {
  try {
    if (fse.existsSync(path)) {
      return
    }
    fse.ensureDirSync(path)
  } catch (error) {
    throw new Error('[Create Folder]创建文件夹失败', error)
  }
}

/**
 * 启动服务
 */
try {
  const port = process.env.PORT || 8081 // 端口号
  const host = process.env.IP || '0.0.0.0' // 主机地址
  app.listen(port, host, () => {
    console.log(`服务已启动,访问地址:http://${host}:${port}`)
  })
} catch (error) {
  console.error('启动服务失败:', error)
}

客户端 (vue3 + element-plus)

<template>
  <div class="upload-video round-8 pd-16 border-box scroll-y">
    <div class="container" style="overflow: hidden;">
      <input ref="uploadRef" type="file" :multiple="uploadOptions.multiple" :accept="uploadOptions.accept" @change="handleSelectFile" />
      <!-- 等待上传 -->
      <div v-if="uploadStatus === 'waiting'" class="upload-box flex-center text-center pointer hover"
        @dragover="handlePreventDefault"
        @dragenter="handlePreventDefault"
        @drop="handleFileDrop"
        @click="handleClickUpload">
        <img src="@/assets/upload.png" alt="上传" class="upload-icon" />
        <div class="mg-l-8" style="line-height: 22px;">
          <p class="color-info font-12 ellipsis">拖拽到此区域上传或点击上传</p>
          <p class="color-info font-12 ellipsis">仅支持 .mp4 格式</p>
        </div>
      </div>
      <!-- 上传 -->
      <div v-else class="upload-box flex-center-column pd-16 border-box">
        <!-- 正在上传 -->
        <div v-if="uploadStatus === 'uploading'" class="flex-column jc-c" style="width: 100%; height: 100%;">
          <el-progress :percentage="progress" />
          <div class="font-12 color-info flex ai-c jc-sb">
            <el-button text type="info" size="small" loading style="margin-left: -8px;">
              <span v-if="chunkInfo.total" class="mg-l-4">
                {{ chunkInfo.uploaded !== chunkInfo.total ? `(${chunkInfo.uploaded}/${chunkInfo.total}) 正在上传...` : '上传成功,正在读取文件...' }}
              </span>
            </el-button>
            <el-button text type="danger" size="small" class="mg-r-16">
              取消
            </el-button>
          </div>
        </div>
        <!-- 上传完成 -->
        <div v-if="uploadStatus === 'success'" class="flex-center-column">
          <div class="preview-video mg-b-12 relative pointer" @click="handleClickPreview">
            <div v-if="isPreview" class="preview-video-mask" />
            <video ref="previewVideoRef" :src="previewUrl" preload="metadata" class="round-4" width="100%" height="100%" style="aspect-ratio: 16/9;" />
          </div>
          <span class="font-12 color-info flex ai-c" style="max-width: 326px;">
            <el-icon class="mg-r-4 font-14 color-success"><CircleCheckFilled /></el-icon>
            <span class="ellipsis">已选择文件【{{ fileInfo?.name }}】</span>
          </span>
          <el-button size="small" class="mg-t-8" type="primary" @click="handleClickUpload">重新上传文件</el-button>
        </div>
      </div>
    </div>
    <div class="form-box mg-t-16">
      <el-form :model="form" ref="formRef" label-position="top" :rules="formRules">
        <el-form-item label="视频名称" style="margin-bottom: 8px;" prop="name">
          <el-input v-model="form.name" type="textarea" :rows="5" resize="none" placeholder="请输入视频名称" clearable />
        </el-form-item>
        <el-form-item label="视频标签" prop="tags">
          <el-select v-model="form.tags" placeholder="请选择视频标签" clearable filterable multiple :disabled="!tags.length">
            <el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleConfirm">确定</el-button>
          <el-button type="info" @click="handleClickBack">返回</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getTagList, checkVideoChunkApi, uploadChunkApi, mergeChunkApi } from '@/api'

const router = useRouter()
const fileInfo = ref(null)

/**
 * 表单
 */
const form = reactive({
  name: '',
  tags: []
})

/**
 * 表单验证
 */
const formRules = {
  name: [{ required: true, message: '请输入视频名称', trigger: 'blur' }],
  tags: [{ required: true, message: '请选择视频标签', trigger: 'blur' }]
}

/**
 * 视频标签
 */
const tags = ref([])

/**
 * 上传视频的配置
 * @type {Object} { accept: 'video/mp4', multiple: true }
 */
const uploadOptions = {
  accept: ['video/mp4'],
  multiple: false
}

/**
 * 上传进度
 * @type {Number}
 */
const progress = ref(10)

/**
 * 上传状态
 * waiting | uploading | success | fail
 */
const uploadStatus = ref('waiting')

/**
 * 阻止浏览器拖拽打开文件的默认行为
 * @param {Object} e
 */
const handlePreventDefault = (e) => {
  e.stopPropagation()
  e.preventDefault()
}

/**
 * 放开鼠标,拖拽结束时回调
 * @param {Object} e
 */
 const handleFileDrop = async (e) => {
  try {
    handlePreventDefault(e)
    const filesList = []
    const target = []
    const types = e.dataTransfer.types
    if (!types.includes('Files')) {
      ElMessage.warning('仅支持MP4文件!')
      return
    }
    // 特殊处理,不然直接看e的files始终为空
    target.forEach.call(e.dataTransfer.files, (file) => { filesList.push(file) }, false)
    if (!filesList.length) {
      return
    }
    const file = filesList[0]
    const fileEvent = {
      target: {
        files: [file]
      }
    }
    handleSelectFile(fileEvent)
  } catch (error) {
    console.error(error)
    uploadStatus.value = 'waiting'
  } finally {
    uploadRef.value.value = null
  }
}

const previewUrl = ref('')
/**
 * 手动选择本地文件
 * @param {Object} fileEvent
 */
const handleSelectFile = async (fileEvent) => {
  try {
    const { target } = fileEvent
    if (!target.files.length) {
      return
    }
    const file = target.files[0]
    console.log('🔅 ~ handleSelectFile ~ file:', file)
    // 校验文件
    if (file.type !== 'video/mp4') {
      ElMessage.warning('仅支持MP4文件!')
      return
    }
    uploadStatus.value = 'success'
    fileInfo.value = file
    // 设置视频名称 -- 去除文件后缀
    form.name = file.name.replace(/.mp4$/, '')
    previewUrl.value = URL.createObjectURL(file)
  } catch (error) {
    console.error(error)
    uploadStatus.value = 'waiting'
  } finally {
    uploadRef.value.value = null
  }
}

const uploadRef = ref(null)
/**
 * 点击上传按钮
 */
const handleClickUpload = () => {
  uploadRef.value.click()
}

const previewVideoRef = ref(null)
const isPreview = ref(true)
/**
 * 点击预览
 */
const handleClickPreview = () => {
  // 如果正在预览,则暂停
  if (!isPreview.value) {
    previewVideoRef.value.pause()
    isPreview.value = true
    return
  }
  // 如果未正在预览,则播放
  isPreview.value = false
  previewVideoRef.value.play()
}

/**
 * 点击返回
 */
const handleClickBack = () => {
  router.back()
}

/**
 * 分片信息
 */
const chunkInfo = reactive({
  total: 0,
  uploaded: 0
})

const formRef = ref(null)
/**
 * 点击确定
 */
const handleConfirm = async () => {
  // console.log('handleConfirm', fileInfo.value)
  try {
    await formRef.value.validate()
    // 检测视频-已上传了多少分片
    const chunkCheckInfo = await checkVideoChunkApi({ name: form.name })
    if (chunkCheckInfo.code === 1) {
      return
    }
    // 已上传分片数量
    const isUploadedChunkArr = chunkCheckInfo.data
    // 分片大小
    const chunkSize = 1024 * 1024 * 20 // 20MB
    // 切片总数量
    chunkInfo.total = Math.ceil(fileInfo.value.size / chunkSize)
    // 切片列表
    const chunkList = []
    for (let i = 0; i < chunkInfo.total; i++) {
      const start = i * chunkSize
      const end = Math.min(fileInfo.value.size, start + chunkSize)
      const chunk = fileInfo.value.slice(start, end)
      chunkList.push(chunk)
    }
    uploadStatus.value = 'uploading'
    //  上传切片
    for (let i = 0; i < chunkList.length; i++) {
      let chunkIndex = i + 1
      if (isUploadedChunkArr.includes(`${chunkIndex}`)) {
        chunkInfo.uploaded++
        continue
      }
      let blobFile = new File([chunkList[i]], `${chunkIndex}.mp4`)
      const formData = new FormData()
      formData.append('file', blobFile)
      formData.append('name', form.name)
      formData.append('chunkIndex', chunkIndex)
      const flag = await uploadChunkApi(formData, (evt) => {
        progress.value = 0
        progress.value = evt?.progress ? Math.floor(evt.progress * 100) : 0
      })
      if (flag.code === 1) {
        break
      }
      chunkInfo.uploaded++
    }
    // 合并切片
    await mergeChunkApi({
      name: form.name,
      tagIds: form.tags
    })
    uploadStatus.value = 'success'
    ElMessage.success('上传成功')
    router.push({
      path: '/list',
      query: {
        tagId: form.tags[0]
      }
    })
  } catch (error) {
    console.log(error)
  }
}

const getTagListData = async () => {
  try {
    const res = await getTagList()
    if (res.code === 0) {
      tags.value = res.data
    }
  } catch (error) {
    console.log(error)
  }
}


onMounted(() => {
  getTagListData()
})

</script>
<script>
export default {
  name: 'UploadVideo'
}
</script>
<style lang="scss" scoped>
.upload-video {
  width: 100%;
  height: 100%;
  background-color: var(--el-bg-color);
}

.upload-box {
  width: 100%;
  height: 220px;
  font-size: 16px;
  border-radius: 8px;
  background-color: var(--el-fill-color-light);
  .upload-icon {
    width: 160px;
  }
  &.hover {
    &:hover {
      border-color: #409EFF;
    }
  }
}

.preview-video {
  width: 220px;
  position: relative;
  object-fit: cover;
  aspect-ratio: 16/9;
  border-radius: 4px;
  background-color: var(--el-color-primary-light-9);
  .preview-video-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: url('@/assets/play.png') no-repeat center center;
    background-size: 22% 30%;
    background-color: rgba(0, 0, 0, 0.5);
    border-radius: 4px;
  }
}

input[type="file"] {
  display: none;
}

:deep(){
  .el-form-item__label {
    margin-bottom: 4px;
  }
}
</style>

预览

未上传

image-20251015165314385.png

待上传

image-20251015174233866.png

上传中

image-20251015174351510.png

react高阶组件

一. 定义

  • 官方定义:参数为组件,返回值为新组件的函数

  • 本质:是函数而非组件,是对原有组件进行拦截封装的新组件,本质上是一种设计模式而非React API

 -   特点:
    -   接收一个组件作为参数
    -   返回一个新组件
    -   对新组件进行拦截和增强
  • 调用方式:const EnhancedComponent = higherOrderComponent(WrappedComponent)

  • 常见应用:

    • Redux中的connect函数(返回高阶组件)
    • React Router中的withRouter函数
  • 实现原理:

    • 结构:接收一个组件作为参数,返回一个新的增强组件
    • 命名规范:可通过displayName属性修改组件调试名称
    • 继承方式:新组件通常继承自PureComponent以获得性能优化

基础示例

import React, { PureComponent } from 'react'

// 定义一个高阶组件
function hoc(Cpn) {
  // 1.定义类组件
  class NewCpn extends PureComponent {
    render() {
      return <Cpn name="why"/>
    }
  }
  // 设置 displayName动态命名
  NewCpn.displayName = `HOC(${Cpn.displayName || Cpn.name || 'Component'})`;
  return NewCpn

  // 定义函数组件
  // function NewCpn2(props) {

  // }
  // return NewCpn2
}

class HelloWorld extends PureComponent {
  render() {
    return <h1>Hello World</h1>
  }
}

//直接命名
HelloWorld.displayName = 'HelloWorldComponent';

const HelloWorldHOC = hoc(HelloWorld)

export class App extends PureComponent {
  render() {
    return (
      <div>
        <HelloWorldHOC/>
      </div>
    )
  }
}

export default App

props增强

import { PureComponent } from 'react'

// 定义组件: 给一些需要特殊数据的组件, 注入props
function enhancedUserInfo(OriginComponent) {
  class NewComponent extends PureComponent {
    constructor(props) {
      super(props)

      this.state = {
        userInfo: {
          name: "clare",
          level: 1
        }
      }
    }

    render() {
      return <OriginComponent {...this.props} {...this.state.userInfo}/>
    }
  }

  return NewComponent
}

export default enhancedUserInfo

import React, { PureComponent } from 'react'
import enhancedUserInfo from './hoc/enhanced_props'
import About from './pages/About'


const Home = enhancedUserInfo(function(props) {
  return <h1>Home: {props.name}-{props.level}-{props.banners}</h1>
})


export class App extends PureComponent {
  render() {
    return (
      <div>
        <Home banners={["轮播1", "轮播2"]}/>   
      </div>
    )
  }
}

export default App

Context共享

import { createContext } from "react"

const ThemeContext = createContext()

export default ThemeContext



import ThemeContext from "../context/theme_context"

function withTheme(OriginComponment) {
  return (props) => {
    return (
      <ThemeContext.Consumer>
        {
          value => {
            return <OriginComponment {...value} {...props}/>
          }
        }
      </ThemeContext.Consumer>
    )
  }
}

export default withTheme



import React, { PureComponent } from 'react'
import withTheme from '../hoc/with_theme'
import ThemeContext from '../context/theme_context'



// export class Product extends PureComponent {
//   render() {
//     return (
//       <div>
//         Product:
//         <ThemeContext.Consumer>
//           {
//             value => {
//               return <h2>theme:{value.color}-{value.size}</h2>
//             }
//           }
//         </ThemeContext.Consumer>
//       </div>
//     )
//   }
// }

// export default Product

export class Product extends PureComponent {
  render() {
    const { color, size } = this.props

    return (
      <div>
        <h2>Product: {color}-{size}</h2>
      </div>
    )
  }
}

export default withTheme(Product)


import React, { PureComponent } from 'react'
import ThemeContext from './context/theme_context'
import Product from './pages/Product'

export class App extends PureComponent {
  render() {
    return (
      <div>
        <ThemeContext.Provider value={{color: "red", size: 30}}>
          <Product/>
        </ThemeContext.Provider>
      </div>
    )
  }
}

export default App

登录鉴权


function loginAuth(OriginComponent) {
  return props => {
    // 从localStorage中获取token
    const token = localStorage.getItem("token")

    if (token) {
      return <OriginComponent {...props}/>
    } else {
      return <h2>请先登录, 再进行跳转到对应的页面中</h2>
    }
  }
}

export default loginAuth

import React, { PureComponent } from 'react'
import loginAuth from '../hoc/login_auth'

export class Cart extends PureComponent {
  render() {
    return (
      <h2>Cart Page</h2>
    )
  }
}

export default loginAuth(Cart)


import React, { PureComponent } from 'react'
import Cart from './pages/Cart'

export class App extends PureComponent {
  constructor() {
    super()

    // this.state = {
    //   isLogin: false
    // }
  }

  loginClick() {
    localStorage.setItem("token", "hhh")

     this.setState({ isLogin: true })
    //this.forceUpdate()  //强制刷新用的较少
  }

  render() {
    return (
      <div>
        App
        <button onClick={e => this.loginClick()}>登录</button>
        <Cart/>
      </div>
    )
  }
}

export default App

二. 缺陷

  • 嵌套问题:需要包裹原组件,大量使用会产生深层嵌套
  • 调试困难:多层嵌套让props来源难以追踪
  • props劫持:可能意外覆盖传入的props(如name属性被覆盖)
  • 适用场景:类组件中仍常见,函数组件推荐使用Hooks

三. 其余高阶组件函数

memo组件作用

当父组件重新渲染时,React 默认会递归渲染所有子组件。memo 可以阻止子组件在 props 没有变化 时的重新渲染。

-   功能:类似PureComponent,对props进行浅比较(shallow compare),
-   原理:比较前后props差异决定是否重新渲染
-   本质:就是一个高阶组件,接收组件返回增强后的组件
import { useState, memo } from 'react';

// 使用 memo 包装子组件
const ChildComponent = memo(function ChildComponent({ name }) {
  console.log('ChildComponent 渲染了'); // 只有 name 变化时才会打印
  return <div>Hello, {name}!</div>;
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Alice');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        计数: {count}
      </button>
      <button onClick={() => setName('Bob')}>
        改名
      </button>
      <ChildComponent name={name} />
    </div>
  );
}

注意事项


// 注意:如果传递对象、数组或函数,memo 可能失效
const ChildComponent = memo(function ChildComponent({ user, onClick }) {
  console.log('ChildComponent 渲染了');
  return <div onClick={onClick}>Hello, {user.name}!</div>;
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  
  // 每次都会创建新的对象和函数,导致 memo 失效
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>计数</button>
      <ChildComponent 
        user={{ name: 'Alice' }}  // 每次都创建新对象
        onClick={() => {}}         // 每次都创建新函数
      />
    </div>
  );
}

解决:使用 useMemo 和 useCallback 来保持引用稳定。

useMemo vs useCallback 对比

特性 useMemo useCallback
缓存函数 useMemo(() => fn, deps) useCallback(fn, deps)
缓存对象 useMemo(() => obj, deps) 不适用
返回值 缓存函数返回的值 直接缓存函数本身
等价关系 useCallback(fn, deps) = useMemo(() => fn, deps)

forwardRef作用

-   问题背景:函数组件无实例,无法直接绑定ref
-   解决方案:使用forwardRef将ref作为第二个参数传递
-   限制:仅适用于函数组件,类组件使用会报错

其余用法可查看: juejin.cn/post/718658…

import { useRef, forwardRef } from 'react';

// 简单的按钮组件
const FancyButton = forwardRef((props, ref) => {
return (
  <button ref={ref} style={{ padding: '10px 20px' }}>
    {props.children}
  </button>
);
});

function App() {
const buttonRef = useRef(null);

const focusButton = () => {
  buttonRef.current.focus(); // 聚焦按钮
};

return (
  <div>
    <FancyButton ref={buttonRef}>点击我</FancyButton>
    <button onClick={focusButton}>让上面按钮获得焦点</button>
  </div>
);
}

PDF中的图像与外部对象

一、PDF 里的“图像”和“外部对象”是什么?

简单来说,PDF 页面就像一张大画布,而 外部对象(XObject) 就是预先准备好、可以随时贴上去的小画布。
每张图片、每个可复用图形,都是一个 XObject。

举个例子:

假设你在 WPS 里插入了一张公司 Logo,PDF 导出后不会把这张图直接画进页面文字流里,而是:

  1. 把这张图单独存成一个对象(小画布);
  2. 在页面内容里,只写一句话告诉 PDF 阅读器去把那张图画上去。

二、Image XObject对象

当你在WPS中插入一张图片时,会在pdf底层生成一个 Image XObject对象, 它是 XObject 的一种类型,专门用于存放位图数据,比如 PNG、JPEG 等。

它的定义看起来像这样:

10 0 obj
<< /Type /XObject
   /Subtype /Image
   /Width 600
   /Height 400
   /ColorSpace /DeviceRGB
   /BitsPerComponent 8
   /Filter /DCTDecode
>>
stream
... JPEG 图片二进制数据 ...
endstream
endobj

上面这个对象(编号 10)保存了整张图片的数据。

三、Do 操作符

PDF 页面并不会直接存图片,而是通过 /名字 Do 的方式引用。
例如:

/KSPX1 Do

这就相当于说:“把名字叫 KSPX1 的图像贴到这里”。

名字 /KSPX1 并不是固定的,它来自于页面的资源字典(Resources)。
在你的 PDF 里看到 /KSPX1 Do,说明资源里定义了一个 /KSPX1 的图片对象。

四、WPS图片对象的完整结构

假设你在 WPS 中插入了一张图片(比如一张 PNG),
WPS 导出 PDF 后大致结构如下:

1️⃣ 页面资源定义

<<
  /Type /Page
  /Resources <<
    /XObject << /KSPX1 10 0 R >>   % 图片注册在资源字典中
    /ProcSet [/PDF /ImageC]
  >>
  /Contents 20 0 R
>>

2️⃣ 图片对象(Image XObject)

10 0 obj
<< /Type /XObject
   /Subtype /Image
   /Width 1200
   /Height 800
   /ColorSpace /DeviceRGB
   /BitsPerComponent 8
   /Filter /DCTDecode
   /Length 123456
>>
stream
... 图片数据(压缩后的二进制) ...
endstream
endobj

3️⃣ 页面内容流(Contents)

20 0 obj
<< /Length 44 >>
stream
q
1 0 0 1 100 200 cm   % 移动到页面坐标(100, 200)
100 0 0 100 0 0 cm   % 缩放或旋转(视情况)
/KSPX1 Do            % 绘制图片(资源名 KSPX1)
Q
endstream
endobj

在这里:

  • /KSPX1 是资源名称;
  • 10 0 R 是图片对象的引用;
  • /KSPX1 Do 就是告诉 PDF 阅读器:“把第10号图片对象画在当前坐标处”。

这就是你在 WPS 里插入图片后,PDF 实际干的事情。

五、其他图片形式

1️⃣ 内联图像(Inline Image)

有时候图片很小,比如一个小图标。
为了方便,不用单独定义成对象,可以直接写进内容流里:

BI
  /W 32 /H 32 /CS /RGB /BPC 8
ID
... 图片数据 ...
EI

这是内联图像,不需要 /Do 引用。

2️⃣ 外部图像(External Image)

PDF 里也可以引用外部文件的图片,比如网络图片:

/F << /FS /URL /F (http://example.com/logo.jpg) >>

PDF 阅读器会在需要时加载。

六、总结

总的来说,在pdf中图片的渲染流程为:

页面内容告诉 PDF:用 /KSPX1 Do 贴一张图;
/KSPX1 在资源里指向图片对象(10 0 obj);
图片对象里存着真实的图像数据。

图片对象类型对比:

类型 说明 是否可复用 数据位置
Image XObject 普通图片 ✅ 是 独立对象
Form XObject 一组图形或文字 ✅ 是 独立对象
Inline Image 小图标 ❌ 否 内容流中
External Image 外部引用 ✅ 是 文件外部

PDF 里的图片不是直接画上去的,而是通过 XObject 引用。
/KSPX1 Do 就像调用函数一样,把图片从资源库“贴”到页面上。
所以无论是 /Im1 还是 /KSPX1,它们的本质都一样——
一个资源名 + 一个图片对象。

Shadcn/ui 重磅更新:7 个实用新组件深度解析与实战指南

Shadcn/ui 重磅更新:7 个实用新组件深度解析与实战指南

引言:不止于更新,更是对开发者工作流的深度思考

前端社区近来波澜再起,而焦点无疑是 Shadcn/ui 的一次重磅更新。这并非一次常规的版本迭代,而是一次深思熟虑的功能扩展,旨在解决开发者在日常工作中反复遇到的真实痛点。正如其官方更新日志所言,这次更新聚焦于那些“我们每天都在构建,一遍又一遍重复重建的枯燥东西”,并为之提供了可复用的抽象。

本次更新引入了七个全新的组件:Spinner、Kbd、Button Group、Input Group、Field、Item 和 Empty。每一个组件都精准地切入了现代 UI 开发中的一个常见场景,极大地提升了开发效率和代码质量。

在深入探讨这些组件之前,我们有必要重温 Shadcn/ui 的核心理念。它并非一个传统的组件库,而是一套帮助你构建 属于你自己的 组件库的工具集。你不是在安装一个黑盒式的依赖包,而是将组件的源代码直接复制到你的项目中,从而获得完全的代码所有权和零厂商锁定的自由度。这次新增的七个组件,同样遵循这一原则,它们是开发者工具箱中一个个透明、可定制的强大积木。

新组件概览:扩展你的开发工具箱

为了让读者快速了解此次更新的全貌,下表总结了七个新组件的核心功能与典型应用场景。这张速查表既是本文的导览,也能在你未来的开发工作中充当便捷的参考手册。

组件 (Component) 核心功能 (Core Function) 常见用例 (Common Use Cases)
Spinner 一个用于显示加载状态的指示器。 表单提交、异步数据获取、按钮加载状态、文件上传进度。
Kbd 用于显示单个或一组键盘按键。 文档中的快捷键说明、命令面板(如 ⌘K)、按钮或工具提示中的操作提示。
Button Group 将相关的按钮组合在一起,用于动作或分割按钮。 工具栏、表单操作组、带有下拉菜单的分割按钮、与输入框结合的复合控件。
Input Group 允许为输入框添加图标、按钮、标签等附加元素。 带搜索图标的输入框、带单位或协议前缀/后缀的输入框、带复制或提交按钮的输入框。
Field 一个组件,搞定所有表单。提供构建复杂表单的统一方案。 "构建与任何表单库(React Hook Form, TanStack Form)或原生 Server Actions 解耦的、具有响应式布局的复杂表单。"
Item 用于显示项目列表、卡片等多种内容的通用容器。 用户列表、通知中心、设置选项、个人资料卡片、任何需要灵活布局的列表项。
Empty 用于处理和展示各种空状态场景。 列表无数据、搜索无结果、404 页面、用户未创建任何内容的初始状态。

组件深度解析

接下来,我们将逐一深入剖析这七个组件,从安装、基础用法到高级技巧,并结合丰富的代码示例,展示它们在实际项目中的强大威力。

Spinner: 小巧而强大的加载指示器

看似简单的加载指示器,却是每个项目中不可或缺的元素。以往,开发者不得不在各个角落重复编写 animate-spin 等 Tailwind CSS 工具类。Spinner 组件的出现,正是为了将这种重复的样式抽象成一个干净、可复用的组件,从而提升代码的整洁度与一致性。

快速上手

安装

pnpm dlx shadcn@latest add spinner

基础用法与导入

import { Spinner } from "@/components/ui/spinner";

<Spinner />;
import { Spinner } from "@/components/ui/spinner"

export function SpinnerBasic() {
  return (
    <div className="flex flex-col items-center justify-center gap-8">
      <Spinner />
    </div>
  )
}

image.png

实战示例

在按钮中使用 这是 Spinner 最常见的应用场景。当按钮触发表单提交或数据请求时,显示加载状态以提供即时反馈。值得一提的是,<Button /> 组件会自动处理 Spinner 与文本之间的间距,无需手动调整。

import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";

function SpinnerButton() {
  return (
    <Button disabled size="sm">
      <Spinner />
      Loading...
    </Button>
  );
}

image.png

尺寸与颜色定制 Spinner 的定制化非常直观,完全遵循 Tailwind CSS 的设计哲学。你可以通过标准的 size-*text-* 工具类来轻松调整其大小和颜色,确保了整个项目视觉风格的统一。

<div className="flex items-center gap-6">
  <Spinner className="size-3" />
  <Spinner className="size-6 text-blue-500" />
  <Spinner className="size-8 text-red-500" />
</div>

image.png

在其他组件中组合 Spinner 的价值在于其出色的可组合性。它可以无缝地嵌入到其他组件中,例如 Badge 或 Input Group,进一步丰富了 UI 的表达能力。

import { Badge } from "@/components/ui/badge";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";

// 在 Badge 中
<Badge>
  <Spinner />
  Syncing
</Badge>

// 在 Input Group 中
<InputGroup>
  <InputGroupAddon align="inline-end">
    <Spinner />
  </InputGroupAddon>
</InputGroup>

通过 Spinner 组件,我们可以看到 Shadcn/ui 的设计精髓:将重复的、底层的样式逻辑封装成一个声明式的、高内聚的组件。开发者不再需要关心 animate-spin 这些实现细节,而是能够以更高级的抽象来思考问题,这不仅减少了模板代码,更重要的是,它让代码的意图变得更加清晰,从而提高了项目的长期可维护性。

Kbd: 优雅地展示键盘快捷键

在 Linear 等现代生产力工具的引领下,清晰地展示键盘快捷键已成为提升用户体验、服务高级用户的关键一环。过去,开发者可能会选择滥用 <Badge /> 组件来模拟键盘按键的样式,但这在语义上并不准确。Kbd 组件的诞生,为此提供了一个语义正确且视觉精致的专属解决方案。

快速上手

安装

pnpm dlx shadcn@latest add kbd

基础用法与导入 Kbd 组件通常与 KbdGroup 配合使用,以展示单个按键或组合键。

import { Kbd, KbdGroup } from "@/components/ui/kbd";

<KbdGroup>
  <Kbd></Kbd>
  <Kbd>K</Kbd>
</KbdGroup>
实战示例

在 UI 文本中嵌入 将快捷键自然地融入提示文本中,引导用户进行高效操作。

<p className="text-muted-foreground text-sm">
  Use{" "}
  <KbdGroup>
    <Kbd>Ctrl + K</Kbd>
  </KbdGroup>{" "}
  to open the command palette.
</p>

image.png

在按钮与工具提示中使用 在按钮或工具提示中加入快捷键提示,可以极大地增强界面的可发现性和易用性。

import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";

<Button variant="outline" size="sm" className="pr-2">
  Accept <Kbd></Kbd>
</Button>

<Tooltip>
  <TooltipTrigger asChild>
    <Button size="sm" variant="outline">Print</Button>
  </TooltipTrigger>
  <TooltipContent>
    <div className="flex items-center gap-2">
      Print Document{" "}
      <KbdGroup>
        <Kbd>Ctrl</Kbd>
        <Kbd>P</Kbd>
      </KbdGroup>
    </div>
  </TooltipContent>
</Tooltip>

image.png

在输入框组中应用 这是 Kbd 组件最具代表性的应用场景之一:在搜索框等输入组件旁显示快捷键提示,已成为现代 Web 应用的标志性设计模式。

import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { SearchIcon } from "lucide-react";

<InputGroup>
  <InputGroupInput placeholder="Search..." />
  <InputGroupAddon align="inline-end">
    <Kbd></Kbd>
    <Kbd>K</Kbd>
  </InputGroupAddon>
</InputGroup>

Kbd 组件虽小,却体现了 Shadcn/ui 对 UI 设计细节的极致追求。它是一个战略性的微小工具,通过推广一致的 UX 文档微模式,提升了应用的整体专业感和用户体验。一个成熟的设计体系,正是由无数个这样对细节的关注所构成的。它鼓励开发者从“能用”走向“好用”,为用户提供更贴心的交互引导。

Button Group: 告别繁琐的圆角处理

将功能相关的按钮组合在一起是 UI 设计中的常见需求。然而,在手动实现时,开发者往往需要与 rounded-l-nonerounded-r-none 等 Tailwind CSS 类名作斗争,以消除相邻按钮间的圆角,这个过程既繁琐又容易出错。ButtonGroup 组件优雅地解决了这个问题,它会自动处理组合内元素的样式,让开发者可以专注于功能而非样式细节。

快速上手

安装

pnpm dlx shadcn@latest add button-group

基础用法与导入 只需将多个 <Button /> 组件包裹在 <ButtonGroup /> 中即可。

import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";

<ButtonGroup>
  <Button variant="outline">Archive</Button>
  <Button variant="outline">Report</Button>
</ButtonGroup>

image.png

实战示例

分割按钮 (Split Buttons) 这是 ButtonGroup 的一个核心特性。通过组合一个标准按钮、一个 ButtonGroupSeparator 分隔符和一个下拉菜单触发器,可以轻松构建出功能强大的分割按钮,这是一个以往实现起来较为复杂的模式。

import { Button } from "@/components/ui/button"
import {
  ButtonGroup,
  ButtonGroupSeparator,
} from "@/components/ui/button-group"

export function ButtonGroupSeparatorDemo() {
  return (
    <ButtonGroup>
      <Button variant="secondary" size="sm">
        Copy
      </Button>
      <ButtonGroupSeparator />
      <Button variant="secondary" size="sm">
        Paste
      </Button>
    </ButtonGroup>
  )
}
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,... } from "@/components/ui/dropdown-menu";
import { ButtonGroupSeparator } from "@/components/ui/button-group";
import { ChevronDownIcon } from "lucide-react";

<ButtonGroup>
  <Button variant="outline">Follow</Button>
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant="outline" className="!pl-2">
        <ChevronDownIcon />
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent>
      {/* Dropdown items... */}
    </DropdownMenuContent>
  </DropdownMenu>
</ButtonGroup>

image.png

与输入框/选择器组合 ButtonGroup 的强大之处在于其通用性,它不仅能组合按钮,还能无缝地将 <Input /><Select /> 等表单控件组合在一起,非常适合创建货币输入、搜索栏等复合控件。

import { Input } from "@/components/ui/input";
import { Select, SelectTrigger, SelectValue, SelectContent,... } from "@/components/ui/select";

<ButtonGroup>
  <Select>
    <SelectTrigger className="font-mono">$</SelectTrigger>
    <SelectContent>{/* Select items */}</SelectContent>
  </Select>
  <Input placeholder="10.00" />
  <Button>Submit</Button>
</ButtonGroup>

方向与分隔符 通过 orientation="vertical" 属性可以创建垂直排列的按钮组。对于非 outline 样式的按钮,建议使用 ButtonGroupSeparator 来增强视觉上的分隔感。

<ButtonGroup>
  <Button variant="secondary" size="sm">Copy</Button>
  <ButtonGroupSeparator />
  <Button variant="secondary" size="sm">Paste</Button>
</ButtonGroup>

ButtonGroup 是一个典型的结构化组件,它将复杂的布局和样式逻辑封装起来。这种抽象让开发者的代码能够更直接地反映其设计意图——“将这些动作组合在一起”,而不是纠缠于“如何处理这些边框和圆角”的实现细节。这大大加快了开发速度,减少了样式 Bug,并产出了更具语义化的、更清晰的标记结构。

Input Group: 为你的输入框添加超能力

现代 Web 应用中的输入框早已超越了简单的文本字段。它们需要承载图标、前缀/后缀文本、行内按钮(如复制、搜索)等丰富的功能。手动实现这些复杂的输入框,往往需要借助繁琐的 position: relative/absolute 布局和精确的 padding 调整,这不仅耗时,而且代码脆弱,难以维护。Input Group 组件为此提供了一个结构化、高灵活性的容器,让构建这些复杂输入框变得前所未有的简单。

快速上手

安装

pnpm dlx shadcn@latest add input-group

基础用法与导入 通过组合 InputGroupInput 和 InputGroupAddon,可以轻松地为输入框添加附加元素。

import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { SearchIcon } from "lucide-react";

<InputGroup>
  <InputGroupInput placeholder="Search..." />
  <InputGroupAddon>
    <SearchIcon />
  </InputGroupAddon>
</InputGroup>

image.png

实战示例

图标、文本与按钮 Input Group 提供了丰富的子组件来满足各种需求,无论是添加图标、前缀/后缀文本,还是嵌入交互式按钮,都能轻松实现。

import { InputGroupText, InputGroupButton } from "@/components/ui/input-group";

// 带文本前缀
<InputGroup>
  <InputGroupAddon>
    <InputGroupText>https://</InputGroupText>
  </InputGroupAddon>
  <InputGroupInput placeholder="example.com" />
</InputGroup>

// 带文本后缀
<InputGroup>
  <InputGroupInput placeholder="username" />
  <InputGroupAddon align="inline-end">
    <InputGroupText>@company.com</InputGroupText>
  </InputGroupAddon>
</InputGroup>

// 带行内按钮
<InputGroup>
  <InputGroupInput value="npm install react" readOnly />
  <InputGroupAddon align="inline-end">
    <InputGroupButton>Copy</InputGroupButton>
  </InputGroupAddon>
</InputGroup>

支持 Textarea Input Group 的能力不仅限于 <input />,它同样完美支持 <textarea />,这使得构建带有行号、操作按钮等功能的富文本编辑组件成为可能。

import { InputGroupTextarea } from "@/components/ui/input-group";

<InputGroup>
  <InputGroupTextarea placeholder="Send a message..." />
  <InputGroupAddon align="block-end">
    <InputGroupButton variant="default">Send</InputGroupButton>
  </InputGroupAddon>
</InputGroup>

Input Group 标志着 Shadcn/ui 在表单控件组合能力上的一次重要进化。它不再将输入框视为一个孤立的元素,而是将其看作一个微型的、专门用于输入场景的布局系统。它从根本上解决了一整类过去需要为每个项目编写自定义 CSS 才能解决的问题。这种声明式的、健壮的组合方式,深刻地体现了现代 UI 开发中“组合优于继承”的思想。

Field: 表单开发的未来范式

Field 是本次更新中最为重要、最具变革性的组件。Shadcn/ui 旧有的 <Form> 组件与 React Hook Form 和 Zod 深度绑定,在一定程度上限制了开发者的技术选型。而全新的 Field 组件则是一个革命性的、与框架无关的解决方案,它提供了一套用于包裹标签、描述、错误信息等的标准接口,可以与 任何 表单库(如 React Hook Form、TanStack Form)、服务端操作(Server Actions)乃至原生 HTML 表单无缝协作。

快速上手

安装

pnpm dlx shadcn@latest add field

组件结构与基础用法 Field 体系的核心是其子组件的分工:Field 作为根容器,FieldLabel 负责标签,FieldDescription 提供辅助文本,FieldError 展示校验错误。

import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input";

<Field>
  <FieldLabel htmlFor="username">Username</FieldLabel>
  <Input id="username" placeholder="shadcn" />
  <FieldDescription>Choose a unique username for your account.</FieldDescription>
  <FieldError>This username is already taken.</FieldError>
</Field>
实战示例

适配所有表单控件 Field 的设计极具通用性,可以与 Input、Textarea、Select、Checkbox、Switch 等所有类型的表单控件完美结合,为构建一致的表单体验提供了坚实基础。

import { Checkbox } from "@/components/ui/checkbox";

<Field orientation="horizontal">
  <Checkbox id="terms" />
  <FieldLabel htmlFor="terms">Accept terms and conditions</FieldLabel>
</Field>

使用 FieldSet 和 FieldGroup 组织复杂表单 对于包含多个区域的复杂表单,可以使用 FieldSet 和 FieldLegend 进行语义化分组,并用 FieldGroup 来组织相关的字段集合,使表单结构更加清晰、可维护。

import { FieldSet, FieldLegend, FieldGroup } from "@/components/ui/field";

<FieldSet>
  <FieldLegend>Address Information</FieldLegend>
  <FieldDescription>We need your address to deliver your order.</FieldDescription>
  <FieldGroup>
    <Field>{/* Street Address */}</Field>
    <Field>{/* City */}</Field>
    <Field>{/* Postal Code */}</Field>
  </FieldGroup>
</FieldSet>

image.png

响应式布局 这是 Field 组件的一大亮点。通过设置 orientation="responsive" 属性,Field 组件可以根据容器宽度在水平和垂直布局之间自动切换,极大地简化了响应式表单的设计与实现。

// 在宽屏下,标签和描述在左,控件在右
// 在窄屏下,标签和描述在上,控件在下
<Field orientation="responsive">
  <FieldContent>
    <FieldTitle>Notification Method</FieldTitle>
    <FieldDescription>How you want to receive notifications.</FieldDescription>
  </FieldContent>
  <Select>{/*... */}</Select>
</Field>

Field 组件的推出是 Shadcn/ui 的一次战略性转向,它将库的定位从一个纯粹的“组件提供者”提升到了“UI 架构提供者”的高度。通过将表单的 表现层 与 状态管理逻辑 彻底解耦,Field 完美践行了 Shadcn/ui 赋予开发者最大自由度的核心哲学。

它不再对底层技术栈做任何假设,而是为任何基于 React 的表单应用提供了一个坚固、可靠且通用的视图层基础。这不仅仅是一个新组件,更是 Shadcn/ui 对 Web 开发中最复杂领域之一的深刻反思和范式重塑,巩固了其作为真正基础性和适应性系统的地位。

Item: 灵活的列表与卡片布局容器

在 Item 组件出现之前,开发者常常会使用通用的 <Card> 组件来展示列表项或简单的信息块,即使这些场景并不需要 <Card> 严格的 Header/Content/Footer 结构。Item 组件正是为解决这一问题而生,它是一个专为列表项设计的、高度灵活的 Flex 容器,并提供了 ItemMedia、ItemContent、ItemTitle、ItemDescription 和 ItemActions 等语义化的子组件插槽。

快速上手

安装

pnpm dlx shadcn@latest add item

基础用法与导入 一个典型的 Item 包含标题、描述和一个操作按钮。

import { Item, ItemContent, ItemTitle, ItemDescription, ItemActions } from "@/components/ui/item";
import { Button } from "@/components/ui/button";

<Item variant="outline">
  <ItemContent>
    <ItemTitle>Basic Item</ItemTitle>
    <ItemDescription>A simple item with title and description.</ItemDescription>
  </ItemContent>
  <ItemActions>
    <Button variant="outline" size="sm">Action</Button>
  </ItemActions>
</Item>

image.png

实战示例

包含媒体元素 (图标/头像) 使用 ItemMedia 插槽可以非常方便地在 Item 的起始位置添加图标或头像,轻松构建通知列表、用户资料等常见 UI 模式。

import { ItemMedia } from "@/components/ui/item";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";

<Item variant="outline">
  <ItemMedia>
    <Avatar>
      <AvatarImage src="https://github.com/shadcn.png" />
      <AvatarFallback>CN</AvatarFallback>
    </Avatar>
  </ItemMedia>
  <ItemContent>
    <ItemTitle>Evil Rabbit</ItemTitle>
    <ItemDescription>Frontend Developer</ItemDescription>
  </ItemContent>
  <ItemActions>{/*... */}</ItemActions>
</Item>

使用 ItemGroup 创建列表 将多个 Item 组件包裹在 ItemGroup 中,可以快速创建一个带有合适间距和分隔线的精美列表。

import { ItemGroup } from "@/components/ui/item";

<ItemGroup>
  <Item>{/* Item 1 */}</Item>
  <Item>{/* Item 2 */}</Item>
  <Item>{/* Item 3 */}</Item>
</ItemGroup>

可点击的 Item (asChild 属性) asChild 是一个非常强大的属性,它允许将 Item 组件的行为和语义“委托”给其唯一的子元素。例如,通过包裹一个 <a> 标签,可以使整个 Item 区域都成为一个可点击的链接,而不会破坏 HTML 的语义结构。

<Item variant="outline" asChild>
  <a href="#">
    <ItemContent>
      <ItemTitle>Go to Settings</ItemTitle>
    </ItemContent>
    <ItemActions>{/* e.g., a chevron icon */}</ItemActions>
  </a>
</Item>

Item 组件的引入,标志着 Shadcn/ui 的 API 设计正走向成熟。它不再满足于提供通用容器,而是开始为特定场景提供更专业、语义更恰当的工具。这种精细化的划分,能够引导开发者写出更清晰、更具可读性的组件结构,避免了对 <Card> 等通用组件的过度使用,是设计体系演进过程中的一个重要里程碑。

Empty: 被忽略的角落,优雅的空状态处理

空状态(例如,列表中没有项目、搜索没有结果)是构成良好用户体验的关键一环,但在开发和设计阶段却常常被忽视。Empty 组件的出现,正是为了填补这一“被遗忘的角落”,它提供了一个专用的、结构化的组件,帮助开发者轻松、一致地构建各种空状态页面。

快速上手

安装

pnpm dlx shadcn@latest add empty

基础用法与导入 Empty 组件由多个子组件构成,包括 EmptyHeader、EmptyMedia(用于图标或图片)、EmptyTitle、EmptyDescription 和 EmptyContent(用于操作按钮等)。

import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent } from "@/components/ui/empty";
import { Button } from "@/components/ui/button";
import { IconFolderCode } from "@tabler/icons-react";

<Empty>
  <EmptyHeader>
    <EmptyMedia variant="icon">
      <IconFolderCode />
    </EmptyMedia>
    <EmptyTitle>No Projects Yet</EmptyTitle>
    <EmptyDescription>
      You haven't created any projects yet. Get started by creating your first project.
    </EmptyDescription>
  </EmptyHeader>
  <EmptyContent>
    <Button>Create Project</Button>
  </EmptyContent>
</Empty>

image.png

实战示例

多样的视觉风格 通过简单的 Tailwind CSS 工具类,可以为 Empty 组件赋予不同的外观,如带虚线边框的轮廓样式,或带有渐变背景的柔和样式。

// 轮廓样式
<Empty className="border border-dashed">{/*... */}</Empty>

// 背景样式
<Empty className="from-muted/50 to-background bg-gradient-to-b">{/*... */}</Empty>

交互式的空状态 Empty 组件的强大之处在于其可组合性。你可以在 EmptyContent 中嵌入任何交互式组件,例如一个 InputGroup。这对于“无搜索结果”并提示用户再次搜索的场景,或是构建一个带有搜索功能的 404 页面,都极为有用。

<Empty>
  <EmptyHeader>
    <EmptyTitle>404 - Not Found</EmptyTitle>
    <EmptyDescription>
      The page you're looking for doesn't exist. Try searching for what you need below.
    </EmptyDescription>
  </EmptyHeader>
  <EmptyContent>
    <InputGroup className="sm:w-3/4">
      <InputGroupInput placeholder="Try searching for pages..." />
      <InputGroupAddon align="inline-end">
        <Kbd>/</Kbd>
      </InputGroupAddon>
    </InputGroup>
  </EmptyContent>
</Empty>

Empty 组件是对 UX 最佳实践的一次直接投资。通过为“空状态”这一场景提供一个一等公民级别的组件,Shadcn/ui 正在积极地引导开发者去思考 UI 的完整生命周期,而不仅仅是数据加载成功的“快乐路径”。它降低了实现优秀用户体验的门槛,让“做正确的事”变得“更容易”。这体现了一个成熟的 UI 库对最终产品质量的深切关怀,而不仅仅是满足开发者的即时需求。

宏观视角:一个更成熟、更完整的生态系统

这次更新的意义远不止于增加了七个独立的组件。从宏观上看,它标志着 Shadcn/ui 生态系统正迈向一个更成熟、更完整的阶段。

这些新组件,特别是 Field、Input Group 和 Button Group,完美地扮演了 Radix UI 无头组件原语 (headless primitives) 和 Tailwind CSS 原子化工具类之间的“结构层”角色。它们并非简单的 UI 元素,而是经过精心设计的、可复用的 结构化模式 ,填补了底层工具留下的空白。

尤其是 Field 组件的发布,堪称一场“表单革命”。它以其与框架无关的特性,将开发者从旧 <Form> 组件的束缚中解放出来,真正兑现了 Shadcn/ui 作为一个非侵入性、无观点工具集的承诺。这使得 Shadcn/ui 的适用范围大大扩展,能够融入更多样化的技术栈中。

总而言之,这七个组件的集合(Input Group、Button Group、Item、Empty 等)深刻地体现了对构建真实世界、生产级应用的全面理解。Shadcn/ui 的关注点已经从提供漂亮的按钮和卡片,扩展到了解决那些不那么光鲜亮丽、但却至关重要的 UI 开发难题。

总结与展望

本次 Shadcn/ui 的更新,通过 Spinner、Kbd、Button Group、Input Group、Field、Item 和 Empty 这七个组件,极大地简化了前端开发中的常见任务,提升了代码质量和开发效率。它们不仅是功能强大的工具,更是 Shadcn/ui 设计哲学的完美体现:赋予开发者完全的控制权,同时提供经过深思熟虑的最佳实践抽象。

现在,我们想听听你的声音。你对哪个新组件最感到兴奋?全新的 Field 组件会如何改变你构建表单的方式?欢迎在评论区分享你的看法和实践经验。

毫无疑问,这次更新再次证明了 Shadcn/ui 对开发者体验的极致追求,并进一步巩固了其作为现代前端开发不可或缺的工具的地位。我们有理由相信,这个充满活力的生态系统未来将带给我们更多惊喜。

【DEMO】互动信息墙 - 无限流动版-点击放大

PixPin_2025-10-14_15-10-53.png

一、 核心概念:流动动画 (The Flow Animation)

这是整个系统的“心跳”,它独立于用户交互,持续不断地运行。

  1. 放弃容器滚动,拥抱绝对定位

    • 我们不再使用父容器的滚动条 (scrollLeft)。
    • 整个卡片容器 (.cards-container) 变成一个静态的“舞台”,所有卡片都通过 position: absolute 定位在其中。
  2. 动画循环是唯一驱动力 (requestAnimationFrame)

    • 我们创建一个名为 animateFlow 的函数,并通过 requestAnimationFrame 让它每秒执行约 60 次,形成一个平滑的动画循环。
    • 这个循环是整个流动效果的引擎。
  3. 独立的位置状态 (currentX)

    • 每个卡片对象都有自己的 currentX 属性,记录它在虚拟的无限长画卷上的当前 X 坐标。
    • 在动画的每一帧,我们都微量地减少所有未展开卡片的 currentX 值,模拟它们向左移动。
  4. 无限循环的“传送门”

    • 这是实现“无限”的关键。动画循环会持续监控每个卡片的位置。
    • 当一个卡片的右边缘完全移出屏幕左侧(card.currentX + card.width < 0)时,系统会立即将它“传送”到整个卡片队列的最右端。
    • 这个“传送”是通过给 currentX 加上一个预先计算好的 totalFlowWidth(所有行中最长一行的总宽度 + 一个屏幕宽度)来实现的。视觉上,这个过程是无缝的。
  5. 性能优化 (transform: translateX)

    • 为了避免频繁修改 left 属性导致的性能问题(浏览器重排),我们将卡片的初始位置用 left 和 top 固定下来。
    • 所有后续的流动动画都通过修改 CSS 的 transform: translateX() 属性来实现。这个属性可以被 GPU 加速,性能远高于修改 left

二、 核心概念:状态管理与交互 (State Management & Interaction)

这部分逻辑负责响应用户的操作,并改变系统的状态,动画循环会根据这些状态来调整自己的行为。

  1. 定义清晰的状态变量

    • isFlowing: 一个布尔值,作为流动的“总开关”。默认 true
    • flowPausedByMouse: 另一个布尔值,用于鼠标悬停时临时暂停,提升用户体验。
    • card.isExpanded: 每个卡片对象内部的状态,标记自己是否被放大。
    • expandedCards: 一个集合(Set),用于快速追踪当前有多少张卡片被放大了。
  2. 交互改变状态,而非直接操作动画

    • 展开卡片 (expandCard) :

      1. 停止流动isFlowing 设置为 false
      2. 改变状态card.isExpanded 设为 true
      3. 视觉变化: 通过 CSS class 和直接修改 widthheightlefttop 来实现放大效果,并清除流动的 transform
      4. 触发推开: 调用 updateCardPositions 来移动周围的卡片。
    • 收起卡片 (collapseCard) :

      1. 改变状态card.isExpanded 设为 false
      2. 检查并恢复流动: 检查 expandedCards 集合是否为空。如果为空,说明所有卡片都已关闭,此时将 isFlowing 重新设为 true
      3. 恢复视觉: 将卡片尺寸和位置恢复,并重新应用基于其 currentX 的 transform,让它无缝地回到流动队列中。
      4. 触发推开逻辑更新: 再次调用 updateCardPositions,让被推开的卡片归位。
  3. “推开”效果的实现 (updateCardPositions)

    • 当有卡片被放大时,此函数被调用。
    • 它会遍历所有未展开的卡片,判断它们是否与任何一个已展开卡片的区域重叠。
    • 如果重叠,它会暂时修改该卡片的 top 值(或添加一个 avoiding class),使其在视觉上“躲开”。
    • 在躲避期间,该卡片的水平流动会暂停,以防止位置错乱。

总结

整个项目的架构可以看作是两层:

  • 底层 (动画引擎) : 一个持续运行、只做一件事的 animateFlow 循环,它根据当前的状态数据来更新所有卡片的位置。
  • 上层 (交互逻辑) : 一系列响应用户操作的函数 (expandCardcollapseCardstartDragging 等),它们不直接控制动画的每一帧,而是通过修改底层的状态数据(如 isFlowingcard.isExpanded)来“指挥”动画引擎如何表现。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>互动信息墙 - 无限流动版</title>
    <style>
        /* ... (大部分CSS保持不变) ... */
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background: linear-gradient(135deg, #0f0f1e 0%, #1a1a2e 100%);
            color: #ffffff;
            overflow: hidden;
            height: 100vh;
            position: relative;
        }

        .wall-header {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            padding: 20px 40px;
            background: rgba(0, 0, 0, 0.5);
            backdrop-filter: blur(20px);
            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
            display: flex;
            justify-content: space-between;
            align-items: center;
            z-index: 10000;
        }

        .wall-header h1 {
            font-size: 28px;
            font-weight: 700;
            background: linear-gradient(90deg, #00d4ff, #7b2ff7, #ff006e);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
            animation: gradient-shift 3s ease infinite;
        }

        @keyframes gradient-shift {
            0%, 100% { background-position: 0% 50%; }
            50% { background-position: 100% 50%; }
        }

        .stats {
            display: flex;
            gap: 30px;
            font-size: 14px;
            color: rgba(255, 255, 255, 0.7);
        }

        .stats span {
            padding: 8px 16px;
            background: rgba(255, 255, 255, 0.05);
            border-radius: 20px;
            border: 1px solid rgba(255, 255, 255, 0.1);
        }

        /* --- [修改] 主容器不再需要滚动条,改为隐藏溢出 --- */
        .wall-viewport {
            position: fixed;
            top: 80px;
            left: 0;
            right: 0;
            bottom: 0;
            overflow: hidden; /* 修改这里 */
            padding: 20px;
        }

        .cards-container {
            position: relative;
            width: 100%; /* 修改这里 */
            height: 100%; /* 修改这里 */
        }
        
        /* --- [修改] 卡片的 transform 也会变化,加入 will-change --- */
        .card {
            position: absolute;
            border-radius: 12px;
            overflow: hidden;
            background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
            backdrop-filter: blur(10px);
            border: 1px solid rgba(255, 255, 255, 0.1);
            cursor: pointer;
            /* --- [修改] 动画现在主要作用于 transform --- */
            transition: all 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
            will-change: transform, left, top, width, height;
        }

        /* ... (其余 CSS 保持不变) ... */
        .card:not(.expanded):hover {
            transform: scale(1.05);
            box-shadow: 
                0 0 40px rgba(0, 212, 255, 0.4),
                0 20px 60px rgba(0, 0, 0, 0.4);
            border-color: rgba(0, 212, 255, 0.6);
            z-index: 100;
        }

        .card.expanded {
            cursor: default;
            box-shadow: 
                0 0 80px rgba(0, 212, 255, 0.6),
                0 30px 100px rgba(0, 0, 0, 0.6),
                inset 0 0 50px rgba(255, 255, 255, 0.1);
            border: 2px solid rgba(0, 212, 255, 0.8);
            background: linear-gradient(135deg, #1e1e2e, #2a2a3e);
        }

        .card.avoiding {
            transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
        }
        .card-content {
            width: 100%;
            height: 100%;
            position: relative;
            overflow: hidden;
        }

        .card-content img {
            width: 100%;
            height: 100%;
            object-fit: cover;
            transition: transform 0.3s ease;
        }

        .card:not(.expanded):hover .card-content img {
            transform: scale(1.1);
        }

        .card-overlay {
            position: absolute;
            bottom: 0;
            left: 0;
            right: 0;
            padding: 12px;
            background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.5), transparent);
            transform: translateY(100%);
            transition: transform 0.3s ease;
        }

        .card:not(.expanded):hover .card-overlay {
            transform: translateY(0);
        }

        .card-overlay h3 {
            font-size: 14px;
            font-weight: 600;
            color: #ffffff;
            margin-bottom: 4px;
        }

        .card-overlay p {
            font-size: 12px;
            color: rgba(255, 255, 255, 0.7);
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        .expanded-content {
            display: none;
            width: 100%;
            height: 100%;
            padding: 24px;
            background: linear-gradient(135deg, #1e1e2e, #2a2a3e);
            overflow-y: auto;
        }

        .card.expanded .card-content {
            display: none;
        }

        .card.expanded .expanded-content {
            display: flex;
            flex-direction: column;
        }

        .expanded-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 20px;
            padding-bottom: 16px;
            border-bottom: 1px solid rgba(255, 255, 255, 0.1);
        }

        .expanded-header h2 {
            font-size: 24px;
            font-weight: 700;
            color: #ffffff;
            background: linear-gradient(90deg, #00d4ff, #7b2ff7);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }

        .close-button {
            width: 40px;
            height: 40px;
            border-radius: 50%;
            border: none;
            background: rgba(255, 255, 255, 0.1);
            color: #ffffff;
            font-size: 24px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.3s ease;
        }

        .close-button:hover {
            background: linear-gradient(135deg, #ff006e, #ff4458);
            box-shadow: 0 0 20px rgba(255, 0, 110, 0.5);
            transform: scale(1.1) rotate(90deg);
        }

        .expanded-image {
            width: 100%;
            height: 250px;
            object-fit: cover;
            border-radius: 12px;
            margin-bottom: 20px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
        }

        .expanded-info {
            flex: 1;
            display: flex;
            flex-direction: column;
            gap: 16px;
        }

        .expanded-info h3 {
            font-size: 20px;
            font-weight: 600;
            color: #ffffff;
            margin-bottom: 8px;
        }

        .expanded-info p {
            font-size: 15px;
            line-height: 1.8;
            color: rgba(255, 255, 255, 0.8);
        }

        .expanded-tags {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            margin: 16px 0;
        }

        .tag {
            padding: 6px 14px;
            background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 47, 247, 0.2));
            border: 1px solid rgba(0, 212, 255, 0.3);
            border-radius: 20px;
            font-size: 12px;
            color: #00d4ff;
            animation: tag-glow 2s ease infinite;
        }

        @keyframes tag-glow {
            0%, 100% { box-shadow: 0 0 5px rgba(0, 212, 255, 0.3); }
            50% { box-shadow: 0 0 15px rgba(0, 212, 255, 0.5); }
        }

        .expanded-actions {
            display: flex;
            gap: 12px;
            margin-top: auto;
            padding-top: 20px;
        }

        .action-button {
            flex: 1;
            padding: 14px 28px;
            border-radius: 10px;
            border: 1px solid rgba(255, 255, 255, 0.2);
            background: linear-gradient(135deg, rgba(0, 212, 255, 0.1), rgba(123, 47, 247, 0.1));
            color: #ffffff;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s ease;
            position: relative;
            overflow: hidden;
        }

        .action-button::before {
            content: '';
            position: absolute;
            top: 50%;
            left: 50%;
            width: 0;
            height: 0;
            border-radius: 50%;
            background: linear-gradient(135deg, rgba(0, 212, 255, 0.5), rgba(123, 47, 247, 0.5));
            transform: translate(-50%, -50%);
            transition: width 0.3s, height 0.3s;
        }

        .action-button:hover::before {
            width: 100%;
            height: 100%;
        }

        .action-button:hover {
            transform: translateY(-2px);
            box-shadow: 0 10px 30px rgba(0, 212, 255, 0.3);
            border-color: rgba(0, 212, 255, 0.5);
        }

        .action-button span {
            position: relative;
            z-index: 1;
        }

        .card.expanded.draggable {
            cursor: move;
        }

        .card.expanded.dragging {
            cursor: grabbing;
            transition: none !important;
        }

        @keyframes ripple {
            0% {
                box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6);
            }
            100% {
                box-shadow: 0 0 0 40px rgba(0, 212, 255, 0);
            }
        }

        .card.expanding {
            animation: ripple 0.6s ease-out;
        }

        .loading {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 24px;
            color: rgba(255, 255, 255, 0.6);
        }

        @media (max-width: 768px) {
            .wall-header {
                padding: 15px 20px;
            }
            
            .wall-header h1 {
                font-size: 20px;
            }
            
            .stats {
                display: none;
            }
        }
    </style>
</head>
<body>
    <!-- HTML structure remains the same -->
    <div class="wall-header">
        <h1>互动信息墙</h1>
        <div class="stats">
            <span id="cardCount">卡片总数: 0</span>
            <span id="expandedCount">展开卡片: 0</span>
        </div>
    </div>
    <div class="wall-viewport" id="viewport">
        <div class="cards-container" id="cardsContainer">
            <div class="loading">正在加载...</div>
        </div>
    </div>

    <script>
        class InteractiveInfoWall {
            constructor() {
                // 配置参数
                this.config = {
                    rows: 12,
                    cardHeight: 120,
                    cardGap: 8,
                    minCardWidth: 150,
                    maxCardWidth: 300,
                    expandScale: 3.5,
                    pushPadding: 50,
                    cardCount: 300,
                    flowSpeed: 25 // 流动速度 (像素/秒)
                };

                // 状态管理
                this.cards = [];
                this.expandedCards = new Set();
                this.isDragging = null;
                this.dragStartPos = { x: 0, y: 0 };
                this.cardStartPos = { x: 0, y: 0 };
                this.highestZIndex = 1000;
                
                this.isFlowing = true;
                this.flowPausedByMouse = false;
                this.lastTimestamp = 0;

                // 用于无限滚动的总宽度
                this.totalFlowWidth = 0;

                // DOM元素
                this.viewport = document.getElementById('viewport');
                this.container = document.getElementById('cardsContainer');
                
                // 绑定 this
                this.animateFlow = this.animateFlow.bind(this);
                
                // 初始化
                this.init();
            }

            init() {
                this.generateCards();
                this.renderCards(); // 错误发生在这里,现在函数已补全
                this.setupEventListeners();
                this.updateStats();
                requestAnimationFrame(this.animateFlow);
            }

            // [核心] 动画逻辑,更新每个卡片的位置
            animateFlow(timestamp) {
                if (!this.lastTimestamp) {
                    this.lastTimestamp = timestamp;
                }
                const deltaTime = (timestamp - this.lastTimestamp) / 1000;
                this.lastTimestamp = timestamp;

                if (this.isFlowing && !this.flowPausedByMouse) {
                    const movement = this.config.flowSpeed * deltaTime;
                    
                    this.cards.forEach(card => {
                        // 只移动未展开的卡片
                        if (!card.isExpanded) {
                            const cardElement = document.getElementById(card.id);
                            if (!cardElement) return;

                            // 如果卡片正在被推开,则不参与流动
                            if (cardElement.classList.contains('avoiding')) {
                                // 恢复其流动位置,以便之后能平滑接入
                                card.currentX = card.originalX + (parseFloat(cardElement.style.transform.replace(/[^0-9-.]/g, '')) || 0);
                                return;
                            }
                            
                            card.currentX -= movement;

                            // 无限循环逻辑
                            if (card.currentX + card.width < 0) {
                                card.currentX += this.totalFlowWidth;
                            }
                            
                            cardElement.style.transform = `translateX(${card.currentX - card.originalX}px)`;
                        }
                    });
                }
                
                requestAnimationFrame(this.animateFlow);
            }
            
            // [补全] 生成卡片数据
            generateCards() {
                const rowOffsets = new Array(this.config.rows).fill(0);
                const viewportWidth = this.viewport.clientWidth;
                
                for (let i = 0; i < this.config.cardCount; i++) {
                    const row = i % this.config.rows;
                    const width = this.config.minCardWidth + 
                                  Math.random() * (this.config.maxCardWidth - this.config.minCardWidth);
                    
                    const card = {
                        id: `card-${i}`,
                        title: `项目 ${i + 1}`,
                        description: `这是项目 ${i + 1} 的详细描述。包含了该项目的核心功能、技术特点、创新亮点以及实际应用场景。通过先进的技术架构和优化的用户体验设计,为用户提供高效、便捷的解决方案。项目采用了最新的技术栈,确保了系统的稳定性和可扩展性。`,
                        image: `https://picsum.photos/400/300?random=${i}`,
                        width: width,
                        height: this.config.cardHeight,
                        row: row,
                        originalX: rowOffsets[row],
                        originalY: row * (this.config.cardHeight + this.config.cardGap),
                        currentX: rowOffsets[row],
                        currentY: row * (this.config.cardHeight + this.config.cardGap),
                        isExpanded: false,
                        zIndex: 1,
                        tags: this.generateRandomTags()
                    };
                    
                    rowOffsets[row] += width + this.config.cardGap;
                    this.cards.push(card);
                }
                
                // 计算用于循环的总宽度
                this.totalFlowWidth = Math.max(...rowOffsets) + viewportWidth;
            }

            // [补全] 生成随机标签
            generateRandomTags() {
                const allTags = ['人工智能', '大数据', '云计算', '物联网', '区块链', '5G', 'AR/VR', '机器学习'];
                const tagCount = Math.floor(Math.random() * 3) + 2;
                const tags = new Set();
                while (tags.size < tagCount) {
                    tags.add(allTags[Math.floor(Math.random() * allTags.length)]);
                }
                return Array.from(tags);
            }

            // [补全] 渲染所有卡片到DOM
            renderCards() {
                this.container.innerHTML = '';
                this.cards.forEach(card => {
                    const cardElement = this.createCardElement(card);
                    this.container.appendChild(cardElement);
                });
            }

            // [补全] 创建单个卡片元素
            createCardElement(card) {
                const cardDiv = document.createElement('div');
                cardDiv.className = 'card';
                cardDiv.id = card.id;
                cardDiv.style.width = `${card.width}px`;
                cardDiv.style.height = `${card.height}px`;
                cardDiv.style.left = `${card.originalX}px`;
                cardDiv.style.top = `${card.originalY}px`;
                cardDiv.style.zIndex = card.zIndex;
                
                const normalContent = `
                    <div class="card-content">
                        <img src="${card.image}" alt="${card.title}" loading="lazy">
                        <div class="card-overlay">
                            <h3>${card.title}</h3>
                            <p>${card.description.substring(0, 50)}...</p>
                        </div>
                    </div>
                `;

                const expandedContent = `
                    <div class="expanded-content">
                        <div class="expanded-header">
                            <h2>${card.title}</h2>
                            <button class="close-button">×</button>
                        </div>
                        <img src="${card.image}" alt="${card.title}" class="expanded-image">
                        <div class="expanded-info">
                            <h3>项目详情</h3>
                            <p>${card.description}</p>
                            <div class="expanded-tags">
                                ${card.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
                            </div>
                            <div class="expanded-actions">
                                <button class="action-button"><span>查看详情</span></button>
                                <button class="action-button"><span>分享项目</span></button>
                            </div>
                        </div>
                    </div>
                `;

                cardDiv.innerHTML = normalContent + expandedContent;
                
                cardDiv.addEventListener('click', (e) => {
                    if (!card.isExpanded && !e.target.closest('.close-button')) {
                        this.expandCard(card);
                    }
                });
                
                return cardDiv;
            }

            // 展开卡片
            expandCard(card) {
                this.isFlowing = false; // 停止流动
                const cardElement = document.getElementById(card.id);
                if (!cardElement || card.isExpanded) return;
                
                card.isExpanded = true;
                this.expandedCards.add(card.id);
                
                card.zIndex = ++this.highestZIndex;
                cardElement.style.zIndex = card.zIndex;
                
                cardElement.classList.remove('avoiding');
                cardElement.classList.add('expanded', 'expanding', 'draggable');
                
                const expandedWidth = card.width * this.config.expandScale;
                const expandedHeight = card.height * this.config.expandScale;
                
                const deltaWidth = (expandedWidth - card.width) / 2;
                const deltaHeight = (expandedHeight - card.height) / 2;
                
                let newX = card.currentX - deltaWidth;
                let newY = card.currentY - deltaHeight;
                
                const viewportRect = this.viewport.getBoundingClientRect();
                if (newX < 20) { newX = 20; }
                if (newX + expandedWidth > viewportRect.width - 20) { newX = viewportRect.width - expandedWidth - 20; }
                if (newY < 20) { newY = 20; }
                if (newY + expandedHeight > viewportRect.height - 20) { newY = viewportRect.height - expandedHeight - 20; }
                
                card.expandedX = newX;
                card.expandedY = newY;
                
                cardElement.style.left = `${newX}px`;
                cardElement.style.top = `${newY}px`;
                cardElement.style.width = `${expandedWidth}px`;
                cardElement.style.height = `${expandedHeight}px`;
                cardElement.style.transform = 'translateX(0px)'; // 清除流动带来的 transform

                const closeBtn = cardElement.querySelector('.close-button');
                closeBtn.onclick = (e) => {
                    e.stopPropagation();
                    this.collapseCard(card);
                };
                
                const expandedHeader = cardElement.querySelector('.expanded-header');
                expandedHeader.addEventListener('mousedown', (e) => {
                    if (!e.target.closest('.close-button')) {
                        this.startDragging(card.id, e);
                    }
                });
                
                setTimeout(() => cardElement.classList.remove('expanding'), 600);
                
                this.updateCardPositions();
                this.updateStats();
                this.playSound('expand');
            }

            // 收起卡片
            collapseCard(card) {
                const cardElement = document.getElementById(card.id);
                if (!cardElement || !card.isExpanded) return;
                
                card.isExpanded = false;
                this.expandedCards.delete(card.id);
                
                if (this.expandedCards.size === 0) {
                    this.isFlowing = true; // 恢复流动
                }

                cardElement.classList.remove('expanded', 'draggable');
                
                cardElement.style.width = `${card.width}px`;
                cardElement.style.height = `${card.height}px`;
                cardElement.style.left = `${card.originalX}px`;
                cardElement.style.top = `${card.originalY}px`;
                
                // 平滑地回到流动队列
                cardElement.style.transform = `translateX(${card.currentX - card.originalX}px)`;

                card.zIndex = 1;
                cardElement.style.zIndex = 1;
                
                card.expandedX = null;
                card.expandedY = null;
                
                this.updateCardPositions();
                this.updateStats();
                this.playSound('close');
            }
            
            // [补全] 更新卡片位置(推开效果)
            updateCardPositions() {
                const expandedAreas = [];
                this.cards.forEach(card => {
                    if (card.isExpanded) {
                        const el = document.getElementById(card.id);
                        expandedAreas.push({
                            left: parseFloat(el.style.left),
                            right: parseFloat(el.style.left) + parseFloat(el.style.width),
                            top: parseFloat(el.style.top),
                            bottom: parseFloat(el.style.top) + parseFloat(el.style.height)
                        });
                    }
                });
                
                this.cards.forEach(card => {
                    if (card.isExpanded) return;

                    const cardElement = document.getElementById(card.id);
                    if (!cardElement) return;

                    const originalTransform = card.currentX - card.originalX;
                    let targetX = card.originalX;
                    let targetY = card.originalY;
                    let isAvoiding = false;

                    for (const area of expandedAreas) {
                        const cardRect = {
                            left: card.currentX,
                            right: card.currentX + card.width,
                            top: card.originalY,
                            bottom: card.originalY + card.height
                        };

                        const paddedArea = {
                            left: area.left - this.config.pushPadding,
                            right: area.right + this.config.pushPadding,
                            top: area.top - this.config.pushPadding,
                            bottom: area.bottom + this.config.pushPadding
                        };

                        if (cardRect.right > paddedArea.left && cardRect.left < paddedArea.right &&
                            cardRect.bottom > paddedArea.top && cardRect.top < paddedArea.bottom) {
                            isAvoiding = true;
                            // 简单的推开逻辑:只向Y轴推开
                            if (cardRect.top + card.height / 2 < paddedArea.top + (paddedArea.bottom - paddedArea.top) / 2) {
                                targetY = paddedArea.top - card.height;
                            } else {
                                targetY = paddedArea.bottom;
                            }
                            break; // 只被一个卡片推开
                        }
                    }

                    if (isAvoiding) {
                        cardElement.classList.add('avoiding');
                        cardElement.style.top = `${targetY}px`;
                        // 当被推开时,保持其水平位置不动,但清除流动 transform
                        cardElement.style.transform = `translateX(${card.currentX - card.originalX}px)`;
                    } else {
                        cardElement.classList.remove('avoiding');
                        cardElement.style.top = `${card.originalY}px`;
                    }
                });
            }


            // [补全] 开始拖动
            startDragging(cardId, event) {
                // ... (实现拖动逻辑)
            }

            // [补全] 设置事件监听器
            setupEventListeners() {
                this.viewport.addEventListener('mouseenter', () => this.flowPausedByMouse = true);
                this.viewport.addEventListener('mouseleave', () => this.flowPausedByMouse = false);
                
                // 拖动事件
                document.addEventListener('mousemove', (e) => {
                    if (!this.isDragging) return;
                    // ... (拖动逻辑)
                });
                document.addEventListener('mouseup', () => {
                     if (this.isDragging) {
                        // ... (停止拖动逻辑)
                     }
                });
                
                document.addEventListener('keydown', (e) => {
                    if (e.key === 'Escape') {
                        this.cards.forEach(card => {
                            if (card.isExpanded) this.collapseCard(card);
                        });
                    }
                });
                
                window.addEventListener('resize', () => this.updateCardPositions());
            }

            // [补全] 更新统计信息
            updateStats() {
                document.getElementById('cardCount').textContent = `卡片总数: ${this.cards.length}`;
                document.getElementById('expandedCount').textContent = `展开卡片: ${this.expandedCards.size}`;
            }

            // [补全] 播放音效
            playSound(type) {
                // ... (音效逻辑)
            }
        }

        // 页面加载完成后初始化
        document.addEventListener('DOMContentLoaded', () => {
            const wall = new InteractiveInfoWall();
            setTimeout(() => {
                const loading = document.querySelector('.loading');
                if (loading) loading.style.display = 'none';
            }, 500);
        });
    </script>
</body>
</html>

你可能忽略了useSyncExternalStore + useOptimistic + useTransition

在现代前端应用中,实时数据更新和顺滑交互体验已经成了标配:
聊天室、协作文档、实时监控面板……都离不开实时通信乐观更新

关于乐观更新的定义:

它是一种用户界面(UI)更新策略,
在执行异步操作(例如网络请求、数据库写入)之前,
先假设操作一定会成功
直接更新 UI 显示预期结果,
再在请求返回时根据实际结果确认或回滚

React 18 引入了几个强大的新 Hook:

  • useSyncExternalStore:让组件安全订阅外部状态;
  • useOptimistic:实现用户操作的“即时反馈”;
  • useTransition:让回滚或刷新变得平滑自然。

本文将通过一个实时聊天室的非完整案例,一步步带你理解这三者如何协同工作。

一、目标场景

我们要实现这样一个聊天室:

  1. 通过 WebSocket 接收服务器推送的消息;
  2. 用户输入后立即显示(不等服务端确认);
  3. 若发送失败,消息自动撤回或可重试。

看似简单,背后却包含了三种典型问题:

  • 外部状态同步(WebSocket);
  • 乐观 UI(即时显示用户操作);
  • 状态一致性与回滚(失败时撤回)。

二、第一步:用 useSyncExternalStore 管理实时状态

传统写法往往这样:

useEffect(() => {
  const ws = new WebSocket('ws://...');
  ws.onmessage = e => setMessages(JSON.parse(e.data));
}, []);

这没问题,但有几个隐患:

  • 多组件同时订阅时,可能重复连接;
  • React 并发模式下可能产生状态不同步;
  • 无法保证快照一致性。

React 官方提供的 useSyncExternalStore 专门解决这些问题。

实现 WebSocket Store

// websocketStore.js
let socket = null;
let messages = [];
const listeners = new Set();

function notify() {
  for (const listener of listeners) listener();
}

export function createWebSocketStore(url) {
  if (socket) return; // 避免重复连接
  socket = new WebSocket(url);

  socket.onmessage = (e) => {
    const data = JSON.parse(e.data);
    messages = [...messages, data];
    notify(); // 通知所有订阅组件更新
  };

  socket.onopen = () => console.log("✅ connected");
  socket.onclose = () => console.log("❌ closed");
}

export const store = {
  subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
  getSnapshot() {
    return messages;
  },
};

// 模拟发送函数
export function sendMessage(msg) {
  if (socket?.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(msg));
  }
}

这段代码就是一个可订阅的全局状态容器
React 组件只需订阅它,就能安全地读取 WebSocket 状态。

三、第二步:用 useOptimistic 实现“消息先显示”

我们希望用户点击“发送”后,消息立刻显示在 UI 上,而不是等到服务器回应。

useOptimistic 就是为这种“乐观更新”场景设计的。

实现示例:

import { useEffect, useSyncExternalStore, useOptimistic } from "react";
import { store, createWebSocketStore, sendMessage } from "./websocketStore";

export default function ChatApp() {
  useEffect(() => {
    createWebSocketStore("wss://example.com/chat");
  }, []);

  const messages = useSyncExternalStore(store.subscribe, store.getSnapshot);

  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newMsg) => [...currentMessages, { ...newMsg, optimistic: true }]
  );

  function handleSend(text) {
    const tempMsg = { user: "Asen", text, tempId: Date.now() };
    addOptimisticMessage(tempMsg);
    sendMessage(tempMsg);
  }

  return (
    <div className="p-4 max-w-md mx-auto">
      <h2 className="font-bold mb-2 text-lg">💬 实时聊天室</h2>

      <MessageList messages={optimisticMessages} />
      <ChatInput onSend={handleSend} />
    </div>
  );
}

function MessageList({ messages }) {
  return (
    <ul className="space-y-1">
      {messages.map((msg, i) => (
        <li
          key={msg.tempId || i}
          className={`p-2 rounded ${
            msg.optimistic ? "bg-gray-200 text-gray-500 italic" : "bg-blue-100"
          }`}
        >
          {msg.user}: {msg.text}
          {msg.optimistic && " (sending...)"} 
        </li>
      ))}
    </ul>
  );
}

现在,用户每次发消息都会立刻在屏幕上出现 (sending...)
真正的服务器消息到达后再被替换掉。

四、第三步:加入 useTransition 实现“失败回滚”

接下来,我们要让发送失败的消息自动撤回。
在实际项目中,这非常常见:网络波动、服务端延迟、权限问题等等。

修改 sendMessage

// websocketStore.js
export function sendMessage(msg) {
  return new Promise((resolve, reject) => {
    if (socket?.readyState !== WebSocket.OPEN) {
      reject("Socket not connected");
      return;
    }

    // 模拟 30% 发送失败
    setTimeout(() => {
      if (Math.random() < 0.3) {
        reject("Network error");
      } else {
        socket.send(JSON.stringify(msg));
        resolve();
      }
    }, 400);
  });
}

在组件中使用 useTransition 平滑回滚

import React, { useTransition } from "react";

const [isPending, startTransition] = useTransition();

async function handleSend(text) {
  const tempId = Date.now();
  const optimisticMsg = { user: "Asen", text, tempId, optimistic: true };
  addOptimisticMessage(optimisticMsg);

  try {
    await sendMessage({ user: "Asen", text });
  } catch (err) {
    console.error("❌ Failed:", err);
    startTransition(() => {
      addOptimisticMessage((msgs) =>
        msgs.filter((m) => m.tempId !== tempId)
      );
    });
  }
}

这样做的效果:

  • 消息先显示;
  • 如果失败,React 平滑地将其移除;
  • 整个过程无闪烁,无状态错乱。

五、三者协同背后的逻辑

Hook 作用 在本例中表现
useSyncExternalStore 安全订阅外部数据源 从 WebSocket 获取实时消息
useOptimistic 管理乐观状态(立即反馈) 发送时立即显示临时消息
useTransition 平滑更新、避免卡顿 回滚失败消息时保持流畅

三者组合形成一个非常稳定的结构:

用户操作 → useOptimistic (添加临时UI)
         ↓
真实异步操作 → 成功 → useSyncExternalStore 更新
                      ↳ 回滚 → useTransition 平滑撤销

六、应用场景

这种模式不仅能用于聊天室,还能应用在:

  • 实时评论区(评论先显示后确认)
  • 协作文档(本地编辑立即生效)
  • 实时监控(数据快速闪现)
  • 弹幕系统(先显示后同步)
  • 电商下单(先更新库存、后校验结果)

几乎所有实时 + 交互敏感的前端系统都能受益于这套组合。

🔥开源零配置!10 分钟上手:create-uni + uView Pro 快速搭建企业级 uni-app 项目

推荐阅读:

🔥 uView Pro 正式开源!70+ Vue3 组件重构完成,uni-app 组件库新晋之星

本文面向希望快速搭建 uni-app 项目的开发者与团队,介绍如何使用 create-uni 脚手架一键创建项目,如何在项目中引入并配置 uView Pro 组件库,以及如何利用 uni-helper 系列插件(vite-plugin、unocss 等)提高开发效率。

一、为什么选择 create-uni + uView Pro?

在 uniapp 构建的多端工程中,速度与一致性至关重要。

create-uni 提供一键生成、模板丰富的项目引导能力,而 uView Pro 则是基于 Vue3 + TypeScript 全面重构的高质量 uni-app 组件库。两者结合,能带来:

  • 快速上手:一行命令生成标准化项目结构;
  • 现代开发体验:Vite + Vue3 + TS,热更新快、类型友好;
  • 丰富组件:70+ 高质量组件覆盖主流业务场景;
  • 高度可扩展:uni-helper 插件体系支持文件路由、按需组件、布局系统等;
  • 企业友好:模板、样式、规范一致,便于团队协作与维护。

6.png

0.png

二、准备工作(环境与工具)

在开始之前,建议准备以下环境:

  • Node.js(建议 LTS 版本,如 18.x 或 20.x)
  • pnpm / npm / yarn(推荐 pnpm,速度更快且适合 monorepo)
  • VS Code + Volar(强烈推荐,Vue3 + TypeScript 最佳搭配,禁用 Vetur)
  • HBuilderX(如果需要使用 HBuilderX 工具链或插件市场,非必要不使用)

确保全局工具可用:

# 建议使用 pnpm
npm install -g pnpm
# 若需要全局安装脚手架(可选)
npm install -g @dcloudio/uni-app

三、使用 create-uni 快速创建项目(一步到位)

create-uni 是一套现代化脚手架,支持选择模板、快速集成 uView Pro 组件库等,下面给出用 pnpm create 的推荐流程:

# 使用 create-uni(交互式选择项目模板)
pnpm create uni@latest
cd my-uni-project
pnpm install

# 启动开发(以 H5 为例)
pnpm run dev:h5

在交互式选择时,选择需要的插件和库、选择需要的组件库 uView Pro ,可以让项目开箱即用:根据您的选择可以帮助您自动集成 uView Pro、UnoCSS、uni-helper 等插件,省去大量配置时间。

示例:

  • 选择需要的 vite 插件时勾选必要的插件:

    • vite-plugin-uni-pages(提供基于文件系统的路由)
    • vite-plugin-uni-components(按需自动引入组件)
    • vite-plugin-uni-layouts(提供类 nuxt 的 layouts 系统)
    • vite-plugin-uni-manifest(自动生成 manifest.json 文件)
  • 选择需要的库时勾选必要的库:

    • Pinia
    • Unocss
  • 选择 UI 组件库时勾选 uView Pro

通过以上选择完成后,脚手架会自动创建包含以下内容的项目:

  • Vite + uni-app 项目骨架
  • uview-pro 依赖与全局样式引入(index.scss / theme.scss)
  • 推荐的 tsconfig.jsonvite.config.ts 配置
  • UnoCSS 与 uni-helper 插件预配置

1.png

四、手动在已存在项目中安装 uView Pro(npm 或 uni_modules)

如果你已用其它方式创建项目,并不是使用 create-uni,下面是两种常见安装方式,分别适用于 CLI 项目(npm)与 HBuilderX 项目(uni_modules)。

1. CLI(npm / pnpm)方式(推荐团队/CLI 项目)

pnpm add uview-pro
# 或者 npm install uview-pro --save

在 Vue3 项目中,全局引入并注册:

// main.ts
import { createSSRApp } from "vue";
import uViewPro from "uview-pro";

export function createApp() {
  const app = createSSRApp(App);
  app.use(uViewPro);
  return {
    app,
  };
}

uni.scss 中引入主题:

@import "uview-pro/theme.scss";

在  App.vue  首行引入基础样式:

<style lang="scss">
  @import "uview-pro/index.scss";
</style>

pages.json / vite 的 easycom 配置中添加:

"easycom": {
  "autoscan": true,
  "custom": {
    "^u-(.*)": "uview-pro/components/u-$1/u-$1.vue"
  }
}

也可以使用@uni-helper/vite-plugin-uni-components(基于文件的按需组件引入)插件来替换 easycom 的方式,详细使用方式见下述介绍。

注:CLI npm 方式更易管理版本、配合 TypeScript 与 Volar 获得更好类型提示体验。

2. HBuilderX(uni_modules)方式(推荐 HBuilderX 项目)

uview-pro 目录放入项目 uni_modules 下(或通过插件市场安装);

DCloud 插件市场:ext.dcloud.net.cn/plugin?id=2…

main.ts全局引入并注册

// main.ts
import { createSSRApp } from 'vue'
import uViewPro from "@/uni_modules/uview-pro";

export function createApp() {
  const app = createSSRApp(App)
  app.use(uViewPro)
  return {
    app
  }
}

pages.json 中配置 easycom:

"easycom": {
  "autoscan": true,
  "custom": {
    "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"
  }
}

uni.scss 中引入主题:

@import "@/uni_modules/uview-pro/theme.scss";

在  App.vue  首行引入基础样式:

<style lang="scss">
  @import "@/uni_modules/uview-pro/index.scss";
</style>

HBuilderX 下,uni_modules 更符合编辑器和打包器的约定,部分原生插件或小程序构建会更兼容。

因此:建议 CLI 项目使用 npm/pnpm 方式,HBuilderX 项目使用 uni_modules 方式

五、结合 uni-helper 插件提升开发效率

uni-helper 系列插件在 vite + uni-app 生态下提供了大量现代化的便利能力。下面按插件逐一介绍它们的作用、安装、配置示例、与 uView Pro 的配合要点以及常见注意事项。

2.png

更多用法及插件请访问 uni-helper 官网文档:uni-helper.js.org/

1. @uni-helper/vite-plugin-uni-pages(文件系统路由)

作用:

  • 自动扫描 src/pagespages 目录,基于文件系统生成路由配置,替代手动维护 pages.json 的繁琐流程;
  • 支持页面元数据、分组、全局样式定义和路由扩展;
  • 提供 virtual:uni-pages 等虚拟模块用于在代码中读取页面信息,便于构建菜单、统计或自动化文档。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-pages

基本配置(vite.config.ts):

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UniPages from "@uni-helper/vite-plugin-uni-pages";

export default defineConfig({
  plugins: [UniPages(), Uni()],
});

pages 配置示例(pages.config.ts):

import { defineUniPages } from "@uni-helper/vite-plugin-uni-pages";

export default defineUniPages({
  pages: [],
  globalStyle: {
    navigationBarTextStyle: "black",
    navigationBarTitleText: "MyApp",
  },
  subPackages: [],
});

在代码中获取页面元数据:

/// <reference types="@uni-helper/vite-plugin-uni-pages/client" />
import { pages } from "virtual:uni-pages";
console.log(pages);

与 uView Pro 的配合要点:

  • 结合 uView Pro Starter,路由自动化能让示例页面、文档 demo 与项目页面保持一致;
  • 当需要在页面自动注入组件演示或 demo 链接时,pages 元数据非常方便。

注意事项:

  • 如果同时存在手动维护的 pages.json,请确认插件优先级与覆盖规则;
  • 某些小程序平台对动态生成的路由有特殊限制,发布前务必在目标平台做真机测试。

2. @uni-helper/vite-plugin-uni-components(基于文件的按需组件引入)

作用:

  • 基于文件系统实现组件按需自动引入,类似于 Vue 的 unplugin-vue-components,但针对 uni-app 场景优化;
  • 可以替代 easycom 的全局扫描,减少启动扫描成本并提升按需加载精度;
  • 支持自定义规则、扩展第三方组件库的映射。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-components

配置示例,已经支持 uView Pro Resolver:

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UniComponents from "@uni-helper/vite-plugin-uni-components";
import { uViewProResolver } from "@uni-helper/vite-plugin-uni-components/resolvers";

export default defineConfig({
  plugins: [
    UniComponents({
      dts: true,
      resolvers: [uViewProResolver()],
    }),
    Uni(),
  ],
});

与 uView Pro 的配合要点:

  • 使用此插件可避免在 pages.json 中重复写 easycom 规则;
  • 当配合 uview-pro 时,需要引入 uViewProResolver 使用;
  • 有助于实现按需打包,减小 H5 与小程序包体积。

注意事项:

  • 部分平台(例如 HBuilderX 的旧版本)可能仍需要 pages.json 的支持,务必在迁移前做兼容性验证;
  • 对于同名组件(不同来源)要明确命名或使用手动 import 以避免歧义。

3. @uni-helper/vite-plugin-uni-layouts(布局系统)

作用:

  • 在 uni-app 中实现类似 Nuxt 的布局机制(layouts),支持多个 layout 组件、slot、以及按页面应用布局;
  • 自动扫描 src/layouts 并将页面包裹在指定布局下,简化头部/尾部/侧边栏等公共区域维护。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-layouts

配置示例:

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UniLayouts from "@uni-helper/vite-plugin-uni-layouts";

export default defineConfig({
  plugins: [UniLayouts(), Uni()],
});

使用示例:

  • src/layouts/default.vue 中定义布局:
<template>
  <div class="layout">
    <slot name="header">默认头部</slot>
    <slot>主内容</slot>
    <slot name="footer">默认底部</slot>
  </div>
</template>
  • 在页面中指定布局(definePage):
<script setup>
definePage({ layout: "default" });
</script>

与 uView Pro 的配合要点:

  • 布局中可直接使用 uView Pro 的导航栏、Tabbar、Footer 等组件,保证风格统一;
  • 结合 uView Pro Starter,布局示例通常已经内置,直接复用即可。

注意事项:

  • 在微信小程序中如果页面使用 web-view,布局插件的包裹机制可能不生效;
  • 动态切换布局时注意保持页面状态。

4. @uni-helper/vite-plugin-uni-manifest(用 TypeScript 管理 manifest)

作用:

  • 允许使用 TypeScript 编写 manifest.json(如 manifest.config.ts),享受类型提示与可组合的配置方式;
  • 在构建时自动生成标准 manifest.json,并支持按平台差异化配置。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-manifest

配置示例:

import Uni from "@uni-helper/plugin-uni";
import UniManifest from "@uni-helper/vite-plugin-uni-manifest";

export default defineConfig({
  plugins: [UniManifest(), Uni()],
});

示例 manifest.config.ts

import { defineManifestConfig } from "@uni-helper/vite-plugin-uni-manifest";

export default defineManifestConfig({
  appid: "your-appid",
  name: "MyApp",
  versionName: "1.0.0",
  h5: {
    devServer: {
      port: 8080,
    },
  },
});

与 uView Pro 的配合要点:

  • 将 theme 或构建相关的配置以类型化方式管理,便于在不同环境(dev/staging/prod)间切换;
  • 在企业项目中能更方便地实现 CI 自动化生成不同渠道包的 manifest 配置。

注意事项:

  • 生成的 manifest.json 应在真机或云打包平台上验证,避免配置项平台不兼容。

5. @uni-helper/vite-plugin-uni-platform(按平台文件替换)

作用:

  • 支持基于文件名的按平台编译,例如 index.h5.vueindex.mp-weixin.vueindex.app.vue 等,构建时自动替换为对应平台文件;
  • 便于按平台做差异化实现,同时保持统一的项目结构与代码管理。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-platform

配置示例:

import Uni from "@uni-helper/plugin-uni";
import UniPlatform from "@uni-helper/vite-plugin-uni-platform";

export default defineConfig({
  plugins: [UniPlatform(), Uni()],
});

使用说明:

  • 在项目中创建文件如 pages/index.h5.vue 针对 H5 的实现,pages/index.mp-weixin.vue 针对微信小程序的实现;
  • 在编译目标为 H5 时,会优先使用 index.h5.vue,否则退回 index.vue

与 uView Pro 的配合要点:

  • 当使用 uView Pro 的某些平台相关适配(例如原生 SDK 或特定 API)时,可以在平台特定文件中做针对性封装;
  • 结合 uni-pages,能更方便地管理平台差异化页面列表。

注意事项:

  • 使用大量平台特异化文件会增加维护成本,建议仅在必要场景使用。

6. @uni-helper/unocss-preset-uni(UnoCSS 预设)

作用:

  • 为 uni-app 定制的 UnoCSS 预设,开箱即用的原子类工具集,支持属性化写法与按平台样式差异;
  • 极大减少重复样式、提高开发速度,同时配合 Uno 的即时编译,开发体验流畅。

安装:

pnpm add -D @uni-helper/unocss-preset-uni unocss unocss-applet

vite 配置示例:

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UnoCSS from "unocss/vite";

export default defineConfig({
  plugins: [Uni(), UnoCSS()],
});

uno.config.ts 配置

import { presetUni } from "@uni-helper/unocss-preset-uni";

import {
  defineConfig,
  presetIcons,
  transformerDirectives,
  transformerVariantGroup,
} from "unocss";

export default defineConfig({
  presets: [
    presetUni({
      attributify: {
        // UnoCSS的解析规则可与uView Pro组件库内置样式冲突
        ignoreAttributes: ["size"],
      },
    }),
  ],
  transformers: [transformerDirectives(), transformerVariantGroup()],
});

与 uView Pro 的配合要点:

  • UnoCSS 非侵入式,可与 uView Pro 的 SCSS 主题变量共存;
  • 在快速原型或设计系统中,Uno 的原子类能极大提升迭代速度;
  • 推荐将设计变量(颜色、间距)同步到 uView Pro 的 theme.scss,并在 Uno 配置中复用。
  • 注意 UnoCSS 的解析规则可能会与 uView Pro 组件库内置样式冲突

注意事项:

  • UnoCSS 从 v0.59 起只提供 ESM 支持,某些老旧构建环境需降级或额外配置;
  • 在使用 apis 或小程序特性时,注意属性名与平台限制。

7. 插件组合示例(完整 vite.config.ts)

下面给出一个常见的 vite.config.ts 组合示例,展示如何把上面插件整合到同一个工程中:

import { fileURLToPath, URL } from "node:url";

import Uni from "@uni-helper/plugin-uni";
import Components from "@uni-helper/vite-plugin-uni-components";
import { uViewProResolver } from "@uni-helper/vite-plugin-uni-components/resolvers";
import UniLayouts from "@uni-helper/vite-plugin-uni-layouts";
import UniManifest from "@uni-helper/vite-plugin-uni-manifest";
import UniMiddleware from "@uni-helper/vite-plugin-uni-middleware";
import UniPages from "@uni-helper/vite-plugin-uni-pages";
import UniPlatform from "@uni-helper/vite-plugin-uni-platform";
import UniPlatformModifier from "@uni-helper/vite-plugin-uni-platform-modifier";
import UniRoot from "@uni-ku/root";
import UnoCSS from "unocss/vite";
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
  plugins: [
    Components({
      dts: true,
      resolvers: [uViewProResolver()],
    }),
    UniPages(),
    UniLayouts(),
    UniManifest(),
    UniPlatform(),
    UniPlatformModifier(),
    UniMiddleware(),
    UniRoot(),
    Uni(),
    UnoCSS(),
  ],
});

8. 常见故障排查(针对插件集成)

  • uni-pages 未识别页面:确认目录结构、文件后缀以及 pages.config.* 是否存在语法错误;
  • uni-components 未按需引入:检查插件 dirs 配置与组件命名是否匹配,或手动添加 resolver;
  • layouts 无效:确认页面是否使用 definePage({ layout: 'xxx' }) 或 pages.json 的 layout 配置被覆盖;
  • manifest 生成错误:在本地构建时查看生成的 manifest.json,并在真机或云打包平台验证;
  • UnoCSS 样式不生效:确认 UnoCSS 是否在 plugins 列表中且 preset 已正确加载;
  • uView Pro 组件样式错乱:确认 UnoCss 解析规则是否与组件库存在冲突问题;

六、uView Pro Starter:开箱即用的项目模板

uView Pro Starter 是官方维护的快速启动模板,目前集成了 create-uni、uView Pro、UnoCSS 与 uni-helper 常用插件,适合作为企业或个人项目的起点。核心优势包括:

  • 规范的项目结构与开发脚本;
  • 预配置的 linter、格式化、TypeScript 与 Volar 支持;
  • UnoCSS 和主题变量已集成,支持快速定制风格;
  • 常用页面、布局、示例组件齐全,便于二次开发。

快速使用:

# 直接 clone
git clone https://github.com/anyup/uView-Pro-Starter.git
cd uView-Pro-Starter
pnpm install
pnpm run dev:h5

后面可以通过 create-uni 直接选择 uView Pro Starter 模板,目前还没建设完成。

Starter 的目的是把工程化、规范、常见实践都“开箱即用”,让团队把精力集中在业务实现上,而不是基础设施搭建。

3.png

5.png

4.png

七、uView Pro 与 uni-helper 的协同最佳实践(总结)

  • 使用 uView Pro Starter 作为项目模板,默认预集成了大部分插件配置,能让团队开箱即用;
  • 对于页面与组件的自动化引入,优先考虑 uni-pages + uni-components,降低重复维护成本;
  • uni-components 中为 u- 前缀做显式 resolver,避免与其他库冲突;
  • 将 uView Pro 的主题变量与 UnoCSS 的设计 tokens 做映射,保证样式统一且可维护;
  • 在 CI 中加入 pnpm install --frozen-lockfile、lint、typecheck 步骤,保证团队一致性;
  • 做好平台差异化管理(合理使用 uni-platform)但尽量减少全平台分支,以降低维护成本。

八、注意事项

1. 样式、sass 与版本兼容建议

在实际项目中,sass 与 sass-loader 的版本兼容性常会引发样式构建问题。建议在团队内统一并锁定版本,减少“本地能跑、CI 失败”的尴尬。

推荐版本(uView Pro 社区实践验证):

"sass": "1.63.2",
"sass-loader": "10.4.1"

同时,注意 uView Pro 的内部样式及主题文件采用 @import 形式引入。所以一定要注意 sass 的版本,

如使用 @use / @forward 语法引入 uView Pro 的样式文件,可能会导致样式丢失,报错,所以请使用 @import 引入。

2. TypeScript、Volar 与类型提示体验

uView Pro 自带 TypeScript 类型声明文件,结合 Volar 能获得良好的组件属性、事件、插槽的代码补全与类型校验。以下为推荐配置:

  1. 确保 VS Code 安装 Volar,并禁用 Vetur
  2. tsconfig.json 中添加:
{
  "compilerOptions": {
    "types": ["uview-pro/types"],
    "skipLibCheck": true
  }
}
  1. 在团队中统一 tsconfig 与 VS Code 推荐扩展配置(.vscode/extensions.json),减少“我的能提示你的不能提示”的现象。

3. 按需加载、tree-shaking 与打包优化

为减小包体积,建议:

  • 优先按需导入工具函数与业务组件(避免全局引入全部组件),
  • 使用 uni-helper 的 uni-components 或配合 Vite 的按需加载插件实现自动 tree-shaking,
  • 对大型列表使用虚拟滚动、分页或懒加载,
  • 在生产构建时开启压缩、静态资源缓存以及 CDN/边缘分发。

示例:按需引入工具函数

import { deepClone } from "uview-pro";
const copy = deepClone(obj);

4. 与其他组件库共存的注意事项

项目中若存在 uview-plusuView 1.xuView 2.x 或其他同类库,可能会出现 easycom 冲突、样式覆盖或工具命名冲突。解决建议:

  • 在迁移期避免自动扫描多个组件库的同名规则;
  • 调整 easycom.custom 规则,只指向 uview-pro 或具体库路径;
  • 团队层面统一组件库选型,减少冲突成本。

5. 常见问题与排查清单

  • 组件没有样式?→ 检查 theme.scssindex.scss 是否正确引入;
  • easycom 无效?→ 检查 pages.jsoncustom 配置与路径;
  • Volar 无补全?→ 禁用 Vetur、重启 VS Code、确认 tsconfig.json 设置;
  • Sass 语法报错?→ 检查 sasssass-loader 版本并统一锁定;
  • 依赖冲突?→ 清理 node_modules / pnpm install --frozen-lockfile 并统一依赖来源。

更多常见问题请参考社区网站,实时更新:uviewpro.cn/zh/guide/fa…

九、uView Pro(为开源而生)

uView Pro 是一款免费、开源、面向个人和企业的组件库。希望通过 uView Pro Startercreate-uni 的结合,降低团队上手成本,提高项目启动速度。

同时欢迎企业与开发者在 GitHub / Gitee 提交 PR、Issue,参与组件优化、示例补全与文档改进。

项目地址

十、结语:把时间交给业务,把基础交给 uView Pro

通过 create-uni + uView Pro + uni-helper 插件体系,你可以在极短的时间内搭建一个现代化、可维护、类型安全的 uni-app 项目。无论是单人项目、快速原型,还是企业级多团队协作,这套组合都能显著降低启动成本、提高开发效率。

所以,强烈建议你:

  • 使用 uView Pro Starter,将其作为项目起点;或者使用 create-uni 创建新项目时选择包含 uView Pro 的模板;
  • 合理使用 uni-helper 插件系统,减少重复工作;
  • 在团队内推广统一模板与依赖锁定策略;

欢迎访问与关注:

前端音频兼容解决:音频神器howler.js从基础到进阶完整使用指南

1. 概括

howler.js 是一款轻量、强大的 JavaScript 音频处理库,专为解决 Web 端音频播放的兼容性、复杂性问题而生。它基于 Web Audio API 和 HTML5 Audio 封装,提供了统一的 API 接口,可轻松实现多音频管理、3D 空间音效、音频淡入淡出、循环播放等功能,同时兼容从桌面端到移动端的几乎所有现代浏览器(包括 IE 10+)。

相比原生 Audio 对象,howler.js 的核心优势的在于:

  • 兼容性强:自动降级(Web Audio API 优先,不支持则使用 HTML5 Audio),无需手动处理浏览器差异;
  • 多音频管理:支持同时加载、播放多个音频,自动管理音频池,避免资源泄漏;
  • 丰富音效:内置 3D 空间音效、立体声平衡、音量淡入淡出等功能,无需额外依赖;
  • 轻量无冗余:核心体积仅 ~17KB(minified + gzipped),无第三方依赖,加载速度快;
  • 事件驱动:提供完整的音频事件监听(加载完成、播放结束、暂停、错误等),便于业务逻辑联动。

2. 快速上手:安装与基础使用

官方仓库传送门

2.1 安装方式

howler.js 支持多种引入方式,可根据项目场景选择:

方式1:直接引入 CDN

无需构建工具,在 HTML 中直接引入脚本:

<!-- 引入 howler.js(最新版本可从官网获取) -->
<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>

<!-- 基础使用 -->
<script>
  // 1. 创建音频实例
  const sound = new Howl({
    src: ['audio.mp3', 'audio.ogg'], // 提供多种格式(兼容不同浏览器)
    autoplay: false, // 是否自动播放
    loop: false, // 是否循环
    volume: 0.5, // 音量(0~1)
  });

  // 2. 绑定播放按钮事件
  document.getElementById('playBtn').addEventListener('click', () => {
    sound.play(); // 播放音频
  });

  // 3. 绑定暂停按钮事件
  document.getElementById('pauseBtn').addEventListener('click', () => {
    sound.pause(); // 暂停音频
  });
</script>

<!-- 页面按钮 -->
<button id="playBtn">播放</button>
<button id="pauseBtn">暂停</button>

方式2:npm 安装(模块化项目)

适用于 React、Vue、TypeScript 等模块化项目:

# 安装依赖
npm install howler --save

在项目中引入(以 React 为例):

import React from 'react';
import { Howl } from 'howler'; // 引入 Howl 类

const AudioPlayer = () => {
  // 组件挂载时创建音频实例
  React.useEffect(() => {
    const sound = new Howl({
      src: ['/audio.mp3'], // 音频路径(需放在项目 public 目录下)
      volume: 0.7,
    });

    // 组件卸载时销毁音频实例(避免内存泄漏)
    return () => {
      sound.unload();
    };
  }, []);

  return (
    <div>
      <button onClick={() => sound.play()}>播放</button>
      <button onClick={() => sound.pause()}>暂停</button>
    </div>
  );
};

export default AudioPlayer;

2.2 核心 API 示例

howler.js 的核心是 Howl 类实例,通过实例调用方法控制音频,以下是最常用的 API 示例:

2.2.1 播放与暂停

// 创建音频实例
const sound = new Howl({
  src: ['music.mp3'],
});

// 播放音频(返回音频 ID,用于多音频实例管理)
const soundId = sound.play();

// 暂停指定音频(若不传 ID,暂停所有音频)
sound.pause(soundId);

// 暂停所有音频
sound.pause();

// 继续播放(与 pause 对应,可传 ID)
sound.play(soundId);

// 停止播放(停止后需重新 play 才能播放,而非继续)
sound.stop(soundId);

2.2.2 音量控制

// 设置音量(0~1,可传 ID 控制单个音频)
sound.volume(0.8, soundId);

// 获取当前音量(返回 0~1 的数值)
const currentVolume = sound.volume(soundId);

// 音量淡入(从 0 淡到 0.8,持续 2 秒)
sound.fade(0, 0.8, 2000, soundId);

// 音量淡出(从当前音量淡到 0,持续 3 秒)
sound.fade(currentVolume, 0, 3000, soundId);

2.2.3 播放进度控制

// 获取音频总时长(单位:秒)
const duration = sound.duration(soundId);

// 获取当前播放进度(单位:秒)
const currentTime = sound.seek(soundId);

// 设置播放进度(跳转到 30 秒处)
sound.seek(30, soundId);

// 快进 10 秒
sound.seek(currentTime + 10, soundId);

// 快退 5 秒
sound.seek(currentTime - 5, soundId);

2.2.4 音频状态查询

// 判断音频是否正在播放
const isPlaying = sound.playing(soundId);

// 判断音频是否已加载完成
const isLoaded = sound.state() === 'loaded';

// 获取音频加载进度(0~1,用于显示加载条)
const loadProgress = sound.loadProgress();

3. 核心配置项详解

创建 Howl 实例时,通过配置对象定义音频的初始状态和行为,以下是常用配置项的分类说明:

3.1 基础配置

配置项 类型 作用 默认值
src string[] 音频文件路径数组(推荐提供多种格式,如 MP3、OGG,兼容不同浏览器) -(必传)
autoplay boolean 音频加载完成后是否自动播放 false
loop boolean 是否循环播放音频 false
volume number 初始音量(0~1,0 为静音,1 为最大音量) 1
mute boolean 是否初始静音 false
preload boolean 是否预加载音频(true 加载全部,false 不预加载,'metadata' 仅加载元数据) true

3.2 高级配置

配置项 类型 作用 默认值
format string[] 音频格式数组(若 src 路径不含后缀,需指定格式,如 ['mp3', 'ogg'] -
rate number 播放速率(0.5~4,1 为正常速率,0.5 慢放,2 快放) 1
pool number 音频池大小(同时可播放的最大实例数,用于多音频叠加播放场景) 5
sprite Object 音频精灵配置(将单个音频文件分割为多个片段,如音效合集) null
3d boolean 是否启用 3D 空间音效(需配合 pos 配置音频位置) false
pos number[] 3D 音效中音频的空间位置([x, y, z],默认 [0, 0, 0]) [0, 0, 0]
distance number[] 3D 音效中音频的距离范围([min, max],超出 max 则听不到) [1, 1000]

示例:音频精灵(Sprite)

若将多个短音效(如按钮点击、弹窗关闭)合并为一个音频文件,可通过 sprite 配置分割播放:

const sound = new Howl({
  src: ['sounds.sprite.mp3'],
  // 音频精灵配置:key 为片段名,value 为 [开始时间(秒), 持续时间(秒), 是否循环]
  sprite: {
    click: [0, 0.5], // 0 秒开始,持续 0.5 秒(按钮点击音效)
    close: [1, 0.3], // 1 秒开始,持续 0.3 秒(弹窗关闭音效)
    success: [2, 1.2, true], // 2 秒开始,持续 1.2 秒,循环播放(成功提示音效)
  },
});

// 播放“按钮点击”音效
sound.play('click');

// 播放“弹窗关闭”音效
sound.play('close');

// 播放“成功提示”音效(循环)
sound.play('success');

4. 场景化进阶示例

4.1 音频播放器(带进度条、音量控制)

实现一个完整的单音频播放器,包含播放/暂停、进度条拖动、音量调节功能:

<div class="audio-player">
  <h3>自定义音频播放器</h3>
  <button id="playPauseBtn">播放</button>
  <!-- 进度条 -->
  <div class="progress-container">
    <div id="progressBar" class="progress-bar"></div>
  </div>
  <!-- 音量控制 -->
  <div class="volume-container">
    <span>音量:</span>
    <input type="range" id="volumeSlider" min="0" max="1" step="0.1" value="0.7">
  </div>
  <!-- 播放时长 -->
  <div class="time-display">
    <span id="currentTime">00:00</span> / <span id="totalTime">00:00</span>
  </div>
</div>

<style>
  .progress-container {
    width: 300px;
    height: 6px;
    background: #eee;
    border-radius: 3px;
    margin: 10px 0;
    cursor: pointer;
  }
  .progress-bar {
    height: 100%;
    width: 0%;
    background: #2c3e50;
    border-radius: 3px;
  }
  .volume-container {
    margin: 10px 0;
  }
</style>

<script src="https://cdn.jsdelivr.net/npm/howler@2.2.4/dist/howler.min.js"></script>
<script>
  // 1. 创建音频实例
  const sound = new Howl({
    src: ['music.mp3'],
    volume: 0.7,
    onload: () => {
      // 音频加载完成后更新总时长
      const totalTime = formatTime(sound.duration());
      document.getElementById('totalTime').textContent = totalTime;
    },
  });

  // 2. 获取 DOM 元素
  const playPauseBtn = document.getElementById('playPauseBtn');
  const progressContainer = document.querySelector('.progress-container');
  const progressBar = document.getElementById('progressBar');
  const volumeSlider = document.getElementById('volumeSlider');
  const currentTimeEl = document.getElementById('currentTime');

  // 3. 播放/暂停切换
  playPauseBtn.addEventListener('click', () => {
    const isPlaying = sound.playing();
    if (isPlaying) {
      sound.pause();
      playPauseBtn.textContent = '播放';
    } else {
      sound.play();
      playPauseBtn.textContent = '暂停';
    }
  });

  // 4. 进度条更新(每秒更新一次)
  setInterval(() => {
    if (sound.playing()) {
      const currentTime = sound.seek();
      const duration = sound.duration();
      const progress = (currentTime / duration) * 100; // 进度百分比
      progressBar.style.width = `${progress}%`;
      currentTimeEl.textContent = formatTime(currentTime);
    }
  }, 1000);

  // 5. 点击进度条跳转播放位置
  progressContainer.addEventListener('click', (e) => {
    const containerWidth = progressContainer.offsetWidth;
    const clickPosition = e.offsetX;
    const progress = (clickPosition / containerWidth); // 点击位置的进度比例
    const targetTime = progress * sound.duration(); // 目标播放时间
    sound.seek(targetTime);
    progressBar.style.width = `${progress * 100}%`;
  });

  // 6. 音量调节
  volumeSlider.addEventListener('input', (e) => {
    const volume = parseFloat(e.target.value);
    sound.volume(volume);
  });

  // 7. 格式化时间(秒 → 分:秒,如 125 → 02:05)
  function formatTime(seconds) {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  }
</script>

4.2 3D 空间音效(模拟音频位置)

通过 3dpos 配置,实现 3D 空间音效,让用户感受到音频来自“特定方向”(如游戏中敌人脚步声从左侧传来):

const sound = new Howl({
  src: ['footstep.mp3'],
  3d: true, // 启用 3D 音效
  loop: true, // 循环播放(模拟持续脚步声)
  volume: 1,
  pos: [-10, 0, 0], // 初始位置:左侧 10 单位(x 轴负方向为左,正方向为右)
  distance: [1, 20], // 最小距离 1(音量最大),最大距离 20(音量为 0)
});

// 播放 3D 音效
sound.play();

// 模拟音频从左向右移动(每 100ms 移动 0.5 单位)
let x = -10;
const moveInterval = setInterval(() => {
  x += 0.5;
  sound.pos([x, 0, 0]); // 更新音频位置

  // 移动到右侧 10 单位后停止
  if (x >= 10) {
    clearInterval(moveInterval);
    sound.stop();
  }
}, 100);

效果说明:音频会从左侧逐渐移动到右侧,用户会听到声音从左耳机逐渐过渡到右耳机,音量随距离变化(靠近时变大,远离时变小)。

4.3 多音频叠加播放(如游戏音效)

在游戏或互动场景中,常需要同时播放多个音频(如背景音乐 + 按钮点击音效 + 技能释放音效),howler.js 会自动管理音频池,无需手动创建多个实例:

// 1. 创建背景音乐实例(循环播放,音量较低)
const bgm = new Howl({
  src: ['bgm.mp3'],
  loop: true,
  volume: 0.3,
});

// 2. 创建音效合集(音频精灵)
const sfx = new Howl({
  src: ['sfx.sprite.mp3'],
  sprite: {
    click: [0, 0.4], // 按钮点击音效
    skill: [1, 1.5], // 技能释放音效
    hit: [3, 0.8], // 击中音效
  },
  volume: 0.8,
});

// 3. 播放背景音乐
bgm.play();

// 4. 点击按钮时播放“点击”音效
document.getElementById('btn').addEventListener('click', () => {
  sfx.play('click');
});

// 5. 释放技能时播放“技能”音效
function releaseSkill() {
  sfx.play('skill');
  // 技能释放逻辑...
}

// 6. 敌人被击中时播放“击中”音效
function enemyHit() {
  sfx.play('hit');
  // 伤害计算逻辑...
}

优势:通过 pool 配置(默认 5),howler.js 会自动复用音频实例,避免同时创建过多实例导致性能问题。

4.4 音频加载错误处理

实际项目中可能出现音频文件不存在、网络加载失败等问题,需通过事件监听处理错误:

const sound = new Howl({
  src: ['invalid-audio.mp3'], // 不存在的音频文件
  onloaderror: (id, err) => {
    // 加载错误回调:id 为音频ID,err 为错误信息
    console.error('音频加载失败:', err);
    alert('音频加载失败,请检查文件路径或网络状态');
  },
  onplayerror: (id, err) => {
    // 播放错误回调(如加载未完成时尝试播放)
    console.error('音频播放失败:', err);
    alert('无法播放音频,请稍后重试');
  },
});

// 尝试播放(若加载失败,会触发 onplayerror)
sound.play();

错误类型说明

  • onloaderror:音频加载阶段错误(如文件不存在、格式不支持、跨域问题);
  • onplayerror:播放阶段错误(如加载未完成、浏览器自动拦截自动播放、音频被静音)。

跨域问题解决:若音频文件放在第三方服务器,需确保服务器配置了 CORS(跨域资源共享),否则会触发加载错误。

5. 性能优化建议

在多音频、长时间播放或移动端场景中,需注意性能优化,避免内存泄漏或卡顿:

5.1 及时销毁无用音频实例

当音频不再使用(如组件卸载、页面切换)时,需调用 unload() 方法销毁实例,释放音频资源(尤其是多音频场景):

// React 组件中示例
useEffect(() => {
  const sound = new Howl({
    src: ['temp-audio.mp3'],
  });

  // 组件卸载时销毁实例
  return () => {
    sound.unload(); // 关键:释放音频资源
  };
}, []);

注意stop() 仅停止播放,不会释放资源;unload() 会彻底销毁实例,后续无法再播放,需重新创建。

5.2 控制音频池大小(pool)

pool 配置用于限制同一 Howl 实例可同时播放的最大音频数量(默认 5),需根据场景调整:

  • 短音效场景(如按钮点击、游戏打击音效):可适当增大 pool(如 10),避免同时播放时被阻塞;
  • 长音频场景(如背景音乐、播客):pool 设为 1 即可(同一时间仅需播放一个实例),减少资源占用。

示例:

// 游戏短音效,支持 10 个同时播放
const sfx = new Howl({
  src: ['sfx.sprite.mp3'],
  sprite: { /* ... */ },
  pool: 10, // 增大音频池
});

5.3 优化音频加载策略

  • 按需加载:非首屏或非立即使用的音频(如游戏关卡音效),可延迟加载,避免首屏加载压力:
    // 点击按钮后加载并播放音频
    document.getElementById('levelBtn').addEventListener('click', () => {
      const levelSound = new Howl({
        src: ['level-bgm.mp3'],
        autoplay: true,
      });
    });
    
  • 预加载关键音频:首屏必需的音频(如首页背景音、引导音效),可设置 preload: true 提前加载;非关键音频设为 preload: false'metadata',仅加载时长、格式等元数据。

5.4 避免频繁创建销毁实例

对于重复使用的音频(如按钮点击音效),建议创建一个全局 Howl 实例反复播放,而非每次点击都创建新实例:

// 全局音效实例(只需创建一次)
const globalSfx = new Howl({
  src: ['sfx.sprite.mp3'],
  sprite: { click: [0, 0.5] },
});

// 多个按钮共用同一实例
document.querySelectorAll('.btn').forEach(btn => {
  btn.addEventListener('click', () => {
    globalSfx.play('click'); // 反复播放,无需重新创建
  });
});

5.5 移动端性能优化

  • 禁用自动播放:移动端浏览器(如 Safari、Chrome)大多禁止音频自动播放,需通过用户交互(如点击、触摸)触发播放,避免 autoplay: true 导致的错误;
  • 降低音频质量:移动端网络带宽有限,可提供低比特率的音频文件(如 MP3 比特率 128kbps),减少加载时间和流量消耗;
  • 避免 3D 音效过度使用:3D 音效需额外计算空间位置,移动端性能较弱时可能导致卡顿,非必要场景建议关闭 3d: false

6. 常见问题与解决方案

6.1 浏览器拦截自动播放?

  • 问题原因:现代浏览器为提升用户体验,禁止“无用户交互”的音频自动播放(如页面加载完成后直接 sound.play());
  • 解决方案
    1. 通过用户交互触发播放(如点击按钮、触摸屏幕):
      // 点击按钮后播放背景音乐
      document.getElementById('startBtn').addEventListener('click', () => {
        const bgm = new Howl({ src: ['bgm.mp3'], loop: true });
        bgm.play();
      });
      
    2. 部分浏览器支持“静音自动播放”,可先静音播放,再提示用户打开声音:
      const bgm = new Howl({
        src: ['bgm.mp3'],
        loop: true,
        mute: true, // 初始静音
        autoplay: true,
      });
      
      // 提示用户打开声音
      document.getElementById('unmuteBtn').addEventListener('click', () => {
        bgm.mute(false); // 取消静音
      });
      

6.2 音频格式不兼容?

  • 问题原因:不同浏览器支持的音频格式不同(如 Safari 不支持 OGG,Firefox 对 MP3 支持有限);
  • 解决方案:提供多种格式的音频文件,src 配置为数组,howler.js 会自动选择浏览器支持的格式:
    const sound = new Howl({
      src: ['audio.mp3', 'audio.ogg', 'audio.wav'], // MP3(主流)、OGG(开源)、WAV(无损)
    });
    
    常用格式兼容性
    • MP3:支持所有现代浏览器(推荐优先);
    • OGG:支持 Chrome、Firefox、Edge,不支持 Safari;
    • WAV:支持所有现代浏览器,但文件体积大(适合短音效)。

6.3 多音频播放时卡顿?

  • 问题原因:同时播放过多音频实例,或音频文件体积过大,导致 CPU/内存占用过高;
  • 解决方案
    1. 减少同时播放的音频数量(通过 pool 限制,或手动停止非必要音频);
    2. 压缩音频文件(如用工具将 MP3 比特率从 320kbps 降至 128kbps);
    3. 合并短音效为音频精灵(sprite),减少 HTTP 请求和实例数量。

6.4 音频进度条拖动不精准?

  • 问题原因setInterval 更新进度条的频率过低(如 1 秒一次),或拖动时未同步更新音频播放位置;
  • 解决方案
    1. 提高进度条更新频率(如 500ms 一次),减少视觉延迟:
      setInterval(() => {
        // 进度更新逻辑...
      }, 500); // 500ms 更新一次,比 1 秒更流畅
      
    2. 拖动进度条时,先停止 setInterval,拖动结束后重启,避免冲突:
      let progressInterval;
      
      // 启动进度更新
      function startProgressUpdate() {
        progressInterval = setInterval(() => { /* ... */ }, 500);
      }
      
      // 停止进度更新
      function stopProgressUpdate() {
        clearInterval(progressInterval);
      }
      
      // 拖动进度条时
      progressContainer.addEventListener('mousedown', () => {
        stopProgressUpdate(); // 停止更新
      });
      
      progressContainer.addEventListener('mouseup', (e) => {
        // 处理拖动逻辑...
        startProgressUpdate(); // 重启更新
      });
      

7. 总结

howler.js 是 Web 端音频处理的“瑞士军刀”,其核心价值在于统一音频操作 API、解决浏览器兼容性问题、简化复杂音效实现。通过本文的讲解,可掌握:

  1. 基础用法:创建音频实例、控制播放/暂停/音量/进度,满足简单音频场景需求;
  2. 进阶功能:音频精灵(Sprite)、3D 空间音效、多音频叠加,应对游戏、互动多媒体等复杂场景;
  3. 性能优化:及时销毁实例、控制音频池大小、按需加载,确保多音频或移动端场景流畅运行;
  4. 问题排查:解决自动播放拦截、格式兼容、进度条精准度等常见问题。

适用场景包括:网页背景音乐、互动音效(按钮点击、弹窗)、游戏音频系统、播客/音频播放器、在线教育音频课件等。在实际开发中,需结合“用户体验”和“性能成本”选择合适的音频策略,让音频成为产品的加分项而非性能负担。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

她问我::is-logged 是啥?我说:前面加冒号,就是 Vue 在发暗号

深夜代码系列 · 第4期

关注我,和小豆一起在掘金看小说

🔥 开篇引爆

周五下午,我刚想摸鱼打开掘金,水篇小说,她突然走过来,一脸困惑地指着我屏幕上的代码。

“豆子,你看看这个,冒号和 @ 都是啥意思?我知道它们是 Vue 的语法糖,但具体怎么理解?我 Vue2 写到吐,Vue3 一升级全不会了!”

我一看,正是我们项目里最常见的 Header 组件调用:

<Header
  :is-logged-in="isLoggedIn"
  :username="username"
  @logout="handleLogout"
  @login-success="handleLoginSuccess"
/>

我放下鼠标,给她倒了杯水,笑眯眯地说:“这三个符号,就像是父子组件之间的三条秘密通道,它们分别负责传递数据接收信号。”


🎯 初步分析:父子组件通信的“传声筒”原理

父组件需要向子组件传递数据(如登录状态),子组件需要向父组件发送事件(如用户点击登出),实现双向通信。

核心概念:

  1. props(父 → 子):父组件通过属性向子组件传递数据。
  2. emit(子 → 父):子组件通过事件向父组件发送消息。

:is-logged-in:它负责“传递数据

我指着代码中的冒号,开始解释:

“你看这个 :,它是 v-bind 的简写。你可以把它想象成一个单向快递。”

<!-- 动态绑定 prop -->
:is-logged-in="isLoggedIn"  // 等价于 v-bind:is-logged-in="isLoggedIn"

“父组件(我们现在所在的这个页面)是快递公司,isLoggedIn 是一个包裹,里面装着‘用户是否登录’这个信息。我们用 :is-logged-in 这个‘快递单’,把这个包裹寄给了子组件 Header。”

“所以,当父组件里的 isLoggedIn 变量从 false 变成 true 时,这个包裹里的内容也会自动更新,子组件就会立刻收到最新的状态。”

小汐若有所思地点点头:“我懂了,这个冒号就是把父组件的数据动态地‘喂’给子组件,对吧?”

“没错,”我打了个响指,“这就是 props 传值 的过程。父组件通过 props 把数据传递给子组件,让子组件知道‘现在是什么情况’。”

Prop 命名规范

  • 父组件模板中使用 kebab-case:is-logged-in
  • 子组件中使用 camelCaseisLoggedin

类型安全

defineProps({
  isLoggedin: Boolean,
  username: {
    type: String,
    required: true,
    default: '游客'
  }
})

@logout@login-success:它们负责“接收信号

我继续指着 @ 符号,解释道:

“如果说冒号是快递,那么 @ 就是一个对讲机。”

<!-- 监听自定义事件 -->
@logout="handleLogout"       // 等价于 v-on:logout="handleLogout"

“当用户在 Header 组件里点击了‘登出’按钮,子组件会对着对讲机喊一声:‘logout’!而父组件这边一直开着对讲机,听到这个信号后,就会立即调用 handleLogout 方法,把 isLoggedIn 设为 false,清空 username。”

@login-success 也是同理,当子组件完成登录操作后,它会对着对讲机喊:‘login-success’,甚至还会顺便把用户信息作为‘暗号’一起发送过来。父组件接收到信号和暗号后,就能调用 handleLoginSuccess 方法来更新用户信息了。”

小汐听完,露出了恍然大悟的表情:“所以,@ 就是 v-on 的简写,用来监听子组件发出的自定义事件。这就像是子组件在告诉父组件:‘我干完活了,你来处理一下吧!’”

事件命名规范

  • 使用 kebab-case@login-success
  • 事件名要有动词:login-successupdate-userdelete-item

事件声明

defineEmits(['logout', 'login-success'])
// 或带验证
defineEmits({
  logout: null,
  'login-success': (user) => {
    return user && typeof user.name === 'string'
  }
})

三兄弟身份档案(必背)

符号 长写 身份 方向 场景
: v-bind: 动态绑定 父 → 子(prop) 变量塞给子组件
@ v-on: 事件监听 子 → 父(emit) 子组件喊"爸,有人点我!"
. 修饰符 语法糖plus —— 如 @click.stop

记住口诀:

"有冒号传变量,无冒号传字面量;有 @ 等孩子喊妈。 "


示例代码

父组件 (App.vue):状态的“总指挥官”

<template>
  <div>
    <Header
      :is-logged-in="isLoggedIn"
      :username="username"
      @logout="handleLogout"
      @login-success="handleLoginSuccess"
    />
    <p>当前登录状态: {{ isLoggedIn ? '已登录' : '未登录' }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Header from './Header.vue';

// 定义父组件的状态
const isLoggedIn = ref(false);
const username = ref('');

// 定义处理子组件发出的事件的方法
const handleLogout = () => {
  isLoggedIn.value = false;
  username.value = '';
  console.log('✅ 父组件收到登出信号,状态已更新!');
};

const handleLoginSuccess = (user) => {
  isLoggedIn.value = true;
  username.value = user.name;
  console.log(`✅ 父组件收到登录成功信号,用户:${user.name}!`);
};
</script>

子组件 (Header.vue):事件的“执行者”

<template>
  <header>
    <div v-if="isLoggedIn">
      <span>欢迎,{{ username }}</span>
      <button @click="logout">登出</button>
    </div>
    <div v-else>
      <button @click="login">登录</button>
    </div>
  </header>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue';

// 接收父组件传递的props
defineProps({
  isLoggedIn: Boolean,
  username: String,
});

// 声明子组件将要发出的事件
const emit = defineEmits(['logout', 'login-success']);

// 触发登出事件
const logout = () => {
  console.log('➡️ 子组件发出登出信号...');
  emit('logout');
};

// 触发登录成功事件,并传递参数
const login = () => {
  const user = { name: '小明' };
  console.log('➡️ 子组件发出登录成功信号,并附带用户信息...');
  emit('login-success', user);
};
</script>

流程图

image.png


⚠️ 常见坑点:

  • 坑1:在子组件中直接修改 prop

    // ❌ 错误做法
    props.isLoggedin = false // 会报警告
    
    // ✅ 正确做法
    emit('update:isLoggedin', false) // 或使用 v-model
    
  • 坑2:忘记声明 emits

    // ❌ 未声明的事件在 strict 模式下会报警告
    emit('logout')
    
    // ✅ 正确做法
    const emit = defineEmits(['logout'])
    
  • 坑3:事件名大小写错误

    <!-- ❌ 模板中不能用 camelCase -->
    @loginSuccess="handleLoginSuccess"
    
    <!-- ✅ 必须用 kebab-case -->
    @login-success="handleLoginSuccess"
    
  • 坑4:静态字符串导致布尔值失效

    <!-- ❌ 恒为真,变量失效 -->
    is-logged-in="true"
    
    <!-- ✅ 使用绑定,让 Vue 知道这是 JS 表达式 -->
    :is-logged-in="true"
    
  • 坑5:emit 名称与声明大小写不一致

    // ❌ 与声明不符,控制台警告
    emit('loginSuccess')
    
    // ✅ 与模板保持一致
    emit('login-success')
    
  • 坑6:prop 类型对不上,dev 爆红

    // ❌ 类型对不上,dev 直接爆红
    defineProps({ isLoggedIn: String })
    
    // ✅ 类型保持一致
    defineProps({ isLoggedIn: Boolean })
    

🌙 温馨收尾:凌晨两点的顿悟

小汐兴奋地拍了拍我的肩膀:“原来如此!这样一讲,我感觉整个组件的通信逻辑都清晰了。怪不得你总是说,理解了 propsemit,就掌握了 Vue 的精髓!”

我看着她远去的背影,心里默默想道:今天下午的摸鱼时间没了,掘金我都还没看呢,这波真是亏大了

JavaScript 循环与对象:深入理解 for、for...in、for...of、不可枚举属性与可迭代对象

在 JavaScript 的世界里,有多种方式可以遍历数据和操作对象。本文将深入探讨 for 循环、for...infor...of 三种循环的区别,并介绍如何创建具有特殊行为(如不可枚举属性)的对象,以及如何自定义可迭代对象,让你的代码更加灵活和强大。

1. 循环的演变:forfor...in 与 for...of

这三种循环各自有不同的设计初衷和最佳应用场景,了解它们的差异对于写出高效且健壮的代码至关重要。

a. for 循环:最传统的遍历方式

  • 功能: 通过手动控制初始化、条件和迭代器,提供对循环过程最细粒度的控制。
  • 迭代目标: 通常用于遍历数组,通过索引访问元素。
  • 迭代内容: 循环变量是数组的索引
  • 最佳实践: 当你需要精确控制循环的开始、结束、步长,或在循环中频繁操作索引时,for 循环是最佳选择。在处理大型数组时,其性能通常优于其他循环。
const arr = ['苹果', '香蕉', '橙子'];
for (let i = 0; i < arr.length; i++) {
  console.log(`索引 ${i} 的值是 ${arr[i]}`);
}
// 输出:
// 索引 0 的值是 苹果
// 索引 1 的值是 香蕉
// 索引 2 的值是 橙子

b. for...in:遍历对象的键

  • 功能: 遍历一个对象所有可枚举的字符串属性,包括原型链上的属性。
  • 迭代目标: 主要用于对象
  • 迭代内容: 循环变量是对象的键(属性名)
  • 重要提示不推荐用于遍历数组。由于其会遍历原型链,且遍历顺序不确定,可能导致不可预测的行为。若需要遍历对象自身的属性,应配合 hasOwnProperty() 方法进行过滤。
const obj = { name: 'Alice', age: 30 };
for (const key in obj) {
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
    console.log(`${key}: ${obj[key]}`);
  }
}
// 输出:
// name: Alice
// age: 30

c. for...of:遍历可迭代对象的值

  • 功能: 遍历可迭代对象(Iterable Object)的。这是 ES6 引入的现代循环方式,旨在解决 for...in 遍历数组时的弊端。
  • 迭代目标: 适用于数组、字符串、MapSet 等所有可迭代对象。
  • 迭代内容: 循环变量是可迭代对象的
  • 优点: 语法简洁,直接访问值,并且支持 break 和 continue 控制流。
const arr = ['苹果', '香蕉', '橙子'];
for (const value of arr) {
  console.log(value);
}
// 输出:
// 苹果
// 香蕉
// 橙子

const str = "hello";
for (const char of str) {
  console.log(char);
}
// 输出:
// h
// e
// l
// l
// o

2. 精确控制属性:创建不可枚举属性

在某些场景下,我们希望给对象添加一些内部使用的属性,但又不想让它们在常规遍历中暴露。这时,可以使用 Object.defineProperty() 方法来创建**不可枚举(non-enumerable)**属性。

使用 Object.defineProperty()

Object.defineProperty() 允许你精确地配置属性的特性,包括其可枚举性(enumerable)、可写性(writable)和可配置性(configurable)。

const user = {
  name: 'Alice',
  age: 30
};

// 使用 Object.defineProperty() 添加一个不可枚举的属性 'id'
Object.defineProperty(user, 'id', {
  value: 12345,        // 属性的值
  writable: false,     // 不可被重新赋值
  enumerable: false,   // 不可被枚举(例如:for...in, Object.keys())
  configurable: false  // 不可被删除或更改特性
});

// 验证不可枚举性
for (const key in user) {
  console.log(key); // 输出: 'name', 'age'。忽略了 'id'。
}
console.log(Object.keys(user)); // 输出: ['name', 'age']。忽略了 'id'。
console.log(user.id); // 输出: 12345。仍然可以通过点或方括号正常访问。

3. 自定义迭代行为:创建可迭代对象

要使一个自定义对象能够被 for...of 循环遍历,你需要让它成为一个可迭代对象(Iterable) 。这意味着你需要在对象上实现一个 Symbol.iterator 方法,该方法返回一个符合迭代器协议的对象。

方法一:使用常规函数

手动实现 [Symbol.iterator] 方法,并返回一个带有 next() 方法的对象。

const myCustomObject = {
  data: ['一', '二', '三'],
  [Symbol.iterator]: function() {
    let index = 0;
    const data = this.data;
    return {
      next: function() {
        if (index < data.length) {
          return { value: data[index++], done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (const item of myCustomObject) {
  console.log(item);
}
// 输出:
// 一
// 二
// 三

方法二:使用生成器函数(更简洁)

生成器函数(function*)是创建迭代器的更现代、更简洁的方法。yield 关键字会自动为你管理迭代状态。

const myCustomObject = {
  data: ['红', '黄', '蓝'],
  *[Symbol.iterator]() {
    for (const item of this.data) {
      yield item;
    }
  }
};

for (const color of myCustomObject) {
  console.log(color);
}
// 输出:
// 红
// 黄
// 蓝

🎨 SCSS 高级用法完全指南:从入门到精通

🚀 想让 CSS 写得更爽?本文手把手教你 SCSS 的各种实用技巧,让你的样式代码又好写又好管理!

📚 目录


为了实时查看,我这边使用工程化来练习:

企业微信截图_17604966056743.png

1. 变量与作用域

1.1 局部变量与全局变量

// 全局变量
$primary-color: #3498db;

.container {
  // 局部变量
  $padding: 20px;
  padding: $padding;

  .item {
    // 可以访问父级局部变量
    margin: $padding / 2;
    color: $primary-color;
  }
}

// $padding 在这里不可用

1.2 !global 标志

.element {
  $local-var: 10px;

  @if true {
    // 使用 !global 将局部变量提升为全局
    $local-var: 20px !global;
  }
}

// 现在可以在外部访问
.another {
  padding: $local-var; // 20px
}

1.3 !default 标志

// 设置默认值,如果变量已存在则不覆盖
$base-font-size: 16px !default;
$primary-color: #333 !default;

// 这在创建主题或库时非常有用

1.4 Map 变量

// 定义颜色系统
$colors: (
  primary: #3498db,
  secondary: #2ecc71,
  danger: #e74c3c,
  warning: #f39c12,
  info: #9b59b6,
);

// 使用 map-get 获取值
.button {
  background: map-get($colors, primary);

  &.danger {
    background: map-get($colors, danger);
  }
}

// 深层嵌套的 Map
$theme: (
  colors: (
    light: (
      bg: #ffffff,
      text: #333333,
    ),
    dark: (
      bg: #1a1a1a,
      text: #ffffff,
    ),
  ),
  spacing: (
    small: 8px,
    medium: 16px,
    large: 24px,
  ),
);

// 获取深层值
.dark-mode {
  background: map-get(map-get(map-get($theme, colors), dark), bg);
}

2. 嵌套与父选择器

2.1 父选择器 & 的高级用法

// BEM 命名法
.card {
  padding: 20px;

  &__header {
    font-size: 18px;
  }

  &__body {
    margin: 10px 0;
  }

  &--featured {
    border: 2px solid gold;
  }

  // 伪类
  &:hover {
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  }

  // 父选择器在后面
  .dark-theme & {
    background: #333;
  }
}

2.2 嵌套属性

.button {
  // 嵌套属性值
  font: {
    family: 'Helvetica', sans-serif;
    size: 14px;
    weight: bold;
  }

  border: {
    top: 1px solid #ccc;
    bottom: 2px solid #999;
    radius: 4px;
  }

  transition: {
    property: all;
    duration: 0.3s;
    timing-function: ease-in-out;
  }
}

2.3 @at-root 跳出嵌套

.parent {
  color: blue;

  @at-root .child {
    // 这会在根级别生成 .child 而不是 .parent .child
    color: red;
  }

  @at-root {
    .sibling-1 {
      color: green;
    }
    .sibling-2 {
      color: yellow;
    }
  }
}

3. Mixins 高级技巧

3.1 带参数的 Mixin

// 基础 Mixin
@mixin flex-center($direction: row) {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: $direction;
}

// 使用
.container {
  @include flex-center(column);
}

3.2 可变参数 (...)

// 接收任意数量的参数
@mixin box-shadow($shadows...) {
  -webkit-box-shadow: $shadows;
  -moz-box-shadow: $shadows;
  box-shadow: $shadows;
}

// 使用
.card {
  @include box-shadow(0 2px 4px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.05));
}

// 传递多个值
@mixin transition($properties...) {
  transition: $properties;
}

.button {
  @include transition(background 0.3s ease, transform 0.2s ease-out);
}

3.3 @content 指令

// 响应式 Mixin
@mixin respond-to($breakpoint) {
  @if $breakpoint == 'mobile' {
    @media (max-width: 767px) {
      @content;
    }
  } @else if $breakpoint == 'tablet' {
    @media (min-width: 768px) and (max-width: 1023px) {
      @content;
    }
  } @else if $breakpoint == 'desktop' {
    @media (min-width: 1024px) {
      @content;
    }
  }
}

// 使用
.sidebar {
  width: 300px;

  @include respond-to('mobile') {
    width: 100%;
    display: none;
  }

  @include respond-to('tablet') {
    width: 200px;
  }
}

3.4 高级响应式 Mixin

$breakpoints: (
  xs: 0,
  sm: 576px,
  md: 768px,
  lg: 992px,
  xl: 1200px,
  xxl: 1400px,
);

@mixin media-breakpoint-up($name) {
  $min: map-get($breakpoints, $name);
  @if $min {
    @media (min-width: $min) {
      @content;
    }
  } @else {
    @content;
  }
}

@mixin media-breakpoint-down($name) {
  $max: map-get($breakpoints, $name) - 1px;
  @if $max {
    @media (max-width: $max) {
      @content;
    }
  }
}

// 使用
.container {
  padding: 15px;

  @include media-breakpoint-up(md) {
    padding: 30px;
  }

  @include media-breakpoint-up(lg) {
    padding: 45px;
  }
}

3.5 主题切换 Mixin

@mixin theme($theme-name) {
  @if $theme-name == 'light' {
    background: #ffffff;
    color: #333333;
  } @else if $theme-name == 'dark' {
    background: #1a1a1a;
    color: #ffffff;
  }
}


// 更灵活的主题系统
$themes: (
  light: (
    bg: #ffffff,
    text: #333333,
    primary: #3498db,
  ),
  dark: (
    bg: #1a1a1a,
    text: #ffffff,
    primary: #5dade2,
  ),
);

@mixin themed() {
  @each $theme, $map in $themes {
    .theme-#{$theme} & {
      $theme-map: $map !global;
      @content;
      $theme-map: null !global;
    }
  }
}

@function t($key) {
  @return map-get($theme-map, $key);
}

// 使用
.card {
  @include themed() {
    background: t(bg);
    color: t(text);
    border-color: t(primary);
  }
}

4. 函数的妙用

4.1 自定义函数

// 计算 rem
@function rem($pixels, $base: 16px) {
  @return ($pixels / $base) * 1rem;
}

.title {
  font-size: rem(24px); // 1.5rem
  margin-bottom: rem(16px); // 1rem
}

4.2 颜色操作函数

// 创建颜色变体
@function tint($color, $percentage) {
  @return mix(white, $color, $percentage);
}

@function shade($color, $percentage) {
  @return mix(black, $color, $percentage);
}

$primary: #3498db;

.button {
  background: $primary;

  &:hover {
    background: shade($primary, 20%);
  }

  &.light {
    background: tint($primary, 30%);
  }
}

4.3 字符串操作

@function str-replace($string, $search, $replace: '') {
  $index: str-index($string, $search);

  @if $index {
    @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index +
            str-length($search)), $search, $replace);
  }

  @return $string;
}

// 使用
$font-family: str-replace('Arial, sans-serif', 'Arial', 'Helvetica');

4.4 深度获取 Map 值

@function deep-map-get($map, $keys...) {
  @each $key in $keys {
    $map: map-get($map, $key);
  }
  @return $map;
}

$config: (
  theme: (
    colors: (
      primary: (
        base: #3498db,
        light: #5dade2,
      ),
    ),
  ),
);
    
.element {
  color: deep-map-get($config, theme, colors, primary, base);
}

5. 继承与占位符

5.1 基础继承

.message {
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.success-message {
  @extend .message;
  border-color: #2ecc71;
  background: #d5f4e6;
}

.error-message {
  @extend .message;
  border-color: #e74c3c;
  background: #fadbd8;
}

5.2 占位符选择器 %

// 占位符不会单独生成 CSS
%flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

%text-truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.card-title {
  @extend %text-truncate;
  font-size: 18px;
}

.modal {
  @extend %flex-center;
  min-height: 100vh;
}

5.3 多重继承

%bordered {
  border: 1px solid #ddd;
}

%rounded {
  border-radius: 8px;
}

%shadowed {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.card {
  @extend %bordered;
  @extend %rounded;
  @extend %shadowed;
  padding: 20px;
}

6. 控制指令

6.1 @if / @else

@mixin theme-color($theme) {
  @if $theme == 'light' {
    background: white;
    color: black;
  } @else if $theme == 'dark' {
    background: black;
    color: white;
  } @else {
    background: gray;
    color: white;
  }
}

.app {
  @include theme-color('dark');
}

6.2 @for 循环

// 生成网格系统
@for $i from 1 through 12 {
  .col-#{$i} {
    width: percentage($i / 12);
  }
}

// 生成间距工具类
$spacing: (5, 10, 15, 20, 25, 30);

@for $i from 1 through length($spacing) {
  $space: nth($spacing, $i);

  .m-#{$space} {
    margin: #{$space}px;
  }
  .p-#{$space} {
    padding: #{$space}px;
  }
  .mt-#{$space} {
    margin-top: #{$space}px;
  }
  .pt-#{$space} {
    padding-top: #{$space}px;
  }
  .mb-#{$space} {
    margin-bottom: #{$space}px;
  }
  .pb-#{$space} {
    padding-bottom: #{$space}px;
  }
}

6.3 @each 循环

// 遍历列表
$colors: primary, secondary, success, danger, warning, info;

@each $color in $colors {
  .btn-#{$color} {
    background: var(--#{$color}-color);
  }
}

// 遍历 Map
$social-colors: (
  facebook: #3b5998,
  twitter: #1da1f2,
  instagram: #e4405f,
  linkedin: #0077b5,
  youtube: #ff0000,
);

@each $name, $color in $social-colors {
  .btn-#{$name} {
    background-color: $color;

    &:hover {
      background-color: darken($color, 10%);
    }
  }
}

// 多重值遍历
$sizes: (small, 12px, 500, medium, 14px, 600, large, 16px, 700);

@each $size, $font-size, $font-weight in $sizes {
  .text-#{$size} {
    font-size: $font-size;
    font-weight: $font-weight;
  }
}

6.4 @while 循环

// 生成渐进式字体大小
$i: 6;
@while $i > 0 {
  h#{$i} {
    font-size: 2em - ($i * 0.2);
  }
  $i: $i - 1;
}

7. 模块化系统

7.1 @use 和 @forward

// _variables.scss
$primary-color: #3498db;
$secondary-color: #2ecc71;

// _mixins.scss
@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

// _functions.scss
@function rem($px) {
  @return ($px / 16px) * 1rem;
}

// main.scss - 新的模块系统
@use 'variables' as vars;
@use 'mixins' as mix;
@use 'functions' as fn;

.container {
  @include mix.flex-center;
  color: vars.$primary-color;
  padding: fn.rem(20px);
}

7.2 命名空间

// _config.scss
$primary: #3498db;

@mixin button {
  padding: 10px 20px;
  border-radius: 4px;
}

// styles.scss
@use 'config' as cfg;

.btn {
  @include cfg.button;
  background: cfg.$primary;
}

// 或者移除命名空间前缀
@use 'config' as *;

.btn {
  @include button;
  background: $primary;
}

7.3 @forward 创建索引文件

// styles/_index.scss
@forward 'variables';
@forward 'mixins';
@forward 'functions';

// main.scss
@use 'styles';

.element {
  color: styles.$primary-color;
  @include styles.flex-center;
}

8. 内置函数库

8.1 颜色函数

$base-color: #3498db;

.color-demo {
  // 颜色调整
  color: adjust-hue($base-color, 45deg);

  // 亮度
  background: lighten($base-color, 20%);
  border-color: darken($base-color, 15%);

  // 饱和度
  &.vibrant {
    background: saturate($base-color, 30%);
  }

  &.muted {
    background: desaturate($base-color, 20%);
  }

  // 透明度
  box-shadow: 0 2px 8px rgba($base-color, 0.3);
  border: 1px solid transparentize($base-color, 0.5);

  // 混合颜色
  &.mixed {
    background: mix(#3498db, #e74c3c, 50%);
  }

  // 补色
  &.complement {
    background: complement($base-color);
  }
}

8.2 数学函数

.math-demo {
  // 基础运算
  width: percentage(5 / 12); // 41.66667%
  padding: round(13.6px); // 14px
  margin: ceil(10.1px); // 11px
  height: floor(19.9px); // 19px

  // 最大最小值
  font-size: max(14px, 1rem);
  width: min(100%, 1200px);

  // 绝对值
  top: abs(-20px); // 20px

  // 随机数
  opacity: random(100) / 100;
}

8.3 列表函数

$list: 10px 20px 30px 40px;

.list-demo {
  // 获取长度
  $length: length($list); // 4

  // 获取元素
  padding-top: nth($list, 1); // 10px
  padding-right: nth($list, 2); // 20px

  // 索引
  $index: index($list, 20px); // 2

  // 追加
  $new-list: append($list, 50px);

  // 合并
  $merged: join($list, (60px 70px));
}

8.4 Map 函数

$theme: (
  primary: #3498db,
  secondary: #2ecc71,
  danger: #e74c3c,
);

.map-demo {
  // 获取值
  color: map-get($theme, primary);

  // 合并 Map
  $extended: map-merge(
    $theme,
    (
      success: #27ae60,
    )
  );

  // 检查键是否存在
  @if map-has-key($theme, primary) {
    background: map-get($theme, primary);
  }

  // 获取所有键
  $keys: map-keys($theme); // primary, secondary, danger

  // 获取所有值
  $values: map-values($theme);
}

8.5 字符串函数

$text: 'Hello World';

.string-demo {
  // 转大写
  content: to-upper-case($text); // "HELLO WORLD"

  // 转小写
  content: to-lower-case($text); // "hello world"

  // 字符串长度
  $length: str-length($text); // 11

  // 查找索引
  $index: str-index($text, 'World'); // 7

  // 切片
  content: str-slice($text, 1, 5); // "Hello"

  // 插入
  content: str-insert($text, ' Beautiful', 6); // "Hello Beautiful World"

  // 去引号
  font-family: unquote('"Arial"'); // Arial
}

9. 实战技巧

9.1 响应式字体大小

@function strip-unit($value) {
  @return $value / ($value * 0 + 1);
}

@mixin fluid-type($min-vw, $max-vw, $min-font-size, $max-font-size) {
  $u1: unit($min-vw);
  $u2: unit($max-vw);
  $u3: unit($min-font-size);
  $u4: unit($max-font-size);

  @if $u1 == $u2 and $u1 == $u3 and $u1 == $u4 {
    & {
      font-size: $min-font-size;

      @media screen and (min-width: $min-vw) {
        font-size: calc(
          #{$min-font-size} + #{strip-unit($max-font-size - $min-font-size)} *
            ((100vw - #{$min-vw}) / #{strip-unit($max-vw - $min-vw)})
        );
      }

      @media screen and (min-width: $max-vw) {
        font-size: $max-font-size;
      }
    }
  }
}

h1 {
  @include fluid-type(320px, 1200px, 24px, 48px);
}

9.2 深色模式切换

$themes: (
  light: (
    bg: #ffffff,
    text: #333333,
    border: #e0e0e0,
    primary: #3498db,
  ),
  dark: (
    bg: #1a1a1a,
    text: #f0f0f0,
    border: #404040,
    primary: #5dade2,
  ),
);

@mixin themed-component {
  @each $theme-name, $theme-colors in $themes {
    [data-theme='#{$theme-name}'] & {
      $theme-map: $theme-colors !global;
      @content;
      $theme-map: null !global;
    }
  }
}

@function theme-color($key) {
  @return map-get($theme-map, $key);
}

.card {
  @include themed-component {
    background: theme-color(bg);
    color: theme-color(text);
    border: 1px solid theme-color(border);
  }

  &__button {
    @include themed-component {
      background: themed-component {
      background: theme-color(primary);
      color: theme-color(bg);
    }
  }
}

9.3 原子化 CSS 生成器

$spacing-map: (
  0: 0,
  1: 0.25rem,
  2: 0.5rem,
  3: 0.75rem,
  4: 1rem,
  5: 1.25rem,
  6: 1.5rem,
  8: 2rem,
  10: 2.5rem,
  12: 3rem,
  16: 4rem,
  20: 5rem,
);

$directions: (
  '': '',
  't': '-top',
  'r': '-right',
  'b': '-bottom',
  'l': '-left',
  'x': (
    '-left',
    '-right',
  ),
  'y': (
    '-top',
    '-bottom',
  ),
);

@each $size-key, $size-value in $spacing-map {
  @each $dir-key, $dir-value in $directions {
    // Margin
    .m#{$dir-key}-#{$size-key} {
      @if type-of($dir-value) == 'list' {
        @each $d in $dir-value {
          margin#{$d}: $size-value;
        }
      } @else {
        margin#{$dir-value}: $size-value;
      }
    }

    // Padding
    .p#{$dir-key}-#{$size-key} {
      @if type-of($dir-value) == 'list' {
        @each $d in $dir-value {
          padding#{$d}: $size-value;
        }
      } @else {
        padding#{$dir-value}: $size-value;
      }
    }
  }
}

9.4 三角形生成器

@mixin triangle($direction, $size, $color) {
  width: 0;
  height: 0;
  border: $size solid transparent;

  @if $direction == 'up' {
    border-bottom-color: $color;
  } @else if $direction == 'down' {
    border-top-color: $color;
  } @else if $direction == 'left' {
    border-right-color: $color;
  } @else if $direction == 'right' {
    border-left-color: $color;
  }
}

.tooltip {
  position: relative;

  &::after {
    content: '';
    position: absolute;
    top: 100%;
    left: 50%;
    transform: translateX(-50%);
    @include triangle(down, 8px, #333);
  }
}

9.5 网格系统生成器

$grid-columns: 12;
$grid-gutter-width: 30px;
$container-max-widths: (
  sm: 540px,
  md: 720px,
  lg: 960px,
  xl: 1140px,
  xxl: 1320px,
);

@mixin make-container($padding-x: $grid-gutter-width / 2) {
  width: 100%;
  padding-right: $padding-x;
  padding-left: $padding-x;
  margin-right: auto;
  margin-left: auto;
}

@mixin make-row($gutter: $grid-gutter-width) {
  display: flex;
  flex-wrap: wrap;
  margin-right: -$gutter / 2;
  margin-left: -$gutter / 2;
}

@mixin make-col($size, $columns: $grid-columns) {
  flex: 0 0 auto;
  width: percentage($size / $columns);
  padding-right: $grid-gutter-width / 2;
  padding-left: $grid-gutter-width / 2;
}

.container {
  @include make-container;

  @each $breakpoint, $width in $container-max-widths {
    @include media-breakpoint-up($breakpoint) {
      max-width: $width;
    }
  }
}
.row {
  @include make-row;
}

@for $i from 1 through $grid-columns {
  .col-#{$i} {
    @include make-col($i);
  }
}

9.6 长阴影效果

@function long-shadow($length, $color, $opacity) {
  $shadow: '';

  @for $i from 0 through $length {
    $shadow: $shadow +
      '#{$i}px #{$i}px rgba(#{red($color)}, #{green($color)}, #{blue($color)}, #{$opacity})';

    @if $i < $length {
      $shadow: $shadow + ', ';
    }
  }

  @return unquote($shadow);
}

.text-shadow {
  text-shadow: long-shadow(50, #000, 0.05);
}

9.7 动画关键帧生成器

@mixin keyframes($name) {
  @keyframes #{$name} {
    @content;
  }
}

@include keyframes(fadeIn) {
  from {
    opacity: 0;
    transform: translateY(-20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-fade {
  animation: fadeIn 0.5s ease-out;
}

9.8 清除浮动

@mixin clearfix {
  &::after {
    content: '';
    display: table;
    clear: both;
  }
}

.container {
  @include clearfix;
}

🎯 总结

SCSS 的高级特性让我们能够:

  1. 提高代码复用性 - 通过 mixin、函数和继承
  2. 增强可维护性 - 使用变量、模块化和命名空间
  3. 提升开发效率 - 利用循环、条件判断自动生成样式
  4. 保持代码整洁 - 嵌套、占位符和模块系统
  5. 创建强大的工具库 - 自定义函数和 mixin 集合

最佳实践建议

  1. 变量命名要语义化

    // Good
    $primary-color: #3498db;
    $spacing-unit: 8px;
    
    // Bad
    $blue: #3498db;
    $var1: 8px;
    
  2. 避免嵌套层级过深(建议不超过 3-4 层)

    // Good
    .card {
      &__header {
      }
      &__body {
      }
    }
    
    // Bad - 嵌套太深
    .card {
      .wrapper {
        .inner {
          .content {
            .text {
            }
          }
        }
      }
    }
    
  3. 优先使用 @use 而不是 @import

// Modern
@use 'variables';
@use 'mixins';

// Legacy
@import 'variables';
@import 'mixins';
  1. 使用占位符代替类继承

    // Good
    %btn-base {
    }
    .btn {
      @extend %btn-base;
    }
    
    // Less optimal
    .btn-base {
    }
    .btn {
      @extend .btn-base;
    }
    
  2. 合理组织文件结构 styles/ ├── abstracts/ │ ├── _variables.scss │ ├── _functions.scss │ └── _mixins.scss ├── base/ │ ├── _reset.scss │ └── _typography.scss ├── components/ │ ├── _buttons.scss │ └── _cards.scss ├── layout/ │ ├── _header.scss │ └── _footer.scss └── main.scss


📚 参考资源


如果这篇文章对你有帮助,欢迎点赞收藏! 👍

有任何问题或补充,欢迎在评论区讨论~ 💬

❌