普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月19日首页

Pinia 状态管理原理与实战全解析

作者 90后晨仔
2025年10月18日 22:43

一、前言:为什么选择 Pinia?

在 Vue2 时代,我们常用 Vuex 来做全局状态管理。

但是 Vue3 带来了全新的响应式系统(Composition API + Proxy),于是 Vue 官方团队推出了 Pinia —— 一款更轻量、更现代、更易用的状态管理库。

Pinia 的核心理念是:

“让状态管理像使用普通变量一样简单。”

相比 Vuex,它具备以下优势:

特点 Vuex Pinia
语法 基于 Mutations/Actions 直接使用函数
类型推导 较弱 TypeScript 支持友好
响应式实现 Object.defineProperty Vue3 Proxy
模块化 需手动命名空间 天然支持模块化
体积 较大 小巧轻量(约1KB)

二、Pinia 的核心原理

要理解 Pinia,先要知道它是如何在底层维持响应式的。

1. 状态的本质:Reactive

Pinia 使用 Vue3 的 reactive() 来存储状态。

每个 store 内部其实就是一个被 reactive 包裹的对象。

import { reactive } from 'vue'

const state = reactive({
  count: 0
})

当 state.count++ 改变时,所有引用它的组件都会自动更新。

这就是 Pinia 响应式的根本机制。

2. Getter 的本质:Computed

在 Pinia 中,getter 相当于 Vue 中的 computed。

getters: {
  doubleCount: (state) => state.count * 2
}

Pinia 会在内部把它转成一个 计算属性,只有依赖变化时才会重新计算。

因此它是 响应式、缓存式 的。

3. Action 的本质:普通函数 + 作用域代理

Pinia 不再强制使用 Mutation。

Action 其实就是对状态修改的封装函数。

actions: {
  increment() {
    this.count++
  }
}

Pinia 通过 Proxy 让 this 指向 store 实例,因此你可以像访问普通对象一样修改状态。

4. 模块与依赖收集机制

每一个 store 都是一个独立的响应式作用域(Reactive Scope)。

Pinia 会将它注册到全局的 store 容器中(类似一个 Map 结构),并在组件使用时完成依赖收集。

组件一旦引用某个 store 的状态,Pinia 就会追踪它的依赖关系。

当 store 内的状态改变时,Pinia 会自动触发依赖更新 —— 这就是响应式传播的原理。


三、Pinia 的安装与配置

1️⃣ 安装

npm install pinia
# 或者
yarn add pinia

2️⃣ 创建与挂载

在 main.js 中引入 Pinia 并挂载到 Vue 应用:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

四、定义第一个 Store

Pinia 推荐使用 defineStore() 来定义一个 store。

// src/stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 1. state:存放共享数据
  state: () => ({
    count: 0,
    name: 'Pinia示例'
  }),

  // 2. getters:相当于计算属性
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // 3. actions:用于定义修改逻辑
  actions: {
    increment() {
      this.count++
    },
    setCount(val) {
      this.count = val
    }
  }
})

五、在组件中使用 Store

在组件中使用时,就像普通变量一样简单。

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>当前数量:{{ counter.count }}</p>
    <p>双倍数量:{{ counter.doubleCount }}</p>
    <button @click="counter.increment">增加</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

💡 响应式自动生效:

当 counter.count 改变时,界面会自动更新,无需手动刷新。


六、解构与响应式陷阱

Pinia store 是响应式对象,如果你用结构赋值要注意保持响应性。

错误写法 ❌:

const { count } = useCounterStore()
console.log(count) // 不会响应更新

正确写法 ✅:

import { storeToRefs } from 'pinia'

const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)

storeToRefs() 会帮你保留响应式引用。


七、模块化管理多个 Store

Pinia 天然支持多 store,无需命名空间:

// user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    token: ''
  }),
  actions: {
    login(name) {
      this.name = name
      this.token = 'token123'
    }
  }
})

在组件中可以自由组合使用:

import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'

const user = useUserStore()
const counter = useCounterStore()

八、持久化存储(localStorage)

Pinia 本身不带持久化功能,但可以通过插件轻松实现:

✅ 手动持久化:

watch(
  () => store.count,
  (newVal) => {
    localStorage.setItem('count', newVal)
  }
)

✅ 使用插件(推荐):

安装:

npm i pinia-plugin-persistedstate

注册插件:

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

开启持久化:

export const useUserStore = defineStore('user', {
  state: () => ({ name: '', token: '' }),
  persist: true
})

九、Pinia + TypeScript 实践(简要示例)

Pinia 的类型推导非常强大。

在 TS 项目中可直接推断出 state 和 getter 的类型。

export const useTodoStore = defineStore('todo', {
  state: () => ({
    list: [] as string[]
  }),
  actions: {
    addTodo(item: string) {
      this.list.push(item)
    }
  }
})

// 自动推导类型
const store = useTodoStore()
store.addTodo('学习 Pinia')

十、Pinia 内部运行机制简述

Pinia 内部核心模块包括:

  1. Store 实例注册

    每个 defineStore() 都注册到全局 pinia._s 容器中。

  2. Reactive 封装

    使用 reactive() 包装 state,配合 Vue 的 effect 机制实现依赖收集。

  3. Getter 包装为 computed

    保证 getter 的懒计算与缓存特性。

  4. Action 包装代理

    使用 Proxy 代理 this 指向当前 store,并自动注入 devtools 日志。

  5. 订阅机制

    Pinia 提供 store.$subscribe(),可以监听 state 变化。


十一、实战示例:Todo 应用

<template>
  <div>
    <h2>我的待办事项</h2>
    <input v-model="newTask" @keyup.enter="addTodo" placeholder="输入任务..."/>
    <ul>
      <li v-for="(item, index) in todos.list" :key="index">
        {{ item }}
        <button @click="removeTodo(index)">删除</button>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'

const todos = useTodoStore()
const newTask = ref('')

const addTodo = () => {
  if (newTask.value.trim()) {
    todos.addTodo(newTask.value)
    newTask.value = ''
  }
}
const removeTodo = (i) => todos.list.splice(i, 1)
</script>

十二、总结

内容 关键点
状态管理核心 Vue3 的 reactive 与 computed
改进点 无需 mutation、天然模块化
类型支持 友好且强大
响应式机制 Proxy + Effect 依赖追踪
持久化 插件 pinia-plugin-persistedstate
适用场景 中大型 Vue3 项目,全局状态同步

Pinia 的设计哲学是:

“简单到你几乎忘了自己在用状态管理库。”

Vue3 状态管理完全指南:从响应式 API 到 Pinia

作者 90后晨仔
2025年10月18日 22:39

什么是状态管理?

在 Vue 开发中,状态管理是一个核心概念。简单来说,状态就是驱动应用的数据源。每一个 Vue 组件实例都在管理自己的响应式状态,让我们从一个简单的计数器组件开始理解:

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

// 状态 - 驱动应用的数据源
const count = ref(0)

// 动作 - 修改状态的方法
function increment() {
  count.value++
}
</script>

<!-- 视图 - 状态的声明式映射 -->
<template>{{ count }}</template>

这个简单的例子展示了状态管理的三个核心要素:

  • 状态:数据源 (count)
  • 视图:状态的声明式映射 (模板)
  • 动作:状态变更的逻辑 (increment)

这就是所谓的"单向数据流"概念。

为什么需要状态管理?

当应用变得复杂时,我们会遇到两个典型问题:

问题 1:多个组件共享状态

<!-- ComponentA.vue -->
<template>组件 A: {{ count }}</template>

<!-- ComponentB.vue -->  
<template>组件 B: {{ count }}</template>

如果多个视图依赖于同一份状态,传统的解决方案是通过 props 逐级传递,但这在深层次组件树中会变得非常繁琐,导致 Prop 逐级透传问题

问题 2:多组件修改同一状态

来自不同视图的交互都需要更改同一份状态时,直接通过事件或模板引用会导致代码难以维护。

解决方案:将共享状态抽取到全局单例中管理。

使用响应式 API 实现简单状态管理

Vue 的响应式系统本身就提供了状态管理的能力。

创建全局状态 Store

// store.js
import { reactive } from 'vue'

export const store = reactive({
  count: 0,
  user: null,
  todos: []
})

在组件中使用

<!-- ComponentA.vue -->
<script setup>
import { store } from './store.js'
</script>

<template>
  <div>From A: {{ store.count }}</div>
  <button @click="store.count++">+1</button>
</template>
<!-- ComponentB.vue -->
<script setup>
import { store } from './store.js'
</script>

<template>
  <div>From B: {{ store.count }}</div>
  <button @click="store.count++">+1</button>
</template>

问题:任意修改的风险

上面的实现有个问题:任何导入 store 的组件都可以随意修改状态,这在大型应用中难以维护。

改进:封装状态修改逻辑

// store.js
import { reactive } from 'vue'

export const store = reactive({
  // 状态
  count: 0,
  user: null,
  todos: [],
  
  // 动作 - 封装状态修改逻辑
  increment() {
    this.count++
  },
  
  setUser(user) {
    this.user = user
  },
  
  addTodo(todo) {
    this.todos.push(todo)
  },
  
  removeTodo(id) {
    this.todos = this.todos.filter(todo => todo.id !== id)
  }
})
<!-- ComponentB.vue -->
<script setup>
import { store } from './store.js'
</script>

<template>
  <button @click="store.increment()">
    From B: {{ store.count }}
  </button>
</template>

注意:这里使用 store.increment() 带圆括号调用,因为它不是组件方法,需要正确的 this 上下文。

使用组合式函数管理状态

// useCounter.js
import { ref } from 'vue'

// 全局状态
const globalCount = ref(1)

export function useCount() {
  // 局部状态
  const localCount = ref(1)
  
  function incrementGlobal() {
    globalCount.value++
  }
  
  function incrementLocal() {
    localCount.value++
  }
  
  return {
    globalCount: readonly(globalCount), // 使用 readonly 保护全局状态
    localCount,
    incrementGlobal,
    incrementLocal
  }
}

Pinia:现代化的状态管理库

虽然手动状态管理在简单场景中足够,但生产级应用需要更多功能:

  • 团队协作约定
  • Vue DevTools 集成
  • 模块热更新
  • 服务端渲染支持
  • 完善的 TypeScript 支持

Pinia 是 Vue 官方推荐的状态管理库,它解决了上述所有问题。

为什么选择 Pinia?

  • 类型安全:完美的 TypeScript 支持
  • DevTools 支持:时间旅行调试等
  • 模块热更新:开发时保持状态
  • 简洁的 API:学习成本低
  • 组合式 API:与 Vue 3 完美契合

安装和配置

npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)

app.use(pinia)
app.mount('#app')

创建 Store

选项式 Store

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 状态
  state: () => ({
    count: 0,
    user: null
  }),
  
  // 计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    isAuthenticated: (state) => state.user !== null
  },
  
  // 动作
  actions: {
    increment() {
      this.count++
    },
    async login(credentials) {
      const user = await api.login(credentials)
      this.user = user
    },
    logout() {
      this.user = null
    }
  }
})

组合式 Store(推荐)

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 状态
  const count = ref(0)
  const user = ref(null)
  
  // 计算属性
  const doubleCount = computed(() => count.value * 2)
  const isAuthenticated = computed(() => user.value !== null)
  
  // 动作
  function increment() {
    count.value++
  }
  
  async function login(credentials) {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    user.value = await response.json()
  }
  
  function logout() {
    user.value = null
  }
  
  return {
    count,
    user,
    doubleCount,
    isAuthenticated,
    increment,
    login,
    logout
  }
})

在组件中使用 Store

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const counterStore = useCounterStore()

// 使用 storeToRefs 保持响应式并解构
const { count, doubleCount, isAuthenticated } = storeToRefs(counterStore)
const { increment, login } = counterStore

// 直接修改状态(不推荐)
const directIncrement = () => {
  counterStore.count++
}

// 使用 action(推荐)
const actionIncrement = () => {
  counterStore.increment()
}

// 批量修改
const patchUpdate = () => {
  counterStore.$patch({
    count: counterStore.count + 1,
    user: { name: 'Updated User' }
  })
}

// 重置状态
const resetStore = () => {
  counterStore.$reset()
}

// 订阅状态变化
counterStore.$subscribe((mutation, state) => {
  console.log('状态变化:', mutation)
  console.log('新状态:', state)
})
</script>

<template>
  <div>
    <p>计数: {{ count }}</p>
    <p>双倍计数: {{ doubleCount }}</p>
    <p>认证状态: {{ isAuthenticated ? '已登录' : '未登录' }}</p>
    
    <button @click="increment">增加</button>
    <button @click="directIncrement">直接增加</button>
    <button @click="patchUpdate">批量更新</button>
    <button @click="resetStore">重置</button>
  </div>
</template>

在 Store 之间使用其他 Store

// stores/auth.js
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref('')
  
  function setAuth(userData, authToken) {
    user.value = userData
    token.value = authToken
  }
  
  return { user, token, setAuth }
})

// stores/todos.js  
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useTodosStore = defineStore('todos', () => {
  const authStore = useAuthStore()
  const todos = ref([])
  
  async function fetchTodos() {
    // 使用其他 store 的状态
    if (!authStore.token) {
      throw new Error('未认证')
    }
    
    const response = await fetch('/api/todos', {
      headers: {
        Authorization: `Bearer ${authStore.token}`
      }
    })
    todos.value = await response.json()
  }
  
  return { todos, fetchTodos }
})

高级模式和最佳实践

1. 数据持久化

// plugins/persistence.js
import { watch } from 'vue'

export function persistStore(store, key = store.$id) {
  // 从 localStorage 恢复状态
  const persisted = localStorage.getItem(key)
  if (persisted) {
    store.$patch(JSON.parse(persisted))
  }
  
  // 监听状态变化并保存
  watch(
    () => store.$state,
    (state) => {
      localStorage.setItem(key, JSON.stringify(state))
    },
    { deep: true }
  )
}

// 在 store 中使用
export const usePersistedStore = defineStore('persisted', () => {
  const state = ref({})
  
  // 在 store 创建后调用
  onMounted(() => {
    persistStore(usePersistedStore())
  })
  
  return { state }
})

2. API 集成模式

// stores/posts.js
import { defineStore } from 'pinia'

export const usePostsStore = defineStore('posts', () => {
  const posts = ref([])
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchPosts() {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/posts')
      if (!response.ok) throw new Error('获取失败')
      posts.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  async function createPost(postData) {
    const response = await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(postData)
    })
    const newPost = await response.json()
    posts.value.push(newPost)
    return newPost
  }
  
  return {
    posts,
    loading,
    error,
    fetchPosts,
    createPost
  }
})

3. 类型安全的 Store(TypeScript)

// stores/types.ts
export interface User {
  id: number
  name: string
  email: string
}

export interface AuthState {
  user: User | null
  token: string
}

// stores/auth.ts
import { defineStore } from 'pinia'
import type { User, AuthState } from './types'

export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    user: null,
    token: ''
  }),
  
  getters: {
    isAuthenticated: (state): boolean => state.user !== null,
    userName: (state): string => state.user?.name || ''
  },
  
  actions: {
    setAuth(user: User, token: string): void {
      this.user = user
      this.token = token
    },
    
    clearAuth(): void {
      this.user = null
      this.token = ''
    }
  }
})

4. 测试 Store

// stores/__tests__/counter.spec.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '../counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  test('increment', () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)
    
    store.increment()
    expect(store.count).toBe(1)
  })
  
  test('doubleCount getter', () => {
    const store = useCounterStore()
    store.count = 4
    expect(store.doubleCount).toBe(8)
  })
})

总结

什么时候使用哪种状态管理?

场景 推荐方案 理由
简单组件状态 组件内 ref/reactive 简单直接
少量组件共享 响应式全局对象 快速实现
中型应用 Pinia (组合式) 类型安全,易于测试
大型企业应用 Pinia + 严格模式 可维护性,团队协作

核心原则

  1. 单一数据源:全局状态集中管理
  2. 状态只读:通过 actions 修改状态
  3. 纯函数修改:相同的输入总是得到相同的输出
  4. 不可变更新:不直接修改原状态,而是创建新状态

Vue 内置组件全解析:提升开发效率的五大神器

作者 90后晨仔
2025年10月18日 22:21

在 Vue 开发中,除了我们日常编写的业务组件外,框架还提供了一系列内置组件,它们为我们处理常见的开发场景提供了优雅的解决方案。今天,我们就来深入探讨 Vue 的五大内置组件:TransitionTransitionGroupKeepAliveTeleportSuspense

1. Transition - 丝滑的过渡动画

什么是 Transition?

Transition 组件用于在元素或组件的插入、更新和移除时添加动画效果,让用户体验更加流畅。

基本使用

<template>
  <button @click="show = !show">切换</button>
  
  <Transition name="fade">
    <p v-if="show">Hello, Vue!</p>
  </Transition>
</template>

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

const show = ref(true)
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

过渡类名详解

Transition 组件会自动应用以下 6 个 CSS 类名:

  • v-enter-from:进入动画的起始状态
  • v-enter-active:进入动画的激活状态
  • v-enter-to:进入动画的结束状态
  • v-leave-from:离开动画的起始状态
  • v-leave-active:离开动画的激活状态
  • v-leave-to:离开动画的结束状态

注意:其中的 v 是默认前缀,可以通过 name 属性自定义。

JavaScript 钩子

除了 CSS 过渡,还可以使用 JavaScript 钩子:

<template>
  <Transition
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @after-enter="onAfterEnter"
    @enter-cancelled="onEnterCancelled"
    @before-leave="onBeforeLeave"
    @leave="onLeave"
    @after-leave="onAfterLeave"
    @leave-cancelled="onLeaveCancelled"
  >
    <div v-if="show">内容</div>
  </Transition>
</template>

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

const show = ref(true)

const onBeforeEnter = (el) => {
  // 元素插入 DOM 前的回调
}

const onEnter = (el, done) => {
  // 元素插入 DOM 后的回调
  // 需要手动调用 done() 来结束过渡
  done()
}
</script>

模式控制

<Transition mode="out-in">
  <button :key="isEditing" @click="isEditing = !isEditing">
    {{ isEditing ? '保存' : '编辑' }}
  </button>
</Transition>

支持的模式:

  • in-out:新元素先进入,当前元素后离开
  • out-in:当前元素先离开,新元素后进入

2. TransitionGroup - 列表过渡专家

什么是 TransitionGroup?

TransitionGroup 组件专门用于处理动态列表中元素的进入、离开和移动的动画效果。

基本使用

<template>
  <button @click="addItem">添加</button>
  <button @click="removeItem">移除</button>
  
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.text }}
    </li>
  </TransitionGroup>
</template>

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

const items = ref([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' }
])

let nextId = 4

const addItem = () => {
  items.value.push({ id: nextId++, text: `项目 ${nextId}` })
}

const removeItem = () => {
  items.value.pop()
}
</script>

<style scoped>
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}
.list-enter-from {
  opacity: 0;
  transform: translateX(30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}
/* 确保离开的元素脱离文档流 */
.list-leave-active {
  position: absolute;
}
</style>

关键特性

  1. 必须设置 key:每个元素都需要唯一的 key
  2. 支持 CSS 变换:自动检测元素位置变化应用移动动画
  3. tag 属性:指定包裹容器的标签,默认为不渲染包裹元素

3. KeepAlive - 组件缓存大师

什么是 KeepAlive?

KeepAlive 组件用于缓存不活动的组件实例,避免重复渲染,保持组件状态。

基本使用

<template>
  <div>
    <button @click="currentTab = 'Home'">首页</button>
    <button @click="currentTab = 'About'">关于</button>
    
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'
import Home from './Home.vue'
import About from './About.vue'

const currentTab = ref('Home')
const tabs = {
  Home,
  About
}
</script>

高级配置

<KeepAlive 
  :include="/Home|About/" 
  :exclude="['Settings']" 
  :max="10"
>
  <component :is="currentComponent" />
</KeepAlive>
  • include:只有名称匹配的组件会被缓存(字符串、正则或数组)
  • exclude:任何名称匹配的组件都不会被缓存
  • max:最多可缓存的组件实例数量

生命周期钩子

被缓存的组件会获得两个新的生命周期钩子:

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // 组件被激活时调用
  console.log('组件激活')
})

onDeactivated(() => {
  // 组件被停用时调用
  console.log('组件停用')
})
</script>

4. Teleport - 任意门组件

什么是 Teleport?

Teleport 组件允许我们将组件模板的一部分"传送"到 DOM 中的其他位置,而不影响组件的逻辑关系。

基本使用

<template>
  <div class="app">
    <button @click="showModal = true">打开模态框</button>
    
    <Teleport to="body">
      <div v-if="showModal" class="modal">
        <div class="modal-content">
          <h2>模态框标题</h2>
          <p>这是模态框内容</p>
          <button @click="showModal = false">关闭</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

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

const showModal = ref(false)
</script>

<style scoped>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
}
</style>

多个 Teleport 到同一目标

<template>
  <Teleport to="#modals">
    <div>第一个模态框</div>
  </Teleport>
  
  <Teleport to="#modals">
    <div>第二个模态框</div>
  </Teleport>
</template>

渲染结果:

<div id="modals">
  <div>第一个模态框</div>
  <div>第二个模态框</div>
</div>

禁用 Teleport

<Teleport to="body" :disabled="isMobile">
  <div>内容</div>
</Teleport>

5. Suspense - 异步组件管家

什么是 Suspense?

Suspense 组件用于协调组件树中的异步依赖,在等待异步组件时显示加载状态。

基本使用

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    
    <template #fallback>
      <div class="loading">加载中...</div>
    </template>
  </Suspense>
</template>

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

const AsyncComponent = defineAsyncComponent(() => 
  import('./AsyncComponent.vue')
)
</script>

<style scoped>
.loading {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

异步 setup 组件

<template>
  <Suspense>
    <template #default>
      <ComponentWithAsyncSetup />
    </template>
    
    <template #fallback>
      <div>加载用户数据...</div>
    </template>
  </Suspense>
</template>

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

const ComponentWithAsyncSetup = {
  async setup() {
    const userData = await fetchUserData()
    return { userData }
  },
  template: `<div>用户: {{ userData.name }}</div>`
}

async function fetchUserData() {
  // 模拟 API 调用
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ name: 'Vue 开发者' })
    }, 2000)
  })
}
</script>

事件处理

<template>
  <Suspense @pending="onPending" @resolve="onResolve" @fallback="onFallback">
    <AsyncComponent />
  </Suspense>
</template>

<script setup>
const onPending = () => {
  console.log('开始加载异步组件')
}

const onResolve = () => {
  console.log('异步组件加载完成')
}

const onFallback = () => {
  console.log('显示加载状态')
}
</script>

实战案例:组合使用内置组件

让我们看一个综合使用多个内置组件的例子:

<template>
  <div class="app">
    <!-- 标签页切换 -->
    <nav>
      <button 
        v-for="tab in tabs" 
        :key="tab.id"
        @click="currentTab = tab.id"
      >
        {{ tab.name }}
      </button>
    </nav>
    
    <!-- 主要内容区域 -->
    <main>
      <KeepAlive>
        <Transition name="slide" mode="out-in">
          <Suspense>
            <template #default>
              <component :is="currentTabComponent" />
            </template>
            <template #fallback>
              <div class="loading">加载中...</div>
            </template>
          </Suspense>
        </Transition>
      </KeepAlive>
    </main>
    
    <!-- 全局通知 -->
    <Teleport to="#notifications">
      <TransitionGroup name="notification">
        <div 
          v-for="notification in notifications" 
          :key="notification.id"
          class="notification"
        >
          {{ notification.message }}
        </div>
      </TransitionGroup>
    </Teleport>
  </div>
</template>

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

// 标签页状态
const currentTab = ref('home')
const tabs = [
  { id: 'home', name: '首页' },
  { id: 'profile', name: '个人资料' },
  { id: 'settings', name: '设置' }
]

// 异步组件
const currentTabComponent = computed(() => 
  defineAsyncComponent(() => import(`./${currentTab.value}.vue`))
)

// 通知系统
const notifications = ref([])

// 添加通知
const addNotification = (message) => {
  const id = Date.now()
  notifications.value.push({ id, message })
  setTimeout(() => {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }, 3000)
}
</script>

<style scoped>
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
}

.slide-enter-from {
  opacity: 0;
  transform: translateX(50px);
}

.slide-leave-to {
  opacity: 0;
  transform: translateX(-50px);
}

.notification-enter-active,
.notification-leave-active {
  transition: all 0.3s ease;
}

.notification-enter-from {
  opacity: 0;
  transform: translateY(-30px);
}

.notification-leave-to {
  opacity: 0;
  transform: translateX(100%);
}

.loading {
  text-align: center;
  padding: 50px;
  font-size: 18px;
}
</style>

总结

Vue 的内置组件为我们提供了强大的工具来处理常见的开发需求:

  • Transition:为单个元素/组件添加过渡动画
  • TransitionGroup:为列表项添加排序和动画效果
  • KeepAlive:缓存组件状态,提升性能
  • Teleport:将内容渲染到 DOM 任意位置
  • Suspense:优雅处理异步组件加载状态

这些内置组件不仅功能强大,而且可以灵活组合使用,帮助我们构建更加优雅、高效的 Vue 应用。掌握它们的使用,将让你的 Vue 开发技能更上一层楼!

希望这篇文章能帮助你全面理解 Vue 的内置组件,并在实际项目中灵活运用。Happy Coding! 🚀

昨天 — 2025年10月18日首页

大前端时代来临,年轻人应该知道的——业内主流跨端框架对比

作者 stayong
2025年10月18日 17:39

随着跨端框架生态的逐渐完善,市面上诸多 TO C 应用采用多端一码可以极大降低开发成本 提升产品迭代效率。 本文对市面主流跨端开发框架对比和分析。 希望对你有帮助, 欢迎一键三连~

一、详细对比表格

框架 React Native Flutter Electron Qt Taro
核心特点 - 使用 JavaScript 和 React 进行开发
- 可以调用原生 API
- 支持热重载
- 使用 Dart 语言
- 高性能的 UI 渲染引擎
- 支持 iOS、Android、Web、桌面等多平台
- 使用 Web 技术(HTML、CSS、JavaScript)
- 打包为跨平台桌面应用
- 使用 C++ 开发跨平台应用
- 提供原生 UI 和图形库
- 支持嵌入式开发
- 基于 React 和 Vue
- 支持多个平台(小程序、H5、原生)
- 用 TypeScript/JavaScript 开发
主要语言 JavaScript, TypeScript Dart JavaScript, HTML, CSS C++, Python, QML JavaScript, TypeScript, React/Vue
支持平台 iOS, Android, Windows, macOS, Web iOS, Android, Web, Windows, macOS, Linux Windows, macOS, Linux Windows, macOS, Linux, 嵌入式 iOS, Android, 微信小程序, H5, 支付宝小程序等
优势 - 大量社区支持和库
- 可直接访问原生代码
- 热重载提高开发效率
- 对比其他框架,较成熟且稳定
- 强大的 UI 渲染能力,支持高性能应用
- 一次开发多平台运行
- 丰富的组件和插件生态
- 适合桌面端应用开发
- 跨平台支持,包括 Windows、macOS 和 Linux
- 可以与原生模块交互
- 性能优秀,适合大型应用和嵌入式设备
- 支持丰富的 UI 和图形渲染功能
- 跨平台开发,适合企业级应用
- 适合多平台开发,特别是小程序
- 支持 React 和 Vue,开发灵活
- 生成小程序和 H5 能力强大
不足 - 性能略逊于原生应用,复杂场景下需优化
- 原生模块集成较为复杂
- 对比其他框架,初期配置略复杂
- 需要学习 Dart 语言
- 对于复杂原生功能的支持较弱
- 对已有项目的接入可能存在困难
- 性能较低,体积较大
- 相比原生桌面应用,启动速度较慢
- 需要较高的资源消耗
- 学习曲线较陡,尤其是 C++ 部分
- 文档较少,社区相对较小
- 开发效率较低,相比其他框架更复杂
- 适合中小型应用,性能和UI相对简单
- 对于复杂的多端需求,可能需要大量配置
- 相较于其他框架,支持的原生功能较少
开发效率 高,热重载和组件化带来较高的开发效率 高,尤其适合UI复杂的应用,热重载功能也提升开发效率 中,桌面应用的开发相对较复杂 低,开发涉及底层代码,需要较高的技术要求 高,支持小程序开发,适合快速开发与迭代
性能 较好,但在性能敏感的场景下需要优化 优秀,接近原生性能,UI 渲染能力强 较差,性能开销较大,适合桌面应用但不适合性能要求高的应用 非常好,原生性能,特别适合大规模企业级应用 中,适合小程序和H5,性能不如原生开发
应用广泛度 高,广泛应用于电商、社交、金融等多种行业 较高,尤其在新的项目和需要高性能UI的应用中受到青睐 中,主要用于桌面端应用,适用于一些企业工具类应用 中,主要用于嵌入式设备和企业级应用 高,特别是在国内市场,主要用于小程序开发
适用场景 - 社交、购物、电商、新闻、娱乐类应用
- 需要快速开发、跨平台的应用
- 高度定制化的应用,尤其是 UI 复杂的移动应用和桌面应用 - 需要跨平台桌面端应用,适用于各类办公软件、开发工具等 - 企业级应用
- 嵌入式系统开发
- 高性能应用
- 多平台(小程序+Web)的跨端应用,特别适合中国市场的需求
生态支持 强大的社区支持,丰富的第三方库和插件 迅速发展的生态,Google 和 Flutter 社区的支持 较小,主要由 Electron 官方和一些社区维护 适合专业开发者,企业级应用较多 国内市场的支持较强,针对小程序有深度优化

二、总结:

React Native:开发效率高,生态支持丰富,但性能略逊于原生应用。

Flutter:性能优秀,UI 渲染能力强,适用于需要复杂 UI 的跨平台应用,学习曲线稍陡,需要掌握 Dart。

Electron:主要用于桌面端应用,适合工具类和办公软件,但性能和体积较大,不适用于对性能要求较高的应用。

Qt:适合企业级桌面应用和嵌入式开发,性能强大,支持多平台,但学习曲线较陡,开发效率较低。

Taro:特别适用于小程序开发和 H5 应用,支持多个平台,开发效率高,适合国内市场。

JavaScript语法进阶(一)

2025年10月18日 17:31

原始值和引用值

栈跟堆的特点

    • 先进后出
    • 内存分配连续且自动管理
    • 访问速度快
    • 空间比较小
    • 空间大
    • 内存不是连续的,管理依赖垃圾回收机制
    • 访问速度相对于栈比较慢

原始值类型存在7种

  1. Number(数字):表示数值,包括整数和浮点数。
  2. String(字符串):表示文本数据,使用引号括起来。
  3. Boolean(布尔值):表示逻辑值,即true或者false.
  4. Null(空):表示一个空值或没有值的对象。
  5. Undefined(未定义):表示一个未被赋值的变量的值
  6. Symbol(唯一值符号):表示唯一的标识符。
  7. bigint:表示任意精度的整数,用 n作为字面量后缀
// number
const a = 1
// string
const b = 'zifuch'

引用值类型

  • 对象,对象包括为数组对象,日期对象,函数对象,等等等,一切皆对象
    const obj = {
        a:1,
        b:2
    }
    
    const arr = ['1','2','3']
    

两者存值的方式

  • 原始值存储在栈中

    let a = 1;
    let b = 2;
    

  • 引用值存储在推中

    const a = {
        b:1,
        c:2
    }
    

复制值

  • 原始值复制

    let a = 1;
    let b = a;
    
    b = 2;
    console.log("a的值是:",a);
    console.log("b的值是:",b);
    

  • 引用值复制

    const a = {
        b:1,
        c:2,
    }
    
    const copyA = a;
    
    copy.c = 5;
    console.log("a.c的值是:",a.c);
    

  • 引用值复制2

    const a = {
        b:1,
        c:2,
    }
    
    const copyA = a;
    copyA = {
        b: 3,
        c: 4,
    }
    copy.c = 5;
    console.log("a.c的值是:",a.c);
    

浅拷贝和深拷贝

  • 浅拷贝

    浅拷贝是只复制对象的第一层属性,如果属性是引用类型(对象、数组等),拷贝的是引用地址,而不是新对象。

    • object.assign()
    • 扩展运行符...
    const obj1 = { a: 1, b: { c: 2 } };
    const obj2 = Object.assign({}, obj1);
    
    obj2.a = 100;           // 不影响 obj1
    obj2.b.c = 200;         // 影响 obj1,因为 b 是引用
    console.log(obj1.b.c);  // 200(共享 b 对象)
    

  • 深拷贝

    深拷贝会递归复制对象的所有层级属性,确保拷贝后的对象与原对象完全独立。

    • JSON.parse(JSON.stringify(...))
    • structuredClone()浏览器内置API
    • Lodash工具库

垃圾回收机制

JS的垃圾回收机制(Garbage Collection,简称GC)帮我们自动管理内存,对于不再使用的对象占用的内存进行释放。

  • Garbage : 指不再被引用、不可达的内存对象。
  • Collection: 指 JS 引擎自动检测并释放这些无用内存的过程。 常见的垃圾回收机制有两种,引用计数标记清除

引用计数

每个对象都有一个计数器,记录当前有多少个引用指向它。
规则

  • 声明变量并给它赋一个引用值的时候,值的引用数+1
  • 类似的把同一个值又被赋值给另外一个变量,值的引用数也+1
  • 保存对该引用的变量被其它值给覆盖的时候,值的引用数-1
  • 当引用解除(比如赋值为null)时,值的引用数-1
let obj = { name: "JS" }; // 引用计数 +1
let a = obj; // 引用计数 +1
obj = null; // 引用计数 -1 (现在还剩 a 引用)
b = null; // 引用计数 -1 (没有引用了,计数为 0 → 可回收)

缺点
当出现循环引用的情况出现的时候,引用计数永远不会清0。

function problem() {  
    let objectA = new Object();   // A对象引用计数 +1
    let objectB = new Object();  // B对象引用计数 +1
    objectA.someOtherObject = objectB;  // B对象引用计数 +1 
    objectB.anotherObject = objectA;   // A对象引用计数 +1
}
problem();

那有人说,我都置为null不就可以了,比如像下这样

function problem() {  
    let objectA = new Object();   // A对象引用计数 +1
    let objectB = new Object();  // B对象引用计数 +1
    objectA.someOtherObject = objectB;  // B对象引用计数 +1 
    objectB.anotherObject = objectA;   // A对象引用计数 +1
    
    objectA.someOtherObject = null;
    objectB.anotherObject = null; 
    let objectA = null; 
    let objectB = null; 
}
problem();

但是现实开发中的循环引用会复杂很多,开发者很难自己追踪到里面的关系,容易造成内存泄漏。so,它被淘汰了。

标记清除

标记清除是JavaScript中最常用的垃圾回收机制,特别是在V8引擎之中,而其核心思想是可达性(Reachability)
规则
标记阶段: 垃圾回收器从根对象(通常是全局对象、活动执行栈和闭包等)出发,遍历所有可访问的对象,并标记为活动对象。在这个阶段,垃圾回收器会识别出所有被引用的对象,将其标记为“存活”。

清除阶段: 在标记阶段完成后,垃圾回收器会对堆内存进行扫描,清除所有未标记的对象,这些对象被认为是“垃圾”,因为它们不再被任何活动对象引用。清除阶段会释放这些垃圾对象所占用的内存空间,使其可用于未来的对象分配。

image.png

作用域

概念 : js代码在查找变量时的一个范围。

词法作用域

作用域在代码编写时就已经确定下来,并且由函数声明的位置决定,而不是由函数调用的位置决定。

var name = "global";

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

function bar() {
    var name = "bar";
    foo(); 
}

bar();  // 输出global

JS的三个作用域

  • 全局作用域: 对浏览器来说全局的作用域的话就是挂载window上的变量,或者是顶层用var声明的变量。使用 let 和 const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。
  • 函数作用域: 在函数执行的时候,函数内部的变量具有函数作用域。
  • 块级作用域: 嵌套在{}括号里面的变量(var声明的变量不具备块级作用域)

执行上下文和执行上下文栈

执行上下文(上下文):变量或函数的上下文决定他们能访问到哪些数据。每个上下文都有一个关联的词法环境(Lexical Environment)\变量对象(variable object)。
执行上下文栈:当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。 在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。

let a = 1
function f1() {
    const b = 2;
    function f2 () {
        const c = 3;
    }
    f2();
}
f1();

image.png

作用域链

上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain),指向上一个作用域。这个作用域链决定了当前上下文中的代码在访问变量和函数时的顺序

let a = 1
function f1() {
    const b = 2;
    function f2 () {
        const c = 3;
    }
    f2();
    }
f1();

image.png 例子

var a = 10;
var b = 10;
var c = 10;

function foo() {
  var b = 20;

  function bar() {
    var c = 30;
    console.log(a, b, c,d);
  }

  bar();
}

foo();

image.png注意点

  1. 函数的参数属于当前函数上下文。
  2. 如果内部作用域和外部作用域使用相同的变量名,则直接使用离当前作用域近的变量名,有书籍称为遮蔽。

分享文章链接

闭包

概念:闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套中实现的。(即函数内部嵌套函数,内部函数使用了外部函数作用域变量)

function outer() {
    let count = 0; // outer 的局部变量

    function inner() {
        count++;
        console.log(count);
    }

    return inner;
}

const fn = outer(); // outer 执行结束,但 count 没被销毁
fn(); // 1
fn(); // 2
fn(); // 3

为了帮助理解闭包,举了以下三个情况,希望对你有帮助。

  • 情况1
    function outer() {
        let count = 0; // outer 的局部变量
    
        function inner() {
            count++;
            console.log(count);
        }
    
        return inner;
    }
    // 定义了函数 函数没有执行 可以理解为闭包的结构体有了 但是闭包不存在
    
  • 情况2
    function outer() {
        let count = 0; // outer 的局部变量
    
        function inner() {
            count++;
            console.log(count);
        }
    
        return inner;
    }
    outer();
    // 闭包产生了 但是闭包不会持久存在,因为没有保留对它的引用
    
  • 情况3
    function outer() {
         let count = 0; // outer 的局部变量
    
         function inner() {
             count++;
             console.log(count);
         }
    
         return inner;
     }
     fn = outer();
     // 闭包产生了 fn保留了对它的引用 持久存在
    

this

概念:this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值。

默认绑定

全局上下文中调用函数的this指向window

window.color = 'red'
function fn() {
    consoel.log("color:",this.color)
}
fn(); // color: 'red'

隐式绑定

函数作为对象的方法调用,this 绑定到该对象

let obj = {
    color: 'blue'
    fn: fn;
}

function fn () {
    console.log("color:",this.color);
}

obj.fn(); // color: 'blue'

显示绑定

使用call、apply、bind显示指定this

function fn() {
    console.log("color:",this.color);
}
const obj = { obj: 'yellow' };
greet.call(obj); // color: 'yellow'

new关键字绑定

构造函数被new调用时,this指向新创建的对象

function Color (color) {
    this.color = color;
}
const c = new Color("yellow");
console.log(c.color); // 'yellow'

注意

  • 箭头函数不绑定自己的this,它会捕获定义是的外层作用域中的this
window.color = 'red';

let obj = {
    color: 'green';
    fn: () => {
    console.log('color:',this.color);
    }
}

obj.fn(); // color: 'red'
  • 绑定的优先级
    new绑定>显示绑定>隐式绑定>默认绑定
// 以下是显示绑定和隐式绑定对比的列子
function foo() {
    console.log( this.a );
}

var obj1 = {
    a: 2,
    foo: foo
};

var obj2 = {
    a: 3,
    foo: foo
};

obj1.foo(); // 2
obj2.foo(); // 3

obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

原型和原型链

JS是一门面向对象语言,那么面向对象语言的特点是封装、继承、多态。在C++或者Java里面都是由类去实现,但JS没有类的说法,在JS里面是由原型实现的,ES6才拥有了类,但本质上是原型的语法糖。

构造函数:构造函数是用于创建特定类型对象/实例的一种方法。默认首字母大写
new关键字:用于创建构造函数的对象。

在c++里面我们创建同一类型的对象一般是这么写

#include <iostream>
using namespace std;

class Animal {
public:
    // 成员变量
    string name;
    int age;

    // 构造函数
    Animal(string n, int a) {
        name = n;
        age = a;
    }

    // 成员方法:打印动物信息
    void printInfo() {
        cout << "Animal Name: " << name << ", Age: " << age << endl;
    }
};

int main() {
    // 创建对象
    Animal dog("Buddy", 3);
    Animal cat("Kitty", 2);

    // 调用方法
    dog.printInfo(); // Animal Name: Buddy, Age: 3
    cat.printInfo(); // Animal Name: Kitty, Age: 2

    return 0;
}

在js我们不使用原型的话大概是这么写

// 构造函数
function Animal(name, age) {
  this.name = name;
  this.age = age;

  // 方法直接在构造函数中定义
  this.printInfo = function() {
    console.log(`Animal Name: ${this.name}, Age: ${this.age}`);
  };
}

// 创建对象
const dog = new Animal("Buddy", 3);
const cat = new Animal("Kitty", 2);

// 调用方法
dog.printInfo(); // Animal Name: Buddy, Age: 3
cat.printInfo(); // Animal Name: Kitty, Age: 2

这么写的缺点是:每new一次,就会在内存里重新创建一个新的函数对象,虽然它两长的一模一样,但他们指向的函数不一样,会导致内存浪费。

dog.prinrInfo === cat.printInfo  // false

// 插播: == 不比较类型 === 需要比较类型
// 1 == '1' √  1 === '1' ×

使用原型进行改善

// 构造函数
function Animal(name, age) {
  this.name = name;
  this.age = age;
}

Animal.prototype.pritInfo = function {
    console.log(`Animal Name: ${this.name}, Age: ${this.age}`);
}

// 创建对象
const dog = new Animal("Buddy", 3);
const cat = new Animal("Kitty", 2);

// 调用方法
dog.printInfo(); // Animal Name: Buddy, Age: 3
cat.printInfo(); // Animal Name: Kitty, Age: 2

// dog.printInfo === cat.printInfo;

接下来,进入正文...

显示原型

每一个函数身上都会创建一个prototype属性,函数原型是一个对象。在它上面定义的属性和方法可以被对象实例共享。

function Animal () {
//.....
}

Animal.prototype.name = '猫'
const animal = new Animal();
console.log(animal.name); // '猫'

image.png

constructor

原型对象身上存在一个默认的constructor属性,指回与之关联的构造函数。

Animal.prototype.constructor === Animal

image.png

隐式原型

每次调用构造函数创建一个新实例,这个实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象。
在现代化浏览器如Firefox、Safari 和 Chrome 会在每个对象上暴露 "proto" 属性,通过这个属性可以访问对象的原型。

function Animal () {
//.....
}

let animal = new Animal();

animal.__proto__ === Animal.prototype;

image.png

原型链

构造函数的原型是一个对象,那么会存在这么一个关系

Animal.prototype.__proto__ === Object.prototype

image.png

那么Object原型的原型是什么?为null。正常的原型链都会终止于 Object 的原型对象。

Animal.prototype.__proto__.__proto__ === null

image.png

那么构造函数也是一个对象,由Function构造函数创建,Function构造函数由也是由本身创建。

image.png

总结
当我们通过对象访问它的属性的时候,会按照这个属性的名称开始搜索。先搜索对象实例本身,如果对象实例本身没有找到就会沿着隐式原型__proto__上的原型对象上查找,直至找到原型链的终端null为止。
为什么是隐式原型链?实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有

大白话讲 React 原理:Scheduler 任务调度器

作者 庞囧
2025年10月18日 17:23

前言

这篇文章是我自己理解 React Scheduler 的思路整理,再用 AI 做了润色。全文尽量用大白话讲清楚 Scheduler 是啥,但肯定有不严谨甚至错误的地方,欢迎指正!

最小堆

什么是最小堆

要了解 Scheduler 之前肯定要先知道什么是最小堆。

具体点说:

  • 最小堆通常用完全二叉树来表示(你可以理解成一层一层从左到右排满的树)。
  • 堆顶(也就是最上面那个数)一定是所有数里最小的。
  • 但它不是完全排好序的!比如第二小的数不一定在第二层左边,它可能在右边,也可能在更深的地方。只要满足“爸爸 ≤ 孩子”这个规则就行。

举个例子:

        1
      /   \
     2     3
    / \
   5   4

这个就是一个最小堆:

  • 1 是最小的,在最顶上;
  • 1 的两个孩子是 2 和 3,都 ≥ 1;
  • 2 的两个孩子是 5 和 4,也都 ≥ 2;
  • 所有“爸爸”都不比“孩子”大,符合规则!

那最小堆有啥用呢?

  • 快速拿到最小值:永远在堆顶,O(1) 时间就能拿到。
  • 动态维护最小值:比如你不断加新数字,或者删掉最小的,堆都能快速调整(O(log n) 时间)。
  • 常用于优先队列堆排序Dijkstra 算法等。

最小堆的数据维护

最小堆可以通过父子节点的索引计算公式,把整棵完全二叉树“扁平化”地存储在一个数组里,而不需要使用指针或树形结构。

🌰 举个例子

假设我们有最小堆(树形):

        1
      /   \
     2     3
    / \
   5   4

按照从上到下、从左到右一层层读下来,放进数组就是:

const heap = [1, 2, 3, 5, 4];

假设数组从 索引 0 开始(JavaScript 就是这样),那么:

某个节点在数组中的位置是 i

  • 它的 左孩子 就在 2 * i + 1
  • 它的 右孩子 就在 2 * i + 2
  • 它的 爸爸(父节点) 就在 Math.floor((i - 1) / 2)

现在我们来验证一下“父子关系”对不对:

  • heap[0] = 1(根)

    • 左孩子:2*0+1 = 1heap[1] = 2
    • 右孩子:2*0+2 = 2heap[2] = 3
  • heap[1] = 2

    • 左孩子:2*1+1 = 3heap[3] = 5
    • 右孩子:2*1+2 = 4heap[4] = 4
  • heap[3] = 5(叶子节点)

    • 左孩子索引是 2*3+1 = 7,但数组长度才 5,说明它没孩子了 ✅

再看爸爸是谁:

  • heap[4] = 4,它的爸爸索引是 Math.floor((4-1)/2) = Math.floor(3/2) = 1heap[1] = 2

完美对应!


❓为啥能这么干?

因为最小堆必须是“完全二叉树”——意思就是:

  • 除了最后一层,上面都填满;
  • 最后一层也必须从左往右紧挨着放,不能跳着空位。

这种“严丝合缝”的结构,正好可以按顺序塞进数组,不会浪费位置,也不会搞混谁是谁的孩子。


🛠 实际好处

  • 不用写复杂的树节点(比如 left/right 指针)
  • 内存连续,访问快
  • 插入/删除时,只要用索引算父子,往上“冒泡”或往下“下沉”调整就行

比如你往堆里加个新数,就先塞到数组末尾,然后不断跟“爸爸”比,如果比爸爸小,就交换,直到满足“爸爸 ≤ 孩子”——这个过程叫 上浮(heapify up)

删最小值(堆顶)时,把最后一个数挪到顶部,然后不断跟两个孩子中更小的那个比,如果比孩子大,就交换——这叫 下沉(heapify down)

全程只用数组和索引计算,超高效!

时间切片

为什么需要时间切片?(核心思想)

JavaScript 是单线程的,意味着在主线程上,一次只能做一件事。如果一个任务(比如一个复杂的计算、渲染一个上万条数据的列表)执行时间过长(比如超过 50ms),它就会“霸占”主线程。

后果:

  • 页面卡顿: 浏览器无法及时响应用户的点击、输入等交互。

  • 渲染延迟: 页面的动画、滚动等视觉效果会掉帧,看起来不流畅。

  • 阻塞其他任务: 其他重要的任务(如用户输入事件、定时器)只能排队等待,导致整体体验下降。

时间切片的解决方案:

时间切片的核心思想是 “合作式调度”。我们主动将一个大的、可能阻塞主线程的 Task,拆分成许多个小的 Work。然后,我们设定一个时间预算(即“时间片”),在每个时间片内执行一个或多个 Work。一旦时间片用完,即使任务还没完成,我们也主动中断,把主线程的控制权交还给浏览器,让它去处理更高优先级的工作(如渲染、用户交互)。等浏览器空闲下来,我们再从中断的地方继续执行

一句话总结:把大任务拆小,分批执行,超时就停,保证主线程随时可响应。

三个核心概念详解

1. Task (任务)

  • 定义: 一个完整的、待调度的工作单元。它是一个抽象的概念,代表了一项需要完成的工作。

  • 角色: 它是被调度的对象。调度器(Scheduler)管理着一个任务队列,决定哪个 Task 应该在何时被执行。

  • 核心属性:

  • id: 任务的唯一标识。
  • callback: 任务具体要执行的函数(见下文)。
  • priority: 任务的优先级(如:立即执行、高、中、低、空闲时执行)。
  • expirationTime: 任务的过期时间,如果超过这个时间还没执行,可能会被提升优先级。
  • startTime: 任务的开始时间。
  • 关键特性:可中断与可恢复。 这是时间切片能够实现的基础。Task 的执行状态可以被保存,并在下次被调度时恢复。
  • 比喻: 一份完整的 “工作订单”。比如“渲染一个包含1000个商品的列表”就是一份大订单。

2. Callback (回调函数)

  • 定义: Task 中具体要执行的逻辑代码,通常是一个函数。

  • 角色: 它是任务的 “灵魂”和“内容”。没有 Callback,Task 就只是一个空壳,不知道要做什么。

  • 执行方式: Callback 函数在被调用时,通常会接收一个 didTimeout 参数和一个 deadline 对象,让函数内部可以判断自己是否还有时间继续执行。

  • 比喻: “订单上的具体施工步骤”。比如,对于渲染列表的订单,Callback 里面就写着:“1. 计算每个商品的位置;2. 创建对应的 DOM 节点;3. 插入到页面中……”

3. Work (工作单元)

  • 定义: 从 Task 的 Callback 中拆分出的、一小段实际被执行的代码片段。

  • 角色: 它是调度器实际执行的最小单位。我们不是一次性执行完整个 Callback,而是在一个时间片内,执行 Callback 中的一小部分,这一小部分就是一个 Work。

  • 实现: 通常通过 while 循环结合 deadline.timeRemaining() 来实现。循环体里的每一次迭代,都可以看作是一个 Work。

  • 比喻: “施工步骤中的一个具体动作”。比如“创建一个 DOM 节点”这个动作,就是一个 Work。

关键区别:Work vs. 时间片

这是最容易混淆的地方,必须彻底分清!

概念 本质 作用 比喻
时间片 一段时间预算(例如 5ms) 限制任务的执行时长,防止其无限运行。 “老板规定,你处理这个订单最多只能用5分钟”
Work 一小段代码 任务的具体执行内容,在时间片内被运行。 “在这5分钟内,你拧了3颗螺丝”,“拧一颗螺丝”这个动作就是一个 Work。

它们的关系:

调度器在一个 时间片 (5ms) 内,会尽力去执行一个或多个 Work (拧螺丝)。如果时间片用完了,即使当前的 Work (拧螺丝) 刚进行到一半,也要强制中断,把“扳手”放下,等待下一个时间片再继续。

工作流程比喻(餐厅后厨)

  • 主线程: 厨师(一次只能专心做一件事)。
  • 调度器: 后厨经理(负责分配任务和监督)。
  • 大订单: 一个100人的宴会订单。
  • Callback: 宴会菜单上的菜谱(比如“宫保鸡丁”的详细做法)。
  • Work: 菜谱中的一个步骤(比如“切丁”、“腌制”、“下锅爆炒”)。
  • 时间片: 经理规定,每5分钟必须停下来看一下门口有没有新客人点单

流程:

  1. 经理拿到宴会订单,把它拆成100个“炒一份宫保鸡丁”的小任务。
  2. 经理对厨师说:“开始做第1份宫保鸡丁,记住,每5分钟停一下!”
  3. 厨师开始执行菜谱:切丁、腌制...(这些都是 Work)。
  4. 5分钟后,经理的闹钟响了(时间片到),他立刻打断厨师:“停!先去处理门口新客人的点单(高优先级任务,如用户点击)!”
  5. 厨师放下手中的锅铲,去给新客人点单、上菜。
  6. 处理完高优先级任务后,经理对厨师说:“好了,回来继续做刚才那份宫保鸡丁吧。”
  7. 厨师从中断的地方(比如刚要下锅爆炒)继续执行。
  8. 这个过程不断重复,直到宴会订单完成。餐厅既没耽误大生意,也没怠慢新客人,运转流畅。

核心要点总结

时间切片是一种 “以退为进” 的调度策略。它通过主动让出控制权,换取了主线程的高响应性。其本质不是让任务执行得更快,而是让长任务的执行过程对用户无感知,从而打造出流畅、不卡顿的用户体验。它是现代前端框架(如 React)实现复杂 UI 更新而不阻塞交互的关键技术之一。

最小堆和时间切片的结合

将时间切片与最小堆结合,是为了解决一个更高级的问题:当有大量任务需要调度时,如何高效、公平地选择下一个要执行的任务?

简单来说:

  • 时间切片 解决了 “如何执行一个任务而不阻塞主线程” 的问题。
  • 最小堆 解决了 “在众多任务中,应该优先选择哪一个来执行” 的问题。

下面我们详细拆解它们是如何天衣无缝地结合在一起的。

一、核心问题:为什么需要最小堆?

想象一下,你有一个任务列表,里面有成千上万个任务。每个任务都有一个“优先级”或者“过期时间”。

  • 任务A: 优先级极高(比如用户点击触发的),但执行时间很短。
  • 任务B: 优先级低(比如后台数据分析),但执行时间很长。
  • 任务C: 优先级中等,但马上就要过期了。

如果你的任务调度器只是一个简单的数组,每次要找下一个任务时,你都需要遍历整个数组,找到优先级最高或最紧急的那个。当任务数量巨大时,这个查找过程本身就会消耗性能,甚至可能造成卡顿。

最小堆就是解决这个“查找”问题的利器。 它是一种特殊的优先级队列,可以让你在 O(1) 的时间内(极快)获取到所有任务中“最小”的那个元素(在我们的场景里,就是优先级最高或最早过期的任务),而插入和删除操作也只需要 O(log n) 的时间,非常高效。

二、概念升级:如何将三个核心概念映射到最小堆

  • Task:一个独立的任务,有很多属性。 一个带有“排序键”的独立任务。 这个排序键就是用来在最小堆中比较的依据。最常见的排序键是:
    1. priority (数字越小,优先级越高)
    2. expiryTime (时间戳越小,越早过期,越紧急)

  • Callback:任务里面具体做什么事情。 保持不变。 它是 Task 的一个属性,定义了任务的具体逻辑。

  • Work:一段 x 毫秒的时间片。 保持不变。 它是执行 Callback 的基本时间单位。

  • 最小堆一个“智能任务队列”或“调度器”。 它专门用来存放所有的 Task,并自动根据它们的“排序键”进行排序,确保我们总能最快地拿到最紧急的任务。

三、结合后的完整工作流程

让我们用一个完整的例子来走一遍流程:

场景: 我们有一个调度器,它内部使用一个最小堆来管理任务。

第1步:任务创建与入队

  1. 一个用户点击事件产生了一个高优先级任务 Task_A,它的 priority1
  2. 一个后台数据同步任务 Task_B 被创建,它的 priority10
  3. 一个需要在 timestamp 1000 之前完成的定时任务 Task_C 被创建,它的 expiryTime1000

我们将这些任务插入到最小堆中。最小堆会自动根据它们的排序键(这里我们假设用 priority)进行排列。堆顶永远是 priority 最小的任务。

最小堆内部状态 (以priority为键):
      (Task_A, p:1)
     /           \
(Task_C, p:5)   (Task_B, p:10)

(注意:Task_C的priority我假设为5,用于示例)

第2步:调度器启动

调度器的主循环开始(通常使用 requestIdleCallbackMessageChannel 来实现)。

第3步:获取下一个任务

  1. 调度器问最小堆:“给我下一个最紧急的任务。”
  2. 最小堆不需要遍历,直接返回堆顶元素:Task_A。这个操作是 O(1),极快。
  3. 调度器将 Task_A 从堆中“取出”(pop),这个操作是 O(log n)

第4步:执行时间切片

  1. 调度器开始执行 Task_ACallback
  2. 它启动一个计时器,时间片为 5ms
  3. Task_ACallback 开始执行。假设它是一个复杂的计算,需要 20ms 才能完成。

第5步:时间片用尽,让出控制权

  1. 5ms 后,计时器响起。调度器强制中断 Task_A 的执行。
  2. 此时,Task_A 还没完成。调度器需要决定如何处理它。
  3. 关键决策:由于 Task_A 还没完成,我们需要把它重新放回最小堆,以便后续继续执行。它的 priority 通常保持不变。

第6步:循环继续

  1. Task_A 被重新插入堆中。堆再次自动调整,Task_A 因为 priority 最低,很可能又回到了堆顶。
  2. 调度器回到第3步,再次从堆顶获取任务。它又拿到了 Task_A
  3. 调度器继续执行 Task_ACallback 5ms...
  4. 这个过程重复4次后,Task_A 终于执行完毕。

第7步:任务完成

当一个任务的 Callback 在一个时间片内执行完毕,调度器就不会把它再放回堆中。它被彻底丢弃。

如果在执行过程中来了新任务怎么办?

假设在第4步执行 Task_A 的第一个时间片时,一个 priority0 的紧急任务 Task_D 进来了。它会被立即插入堆中,并成为新的堆顶。当 Task_A 的时间片用尽并被重新插入堆后,调度器下一次获取的将是 Task_D,而不是 Task_A。这就保证了高优先级任务总能被优先处理。

总结

所以,当你看到 React 的 Scheduler(调度器)源码时,你会发现它内部就实现了一个最小堆,用来管理各种不同优先级的更新任务(比如用户输入、数据请求、动画等),然后通过时间切片的机制,在浏览器空闲时去执行这些任务。这正是 React 18 并发特性的基石。

如何调度

一、什么是原生的 requestIdleCallback (rIC)?

这是浏览器提供的一个 API,它的初衷非常好:

“开发者,你给我一个回调函数。我(浏览器)会在主线程空闲的时候,也就是处理完渲染、用户输入等高优先级任务后,调用你的函数。这样你就可以在不影响用户体验的情况下,做一些不那么重要的事情。”

它还提供了一个 deadline 对象,告诉你还剩多少空闲时间 (timeRemaining()),这简直就是为时间切片量身定做的!

理想很丰满: React 最初也想直接用它来调度低优先级的更新。

二、为什么 React 不直接用原生的 requestIdleCallback

现实很骨感,原生的 rIC 存在几个致命缺陷,导致它无法支撑 React 的并发模式:

  1. 触发频率太低,甚至不触发

    1. 如果浏览器一直很忙(比如有复杂的动画、大量计算),rIC 的回调可能永远不会被执行。这意味着 React 的更新可能会被无限期推迟,导致界面看起来“卡死了”。
    2. React 需要一个更可靠的机制,确保任务最终一定会被执行,而不是“有空再说”。
  2. 执行时机不可控

    1. rIC 通常在一帧的末尾被调用。但 React 的并发调度需要更精细的控制,比如在一帧的中间就插入一个高优先级任务,或者在一个空闲周期内执行多个小任务。rIC 的粒度太粗了。
  3. 兼容性问题

    1. Safari 对 rIC 的实现有 Bug,并且触发频率极低,基本不可用。React 必须保证在所有主流浏览器上表现一致。
  4. 缺乏优先级控制

    1. rIC 只是一个“有空就做”的机制,它本身没有优先级概念。而 React 的调度器需要处理非常复杂的优先级(比如用户输入 > 动画 > 数据获取 > 页面懒加载),rIC 无法满足这种需求。

三、React 的解决方案:自定义调度器

由于原生的 rIC 靠不住,React 团队做了一个大胆的决定:我们自己造一个!

这个自定义的调度器(在 scheduler 包中)就是时间切片和最小堆的完美结合体,它解决了 rIC 的所有问题。

它是如何工作的?

  1. 触发机制:不用 rIC,改用 MessageChannel

    1. React 使用 MessageChannelpostMessage API 来调度任务。这两个 API 会将一个任务作为宏任务 推送到任务队列的末尾。
    2. 这就模拟了一个“空闲”状态:当前同步代码执行完毕,浏览器处理完微任务和渲染后,就会来执行这个宏任务。这比 rIC 可靠得多,因为它保证会在下一个事件循环中被触发
  2. 时间切片的实现

    1. MessageChannel 的回调被触发时,React 的调度器就开始工作。
    2. 它从最小堆中取出优先级最高的任务。
    3. 开始执行这个任务的 Callback(也就是 Work)。
    4. 在执行过程中,它会不断检查时间,看是否超过了预设的时间片(比如 5ms)。
    5. 如果时间片用完,它就主动中断,然后再次通过 MessageChannel 把自己(剩下的工作)安排到下一个宏任务中,从而让出主线程。
  3. 最小堆的作用

    1. 在每次 MessageChannel 回调被触发,准备开始工作时,调度器都会去最小堆里查找当前应该执行哪个任务。这保证了高优先级的任务总是被优先处理。

场景举例

场景设置

这个页面包含:

  1. 一个侧边栏,上面有多个筛选器(如日期范围、用户类型等)。
  2. 主内容区,显示了三个复杂的图表:一个柱状图、一个折线图和一个饼图。每个图表的数据都需要经过大量计算才能渲染出来。

用户操作

用户在侧边栏点击了“显示上个月数据”的按钮。

React 内部发生了什么?

当用户点击按钮,React 需要更新整个页面。我们来看看 React 的调度器(那个“自制的 requestIdleCallback”)是如何工作的。

第一步:任务的创建与排序 (最小堆登场)

点击按钮触发了状态更新,React 知道需要重新渲染。但它不会一股脑地把所有事情都做了。它会创建一系列的 Task,并根据优先级将它们放入一个最小堆中。

  • 高优先级任务 (优先级数值小,在堆顶):

    • Task A: 更新按钮的视觉状态(比如显示一个加载中的小图标),给用户即时反馈。
    • Task B: 更新图表上方的标题,从“本月数据”变为“上月数据”。
  • 普通优先级任务 (优先级数值大,在堆底):

    • Task C: 重新计算并渲染柱状图
    • Task D: 重新计算并渲染折线图
    • Task E: 重新计算并渲染饼图

最小堆的作用:React 调度器从堆里取任务时,总能以最快的速度(O(log n))拿到优先级最高的那个。所以它会先执行 Task A,然后是 Task B,之后才会轮到 C, D, E。这保证了用户能立刻看到页面的关键部分发生了变化。

第二步:任务的执行与中断 (时间切片登场)

现在,React 开始执行任务。Task ATask B 很小,瞬间就完成了。

接下来,轮到了 Task C(重新计算并渲染柱状图)。这是一个大任务,可能需要 100 毫秒才能完成。如果直接执行,页面会卡死 100 毫秒!

这时,时间切片机制启动了:

  1. React 调度器不会一次性执行完 Task C。它会把这个大任务拆分成很多个小 Work(比如,计算第一行数据是一个 Work,计算第二行数据是另一个 Work)。
  2. 调度器开始执行第一个 Work,同时启动一个 5 毫秒的计时器(这就是时间片)。
  3. 5 毫秒后,计时器响起!调度器立即中断当前 Work 的执行,即使 Task C 还没完成。
  4. 它把控制权交还给浏览器。浏览器现在可以去处理其他事情了,比如响应鼠标移动、播放 CSS 动画等。页面依然流畅。
  5. 在下一帧的空闲时间,React 的调度器(通过 MessageChannel)会再次被唤醒。
  6. 它回到最小堆,发现最高优先级的任务仍然是未完成的 Task C
  7. 于是,它从上次中断的地方继续执行 Task C 的下一个 Work,同样只执行 5 毫秒。
  8. 这个“执行 5ms -> 中断 -> 让出控制权 -> 下一帧继续”的循环,就是时间切片。直到 Task C 完全完成,调度器才会从堆里取出下一个任务 Task D,用同样的方式去处理它。

第三步:React 的调度器 (自制 requestIdleCallback) 统一指挥

整个过程的总指挥,就是 React 的调度器。它就是那个“自制的 requestIdleCallback”。

  • 它用最小堆来管理所有待办事项,并决定 “下一个该做什么?”(优先级调度)。
  • 它用时间切片来控制每一个任务的执行方式,确保 “怎么做才不会卡死?”(不阻塞主线程)。
  • 它用 MessageChannel 等技术来获得一个可靠的执行时机,而不是像浏览器原生的 requestIdleCallback 那样“看心情”被调用。

提问 1:阻塞问题

前面提到:

  1. 假设 Task C 是渲染一个柱状图,在执行 5ms 后被中断,此时柱状图尚未渲染完成。
  2. 浏览器接着去处理其他任务,比如播放一个持续的 CSS 加载动画(比如旋转的小图标)。

问题来了:如果这个 CSS 动画一直在运行(比如每帧都需要重绘),那浏览器是否还有“空闲时间”留给 React 继续执行 Task C?换句话说,持续的动画是否会阻塞 React 的后续调度?

答案是:不会的! 这背后有一个关键的浏览器优化机制:主线程合成器线程 的分离。

让我们用一个更精确的模型来理解这个过程。

浏览器的“双核”工作模式

想象一下,浏览器渲染页面就像一个高级厨房。

  • 主线程:这是主厨。他负责所有复杂、精细的工作:

    • 运行 JavaScript (包括 React 的时间切片和任务调度)。
    • 计算 HTML 元素的样式 (CSS)。
    • 构建页面的布局 (Layout)。
    • 绘制页面的初始内容 (Paint)。
    •   主厨一次只能做一件事。如果他被一个超大的 JS 任务卡住,整个厨房就停摆了。
  • 合成器线程:这是糕点师。他专门负责处理简单、重复性、可以并行的工作:

    • 将主厨已经画好的图层(比如加载小图标)进行移动、缩放、旋转。
    • 处理 CSS 的 transformopacity 动画。
    •   糕点师有自己的工作台,不需要主厨的干预。他可以独立、流畅地完成自己的工作。

5ms 后发生了什么?

现在,我们用“主厨”和“糕点师”的模型,重新走一遍流程。假设屏幕刷新率是 60fps,那么每一帧的时间预算是 16.67ms

第 1 帧 (0ms - 16.67ms)

  1. (0ms - 5ms) 主厨工作

    1. React 的调度器唤醒,告诉主厨:“开始渲染柱状图!”
    2. 主厨开始执行 Task C 的第一块 Work(比如计算第一行数据)。
    3. 5ms 后,闹钟响了! 调度器强制中断:“停!时间到,把锅放下!”
  2. (5ms - 10ms) 主厨继续做其他必要工作

    1. 主厨放下柱状图,开始处理这一帧必须完成的任务:计算样式、布局、绘制。他把加载小图标这个图层画好了。
  3. (10ms - 16.67ms) 糕点师开始工作 & 主厨空闲

    1. 主厨把画好的图层(包括加载小图标)交给糕点师。
    2. 糕点师接管! 他开始独立地、流畅地播放加载小图标的 CSS 动画(比如旋转)。这个过程完全不占用主厨的时间
    3. 此时,主厨就空闲下来了!他站在那里,等待下一个指令。这个空闲时间可能很短,比如 3ms。
  4. React 的调度器再次出击

    1. React 的调度器(通过 MessageChannel)一直在观察。它发现主厨空闲了,于是立刻把 Task C 的下一块 Work 作为一个新任务,扔到主厨的任务队列里。
    2. 因为现在主厨是空闲的,他会立刻拿起这个新任务,开始执行下一块 Work

第 2 帧 (16.67ms - 33.33ms)

  • 这个过程会重复。主厨可能又干了 5ms 的柱状图活儿,然后被中断,去处理其他必要工作,然后再次空闲下来,被调度器叫醒继续干柱状图的活儿。
  • 与此同时,糕点师从未停歇,一直在他那边流畅地播放着加载动画。

核心要点总结

  1. CSS 动画不等于 JS 任务:我们通常说的流畅的 CSS 动画(使用 transformopacity)是由合成器线程独立处理的,它不会阻塞主线程。
  2. “空闲时间”是真实存在的:它是指在一帧内,主线程完成了所有必要的渲染工作后,到下一帧开始前的短暂间隙。React 的调度器目标就是抢占这些零碎的空闲时间。
  3. 中断是为了更好的协作:时间切片的中断机制,就是为了确保主厨(主线程)不会被一个巨大的任务(渲染整个柱状图)累死,从而能及时响应其他紧急任务(比如用户点击),并完成每一帧的必要渲染工作。
  4. MessageChannel 的作用:它像一个精准的传令兵,能在当前任务队列清空后,立刻把下一个 React 任务插进去,确保能最大化地利用主线程的空闲时间,比 setTimeout(fn, 0) 更可靠。

所以,你的画面里,加载小图标一直在流畅旋转(糕点师在工作),而柱状图在后台一点一点地、不卡顿地被绘制出来(主厨在见缝插针地工作)。这就是 React 并发渲染带来的流畅体验!

提问 2:与宏任务之间的关系

React 调度器是如何工作的?

React 的调度器(Scheduler)并不会为每一个 Work 创建一个宏任务。那样效率太低了。它的策略是:

  1. 开启一个“打包会话”(一个宏任务): 调度器通过 MessageChannelsetTimeout 等方式,向浏览器的宏任务队列里放入一个回调函数。我们称这个回调函数为 flushWork。当浏览器轮到这个任务时,flushWork 开始执行。这一个 flushWork 就是一个宏任务。

  2. 在“会话”内疯狂打包(执行多个 Work): flushWork 函数内部有一个循环。这个循环会做以下事情:

    1. 从任务队列中取出一个 Work。
    2. 执行这个 Work 的 Callback。
    3. 执行完毕后,检查当前时间。是否超过了时间片(比如 5ms)?
    4. 如果没超过:继续循环,取出下一个 Work 并执行。
    5. 如果超过了:立即 break 退出循环。
  3. 结束“会话”,并预约下一次:

    1. 当循环因为时间用完而退出时,flushWork 函数就执行完毕了。这个宏任务结束。
    2. 此时,主线程的控制权交还给浏览器,浏览器可以去处理渲染、用户输入等其他事情。
    3. 如果还有未完成的 Work,调度器会在 flushWork 结束前,再次通过 MessageChannel 预约一个新的宏任务,以便在下一轮事件循环中继续打包。

举例说明

假设我们有 Work A, B, C, D,时间片是 5ms。

  1. 调度开始:React 调度器通过 MessageChannelflushWork 回调放入宏任务队列。
  2. 宏任务 #1 开始:浏览器的事件循环取出 flushWork 并执行。
  3. 执行 Work A:耗时 2ms。剩余时间:3ms。
  4. 执行 Work B:耗时 2ms。剩余时间:1ms。
  5. 执行 Work C:耗时 2ms。时间超了!(总耗时 2+2+2 = 6ms)。
  6. 中断:flushWork 中的循环检测到时间超限,立即中断。Work C 可能只执行了一半,或者执行完了但时间也刚好用完。
  7. 宏任务 #1 结束:flushWork 函数执行完毕。主线程空闲。
  8. 浏览器工作:浏览器处理页面渲染、响应用户点击等。
  9. 调度继续:在宏任务 #1 结束前,调度器已经预约了 flushWork 的下一次执行。
  10. 宏任务 #2 开始:在下一轮事件循环中,浏览器取出新的 flushWork 并执行。
  11. 继续执行:循环从上次中断的地方继续,开始执行 Work D…
  12. …如此往复,直到所有 Work 完成。

那个让我熬夜三天的 “小数点”:一次 URL 踩坑记

2025年10月18日 17:00

一、上线前炸锅:页面玩起 “捉迷藏”

离项目上线只剩 3 天,我盯着屏幕上的 404 页面,手指在键盘上飞快敲击 —— 这已经是今天第 12 次刷新产品详情页了,可它就像跟我捉迷藏,时而出现,时而消失,偏偏老板还在群里催着 “最后一轮测试收尾”。

我叫小宇,是个刚入行两年的前端开发。这次负责的是电商产品的详情页模块,前几天中英文切换功能刚上线,测试小姐姐就抱着电脑找到我:“小宇,你看,从中文切英文再切回来,页面就没了,刷新也没用。”

我当时满不在乎:“肯定是翻译插件冲突了,我调调配置就行。” 可折腾了一下午,把翻译插件卸载重装、改了三次参数,问题还是没解决。更奇怪的是,就算不切换语言,直接复制带产品编码的 URL 打开,第一次能正常显示,刷新一下就跳 404—— 比如那个让我头大的路径:192.168.3.171:5173/product/detail/P-X1-21.5

二、求助后端:揪出 “小数点” 真凶

“难道是后端接口的问题?” 我抱着怀疑找到后端同事老周,他正对着监控屏喝咖啡。听我说完情况,老周点开服务器日志,指着一行红色记录:“你看,刷新时请求的路径是P-X1-21.5.,末尾多了个小数点,我们路由规则里没这种格式。”

我愣了愣:“可第一次打开明明能显示啊?”

老周拿过我的鼠标,点开页面又刷新:“你前端框架帮你‘擦屁股’了呗。” 他边说边打开 Vue Router 的文档,“你用的 Vue Router,初始化时会自动忽略路径末尾的点、斜杠这些‘小尾巴’,第一次打开时,它悄悄把P-X1-21.5.改成了P-X1-21.5,所以能匹配到页面。但刷新时,请求直接走后端,我们的 Nginx 路由规则是死的 ——/product/detail/[A-Z]-[A-Z0-9]-[0-9.]+,要求编码末尾不能有多余符号,多一个点就认不出来了。”

我这才恍然大悟,想起前几天改产品编码时,为了区分不同尺寸,把 “21.5 英寸” 直接写成了 “21.5”,还随手在测试链接末尾多敲了个点,没成想这成了 “定时炸弹”。

三、三招破局:从应急到根源解决

接下来的解决过程倒没那么曲折。前端就是一顿修改路径

然后我又找了产品经理,一起把 “产品编码不允许末尾带小数点” 加进了开发规范 —— 毕竟从源头避免问题,比事后补救更省心。老周也帮我调整了后端的 404 页面,加了行提示:“若路径包含多余符号(如末尾小数点),请检查 URL 格式”,免得以后其他同事踩同样的坑。

四、踩坑总结:小符号里的大逻辑

上线那天,我看着监控里零报错的数据,终于松了口气。现在再想起那个小数点,总觉得有点好笑:明明是个不起眼的小符号,却因为前后端对 URL 的 “认知差异”,让我熬了三个夜。

后来我跟同事们分享这个故事时总说:“有时候解决技术问题,就像破案 —— 找到那个‘不合群’的细节,答案自然就出来了。而这次的教训更让我明白,前后端的‘认知同步’,比单独做好自己的模块更重要。”


下面是正经描述

🔍 开发踩坑实录:

项目测试时遇到一个诡异问题 —— 产品详情页从中文切换到英文后,再切回中文或直接刷新,页面突然 “消失”,浏览器报 404 错误。

最初怀疑是中英文翻译插件冲突,排查后发现:即使不切换语言,直接访问带末尾「.」的 URL 也会触发问题!

比如这个路径:192.168.3.171:5173/product/detail/P-X1-21.5

(注意产品编码P-X1-21.5末尾的「.」,正是它导致了后续的页面丢失)

核心矛盾:前端 “容错” vs 后端 “严格”

为什么首次访问能成功,刷新就失败?本质是前端路由的临时兼容逻辑与后端服务器的固定校验规则对「.」的处理方式不同。

一、首次访问能成功?前端路由的 “临时妥协”

首次打开页面时,URL 解析由前端框架主导,会自动 “修正” 非关键错误,让页面正常加载:

1. 前端路由主动 “过滤噪音”

主流前端框架(Vue Router、React Router 等)初始化时,会对路径做 “容错处理”—— 自动忽略末尾的「.」「/」等非核心符号。

比如将带问题的路径 product/detail/P-X1-21.5.(末尾多「.」),临时修正为 product/detail/P-X1-21.5,精准匹配到对应的产品详情页路由。

2. 浏览器缓存 “助攻”

若之前访问过正确路径(如 P-X1-21.5),浏览器会缓存该路径的资源映射。即使这次 URL 多了「.」,也会优先调用缓存资源,暂时 “掩盖” 路径错误。

二、刷新后必失败?后端服务器的 “铁律”

刷新页面时,请求会跳过前端的临时处理,直接发送给后端服务器 —— 而后端对 URL 格式的校验,没有 “妥协” 的余地:

1. 后端路由无容错,格式不对直接拒

后端服务器(如 Nginx、Java Spring Boot)的路由规则是 “写死” 的,比如预设产品详情路径为:

/product/detail/[A-Z]-[A-Z0-9]-[0-9.]+

(规则要求:编码由 “字母 - 字母数字 - 数字 / 小数点” 组成,且末尾无多余符号)

当 URL 多了一个「.」,编码长度、符号数量都不符合规则,服务器无法识别,直接返回 404。

2. 刷新清空前端 “临时状态”

刷新会重置前端路由的容错逻辑,之前的 “自动修正” 失效。此时请求必须带着原始错误 URL(如 P-X1-21.5.)发送给后端,自然触发路径不匹配。

手撕 Promise 一文搞定

作者 CptW
2025年10月18日 16:54

手撕 Promise,做 2 件事就够了:

  1. 了解 Promise 的行为,理解清晰后就能写出 then 方法
  2. 实现 then 方法,其他方法大多是它的衍生

其他方法,放到最后,很容易能用 then 衍生出来

Promise 行为概览

  1. 通过 new 调用,说明它是一个 class
  2. 构造时传入一个函数 executor
    • 同步执行 executor
    • 它有两个参数 resolvereject
    • 这两个参数负责:改变 Promise 状态、传递数据(分别叫 valuereason)
  3. 有 3 种状态,pendingfulfilledrejected
    • 初始状态是 pending
    • 只能 pending -> fulfilledpending -> rejected
    • 状态一旦改变后,无法继续调用 fulfilledrejected
  4. then 方法
    • 状态改变后,then 方法异步接收 resolve/reject 的回调信息
    • then 内的回调函数实际是 微任务
    • 可以链式(多次)调用
    • 在状态敲定后调用,则立即执行
    • 立刻返回一个新 Promise(pending) 对象(下文称 p):
      • onFulfilledonRejected 将被异步执行,即使状态已被敲定
      • p 的行为取决于上一条的执行结果
        • 返回一个值:以该值作为兑现值
        • 无返回:以 undefined 作为兑现值(value)
        • 抛出错误:以错误作为拒绝值(reason)
        • 返回已兑现的Promise:以该 Promise 作为兑现值
        • 返回已拒绝的 Promise:以该 Promise 作为拒绝值
        • 返回待定(pending)的 Promise:保持待定,并在该 Promise 状态改变后以其值作为兑现/拒绝值

实现 then 方法

const PENDING = 'pending'
const FULFILLED = 'fulfilled'
const REJECTED = 'rejected'

class Kromise {
  constructor(executor) {
    this.status = PENDING
    // value 和 reason 储存到静态属性里,方便 then 访问
    this.value = undefined
    this.reason = undefined
    // 支持链式调用,以数组保存回调
    this.onFulfilledFns = []
    this.onRejectedFns = []

    // value 传递值
    const resolve = (value) => {
      // 仅当 pending 时有效
      if (this.status === PENDING) {
        // 当兑现值是 Promise 时,等待他兑现/拒绝
        if (value instanceof Kromise) {
          value.then(resolve, reject)
          return
        }
        // Keep simple, 省略 thenable 部分

        /**
         * 执行回调,微任务:
         * 1、确保符合规范
         * 2、确保顺序执行 executor 时,不会立即执行 onFulfilled 导致 undefined 错误
         */
        queueMicrotask(() => {
          // 异步锁,防止异步流程中多次改变状态
          if (this.status !== PENDING) return

          // 将改变状态代码放到微任务中,为了确保不会过早敲定状态,导致 then 总执行敲定状态后的代码
          // 敲定状态
          this.status = FULFILLED
          // 储存value
          this.value = value
          this.onFulfilledFns.forEach(fn => fn(this.value))
        })
      }
    }

    const reject = (reason) => {
      if (this.status === REJECTED) {
        queueMicrotask(() => {
          if (this.status !== PENDING) return

          this.status = REJECTED
          this.reason = reason
          this.onRejectedFns.forEach(fn => fn(this.reason))
        })
      }
    }

    // 同步执行 executor,并传递参数
    executor(resolve, reject)
  }

  then(onFulfilled, onRejected) {
    // 返回 Promise,以支持链式调用
    return new Kromise((resolve, reject) => {
      // 如果在状态敲定后再执行 then,则立即执行回调
      if (this.status === FULFILLED && onFulfilled) {
        // 将值传递给返回的 p
        const value = onFulfilled(this.value)
        resolve(value)
      }
      if (this.status === REJECTED && onRejected) {
        const reason = onRejected(this.reason)
        reject(reason)
      }

      // 暂存,等状态改变(即 resolve/reject 执行)时才真正调用
      // try...catch 处理抛出错误的情况
      this.onFulfilledFns.push(() => executeFunctionWithErrorCatch(onFulfilled, this.value, resolve, reject))
      this.onRejectedFns.push(() => executeFunctionWithErrorCatch(onRejected, this.reason, resolve, reject))
    })
  }
}

function executeFunctionWithErrorCatch(fn, value, resolve, reject) {
  try {
    // 将自己的值,传递给返回的 Promise p
    const result = execFn(value)
    resolve(result)
  } catch(err) {
    reject(err)
  }
}

其他方法

catch

实际上是 then(undefined, onRejected) 的简写,但这里有一个边界情况要处理:

如何在then未处理onRejected的情况下,将该错误作为返回值传递给接下来链式调用的catch进行处理?

因为 then 仅注册 onFulfilled 回调时,返回的 p 无法将错误传递下去; 解决方法很简单,只需要提供一个默认的 onRejected 实现,保证错误传递即可

  catch(onRejected) {
    return this.then(undefined, onRejected)
  }
  // 修改 then 的实现
  then(onFulfilled, onRejected) {
    // 返回 Promise,以支持链式调用
    return new Kromise((resolve, reject) => {
      // 如果在状态敲定后再执行 then,则立即执行回调
      if (this.status === FULFILLED && onFulfilled) {
        // 将值传递给返回的 p
        const value = onFulfilled(this.value)
        resolve(value)
      }
      if (this.status === REJECTED && onRejected) {
        const reason = onRejected(this.reason)
        reject(reason)
      }

      const defaultOnFulfilled = (value) => value
      const defaultOnRejected = (reason) => { throw reason }
      onFulfilled = onFulfilled || defaultOnFulfilled
      onRejected = onRejected || defaultOnRejected

      // 暂存,等状态改变(即 resolve/reject 执行)时才真正调用
      // try...catch 处理抛出错误的情况
      if (onFulfilled) this.onFulfilledFns.push(() => executeFunctionWithErrorCatch(onFulfilled, this.value, resolve, reject))
      if (onRejected) this.onRejectedFns.push(() => executeFunctionWithErrorCatch(onRejected, this.reason, resolve, reject))
    })
  }

finally

MDN: Promise实例的 finally()  方法用于注册一个在 promise 敲定(兑现或拒绝)时调用的函数。它会立即返回一个等效的Promise对象,这可以允许我们链式调用其他 promise 方法

finally(onFinally) {
  this.then(onFinally, onFinally)
}

n8n 入门笔记:用零代码工作流自动化重塑效率边界

作者 Larcher
2025年10月18日 16:51

在这个信息爆炸的时代,我们每天都被大量重复工作淹没:定时查收邮件、重复录入数据、定期爬取信息、批量处理文件…… 这些机械劳动不仅消耗时间,更会磨灭创造力。而 n8n 的出现,正为解决这些问题提供了全新思路 —— 它是一款开源的自动化工作流工具,通过可视化拖拽的方式,让任何人都能快速搭建自动化流程,将重复工作交给机器,专注于更有价值的思考。

一、认识 n8n:自动化工作流的 "万能胶水"

n8n(发音为 "n-eight-n")是一款基于 Node.js 开发的低代码 / 零代码自动化工具,核心定位是 "连接一切可连接的服务,自动化一切可自动化的工作"。它的名字源于 "node-based workflow"(基于节点的工作流),直观体现了其核心设计理念:用 "节点" 封装功能,用 "连线" 定义流程,最终形成可自动执行的工作流。

与传统编程自动化相比,n8n 的优势体现在三个方面:

  • 零代码门槛:无需深入掌握编程语言,通过拖拽节点、配置参数即可完成自动化流程,适合非技术人员快速上手。
  • 生态丰富:内置 500 + 节点,覆盖常用服务(如邮件、数据库、云存储、API 接口、AI 模型等),几乎能连接所有你日常使用的工具。
  • 高度灵活:支持自定义节点开发(JS/Python),既能满足简单的定时任务,也能实现复杂的业务流程(如数据清洗、多系统协同、AI 推理等)。

从个人用户的 "自动整理邮件",到企业级的 "跨系统数据同步",n8n 都能胜任。尤其在处理重复工作时,它能释放出惊人的效率 —— 比如原本每天 2 小时的简历筛选,用 n8n 搭建工作流后可缩短至 5 分钟,且准确率更高。

二、安装 n8n:避坑指南与环境搭建

n8n 的安装方式有多种,但在 Windows 环境中直接安装可能会遇到 SQLite 驱动兼容问题(n8n 默认使用 SQLite 存储工作流数据)。经过实践验证,Docker 方式是最稳定可靠的选择,尤其适合新手。

1. 推荐:Docker 安装(Windows/macOS/Linux 通用)

前置条件:

  • 安装 Docker Desktop(官网下载对应系统版本,一路默认安装即可)。安装完成后启动 Docker,确保状态栏显示 "Running"(运行中)。

安装步骤:

  1. 打开终端(Windows 用 PowerShell,macOS 用终端),创建一个存放 n8n 数据的文件夹(如n8n-workflow),并进入该文件夹:

    bash

    mkdir n8n-workflow && cd n8n-workflow
    
  2. 创建docker-compose.yml文件(用于定义 n8n 服务配置)。在文件夹中新建文本文件,粘贴以下内容并保存为docker-compose.yml

    yaml

    version: '3.8'
    services:
      n8n:
        image: n8nio/n8n
        restart: always
        ports:
          - "5678:5678"  # 本地端口:容器端口,确保5678端口未被占用
        volumes:
          - ./n8n-data:/home/node/.n8n  # 数据持久化,避免重启后工作流丢失
        environment:
          - N8N_DISABLE_PRODUCTION_MAIN_PROCESS=true  # 开发模式,便于调试
    
  3. 启动 n8n 服务:在终端中执行以下命令,Docker 会自动拉取镜像并启动服务:

    bash

    docker-compose up -d
    
  4. 验证安装:打开浏览器访问http://localhost:5678,若出现 n8n 的可视化编辑界面,说明安装成功。

2. 其他安装方式(适合有技术基础用户)

  • Node.js 直接安装:需先安装 Node.js(v16+),然后通过 npm 安装:

    bash

    npm install n8n -g
    n8n start  # 启动服务,访问http://localhost:5678
    

    注意:Windows 用户可能需要手动安装 SQLite 驱动,推荐参考官方文档解决依赖问题。

  • 云部署:支持在 AWS、Google Cloud、Heroku 等平台部署,适合需要长期稳定运行的团队场景,具体步骤可参考 n8n 官方部署指南。

三、n8n 核心概念:从 "节点" 到 "工作流"

要快速上手 n8n,需先理解三个核心概念:节点(Nodes)、工作流(Workflows)、触发器(Triggers)。

1. 节点(Nodes):功能的最小单元

节点是 n8n 的 "原子组件",每个节点封装了特定功能。比如:

  • Trigger 节点:工作流的 "启动器",用于定义工作流何时执行(如定时触发、接收 HTTP 请求时触发、文件新增时触发等)。
  • Action 节点:执行具体操作的节点(如发送邮件、调用 API、处理数据、调用 AI 模型等)。
  • 逻辑节点:处理流程分支(如 IF 条件判断、循环、数据过滤等)。

每个节点都有 "输入" 和 "输出":输入是上一个节点传递的数据,输出是当前节点处理后的结果(会传递给下一个节点)。例如,"HTTP Request" 节点的输入可以是一个 URL,输出则是 API 返回的 JSON 数据。

2. 工作流(Workflows):节点的有序组合

工作流是节点按业务逻辑连接形成的流程图。一个完整的工作流包含三个部分:

  • 启动方式(Trigger 节点定义);
  • 处理步骤(Action 节点、逻辑节点的组合);
  • 输出结果(如保存到数据库、发送通知等)。

例如,"自动化简历投递" 工作流的结构可能是:

plaintext

定时触发器(每天9点)→ HTTP请求(爬取招聘网站)→ 数据清洗(过滤重复岗位)→ AI分析(匹配岗位要求)→ 邮件节点(发送简历)→ 短信节点(通知结果)

3. 触发器(Triggers):工作流的 "开关"

触发器决定工作流何时开始执行,常见类型有:

  • 定时触发:按固定时间间隔(如每天、每小时)或特定时间点执行(适合周期性任务)。
  • 事件触发:当特定事件发生时执行(如收到新邮件、文件夹新增文件、API 收到请求等)。
  • 手动触发:点击 "执行" 按钮时立即运行(适合测试或临时任务)。

四、实操案例:用 n8n 搭建 "招聘信息监控" 工作流

以 "自动监控招聘网站并推送匹配岗位" 为例,带大家从零开始搭建第一个工作流,理解 n8n 的使用逻辑。

场景需求:

每天上午 9 点爬取某招聘网站的 "前端开发" 岗位,过滤重复岗位和不符合条件(如要求 5 年以上经验)的信息,将匹配结果通过短信发送到手机。

步骤 1:创建工作流并添加触发器

  1. 打开 n8n 界面,点击左上角 "New Workflow" 创建新工作流,命名为 "招聘信息监控"。
  2. 添加触发器:在左侧节点面板搜索 "Schedule"(定时触发器),拖拽到画布中。
  3. 配置触发器:双击节点,设置 "Interval" 为 "Daily"(每天),"Time" 为 "09:00",点击 "Save" 保存。

步骤 2:爬取招聘网站数据(HTTP Request 节点)

  1. 添加 "HTTP Request" 节点:拖拽到画布,连接到 "Schedule" 节点(点击前一个节点的输出点,连线到当前节点的输入点)。

  2. 配置请求参数:

    • "Method" 选择 "GET";
    • "URL" 填写招聘网站的岗位列表 API(若没有公开 API,可先爬取网页 HTML,后续用 "HTML Extract" 节点解析);
    • 若网站需要 headers(如 User-Agent),在 "Headers" 中添加键值对(例如User-Agent: Mozilla/5.0...)。
  3. 测试请求:点击节点右上角 "Execute Node",查看输出是否返回岗位数据(若成功,会显示 JSON 格式的岗位列表)。

步骤 3:数据清洗与过滤(Edit Fields + Filter 节点)

  1. 整理字段(Edit Fields 节点) :招聘网站返回的数据可能包含冗余字段(如广告标识、无关参数),用该节点保留需要的信息。

    • 拖拽 "Edit Fields" 节点,连接到 "HTTP Request" 节点;
    • 配置 "Operation" 为 "Keep Only Selected Fields";
    • 选择需要保留的字段(如岗位名称公司经验要求薪资链接)。
  2. 过滤不符合条件的岗位(Filter 节点)

    • 拖拽 "Filter" 节点,连接到 "Edit Fields" 节点;
    • 配置过滤规则:"经验要求" 不包含 "5 年以上"(点击 "Add Condition",选择字段和判断逻辑);
    • 若满足条件,数据会流向 "True" 分支(继续处理);否则流向 "False" 分支(可忽略或记录日志)。
  3. 去重处理(Deduplicate 节点) :避免重复推送同一岗位。

    • 拖拽 "Deduplicate" 节点,连接到 "Filter" 的 "True" 分支;
    • 选择去重依据字段(如岗位链接,因为同一岗位链接唯一)。

步骤 4:推送结果到手机(SMS 节点)

  1. 选择短信服务:n8n 支持 Twilio、阿里云短信等服务,这里以阿里云为例(需先注册阿里云账号并申请短信 API)。
  2. 添加 "Aliyun SMS" 节点,连接到 "Deduplicate" 节点;
  3. 配置 API 参数:填写阿里云的 "Access Key ID"、"Access Key Secret"、"签名"、"模板 ID";
  4. 设置短信内容:在 "Template Params" 中关联岗位数据(如岗位名称:{{$json.岗位名称}},公司:{{$json.公司}}),{{$json}}表示当前节点接收的数据。

步骤 5:测试与运行

  1. 点击工作流顶部 "Execute Workflow" 按钮,手动触发一次流程,查看每个节点的输出是否符合预期(若出错,点击节点可查看错误日志)。
  2. 测试通过后,工作流会在每天 9 点自动执行,符合条件的岗位信息会以短信形式推送。

五、进阶技巧:让工作流更强大

掌握基础操作后,可通过以下技巧提升工作流的能力:

1. 结合 AI 处理复杂逻辑

n8n 支持接入 OpenAI、Claude 等 LLM 模型,让 AI 帮你处理自然语言任务。例如:

  • 在招聘场景中,用 "OpenAI" 节点分析岗位描述与简历的匹配度(输入岗位要求和简历文本,输出匹配分数);
  • 用 AI 自动生成邮件模板(根据岗位信息生成个性化投递邮件)。

操作方式:拖拽 "OpenAI" 节点,配置 API Key 和提示词(Prompt),例如:

plaintext

请分析以下岗位要求与候选人技能的匹配度,返回分数(0-100)和理由:
岗位要求:{{$json.岗位要求}}
候选人技能:熟悉JavaScript、React、3年前端经验

2. 数据持久化与查询

对于需要长期跟踪的数据(如已投递的岗位记录),可使用数据库节点(如 MySQL、PostgreSQL)存储信息:

  • 添加 "MySQL" 节点,配置数据库连接;
  • 在去重步骤前,先查询数据库中是否已存在该岗位 ID,避免重复处理。

3. 错误处理与通知

为避免工作流中断后无人知晓,可添加 "Error Trigger" 节点:

  • 拖拽 "Error Trigger" 节点到画布,它会监听工作流中其他节点的错误;
  • 连接到 "Email" 节点,当出现错误时自动发送邮件通知(包含错误详情)。

六、n8n 与 AI 时代:从工具到 "数字助手"

随着大模型技术的发展,n8n 的定位正在从 "自动化工具" 升级为 "AI 工作流编排平台"。它的核心价值不再是简单的 "节点连接",而是成为人类与 AI 协作的 "指挥中心"—— 通过编排 LLM、工具、数据,让 AI 按人类指令自动完成复杂任务。

例如:

  • 市场人员用 n8n 搭建 "竞品分析" 工作流:定时爬取竞品官网→调用 AI 总结更新内容→生成分析报告→发送给团队;
  • 教师用 n8n 搭建 "作业批改" 工作流:接收学生提交的作业→调用 AI 检查语法错误→统计常见问题→生成反馈邮件。

未来,每个人都可以通过 n8n 创建专属的 "AI 助手",接管重复工作,而我们则聚焦于目标设定、流程设计和结果审核 —— 这正是 AI + 时代工程师的核心能力:不只是写代码,更要会 "指挥" 机器协同工作。

七、总结与资源

n8n 的出现,打破了 "自动化 = 编程" 的固有认知。它用可视化的方式降低了自动化门槛,让重复工作的解放不再依赖专业开发能力。无论是个人用户还是企业团队,都能通过它快速搭建符合需求的工作流,将时间投入到更有创造性的事务中。

学习资源推荐

  • 官方文档:docs.n8n.io(最权威的操作指南);
  • 社区论坛:community.n8n.io(可获取现成工作流模板和问题解答);
  • 视频教程:n8n 官方 YouTube 频道(包含大量实操案例)。

从今天开始,试着用 n8n 解决一个你每天都在做的重复工作 —— 或许你会发现,效率的提升,往往始于一个简单的自动化工作流。

自定义 markdown 解析规则并渲染 Vue 组件

作者 Itai
2025年10月18日 16:44

前言

文本主要是介绍一种在 Vue 项目中自定义 markdown 解析规则并渲染 Vue 组件的方式,这里借助一个实际的业务场景来分析如何实现这个功能

需求分析

需求是基于 Vue 写一个 AI 前端对话网页,并且实现类似于腾讯元宝的联网搜索功能,具体效果可以参考下面的页面截图:

image.png

我们知道,AI 返回的内容是 markdown 格式的,因此图中那些标注引用来源的小圆点,实际上是由 markdown 解析器对某种自定义语法进行渲染后生成的。为了确认这一点,我们可以打开开发者工具,查看 AI 流式返回的原始内容到底是什么样子:

image.png

实际上,标注引用来源的小圆点,其本质是 markdown 解析器解析特定语法并进行渲染的结果。当解析器遇到  [citation:<num>] 格式的文字(<num> 为引用在搜索结果列表中的索引)时,便会将其渲染为对应的小圆点

当我们的鼠标悬停在小圆点上的时候,上方还会有一个悬浮的网页卡片,这个需求的实现方法在文章后面也会写到,先把基本的小圆点给渲染出来

项目准备

本文的示例代码的 Vue 版本是 vue3,采用的 markdown 解析器是 unified

为什么要用这个 markdown 解析器呢?因为这个解析器的插件系统允许深度定制解析流程,适合需要非标准语法扩展的场景

这里顺带附上本文示例代码中涉及到的所有和 markdown 解析器相关的依赖:

# 核心依赖
npm install unified
npm install remark-parse
npm install remark-rehype
npm install rehype-stringify

# 插件依赖
npm install remark-gfm
npm install remark-math
npm install rehype-katex
npm install rehype-raw

# 工具依赖
npm install hast
npm install unist-util-visit

# 类型依赖
npm install --save-dev @types/hast

基础 Markdown 渲染

先不考虑对于自定义语法的解析,先基于 unified 把最基本的 markdown 语法渲染给实现了

首先我们先创建一个 MarkdownRenderer 组件,文件结构如下:

components/
└── MarkdownRender/
    ├── index.vue
    └── processor.ts

processor.ts 写的是 markdown 处理器的核心逻辑:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeStringify from 'rehype-stringify'

/**
 * 将 Markdown 内容处理为 HTML 字符串的异步函数
 *
 * @param content - 原始 Markdown 文本内容
 * @returns 处理后的 HTML 字符串
 */
export async function processMarkdown(content: string) {
  // 预处理数学公式标记:
  // 1. 将 \(公式\) 格式的行内数学公式转换为 $公式$ 格式,以便 remark-math 正确识别
  // 2. 将 \[公式\] 格式的块级数学公式转换为 $$公式$$ 格式,以便 remark-math 正确识别
  const processed = content
    .replace(/\\\(([^]*?)\\\)/g, (_, math) => `$${math}$`)
    .replace(/\\\[([^]*?)\\\]/g, (_, math) => `$$${math}$$`)

  // 创建统一处理器实例,并配置处理流水线
  const processor = unified()
    // 使用 remark-parse 将 Markdown 文本解析为 MDAST
    .use(remarkParse)
    // 使用 remark-gfm 添加对 GFM 扩展语法的支持
    .use(remarkGfm)
    // 使用 remark-math 识别和解析数学公式语法
    .use(remarkMath)
    // 使用 remark-rehype 将 MDAST 转换为 HAST (HTML AST)
    .use(remarkRehype)
    // 使用 rehype-raw 允许保留原始 HTML 标签
    .use(rehypeRaw)
    // 使用 rehype-katex 将数学公式渲染为美观的数学符号
    .use(rehypeKatex)
    // 添加 rehype-stringify 将 HAST 编译为 HTML 字符串
    .use(rehypeStringify)

  // 执行处理流程,将预处理后的内容转换为 HTML
  const file = await processor.process(processed)

  // 返回处理结果中的 HTML 字符串
  return file.value as string
}

处理器接收原始的 markdown 文本字符串,可以返回解析之后得到的 HTML 字符串。然后我们可以直接通过 v-html 指令将解析得到的 HTML 字符串绑定到一个容器元素里面:

<script setup lang="ts">
import { ref, watch, defineOptions } from 'vue'
import { processMarkdown } from './processor'

defineOptions({
  name: 'MarkdownRenderer',
})
const props = defineProps<{
  content: string
}>()

const htmlString = ref('')

// 监听 content 变化
watch(
  () => props.content,
  async (newContent) => {
    htmlString.value = await processMarkdown(newContent)
  },
  { immediate: true },
)
</script>

<template>
  <div class="markdown-container" v-html="htmlString"></div>
</template>

<style>
@import 'katex/dist/katex.min.css';
.markdown-container {
  line-height: 1.6;
}

/* 基础 Markdown 样式 */
.markdown-container h1 {
  font-size: 2em;
  margin: 0.67em 0;
}
.markdown-container h2 {
  font-size: 1.5em;
  margin: 0.75em 0;
}
.markdown-container p {
  margin: 1em 0;
}
.markdown-container pre {
  background-color: #f6f8fa;
  padding: 16px;
  border-radius: 6px;
  overflow: auto;
}
.markdown-container code {
  font-family: monospace;
  background-color: rgba(175, 184, 193, 0.2);
  padding: 0.2em 0.4em;
  border-radius: 6px;
}
.markdown-container blockquote {
  border-left: 4px solid #dfe2e5;
  color: #6a737d;
  padding: 0 1em;
  margin: 0 0 1em 0;
}
.markdown-container table {
  border-collapse: collapse;
  width: 100%;
}
.markdown-container th,
.markdown-container td {
  border: 1px solid #dfe2e5;
  padding: 6px 13px;
}
.markdown-container tr {
  background-color: #fff;
  border-top: 1px solid #c6cbd1;
}
.markdown-container tr:nth-child(2n) {
  background-color: #f6f8fa;
}
</style>

然后我们在 App.vue 里面写一下测试代码:

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRenderer from '@/components/MarkdownRender/index.vue'

// 基本Markdown语法测试
const basicMarkdown = ref(`
# 标题1
## 标题2

这是一个段落,包含**粗体**和*斜体*文本。

- 无序列表项1
- 无序列表项2

1. 有序列表项1
2. 有序列表项2

> 这是一个引用块

\`行内代码\`
`)

// 数学公式测试
const mathMarkdown = `
# 数学公式测试

行内公式:$E = mc^2$

块级公式:
$$
\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
$$

转义括号公式:\\(a^2 + b^2 = c^2\\) 和 \\[x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\\]
`

// GFM扩展语法测试
const gfmMarkdown = `
# GFM扩展语法测试

## 表格

| 姓名 | 年龄 | 城市 |
| ---- | ---- | ---- |
| 张三 | 25   | 北京 |
| 李四 | 30   | 上海 |

## 删除线

~~这是删除的文本~~

## 任务列表

- [x] 已完成任务
- [ ] 未完成任务
`
</script>

<template>
  <div class="container">
    <!-- 测试基本Markdown语法 -->
    <MarkdownRenderer :content="basicMarkdown" />

    <!-- 测试数学公式 -->
    <MarkdownRenderer :content="mathMarkdown" />

    <!-- 测试GFM扩展语法 -->
    <MarkdownRenderer :content="gfmMarkdown" />
  </div>
</template>

<style scoped>
.container {
  padding: 20px;
}
</style>

image.png

测试效果没有问题,至此我们已经完成了基础 markdown 语法的解析和渲染

但如果我们要实现「需求分析」中提到的引用小圆点和悬浮网页卡片的需求,思路肯定是去自定义一套解析规则,并且最好能实现直接渲染一个 Vue 组件,这样的话我们可以自由定制渲染内容和交互逻辑

至于怎么实现渲染 Vue 组件,这里先卖个关子,先来简单了解一下 unified 的工作流程

unified 工作流程简述

假设现在有如下这样一段 markdown 字符串:

# 标题

这是一个**粗体**文本。

行内公式:$E = mc^2$

以我们之前编写的那个 markdown 解析器为例,讲解一下它是怎么解析上面这段 markdown 字符串的

文本字符串转 MDAST

remarkParse 插件会把 markdown 字符串文本解析为抽象语法树:

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "标题"
        }
      ]
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "这是一个"
        },
        {
          "type": "strong",
          "children": [
            {
              "type": "text",
              "value": "粗体"
            }
          ]
        },
        {
          "type": "text",
          "value": "文本。"
        }
      ]
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "行内公式:"
        },
        {
          "type": "inlineMath",
          "value": "E = mc^2"
        }
      ]
    }
  ]
}

功能增强

完成了基础的转换之后,还会继续使用我们注册的一些别的插件对 markdown 解析结果再进行增强:

  1. GFM 扩展 (remarkGfm):增强对表格等语法的支持
  2. 数学公式处理 (remarkMath):专门处理数学公式节点
  3. 转换为 HAST (remarkRehype):将 MDAST 转换为 HTML 抽象语法树

转换后的 HAST 结构类似于:

{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "h1",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "标题"
        }
      ]
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "这是一个"
        },
        {
          "type": "element",
          "tagName": "strong",
          "properties": {},
          "children": [
            {
              "type": "text",
              "value": "粗体"
            }
          ]
        },
        {
          "type": "text",
          "value": "文本。"
        }
      ]
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "行内公式:"
        },
        {
          "type": "element",
          "tagName": "span",
          "properties": {
            "class": "math-inline"
          },
          "children": [
            {
              "type": "text",
              "value": "E = mc^2"
            }
          ]
        }
      ]
    }
  ]
}

对比之前第一步解析得到的 MDAST,可以很明显地看出有如下区别:

  1. HAST 的节点类型是基于 HTML 标签来定义的;MDAST 的节点类型是基于 markdown 语法来定义的(比如 heading/paragraph
  2. HAST 具有 tagName 属性,因为最终 HAST 会转化为 HTML 字符串,需要指定每个节点的标签名是什么才能进行转换;而 MDAST 没有这个属性,其节点字段定义基于 markdown 语法
  3. HAST 具有 properties 字段,存储一些 HTML 节点的属性,比如类名、自定义属性;而 MDAST 则没有属性字段

到这一步,其实思路就已经浮现出来了,细心的你肯定可以发现,这个所谓的 HAST 和 Vue 的虚拟 DOM 有一定的相似之处,其本质都是用对象去模拟真实的 DOM 节点

那我们是不是可以不把 HAST 转化为 HTML 字符串,而是建立一个 HASTVNODE 之间的映射,然后直接把映射得到的 VNODE 交给 Vue 框架自己去渲染,这样的话,我们就可以在映射的过程中,把自定义的 HAST 节点映射为 Vue 的组件来进行渲染了

原始 HTML 和数学公式处理

  1. 原始 HTML 支持 (rehypeRaw):允许保留原始 HTML 标签
  2. 数学公式渲染 (rehypeKatex):用 KaTeX 将数学公式渲染为美观的数学符号

编译为 HTML 字符串

使用  rehypeStringify  将 HAST 编译为最终的 HTML 字符串:

<h1>标题</h1>

<p>
  这是一个
  <strong>粗体</strong>
  文本。
</p>

<p>
  行内公式:
  <span class="math-inline">E = mc^2</span>
</p>

建立 HAST-VNODE 映射

获取 HAST

根据之前的思路,我们需要先去获得字符串解析得到的 HAST,要获得 HAST 得先解析得到前置产物 MDAST,我们可以通过处理器实例的 parse 方法获得 MDAST,然后再 run 方法将 MDAST 转化为 HAST:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'

// 获取HAST的函数
export async function processMarkdown(content: string) {
  const processed = content
    .replace(/\\\(([^]*?)\\\)/g, (_, math) => `$${math}$`)
    .replace(/\\\[([^]*?)\\\]/g, (_, math) => `$$${math}$$`)

  console.log(processed)

  // 创建处理器实例
  const processor = unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkMath)
    .use(remarkRehype)
    .use(rehypeRaw)
    .use(rehypeKatex)

  // 先解析为 MDAST
  const mdast = processor.parse(processed)
  // 再运行转换
  const hast = await processor.run(mdast)
  console.log(JSON.stringify(hast, null, 2))

  return hast
}

rehypeStringify 这个插件可以删掉了,我们后面不需要转化为 HTML 字符串,只需要 HAST

然后这里我们用 processMarkdown 函数去获取之前例子中 markdown 文本的 HAST 结构,看看打印出了什么东西:

{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "h1",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "标题"
        }
      ]
    },
    {
      "type": "text",
      "value": "\n" // 换行符
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "这是一个"
        },
        {
          "type": "element",
          "tagName": "strong",
          "properties": {},
          "children": [
            {
              "type": "text",
              "value": "粗体"
            }
          ]
        },
        {
          "type": "text",
          "value": "文本。"
        }
      ]
    },
    {
      "type": "text",
      "value": "\n" // 换行符
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "行内公式:"
        },
        {
          "type": "element",
          "tagName": "span",
          "properties": {
            "className": ["katex"] // KaTeX公式样式
          },
          "children": [
            // 以下是KaTeX公式的HTML渲染结果
            {
              "type": "element",
              "tagName": "span",
              "properties": {
                "className": ["katex-html"],
                "ariaHidden": "true"
              },
              "children": [
                {
                  "type": "element",
                  "tagName": "span",
                  "properties": {
                    "className": ["base"]
                  },
                  "children": [
                    {
                      "type": "text",
                      "value": "E = mc²" // 最终渲染的公式文本
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

这个省略版的结果再一次验证了之前的思路,HAST 的结果完全符合预期

HAST 到 VNODE 的映射

接下来就只要实现从 HAST 节点到 VNODE 的映射就行了

对于大部分 markdown 解析得到的结果都可以用 HAST 的 element 节点和 text 节点来表示(当然,节点类型不止这么点),这里我们做映射主要考虑的节点类型有:

  • element:表示一个 HTML 元素节点
  • text:表示一个文本节点
  • root:表示一个根节点

HAST 节点的 tagName 属性就是 VNODE 的 tag 属性,而 HAST 节点的 properties 属性就是 VNODE 的 props 属性,所以我们只要把 HAST 节点的 tagNameproperties 属性分别赋给 VNODE 的 tagprops 属性就行了

怎么去创建 VNODE 自然不用多说,直接用 h 函数就行了。接下来我们来完成这个映射:

<script setup lang="ts">
import { h, ref, watch } from 'vue'
import { processMarkdown } from './processor'

const props = defineProps<{
  content: string
}>()

const rootNode = ref<any>(null)

const astToVnode = (ast: any) => {
  if (ast.type === 'text') {
    return ast.value
  }

  if (ast.type === 'element') {
    return h(ast.tagName, ast.properties, ast.children?.map(astToVnode) || [])
  }

  return null
}

watch(
  () => props.content,
  async (newContent) => {
    const ast = await processMarkdown(newContent)
    // 直接创建包含所有子节点的根 div
    rootNode.value = h(
      'div',
      { class: 'markdown-container' },
      ast.children?.map(astToVnode) || [],
    )
  },
  { immediate: true },
)
</script>

<template>
  <component :is="rootNode" />
</template>

<!-- 这里不能加 scoped -->
<style>
/* 省略样式 */
</style>

仍然复用之前 App.vue 的测试代码,发现渲染得到的结果是完全一样的!

阶段性总结

到这里为止,我们已经验证了之前的思路完全可行。相比于直接将 HTML 字符串插入到 DOM 中,使用 VNODE 来渲染有如下优点:

  1. 安全性
  • v-html:存在 XSS 风险,直接插入原始 HTML
  • VNode 渲染:可通过转换过程过滤危险内容,更安全
  1. Vue 集成度
  • v-html:脱离 Vue 响应式系统,无法使用组件
  • VNode 渲染:完全集成在 Vue 中,可插入自定义组件
  1. 自定义能力
  • v-html:只能渲染固定 HTML,难以扩展
  • VNode 渲染:可在转换过程中处理自定义语法和组件
  1. 性能
  • v-html:每次全量更新,无法细粒度控制
  • VNode 渲染:可实现更精细的 diff 和更新策略

实现小圆点的渲染

经过之前的一系列分析,实现小圆点渲染的方案已经呼之欲出了:

  1. 首先,通过某种方式去遍历解析得到的 MDAST 树,查找所有的文本节点

  2. 然后,使用正则表达式对文本进行匹配,检查一下文本中是否存在符合如 [citation:<num>] 这种格式的文本

  3. 如果匹配到了,那么不要将其作为文本节点输出,而是转化为一个我们自定义的 citations 类型节点,并把匹配到的数字作为节点属性存入到 citations 节点中

  4. HAST 中也会存在我们自定义的这个节点,而后我们可以在 HAST 到 VNODE 的映射中,将这个节点直接用 h 函数转化为一个 Vue 组件,最终 Vue 会把这个组件渲染成小圆点

编写自定义插件

首先我们去实现步骤 1、2、3。unist-util-visit 这个库提供了一个 visit 工具函数,它可以让我们很方便地去遍历解析得到的 MDAST 树,并且在遍历的过程中对节点进行增删改,影响最终解析输出的结果

这里直接附上笔者写的一个自定义插件,用于解析小圆点语法:

import { visit } from 'unist-util-visit'

/**
 * 自定义 remark 插件来处理 citation 标记
 */
export const remarkCitation = () => {
  return (tree: any) => {
    visit(tree, 'text', (node: any, index: number | undefined, parent: any) => {
      const citationRegex = /\[\s*citation\s*:\s*(\d+(?:\s*[,,]\s*\d+)*)\s*\]/g
      const matches = [...node.value.matchAll(citationRegex)]

      if (matches.length === 0) return

      const newChildren = []
      let lastIndex = 0

      matches.forEach((match) => {
        const [fullMatch, numsString] = match
        const startIndex = match.index!

        // 添加匹配前的文本
        if (startIndex > lastIndex) {
          newChildren.push({
            type: 'text',
            value: node.value.slice(lastIndex, startIndex),
          })
        }

        // 处理数字部分,支持中英文逗号和空格
        const nums = numsString
          .split(/[,,]\s*/)
          .map((num: string) => num.trim())

        // 添加citations节点
        newChildren.push({
          type: 'citations',
          data: {
            hName: 'citations',
            hProperties: {
              dataNums: nums.join(','),
            },
          },
          children: [{ type: 'text', value: nums.join(',') }],
        })

        lastIndex = startIndex + fullMatch.length
      })

      // 添加剩余文本
      if (lastIndex < node.value.length) {
        newChildren.push({
          type: 'text',
          value: node.value.slice(lastIndex),
        })
      }

      // 替换原节点
      parent.children.splice(index, 1, ...newChildren)
    })
  }
}

export default remarkCitation

这个插件可以实现如下的转化效果:

  • 转化前的原始 markdown 文本
这是内容[citation:1,2]这是后续内容
  • 转化前的 MDAST 树
{
  "type": "paragraph",
  "children": [
    {
      "type": "text",
      "value": "这是内容[citation:1,2]这是后续内容"
    }
  ]
}
  • 转化后的 MDAST 树
[
  { "type": "text", "value": "这是内容" },
  {
    "type": "citations",
    "data": {
      "hName": "citations",
      "hProperties": { "dataNums": "1,2" }
    },
    "children": [{ "type": "text", "value": "1,2" }]
  },
  { "type": "text", "value": "这是后续内容" }
]

插件写好之后,直接导入 processor.ts 中,并且注册插件即可让自定义解析逻辑生效

扩展 HAST-VNODE 映射

现在 HAST 树中已经存在了自定义的 citations 节点,接下来我们需要将这个自定义节点映射成 Vue 组件,才能在渲染出我们想要的小圆点的效果

首先我们完成小圆点组件 CitationList.vue 的编写:

<script setup lang="ts">
interface Props {
  nums: string // 以逗号分隔的数字字符串,如 "1,2,3"
}

const props = defineProps<Props>()
</script>

<template>
  <span class="citation-list">
    <span v-for="num in props.nums.split(',')" :key="num" class="citation">
      {{ num }}
    </span>
  </span>
</template>

<style scoped>
/* 样式省略 */
</style>

然后,在 markdown 渲染组件中,将 citations 节点映射成 CitationList 组件:

<script setup lang="ts">
import { h, ref, watch } from 'vue'
import { processMarkdown } from './processor'
import CitationList from './CitationList.vue'

const props = defineProps<{
  content: string
}>()

const rootNode = ref<any>(null)

const astToVnode = (ast: any) => {
  if (ast.type === 'text') {
    return ast.value
  }

  if (ast.type === 'element') {
    // 处理自定义的 citations 节点
    if (ast.tagName === 'citations') {
      console.log(ast)
      return h(CitationList, {
        nums: ast.properties?.dataNums || '',
      })
    }

    // 处理普通的 HTML 节点
    return h(ast.tagName, ast.properties, ast.children?.map(astToVnode) || [])
  }

  return null
}

watch(
  () => props.content,
  async (newContent) => {
    const ast = await processMarkdown(newContent)
    // 直接创建包含所有子节点的根 div
    rootNode.value = h(
      'div',
      { class: 'markdown-container' },
      ast.children?.map(astToVnode) || [],
    )
  },
  { immediate: true },
)
</script>

<template>
  <component :is="rootNode" />
</template>

<!-- 这里不能加 scoped -->
<style>
/* 样式省略 */
</style>

修改 App.vue 中的测试代码,加上测试文本文本:

这是内容[citation:1]这是后续内容

测试效果如下图:

image.png

可以看到,小圆点非常完美地渲染出来了!这个小圆点就是一个正常 Vue 组件,具备所有 Vue 组件的特性,比如数据绑定、事件处理、生命周期钩子等等

拓展——实现悬浮卡片的思路

这个悬浮卡片其实就是类似于各大组件库中的 Tooltip 组件,我们只需要扩展一下 CitationList 组件,当鼠标悬浮在小圆点上的时候就显示 Tooltip 组件即可

现在最关键的问题是,悬浮卡片上展示的内容是哪里来的?

大模型的联网搜索,其实本质就是先根据用户的问题调用搜索引擎接口,然后把搜索结果在上下文中带给模型

我们可以为模型添加一个 prompt,让模型根据联网搜索的结果来生成回复,并且如果有引用联网搜索的内容的话,那么就要以 [citation:<num>] 这种格式来标注出引用的网页的编号,而搜索引擎接口返回的是一个对象数组,大致如下:

[
  {
    "name": "2024年华科专业按分排名-专业填报-高考资讯网",
    "url": "http://www.gkzxw.com/major/1567890.html",
    "snippet": "录取分数线 2024年华中科技大学在湖北省物理类考生中的录取分数线为635分~658分。 以上信息根据最近的数据更新整理而来,具体排名可能会根据每年的实际情况有所变动。 以上内容仅供参考,部分文章是来自自",
    "siteName": "高考资讯网",
    "siteIcon": "https://th.bochaai.com/favicon?domain_url=http://www.gkzxw.com/major/1567890.html",
    "time": "y年M月d日"
  },
  {
    "name": "华科录取专业线-高校招生问答平台",
    "url": "http://www.zsask.com/university/2134567.html",
    "snippet": "问华科录取专业线 2024-12-01 15:30:22 156次 问题描述: 华科录取专业线希望能解答下璀璨的莫过于事业 原来就是每天吃饭的筷子,每天睡觉的那张床 2024-12-01 15:30:",
    "siteName": "高校招生问答平台",
    "siteIcon": "https://th.bochaai.com/favicon?domain_url=http://www.zsask.com/university/2134567.html",
    "time": "y年M月d日"
  }
]

联网搜索的结果会带给前端,小圆点里的数字其实就是上面这个数组的索引(引用了第几个搜索结果)

我们可以将上面这个搜索结果传入 MarkdownRenderer 中,再透传到 CitationList 组件中,这样就可以根据索引获取对应的搜索结果,并展示在悬浮卡片中了

具体实现代码在这里省略,感兴趣的笔者可以自行尝试实现

总结

本文详细介绍了如何在 Vue3 项目中通过自定义 markdown 解析规则来实现特殊语法(如 [citation:<num>])的解析,并将其渲染为 Vue 组件

基于 HAST-VNODE 映射的原理,我们可以自定义各种特殊语法,并且渲染成复杂的 Vue 组件,诸如 echarts 图标、mermaid 流程图等

新人第一次创作,如果本文有任何错误,欢迎各位大佬在评论区批评指正🌹

前端fetch手动解析SSE消息体,字符串双引号去除不掉的问题定位

作者 angelQ
2025年10月18日 16:07

本文共五部分,问题出现场景、定位问题、思考原因、问题解决、总结。 定位到JSON规范问题。

1. 问题出现场景

前后端联调sse消息体数据时,发现前端收到的数据,双引号去除不掉。如下图所示:

1.PNG

后端nodejs,消息体写入如下代码所示:

//后端stream写入
for await (const chunk of stream) {
      // todo finish_reason: chunk.choices[0].finish_reason,??
      if (chunk.choices[0]?.delta?.content) {
        res.write(`data: ${JSON.stringify(chunk.choices[0]?.delta?.content)}\n\n`)
      }
    }

前端vue,fetch接收消息体,手动解析如下代码所示:

const chunk = decoder.decode(value)
// 手动解析SSE格式
const lines = chunk.split('\n\n')
let data = ''
lines.forEach((line) => {
  if (line.startsWith('data:')) {
    data += line.replace('data:', '').trim()
  }
})
options.onMessage(data)

网络接收数据如下:

捕获.PNG

2. 定位问题

发现是前端接收数据时,双引号的问题,即使手动正则匹配去除双引号,双引号依然存在,【双引号变为一个引号】。

data += line.replace('data:', '').replace(/^"|"$/g, '').trim()

2.PNG

即使修改正则表达式,问题依然存在,双引号去不掉。

data += line.replace('data:', '').replace(/^"(.*)"$/, '$1').trim()

寻找双引号依然存在的原因,将后端的传入代码进行修改,将JSON.stringify修改为String。:

 res.write(`data: ${String(chunk.choices[0]?.delta?.content)}\n\n`)

发现问题解决。

3. 思考原因

后端传入时,使用JSON.stringify(),前端接收未使用JSON.parse()解析。

为什么 JSON.stringify 会产生双引号?

JSON 规范要求。

JSON 规范要求所有字符串必须用双引号包围,这是 JSON 格式的标准。

// JSON 标准格式
const jsonString = JSON.stringify("作文");
console.log(jsonString); // 输出: "\"作文\""
// 实际存储: ""作文"" (但显示时会转义)

4. 问题解决

后端依然使用JSON.stringify(), 为了http消息体保持字符串传输。前端使用JSON.parse()进行转译,因为 JSON.parse 已经去掉了外层的双引号

 data += JSON.parse(line.replace('data:', ''))

发现问题解决,格式正常显示。

3.PNG

5. 总结

  • JSON.stringify 遵循 JSON 规范,会给字符串添加双引号
  • String()  只是简单的类型转换,不会添加额外格式
  • 最佳实践是在服务器端返回结构化数据,避免直接字符串化简单字符串
  • 如果必须处理带双引号的字符串,使用清理方法,即在JSON.parse()传入第二个参数,使用过滤函数进行处理。

等前后端完成完整的sse逻辑,再写一篇完整的代码,发现网上的代码不全,且发送、接收时代码需要注意很多格式问题。

react组件常见的性能优化

作者 东华帝君
2025年10月18日 16:06

React 组件的性能优化是构建流畅用户体验的关键。下面我为你梳理了常见的优化方法、适用场景及核心原理,并附上一个总结表格,方便你快速概览。下表汇总了主要的优化策略及其核心目标:

优化策略 核心目标 典型方法或工具
减少渲染量 减少需要渲染的组件或 DOM 节点数量 组件懒加载、虚拟列表、条件渲染
减少渲染次数 避免不必要的组件重新渲染 React.memoPureComponentuseCallbackuseMemo
提升渲染效率 降低单次渲染的耗时和复杂度 使用不可变数据、简化组件逻辑、优化状态结构

🔧 核心优化方法详解

1. 使用 React.memo 和 PureComponent 避免重复渲染

这是最直接有效的优化手段之一。

  • React.memo (用于函数组件):对组件 props 进行浅比较,仅在 props 发生变化时重新渲染。

    import { memo } from 'react';
    
    const MyExpensiveComponent = memo(function MyExpensiveComponent({ data }) {
      // 组件逻辑
      return <div>{/* 渲染内容 */}</div>;
    });
    
  • PureComponent (用于类组件):通过浅比较 props 和 state 来自动判断是否需要重新渲染。

2. 使用 useCallback 和 useMemo 缓存记忆化

用于缓存那些在多次渲染间需要保持稳定的函数或计算结果。

  • useCallback:缓存函数,避免因函数引用变化导致子组件不必要的重渲染。

    import { useCallback, useState } from 'react';
    
    function ParentComponent() {
      const [count, setCount] = useState(0);
      // 使用 useCallback 缓存函数
      const handleClick = useCallback(() => {
        setCount(c => c + 1);
      }, []); // 依赖数组为空,表示该函数不会重建
    
      return <ChildComponent onClick={handleClick} />;
    }
    
  • useMemo:缓存计算结果,避免每次渲染都进行复杂的计算。

    import { useMemo } from 'react';
    
    function ExpensiveCalculationComponent({ items }) {
      const computedValue = useMemo(() => {
        return items.reduce((acc, item) => {
          // 复杂的计算逻辑
          return acc + item.value;
        }, 0);
      }, [items]); // 当 items 变化时重新计算
    
      return <div>{computedValue}</div>;
    }
    

3. 代码分割与懒加载 (Code Splitting & Lazy Loading)

通过动态导入(dynamic imports)将代码分割成不同的块(chunks),按需加载,显著降低应用初始加载体积。

import { lazy, Suspense } from 'react';

// 使用 React.lazy 进行动态导入
const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  return (
    <div>
      {/* 使用 Suspense 提供加载中的回退界面 */}
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}

4. 虚拟列表 (Virtualization)

当渲染超长列表时,虚拟列表技术可以极大提升性能。它只渲染当前可视区域(viewport)内的列表项,而不是整个列表。

import { FixedSizeList as List } from 'react-window';

const Row = ({ index, style }) => (
  <div style={style}>Row {index}</div>
);

const VirtualizedList = () => (
  <List
    height={400} // 列表容器高度
    itemCount={10000} // 列表项总数
    itemSize={35} // 每个列表项的高度
    width={600} // 列表容器宽度
  >
    {Row}
  </List>
);

5. 优化事件处理与避免内联对象

在 JSX 中直接定义函数或对象,会导致每次渲染都创建一个新的引用,可能引发子组件不必要的重渲染。

  • 优化前

    // 不推荐:内联函数和内联对象
    <MyComponent 
      onClick={() => { /* 处理逻辑 */ }}
      style={{ color: 'red' }}
    />
    
  • 优化后

    // 推荐:使用 useCallback 和 useMemo/外部定义
    const handleClick = useCallback(() => { /* 处理逻辑 */ }, []);
    const style = useMemo(() => ({ color: 'red' }), []);
    
    <MyComponent onClick={handleClick} style={style} />
    

6. 优化列表的 Key 属性

为列表项提供稳定、唯一的 key属性,帮助 React 更准确地识别哪些项发生了变化、被添加或移除,从而高效地更新 DOM。避免使用数组索引作为 key,尤其是在列表会发生重排序的情况下。

// 推荐:使用唯一ID
{items.map(item => (
  <li key={item.id}>{item.name}</li>
))}

正则表达式捕获组与全局匹配

2025年10月18日 16:02

正则表达式捕获组与全局匹配

在 JavaScript 中,正则表达式提供了强大的字符串匹配和提取功能,特别是通过捕获组全局匹配,你可以提取字符串中符合特定模式的内容。本文将介绍如何使用这些功能进行有效的字符串处理。

1. 捕获组 (())

捕获组可以帮助你从正则表达式匹配的字符串中提取出某些特定部分。通过在正则表达式中使用圆括号 (),你可以将某些字符部分单独提取出来,以便进一步处理。

示例1:提取字符串中的匹配部分

let str = 'ajbjjakbdsk1323441';
let reg = /a(([a-z])b)/ig; // i:忽略大小写,g:全局匹配,() 捕获组
let result = reg.exec(str);

while (result) {
  console.log(result[0], result[1]);  // result[0] 是整个匹配项,result[1] 是捕获的部分
  result = reg.exec(str);  // 执行下一次匹配
}

输出:

ajb ajb
ajb ajb
  • result[0]:表示匹配的整个字符串。
  • result[1]:表示第一个捕获组 (([a-z])b) 中提取的内容,即 ajb

2. 全局匹配 (g 标志)

g 标志用于进行全局匹配,这意味着正则表达式会匹配输入字符串中的所有符合条件的部分,而不仅仅是第一个。

在上面的示例中,正则表达式使用了 g 标志,因此会返回所有匹配的部分。每次调用 exec() 方法都会返回下一个匹配项,直到没有更多匹配。

3. 匹配特定模式

你可以根据需要编写适当的正则表达式来匹配特定的模式。比如,如果你想提取符合 axc 格式的内容,可以按以下方式编写正则表达式:

let str = "abcaccafcacc";
let reg = /a(([a-z])c)/ig; // 匹配 'axc' 格式,捕获其中的字符
let result = reg.exec(str);

while (result) {
  console.log(result[0], result[1], result[2]);  // 输出完整匹配项和捕获的部分
  result = reg.exec(str);
}

输出:

abc abc a
acc acc c

在这个例子中:

  • result[0]:是整个匹配的字符串。
  • result[1]:是捕获组中的内容,ac
  • result[2]:是捕获组的第二部分,即 ac

4. 总结与优化

  • 捕获组:使用圆括号 () 包裹你想要提取的部分。你可以提取多个部分,每个部分会以 result[1], result[2], ... 的方式存储。
  • 全局匹配g 标志使得正则表达式在字符串中查找所有符合条件的匹配项。
  • 执行多次匹配:通过 exec() 方法可以循环查找多个匹配项,并通过 result 获取详细的捕获内容。

5. 实际应用

这种正则表达式的用法非常适合用于:

  • 数据提取:从一段文本中提取符合某个模式的数据,如邮箱、电话号码等。
  • 文本分析:对文本进行分析,提取特定的关键词或格式。
  • 字符串验证:使用正则表达式验证字符串是否符合某种格式。

掌握这些正则表达式的技巧,你将在字符串处理方面变得更加高效!

JavaScript 解构赋值详解,一文通其意。

2025年10月18日 15:51

JavaScript 解构赋值详解

值是 JavaScript 中一种非常强大的语法,能够轻松地从数组或对象中提取值并将其赋给变量。它使得代码更加简洁,尤其在处理复杂的数据结构时,能显著提高代码的可读性和效率。

数组解构赋值。

解构赋

1️ 基本的数组解构

const num1 = [1, 3, 5, 7];
let [a, b, c, d] = num1;  // 声明同时解构  
console.log(a, b, c, d); // 输出: 1 3 5 7
  • 解释:通过数组的顺序解构,将数组中的元素按顺序赋值给变量。

2️ 解构赋值与默认值

const num2 = ['xixi', 'linxi', 'xiaoxixi'];
let e, f, g;
[e, f, g] = num2;  // 赋值给 e, f, g
console.log(e, f, g); // 输出: xixi linxi xiaoxixi

[e, f, g = 'default'] = ['yueye', '5418y']; // 为 g 设置默认值
console.log(e, f, g); // 输出: yueye 5418y default
  • 解释:当解构的变量缺少对应的值时,可以通过给变量设置默认值,防止 undefined 值。

###3️⃣ 使用剩余运算符获取多余的值

const num2 = ['xixi', 'linxi', 'xiaoxixi'];
let h, k;
[h, ...k] = num2;
console.log(h, k); // 输出: xixi ['linxi', 'xiaoxixi']
  • 解释:使用剩余运算符 ... 将其余的值分配到一个新数组中,常用于提取数组前几个元素。

4️ 交换变量的值

let x = 10;
let y = 20;
console.log(x, y); // 输出: 10 20
[x, y] = [y, x];  // 快速交换两个变量的值
console.log(x, y); // 输出: 20 10
  • 解释:通过解构赋值,快速交换两个变量的值,不需要额外的中间变量。

5️ 二维数组解构赋值

const arr3 = [['linxi', '18', 'female'], ['xixi', '17', 'female']];
let [[name, age, gender], obj] = arr3;
console.log(name, age, gender, obj); // 输出: linxi 18 female ['xixi', '17', 'female']

// 循环遍历二维数组
for (let value1 of arr3) {
  console.log(value1);
  for (let value2 of value1) {
    console.log(value2);
  }
}
  • 解释:数组中的元素如果还是数组,可以继续用解构赋值提取内部数据。

对象解构赋值

1️ 基本的对象解构

const person = { name: 'linxi', age: 18, gender: 'female' };
let { name, age, gender } = person;
console.log(name, age, gender); // 输出: linxi 18 female
  • 解释:直接通过对象的属性名进行解构,自动将属性值赋给同名的变量。

2️ 对象属性名与变量名不一致时的解构

const person1 = { name1: 'lin', age1: 18, gender1: 'female' };
let name1, age1, gender1;
({ name1, age1, gender1 } = person1); // 使用圆括号包裹解构赋值
console.log(name1, age1, gender1); // 输出: lin 18 female
  • 解释:当对象的属性名与变量名不一致时,必须使用圆括号包裹解构,否则会报错。

3️ 为解构的属性赋予别名

let { name: a, age: b, gender: c } = person; // 为 name, age, gender 赋予别名
console.log(a, b, c); // 输出: linxi 18 female
  • 解释:通过 : 为属性赋予别名,将对象的属性值赋给新的变量名。

解构赋值的注意事项

  1. 对象解构时,属性名必须匹配
    如果解构对象时,变量名和对象属性名不一致,会导致 undefined
  2. 解构的顺序要与数组的顺序匹配
    数组解构时,变量会按顺序获取数组中的元素,顺序不匹配会导致 undefined
  3. 默认值的使用
    对于 undefined 的值,可以通过默认值来避免报错或返回 undefined
  4. 结合剩余运算符处理不定长数组
    在解构数组时,使用剩余运算符可以收集其余的元素,灵活应对不定长的数组。

总结

解构赋值是 JavaScript 中的一项强大特性,能够通过简洁的语法对数组和对象进行赋值操作。无论是交换变量值、处理多维数组,还是给属性赋予别名,解构赋值都能提供极大的便利。通过合理使用解构赋值,可以使代码更加清晰、简洁,并减少冗余。

浅谈React19的破坏性更新

2025年10月18日 15:35

2024年12月,React 19 正式发布,至今已过去大半年。尽管目前多数项目仍在使用 React 18,但我们可以通过官方文档和 GitHub 了解其带来的关键新特性和破坏性变更。以下是我总结的一些重要更新。

React相关文档也可参考这个地址:传送门

1、Optimistic (直译为:乐观)

React19新加入了一个概念,叫做乐观。对应的API为useOptimistic。乐观更新是一种 UI 设计模式,其核心思想是:

“先相信操作会成功,提前更新 UI;如果失败了,再回滚到之前的状态。 这与传统的“悲观更新”(先等待服务器响应,成功后再更新 UI)形成对比。

为什么需要乐观更新?
  • 提升用户体验:用户点击按钮后,UI 立即响应,无需等待网络延迟。
  • 感觉更快:即使网络慢,用户也能看到操作“已生效”,减少等待焦虑。
  • 现代应用的标准做法:如社交媒体点赞、评论删除等,都采用此模式。

示例

import { useOptimistic, useState } from "react";
import { postComment as apiPostComment } from "../utils";

const apiPostComment = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('comment success')
    }, 1000)
  })
}

// 评论项组件
function CommentItem({ comment }) {
  return (
    <div className="border p-3 mb-2 rounded bg-white shadow-sm">
      <p className="text-gray-800">{comment.text}</p>
      {comment.isPending && (
        <small className="text-blue-500 mt-1 block">正在提交...</small>
      )}
    </div>
  );
}

// 评论区域组件
function CommentSection({ commentList, onAddComment }) {
  const [pendingCommentId, setPendingCommentId] = useState(0);

  // 乐观更新:立即显示待定评论
  const [displayedComments, addPendingComment] = useOptimistic(
    commentList,
    (state, newCommentText) => {
      const id = `pending-${pendingCommentId}`;
      return [
        ...state,
        {
          id,
          text: newCommentText,
          isPending: true,
        },
      ];
    }
  );

  // 表单提交处理器
  async function handleAddComment(formData) {
    const content = formData.get("commentContent");
    if (!content?.trim()) return;

    // 生成本次提交的临时 ID
    const currentPendingId = pendingCommentId;
    setPendingCommentId((prev) => prev + 1);

    // 1. 立即乐观更新 UI
    addPendingComment(content);

    try {
      // 2. 发起真实请求
      const result = await onAddComment(formData, `pending-${currentPendingId}`);

      if (result.error) {
        throw new Error(result.error.message);
      }
    } catch (error) {
      console.error("评论提交失败:", error.message);
      // 注意:useOptimistic 不会自动回滚,需配合其他状态管理
    }
  }

  return (
    <div className="comment-container">
      {/* 渲染当前显示的评论(含乐观更新) */}
      {displayedComments.length === 0 ? (
        <p className="text-gray-500 italic">暂无评论</p>
      ) : (
        displayedComments.map((comment) => (
          <CommentItem key={comment.id} comment={comment} />
        ))
      )}

      {/* 添加评论表单 */}
      <form onSubmit={handleAddComment} className="mt-4 space-y-3">
        <textarea
          name="commentContent"
          placeholder="写下你的评论..."
          rows="3"
          className="w-full border rounded p-2 focus:outline-none focus:ring-2 focus:ring-blue-300"
          autoComplete="off"
        />
        <button
          type="submit"
          className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded transition"
        >
          发布评论
        </button>
      </form>
    </div>
  );
}

// 页面组件:评论功能演示
export default function OptimisticCommentDemo() {
  const [errorMessage, setErrorMessage] = useState("");
  const [comments, setComments] = useState([
    { id: "initial-1", text: "第一条评论!", isPending: false },
  ]);

  // 处理真实评论提交
  async function handleCommentSubmit(formData, pendingId) {
    const content = formData.get("commentContent");

    // 模拟 API 调用
    const response = await apiPostComment({ content });

    if (response.error) {
      setErrorMessage(`"${content}" 提交失败:${response.error.message}`);
      return response;
    } else {
      setErrorMessage("");
      // ✅ 成功后:添加真实评论(不带 isPending)
      setComments((prev) => [
        ...prev,
        {
          id: `comment-${Date.now()}`,
          text: response.data.text,
          isPending: false,
        },
      ]);
    }

    return response;
  }

  return (
    <div className="p-6 max-w-2xl mx-auto">
      <h2 className="text-2xl font-bold text-gray-800 mb-4">
        乐观更新评论系统演示
      </h2>

      <CommentSection commentList={comments} onAddComment={handleCommentSubmit} />

      {errorMessage && (
        <p className="text-red-600 mt-4 text-sm bg-red-50 p-3 rounded">
          {errorMessage}
        </p>
      )}
    </div>
  );
}

2、use (支持异步)

1、use可读取promise

示例:

import { useState, use, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

// 模拟异步获取天气数据
function fetchWeatherData(city) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 50% 概率失败,增加错误边界演示效果
      if (Math.random() < 0.5) {
        reject(new Error("天气服务暂时不可用"));
      } else {
        resolve({
          city,
          temperature: Math.round(Math.random() * 30),
          condition: ["晴", "多云", "小雨", "雷阵雨"][Math.floor(Math.random() * 4)],
          lastUpdated: new Date().toLocaleTimeString(),
        });
      }
    }, 1200);
  });
}

export default function WeatherDashboard() {
  const [weatherQuery, setWeatherQuery] = useState(null);
  const [isFetching, setFetching] = useState(false);

  const handleFetchWeather = () => {
    setWeatherQuery(fetchWeatherData("杭州"));
    setFetching(true);
  };

  if (!isFetching) {
    return (
      <div className="text-center">
        <h2 className="text-xl font-semibold mb-4">🌤️ 天气信息看板</h2>
        <button
          onClick={handleFetchWeather}
          className="bg-green-500 hover:bg-green-600 text-white px-5 py-2 rounded transition"
        >
          获取杭州天气
        </button>
      </div>
    );
  }

  return <WeatherDisplay weatherPromise={weatherQuery} />;
}

// 展示天气数据的容器(含错误和加载状态)
function WeatherDisplay({ weatherPromise }) {
  console.log(
    "%c [ WeatherDisplay ]-38",
    "font-size:13px; background:#4B5563; color:#fff; padding:2px 6px;",
    "渲染 WeatherDisplay"
  );

  return (
    <div className="max-w-md mx-auto p-4 border rounded-lg shadow bg-white">
      <h3 className="text-lg font-medium text-gray-800 mb-3">🌤️ 实时天气</h3>

      <ErrorBoundary fallback={<WeatherError />}>
        <Suspense fallback={<WeatherLoading />}>
          <WeatherCard dataPromise={weatherPromise} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

// 加载中状态
function WeatherLoading() {
  return (
    <div className="text-center text-gray-500 animate-pulse">
      <p>📡 正在连接天气服务器...</p>
      <p className="text-sm mt-1">请稍候</p>
    </div>
  );
}

// 错误状态
function WeatherError() {
  return (
    <div className="text-center text-red-600">
      <p>❌ 获取天气失败</p>
      <p className="text-sm mt-1">请检查网络或稍后重试</p>
    </div>
  );
}

// 实际渲染天气数据(使用 use)
function WeatherCard({ dataPromise }) {
  const weather = use(dataPromise);

  return (
    <div>
      <p><strong>城市:</strong>{weather.city}</p>
      <p><strong>温度:</strong>{weather.temperature}°C</p>
      <p><strong>天气:</strong>{weather.condition}</p>
      <p className="text-xs text-gray-500 mt-2">
        更新时间:{weather.lastUpdated}
      </p>
    </div>
  );
}

2、use可读取context

示例:

import {use} from 'react';
import ThemeContext from './ThemeContext'

function Heading({children}) {
  if (children == null) {
    return null;
  }
  
  const theme = use(ThemeContext);
  return (
    <h1 style={{color: theme.color}}>
      {children}
    </h1>
  );

3、Ref 支持在 props 中转发使用

从19开始,ref可以作为prop在函数组件中使用了。之前函数组件中想要使用ref,必须使用forwardRef。这表示着从React19开始,forwardRef可能要被弃用了。会对一些三方组件库有比较大的影响,因为基本上每个组件都会用到ref,之前的写法都是使用的forwardRef

1、React19给ref加上了自己的清理函数,之前可以用useEffect清理,现在ref有自己的清理函数了。

示例:

<div
  ref={(ref) => {
    // 当元素从DOM中移除时的引用。
    return () => {
      // ref 清理函数
    };
  }}
/>

4、Context 改动

之前使用 Context 基本就是三步走:创建Context;Provider传递value;后代组件消费value。React19中简化了Context.Provider,可以直接使用Context代替Provider:

示例:

const ThemeContext = createContext('');

function App({children}) {
  return (
    <ThemeContext value="dark">
      {children}
    </ThemeContext>
  );  
}

5、其他改动

1、支持文档元数据

示例:

export default function Demo ({ props }) {
  return (
    <section>
      <title>{props.title}</title>
      <meta name="name" content="content" />
      <Link rel="test" href="xxx" />
      <meta name="keywords" content={props.keywords} />
      <p>测试内容...</p>
    </section>
  )
}

2、支持样式表

export default function Demo ({ props }) {
  return (
    <section>
      <link rel="stylesheet" href="xxx" />
      <p>测试内容...</p>
    </section>
  )
}

3、支持异步脚本

export default function Demo ({ props }) {
  return (
    <section>
      <script async={true} src="xxx" />
      <p>测试内容...</p>
    </section>
  )
}

6、支持自定义元素

在之前的版本中,React会把不认识的props当做attributes来处理,在React19中,此行为改为与自定义元素实例上的属性匹配的 props 被分配为 properties,其他的被分配为attributes。

iOS 基于Vision.framework从图片中提取文字

2025年10月18日 14:15

基于Vision.framework从图片中提取文字 苹果在iOS 11中引入的Vision框架为OCR提供了基础能力,其核心组件VNRecognizeTextRequest可实现高效文字检测与识别。结合VisionKit中的DocumentCameraViewController,可快速构建扫描界面,支持自动裁剪、透视校正等预处理功能。

技术优势

  • 硬件加速:利用神经网络引擎(Neural Engine)实现低功耗、高帧率识别
  • 隐私保护:所有计算在设备端完成,无需上传至云端
  • 系统级优化:与iOS相机、相册系统深度集成
#import <Foundation/Foundation.h>
#import <Vision/Vision.h>

NS_ASSUME_NONNULL_BEGIN

API_AVAILABLE(ios(11.0))
typedef void(^SBVisionTextCallBack)(NSError *error, NSArray<__kindof VNObservation*>* results);


API_AVAILABLE(ios(11.0))

@interface SBVisionText : NSObject

@property (nonatomic,copy)SBVisionTextCallBack resultBlock;

+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock;

@end


#import "SBVisionText.h"

@implementation SBVisionText

+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock{

    if (@available(iOS 13.0, *)) {

        VNRecognizeTextRequest *textRequest = [[VNRecognizeTextRequest alloc] initWithCompletionHandler:^(VNRequest * _Nonnull request, NSError * _Nullable error){

            NSArray *observations = request.results;

            //        [self textRectangles:observations image:image complete:complete];

            NSLog(@"sb_vision_text_image:%@",observations);

            if (resultBlock) {
                resultBlock(error,request.results);
            }
        }];
        
        textRequest.recognitionLevel = VNRequestTextRecognitionLevelAccurate;
        textRequest.usesLanguageCorrection = NO;
        textRequest.recognitionLanguages = @[@"zh-Hans", @"en-US"];

        // 转换CIImage
        CIImage *convertImage = [[CIImage alloc]initWithImage:img];

        // 创建处理requestHandler

        VNImageRequestHandler *detectRequestHandler = [[VNImageRequestHandler alloc]initWithCIImage:convertImage options:@{}];

        // 发送识别请求
        [detectRequestHandler performRequests:@[textRequest] error:nil];

    } else {
        // Fallback on earlier versions
        NSLog(@"Fallback on earlier versions");
    }
}

@end

方法调用

#import "SBVisionTextViewController.h"
#import "SBVisionText.h"


@implementation SBVisionTextViewController
- (void)viewDidLoad {
    [super viewDidLoad];
}

- (IBAction)getText:(UIButton *)sender {
    [self getTextFormImage:[UIImage imageNamed:@"1681888102373.jpg"]];
}

-(void)getTextFormImage:(UIImage *)img{
    if (@available(iOS 11.0, *)) {
        [SBVisionText sb_vision_text_image:img result:^(NSError * _Nonnull error, NSArray<__kindof VNObservation *> * _Nonnull results) {

            if (@available(iOS 13.0, *)) {
                for (VNRecognizedTextObservation *observation in results) {
                    NSLog(@"%@", [observation topCandidates:1].firstObject.string);
                }
            } else {
                NSLog(@"Fallback on earlier versions");
            }
        }];

    } else {
        NSLog(@"Fallback on earlier versions");
    }
    return;
}

@end

PaddleOCR-VL,超强文字识别能力,PDF的拯救者

作者 战场小包
2025年10月17日 16:34

转眼间已经是 2025 年的 Q4 了,年终越来越近,领导给予的 okr 压力越来越大,前段时间,领导提出了一个非常搞的想法,当然也是急需解决的痛点——线上一键翻译功能。

小包当前负责是开发开发面向全球各国的活动,因此活动中不免就会出现各种各样的语言,此时就出现了一个困扰已久的难题,线上体验的同学看不懂,体验过程重重受阻,很容易遗漏掉一些环节,导致一些问题很难暴露出来。

为了这个问题,小包跟进了一段时间了,主要有两个地方的文案来源

  • 代码渲染的文本
  • 切图中的静态文本

大多数文本来源于是切图中,因此如何应对各种各样的切图成为难题。由此小包提出了两种解决方案:

  1. 同时保存两种图片资源,分别为中文和当前区服语言

  2. 直接进行图片翻译

第一种方案被直接拒绝了,主要由于当前的技术架构和同事们的一些抵触,业务中使用的 img、txt 信息都存储在配置平台中,存储两份就需要维护两类配置,严重增加了心智负担。

那我是这么思考的,第一次上传图片资源时,自动进行图片翻译,存储在另一个配置字段中,当开启一键翻译功能后,切换翻译后的图片。

由于是内部使用的工具,因此不需要非常准确,为了节省 token,只在第一次进行翻译。

图片翻译需要两个过程,首先进行 OCR,识别出图片中的文字;其次对识别出的文字进行翻译。

尝试了好几款 OCR 工具,都有些不尽人意,整个过程中,体验最好的是上个月PaddleOCR推出的PP-OCRv5。

在一段时间内,都一直盯着 PaddleOCR 的最新进度,昨天,百度发布并开源自研多模态文档解析模型 PaddleOCR-VL,该模型在最新 OmniDocBench V1.5 榜单中,综合性能全球第一,四项核心能力SOTA,模型已登顶HF全球第一。

这么说我的 OKR 有救了啊,快马加鞭的来试一下。

对于线上翻译,有两种指标是必须要达到的

  • 文字区域识别的准确性
  • 支持语言的多样性

下面逐一地体验一下

OKR 需求测试

先随便找了一张较为简单的韩服的设计稿,识别效果见右图,识别的区域非常准确,精准的区分开文字区域和图像区域。

右侧有三个 tab,其中第一个 tab:Markdown Preview 预览还支持翻译功能,翻译的文案也是非常准确的

激动了啊,感觉 PaddleOCR-VL 自己就可以解决当前的需求啊。

再换一种比较复杂的语言,阿拉伯语。支持效果也是出奇的好啊,阿语活动开发过程和体验过程是最难受的啊,目前也是最严重的卡点

对于阿语的翻译的效果也非常好,这点太惊喜了,阿服的字体又细又长,字间距又窄,能做到这么好的识别真是让人惊艳

经过一番简单的测试,PaddleOCR-VL 完全可以应对领导的 OKR 要求了(毕竟天下第一难语言阿服都可以较为完美的应对,撒花),爽啊!只需要把 demo 跑出来,就可以去申请经费啦。

更多测试

作为一个程序员,除了要干好本职的工作,更要积极的探索啊,多来几个场景,倒要看看 PaddleOCR VL 能做到什么程度。

糊图识别

日常中经常有这种需求,领导给了一张扫描了一万次或者扫描的一点都不清楚的图片,阅读难度甚大,那时候就想能不能有一种方案直接把内容提取出来。

例如就像下面的糊糊的作文,连划去的内容都成功提取出来了,牛

元素级识别

PaddleOCR-VL 除了文档解析能力,还提供了元素级识别能力,例如公式识别、表格内容识别等,诸如此类都是图片识别中的超难点。

先来个简单公式试一下

效果这么好的吗,全对了,那就要上难度了啊

黑板中的公式繁杂,混乱,且是手写体,没想到识别的整体内容都是非常准确的,只有最后一个公式错误的乘在一起了,效果有些令人惊叹啊。

总结

PaddleOCR-VL 效果真是非常惊艳啊,年底的 okr 实现的信心大增。

PaddleOCR-VL 文字识别感觉像戴了高精度眼镜一般,后续遇到类似的文字识别需求,可以首选 PaddleOCR-VL 啊。

此外小小看了一下论文,PaddleOCR-VL 采用创新的两阶段架构:第一阶段由 PP-DocLayoutV2 模型负责版面检测与阅读顺序预测;第二阶段由 PaddleOCR-VL-0.9B 识别并结构化输出文字、表格、公式、图表等元素。相较端到端方案,能够在复杂版面中更稳定、更高效,有效避免多模态模型常见的幻觉与错位问题。

PaddleOCR-VL在性能、成本和落地性上实现最佳平衡,具备强实用价值。后续遇到文字识别的需求,PaddleOCR-VL 是当之无愧的首选。

体验链接:

Pixelium Design:Vue3 的像素风 UI 组件库

2025年10月17日 15:41

👾 Pixelium Design:Vue3 的像素风 UI 组件库

Pixelium Design 是一个基于 Vue 3 的像素风 UI 组件库。我们从早期像素游戏的经典美学中汲取灵感,为现代 Web 应用带来复古风格的界面和体验。该项目的初衷是为开发者提供一系列可复用、高效能的像素风组件,让项目中轻松实现独特的视觉风格。

如果你对这个项目感兴趣,欢迎点个 Star⭐️。Github:github.com/shika-works…

image.png

👾 为什么发起这个项目

我是一名像素游戏爱好者,被早期游戏独特的像素风格和艺术表现深深吸引。Pixelium Design 的灵感来源于我们团队(虽然只有我一个人)对像素风格游戏的热爱,例如《宝可梦 红/蓝/绿宝石》《星露谷物语》以及开罗游戏系列。这些游戏的视觉风格简洁而富有表现力,目前前端领域像素风格 UI 组件库较少,我们希望将这种经典美学引入现代 Web 开发中。

我们的目标是提供一套完整的像素风格 Vue 3 组件,让开发者能够便捷地在项目中实现一致的像素化界面。通过封装常用的 UI 元素和交互模式,降低实现特定视觉风格的技术成本。

🤔 现有方案的不足与我们的思考

NES.css 的局限性

在 Pixelium Design 之前,已有如 NES.css 这样的项目,但它们通常只提供基础的 CSS 样式,缺乏功能完备的交互组件。开发者仍需编写大量 HTML 和 JavaScript 来实现交互,不仅增加了开发负担,也提高了后期维护的复杂度。 此外,这类现有方案的样式较为固定,难以灵活调整,无法充分满足多样化的项目需求。例如,颜色、尺寸等视觉属性往往受限于预设样式,限制了设计的个性表达。

Pixelium Design 的解决方案

为弥补现有方案的不足,Pixelium Design 采用现代前端技术栈,基于 Vue 3 搭建项目(未来稳定后也计划开发 React 版本),提供功能完整的交互组件:

  • 开箱即用的 Vue 组件,内置交互状态管理;
  • 支持灵活的样式定制和主题配置;
  • 完整的 TypeScript 类型支持,提升开发体验;
  • 采用现代前端开发模式,注重代码质量和可维护性。

🛠️ 技术选型与考量

核心技术栈

我们选择了以下技术栈,以确保组件的高性能和良好的开发体验:

  • Vue 3:我们选择 Vue 3 作为框架,利用其 Composition API 等新特性,提高组件的可维护性和开发效率。

  • TypeScript:项目完全使用 TypeScript 编写,提供了完整的类型定义文件,从而在开发过程中确保类型安全。

  • Canvas:大部分像素效果通过 Canvas 实现,用于绘制复杂的图形和动画,确保视觉效果的一致性。

  • oxlint:用 Rust 编写的超快 Lint 工具。

  • VitePress:展示交互式示例。

  • ts-morph + comment-parser: 解析 JSDoc 注释生成 API 文档。

为何未选择 CSS Houdini

在技术选型中,我个人对 CSS Houdini 进行了深入评估,但最终决定不将其用于本项目。核心障碍在于其浏览器兼容性尚未达到可投入生产环境的标准。 CSS Houdini 虽然通过一组底层 API 为浏览器带来了强大的 CSS 扩展能力,但其规范与实现仍处于演进阶段。目前,关键 API 在不同内核浏览器中的支持度参差不齐,这意味着依赖 Houdini 将直接导致大量用户的浏览器无法正常渲染预期效果,从而破坏体验的一致性。 为了保证 Pixelium Design 像素风格在各种环境下的稳定性和可靠性,我最终选择了支持度更广、更为成熟的 Canvas 方案作为替代实现。

像素风图标选择和预处理

在图标选型上,Pixelium Design 整合了风格契合的 @hackernoon/pixel-icon-librarypixelarticons 开源版本两个开源库。为确保性能与体验,我们将所有图标预处理为 SVG Vue 组件,这使得图标支持按需引入(Tree Shaking),能有效减少打包体积。开发者可以灵活地按需或全局注册使用这些图标,并通过统一的属性(如 size、color、spin)来控制其外观和交互。

✨ 项目核心特点

  • 👾 像素美学
    严格采用硬边缘像素绘制技术,注重每一像素的精确排布,形成独特的数字艺术风格,还原早期数码界面的纯粹质感与视觉魅力。
  • 🎨 自定义主题
    提供了高度灵活的主题定制能力,您不仅可以自由定义全局色彩方案,还能调整像素颗粒的基础尺寸,轻松打造独一无二的个性化像素风界面。
  • 🌙 深色模式
    完整支持浅色与深色主题切换,既可设置为自动跟随系统外观,也能在应用内手动控制,确保在任何光线下都能获得舒适的视觉体验。
  • 🌈 OKlab 色域
    核心渐变算法基于感知均匀的 OKlab 色彩空间,有效解决了传统色域中亮度不均的问题,确保了色彩在任何明度下过渡都平滑且自然。
  • 📱 响应式布局
    采用现代 CSS Grid 与 Flexbox 布局技术,结合断点系统,确保所有组件与布局都能在各种屏幕尺寸和设备上自适应显示。
  • 🧩 Tree-shaking
    项目构建时支持先进的 Tree-shaking 优化,所有组件均可独立导入,最终打包产物将自动剔除所有未使用的代码,极致优化项目体积。
  • 类型安全
    项目 100% 由 TypeScript 构建,提供了完整、精确的类型定义文件,在开发阶段即可捕获潜在类型错误,极大提升代码的健壮性和可维护性。
  • 📚 文档全面
    使用 VitePress 构建了交互式文档站,结合标准化的 JSDoc 注释,能够自动生成实时可用的组件示例和详尽的 API 接口文档。

🧩 组件预览

🔧 通用组件

image.png

⌨️ 数据输入组件

image.png

🗂️ 布局组件

image.png

💬 反馈组件

image.png

🗺️ 更新计划与路线图

近期目标(v0.0.3)

  • 支持切换像素宽度,支持 2px 和 4px。
  • 完善表单组件:radio、checkbox、range、switch、form。
  • 图片展示组件和图片像素化功能。
  • 虚拟列表组件。
  • 完善测试用例。

长期规划

  • 持续增加新组件 我们将持续增加新的组件,以满足更多的应用场景。我们欢迎社区的建议和贡献,共同打造更加完善的组件库。
  • 性能优化与社区共建 我们将持续优化组件的性能,并积极与社区互动,收集用户的反馈,不断改进。

🤝 参与与贡献

欢迎 Star, Issue 与 Pull Request

我们欢迎所有对像素风格和开源项目感兴趣的开发者加入 Pixelium Design 的开发。如果您对项目有疑问、建议或发现任何问题,欢迎在 GitHub 上:

  • Star:给项目点个星,支持开源项目的发展。

  • Issue:提出问题或建议,我们将及时处理。

  • Pull Request:贡献代码,共同完善项目。

欢迎大家使用 Pixelium Design,目前组件数量较少,项目正在持续施工中......

大家的阅读是我发帖的动力,本文也更新于我的博客:deer.shika-blog.xyz/,欢迎大家来玩,转载请注明出处

Monorepo 工具大比拼:为什么我最终选择了 pnpm + Turborepo?

2025年10月17日 15:38

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第二篇。上一篇我们深入分析了 Monorepo 的核心概念,这篇文章将聚焦于工具选型,带你了解主流 Monorepo 工具的优劣,以及我的选择理由。

🎯 本文目标

读完这篇文章,你将了解:

  • 主流 Monorepo 工具的对比分析
  • pnpm workspace 的核心优势
  • Turborepo 为什么这么快
  • 如何根据项目规模选择合适的工具

📖 Monorepo 工具全景图

工具分类

Monorepo 工具链
├── 包管理器层
│   ├── npm workspaces
│   ├── yarn workspaces  
│   └── pnpm workspace ⭐ (我的选择)
│
├── 构建编排层
│   ├── Lerna
│   ├── Rush
│   ├── Nx
│   └── Turborepo ⭐ (我的选择)
│
└── 一体化方案
    ├── Nx (包管理 + 构建)
    └── Rush (包管理 + 构建)

🔍 包管理器对比

npm workspaces vs yarn workspaces vs pnpm workspace

特性 npm yarn pnpm 推荐指数
安装速度 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ 🏆 pnpm
磁盘空间 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ 🏆 pnpm
依赖隔离 ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ 🏆 pnpm
幽灵依赖 ❌ 有 ❌ 有 ✅ 无 🏆 pnpm
生态成熟度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 🏆 npm
学习成本 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 🏆 npm

pnpm 的杀手级特性

1. 节省磁盘空间(最多节省 75%)

传统 npm/yarn:

# 每个项目都复制一份依赖
~/projects/
├── project-a/node_modules/lodash/  # 1MB
├── project-b/node_modules/lodash/  # 1MB  
├── project-c/node_modules/lodash/  # 1MB
└── project-d/node_modules/lodash/  # 1MB
# 总共 4MB

pnpm 的硬链接:

# 所有项目共享同一份依赖
~/.pnpm-store/
└── lodash@4.17.21/     # 1MB(只存一份)

~/projects/
├── project-a/node_modules/lodash/  → 硬链接
├── project-b/node_modules/lodash/  → 硬链接
├── project-c/node_modules/lodash/  → 硬链接
└── project-d/node_modules/lodash/  → 硬链接
# 总共只占用 1MB!

实际效果:

# 我的项目数据
npm:  1.2 GB node_modules
pnpm: 350 MB node_modules

# 节省空间:70.8%!

2. 杜绝幽灵依赖

什么是幽灵依赖?

// package.json 中没有声明 lodash
{
  "dependencies": {
    "some-package": "^1.0.0"  // some-package 依赖了 lodash
  }
}

// 但你居然可以直接用!这就是幽灵依赖
import _ from 'lodash'  // 😱 能用,但不安全!

pnpm 的严格模式:

# pnpm 会报错
Error: Cannot find module 'lodash'
# 必须显式声明依赖才能使用 ✅

3. 更快的安装速度

性能对比(安装 1000+ 依赖):

npm:  45s  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
yarn: 32s  ▓▓▓▓▓▓▓▓▓▓▓▓
pnpm: 12s  ▓▓▓▓ ⚡

速度提升:
- 比 npm 快 3.75 倍
- 比 yarn 快 2.67 倍

🚀 构建编排工具对比

Lerna vs Rush vs Nx vs Turborepo

📊 综合对比

工具 学习曲线 性能 功能丰富度 配置复杂度 社区活跃度 推荐指数
Lerna ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Rush ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐ ⭐⭐ ⭐⭐⭐
Nx ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
Turborepo ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

1️⃣ Lerna:老牌工具,渐显疲态

优势:

# 简单易用
lerna init
lerna bootstrap
lerna publish

劣势:

  • ❌ 性能较差(没有缓存机制)
  • ❌ 功能有限(主要是版本管理)
  • ❌ 维护不活跃(已转交给 Nx 团队)

适用场景: 小型项目,简单的版本管理需求

2️⃣ Rush:微软出品,企业级方案

优势:

// rush.json - 强大的配置能力
{
  "projects": [
    { "packageName": "ui-lib", "projectFolder": "packages/ui" }
  ],
  "pnpmOptions": {
    "strictPeerDependencies": true
  }
}

特点:

  • ✅ 严格的依赖管理
  • ✅ 企业级特性完善
  • ✅ 支持 pnpm

劣势:

  • ❌ 学习曲线陡峭
  • ❌ 配置复杂
  • ❌ 社区相对小众

适用场景: 大型企业项目,需要严格管理

3️⃣ Nx:功能最强大的方案

优势:

# 强大的代码生成
nx generate @nx/react:component Button

# 智能的依赖图分析
nx graph

# 高效的缓存
nx run-many --target=build --all

特点:

  • ✅ 功能最丰富(代码生成、依赖图、插件系统)
  • ✅ 性能优秀(智能缓存)
  • ✅ 支持多种框架(React、Vue、Angular)

劣势:

  • ❌ 学习成本高
  • ❌ 配置复杂
  • ❌ 上手门槛高

适用场景: 大型项目,需要完整的工具链支持

4️⃣ Turborepo:我的最终选择 🏆

核心优势:

📈 极致的性能

# 真实项目数据对比
                无缓存    有缓存    提升倍数
Lerna:         45s       45s       1x
Rush:          38s       12s       3.2x
Nx:            35s       2.5s      14x
Turborepo:     9s        0.45s     20x ⚡

# Turborepo 在缓存命中时快了 19-20 倍!

🎯 极简的配置

Turborepo 配置:

// turbo.json - 仅需 76 行配置
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

对比 Nx 配置:

// nx.json + workspace.json + project.json
// 需要 200+ 行配置

🔥 零配置开箱即用

# 3 步搞定
npx create-turbo@latest
cd my-turborepo
pnpm install

# 就这么简单!

⚡ 智能缓存机制

# 第一次构建
pnpm build
✓ @company/utils built in 2.1s
✓ @company/ui built in 3.4s

# 代码没变化,再次构建
pnpm build
✓ @company/utils cached ⚡
✓ @company/ui cached ⚡
# 完成时间:0.3s!

🎨 我的技术选型过程

项目需求分析

GDU Common 项目特点:
✓ 4 个包(ui、utils、shared、controls-sdk)
✓ 都使用 Vue 3 + TypeScript
✓ 需要频繁联调
✓ 团队 5-8 人
✓ 需要快速迭代

决策树

开始
  ↓
需要 Monorepo? → 是
  ↓
团队规模? → 5-8人(中小型)
  ↓
是否需要代码生成? → 否
  ↓
是否需要多框架支持? → 否(只用 Vue)
  ↓
最看重什么? → 性能 + 简单
  ↓
选择:pnpm + Turborepo ✅

选型理由

1️⃣ pnpm workspace

为什么不选 npm/yarn?

# npm workspaces 的问题
npm install
# 幽灵依赖问题
# 速度较慢

# yarn workspaces 的问题  
yarn install
# 依赖提升导致的版本冲突
# PnP 模式不够成熟

# pnpm workspace 的优势
pnpm install
# ✅ 快速
# ✅ 严格
# ✅ 节省空间

pnpm-workspace.yaml 配置:

packages:
  - packages/*
  - docs
  - build

就这么简单!

2️⃣ Turborepo

为什么不选 Nx?

# Nx 的问题
- 配置复杂(3-4 个配置文件)
- 学习曲线陡
- 功能过于丰富(我们用不上)

# Turborepo 的优势
- 配置简单(1 个 turbo.json)
- 性能极致(Go 语言编写)
- 专注于构建(做好一件事)

为什么不选 Lerna?

# Lerna 的问题
- 性能差(无缓存机制)
- 功能有限
- 维护不活跃

# 数据对比
Lerna:     45s 构建
Turborepo: 9s 构建(无缓存)
          0.45s 构建(有缓存)

# 差距太明显了!

🛠️ pnpm 深度解析

核心原理:基于符号链接的依赖管理

传统的 node_modules 结构(npm/yarn)

node_modules/
├── package-a/
│   ├── index.js
│   └── node_modules/
│       └── package-b/  # 依赖被提升到顶层
├── package-b/          # 重复了!
└── package-c/

问题:

  • 依赖提升导致幽灵依赖
  • 重复的依赖占用空间

pnpm 的 content-addressable 存储

node_modules/
├── .pnpm/
│   ├── package-a@1.0.0/
│   │   └── node_modules/
│   │       ├── package-a/ → ~//.pnpm-store/...
│   │       └── package-b/ → .pnpm/package-b@1.0.0/...
│   └── package-b@1.0.0/
│       └── node_modules/
│           └── package-b/ → ~/.pnpm-store/...
└── package-a/ → .pnpm/package-a@1.0.0/...

优势:

  • ✅ 扁平的 node_modules,但严格的依赖隔离
  • ✅ 全局存储,硬链接复用
  • ✅ 避免幽灵依赖

实战配置

package.json:

{
  "name": "gdu-common",
  "private": true,
  "scripts": {
    "install": "pnpm install"
  }
}

pnpm-workspace.yaml:

packages:
  - packages/*
  - docs
  - build

.npmrc 配置:

# 使用严格的 peer 依赖检查
strict-peer-dependencies=true

# 不要幽灵依赖
shamefully-hoist=false

# 使用硬链接
link-workspace-packages=true

常用命令

# 安装依赖
pnpm install

# 添加依赖到根目录
pnpm add -w lodash-es

# 添加依赖到特定包
pnpm add vue --filter @gdu-common/ui

# 运行所有包的脚本
pnpm -r build

# 只运行特定包
pnpm --filter @gdu-common/ui build

⚡ Turborepo 深度解析

核心概念

1. 任务编排(Task Orchestration)

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],  // ^ 表示依赖包的 build
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],   // 先 build 再 test
      "outputs": ["coverage/**"]
    }
  }
}

依赖图:

@gdu-common/ui:build
  ↓ 依赖
@gdu-common/utils:build
  ↓ 依赖
@gdu-common/shared:build

# Turborepo 会自动计算正确的执行顺序

2. 智能缓存(Smart Caching)

缓存键计算:

# Turborepo 会基于这些内容计算缓存键
- 源代码的哈希值
- 依赖的哈希值
- 环境变量
- 任务配置

# 任何一个变化,缓存失效

缓存命中示例:

$ pnpm build

Tasks:    4 successful, 4 total
Cached:   4 cached, 4 total ⚡
  Time:   450ms >>> FULL TURBO

# 4 个包全部命中缓存,只用了 450ms!

3. 并行执行(Parallel Execution)

# Turborepo 自动分析依赖关系,最大化并行
                时间轴 →
shared:build    ▓▓▓
                  ↓
utils:build       ▓▓▓▓
                    ↓
ui:build            ▓▓▓▓▓
docs:build        ▓▓▓▓▓▓▓▓

# shared 和 docs 可以并行
# utils 等待 shared 完成
# ui 等待 utils 完成

真实性能数据

我的项目构建性能:

场景 时间 缓存命中 说明
首次构建 9.2s 0/4 无缓存
完全缓存 450ms 4/4 ⚡ FULL TURBO
修改 1 个包 2.3s 3/4 增量构建
修改配置文件 9.1s 0/4 配置变化,缓存失效

效率提升:

  • 完全缓存时提升 20.4 倍 🚀
  • 日常开发平均提升 4-5 倍

Turborepo 配置实战

基础配置:

{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",  // 使用终端 UI
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".vitepress/dist/**"],
      "cache": true
    },
    "lint": {
      "cache": true
    },
    "lint:fix": {
      "cache": false  // 修改文件的任务不缓存
    },
    "clean": {
      "cache": false
    }
  }
}

高级配置:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        "$TURBO_DEFAULT$",
        "!{dist,build,coverage,.turbo}/**",
        "!**/*.md",
        "!**/*.test.{ts,tsx}"
      ],
      "outputs": ["dist/**"],
      "cache": true
    }
  },
  "globalDependencies": [
    ".env",
    "tsconfig.json",
    "eslint.config.js"
  ],
  "globalEnv": ["NODE_ENV", "CI"]
}

🎯 最终选择:pnpm + Turborepo

组合优势

pnpm workspace (包管理)
    +
Turborepo (构建编排)
    =
完美组合 🎉

1. pnpm 负责依赖管理

  • 快速安装
  • 节省空间
  • 严格隔离

2. Turborepo 负责构建编排

  • 智能缓存
  • 并行执行
  • 增量构建

3. 强强联合

# pnpm 快速安装依赖
pnpm install  # 12s

# Turborepo 快速构建
pnpm build    # 9s(首次) / 0.45s(缓存)

# 总时间:13s(首次) / 12.5s(缓存)

实际效果

开发效率提升:

# 传统 Multirepo 工作流
修改共享函数 → 发布 → 更新依赖 → 重新安装 → 测试
总耗时:5-10 分钟 😫

# Monorepo + Turborepo 工作流
修改共享函数 → 保存 → 自动重建 → 热更新
总耗时:2-3 秒 ⚡

# 效率提升:100-200 倍!

CI/CD 性能:

# .gitlab-ci.yml
build:
  script:
    - pnpm install          # 3s
    - pnpm build            # 9s (首次)
    # 后续 pipeline 只需 0.5s!

💡 选型建议

根据项目规模选择

小型项目(2-3 个包)

✅ 推荐:pnpm workspace
❌ 不需要:Turborepo

# 理由:包少,构建快,不需要复杂的编排

中型项目(4-10 个包)

✅ 推荐:pnpm + Turborepo
⭐ 最佳组合!

# 理由:缓存和并行构建带来明显收益

大型项目(10+ 个包)

✅ 推荐:pnpm + Turborepo
或
✅ 推荐:pnpm + Nx

# Nx 提供更多功能(代码生成、依赖图)
# Turborepo 更简单,性能更好
# 根据团队技术储备选择

根据团队特点选择

团队特点 推荐方案
前端团队,技术栈统一 pnpm + Turborepo
全栈团队,多技术栈 pnpm + Nx
大型企业,严格管理 pnpm + Rush
简单项目,快速上手 pnpm workspace

🚀 快速体验

创建一个 Turborepo 项目

# 使用官方脚手架
npx create-turbo@latest my-monorepo

# 选择 pnpm
? Which package manager do you want to use? › pnpm

# 项目结构
my-monorepo/
├── apps/
│   ├── web/
│   └── docs/
├── packages/
│   ├── ui/
│   └── eslint-config/
├── turbo.json
└── package.json

运行命令

# 安装依赖
pnpm install

# 构建所有包
pnpm build

# 查看缓存效果
pnpm build  # 第二次运行,体验闪电般的速度 ⚡

📊 成本收益分析

迁移成本

项目 学习成本 迁移时间 配置复杂度
Lerna 1 天 2-3 天
Rush 3-5 天 1-2 周
Nx 5-7 天 1-2 周
Turborepo 半天 1-2 天

长期收益

开发效率:

  • 跨包重构时间减少 80%
  • 本地构建时间减少 90%(缓存命中)
  • CI/CD 时间减少 70%

维护成本:

  • 配置文件减少 75%(统一管理)
  • 依赖冲突减少 90%
  • 版本管理复杂度降低 80%

团队协作:

  • 代码审查效率提升 50%
  • 跨项目问题定位快 3 倍
  • 新人上手时间减少 60%

🎉 总结

经过详细的对比和实践,我选择了 pnpm + Turborepo 组合,理由是:

pnpm 的三大优势

  1. - 安装速度比 npm 快 3.75 倍
  2. - 节省 70% 磁盘空间
  3. - 杜绝幽灵依赖,依赖管理更安全

Turborepo 的三大优势

  1. 极致性能 - 缓存命中时快 20 倍
  2. 🎯 极简配置 - 一个 turbo.json 搞定
  3. 🚀 零学习成本 - 半天上手,开箱即用

实际收益

  • 📈 构建速度提升 20 倍(缓存命中)
  • 💾 磁盘空间节省 70%
  • ⏱️ 开发效率提升 100 倍(跨包修改)

在下一篇文章中,我将手把手带你从零搭建一个完整的 pnpm + Turborepo 项目,包括:

  • 项目初始化
  • 包结构设计
  • 配置文件详解
  • 第一个 Hello World 包

🔗 系列文章


你的项目用的是什么 Monorepo 工具?效果如何?欢迎在评论区分享! 🙏

觉得 pnpm + Turborepo 组合不错?点个赞收藏一下,下篇文章将实战搭建! 👍

❌
❌