阅读视图

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

Vue 路由信息获取全攻略:8 种方法深度解析

Vue 路由信息获取全攻略:8 种方法深度解析

在 Vue 应用中,获取当前路由信息是开发中的常见需求。本文将全面解析从基础到高级的各种获取方法,并帮助你选择最佳实践。

一、路由信息全景图

在深入具体方法前,先了解 Vue Router 提供的完整路由信息结构:

// 路由信息对象结构
{
  path: '/user/123/profile?tab=info',    // 完整路径
  fullPath: '/user/123/profile?tab=info&token=abc',
  name: 'user-profile',                   // 命名路由名称
  params: {                               // 动态路径参数
    id: '123'
  },
  query: {                                // 查询参数
    tab: 'info',
    token: 'abc'
  },
  hash: '#section-2',                     // 哈希片段
  meta: {                                 // 路由元信息
    requiresAuth: true,
    title: '用户资料'
  },
  matched: [                              // 匹配的路由记录数组
    { path: '/user', component: UserLayout, meta: {...} },
    { path: '/user/:id', component: UserContainer, meta: {...} },
    { path: '/user/:id/profile', component: UserProfile, meta: {...} }
  ]
}

二、8 种获取路由信息的方法

方法 1:$route 对象(最常用)

<template>
  <div>
    <h1>用户详情页</h1>
    <p>用户ID: {{ $route.params.id }}</p>
    <p>当前标签: {{ $route.query.tab || 'default' }}</p>
    <p>需要认证: {{ $route.meta.requiresAuth ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  created() {
    // 访问路由信息
    console.log('路径:', this.$route.path)
    console.log('参数:', this.$route.params)
    console.log('查询:', this.$route.query)
    console.log('哈希:', this.$route.hash)
    console.log('元信息:', this.$route.meta)
    
    // 获取完整的匹配记录
    const matchedRoutes = this.$route.matched
    matchedRoutes.forEach(route => {
      console.log('匹配的路由:', route.path, route.meta)
    })
  }
}
</script>

特点:

  • ✅ 简单直接,无需导入
  • ✅ 响应式变化(路由变化时自动更新)
  • ✅ 在模板和脚本中都能使用

方法 2:useRoute Hook(Vue 3 Composition API)

<script setup>
import { useRoute } from 'vue-router'
import { watch, computed } from 'vue'

// 获取路由实例
const route = useRoute()

// 直接使用
console.log('当前路由路径:', route.path)
console.log('路由参数:', route.params)

// 计算属性基于路由
const userId = computed(() => route.params.id)
const isEditMode = computed(() => route.query.mode === 'edit')

// 监听路由变化
watch(
  () => route.params.id,
  (newId, oldId) => {
    console.log(`用户ID从 ${oldId} 变为 ${newId}`)
    loadUserData(newId)
  }
)

// 监听多个路由属性
watch(
  () => ({
    id: route.params.id,
    tab: route.query.tab
  }),
  ({ id, tab }) => {
    console.log(`ID: ${id}, Tab: ${tab}`)
  },
  { deep: true }
)
</script>

<template>
  <div>
    <h1>用户 {{ userId }} 的资料</h1>
    <nav>
      <router-link :to="{ query: { tab: 'info' } }" 
                   :class="{ active: route.query.tab === 'info' }">
        基本信息
      </router-link>
      <router-link :to="{ query: { tab: 'posts' } }"
                   :class="{ active: route.query.tab === 'posts' }">
        动态
      </router-link>
    </nav>
  </div>
</template>

方法 3:路由守卫中获取

// 全局守卫
router.beforeEach((to, from, next) => {
  // to: 即将进入的路由
  // from: 当前导航正要离开的路由
  
  console.log('前往:', to.path)
  console.log('来自:', from.path)
  console.log('需要认证:', to.meta.requiresAuth)
  
  // 权限检查
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next({
      path: '/login',
      query: { redirect: to.fullPath } // 保存目标路径
    })
  } else {
    next()
  }
})

// 组件内守卫
export default {
  beforeRouteEnter(to, from, next) {
    // 不能访问 this,因为组件实例还没创建
    console.log('进入前:', to.params.id)
    
    // 可以通过 next 回调访问实例
    next(vm => {
      vm.initialize(to.params.id)
    })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 可以访问 this
    console.log('路由更新:', to.params.id)
    this.loadData(to.params.id)
    next()
  },
  
  beforeRouteLeave(to, from, next) {
    // 离开前的确认
    if (this.hasUnsavedChanges) {
      const answer = window.confirm('有未保存的更改,确定离开吗?')
      if (!answer) {
        next(false) // 取消导航
        return
      }
    }
    next()
  }
}

方法 4:$router 对象获取当前路由

export default {
  methods: {
    getCurrentRouteInfo() {
      // 获取当前路由信息(非响应式)
      const currentRoute = this.$router.currentRoute
      
      // Vue Router 4 中的变化
      // const currentRoute = this.$router.currentRoute.value
      
      console.log('当前路由对象:', currentRoute)
      
      // 编程式导航时获取
      this.$router.push({
        path: '/user/456',
        query: { from: currentRoute.fullPath } // 携带来源信息
      })
    },
    
    // 检查是否在特定路由
    isActiveRoute(routeName) {
      return this.$route.name === routeName
    },
    
    // 检查路径匹配
    isPathMatch(pattern) {
      return this.$route.path.startsWith(pattern)
    }
  },
  
  computed: {
    // 基于当前路由的复杂计算
    breadcrumbs() {
      return this.$route.matched.map(route => ({
        name: route.meta?.breadcrumb || route.name,
        path: route.path
      }))
    },
    
    // 获取嵌套路由参数
    nestedParams() {
      const params = {}
      this.$route.matched.forEach(route => {
        Object.assign(params, route.params)
      })
      return params
    }
  }
}

方法 5:通过 Props 传递路由参数(推荐)

// 路由配置
const routes = [
  {
    path: '/user/:id',
    component: UserDetail,
    props: true // 将 params 作为 props 传递
  },
  {
    path: '/search',
    component: SearchResults,
    props: route => ({ // 自定义 props 函数
      query: route.query.q,
      page: parseInt(route.query.page) || 1,
      sort: route.query.sort || 'relevance'
    })
  }
]

// 组件中使用
export default {
  props: {
    // 从路由 params 自动注入
    id: {
      type: [String, Number],
      required: true
    },
    // 从自定义 props 函数注入
    query: String,
    page: Number,
    sort: String
  },
  
  watch: {
    // props 变化时响应
    id(newId) {
      this.loadUser(newId)
    },
    query(newQuery) {
      this.performSearch(newQuery)
    }
  },
  
  created() {
    // 直接使用 props,无需访问 $route
    console.log('用户ID:', this.id)
    console.log('搜索词:', this.query)
  }
}

方法 6:使用 Vuex/Pinia 管理路由状态

// store/modules/route.js (Vuex)
const state = {
  currentRoute: null,
  previousRoute: null
}

const mutations = {
  SET_CURRENT_ROUTE(state, route) {
    state.previousRoute = state.currentRoute
    state.currentRoute = {
      path: route.path,
      name: route.name,
      params: { ...route.params },
      query: { ...route.query },
      meta: { ...route.meta }
    }
  }
}

// 在全局守卫中同步
router.afterEach((to, from) => {
  store.commit('SET_CURRENT_ROUTE', to)
})

// 组件中使用
export default {
  computed: {
    ...mapState({
      currentRoute: state => state.route.currentRoute,
      previousRoute: state => state.route.previousRoute
    }),
    
    // 基于路由状态的衍生数据
    pageTitle() {
      const route = this.currentRoute
      return route?.meta?.title || '默认标题'
    }
  }
}
// Pinia 版本(Vue 3)
import { defineStore } from 'pinia'

export const useRouteStore = defineStore('route', {
  state: () => ({
    current: null,
    history: []
  }),
  
  actions: {
    updateRoute(route) {
      this.history.push({
        ...this.current,
        timestamp: new Date().toISOString()
      })
      
      // 只保留最近10条记录
      if (this.history.length > 10) {
        this.history = this.history.slice(-10)
      }
      
      this.current = {
        path: route.path,
        fullPath: route.fullPath,
        name: route.name,
        params: { ...route.params },
        query: { ...route.query },
        meta: { ...route.meta }
      }
    }
  },
  
  getters: {
    // 获取路由参数
    routeParam: (state) => (key) => {
      return state.current?.params?.[key]
    },
    
    // 获取查询参数
    routeQuery: (state) => (key) => {
      return state.current?.query?.[key]
    },
    
    // 检查是否在特定路由
    isRoute: (state) => (routeName) => {
      return state.current?.name === routeName
    }
  }
})

方法 7:自定义路由混合/组合函数

// 自定义混合(Vue 2)
export const routeMixin = {
  computed: {
    // 便捷访问器
    $routeParams() {
      return this.$route.params || {}
    },
    
    $routeQuery() {
      return this.$route.query || {}
    },
    
    $routeMeta() {
      return this.$route.meta || {}
    },
    
    // 常用路由检查
    $isHomePage() {
      return this.$route.path === '/'
    },
    
    $hasRouteParam(param) {
      return param in this.$route.params
    },
    
    $getRouteParam(param, defaultValue = null) {
      return this.$route.params[param] || defaultValue
    }
  },
  
  methods: {
    // 路由操作辅助方法
    $updateQuery(newQuery) {
      this.$router.push({
        ...this.$route,
        query: {
          ...this.$route.query,
          ...newQuery
        }
      })
    },
    
    $removeQueryParam(key) {
      const query = { ...this.$route.query }
      delete query[key]
      this.$router.push({ query })
    }
  }
}

// 在组件中使用
export default {
  mixins: [routeMixin],
  
  created() {
    console.log('用户ID:', this.$getRouteParam('id', 'default'))
    console.log('是否首页:', this.$isHomePage)
    
    // 更新查询参数
    this.$updateQuery({ page: 2, sort: 'name' })
  }
}
// Vue 3 Composition API 版本
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useRouteHelpers() {
  const route = useRoute()
  const router = useRouter()
  
  const routeParams = computed(() => route.params || {})
  const routeQuery = computed(() => route.query || {})
  const routeMeta = computed(() => route.meta || {})
  
  const isHomePage = computed(() => route.path === '/')
  
  function getRouteParam(param, defaultValue = null) {
    return route.params[param] || defaultValue
  }
  
  function updateQuery(newQuery) {
    router.push({
      ...route,
      query: {
        ...route.query,
        ...newQuery
      }
    })
  }
  
  function removeQueryParam(key) {
    const query = { ...route.query }
    delete query[key]
    router.push({ query })
  }
  
  return {
    routeParams,
    routeQuery,
    routeMeta,
    isHomePage,
    getRouteParam,
    updateQuery,
    removeQueryParam
  }
}

// 在组件中使用
<script setup>
const {
  routeParams,
  routeQuery,
  getRouteParam,
  updateQuery
} = useRouteHelpers()

const userId = getRouteParam('id')
const currentTab = computed(() => routeQuery.tab || 'info')

function changeTab(tab) {
  updateQuery({ tab })
}
</script>

方法 8:访问 Router 实例的匹配器

export default {
  methods: {
    // 获取所有路由配置
    getAllRoutes() {
      return this.$router.options.routes
    },
    
    // 通过名称查找路由
    findRouteByName(name) {
      return this.$router.options.routes.find(route => route.name === name)
    },
    
    // 检查路径是否匹配路由
    matchRoute(path) {
      // Vue Router 3
      const matched = this.$router.match(path)
      return matched.matched.length > 0
      
      // Vue Router 4
      // const matched = this.$router.resolve(path)
      // return matched.matched.length > 0
    },
    
    // 生成路径
    generatePath(routeName, params = {}) {
      const route = this.findRouteByName(routeName)
      if (!route) return null
      
      // 简单的路径生成(实际项目建议使用 path-to-regexp)
      let path = route.path
      Object.keys(params).forEach(key => {
        path = path.replace(`:${key}`, params[key])
      })
      return path
    }
  }
}

三、不同场景的推荐方案

场景决策表

场景 推荐方案 理由
简单组件中获取参数 $route.params.id 最简单直接
Vue 3 Composition API useRoute() Hook 响应式、类型安全
组件复用/测试友好 Props 传递 解耦路由依赖
复杂应用状态管理 Vuex/Pinia 存储 全局访问、历史记录
多个组件共享逻辑 自定义混合/组合函数 代码复用
路由守卫/拦截器 守卫参数 (to, from) 官方标准方式
需要路由配置信息 $router.options.routes 访问完整配置

性能优化建议

// ❌ 避免在模板中频繁访问深层属性
<template>
  <div>
    <!-- 每次渲染都会计算 -->
    {{ $route.params.user.details.profile.name }}
  </div>
</template>

// ✅ 使用计算属性缓存
<template>
  <div>{{ userName }}</div>
</template>

<script>
export default {
  computed: {
    userName() {
      return this.$route.params.user?.details?.profile?.name || '未知'
    },
    
    // 批量提取路由信息
    routeInfo() {
      const { params, query, meta } = this.$route
      return {
        userId: params.id,
        tab: query.tab,
        requiresAuth: meta.requiresAuth
      }
    }
  }
}
</script>

响应式监听最佳实践

export default {
  watch: {
    // 监听特定参数变化
    '$route.params.id': {
      handler(newId, oldId) {
        if (newId !== oldId) {
          this.loadUserData(newId)
        }
      },
      immediate: true
    },
    
    // 监听查询参数变化
    '$route.query': {
      handler(newQuery) {
        this.applyFilters(newQuery)
      },
      deep: true // 深度监听对象变化
    }
  },
  
  // 或者使用 beforeRouteUpdate 守卫
  beforeRouteUpdate(to, from, next) {
    // 只处理需要的变化
    if (to.params.id !== from.params.id) {
      this.loadUserData(to.params.id)
    }
    next()
  }
}

四、实战案例:用户管理系统

<template>
  <div class="user-management">
    <!-- 面包屑导航 -->
    <nav class="breadcrumbs">
      <router-link v-for="item in breadcrumbs" 
                   :key="item.path"
                   :to="item.path">
        {{ item.title }}
      </router-link>
    </nav>
    
    <!-- 用户详情 -->
    <div v-if="$route.name === 'user-detail'">
      <h2>用户详情 - {{ userName }}</h2>
      <UserTabs :active-tab="activeTab" @change-tab="changeTab" />
      <router-view />
    </div>
    
    <!-- 用户列表 -->
    <div v-else-if="$route.name === 'user-list'">
      <UserList :filters="routeFilters" />
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState(['currentUser']),
    
    // 从路由获取信息
    userId() {
      return this.$route.params.userId
    },
    
    activeTab() {
      return this.$route.query.tab || 'profile'
    },
    
    routeFilters() {
      return {
        department: this.$route.query.dept,
        role: this.$route.query.role,
        status: this.$route.query.status || 'active'
      }
    },
    
    // 面包屑导航
    breadcrumbs() {
      const crumbs = []
      const { matched } = this.$route
      
      matched.forEach((route, index) => {
        const { meta, path } = route
        
        // 生成面包屑项
        if (meta?.breadcrumb) {
          crumbs.push({
            title: meta.breadcrumb,
            path: this.generateBreadcrumbPath(matched.slice(0, index + 1))
          })
        }
      })
      
      return crumbs
    },
    
    // 用户名(需要根据ID查找)
    userName() {
      const user = this.$store.getters.getUserById(this.userId)
      return user ? user.name : '加载中...'
    }
  },
  
  watch: {
    // 监听用户ID变化
    userId(newId) {
      if (newId) {
        this.$store.dispatch('fetchUser', newId)
      }
    },
    
    // 监听标签页变化
    activeTab(newTab) {
      this.updateDocumentTitle(newTab)
    }
  },
  
  created() {
    // 初始化加载
    if (this.userId) {
      this.$store.dispatch('fetchUser', this.userId)
    }
    
    // 设置页面标题
    this.updateDocumentTitle()
    
    // 记录页面访问
    this.logPageView()
  },
  
  methods: {
    changeTab(tab) {
      // 更新查询参数
      this.$router.push({
        ...this.$route,
        query: { ...this.$route.query, tab }
      })
    },
    
    generateBreadcrumbPath(routes) {
      // 生成完整路径
      return routes.map(r => r.path).join('')
    },
    
    updateDocumentTitle(tab = null) {
      const tabName = tab || this.activeTab
      const title = this.$route.meta.title || '用户管理'
      document.title = `${title} - ${this.getTabDisplayName(tabName)}`
    },
    
    logPageView() {
      // 发送分析数据
      analytics.track('page_view', {
        path: this.$route.path,
        name: this.$route.name,
        params: this.$route.params
      })
    }
  }
}
</script>

五、常见问题与解决方案

问题1:路由信息延迟获取

// ❌ 可能在 created 中获取不到完整的 $route
created() {
  console.log(this.$route.params.id) // 可能为 undefined
}

// ✅ 使用 nextTick 确保 DOM 和路由都就绪
created() {
  this.$nextTick(() => {
    console.log('路由信息:', this.$route)
    this.loadData(this.$route.params.id)
  })
}

// ✅ 或者使用 watch + immediate
watch: {
  '$route.params.id': {
    handler(id) {
      if (id) this.loadData(id)
    },
    immediate: true
  }
}

问题2:路由变化时组件不更新

// 对于复用组件,需要监听路由变化
export default {
  // 使用 beforeRouteUpdate 守卫
  beforeRouteUpdate(to, from, next) {
    this.userId = to.params.id
    this.loadUserData()
    next()
  },
  
  // 或者使用 watch
  watch: {
    '$route.params.id'(newId) {
      this.userId = newId
      this.loadUserData()
    }
  }
}

问题3:TypeScript 类型支持

// Vue 3 + TypeScript
import { RouteLocationNormalized } from 'vue-router'

// 定义路由参数类型
interface UserRouteParams {
  id: string
}

interface UserRouteQuery {
  tab?: 'info' | 'posts' | 'settings'
  edit?: string
}

export default defineComponent({
  setup() {
    const route = useRoute()
    
    // 类型安全的参数访问
    const userId = computed(() => {
      const params = route.params as UserRouteParams
      return params.id
    })
    
    const currentTab = computed(() => {
      const query = route.query as UserRouteQuery
      return query.tab || 'info'
    })
    
    // 类型安全的路由跳转
    const router = useRouter()
    function goToEdit() {
      router.push({
        name: 'user-edit',
        params: { id: userId.value },
        query: { from: route.fullPath }
      })
    }
    
    return { userId, currentTab, goToEdit }
  }
})

六、总结:最佳实践指南

  1. 优先使用 Props 传递 - 提高组件可测试性和复用性
  2. 复杂逻辑使用组合函数 - Vue 3 推荐方式,逻辑更清晰
  3. 适当使用状态管理 - 需要跨组件共享路由状态时
  4. 性能优化 - 避免频繁访问深层属性,使用计算属性缓存
  5. 类型安全 - TypeScript 项目一定要定义路由类型

快速选择流程图:

graph TD
    A[需要获取路由信息] --> B{使用场景}
    
    B -->|简单访问参数| C[使用 $route.params]
    B -->|Vue 3 项目| D[使用 useRoute Hook]
    B -->|组件需要复用/测试| E[使用 Props 传递]
    B -->|多个组件共享状态| F[使用 Pinia/Vuex 存储]
    B -->|通用工具函数| G[自定义组合函数]
    
    C --> H[完成]
    D --> H
    E --> H
    F --> H
    G --> H

记住黄金法则:优先考虑组件独立性,只在必要时直接访问路由对象。


思考题:在你的 Vue 项目中,最常使用哪种方式获取路由信息?遇到过哪些有趣的问题?欢迎分享你的实战经验!

Vue Watch 立即执行:5 种初始化调用方案全解析

Vue Watch 立即执行:5 种初始化调用方案全解析

你是否遇到过在组件初始化时就需要立即执行 watch 逻辑的场景?本文将深入探讨 Vue 中 watch 的立即执行机制,并提供 5 种实用方案。

一、问题背景:为什么需要立即执行 watch?

在 Vue 开发中,我们经常遇到这样的需求:

export default {
  data() {
    return {
      userId: null,
      userData: null,
      filters: {
        status: 'active',
        sortBy: 'name'
      },
      filteredUsers: []
    }
  },
  
  watch: {
    // 需要组件初始化时就执行一次
    'filters.status'() {
      this.loadUsers()
    },
    
    'filters.sortBy'() {
      this.sortUsers()
    }
  },
  
  created() {
    // 我们期望:初始化时自动应用 filters 的默认值
    // 但默认的 watch 不会立即执行
  }
}

二、解决方案对比表

方案 适用场景 优点 缺点 Vue 版本
1. immediate 选项 简单监听 原生支持,最简洁 无法复用逻辑 2+
2. 提取为方法 复杂逻辑复用 逻辑可复用,清晰 需要手动调用 2+
3. 计算属性 派生数据 响应式,自动更新 不适合副作用 2+
4. 自定义 Hook 复杂业务逻辑 高度复用,可组合 需要额外封装 2+ (Vue 3 最佳)
5. 侦听器工厂 多个相似监听 减少重复代码 有一定复杂度 2+

三、5 种解决方案详解

方案 1:使用 immediate: true(最常用)

export default {
  data() {
    return {
      searchQuery: '',
      searchResults: [],
      loading: false
    }
  },
  
  watch: {
    // 基础用法:立即执行 + 深度监听
    searchQuery: {
      handler(newVal, oldVal) {
        this.performSearch(newVal)
      },
      immediate: true,    // ✅ 组件创建时立即执行
      deep: false         // 默认值,可根据需要开启
    },
    
    // 监听对象属性
    'filters.status': {
      handler(newStatus) {
        this.applyFilter(newStatus)
      },
      immediate: true
    },
    
    // 监听多个源(Vue 2.6+)
    '$route.query': {
      handler(query) {
        // 路由变化时初始化数据
        this.initFromQuery(query)
      },
      immediate: true
    }
  },
  
  methods: {
    async performSearch(query) {
      this.loading = true
      try {
        this.searchResults = await api.search(query)
      } catch (error) {
        console.error('搜索失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    initFromQuery(query) {
      // 从 URL 参数初始化状态
      if (query.search) {
        this.searchQuery = query.search
      }
    }
  }
}

进阶技巧:动态 immediate

export default {
  data() {
    return {
      shouldWatchImmediately: true,
      value: ''
    }
  },
  
  watch: {
    value: {
      handler(newVal) {
        this.handleValueChange(newVal)
      },
      // 动态决定是否立即执行
      immediate() {
        return this.shouldWatchImmediately
      }
    }
  }
}

方案 2:提取为方法并手动调用(最灵活)

export default {
  data() {
    return {
      pagination: {
        page: 1,
        pageSize: 20,
        total: 0
      },
      items: []
    }
  },
  
  created() {
    // ✅ 立即调用一次
    this.handlePaginationChange(this.pagination)
    
    // 同时设置 watch
    this.$watch(
      () => ({ ...this.pagination }),
      this.handlePaginationChange,
      { deep: true }
    )
  },
  
  methods: {
    async handlePaginationChange(newPagination, oldPagination) {
      // 避免初始化时重复调用(如果 created 中已调用)
      if (oldPagination === undefined) {
        // 这是初始化调用
        console.log('初始化加载数据')
      }
      
      // 防抖处理
      if (this.loadDebounce) {
        clearTimeout(this.loadDebounce)
      }
      
      this.loadDebounce = setTimeout(async () => {
        this.loading = true
        try {
          const response = await api.getItems({
            page: newPagination.page,
            pageSize: newPagination.pageSize
          })
          this.items = response.data
          this.pagination.total = response.total
        } catch (error) {
          console.error('加载失败:', error)
        } finally {
          this.loading = false
        }
      }, 300)
    }
  }
}

优势对比:

// ❌ 重复逻辑
watch: {
  pagination: {
    handler() { this.loadData() },
    immediate: true,
    deep: true
  },
  filters: {
    handler() { this.loadData() },  // 重复的 loadData 调用
    immediate: true,
    deep: true
  }
}

// ✅ 提取方法,复用逻辑
created() {
  this.loadData()  // 初始化调用
  
  // 多个监听复用同一方法
  this.$watch(() => this.pagination, this.loadData, { deep: true })
  this.$watch(() => this.filters, this.loadData, { deep: true })
}

方案 3:计算属性替代(适合派生数据)

export default {
  data() {
    return {
      basePrice: 100,
      taxRate: 0.08,
      discount: 10
    }
  },
  
  computed: {
    // 计算属性自动响应依赖变化
    finalPrice() {
      const priceWithTax = this.basePrice * (1 + this.taxRate)
      return Math.max(0, priceWithTax - this.discount)
    },
    
    // 复杂计算场景
    formattedReport() {
      // 这里会立即执行,并自动响应 basePrice、taxRate、discount 的变化
      return {
        base: this.basePrice,
        tax: this.basePrice * this.taxRate,
        discount: this.discount,
        total: this.finalPrice,
        timestamp: new Date().toISOString()
      }
    }
  },
  
  created() {
    // 计算属性在 created 中已可用
    console.log('初始价格:', this.finalPrice)
    console.log('初始报告:', this.formattedReport)
    
    // 如果需要执行副作用(如 API 调用),仍需要 watch
    this.$watch(
      () => this.finalPrice,
      (newPrice) => {
        this.logPriceChange(newPrice)
      },
      { immediate: true }
    )
  }
}

方案 4:自定义 Hook/Composable(Vue 3 最佳实践)

// composables/useWatcher.js
import { watch, ref, onMounted } from 'vue'

export function useImmediateWatcher(source, callback, options = {}) {
  const { immediate = true, ...watchOptions } = options
  
  // 立即执行一次
  if (immediate) {
    callback(source.value, undefined)
  }
  
  // 设置监听
  watch(source, callback, watchOptions)
  
  // 返回清理函数
  return () => {
    // 如果需要,可以返回清理逻辑
  }
}

// 在组件中使用
import { ref } from 'vue'
import { useImmediateWatcher } from '@/composables/useWatcher'

export default {
  setup() {
    const searchQuery = ref('')
    const filters = ref({ status: 'active' })
    
    // 使用自定义 Hook
    useImmediateWatcher(
      searchQuery,
      async (newQuery) => {
        await performSearch(newQuery)
      },
      { debounce: 300 }
    )
    
    useImmediateWatcher(
      filters,
      (newFilters) => {
        applyFilters(newFilters)
      },
      { deep: true, immediate: true }
    )
    
    return {
      searchQuery,
      filters
    }
  }
}

Vue 2 版本的 Mixin 实现:

// mixins/immediateWatcher.js
export const immediateWatcherMixin = {
  created() {
    this._immediateWatchers = []
  },
  
  methods: {
    $watchImmediate(expOrFn, callback, options = {}) {
      // 立即执行一次
      const unwatch = this.$watch(
        expOrFn,
        (...args) => {
          callback(...args)
        },
        { ...options, immediate: true }
      )
      
      this._immediateWatchers.push(unwatch)
      return unwatch
    }
  },
  
  beforeDestroy() {
    // 清理所有监听器
    this._immediateWatchers.forEach(unwatch => unwatch())
    this._immediateWatchers = []
  }
}

// 使用
export default {
  mixins: [immediateWatcherMixin],
  
  created() {
    this.$watchImmediate(
      () => this.userId,
      (newId) => {
        this.loadUserData(newId)
      }
    )
  }
}

方案 5:侦听器工厂函数(高级封装)

// utils/watchFactory.js
export function createImmediateWatcher(vm, configs) {
  const unwatchers = []
  
  configs.forEach(config => {
    const {
      source,
      handler,
      immediate = true,
      deep = false,
      flush = 'pre'
    } = config
    
    // 处理 source 可以是函数或字符串
    const getter = typeof source === 'function' 
      ? source 
      : () => vm[source]
    
    // 立即执行
    if (immediate) {
      const initialValue = getter()
      handler.call(vm, initialValue, undefined)
    }
    
    // 创建侦听器
    const unwatch = vm.$watch(
      getter,
      handler.bind(vm),
      { deep, immediate: false, flush }
    )
    
    unwatchers.push(unwatch)
  })
  
  // 返回清理函数
  return function cleanup() {
    unwatchers.forEach(unwatch => unwatch())
  }
}

// 组件中使用
export default {
  data() {
    return {
      filters: { category: 'all', sort: 'newest' },
      pagination: { page: 1, size: 20 }
    }
  },
  
  created() {
    // 批量创建立即执行的侦听器
    this._cleanupWatchers = createImmediateWatcher(this, [
      {
        source: 'filters',
        handler(newFilters) {
          this.applyFilters(newFilters)
        },
        deep: true
      },
      {
        source: () => this.pagination.page,
        handler(newPage) {
          this.loadPage(newPage)
        }
      }
    ])
  },
  
  beforeDestroy() {
    // 清理
    if (this._cleanupWatchers) {
      this._cleanupWatchers()
    }
  }
}

四、实战场景:表单初始化与验证

<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="form.email" @blur="validateEmail" />
    <input v-model="form.password" type="password" />
    
    <div v-if="errors.email">{{ errors.email }}</div>
    <button :disabled="!isFormValid">提交</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      form: {
        email: '',
        password: ''
      },
      errors: {
        email: '',
        password: ''
      },
      isInitialValidationDone: false
    }
  },
  
  computed: {
    isFormValid() {
      return !this.errors.email && !this.errors.password
    }
  },
  
  watch: {
    'form.email': {
      handler(newEmail) {
        // 只在初始化验证后,或者用户修改时验证
        if (this.isInitialValidationDone || newEmail) {
          this.validateEmail()
        }
      },
      immediate: true  // ✅ 初始化时触发验证
    },
    
    'form.password': {
      handler(newPassword) {
        this.validatePassword(newPassword)
      },
      immediate: true  // ✅ 初始化时触发验证
    }
  },
  
  created() {
    // 标记初始化验证完成
    this.$nextTick(() => {
      this.isInitialValidationDone = true
    })
  },
  
  methods: {
    validateEmail() {
      const email = this.form.email
      if (!email) {
        this.errors.email = '邮箱不能为空'
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
        this.errors.email = '邮箱格式不正确'
      } else {
        this.errors.email = ''
      }
    },
    
    validatePassword(password) {
      if (!password) {
        this.errors.password = '密码不能为空'
      } else if (password.length < 6) {
        this.errors.password = '密码至少6位'
      } else {
        this.errors.password = ''
      }
    }
  }
}
</script>

五、性能优化与注意事项

1. 避免无限循环

export default {
  data() {
    return {
      count: 0,
      doubled: 0
    }
  },
  
  watch: {
    count: {
      handler(newVal) {
        // ❌ 危险:可能导致无限循环
        this.doubled = newVal * 2
        
        // 在某些条件下修改自身依赖
        if (newVal > 10) {
          this.count = 10  // 这会导致循环
        }
      },
      immediate: true
    }
  }
}

2. 合理使用 deep 监听

export default {
  data() {
    return {
      config: {
        theme: 'dark',
        notifications: {
          email: true,
          push: false
        }
      }
    }
  },
  
  watch: {
    // ❌ 过度使用 deep
    config: {
      handler() {
        this.saveConfig()
      },
      deep: true,  // 整个对象深度监听,性能开销大
      immediate: true
    },
    
    // ✅ 精确监听
    'config.theme': {
      handler(newTheme) {
        this.applyTheme(newTheme)
      },
      immediate: true
    },
    
    // ✅ 监听特定嵌套属性
    'config.notifications.email': {
      handler(newValue) {
        this.updateNotificationPref('email', newValue)
      },
      immediate: true
    }
  }
}

3. 异步操作的防抖与取消

export default {
  data() {
    return {
      searchInput: '',
      searchRequest: null
    }
  },
  
  watch: {
    searchInput: {
      async handler(newVal) {
        // 取消之前的请求
        if (this.searchRequest) {
          this.searchRequest.cancel('取消旧请求')
        }
        
        // 创建新的可取消请求
        this.searchRequest = this.$axios.CancelToken.source()
        
        try {
          const response = await api.search(newVal, {
            cancelToken: this.searchRequest.token
          })
          this.searchResults = response.data
        } catch (error) {
          if (!this.$axios.isCancel(error)) {
            console.error('搜索错误:', error)
          }
        }
      },
      immediate: true,
      debounce: 300  // 需要配合 debounce 插件
    }
  }
}

六、Vue 3 Composition API 特别指南

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

const userId = ref(null)
const userData = ref(null)
const loading = ref(false)

// 方案1: watch + immediate
watch(
  userId,
  async (newId) => {
    loading.value = true
    try {
      userData.value = await fetchUser(newId)
    } finally {
      loading.value = false
    }
  },
  { immediate: true }  // ✅ 立即执行
)

// 方案2: watchEffect(自动追踪依赖)
const searchQuery = ref('')
const searchResults = ref([])

watchEffect(async () => {
  // 自动追踪 searchQuery 依赖
  if (searchQuery.value.trim()) {
    const results = await searchApi(searchQuery.value)
    searchResults.value = results
  } else {
    searchResults.value = []
  }
})  // ✅ watchEffect 会立即执行一次

// 方案3: 自定义立即执行的 composable
function useImmediateWatch(source, callback, options = {}) {
  const { immediate = true, ...watchOptions } = options
  
  // 立即执行
  if (immediate && source.value !== undefined) {
    callback(source.value, undefined)
  }
  
  return watch(source, callback, watchOptions)
}

// 使用
const filters = ref({ category: 'all' })
useImmediateWatch(
  filters,
  (newFilters) => {
    applyFilters(newFilters)
  },
  { deep: true }
)
</script>

七、决策流程图

graph TD
    A[需要初始化执行watch] --> B{场景分析}
    
    B -->|简单监听,逻辑不复杂| C[方案1: immediate:true]
    B -->|复杂逻辑,需要复用| D[方案2: 提取方法]
    B -->|派生数据,无副作用| E[方案3: 计算属性]
    B -->|Vue3,需要组合复用| F[方案4: 自定义Hook]
    B -->|多个相似监听器| G[方案5: 工厂函数]
    
    C --> H[完成]
    D --> H
    E --> H
    F --> H
    G --> H
    
    style C fill:#e1f5e1
    style D fill:#e1f5e1

八、总结与最佳实践

核心原则:

  1. 优先使用 immediate: true - 对于简单的监听需求
  2. 复杂逻辑提取方法 - 提高可测试性和复用性
  3. 避免副作用在计算属性中 - 保持计算属性的纯函数特性
  4. Vue 3 优先使用 Composition API - 更好的逻辑组织和复用

代码规范建议:

// ✅ 良好实践
export default {
  watch: {
    // 明确注释为什么需要立即执行
    userId: {
      handler: 'loadUserData', // 使用方法名,更清晰
      immediate: true // 初始化时需要加载用户数据
    }
  },
  
  created() {
    // 复杂初始化逻辑放在 created
    this.initializeComponent()
  },
  
  methods: {
    loadUserData(userId) {
      // 可复用的方法
    },
    
    initializeComponent() {
      // 集中处理初始化逻辑
    }
  }
}

常见陷阱提醒:

  1. 不要immediate 回调中修改依赖数据(可能导致循环)
  2. 谨慎使用 deep: true,特别是对于大型对象
  3. 记得清理手动创建的侦听器(避免内存泄漏)
  4. 考虑 SSR 场景下 immediate 的执行时机

Vue 三剑客:组件、插件、插槽的深度辨析

Vue 三剑客:组件、插件、插槽的深度辨析

组件、插件、插槽是 Vue 生态中的三个核心概念,理解它们的差异是掌握 Vue 架构设计的关键。让我们通过一个完整的对比体系来彻底搞懂它们。

一、核心概念全景图

graph TB
    A[Vue 核心概念] --> B[Component 组件]
    A --> C[Plugin 插件]
    A --> D[Slot 插槽]
    
    B --> B1[UI 复用单元]
    B --> B2[局部作用域]
    B --> B3[父子通信]
    
    C --> C1[全局功能扩展]
    C --> C2[一次配置]
    C --> C3[多组件共享]
    
    D --> D1[内容分发]
    D --> D2[灵活占位]
    D --> D3[模板组合]
    
    B --> E[使用插件]
    B --> F[包含插槽]
    C --> G[增强组件]
    D --> H[扩展组件]

二、三者的本质区别:一句话概括

概念 本质 类比
组件 可复用的 UI 单元 乐高积木块
插件 全局功能扩展包 乐高工具箱
插槽 组件的内容占位符 乐高积木上的接口

三、组件 (Component) - Vue 的基石

定义与核心特征

组件是 Vue 应用的构建块,每个组件都是自包含的、可复用的 Vue 实例。

<!-- UserCard.vue - 组件示例 -->
<template>
  <div class="user-card">
    <img :src="avatar" alt="用户头像" />
    <h3>{{ name }}</h3>
    <p>{{ bio }}</p>
    <!-- 使用插槽提供扩展点 -->
    <slot name="actions"></slot>
  </div>
</template>

<script>
export default {
  // 组件定义
  name: 'UserCard',
  props: {
    name: String,
    avatar: String,
    bio: String
  },
  // 局部状态
  data() {
    return {
      isActive: false
    }
  },
  // 生命周期
  mounted() {
    console.log('组件已挂载')
  }
}
</script>

<style scoped>
.user-card {
  border: 1px solid #ccc;
  padding: 20px;
}
</style>

组件的核心能力:

// 1. 组件注册
// 全局注册
Vue.component('global-component', {
  template: '<div>全局组件</div>'
})

// 局部注册
const LocalComponent = {
  template: '<div>局部组件</div>'
}

new Vue({
  components: {
    'local-component': LocalComponent
  }
})

// 2. 组件通信体系
const ParentComponent = {
  template: `
    <child-component 
      :title="parentTitle" 
      @child-event="handleChildEvent"
    />
  `,
  methods: {
    handleChildEvent(payload) {
      // 处理子组件事件
    }
  }
}

四、插件 (Plugin) - Vue 的扩展系统

定义与核心特征

插件是对 Vue 的全局增强,用于添加全局级的功能。

// my-plugin.js - 自定义插件
const MyPlugin = {
  install(Vue, options) {
    // 1. 添加全局方法或属性
    Vue.myGlobalMethod = function() {
      console.log('全局方法')
    }
    
    // 2. 添加全局资源(指令/过滤器/组件)
    Vue.directive('my-directive', {
      bind(el, binding) {
        // 指令逻辑
      }
    })
    
    // 3. 注入组件选项
    Vue.mixin({
      created() {
        console.log('所有组件都会执行')
      }
    })
    
    // 4. 添加实例方法
    Vue.prototype.$myMethod = function() {
      console.log('实例方法')
    }
  }
}

// 使用插件
Vue.use(MyPlugin, { someOption: true })

常见插件类型:

// 1. UI 组件库插件
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)

// 2. 功能增强插件
import VueRouter from 'vue-router'
Vue.use(VueRouter)

import Vuex from 'vuex'
Vue.use(Vuex)

// 3. 工具类插件
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
  loading: '/loading.gif'
})

插件 vs 组件的关键差异:

对比维度 组件 插件
作用范围 局部(需要显式引入) 全局(一次配置,处处可用)
主要目的 构建 UI 界面 增强 Vue 本身的能力
使用频率 高频率、多次使用 一次性配置
典型示例 Button、Modal、Form Router、Vuex、i18n

五、插槽 (Slot) - 组件的灵活扩展点

定义与核心特征

插槽是组件的内容分发出口,让父组件可以向子组件传递模板内容。

<!-- BaseLayout.vue - 包含插槽的组件 -->
<template>
  <div class="container">
    <header>
      <!-- 具名插槽 -->
      <slot name="header"></slot>
    </header>
    
    <main>
      <!-- 默认插槽 -->
      <slot>
        <!-- 后备内容(当父组件不提供内容时显示) -->
        <p>默认内容</p>
      </slot>
    </main>
    
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

插槽的三种类型:

<!-- 父组件使用 -->
<template>
  <BaseLayout>
    <!-- 具名插槽 -->
    <template v-slot:header>
      <h1>页面标题</h1>
    </template>
    
    <!-- 默认插槽(简写) -->
    <p>主要内容区域</p>
    <p>这是默认插槽的内容</p>
    
    <!-- 作用域插槽 -->
    <template v-slot:footer="slotProps">
      <p>页脚: {{ slotProps.year }} 年</p>
    </template>
    
    <!-- 动态插槽名 -->
    <template v-slot:[dynamicSlotName]>
      动态内容
    </template>
  </BaseLayout>
</template>

作用域插槽(高级模式):

<!-- TodoList.vue -->
<template>
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <!-- 作用域插槽:向父组件暴露数据 -->
      <slot :todo="todo" :index="index">
        <!-- 默认显示 -->
        {{ todo.text }}
      </slot>
    </li>
  </ul>
</template>

<!-- 父组件接收数据 -->
<template>
  <TodoList :todos="todos">
    <template v-slot:default="slotProps">
      <span :class="{ completed: slotProps.todo.done }">
        {{ slotProps.index + 1 }}. {{ slotProps.todo.text }}
      </span>
    </template>
  </TodoList>
</template>

六、三者协同工作的完整示例

让我们通过一个实战项目理解三者如何协同:

// 1. 首先安装路由插件
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)  // 🔴 插件:全局启用路由功能

// 2. 定义可复用的布局组件
const AppLayout = {
  template: `
    <div class="app-layout">
      <slot name="navbar"></slot>
      <div class="content">
        <!-- 默认插槽用于显示页面内容 -->
        <slot></slot>
      </div>
      <slot name="footer"></slot>
    </div>
  `
}

// 3. 创建页面组件
const HomePage = {
  template: `
    <AppLayout>
      <template v-slot:navbar>
        <!-- 向布局组件传递自定义导航栏 -->
        <NavBar title="首页" />
      </template>
      
      <!-- 默认插槽内容 -->
      <h1>欢迎访问</h1>
      <ProductList>
        <!-- 作用域插槽自定义产品显示 -->
        <template v-slot:product="props">
          <ProductCard :product="props.product" />
        </template>
      </ProductList>
      
      <template v-slot:footer>
        <AppFooter />
      </template>
    </AppLayout>
  `,
  components: {
    AppLayout,      // 🔵 组件:布局组件
    NavBar,         // 🔵 组件:导航栏组件
    ProductList,    // 🔵 组件:产品列表
    ProductCard,    // 🔵 组件:产品卡片
    AppFooter       // 🔵 组件:页脚组件
  }
}

// 4. 配置路由(使用插件提供的功能)
const router = new VueRouter({
  routes: [
    {
      path: '/',
      component: HomePage  // 使用组件作为路由页面
    }
  ]
})

// 5. 创建Vue实例
new Vue({
  router,  // 🔴 插件提供的路由实例
  template: '<router-view></router-view>'  // 🟡 插槽:路由视图占位
}).$mount('#app')

七、设计模式与最佳实践

何时使用什么?

graph LR
    A[需求分析] --> B{需要什么?}
    
    B -->|构建UI界面| C[使用组件]
    B -->|全局功能扩展| D[使用插件]
    B -->|自定义组件内容| E[使用插槽]
    
    C --> F{组件需要灵活性?}
    F -->|是| G[在组件中添加插槽]
    F -->|否| H[创建完整组件]
    
    D --> I{功能需要复用?}
    I -->|是| J[开发为插件]
    I -->|否| K[使用局部混入]

组件设计原则:

<!-- 好的组件设计示例 -->
<template>
  <!-- 提供清晰的插槽接口 -->
  <div class="card">
    <div class="card-header" v-if="$slots.header">
      <slot name="header"></slot>
    </div>
    
    <div class="card-body">
      <slot>
        <!-- 合理的默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
    
    <!-- 作用域插槽提供数据 -->
    <div class="card-footer" v-if="$slots.footer">
      <slot name="footer" :data="footerData"></slot>
    </div>
  </div>
</template>

插件开发规范:

// 良好的插件结构
const WellDesignedPlugin = {
  install(Vue, options = {}) {
    // 1. 参数验证
    if (!options.requiredConfig) {
      console.warn('插件需要配置 requiredConfig')
    }
    
    // 2. 安全的全局扩展
    const version = Number(Vue.version.split('.')[0])
    if (version >= 2) {
      Vue.prototype.$safeMethod = function() {
        // 兼容性处理
      }
    }
    
    // 3. 提供卸载方法
    const originalDestroy = Vue.prototype.$destroy
    Vue.prototype.$destroy = function() {
      // 清理逻辑
      originalDestroy.call(this)
    }
  }
}

八、常见误区与澄清

误区1:插件可以替代组件

// ❌ 错误:用插件实现UI组件
Vue.use({
  install(Vue) {
    Vue.prototype.$showModal = function(content) {
      // 这应该是组件,不是插件
    }
  }
})

// ✅ 正确:组件实现UI,插件封装工具
// Modal.vue - 作为组件
// modal-plugin.js - 如果需要全局调用,可以包装为插件

误区2:插槽就是子组件

<!-- ❌ 误解:插槽是子组件 -->
<Parent>
  <Child />  <!-- 这是组件,不是插槽内容 -->
</Parent>

<!-- ✅ 正确理解 -->
<Parent>
  <!-- 这是插槽内容,会被分发到Parent的<slot>位置 -->
  <template v-slot:default>
    <Child />
  </template>
</Parent>

误区3:过度使用混入(Mixin)

// ❌ 过度使用:应该用插槽或组合式API代替
Vue.mixin({
  data() {
    return {
      globalData: '应该避免'
    }
  }
})

// ✅ 更好的方式:组合式函数(Vue 3)
// 或使用作用域插槽传递数据

九、Vue 3 中的演进

组合式 API 的影响:

<!-- Vue 3 中三者关系更加清晰 -->
<script setup>
// 1. 组件 - 更简洁的定义
import { defineComponent } from 'vue'

// 2. 插件 - 通过 provide/inject 更好地集成
import { provide } from 'vue'
provide('pluginData', data)

// 3. 插槽 - 更灵活的用法
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <!-- 插槽作用域解构 -->
  <slot name="item" v-bind="{ id, name }"></slot>
</template>

十、总结:三位一体的 Vue 架构

概念 角色 关键特征 最佳实践
组件 构建者 局部作用域、props/events接口、可复用 单一职责、合理拆分、明确接口
插件 增强者 全局作用域、一次配置、功能扩展 轻量封装、提供选项、良好文档
插槽 连接者 内容分发、模板组合、作用域暴露 明确命名、提供后备、作用域数据

记住这个核心公式:

应用 = 插件增强的Vue实例 + 组件构建的UI树 + 插槽连接的组件关系

最终决策指南:

  1. 当你需要...

    • 复用UI片段 → 创建组件
    • 添加全局功能 → 开发插件
    • 自定义组件内部结构 → 使用插槽
  2. 在架构中...

    • 插件在最外层配置全局能力
    • 组件在中间层构建功能模块
    • 插槽在最内层实现灵活定制
  3. 进化方向...

    • Vue 2:Options API + 三者分明
    • Vue 3:Composition API + 更灵活的组合

Vue 组件模板的 7 种定义方式:从基础到高级的完整指南

Vue 组件模板的 7 种定义方式:从基础到高级的完整指南

模板是 Vue 组件的核心视图层,但你可能不知道它竟有如此多灵活的定义方式。掌握这些技巧,让你的组件开发更加得心应手。

一、模板定义全景图

在深入细节之前,先了解 Vue 组件模板的完整知识体系:

graph TD
    A[Vue 组件模板] --> B[单文件组件 SFC]
    A --> C[内联模板]
    A --> D[字符串模板]
    A --> E[渲染函数]
    A --> F[JSX]
    A --> G[动态组件]
    A --> H[函数式组件]
    
    B --> B1[&lttemplate&gt标签]
    B --> B2[作用域 slot]
    
    D --> D1[template 选项]
    D --> D2[内联模板字符串]
    
    E --> E1[createElement]
    E --> E2[h 函数]
    
    G --> G1[component:is]
    G --> G2[异步组件]

下面我们来详细探讨每种方式的特点和适用场景。

二、7 种模板定义方式详解

1. 单文件组件(SFC)模板 - 现代 Vue 开发的标准

<!-- UserProfile.vue -->
<template>
  <!-- 最常用、最推荐的方式 -->
  <div class="user-profile">
    <h2>{{ user.name }}</h2>
    <img :src="user.avatar" alt="Avatar" />
    <slot name="actions"></slot>
  </div>
</template>

<script>
export default {
  props: ['user']
}
</script>

<style scoped>
.user-profile {
  padding: 20px;
}
</style>

特点:

  • ✅ 语法高亮和提示
  • ✅ CSS 作用域支持
  • ✅ 良好的可维护性
  • ✅ 构建工具优化(如 Vue Loader)

最佳实践:

<template>
  <!-- 始终使用单个根元素(Vue 2) -->
  <div class="container">
    <!-- 使用 PascalCase 的组件名 -->
    <UserProfile :user="currentUser" />
    
    <!-- 复杂逻辑使用计算属性 -->
    <p v-if="shouldShowMessage">{{ formattedMessage }}</p>
  </div>
</template>

2. 字符串模板 - 简单场景的轻量选择

// 方式1:template 选项
new Vue({
  el: '#app',
  template: `
    <div class="app">
      <h1>{{ title }}</h1>
      <button @click="handleClick">点击</button>
    </div>
  `,
  data() {
    return {
      title: '字符串模板示例'
    }
  },
  methods: {
    handleClick() {
      alert('按钮被点击')
    }
  }
})

// 方式2:内联模板字符串
const InlineComponent = {
  template: '<div>{{ message }}</div>',
  data() {
    return { message: 'Hello' }
  }
}

适用场景:

  • 简单的 UI 组件
  • 快速原型开发
  • 小型项目或演示代码

注意事项:

// ⚠️ 模板字符串中的换行和缩进
const BadTemplate = `
<div>
  <p>第一行
  </p>
</div>  // 缩进可能被包含

// ✅ 使用模板字面量保持整洁
const GoodTemplate = `<div>
  <p>第一行</p>
</div>`

3. 内联模板 - 快速但不推荐

<!-- 父组件 -->
<div id="parent">
  <child-component inline-template>
    <!-- 直接在 HTML 中写模板 -->
    <div>
      <p>来自子组件: {{ childData }}</p>
      <p>来自父组件: {{ parentMessage }}</p>
    </div>
  </child-component>
</div>

<script>
new Vue({
  el: '#parent',
  data: {
    parentMessage: '父组件数据'
  },
  components: {
    'child-component': {
      data() {
        return { childData: '子组件数据' }
      }
    }
  }
})
</script>

⚠️ 警告:

  • ❌ 作用域难以理解
  • ❌ 破坏组件封装性
  • ❌ 不利于维护
  • ✅ 唯一优势:快速原型

4. X-Templates - 分离但老式

<!-- 在 HTML 中定义模板 -->
<script type="text/x-template" id="user-template">
  <div class="user">
    <h3>{{ name }}</h3>
    <p>{{ email }}</p>
  </div>
</script>

<script>
// 在 JavaScript 中引用
Vue.component('user-component', {
  template: '#user-template',
  props: ['name', 'email']
})
</script>

特点:

  • 🟡 模板与逻辑分离
  • 🟡 无需构建工具
  • ❌ 全局命名空间污染
  • ❌ 无法使用构建工具优化

5. 渲染函数 - 完全的 JavaScript 控制力

// 基本渲染函数
export default {
  props: ['items'],
  render(h) {
    return h('ul', 
      this.items.map(item => 
        h('li', { key: item.id }, item.name)
      )
    )
  }
}

// 带条件渲染和事件
export default {
  data() {
    return { count: 0 }
  },
  render(h) {
    return h('div', [
      h('h1', `计数: ${this.count}`),
      h('button', {
        on: {
          click: () => this.count++
        }
      }, '增加')
    ])
  }
}

高级模式 - 动态组件工厂:

// 组件工厂函数
const ComponentFactory = {
  functional: true,
  props: ['type', 'data'],
  render(h, { props }) {
    const components = {
      text: TextComponent,
      image: ImageComponent,
      video: VideoComponent
    }
    
    const Component = components[props.type]
    return h(Component, {
      props: { data: props.data }
    })
  }
}

// 动态 slot 内容
const LayoutComponent = {
  render(h) {
    // 获取具名 slot
    const header = this.$slots.header
    const defaultSlot = this.$slots.default
    const footer = this.$slots.footer
    
    return h('div', { class: 'layout' }, [
      header && h('header', header),
      h('main', defaultSlot),
      footer && h('footer', footer)
    ])
  }
}

6. JSX - React 开发者的福音

// .vue 文件中使用 JSX
<script>
export default {
  data() {
    return {
      items: ['Vue', 'React', 'Angular']
    }
  },
  render() {
    return (
      <div class="jsx-demo">
        <h1>JSX 在 Vue 中</h1>
        <ul>
          {this.items.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
        {/* 使用指令 */}
        <input vModel={this.inputValue} />
        {/* 事件监听 */}
        <button onClick={this.handleClick}>点击</button>
      </div>
    )
  }
}
</script>

配置方法:

// babel.config.js
module.exports = {
  presets: ['@vue/cli-plugin-babel/preset'],
  plugins: [
    '@vue/babel-plugin-jsx' // 启用 Vue JSX 支持
  ]
}

JSX vs 模板:

// JSX 的优势:动态性更强
const DynamicList = {
  props: ['config'],
  render() {
    const { tag: Tag, items, itemComponent: Item } = this.config
    
    return (
      <Tag class="dynamic-list">
        {items.map(item => (
          <Item item={item} />
        ))}
      </Tag>
    )
  }
}

7. 动态组件 - 运行时模板决策

<template>
  <!-- component:is 动态组件 -->
  <component 
    :is="currentComponent"
    v-bind="currentProps"
    @custom-event="handleEvent"
  />
</template>

<script>
import TextEditor from './TextEditor.vue'
import ImageUploader from './ImageUploader.vue'
import VideoPlayer from './VideoPlayer.vue'

export default {
  data() {
    return {
      componentType: 'text',
      content: ''
    }
  },
  computed: {
    currentComponent() {
      const components = {
        text: TextEditor,
        image: ImageUploader,
        video: VideoPlayer
      }
      return components[this.componentType]
    },
    currentProps() {
      // 根据组件类型传递不同的 props
      const baseProps = { content: this.content }
      
      if (this.componentType === 'image') {
        return { ...baseProps, maxSize: '5MB' }
      }
      
      return baseProps
    }
  }
}
</script>

三、进阶技巧:混合模式与优化

1. 模板与渲染函数结合

<template>
  <!-- 使用模板定义主体结构 -->
  <div class="data-table">
    <table-header :columns="columns" />
    <table-body :render-row="renderTableRow" />
  </div>
</template>

<script>
export default {
  methods: {
    // 使用渲染函数处理复杂行渲染
    renderTableRow(h, row) {
      return h('tr', 
        this.columns.map(column => 
          h('td', {
            class: column.className,
            style: column.style
          }, column.formatter ? column.formatter(row) : row[column.key])
        )
      )
    }
  }
}
</script>

2. 高阶组件模式

// 高阶组件:增强模板功能
function withLoading(WrappedComponent) {
  return {
    render(h) {
      const directives = [
        {
          name: 'loading',
          value: this.isLoading,
          expression: 'isLoading'
        }
      ]
      
      return h('div', { directives }, [
        h(WrappedComponent, {
          props: this.$attrs,
          on: this.$listeners
        }),
        this.isLoading && h(LoadingSpinner)
      ])
    },
    data() {
      return { isLoading: false }
    },
    mounted() {
      // 加载逻辑
    }
  }
}

3. SSR 优化策略

// 服务端渲染友好的模板
export default {
  // 客户端激活所需
  mounted() {
    // 仅客户端的 DOM 操作
    if (process.client) {
      this.initializeThirdPartyLibrary()
    }
  },
  
  // 服务端渲染优化
  serverPrefetch() {
    // 预取数据
    return this.fetchData()
  },
  
  // 避免客户端 hydration 不匹配
  template: `
    <div>
      <!-- 避免使用随机值 -->
      <p>服务器时间: {{ serverTime }}</p>
      
      <!-- 避免使用 Date.now() 等 -->
      <!-- 服务端和客户端要一致 -->
    </div>
  `
}

四、选择指南:如何决定使用哪种方式?

场景 推荐方式 理由
生产级应用 单文件组件(SFC) 最佳开发体验、工具链支持、可维护性
UI 组件库 SFC + 渲染函数 SFC 提供开发体验,渲染函数处理动态性
高度动态 UI 渲染函数/JSX 完全的 JavaScript 控制力
React 团队迁移 JSX 降低学习成本
原型/演示 字符串模板 快速、简单
遗留项目 X-Templates 渐进式迁移
服务端渲染 SFC(注意 hydration) 良好的 SSR 支持

决策流程图:

graph TD
    A[开始选择模板方式] --> B{需要构建工具?}
    B -->|是| C{组件动态性强?}
    B -->|否| D[使用字符串模板或X-Templates]
    
    C -->|是| E{团队熟悉JSX?}
    C -->|否| F[使用单文件组件SFC]
    
    E -->|是| G[使用JSX]
    E -->|否| H[使用渲染函数]
    
    D --> I[完成选择]
    F --> I
    G --> I
    H --> I

五、性能与最佳实践

1. 编译时 vs 运行时模板

// Vue CLI 默认配置优化了 SFC
module.exports = {
  productionSourceMap: false, // 生产环境不生成 source map
  runtimeCompiler: false, // 不使用运行时编译器,减小包体积
}

2. 模板预编译

// 手动预编译模板
const { compile } = require('vue-template-compiler')

const template = `<div>{{ message }}</div>`
const compiled = compile(template)

console.log(compiled.render)
// 输出渲染函数,可直接在组件中使用

3. 避免的常见反模式

<!-- ❌ 避免在模板中使用复杂表达式 -->
<template>
  <div>
    <!-- 反模式:复杂逻辑在模板中 -->
    <p>{{ user.firstName + ' ' + user.lastName + ' (' + user.age + ')' }}</p>
    
    <!-- 正确:使用计算属性 -->
    <p>{{ fullNameWithAge }}</p>
  </div>
</template>

<script>
export default {
  computed: {
    fullNameWithAge() {
      return `${this.user.firstName} ${this.user.lastName} (${this.user.age})`
    }
  }
}
</script>

六、Vue 3 的新变化

<!-- Vue 3 组合式 API + SFC -->
<template>
  <!-- 支持多个根节点(Fragment) -->
  <header>{{ title }}</header>
  <main>{{ content }}</main>
  <footer>{{ footerText }}</footer>
</template>

<script setup>
// 更简洁的语法
import { ref, computed } from 'vue'

const title = ref('Vue 3 组件')
const content = ref('新特性介绍')

const footerText = computed(() => `© ${new Date().getFullYear()}`)
</script>

总结

Vue 提供了从声明式到命令式的完整模板方案光谱:

  1. 声明式端:SFC 模板 → 易读易写,适合大多数业务组件
  2. 命令式端:渲染函数/JSX → 完全控制,适合高阶组件和库
  3. 灵活选择:根据项目需求和团队偏好选择合适的方式

记住这些关键原则:

  • 默认使用 SFC,除非有特殊需求
  • 保持一致性,一个项目中不要混用太多模式
  • 性能考量:生产环境避免运行时编译
  • 团队协作:选择团队最熟悉的方式

深入理解 Vue 生命周期:created 与 mounted 的核心差异与实战指南

深入理解 Vue 生命周期:created 与 mounted 的核心差异与实战指南

掌握生命周期钩子,是 Vue 开发从入门到精通的关键一步。今天我们来深度剖析两个最容易混淆的钩子:createdmounted

一、生命周期全景图:先看森林,再见树木

在深入细节之前,让我们先回顾 Vue 实例的完整生命周期:

graph TD
    A[new Vue()] --> B[Init Events & Lifecycle]
    B --> C[beforeCreate]
    C --> D[Init Injections & Reactivity]
    D --> E[created]
    E --> F[Compile Template]
    F --> G[beforeMount]
    G --> H[Create vm.$el]
    H --> I[mounted]
    I --> J[Data Changes]
    J --> K[beforeUpdate]
    K --> L[Virtual DOM Re-render]
    L --> M[updated]
    M --> N[beforeDestroy]
    N --> O[Teardown]
    O --> P[destroyed]

理解这张图,你就掌握了 Vue 组件从出生到消亡的完整轨迹。而今天的主角——createdmounted,正是这个旅程中两个关键的里程碑。

二、核心对比:created vs mounted

让我们通过一个表格直观对比:

特性 created mounted
执行时机 数据观测/方法/计算属性初始化后,模板编译前 模板编译完成,DOM 挂载到页面后
DOM 可访问性 ❌ 无法访问 DOM ✅ 可以访问 DOM
$el 状态 undefined 已挂载的 DOM 元素
主要用途 数据初始化、API 调用、事件监听 DOM 操作、第三方库初始化
SSR 支持 ✅ 在服务端和客户端都会执行 ❌ 仅在客户端执行

三、实战代码解析:从理论到实践

场景 1:API 数据获取的正确姿势

export default {
  data() {
    return {
      userData: null,
      loading: true
    }
  },
  
  async created() {
    // ✅ 最佳实践:在 created 中发起数据请求
    // 此时数据观测已就绪,可以设置响应式数据
    try {
      this.userData = await fetchUserData()
    } catch (error) {
      console.error('数据获取失败:', error)
    } finally {
      this.loading = false
    }
    
    // ❌ 这里访问 DOM 会失败
    // console.log(this.$el) // undefined
  },
  
  mounted() {
    // ✅ DOM 已就绪,可以执行依赖 DOM 的操作
    const userCard = document.getElementById('user-card')
    if (userCard) {
      // 使用第三方图表库渲染数据
      this.renderChart(userCard, this.userData)
    }
    
    // ✅ 初始化需要 DOM 的第三方插件
    this.initCarousel('.carousel-container')
  }
}

关键洞察:数据获取应尽早开始(created),DOM 相关操作必须等待 mounted。

场景 2:计算属性与 DOM 的微妙关系

<template>
  <div ref="container">
    <p>容器宽度: {{ containerWidth }}px</p>
    <div class="content">
      <!-- 动态内容 -->
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: []
    }
  },
  
  computed: {
    // ❌ 错误示例:在 created 阶段访问 $refs
    containerWidth() {
      // created 阶段:this.$refs.container 是 undefined
      // mounted 阶段:可以正常访问
      return this.$refs.container?.offsetWidth || 0
    }
  },
  
  created() {
    // ✅ 安全操作:初始化数据
    this.items = this.generateItems()
    
    // ⚠️ 注意:computed 属性在此阶段可能基于错误的前提计算
    console.log('created 阶段宽度:', this.containerWidth) // 0
  },
  
  mounted() {
    console.log('mounted 阶段宽度:', this.containerWidth) // 实际宽度
    
    // ✅ 正确的 DOM 相关初始化
    this.observeResize()
  },
  
  methods: {
    observeResize() {
      // 使用 ResizeObserver 监听容器大小变化
      const observer = new ResizeObserver(entries => {
        this.handleResize(entries[0].contentRect.width)
      })
      observer.observe(this.$refs.container)
    }
  }
}
</script>

四、性能优化:理解渲染流程避免常见陷阱

1. 避免在 created 中执行阻塞操作

export default {
  created() {
    // ⚠️ 潜在的渲染阻塞
    this.processLargeData(this.rawData) // 如果处理时间过长,会延迟首次渲染
    
    // ✅ 优化方案:使用 Web Worker 或分块处理
    this.asyncProcessData()
  },
  
  async asyncProcessData() {
    // 使用 requestIdleCallback 避免阻塞主线程
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        this.processInBackground()
      })
    } else {
      // 回退方案:setTimeout 让出主线程
      setTimeout(() => this.processInBackground(), 0)
    }
  }
}

2. 理解异步更新队列

export default {
  mounted() {
    // 情景 1:直接修改数据
    this.someData = 'new value'
    console.log(this.$el.textContent) // ❌ 可能还是旧值
    
    // 情景 2:使用 $nextTick
    this.someData = 'new value'
    this.$nextTick(() => {
      console.log(this.$el.textContent) // ✅ 更新后的值
    })
    
    // 情景 3:多个数据变更
    this.data1 = 'new1'
    this.data2 = 'new2'
    this.data3 = 'new3'
    
    // Vue 会批量处理,只触发一次更新
    this.$nextTick(() => {
      // 所有变更都已反映到 DOM
    })
  }
}

五、高级应用:SSR 场景下的特殊考量

export default {
  // created 在服务端和客户端都会执行
  async created() {
    // 服务端渲染时,无法访问 window、document 等浏览器 API
    if (process.client) {
      // 客户端特定逻辑
      this.screenWidth = window.innerWidth
    }
    
    // 数据预取(Universal)
    await this.fetchUniversalData()
  },
  
  // mounted 只在客户端执行
  mounted() {
    // 安全的浏览器 API 使用
    this.initializeBrowserOnlyLibrary()
    
    // 处理客户端 hydration
    this.handleHydrationEffects()
  },
  
  // 兼容 SSR 的数据获取模式
  async fetchUniversalData() {
    // 避免重复获取数据
    if (this.$ssrContext && this.$ssrContext.data) {
      // 服务端已获取数据
      Object.assign(this, this.$ssrContext.data)
    } else {
      // 客户端获取数据
      const data = await this.$axios.get('/api/data')
      Object.assign(this, data)
    }
  }
}

六、实战技巧:常见问题与解决方案

Q1:应该在哪个钩子初始化第三方库?

export default {
  mounted() {
    // ✅ 大多数 UI 库需要 DOM 存在
    this.$nextTick(() => {
      // 确保 DOM 完全渲染
      this.initSelect2('#my-select')
      this.initDatepicker('.date-input')
    })
  },
  
  beforeDestroy() {
    // 记得清理,防止内存泄漏
    this.destroySelect2()
    this.destroyDatepicker()
  }
}

Q2:如何处理动态组件?

<template>
  <component :is="currentComponent" ref="dynamicComponent" />
</template>

<script>
export default {
  data() {
    return {
      currentComponent: 'ComponentA'
    }
  },
  
  watch: {
    currentComponent(newVal, oldVal) {
      // 组件切换时,新的 mounted 会在下次更新后执行
      this.$nextTick(() => {
        console.log('新组件已挂载:', this.$refs.dynamicComponent)
      })
    }
  },
  
  mounted() {
    // 初次挂载
    this.initializeCurrentComponent()
  }
}
</script>

七、最佳实践总结

  1. 数据初始化 → 优先选择 created
  2. DOM 操作 → 必须使用 mounted(配合 $nextTick 确保渲染完成)
  3. 第三方库初始化mounted + beforeDestroy 清理
  4. 性能敏感操作 → 考虑使用 requestIdleCallback 或 Web Worker
  5. SSR 应用 → 注意浏览器 API 的兼容性检查

写在最后

理解 createdmounted 的区别,本质上是理解 Vue 的渲染流程。记住这个核心原则:

created 是关于数据的准备,mounted 是关于视图的准备。

随着 Vue 3 Composition API 的普及,生命周期有了新的使用方式,但底层原理依然相通。掌握这些基础知识,能帮助你在各种场景下做出更合适的架构决策。

Vuex日渐式微?状态管理的三大痛点与新时代方案

作为Vue生态曾经的“官方标配”,Vuex在无数项目中立下汗马功劳。但近年来,随着Vue 3和Composition API的崛起,越来越多的开发者开始重新审视这个老牌状态管理库。

Vuex的设计初衷:解决组件通信难题

回想Vue 2时代,当我们的应用从简单的单页面逐渐演变成复杂的中大型应用时,组件间的数据共享成为了一大痛点。

// 经典的Vuex store结构
const store = new Vuex.Store({
  state: {
    count0,
    usernull
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    async fetchUser({ commit }) {
      const user = await api.getUser()
      commit('SET_USER', user)
    }
  },
  getters: {
    doubleCountstate => state.count * 2
  }
})

这种集中式的状态管理模式,确实在当时解决了:

  • • 多个组件共享同一状态的问题
  • • 状态变更的可追溯性
  • • 开发工具的时间旅行调试

痛点浮现:Vuex的三大“时代局限”

1. 样板代码过多,开发体验繁琐

这是Vuex最常被诟病的问题。一个简单的状态更新,需要经过actionmutationstate的完整流程:

// 定义部分
const actions = {
  updateUser({ commit }, user) {
    commit('SET_USER', user)
  }
}

const mutations = {
  SET_USER(state, user) {
    state.user = user
  }
}

// 使用部分
this.$store.dispatch('updateUser', newUser)

相比之下,直接的状态赋值只需要一行代码。在中小型项目中,这种复杂度常常显得“杀鸡用牛刀”。

2. TypeScript支持不友好

虽然Vuex 4改进了TS支持,但其基于字符串的dispatchcommit调用方式,始终难以获得完美的类型推断:

// 类型安全较弱
store.commit('SET_USER', user) // 'SET_USER'字符串无类型检查

// 需要额外定义类型
interface User {
  idnumber
  namestring
}

// 但定义和使用仍是分离的

3. 模块系统复杂,代码组织困难

随着项目增大,Vuex的模块系统(namespaced modules)带来了新的复杂度:

// 访问模块中的状态需要命名空间前缀
computed: {
  ...mapState({
    userstate => state.moduleA.user
  })
}

// 派发action也需要前缀
this.$store.dispatch('moduleA/fetchData')

动态注册模块、模块间的依赖关系处理等问题,让代码维护成本逐渐升高。

新时代的解决方案:更轻量、更灵活的选择

方案一:Composition API + Provide/Inject

Vue 3的Composition API为状态管理提供了全新思路:

// 使用Composition API创建响应式store
export function useUserStore() {
  const user = ref<User | null>(null)
  
  const setUser = (newUser: User) => {
    user.value = newUser
  }
  
  return {
    user: readonly(user),
    setUser
  }
}

// 在组件中使用
const { user, setUser } = useUserStore()

优点

  • • 零依赖、零学习成本
  • • 完美的TypeScript支持
  • • 按需导入,Tree-shaking友好

方案二:Pinia——Vuex的现代继承者

Pinia被看作是“下一代Vuex”,解决了Vuex的许多痛点:

// 定义store
export const useUserStore = defineStore('user', {
  state() => ({
    usernull as User | null,
  }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser()
    },
  },
})

// 使用store
const userStore = useUserStore()
userStore.fetchUser()

Pinia的进步

  • • 移除mutations,actions可直接修改状态
  • • 完整的TypeScript支持
  • • 更简洁的API设计
  • • 支持Composition API和Options API

实战建议:如何选择?

根据我的项目经验,建议如下:

继续使用Vuex的情况

  • • 维护已有的Vue 2大型项目
  • • 团队已深度熟悉Vuex,且项目运行稳定
  • • 需要利用Vuex DevTools的特定功能

考虑迁移/使用新方案的情况

  • • 新项目:优先考虑Pinia
  • • Vue 3项目:中小型可用Composition API,大型推荐Pinia
  • • 对TypeScript要求高:直接选择Pinia

迁移策略:平稳过渡

如果你决定从Vuex迁移到Pinia,可以采取渐进式策略:

  1. 1. 并行运行:新旧store系统共存
  2. 2. 模块逐个迁移:按业务模块逐步迁移
  3. 3. 工具辅助:利用官方迁移指南和工具
// 迁移示例:将Vuex模块转为Pinia store
// Vuex版本
const userModule = {
  state: { name'' },
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// Pinia版本
const useUserStore = defineStore('user', {
  state() => ({ name'' }),
  actions: {
    setName(name: string) {
      this.name = name
    }
  }
})

写在最后

技术总是在不断演进。Vuex作为特定历史阶段的优秀解决方案,完成了它的使命。而今天,我们有更多、更好的选择。

核心不是追求最新技术,而是为项目选择最合适的工具。

对于大多数新项目,Pinia无疑是更现代、更优雅的选择。但对于已有的Vuex项目,除非有明确的痛点需要解决,否则“稳定压倒一切”。

从“死了么”到“活着记”:用Gmeek在数字世界留下思想印记

本文从近期热议的“死了么”App入手,探讨现代人对数字安全与思想存续的双重需求,详细介绍基于GitHub的极简博客框架Gmeek,阐述在数字时代通过博客记录思想、对抗遗忘的重要意义,鼓励读者建立个人数字思想家园。

github_gmeek.png

github_gmeek.png

引言

一款名为“死了么”的App近期引发广泛讨论。它以直白甚至略显生硬的名字,精准地切中了当代社会一个真实且规模庞大的群体需求。

可以说,“死了么”的火爆,是产品创意、社会情绪与网络传播共同作用的结果。它如同一面棱镜,折射出当代独居生活的潜在隐忧。

如今,独居者人数众多。他们往往独自奋斗,习惯“一个人扛下所有”,最担心的莫过于在突发疾病或意外时无人知晓。这款App之所以能够走红,恰恰在于它敏锐地捕捉到了现代人一种微妙而普遍的心理——“不愿日常打扰他人,却渴望在异常状况下被关注”。用户无需复杂的社交,仅通过简单的每日打卡,就能为自己构筑一道最低成本的“安全防线”。其付费下载量的快速增长,也印证了这一需求真实而强烈。

然而,人终有一死。那么,我们该如何留下生活过的痕迹?如何在日常中安顿内心、与自己对话?活着,不仅仅意味着没有死去,而是要有思想、有记录、有回响地活着。博客,作为一种数字化的表达方式,正成为越来越多人记录自我、分享见解、沉淀思想的平台。

最近读到一篇文章,颇受启发。People Die, but Long Live GitHub

people-die-but-long-live-github.png

people-die-but-long-live-github.png

最近,我还在 GitHub 上发现了一个开源项目——Gmeek。它是一个超轻量级的个人博客框架,完全基于 GitHub Pages、GitHub Issues 与 GitHub Actions 构建,可谓“All in GitHub”。无需本地部署,从搭建到写作,整个过程只需三步:前两步用 18 秒完成博客搭建,第三步即可开始书写。今天我们就来介绍下如何使用这款开源项目构建个人github博客。

Gmeek:三步构建你的数字思想家园

一个博客框架,超轻量级个人博客模板。完全基于Github PagesGithub IssuesGithub Actions。不需要本地部署,从搭建到写作,只需要18秒,2步搭建好博客,第3步就是写作。

github地址:github.com/Meekdai/Gme…

文档博客:blog.meekdai.com/tag.html#Gm…

gmeek_star.png

gmeek_star.png

gmeek_doc_blog.png

gmeek_doc_blog.png

快速开始

  1. 【创建仓库】点击通过模板创建仓库,建议仓库名称为XXX.github.io,其中XXX为你的github用户名。

xiuji_github_blog.png

xiuji_github_blog.png

  1. 【启用Pages】在仓库的SettingsPages->Build and deployment->Source下面选择Github Actions
  2. 【开始写作】打开一篇issue,开始写作,并且必须添加一个标签Label(至少添加一个),再保存issue后会自动创建博客内容,片刻后可通过XXX.github.io 访问(可进入Actions页面查看构建进度)。
  3. 【手动全局生成】这个步骤只有在修改config.json文件或者出现奇怪问题的时候,需要执行。
通过Actions->build Gmeek->Run workflow->里面的按钮全局重新生成一次

[!NOTE] issue必须添加一个标签Label(至少添加一个)

到此,提交完issue之后Actions页面构建完成之后就可以看到我们的博客了。

博主的github博客地址:xiuji008.github.io/

配置及使用

config.json 文件就是配置文件,在创建的仓库内可以找到,对应修改为自己的即可。

配置可参考Gmeek作者博文:blog.meekdai.com/post/Gmeek-…

static文件夹使用

  1. 在自己的仓库根目录下新建一个文件夹,名称必须是static。
  2. 然后在static文件内上传一些自己的文件,比如博客图片、插件js等。
  3. 通过手动全局生成一次成功后,你就可以通过 xxx.github.io/your.png 访问了

插件功能的使用

为了使得Gmeek的功能更加的丰富,Gmeek作者添加了插件的功能,目前已经有几个插件可以使用。大家可以直接复制文章中的配置代码使用,也可以把对应的插件文件拷贝到自己的static文件夹下使用。

计数工具 Vercount

  1. 全站添加计数工具Vercount,只需要在config.json文件内添加配置
"allHead":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekVercount.js'></script>",

2. 单个文章页添加Vercount,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekVercount.js'></script>"}## -->

gmeek_plugin_vercount.png

gmeek_plugin_vercount.png

TOC目录

  1. 所有文章页添加TOC目录,只需要在config.json文件内添加配置
"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekTOC.js'></script>",

2. 单个文章页添加TOC目录,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/GmeekTOC.js'></script>"}## -->

gmeek_plugins_toc.png

gmeek_plugins_toc.png

灯箱插件

[!TIP] 此插件由Tiengming编写,可以放大浏览文章中的图片,适合一些图片较多的文章。

  1. 所有文章页添加lightbox,只需要在config.json文件内添加配置
"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/lightbox.js'></script>",

2. 单个文章页添加lightbox,只需要在文章最后一行添加如下

<!-- ##{"script":"<script src='https://blog.meekdai.com/Gmeek/plugins/lightbox.js'></script>"}## -->

看板娘(花里胡哨)

[!TIP] 此插件从github开源项目live2d-widget引入,纯属页面展示

  1. 所有文章页添加lightbox,只需要在config.json文件内添加配置
"script":"<script src='https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js'></script>",

gmeek_plugins_live2d-widget.png

gmeek_plugins_live2d-widget.png

对看板娘项目感兴趣的伙伴也可以研究下

看板娘项目github地址:github.com/stevenjoezh…

github_live2d_widget.png

github_live2d_widget.png

多插件使用

同时在所有文章页使用TOC目录、灯箱插件及其它插件,需要这样添加配置文件:

    "allHead":"<script src='https://xiuji008.github.io/plugins/gmeekVercount.js'></script><script src='https://xiuji008.github.io/plugins/lightbox.js'></script><script src='https://xiuji008.github.io/plugins/gmeekTOC.js'></script><script src='https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js'></script>",

其它使用说明

issue添加中文标签

  1. 点击 issue页签, 点击右侧 Labels 后边的设置按钮,点击Edit labels

issues_labels_setting.png

issues_labels_setting.png

  1. Labels 页面则可以新增或修改标签

issues_labels_edit.png

issues_labels_edit.png

置顶博客文章

只需要Pin issue后,手动全局生成一次即可。

issues_pin.png

issues_pin.png

评论 utteranc报错

如果在评论里面登录后评论报错,可直接按照提示安装utteranc app即可

Error: utterances is not installed on xxx/xxx.github.io. If you own this repo, install the app. Read more about this change in the PR.

删除文章

只需要Close issue或者Delete issue后,再手动全局生成一次即可。

结语:在数字时代留下有温度的痕迹

“死了么”关注的是物理存在的安全,而Gmeek这样的工具关注的是思想存在的延续。两者看似无关,实则都回应了现代人对存在感的深层渴望。

在这个算法主导、注意力碎片化的时代,拥有一个属于自己的数字角落,定期记录、整理、输出,不仅是对抗遗忘的方式,更是一种积极的生活态度——主动塑造自己的数字身份,而非被动地被平台定义。

从担心“无人知晓的离去”到主动“留下有思想的痕迹”,或许正是数字时代给予我们的一种平衡:既通过工具获得安全感,也通过表达实现自我确认。

你的思想值得被记录,你的声音值得被听见。现在,只需18秒,就可以开始在GitHub上建造你的数字思想家园。

HarmonyOS 多模块项目中的公共库治理与最佳实践

鸿蒙(HarmonyOS)多模块项目 中,如果你希望 避免在每个模块(Module)中重复集成同一个三方库或公共库,可以将该库 提升到项目级别(Project-level)进行统一管理。以下是标准做法,适用于 Stage 模型 + ArkTS + DevEco Studio 的工程结构。


✅ 目标

将公共库(如 @ohos/utils、自研工具库、第三方 npm 包等)只声明一次,供多个模块(entry、feature、service 等)共享使用


📁 鸿蒙项目结构回顾

MyHarmonyProject/
├── build-profile.json5        ← 项目级构建配置
├── oh-package.json5           ← 项目级依赖(关键!)
├── modules/
│   ├── entry/                 ← 主模块
│   ├── feature_news/          ← 功能模块1
│   └── feature_ebook/         ← 功能模块2
└── libs/                      ← (可选)本地 aar/har 公共库

✅ 正确做法:在 项目根目录的 oh-package.json5 中声明依赖

步骤 1:在项目根目录的 oh-package.json5 中添加依赖

{
  "devDependencies": {
    // 开发依赖(如 types)
  },
  "dependencies": {
    // 👇 把公共库放在这里(项目级)
    "@ohos/utils": "1.0.0",
    "some-third-party-lib": "^2.3.0"
  }
}

✅ 这样,所有子模块都可以继承使用这些依赖,无需在每个 module/xxx/oh-package.json5 中重复声明。


步骤 2:删除各子模块中的重复依赖

确保 modules/entry/oh-package.json5modules/feature_news/oh-package.json5不再包含 已提升到项目级的依赖。

例如,不要entry/oh-package.json5 中再写:

{
  "dependencies": {
    "@ohos/utils": "1.0.0"  // ❌ 删除这行!
  }
}

步骤 3:在子模块代码中正常 import 使用

// 在 entry 或 feature_news 模块中
import { ZGJYBAppearanceColorUtil } from '@ohos/utils';

// ✅ 可以正常使用,因为依赖已由项目级提供

⚠️ 注意事项

1. 仅适用于 npm 类型的包(通过 ohpm 安装)

  • 如果你是通过 ohpm install @ohos/utils 安装的库,它会被记录在 oh-package.json5
  • 这种方式支持 依赖提升(hoisting) ,类似 npm/yarn 的 workspace。

2. 本地 .har.hap 库不能这样共享

  • 如果你的“库”是一个 本地开发的 .har(HarmonyOS Archive)模块,则需要:

    • 将其放在 libs/ 目录下;
    • 每个需要使用的模块module.json5 中声明 deps 引用;
    • 或者将其发布为私有 ohpm 包,再通过 oh-package.json5 引入。

示例:引用本地 har(仍需逐模块配置)

// modules/entry/module.json5
{
  "deps": [
    "../libs/my-common-utils.har"
  ]
}

❌ 这种情况无法完全避免重复声明,但你可以通过脚本或模板减少工作量。


3. 确保 DevEco Studio 同步了依赖

  • 修改 oh-package.json5 后,点击 “Sync Now” 或运行:

    ohpm install
    

    在项目根目录执行,会安装所有模块共享的依赖。


✅ 最佳实践总结

场景 推荐方案
公共 npm/ohpm 库(如 @ohos/utils ✅ 在 项目根目录 oh-package.json5 中声明一次
自研公共逻辑(TS 工具函数) ✅ 创建一个 shared 模块,发布为 ohpm 私有包,再在项目级引入
本地 .har ⚠️ 需在每个模块的 module.json5 中引用,但可统一放在 libs/ 目录管理
避免重复代码 ✅ 抽象公共组件/工具到独立模块,通过依赖注入使用

🔧 附加建议:创建 shared 模块(高级)

  1. 新建模块:File > New > Module > Static Library (HAR)

    • 命名为 shared
  2. 在其中放置公共工具类、常量、网络封装等

  3. shared/oh-package.json5 中定义包名:

    { "name": "@myorg/shared", "version": "1.0.0" }
    
  4. 在项目根目录运行:

    ohpm install ./modules/shared --save
    
  5. 然后在 oh-package.json5 中就会出现:

    "dependencies": {
      "@myorg/shared": "file:./modules/shared"
    }
    
  6. 所有模块即可通过 import { xxx } from '@myorg/shared' 使用。

✅ 这是最接近“项目级公共库”的鸿蒙官方推荐方案。


✅ 结论

把公共库写在项目根目录的 oh-package.json5dependencies 中,即可实现“一次集成,多模块共享”

只要你的库是通过 ohpm 管理的(包括本地 file: 引用),就支持这种共享机制。这是 HarmonyOS 多模块项目的标准依赖管理方式。

Vue插槽

一、先明确核心概念

  1. 具名插槽:给 <slot> 标签添加 name 属性,用于区分不同位置的插槽,让父组件可以精准地将内容插入到子组件的指定位置,解决「默认插槽只能插入一处内容」的问题。
  2. 默认插槽:没有 name 属性的 <slot>,是具名插槽的特殊形式(默认名称为 default),父组件中未指定插槽名称的内容,会默认插入到这里。
  3. 插槽默认内容:在子组件的 <slot> 标签内部写入内容,当父组件未给该插槽传递任何内容时,会显示这份默认内容;若父组件传递了内容,会覆盖默认内容,提升组件的复用性和容错性。
  4. 作用域插槽:子组件通过「属性绑定」的方式给 <slot> 传递内部私有数据,父组件在使用插槽时可以接收这些数据并自定义渲染,解决「父组件无法访问子组件内部数据」的问题,实现「子组件供数、父组件定制渲染」。

二、分步实例演示

第一步:实现最基础的「具名插槽 + 默认插槽」

核心需求:创建一个通用的「页面容器组件」,包含「页头」「页面内容」「页脚」三个部分,其中「页面内容」用默认插槽,「页头」「页脚」用具名插槽。

1. 子组件:定义插槽(文件名:PageContainer.vue

<template>
  <!-- 通用页面容器样式(简单美化,方便查看效果) -->
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头(name="header") -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容(无name属性,对应default) -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(name="footer") -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer" />
    </div>
  </div>
</template>

<script setup>
// 子组件无需额外逻辑,仅定义插槽结构即可
</script>

2. 父组件:使用插槽(传递内容,文件名:App.vue

父组件通过 v-slot:插槽名(简写:#插槽名)指定内容要插入的具名插槽,未指定的内容默认插入到默认插槽。

<template>
  <h2>基础具名插槽 + 默认插槽演示</h2>

  <!-- 使用子组件 PageContainer -->
  <PageContainer>
    <!-- 给具名插槽 header 传递内容(简写 #header,完整写法 v-slot:header) -->
    <template #header>
      <h3>这是文章详情页的页头</h3>
      <nav>首页 > 文章 > Vue 插槽教程</nav>
    </template>

    <!-- 未指定插槽名,默认插入到子组件的默认插槽 -->
    <div>
      <p>1. 具名插槽可以让父组件精准控制内容插入位置。</p>
      <p>2. 默认插槽用于承载组件的核心内容,使用更简洁。</p>
      <p>3. 这部分内容会显示在页头和页脚之间。</p>
    </div>

    <!-- 给具名插槽 footer 传递内容(简写 #footer) -->
    <template #footer>
      <span>发布时间:2026-01-13</span>
      <button style="margin-left: 20px; padding: 4px 12px;">收藏文章</button>
    </template>
  </PageContainer>
</template>

<script setup>
// 导入子组件
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 页头区域显示「文章详情页标题 + 面包屑导航」(对应 #header 插槽内容)。
  • 中间区域显示核心正文(对应默认插槽内容)。
  • 页脚区域显示「发布时间 + 收藏按钮」(对应 #footer 插槽内容)。
  • 关键:父组件的 <template> 标签包裹插槽内容,通过 #插槽名 绑定子组件的具名插槽,结构清晰,互不干扰。

第二步:实现「带默认内容的插槽」

核心需求:优化上面的 PageContainer.vue,给「页脚插槽」添加默认内容(默认显示「返回顶部」按钮),当父组件未给 footer 插槽传递内容时,显示默认按钮;若传递了内容,覆盖默认内容。

1. 修改子组件:给插槽添加默认内容(PageContainer.vue

仅修改 footer 插槽部分,在 <slot name="footer"> 内部写入默认内容:

<template>
  <div class="page-container" style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 20px 0;">
    <!-- 具名插槽:页头 -->
    <div class="page-header" style="border-bottom: 1px dashed #e0e0e0; padding-bottom: 10px; margin-bottom: 10px;">
      <slot name="header" />
    </div>

    <!-- 默认插槽:页面核心内容 -->
    <div class="page-content" style="margin: 20px 0; min-height: 100px;">
      <slot />
    </div>

    <!-- 具名插槽:页脚(带默认内容) -->
    <div class="page-footer" style="border-top: 1px dashed #e0e0e0; padding-top: 10px; margin-top: 10px; text-align: right;">
      <slot name="footer">
        <!-- 插槽默认内容:父组件未传递footer内容时,显示该按钮 -->
        <button style="padding: 4px 12px;" @click="backToTop">返回顶部</button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 定义默认内容的点击事件(返回顶部)
const backToTop = () => {
  window.scrollTo({
    top: 0,
    behavior: 'smooth' // 平滑滚动
  });
};
</script>

2. 父组件演示两种场景(App.vue

分别演示「不传递 footer 内容」和「传递 footer 内容」的效果:

<template>
  <h2>带默认内容的插槽演示</h2>

  <!-- 场景1:父组件不传递 footer 插槽内容,显示子组件的默认「返回顶部」按钮 -->
  <h4>场景1:未传递页脚内容(显示默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是未传递页脚的页面</h3>
    </template>
    <p>该页面父组件没有给 footer 插槽传递内容,所以页脚会显示子组件默认的「返回顶部」按钮。</p>
  </PageContainer>

  <!-- 场景2:父组件传递 footer 插槽内容,覆盖默认按钮 -->
  <h4 style="margin-top: 40px;">场景2:传递页脚内容(覆盖默认按钮)</h4>
  <PageContainer>
    <template #header>
      <h3>这是传递了页脚的页面</h3>
    </template>
    <p>该页面父组件给 footer 插槽传递了自定义内容,会覆盖子组件的默认「返回顶部」按钮。</p>
    <template #footer>
      <span>作者:Vue 小白教程</span>
      <button style="margin-left: 20px; padding: 4px 12px;">点赞</button>
      <button style="margin-left: 10px; padding: 4px 12px;">评论</button>
    </template>
  </PageContainer>
</template>

<script setup>
import PageContainer from './PageContainer.vue';
</script>

3. 运行效果与说明

  • 场景1:页脚显示「返回顶部」按钮,点击可实现平滑滚动到页面顶部(默认内容生效)。
  • 场景2:页脚显示「作者 + 点赞 + 评论」,默认的「返回顶部」按钮被覆盖(自定义内容生效)。
  • 核心价值:插槽默认内容让组件更「健壮」,无需父组件每次都传递所有插槽内容,减少冗余代码,提升组件复用性。

第三步:实际业务场景综合应用(卡片组件)

核心需求:创建一个通用的「商品卡片组件」,使用具名插槽实现「商品图片」「商品标题」「商品价格」「操作按钮」的自定义配置,其中「操作按钮」插槽带默认内容(默认「加入购物车」按钮)。

1. 子组件:商品卡片(GoodsCard.vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 具名插槽:商品图片 -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" />
    </div>

    <!-- 具名插槽:商品标题 -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" />
    </div>

    <!-- 具名插槽:商品价格 -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 无需额外逻辑,仅提供插槽结构和默认内容
</script>

2. 父组件:使用商品卡片组件(App.vue

自定义不同商品的内容,演示插槽的灵活性:

<template>
  <h2>实际业务场景:商品卡片组件</h2>
  <div style="overflow: hidden; clear: both;">
    <!-- 商品1:使用默认操作按钮(加入购物车) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=1" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        小米手机 14 旗舰智能手机
      </template>
      <template #price>
        ¥ 4999
      </template>
      <!-- 未传递 #action 插槽,显示默认「加入购物车」按钮 -->
    </GoodsCard>

    <!-- 商品2:自定义操作按钮(立即购买 + 收藏) -->
    <GoodsCard>
      <template #image>
        <img src="https://picsum.photos/240/180?random=2" alt="商品图片" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
      </template>
      <template #title>
        苹果 iPad Pro 平板电脑
      </template>
      <template #price>
        ¥ 7999
      </template>
      <!-- 自定义 #action 插槽内容,覆盖默认按钮 -->
      <template #action>
        <button style="width: 48%; padding: 8px 0; background: #0071e3; color: #fff; border: none; border-radius: 8px; cursor: pointer; margin-right: 4%;">
          立即购买
        </button>
        <button style="width: 48%; padding: 8px 0; background: #f0f0f0; color: #333; border: none; border-radius: 8px; cursor: pointer;">
          收藏
        </button>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './GoodsCard.vue';
</script>

3. 运行效果与说明

  • 商品1:操作按钮显示默认的「加入购物车」,快速实现基础功能。
  • 商品2:操作按钮显示「立即购买 + 收藏」,满足自定义需求。
  • 业务价值:通过具名插槽,打造了「通用可复用」的商品卡片组件,父组件可以根据不同商品场景,灵活配置各个区域的内容,既减少了重复代码,又保证了灵活性。

第四步:实现「作用域插槽」

核心需求:基于现有商品卡片组件优化,让子组件持有私有商品数据,通过作用域插槽传递给父组件,父组件自定义渲染格式(如给高价商品加「高端」标识、显示商品优惠信息)。

1. 修改子组件:定义作用域插槽,传递内部数据(GoodsCard.vue

子组件新增内部私有数据,通过「属性绑定」给插槽传递数据(:数据名="子组件内部数据"):

vue

<template>
  <div class="goods-card" style="width: 280px; border: 1px solid #f0f0f0; border-radius: 12px; padding: 16px; margin: 16px; float: left; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
    <!-- 作用域插槽:商品图片(暴露商品id和图片地址) -->
    <div class="goods-img" style="width: 100%; height: 180px; margin-bottom: 12px; text-align: center;">
      <slot name="image" :goodsId="goods.id" :imgUrl="goods.imgUrl" />
    </div>

    <!-- 作用域插槽:商品标题(暴露商品名称和价格) -->
    <div class="goods-title" style="font-size: 16px; font-weight: 500; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
      <slot name="title" :goodsName="goods.name" :goodsPrice="goods.price" />
    </div>

    <!-- 作用域插槽:商品价格(暴露价格和优惠信息) -->
    <div class="goods-price" style="font-size: 18px; color: #ff4400; margin-bottom: 16px;">
      <slot name="price" :price="goods.price" :discount="goods.discount" />
    </div>

    <!-- 具名插槽:操作按钮(带默认内容) -->
    <div class="goods-actions" style="text-align: center;">
      <slot name="action">
        <!-- 默认内容:加入购物车按钮 -->
        <button style="width: 100%; padding: 8px 0; background: #ff4400; color: #fff; border: none; border-radius: 8px; cursor: pointer;">
          加入购物车
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup>
// 子组件内部私有数据(模拟接口返回,父组件无法直接访问)
const goods = {
  id: 1001,
  name: "小米手机 14 旗舰智能手机",
  price: 4999,
  imgUrl: "https://picsum.photos/240/180?random=1",
  discount: "立减200元,支持分期免息"
};
</script>

2. 父组件:接收并使用作用域插槽数据(App.vue

父组件通过 template #插槽名="插槽数据对象" 接收子组件暴露的数据,支持解构赋值简化代码,自定义渲染逻辑:

vue

<template>
  <h2>进阶:作用域插槽演示(子组件供数,父组件定制渲染)</h2>
  <div style="overflow: hidden; clear: both; margin-top: 40px;">
    <GoodsCard>
      <!-- 接收图片插槽的作用域数据:slotProps(自定义名称,包含goodsId、imgUrl) -->
      <template #image="slotProps">
        <img :src="slotProps.imgUrl" :alt="'商品' + slotProps.goodsId" style="width: 240px; height: 180px; object-fit: cover; border-radius: 8px;">
        <!-- 利用子组件传递的goodsId,添加自定义标识 -->
        <span style="position: absolute; top: 8px; left: 8px; background: red; color: #fff; padding: 2px 8px; border-radius: 4px; z-index: 10;">
          编号:{{ slotProps.goodsId }}
        </span>
      </template>

      <!-- 接收标题插槽的作用域数据:解构赋值(更简洁,推荐) -->
      <template #title="{ goodsName, goodsPrice }">
        {{ goodsName }}
        <!-- 父组件自定义逻辑:价格高于4000加「高端」标识 -->
        <span v-if="goodsPrice > 4000" style="color: #ff4400; font-size: 12px; margin-left: 8px;">
          高端
        </span>
      </template>

      <!-- 接收价格插槽的作用域数据:结合优惠信息渲染 -->
      <template #price="{ price, discount }">
        <span>¥ {{ price }}</span>
        <!-- 渲染子组件传递的优惠信息,自定义样式 -->
        <p style="font-size: 12px; color: #999; margin-top: 4px; text-align: left;">
          {{ discount }}
        </p>
      </template>
    </GoodsCard>
  </div>
</template>

<script setup>
import GoodsCard from './components/GoodsCard.vue';
</script>

3. 运行效果与说明

  • 父组件成功获取子组件私有数据(goodsIddiscount 等),并实现自定义渲染(商品编号、高端标识、优惠信息);
  • 核心语法:子组件「属性绑定传数据」,父组件「插槽数据对象接收」,支持解构赋值简化代码;
  • 核心价值:通用组件(列表、卡片、表格)既保留内部数据逻辑,又开放渲染格式定制权,极大提升组件灵活性和复用性;
  • 注意:作用域插槽本质仍是具名 / 默认插槽,只是增加了「子向父」的数据传递能力。

三、总结(核心知识点回顾,加深记忆)

  1. 使用步骤
  • 子组件:用 <slot name="xxx"> 定义具名插槽(内部可写默认内容),用 :数据名="内部数据" 给插槽传递数据(作用域插槽);
  • 父组件:用 <template #xxx> 给指定具名插槽传内容,用 <template #xxx="slotProps"> 接收作用域插槽数据,未指定插槽名的内容默认插入到 <slot>(默认插槽)。
  1. 核心语法
  • v-slot:插槽名 可简写为 #插槽名,仅能用于 <template> 标签或组件标签上;
  • 作用域插槽数据支持解构赋值,可设置默认值(如 #title="{ goodsName = '默认商品', goodsPrice = 0 }")避免报错。
  1. 插槽体系
  • 基础层:默认插槽(单一区域)、具名插槽(多区域精准定制);
  • 增强层:插槽默认内容(提升健壮性)、作用域插槽(子供数 + 父定制,进阶核心)。

从零实现 React Native(2): 跨平台支持

上一回:从零实现 React Native(1): 桥通信的原理与实现

平台支持的取舍

在上一篇《从零实现 React Native(1): 桥通信的原理与实现》中,基于 macos 平台实现了 JS 与 Native 的双向桥通信,在本篇中将对其他平台进行支持,实现「write once,run anywhere」这个理念。

接下来便来进行 iOS 和 Android 平台的支持工作。

iOS 进展顺利

在支持了 macos 端后,支持 iOS 是很容易的,可以马上着手来搞这个事情。得益于 Apple 生态带来的:macOS 和 iOS 都内置了 JavaScriptCore.framework,这意味着无需额外的引擎移植工作;且编程 API 很相似,这意味着差异化实现较少,大多可复用或类比实现。

事实上,我只花了半天时间就完成了 iOS 端的支持工作,其中主要的时间花在了构建配置的修改、测试示例的新增和调整,少部分时间花在了差异化的 DeviceInfo 模块实现。

得益于 Apple 生态,iOS 的支持工作中大部分代码都是复用的,复用率 90%。因为 macos 和 iOS 的 JSC API 一致,以及 C++ 语言的优势,可以用于跨端复用。复用的内容包含:

  • JSCExector
  • Bridge 通信逻辑
  • 模块注册逻辑

Android 滑铁卢

在顺利支持了 iOS 后,预想是 Android 的支持也不会太难,但实际做起来发现没这么简单。

记得是周末的午后的轻松下午,我先把 Android 的相关环境搭建好(包括 Android Studio、Java SDK 及其环境变量、NDK 等),然后进入 JSC 的移植工作。Why JSC 移植?因为不同于 Apple 生态,Android 系统是没有内置 JSC 引擎的。而正是这一步让我陷入泥潭。

我首先尝试了三方编译的版本,但是要么遇到了 libjsc.so(JSC 编译后的二进制文件,可供 Android 平台运行,可类比理解为前端的 wasm) 不支持 arm64(由于是 MBP 机器,安卓模拟器必须用 arm64 而非 x86 架构的),要么是遇到了 libjsc.so 和 NDK 版本不兼容。然后尝试了从社区提供的 jsc-android-buildscripts 自行编译,也遇到了各种问题编译失败,考虑到每次编译时间:2-3 小时,这也是一个艰难的旅程。

就算解决了 JavaScriptCore,还有 JNI 在等着我。Java 和 C++ 之间的桥梁不是简单的函数调用。我要处理诸如:类型转换、线程同步等问题。前方有很多新的坑在等着我。

舍与得

Maybe it's not the right time. 先理解核心,再扩展边界。先放下 Android 的支持,或许未来的某一天再回头来看这件事。

这个决定让我想起了 MVP(最小可行产品)的原则:先让核心功能跑通,再逐步完善。在学习项目中,这个原则同样适用——先掌握本质,再扩展边界。

既然决定专注于 iOS 和 macOS 双平台,那么接下来就需要一套优雅的构建系统来支撑跨平台开发。一个好的构建系统不仅能让开发者轻松切换平台,更重要的是,它能为后续的代码复用奠定基础。

构建系统的演进

在上一篇博客中,受制于篇幅的限制,跳过了对构建系统的讲解。而在跨平台支持中,天然需要迭代构建系统,也正是对其展开讲讲的一个好时机。

Make 是什么

Make 是一个诞生于 1976 年的构建工具,它的工作原理很简单:描述文件之间的依赖关系,然后只重新编译"变化过的"文件。

Make 适合于 需要多步骤构建流程 的项目,本项目的构建流程较为复杂:JS 产物打包 -> CMake 配置 -> C++ 产物编译 -> 运行 test 代码,很适合引入 Make 进行任务流程的编排。

Make 工具的配套 Makefile 文件是一个文本配置文件,它定义了构建规则、依赖关系和执行命令,可以将其理解为 npm 和 package.json 的关系。

以下是基于 macos 编译和测试的 Makefile 文件摘要代码,核心步骤包含了 js-build, configure, test

# ============================================
# 变量定义 (Makefile 第 10-13 行)
# ============================================
BUILD_DIR = build
CMAKE_BUILD_TYPE ?= Debug
CORES = $(shell sysctl -n hw.ncpu)  # 动态检测 CPU 核心数

# ============================================
# 主要构建目标 - 依赖链设计
# ============================================

# 默认目标:make 等价于 make build
.PHONY: all
all: build

# 核心构建流程:js-build → configure → 实际编译
.PHONY: build
build: js-build configure
    @echo "🔨 Building Mini React Native..."
    @cd $(BUILD_DIR) && make -j$(CORES)
    @echo "✅ Build complete"

# ============================================
# 步骤 1:JavaScript 构建 (第 29-33 行)
# ============================================
.PHONY: js-build
js-build:
    @echo "📦 Building JavaScript bundle..."
    @npm run build    # 执行 rollup -c,生成 dist/bundle.js
    @echo "✅ JavaScript bundle built"

# ============================================
# 步骤 2:CMake 配置 (第 22-26 行)
# ============================================
.PHONY: configure
configure:
    @echo "🔧 Configuring build system..."
    @mkdir -p $(BUILD_DIR)
    @cd $(BUILD_DIR) && cmake -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
    @echo "✅ Configuration complete"


# ============================================
# 步骤 4:分层测试流程 (第 91-130 行)
# ============================================

# 完整测试:build → 4 个测试依次执行
.PHONY: test
test: build
    @echo "🧪 Running all tests..."
    @echo "\n📝 Test 1: Basic functionality test"
    @./$(BUILD_DIR)/mini_rn_test
    @echo "\n📝 Test 2: Module framework test"
    @./$(BUILD_DIR)/test_module_framework
    @echo "\n📝 Test 3: Integration test"
    @./$(BUILD_DIR)/test_integration
    @echo "\n📝 Test 4: Performance test"
    @./$(BUILD_DIR)/test_performance
    @echo "\n✅ All tests complete"

# 单独的测试目标 - 允许细粒度测试
.PHONY: test-basic
test-basic: build
    @echo "🧪 Running basic functionality test..."
    @./$(BUILD_DIR)/mini_rn_test

.PHONY: test-performance
test-performance: build
    @echo "🧪 Running performance test..."
    @./$(BUILD_DIR)/test_performance

在引入了 make 后,可以很方便的进行复杂流程的编排,例如我们想要运行测试代码时,实际的发生的事情如下所示。

用户命令: make test
    ↓
test: build
    ↓
build: js-build configure
        ↓             ↓
    js-build          configure
        ↓                   ↓
    npm run build       cmake ..
        ↓                   ↓
    dist/bundle.js      build/Makefile
                            ↓
                        make -j8 (CMake 管理的依赖)
                            ↓
                        libmini_react_native.a
                            ↓
                        mini_rn_test (等 4 个可执行文件)

Before 引入 Make:想象一下,如果没有 Make,每次修改代码后你需要手动执行

# 步骤1:构建 JavaScript
npm run build

# 步骤2:配置 CMake
mkdir -p build
cd build && cmake ..

# 步骤3:编译 C++
cd build && make -j8

# 步骤4:运行测试
./build/mini_rn_test
./build/test_module_framework
./build/test_integration
./build/test_performance

每次都要记住这么多命令,还要确保执行顺序正确。更糟糕的是,如果某个步骤失败了,你需要手动判断从哪里重新开始。

After 引入 Makemake test 一条命令搞定所有事情

CMake 是什么

在把 C++ 代码编译成二进制文件这一步之前,其实构建系统提前引入了 CMake 进行管理。CMake 不是“构建工具”,而是“构建系统的构建系统”,在这个场景中 CMake 实际上生成了编译代码的工具 Makefile 文件。CMake 会读取 CMakeLists.txt,然后生成原生的构建文件。

Why CMake?因为 mini-rn 项目开始之初就是要考虑多平台支持的,为了实现这个 feature,便会遇到 多平台构建的复杂性 这个问题。

问题 1:平台特定源文件管理

不同平台需要不同的实现:

  • macOS:使用 IOKit 获取硬件信息
  • iOS:使用 UIDevice 获取设备信息

没有 CMake 需要维护两套构建脚本,引入 CMake 后可通过条件编译一套配置搞定。

问题 2:系统框架动态链接

不同平台需要链接不同框架:macOS 需要 IOKit,iOS 需要 UIKit

引入 CMake 后可自动检测并链接正确的框架。

解决效果

引入 CMake 前:需要维护多套构建脚本,手动管理复杂配置,容易出错。

引入 CMake 后:一套 CMakeLists.txt 支持所有平台,自动处理平台差异,大幅降低维护成本。

CMake 关键语法解释

  • CMAKE_SYSTEM_NAME:CMake 内置变量,表示目标系统名称(iOS、Darwin等)
  • find_library():在系统中查找指定的库文件
  • target_link_libraries():将库文件链接到目标可执行文件
  • set():设置变量的值
  • if(MATCHES):条件判断,支持正则表达式匹配

改动一:Makefile 新增 iOS 构建目标

在 macOS 的可扩展构建系统配置就绪后,接下来看看如何改动以支持 iOS。

改动一实现了什么?

核心目标:在现有 Makefile 基础上,新增 iOS 平台的完整构建流程,实现"一套 Makefile,双平台支持"。

具体实现

  1. 新增 4 个 iOS 专用目标ios-configureios-buildios-testios-test-deviceinfo
  2. 建立 iOS 构建流程:js-build → ios-configure → ios-build → ios-test
  3. 实现平台隔离:iOS 使用独立的 build_ios/ 目录,与 macOS 的 build/ 目录完全分离
  4. 自动化 Xcode 环境配置:自动检测 SDK 路径、设置开发者目录、配置模拟器架构

新增的 4 个 iOS 目标

原本基于 macOS 的构建路径是:js-build → configure → build → test,现在为 iOS 新增了对应的平行路径:js-build → ios-configure → ios-build → ios-test。

# iOS 构建配置(模拟器)
.PHONY: ios-configure
ios-configure:
    @mkdir -p $(BUILD_DIR)_ios
    @cd $(BUILD_DIR)_ios && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer cmake \
        -DCMAKE_SYSTEM_NAME=iOS \
        -DCMAKE_OSX_ARCHITECTURES=$$(uname -m) \
        -DCMAKE_OSX_SYSROOT=$$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator --show-sdk-path) \
        -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
        -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
        ..

# 构建 iOS 版本(模拟器)
.PHONY: ios-build
ios-build: js-build ios-configure
    @cd $(BUILD_DIR)_ios && make -j$(CORES)

# iOS 测试目标
.PHONY: ios-test
ios-test: ios-build
    @./test_ios.sh all

# iOS DeviceInfo 测试
.PHONY: ios-test-deviceinfo
ios-test-deviceinfo: ios-build
    @./test_ios.sh deviceinfo

关键设计决策

1. 独立构建目录

macOS 用 build/,iOS 用 build_ios/,互不干扰:

@mkdir -p $(BUILD_DIR)_ios   # iOS 构建目录

2. 仅支持 iOS 模拟器

为什么不支持真机?因为:

  • 真机需要开发者证书和配置文件
  • 模拟器足够验证 Bridge 通信机制
  • 降低环境配置复杂度
-DCMAKE_OSX_SYSROOT=$$(xcrun --sdk iphonesimulator --show-sdk-path)

3. 语义化命令

make ios-configure 比写一长串 CMake 命令简洁太多。这就是 Makefile 作为用户接口的价值。

改动二:CMake 平台条件编译

改动二实现了什么?

核心目标:让 CMake 能够智能识别目标平台,并自动选择正确的源文件和系统框架,实现"一套 CMakeLists.txt,智能适配双平台"。

具体实现

  1. 平台检测机制:通过 CMAKE_SYSTEM_NAME 变量自动识别是 macOS 还是 iOS
  2. 源文件智能选择:根据平台自动选择对应的 .mm 实现文件
  3. 框架动态链接:iOS 链接 UIKit,macOS 链接 IOKit,共享 JavaScriptCore 和 Foundation
  4. 编译标志自动设置:为 Objective-C++ 文件自动设置 ARC 标志
  5. 部署目标配置:iOS 设为 12.0+,macOS 设为 10.15+

设计精髓:编译时确定,运行时无开销。最终的 iOS 二进制文件中完全没有 macOS 代码,反之亦然。

原来的代码(仅 macOS)

# 原始版本 - 仅支持 macOS
if(APPLE)
    set(PLATFORM_SOURCES
        src/macos/modules/deviceinfo/DeviceInfoModule.mm
    )
    find_library(IOKIT_FRAMEWORK IOKit)
endif()

target_link_libraries(mini_react_native
    ${JAVASCRIPTCORE_FRAMEWORK}
    ${IOKIT_FRAMEWORK}
)

演进后的代码(macOS + iOS)

# 演进版本 - 支持 macOS + iOS
if(APPLE)
    # 根据具体平台选择源文件
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        set(PLATFORM_SOURCES
            src/ios/modules/deviceinfo/DeviceInfoModule.mm
        )
    else()
        # macOS
        set(PLATFORM_SOURCES
            src/macos/modules/deviceinfo/DeviceInfoModule.mm
        )
    endif()

    # 平台特定框架
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        find_library(UIKIT_FRAMEWORK UIKit)
        set(PLATFORM_FRAMEWORKS ${UIKIT_FRAMEWORK})
    else()
        find_library(IOKIT_FRAMEWORK IOKit)
        set(PLATFORM_FRAMEWORKS ${IOKIT_FRAMEWORK})
    endif()

    # 统一链接
    target_link_libraries(mini_react_native
        ${JAVASCRIPTCORE_FRAMEWORK}
        ${FOUNDATION_FRAMEWORK}
        ${PLATFORM_FRAMEWORKS}
    )
endif()

三个关键变化

1. 源文件分离

src/
├── macos/modules/deviceinfo/DeviceInfoModule.mm
└── ios/modules/deviceinfo/DeviceInfoModule.mm

两个文件虽然文件名相同,但实现不同:

  • macOS 版本:用 IOKit 获取硬件信息
  • iOS 版本:用 UIDevice 获取设备信息

2. 框架动态链接

平台 共享框架 平台特定框架
macOS JavaScriptCore, Foundation IOKit
iOS JavaScriptCore, Foundation UIKit

3. 部署目标设置

if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0")
elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15")
endif()

两个改动的协同作用

改动一 + 改动二 = 完美的跨平台构建系统

这两个改动巧妙地分工合作:

  • Makefile(改动一):作为用户接口层,提供简单统一的命令,隐藏平台配置的复杂性
  • CMake(改动二):作为构建逻辑层,智能处理平台差异,自动选择正确的源文件和框架

协同效果

  1. 开发者体验make build vs make ios-build,命令接口一致
  2. 构建隔离:两个平台使用独立目录,可以并行构建,切换无需清理
  3. 智能适配:CMake 根据 Makefile 传入的平台信息,自动配置所有细节
  4. 零运行时开销:编译时就确定了平台,最终二进制文件纯净无冗余

这种设计让跨平台支持变得既强大又优雅:开发者只需要记住两个命令,背后的所有复杂性都被自动化处理了。

DeviceInfo - 变与不变

在构建系统演进完成后,我们来深入分析 DeviceInfo 模块的双平台实现。这个模块展示了跨平台架构设计的智慧:如何在保持接口统一的同时,让每个平台发挥自身优势。

90% 复用率是怎么做到的?

关键洞察:大部分逻辑其实是平台无关的

仔细分析 DeviceInfo 模块,你会发现一个惊人的事实:

// 这些逻辑在任何平台都一样
std::string DeviceInfoModule::getName() const {
    return "DeviceInfo";
}

std::vector<std::string> DeviceInfoModule::getMethods() const {
    return {
        "getUniqueId",       // methodId = 0
        "getSystemVersion",  // methodId = 1
        "getDeviceId"        // methodId = 2
    };
}

void DeviceInfoModule::invoke(const std::string& methodName,
                             const std::string& args, int callId) {
    try {
        if (methodName == "getUniqueId") {
            std::string uniqueId = getUniqueIdImpl();  // 只是调用,不关心具体实现
            sendSuccessCallback(callId, uniqueId);
        } else {
            sendErrorCallback(callId, "Unknown method: " + methodName);
        }
    } catch (const std::exception& e) {
        sendErrorCallback(callId, "Method invocation failed: " + std::string(e.what()));
    }
}

**Bridge 通信协议、方法注册机制、消息分发逻辑,完全都是可以复用的!**真正不同的,只是那几个 xxxImpl() 方法的底层实现。

复用的边界

但这里有个更深层的问题:为什么有些代码能 100% 复用,有些却完全不能?

让我们看看实际的复用率统计:

代码类型 复用率 为什么?
Bridge 通信逻辑 100% 协议标准化
模块注册机制 100% 框架层抽象
错误处理机制 100% 异常处理逻辑相同
设备唯一标识 0% 平台理念完全不同
系统版本获取 95% 只有注释不同
设备型号获取 85% 都用 sysctlbyname,iOS多了模拟器判断

100% 复用:协议的力量

为什么 Bridge 通信能 100% 复用?

因为这是协议层,不管底层平台怎么变,JavaScript 和 Native 之间的通信协议是固定的。方法名、参数、回调 ID、错误处理这些都是标准化的。就像 HTTP 协议,不管服务器是 Linux 还是 Windows,浏览器都用同样的方式发请求。

0% 复用:平台的鸿沟

为什么设备唯一标识完全不能复用?

macOS 追求真正的硬件级别唯一性,有复杂的降级机制;iOS 在 MVP 阶段采用了简化策略,每次启动生成新ID。这不是技术问题,而是:

  1. 平台哲学的差异:桌面 vs 移动的隐私理念
  2. 开发策略的差异:完整实现 vs MVP验证

复用边界的哲学

通过 DeviceInfo 模块,我们发现了跨平台复用的三个层次:

  1. 协议层:100% 复用,因为标准统一
  2. API 层:看运气,苹果生态有优势
  3. 实现层:看平台差异,移动端更复杂

这揭示了一个残酷的真相:跨平台的成本永远存在,只是被转移了。

可以用抽象基类隐藏差异,但差异本身不会消失。关键是找到合适的边界,让复用最大化,让差异最小化。

头文件的魔法

解决方案其实就是基于 面向对象 的:

// common/modules/DeviceInfoModule.h
class DeviceInfoModule : public NativeModule {
public:
    DeviceInfoModule();
    ~DeviceInfoModule() override = default;

    // NativeModule 接口实现 - 所有平台共享
    std::string getName() const override;
    std::vector<std::string> getMethods() const override;
    void invoke(const std::string& methodName, const std::string& args,
                int callId) override;

    // 平台特定的实现接口 - 让各平台去填这些"洞"
    std::string getUniqueIdImpl() const;
    std::string getSystemVersionImpl() const;
    std::string getDeviceIdImpl() const;

private:
    // 工具方法
    std::string createSuccessResponse(const std::string& data) const;
    std::string createErrorResponse(const std::string& error) const;
};

注意这里没有用虚函数,因为已经引入了 CMake 在编译时确定了对应平台的文件,不需要运行时多态,结果是 同一个头文件,不同的实现文件。每个平台都有自己的 .mm 文件来实现这些方法,编译时自动选择对应的实现。

基类定义了 what(做什么),各平台实现 how (怎么做)。Bridge 通信、方法注册、消息分发等这些复杂的逻辑只写一遍,所有平台自动继承。

分平台实现

// macOS 实现 - src/macos/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // 尝试获取硬件 UUID
        io_registry_entry_t ioRegistryRoot =
            IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/");
        CFStringRef uuidCf = (CFStringRef)IORegistryEntryCreateCFProperty(
            ioRegistryRoot, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0);

        if (uuidCf) {
            NSString* uuid = (__bridge NSString*)uuidCf;
            std::string result = [uuid UTF8String];
            CFRelease(uuidCf);
            return result;
        }
        // 多层降级机制...
        return "macOS-" + getDeviceIdImpl() + "-" + getSystemVersionImpl();
    }
}

// iOS 实现 - src/ios/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // iOS 简化实现:使用 NSUUID 生成唯一标识
        // 注意:这个实现每次启动都会生成新的ID,适用于MVP测试
        NSUUID* uuid = [NSUUID UUID];
        NSString* uuidString = [uuid UUIDString];
        return [uuidString UTF8String];
    }
}

Objective-C++ 关键字解释

  • @autoreleasepool:自动释放池,管理 Objective-C 对象的内存,确保及时释放
  • __bridge:ARC(自动引用计数)中的桥接转换,在 C/C++ 指针和 Objective-C 对象间转换
  • [object method]:Objective-C 的方法调用语法
  • .mm 文件扩展名:表示 Objective-C++ 文件,可以混合使用 C++、C 和 Objective-C 代码

两个平台的实现文件自动拥有了完整的 Bridge 通信能力,现在只需要实现平台差异部分即可~

应自动化尽自动化

DeviceInfo 模块的自动化实现揭示了一个重要原则:

好的跨平台架构不是让代码在所有平台都能跑,而是让正确的代码在正确的平台上跑。

通过这个项目的三层自动化体系:

  1. Makefile 自动化:统一的命令接口,隐藏平台配置复杂性
  2. CMake 自动化:智能的源文件选择和框架链接
  3. 编译器自动化:平台特定的二进制生成

这样的架构让开发者专注于业务逻辑,而把平台适配的复杂性交给了工具链。

真正的自动化不是写一份代码到处跑,而是:

  • 开发体验统一make build vs make ios-build,命令接口一致
  • 实现策略分离:每个平台有最适合的实现方式
  • 构建过程透明:开发者不需要关心 Xcode SDK 路径、编译标志等细节

这种设计在面对更复杂的系统时依然有效:只要保持接口统一、实现分离、构建自动化,就能优雅地扩展到视图渲染、事件处理等更复杂的场景。

彩蛋

项目地址: github.com/zerosrat/mi…

当前项目中包含了本篇文章中的全部内容:

  • ✅ iOS 构建系统适配
  • ✅ iOS 跨平台的差异化实现(DeviceInfo)

完成本阶段后,项目已经具备了进入第三阶段的基础:视图渲染系统


📝 本文首发于个人博客: zerosrat.dev/n/2025/mini…

useEffect 空依赖 + 定时器 = 闭包陷阱?count 永远停在 1 的坑我踩透了

写 React 时,你有没有遇到过「定时器里的 state 永远不更新」的诡异情况?比如明明写了setCount(count + 1),页面上的count却永远停在 1—— 这其实是 ** 闭包陷阱(Stale Closure)** 在搞鬼。

今天用一个极简示例,拆解这个坑的本质,再给你 2 个一劳永逸的解决方案。

一、先看复现:count 为什么永远停在 1?

先看这段 “看似没问题” 的代码:

carbon.png

运行结果:页面上的count从 0 变成 1 后,就再也不涨了。

二、核心原因:闭包 “定格” 了初始 state

问题出在 2 个关键点的叠加:

  1. useEffect 的空依赖[] :空依赖意味着useEffect只在组件挂载时执行 1 次,后续组件更新不会重新运行这个 effect。
  2. 闭包捕获了 “快照” 值useEffect执行时,内部的setInterval函数形成了闭包 —— 它 “抓住” 了当时的count(值为 0)。后续count虽然被更新,但因为useEffect没重新执行,这个闭包永远拿着初始值 0,所以setCount(count + 1)永远是0 + 1 = 1

三、2 个解决方案:从根源避开闭包陷阱

针对这个场景,推荐 2 种既简单又安全的写法:

方案 1:函数式更新(推荐)

setState函数式写法,直接获取最新的 state 值,绕开闭包的旧值:

carbon (1).png

原理setCount(c => c + 1)会从 React 内部获取当前最新的count值,不管闭包抓的是旧值,都能拿到最新数据。

方案 2:补全依赖数组

count加入useEffect的依赖数组,让useEffectcount变化时重新执行,生成新的闭包:

carbon (2).png

注意:这个方案会频繁创建 / 清理定时器(每次count变化都重新执行 effect),性能不如方案 1,仅推荐在 “必须依赖外部变量” 的场景使用。

四、避坑总结:useEffect + 定时器的正确姿势

  1. 优先用函数式更新setState(prev => prev + 1)是避开闭包陷阱的 “万能钥匙”;
  2. 空依赖要谨慎:空依赖的useEffect里,尽量避免直接引用 state/props,改用函数式更新;
  3. 依赖数组要写全:如果必须依赖外部变量,一定要把变量加入依赖数组(配合 ESLint 的react-hooks/exhaustive-deps规则)。

React + Ts eslint配置

一、核心依赖包(分基础和 React/TS 适配)

React + TS 项目的 ESLint 依赖同样分为基础核心包适配 React/TS 的插件包,以下是完整列表及作用:

包名 作用
eslint ESLint 核心库,提供代码检查基础能力
@typescript-eslint/eslint-plugin TypeScript 专属 ESLint 规则插件
@typescript-eslint/parser ESLint 解析 TS 代码的解析器
eslint-plugin-react React 专属 ESLint 插件(支持 React 18+)
eslint-plugin-react-hooks 检查 React Hooks 使用规范(如依赖项、规则 Hooks 调用)
eslint-plugin-react-refresh(可选) 检查 React 组件热更新相关规范(Vite 项目推荐)
eslint-config-prettier(可选) 禁用 ESLint 中与 Prettier 冲突的规则
eslint-plugin-prettier(可选) 将 Prettier 规则集成到 ESLint 中

二、安装命令

1. 基础安装(仅 ESLint + React + TS)

bash

运行

# npm 安装
npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks --save-dev

# yarn 安装
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

# pnpm 安装(推荐)
pnpm add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

2. 包含 Prettier + 热更新检查(推荐)

如果需要 Prettier 格式化 + React 热更新检查,补充安装:

bash

运行

pnpm add prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-react-refresh --save-dev

三、核心配置(.eslintrc.js)

在项目根目录创建 .eslintrc.js 文件,这是 React + TS 最常用的配置模板:

javascript

运行

module.exports = {
  // 指定代码运行环境,启用对应全局变量
  env: {
    browser: true, // 浏览器环境(React 运行环境)
    es2021: true,  // 支持 ES2021 语法
    node: true     // Node.js 环境(如配置文件、脚本)
  },
  // 继承已有规则集,减少重复配置
  extends: [
    'eslint:recommended', // ESLint 官方推荐规则
    'plugin:@typescript-eslint/recommended', // TS 推荐规则
    'plugin:react/recommended', // React 推荐规则
    'plugin:react/jsx-runtime', // 适配 React 17+ 的 JSX 自动导入(无需手动 import React)
    'plugin:react-hooks/recommended', // React Hooks 强制规则
    'eslint-config-prettier' // 禁用与 Prettier 冲突的规则(装了 Prettier 才加)
    // 'plugin:prettier/recommended' // 开启 Prettier 作为 ESLint 规则(装了 eslint-plugin-prettier 才加)
  ],
  // 指定解析器(TS 解析器)
  parser: '@typescript-eslint/parser',
  // 解析器选项
  parserOptions: {
    ecmaVersion: 'latest', // 支持最新 ES 版本
    sourceType: 'module',  // 模块化代码(ES Module)
    ecmaFeatures: {
      jsx: true // 支持 JSX 语法(React 核心)
    }
  },
  // 启用的插件
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'react-refresh' // 可选,热更新检查
  ],
  // 自定义规则(按需调整)
  rules: {
    // 关闭 TS any 类型禁止规则(新手项目可临时关闭)
    '@typescript-eslint/no-explicit-any': 'off',
    // React Hooks 必选规则(强制检查依赖项)
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    // 禁用 React 组件文件名必须 PascalCase 的检查(可选)
    'react/filename-rules': 'off',
    // 关闭 React 必须声明 props 类型的检查(TS 已做类型检查,无需重复)
    'react/prop-types': 'off',
    // 热更新检查:禁止默认导出(React 组件推荐命名导出)
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true }
    ]
  },
  // 针对 React 版本的配置(自动检测)
  settings: {
    react: {
      version: 'detect'
    }
  }
}

四、补充配置(可选但推荐)

  1. 忽略文件(.eslintignore) :指定 ESLint 不检查的文件 / 目录

plaintext

node_modules/
dist/
build/
*.d.ts
.vscode/
  1. package.json 脚本:添加检查 / 修复命令

json

{
  "scripts": {
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx", // 检查所有 React/TS 文件
    "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" // 自动修复可修复问题
  }
}

五、特殊说明(针对 Create React App 项目)

如果你的 React 项目是通过 create-react-app 创建的(内置了 ESLint),无需手动安装核心包,只需:

  1. 安装缺失的适配包:

bash

运行

pnpm add eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
  1. 在项目根目录创建 .eslintrc.js 覆盖默认配置即可。

总结

  1. 核心必装包eslint + @typescript-eslint/*(解析器 + 插件) + eslint-plugin-react + eslint-plugin-react-hooks
  2. 可选扩展eslint-plugin-react-refresh(热更新)、eslint-config-prettier/eslint-plugin-prettier(兼容 Prettier);
  3. 关键配置.eslintrc.js 中需启用 jsx: true 支持 React 语法,通过 settings 自动检测 React 版本,同时开启 React Hooks 核心规则。

安装完成后运行 npm run lint 即可检查代码,npm run lint:fix 可自动修复缩进、空格等格式问题。

每组件(Per-Component)与集中式(Centralized)i18n

每组件(per-component)方法并非新概念。例如,在 Vue 生态系统中,vue-i18n 支持 SFC i18n(单文件组件)。Nuxt 也提供 按组件翻译,Angular 通过其 Feature Modules 采用类似的模式。

即使在 Flutter 应用中,我们也常能发现这样的模式:

lib/
└── features/
    └── login/
        ├── login_screen.dart
        └── login_screen.i18n.dart  # <- 翻译存放在这里
import 'package:i18n_extension/i18n_extension.dart';

extension Localization on String {
  static var _t = Translations.byText("en") +
      {
        "Hello": {
          "en": "Hello",
          "fr": "Bonjour",
        },
      };

  String get i18n => localize(this, _t);
}

然而在 React 领域,我们主要看到不同的做法,我会将它们分为三类:

集中式方法(i18next、next-intl、react-intl、lingui)

  • (无命名空间)将内容视为单一来源进行检索。默认情况下,当应用加载时,会从所有页面加载内容。

细粒度方法 (intlayer, inlang)

  • 按键或按组件对内容检索进行细化。

在本博文中,我不会专注于基于编译器的解决方案,我已经在这里覆盖过:Compiler vs Declarative i18n. 注意,基于编译器的 i18n(例如 Lingui)只是自动化了内容的提取和加载。在底层,它们通常与其他方法共享相同的限制。

注意,你越细化内容的检索方式,就越有可能将额外的 state 和逻辑插入到组件中。

细粒度方法比集中式方法更灵活,但这通常是一种权衡。即使这些 libraries 宣称支持 "tree shaking",在实际中,你通常仍会以每种语言加载整个页面。

所以,概括来说,决策大致可以分为:

  • 如果你的应用页面数量多于语言数量,应优先采用细粒度方法。
  • 如果语言数量多于页面数量,则应倾向于集中式方法。

当然,library 的作者们也意识到这些限制并提供了解决方案。 其中包括:将内容拆分为命名空间、动态加载 JSON 文件(await import()),或在构建时剔除内容。

与此同时,你应该知道,当你动态加载内容时,会向服务器引入额外的请求。每多一个 useState 或 hook,就意味着一次额外的服务器请求。

为了解决这一点,Intlayer 建议将多个内容定义分组到同一个键下,Intlayer 会合并这些内容。

但综观这些解决方案,最流行的方法显然是集中式的。

那么为什么集中式方法如此受欢迎?

  • 首先,i18next 是第一个被广泛采用的解决方案,其理念受 PHP 和 Java 架构(MVC)的启发,依赖于严格的关注点分离(将内容与代码分离)。它于 2011 年出现,在向基于组件的架构(如 React)大规模转变之前就确立了其标准。
  • 此外,一旦某个库被广泛采用,就很难将生态系统转向其他模式。
  • 在 Crowdin、Phrase 或 Localized 等翻译管理系统中,使用集中式方法也更为方便。
  • 按组件(per-component)方法背后的逻辑比集中式更复杂,开发需要更多时间,尤其是在需要解决诸如识别内容位置等问题时。

好的,但为什么不直接坚持集中式方法?

让我告诉你这对你的应用可能会带来哪些问题:

  • 未使用的数据: 当一个页面加载时,你通常会加载来自所有其他页面的内容。(在一个 10 页的应用中,那就是 90% 未使用的内容被加载)。你懒加载一个 modal?i18n 库并不在意,它反正会先加载这些字符串。
  • 性能: 每次重新渲染时,你的每个组件都会被一个巨大的 JSON payload 进行 hydrate,这会随着应用增长影响其响应性。
  • 维护: 维护大型 JSON 文件很痛苦。你必须在文件之间来回跳转以插入翻译,确保没有翻译缺失且没有孤立的 key 留下。
  • 设计系统: 这会导致与设计系统不兼容(例如,LoginForm 组件),并限制在不同应用之间复制组件的能力。

“但我们发明了 Namespaces!”

当然,这确实是一个巨大的进步。下面对比一下在 Vite + React + React Router v7 + Intlayer 配置下主 bundle 大小的差异。我们模拟了一个 20 页的应用。

第一个示例没有为每个 locale 进行懒加载翻译,也没有进行命名空间拆分。第二个示例则包含内容清理(purging)和翻译的动态加载。

已优化的 bundle 未优化的 bundle
bundle_no_optimization.png

bundle.png

因此,多亏了 namespaces,我们将结构从以下形式迁移:

locale/
├── en.json
├── fr.json
└── es.json

到这个结构:

locale/
├── en/
│   ├── common.json
│   ├── navbar.json
│   ├── footer.json
│   ├── home.json
│   └── about.json
├── fr/
│   └── ...
└── es/
    └── ...

现在你必须精细地管理应用的哪些内容应该被加载,以及在何处加载它们。总之,由于复杂性,绝大多数项目都会跳过这一步(例如参见 [next-i18next 指南](intlayer.org/zh/blog/nex…) 来了解仅仅遵循良好实践也会带来哪些挑战)。因此,这些项目最终会遇到前面解释的庞大 JSON 加载问题。

注意,这个问题并非 i18next 所特有,而是上述所有集中式方法共有的问题。

然而,我想提醒你,并非所有细粒度方法都能解决这个问题。例如,vue-i18n SFCinlang 的做法并不会本质上为每个语言环境按需懒加载翻译,因此你只是将捆绑包大小的问题换成了另一个问题。

此外,如果没有适当的关注点分离,就更难将翻译内容提取并提供给译者进行审核。

Intlayer 的按组件方法如何解决这个问题

Intlayer 通过以下几个步骤来处理:

  1. 声明: 在代码库的任何位置使用 *.content.{ts|jsx|cjs|json|json5|...} 文件声明你的内容。这既确保了关注点分离,又保持内容与组件同处一处。内容文件可以是针对单一语言的,也可以是多语言的。
  2. Processing: Intlayer 在构建步骤中运行,用于处理 JS 逻辑、处理缺失的翻译回退、生成 TypeScript 类型、管理重复内容、从你的 CMS 获取内容,等等。
  3. Purging: 当你的应用构建时,Intlayer 会清除未使用的内容(有点像 Tailwind 管理你的类的方式),通过如下方式替换内容:

Declaration:

// src/MyComponent.tsx
export const MyComponent = () => {
  const content = useIntlayer("my-key");
  return <h1>{content.title}</h1>;
};
// src/myComponent.content.ts
export const {
  key: "my-key",
  content: t({
    zh: { title: "我的标题" },
    en: { title: "My title" },
    fr: { title: "Mon titre" }
  })
}

Processing: Intlayer builds the dictionary based on the .content file and generates:

// .intlayer/dynamic_dictionary/zh/my-key.json(翻译后的 JSON 文件示例)
{
  "key": "my-key",
  "content": { "title": "我的标题" },
}

替换: Intlayer 在应用构建期间转换你的组件。

- 静态导入模式:

// 在类 JSX 语法中的组件表示
export const MyComponent = () => {
  const content = useDictionary({
    key: "my-key",
    content: {
      nodeType: "translation",
      translation: {
        zh: { title: "我的标题" },
        en: { title: "My title" },
        fr: { title: "Mon titre" },
      },
    },
  });

  return <h1>{content.title}</h1>;
};

- 动态导入模式:

// 在类 JSX 语法中的组件表示
export const MyComponent = () => {
  const content = useDictionaryAsync({
    en: () =>
      import(".intlayer/dynamic_dictionary/en/my-key.json", {
        with: { type: "json" },
      }).then((mod) => mod.default),
    // Same for other languages
  });

  return <h1>{content.title}</h1>;
};

useDictionaryAsync 使用类似 Suspense 的机制,仅在需要时加载本地化的 JSON。

此按组件方法的主要优点:

  • 将内容声明与组件放在一起可以提高可维护性(例如:将组件移动到另一个应用或 design system。删除组件文件夹时也会同时移除相关内容,就像你通常对 .test.stories 所做的那样)

  • 以组件为单位的方法可以防止 AI 代理需要在你所有不同的文件之间来回跳转。它将所有翻译集中在一个地方,限制了任务的复杂性以及使用的 tokens 数量。

限制

当然,这种方法有其权衡:

  • 更难与其他 l10n 系统和额外的工具链对接。
  • 会产生锁定(lock-in)问题(这在任何 i18n 解决方案中基本都存在,因为它们有特定的语法)。

这就是 Intlayer 试图为 i18n 提供完整工具集(100% 免费且 OSS)的原因,包括使用你自己的 AI Provider 和 API 密钥进行 AI 翻译的功能。Intlayer 还提供用于同步你的 JSON 的工具,类似于 ICU / vue-i18n / i18next 的消息格式化器,用以将内容映射到它们的特定格式。

我很乐意听到你对它的真实反馈。你的反对意见真的有助于打造一个更好的产品。这是不是有点过度设计了?还是它会成为下一个 Tailwind?

Generator 函数

 1.核心知识点 总结

  1. Generator 是「分段执行的函数」,function* 声明,yield 暂停,next() 恢复执行
  2. yield 是暂停标记 + 返回值,yield* 是遍历器委托,用于调用其他生成器 / 可遍历结构
  3. next(参数) 可以给「上一个 yield」传值,首次传参无效
  4. Generator 返回遍历器,可被 for...of 遍历,return() 强制终止遍历
  5. 核心优势:无全局变量污染、保存执行状态、外部灵活控制内部逻辑,是 ES6 异步编程的重要方案

2.什么是 Generator 函数

在Javascript中,一个函数一旦开始执行,就会运行到最后或遇到return时结束,运行期间不会有其它代码能够打断它,也不能从外部再传入值到函数体内

Generator函数(生成器)的出现使得打破函数的完整运行成为了可能,其语法行为与传统函数完全不同

Generator函数是ES6提供的一种异步编程解决方案,形式上也是一个普通函数,但有几个显著的特征:

-- function关键字与函数名之间有一个星号 "*" (推荐紧挨着function关键字)
-- 函数体内使用 yield 表达式,定义不同的内部状态 (可以有多个yield)
-- 直接调用 Generator函数并不会执行,也不会返回运行结果,而是返回一个遍历器对象(Iterator Object)
-- 依次调用遍历器对象的next方法,遍历 Generator函数内部的每一个状态

2.1 传统函数和Generator函数区别

{
  // 传统函数
  function foo() {
    return 'hello world'
  }

  foo()   // 'hello world',一旦调用立即执行


  // Generator函数
  function* generator() {
    yield 'status one'         // yield 表达式是暂停执行的标记  
    return 'hello world'
  }

  let iterator = generator()   
  // 调用 Generator函数,函数并没有执行,返回的是一个Iterator对象
  iterator.next()              
  // {value: "status one", done: false},value 表示返回值,done 表示遍历还没有结束
  iterator.next()              
  // {value: "hello world", done: true},value 表示返回值,done 表示遍历结束
}

2.2 Generator函数详解

{
  function* gen() {
    //定义了一个 Generator函数,其中包含两个 yield 表达式和一个 return 语句(即产生了三个状态)
    yield 'hello'
    yield 'world'
    return 'ending'
  }

  let it = gen()

  it.next()   // {value: "hello", done: false}
  it.next()   // {value: "world", done: false}
  it.next()   // {value: "ending", done: true}
  it.next()   // {value: undefined, done: true}
}

每次调用Iterator对象的next方法时,内部的指针就会从函数的头部或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式或return语句暂停。换句话说,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而 next方法可以恢复执行

执行过程如下:

第一次调用next方法时,内部指针从函数头部开始执行,遇到第一个 yield 表达式暂停,并返回当前状态的值 'hello'

第二次调用next方法时,内部指针从上一个(即第一个) yield 表达式开始,遇到第二个 yield 表达式暂停,返回当前状态的值 'world'

第三次调用next方法时,内部指针从第二个 yield 表达式开始,遇到return语句暂停,返回当前状态的值 'ending',同时所有状态遍历完毕,done 属性的值变为true

第四次调用next方法时,由于函数已经遍历运行完毕,不再有其它状态,因此返回 {value: undefined, done: true}。如果继续调用next方法,返回的也都是这个值

3.yield 表达式

(1)、yield 表达式只能用在 Generator 函数里面,用在其它地方都会报错

{
  (function (){
    yield 1;
  })()

  // SyntaxError: Unexpected number
  // 在一个普通函数中使用yield表达式,结果产生一个句法错误
}

(2)、yield 表达式如果用在另一个表达式中,必须放在圆括号里面

{
  function* demo() {
    console.log('Hello' + yield); // SyntaxError
    console.log('Hello' + yield 123); // SyntaxError

    console.log('Hello' + (yield)); // OK
    console.log('Hello' + (yield 123)); // OK
  }
}

(3)、yield 表达式用作参数或放在赋值表达式的右边,可以不加括号

{
  function* demo() {
    foo(yield 'a', yield 'b'); // OK
    let input = yield; // OK
  }
}

(4)、yield 表达式和return语句的区别

相似:都能返回紧跟在语句后面的那个表达式的值

区别:
-- 每次遇到 yield,函数就暂停执行,下一次再从该位置继续向后执行;而 return 语句不具备记忆位置的功能
-- 一个函数只能执行一次 return 语句,而在 Generator 函数中可以有任意多个 yield

4. return()throw() 方法

Generator 对象除了 next(),还有两个方法用于主动控制执行:

return(value)

  • 作用:立即终止 Generator 函数,返回 { value: 传入值, done: true }
  • 后续再调用 next(),仅返回 { value: undefined, done: true }
function* gen() {
  yield 1;
  yield 2;
}
const g = gen();
console.log(g.next()); // { value:1, done:false }
console.log(g.return('终止')); // { value:'终止', done:true }
console.log(g.next()); // { value:undefined, done:true }

转存失败,建议直接上传图片文件

throw(error)

  • 作用:在当前暂停点抛出一个错误,若函数内未捕获,错误会向外传播;
  • 若函数内用 try/catch 捕获错误,函数会继续执行,直到下一个 yield
function* gen() {
  try {
    yield 1;
  } catch (e) {
    console.log('捕获错误:', e); // 捕获 throw() 抛出的错误
  }
  yield 2;
}
const g = gen();
console.log(g.next()); // { value:1, done:false }
g.throw(new Error('手动抛错')); // 输出:捕获错误:Error: 手动抛错
console.log(g.next()); // { value:2, done:false }

转存失败,建议直接上传图片文件

5.yield* 表达式

如果在 Generator 函数里面调用另一个 Generator 函数,默认情况下是没有效果的

{
  function* foo() {
    yield 'aaa'
    yield 'bbb'
  }

  function* bar() {
    foo()
    yield 'ccc'
    yield 'ddd'
  }

  let iterator = bar()

  for(let value of iterator) {
    console.log(value)
  }

  // ccc
  // ddd

}

转存失败,建议直接上传图片文件

上例中,使用 for...of 来遍历函数bar的生成的遍历器对象时,只返回了bar自身的两个状态值。此时,如果想要正确的在bar 里调用foo,就需要用到 yield* 表达式

yield 表达式用来在一个 Generator 函数里面 执行 另一个 Generator 函数*

{
  function* foo() {
    yield 'aaa'
    yield 'bbb'
  }

  function* bar() {
    yield* foo()      // 在bar函数中 **执行** foo函数
    yield 'ccc'
    yield 'ddd'
  }

  let iterator = bar()

  for(let value of iterator) {
    console.log(value)
  }

  // aaa
  // bbb
  // ccc
  // ddd
}

6.next() 方法的参数

yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值

 [rv] = yield [expression]

expression:定义通过遍历器从生成器函数返回的值,如果省略,则返回 undefined
rv:接收从下一个 next() 方法传递来的参数

例子,并尝试解析遍历生成器函数的执行过程

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next())      // {value: 14, done: false}
  console.log(it.next())      // undefined    {value: undefined, done: false}
}

第一次调用遍历器对象的next方法,函数从头部开始执行,遇到第一个 yield 暂停,在这个过程中其实是分了三步:

(1)、声明了一个变量result,并将声明提前,默认值为 undefined
(2)、由于 Generator函数是 “惰性求值”,执行到第一个 yield 时才会计算求和,并加计算结果返回给遍历器对象 {value: 14, done: false},函数暂停运行
(3)、理论上应该要把等号右边的 [yield 3 + 5 + 6] 赋值给变量result,但是, 由于函数执行到 yield 时暂定了,这一步就被挂起了

第二次调用next方法,函数从上一次 yield 停下的地方开始执行,也就是给result赋值的地方开始,由于next()并没有传参,就相当于传参为undefined

基于以上分析,就不难理解为什么说 yield表达式本身的返回值(特指 [rv])总是undefined了。现在把上面的代码稍作修改,第二次调用 next() 方法传一个参数3,按照上图分析可以很快得出输出结果

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next())      // {value: 14, done: false}
  console.log(it.next(3))      // 3    {value: 3, done: false}
}

如果第一次调用next()的时候也传了一个参数呢?这个当然是无效的,next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。

从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next(10))      // {value: 14, done: false}
  console.log(it.next(3))      // 3    {value: 3, done: false}
}

Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。 也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    // 注意:yield 表达式如果用在另一个表达式中,必须放在圆括号里面
    let z = yield (y / 3)
    return x + y + z
  }

  let it = gen(5)
  /* 通过前面的介绍就知道这部分输出结果是错误的啦
    
    console.log(it.next())  // {value: 6, done: false}
    console.log(it.next())  // {value: 2, done: false}
    console.log(it.next())  // {value: 13, done: false}
  */

  /*** 正确的结果在这里 ***/
  console.log(it.next())  
  // 首次调用next,函数只会执行到 “yield(5+1)” 暂停,并返回 {value: 6, done: false}
  console.log(it.next())  
  // 第二次调用next,没有传递参数,
  //所以 y的值是undefined,那么 y/3 当然是一个NaN,所以应该返回 {value: NaN, done: false}
  console.log(it.next())  
  // 同样的道理,z也是undefined,6 + undefined + undefined = NaN,
  //返回 {value: NaN, done: true}
}

如果向next方法提供参数,返回结果就完全不一样了

{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    // 注意:yield 表达式如果用在另一个表达式中,必须放在圆括号里面
    let z = yield (y / 3)
    return x + y + z
  }

  let it = gen(5)

  console.log(it.next())  
  // 正常的运算应该是先执行圆括号内的计算,再去乘以2,
  //由于圆括号内被 yield 返回 5 + 1 的结果并暂停,所以返回{value: 6, done: false}
  console.log(it.next(9))  
  // 上次是在圆括号内部暂停的,所以第二次调用 next方法应该从圆括号里面开始,
  //就变成了 let y = 2 * (9),y被赋值为18,
  //所以第二次返回的应该是 18/3的结果 {value: 6, done: false}
  console.log(it.next(2))  
  // 参数2被赋值给了 z,最终 x + y + z = 5 + 18 + 2 = 25,返回 {value: 25, done: true}
}



{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    let z = yield (y / 3)
    z = 88    // 注意看这里
    return x + y + z
  }

  let it = gen(5)

  console.log(it.next())   // {value: 6, done: false}
  console.log(it.next(9))  // {value: 6, done: false}
  console.log(it.next(2))  // 这里其实也很容易理解,参数2被赋值给了 z,但是函数体内又给 z 重新赋值为88, 最终 x + y + z = 5 + 18 + 88 = 111,返回 {value: 111, done: true}
}

7.Generator函数与 Iterator 接口的关系

7.1Generator 函数的核心用途之一是简化迭代器的创建

  • Generator 对象本身就是一个迭代器(实现了 Symbol.iterator 方法,且返回自身);
  • 普通迭代器需要手动实现 next() 方法和状态管理,而 Generator 用 yield 即可自动实现迭代逻辑。

(1). 手动实现迭代器(繁琐)

// 手动创建一个迭代器,生成 1~3 的数字
const iterator = {
  count: 1,
  next() {
    if (this.count <= 3) {
      return { value: this.count++, done: false };
    } else {
      return { value: undefined, done: true };
    }
  },
  [Symbol.iterator]() { return this; } // 实现可迭代协议
};

// 迭代
for (const val of iterator) {
  console.log(val); // 1, 2, 3
}

(2). Generator 实现迭代器(简洁)

// Generator 自动生成迭代器
function* numberGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

// 迭代(支持 for...of,因为 Generator 对象是可迭代的)
for (const val of numberGenerator()) {
  console.log(val); // 1, 2, 3
}

7.2 Iterator(迭代器)

JavaScript原有的表示集合的数据结构有数组(Array)和对象(Object),ES6又添加了Map和Set。这样就有了4种数据集合,此时便需要****一种统一的接口机制来处理不同的数据结构

ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。

Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。

传统对象没有原生部署 Iterator接口,不能使用 for...of 和 扩展运算符,现在通过给对象添加 Symbol.iterator 属性和对应的遍历器生成函数,就可以使用了

Iterator对象

  1. Iterator就是这样一个统一的接口。任何数据结构,主要部署Iterator接口,就可以完成遍历
  2. Iterator接口主要供for...of使用(ES6创造的新的遍历命令),当使用for...of循环时,该循环会自动寻找Iterator接口
  3. Iterator对象本质上是一个指针对象。(创建时指向数据结构头部,依次调用next()方法后指针会移动,依次指向第1,2,3...个成员,最后指向结束位置)

默认迭代器

const obj = {//obj具有Symbol.iterator(它是一个方法),因此是可遍历的
  [Symbol.iterator]:function(){
    return {
      next:function(){
        return {
          value:1,
          done:true
        }
      }
    }
  }
}

ES6的有些数据结构(数组)原生部署了Symbol.iterator属性(称为部署了 遍历器接口 ),即不用任何处理就可以被for...of循环。另外一些数据结构(对象)没有。
以下数据结构原生部署Iterator接口:也就是说这些都可以使用for...of。除了这些,其他数据结构(如对象)的Iterator接口需要自己在Symbol.iterator属性上面部署,才会被for...of遍历。

  • Map
  • Set
//NodeList对象//数组的默认迭代器:
let color = ['red','yellow','blue']
let arrIt = colorSymbol.iterator;//返回一个迭代器
arrIt.next()//{value:'red',done:false}
//类数组arguments的默认迭代器:
function fn(){
let argsIt = argumentsSymbol.iterator;
argsIt.next()
}
//类数组dom节点的默认迭代器:
let myP = document.getElementsByTagName('li');
let pIt = myPSymbol.iterator;
pIt.next();
//字符串的默认迭代器:
let str = 'dhakjda';
let strIt = strSymbol.iterator;
strIt.next();
//对象没有默认(即内置)迭代器:obj[Symbol.iterator] is not a function

8.for...of 循环

由于 Generator 函数运行时生成的是一个 Iterator 对象,因此,可以直接使用 for...of 循环遍历,且此时无需再调用 next() 方法

这里需要注意,一旦 next() 方法的返回对象的 done 属性为 true,for...of 循环就会终止,且不包含该返回对象

{
  function* gen() {
    yield 1
    yield 2
    yield 3
    yield 4
    return 5
  }

  for(let item of gen()) {
    console.log(item)
  }

  // 1 2 3 4
}

9.Generator 的典型应用场景

9.1 异步编程(ES6 时代的方案)

Generator 是 async/await 的 “前身” ,通过 yield 暂停异步操作,next() 恢复执行,解决了回调地狱问题。需配合自动执行器(如 co 库)使用:

// 模拟异步请求
function fetchData(url) {
  return new Promise(resolve => {
    setTimeout(() => resolve(`数据:${url}`), 1000);
  });
}

// Generator 函数封装异步逻辑
function* asyncGenerator() {
  const data1 = yield fetchData('url1');
  console.log(data1); // 1秒后输出:数据:url1
  const data2 = yield fetchData('url2');
  console.log(data2); // 再1秒后输出:数据:url2
}

// 手动执行(实际用 co 库自动执行)
const gen = asyncGenerator();
gen.next().value.then(data1 => {
  gen.next(data1).value.then(data2 => {
    gen.next(data2);
  });
});
//.then(data1 => {}) → 是 Promise 的异步回调,等 1 秒后,异步请求完成,
//Promise 的 resolve 值是 数据:url1,这个值会被自动传给回调函数的形参 data1;

注:ES7 引入的 async/await 是 Generator + Promise 的语法糖,更简洁易用,现在已替代 Generator 成为主流异步方案

9.2 生成无限序列(惰性求值)

Generator 支持 “按需生成” 数据,不会一次性创建所有数据,适合处理无限序列(如斐波那契数列)或大数据集:

// 生成无限斐波那契数列
function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3
// 按需获取,不会占用大量内存

9.3 控制流管理

通过 yield 可以灵活控制函数执行顺序,适合复杂的流程控制(如分步执行、条件分支):

function* taskFlow() {
  console.log('任务1');
  yield; // 暂停,等待外部触发下一步
  console.log('任务2');
  const flag = yield '是否执行任务3?'; // 产出询问,接收外部决策
  if (flag) {
    console.log('任务3执行');
  } else {
    console.log('任务3跳过');
  }
}

const flow = taskFlow();
flow.next(); // 任务1 → { value: undefined, done: false }
const res = flow.next(); // 任务2 → { value: '是否执行任务3?', done: false }
flow.next(true); // 任务3执行 → { value: undefined, done: true }

最后

这是《JavaScript系列》第8篇,将持续更新。

小伙伴如果喜欢我的分享,可以动动您发财的手关注下我,我会持续更新的!!!
您对我的关注、点赞和收藏,是对我最大的支持!欢迎关注、评论、讨论和指正!

🔥 Vue 3 项目深度优化之旅:从 787KB 到极致性能

当你以为优化已经结束,真正的挑战才刚刚开始

🎬 序章:优化永无止境

还记得上次我们把构建时间从 35 秒优化到 21 秒,把 vendor 包从 227 KB 压缩到 157 KB 的故事吗?

那时候我以为优化工作已经完成了,直到我看到了这个数字:

element-plus-jMvik2ez.js    787.16 KB  (Gzip: 241.53 KB)

787 KB! 一个 UI 库就占了整个项目 40% 的体积!

这就像你辛辛苦苦减肥成功,结果发现衣柜里还藏着一堆 XXL 的衣服。是时候来一次"断舍离"了。

🔍 第一步:侦探工作 - 找出真凶

工具准备

# 生成包体积分析报告
VITE_ANALYZE=true npm run build:dev

# 打开 dist/stats.html
open dist/stats.html

打开报告的那一刻,我惊呆了:

📦 包体积分布
├─ element-plus (787 KB) 👈 占比 40.8% 🔴
├─ vendor (157 KB)       👈 占比 8.1%  🟢
├─ framework (180 KB)    👈 占比 9.4%  🟢
├─ main (153 KB)         👈 占比 7.9%  🟢
└─ others (651 KB)       👈 占比 33.8% 🟡

Element Plus 一家独大,比其他所有第三方库加起来还要大!

深入调查

让我们看看项目到底用了哪些 Element Plus 组件:

# 搜索所有 Element Plus 组件的使用
grep -r "from 'element-plus'" src/

结果让人意外:

// 实际使用的组件(15 个)
ElMessage          // 消息提示
ElNotification     // 通知
ElMessageBox       // 确认框
ElDialog           // 对话框
ElButton           // 按钮
ElTable            // 表格
ElCheckbox         // 复选框
ElUpload           // 上传
ElIcon             // 图标
ElPopover          // 弹出框
ElScrollbar        // 滚动条
ElCollapseTransition // 折叠动画
ElTour, ElTourStep // 引导
ElTag              // 标签
ElConfigProvider   // 全局配置

// Element Plus 提供的组件(80+ 个)
ElCalendar         // ❌ 未使用
ElDatePicker       // ❌ 未使用
ElTimePicker       // ❌ 未使用
ElCascader         // ❌ 未使用
ElTree             // ❌ 未使用
ElTransfer         // ❌ 未使用
// ... 还有 60+ 个未使用的组件

真相大白: 我们只用了 15 个组件,却打包了 80+ 个组件!

这就像去超市买一瓶水,结果收银员说:"不好意思,我们只卖整箱。"

💡 第二步:制定作战计划

方案 A:手术刀式精准切除

思路: 手动导入需要的组件,排除不需要的

// build/plugins.ts
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的大型组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

优点:

  • 精准控制
  • 风险可控
  • 易于维护

缺点:

  • 需要手动维护排除列表
  • 可能遗漏某些组件

预期效果: 减少 100-150 KB

方案 B:CSS 瘦身计划

问题: Element Plus CSS 也有 211 KB

element-plus.css    210.92 KB  (Gzip: 26.43 KB)

思路: 使用更高效的 CSS 压缩工具

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',  // 比 esbuild 更快更小
  },
})

lightningcss vs esbuild:

指标 esbuild lightningcss 提升
压缩率 87.5% 90.2% ↑ 3.1%
速度 更快 ↑ 20%
兼容性 更好

预期效果: 减少 30-50 KB

方案 C:图片"减肥"大作战

发现问题:

ls -lh dist/assets/webp/

-rw-r--r--  login-bg-line.webp     5.37 KB  ✅ 合理
-rw-r--r--  empty.webp             8.50 KB  ✅ 合理
-rw-r--r--  cargo-ship.webp       13.78 KB  ✅ 合理
-rw-r--r--  logo.webp             14.46 KB  ✅ 合理
-rw-r--r--  login-bg2.webp       267.07 KB  🔴 过大!

267 KB 的背景图! 这相当于 1.7 个 lodash 库的大小!

优化方案:

# 方案 1:压缩图片
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2-optimized.webp \
  --webp-quality 80

# 结果:267 KB → 120 KB (减少 55%)
<!-- 方案 2:懒加载 -->
<template>
  <img
    v-lazy="loginBg"
    alt="Login Background"
    class="login-bg"
  />
</template>

<script setup lang="ts">
// 只在需要时加载
const loginBg = new URL('@/assets/images/login-bg2.webp', import.meta.url).href
</script>
// 方案 3:使用 CDN
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: [/\.(png|jpe?g|gif|svg|webp)$/],
    },
  },
})

预期效果: 减少 200-300 KB

🎯 第三步:实战演练

优化 1:Element Plus 精准打击

实施前的准备

// 1. 创建组件使用清单
const usedComponents = [
  'ElMessage',
  'ElNotification',
  'ElMessageBox',
  'ElDialog',
  'ElButton',
  'ElTable',
  'ElCheckbox',
  'ElUpload',
  'ElIcon',
  'ElPopover',
  'ElScrollbar',
  'ElCollapseTransition',
  'ElTour',
  'ElTourStep',
  'ElTag',
  'ElConfigProvider',
]

// 2. 创建排除清单
const excludedComponents = [
  'ElCalendar',
  'ElDatePicker',
  'ElTimePicker',
  'ElCascader',
  'ElTree',
  'ElTransfer',
  'ElColorPicker',
  'ElRate',
  'ElSlider',
  'ElSwitch',
  // ... 更多未使用的组件
]

配置优化

// build/plugins.ts
AutoImport({
  resolvers: [
    ElementPlusResolver({
      // 只自动导入使用的 API
      exclude: /^El(Calendar|DatePicker|TimePicker)$/,
    }),
  ],
})

Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

验证效果

# 构建并分析
VITE_ANALYZE=true npm run build:dev

# 对比结果
Before: element-plus-xxx.js  787.16 KB (Gzip: 241.53 KB)
After:  element-plus-xxx.js  650.00 KB (Gzip: 195.00 KB)

# 减少:137 KB (17.4%)  🎉

优化 2:CSS 压缩升级

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',
  },
})
# 构建并对比
Before: element-plus.css  210.92 KB (Gzip: 26.43 KB)
After:  element-plus.css  210.92 KB (Gzip: 24.50 KB)

# 减少:1.93 KB (7.3%)  ✨

优化 3:图片压缩

# 压缩背景图
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2.webp \
  --webp-quality 80

# 结果
Before: 267.07 KB
After:  120.00 KB

# 减少:147 KB (55%)  🚀

📊 第四步:战果统计

优化前后对比

指标 优化前 优化后 减少
Element Plus JS 787 KB 650 KB ↓ 137 KB (17%) 🎉
Element Plus CSS 211 KB 211 KB -
CSS (Gzip) 26.43 KB 24.50 KB ↓ 1.93 KB (7%)
背景图片 267 KB 120 KB ↓ 147 KB (55%) 🚀
总计减少 - - ↓ 286 KB 🎊

性能提升

指标 优化前 优化后 提升
首次加载 2.8s 2.2s ↓ 21% 👍
二次访问 0.8s 0.6s ↓ 25% 🚀
FCP 1.8s 1.4s ↓ 22%
LCP 2.5s 2.0s ↓ 20% 💨

用户体验提升

优化前的用户体验:
[========== 加载中 ==========] 2.8s
"怎么这么慢?" 😤

优化后的用户体验:
[====== 加载中 ======] 2.2s
"还不错!" 😊

🎓 第五步:经验总结

踩过的坑

坑 1:过度排除组件

问题:

// ❌ 错误:排除了实际使用的组件
exclude: /^El(Dialog|Button|Table)$/

结果: 页面报错,组件无法加载

解决:

// ✅ 正确:只排除确认未使用的组件
exclude: /^El(Calendar|DatePicker|TimePicker)$/

教训: 充分测试所有功能,确保没有遗漏

坑 2:CSS 压缩导致样式丢失

问题:

// ❌ 错误:使用 PurgeCSS 过度清理
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
})

结果: 动态生成的样式被移除

解决:

// ✅ 正确:配置 safelist
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
  safelist: {
    standard: [/^el-/],
    deep: [/^el-.*__/],
  },
})

教训: 保守优化,充分测试

坑 3:图片压缩过度

问题:

# ❌ 错误:质量设置过低
--webp-quality 50

结果: 图片模糊,用户体验差

解决:

# ✅ 正确:平衡质量和大小
--webp-quality 80

教训: 在质量和大小之间找平衡

最佳实践

1. 组件使用分析

// 创建组件使用清单
const componentUsage = {
  used: [
    'ElMessage',
    'ElDialog',
    // ...
  ],
  unused: [
    'ElCalendar',
    'ElDatePicker',
    // ...
  ],
}

// 定期审查
npm run analyze:components

2. 渐进式优化

第一阶段:低风险优化
├─ CSS 压缩 ✅
├─ 图片压缩 ✅
└─ 代码分割 ✅

第二阶段:中风险优化
├─ 组件排除 ⚠️
├─ CSS 清理 ⚠️
└─ 动态导入 ⚠️

第三阶段:高风险优化
├─ 替换大型库 🔴
├─ 自定义组件 🔴
└─ 深度定制 🔴

3. 持续监控

// package.json
{
  "scripts": {
    "analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "lighthouse": "lighthouse https://your-domain.com --view"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "200 KB"  // 设置预算
    }
  ]
}

🚀 第六步:展望未来

下一步优化方向

1. 考虑替代方案

Element Plus 的轻量级替代:

大小 组件数 优势
Element Plus 787 KB 80+ 功能完整
Naive UI 450 KB 80+ 更轻量
Arco Design 380 KB 60+ 性能好
自定义组件 100 KB 15 完全可控

权衡:

  • 迁移成本 vs 性能收益
  • 功能完整性 vs 包体积
  • 团队熟悉度 vs 学习成本

2. 微前端架构

// 按需加载子应用
const loadSubApp = async (name: string) => {
  const app = await import(`./apps/${name}/index.js`)
  return app.mount('#app')
}

// 只加载当前需要的功能
if (route.path.startsWith('/user')) {
  await loadSubApp('user-management')
}

优势:

  • 更细粒度的代码分割
  • 独立部署和更新
  • 更好的缓存策略

3. 边缘计算

// 使用 CDN 边缘节点
const CDN_BASE = 'https://cdn.example.com'

// 静态资源从 CDN 加载
const loadAsset = (path: string) => {
  return `${CDN_BASE}${path}`
}

优势:

  • 更快的加载速度
  • 减轻服务器压力
  • 全球加速

💰 ROI 分析

投入产出比

投入:

  • 分析时间:2 小时
  • 优化时间:3 小时
  • 测试时间:2 小时
  • 总计:7 小时

产出:

1. 性能提升

  • 包体积减少:286 KB
  • 加载速度提升:21-25%
  • 用户体验提升:显著

2. 成本节省

  • 带宽节省:286 KB × 10000 用户/月 = 2.8 GB/月
  • 服务器成本:约 $50/月
  • 年度节省:$600

3. 用户留存

  • 加载速度提升 → 跳出率降低 15%
  • 用户体验提升 → 留存率提升 10%
  • 潜在价值:难以估量

ROI = (600 + 无形价值) / (7 × 时薪) > 1000%

🎬 尾声:优化是一场马拉松

经过这次深度优化,我们实现了:

  1. Element Plus 瘦身 17%:从 787 KB 到 650 KB
  2. CSS 优化 7%:更高效的压缩
  3. 图片减肥 55%:从 267 KB 到 120 KB
  4. 总体减少 286 KB:约 15% 的体积优化

但更重要的是,我们学会了:

  • 🔍 如何分析:使用工具找出真正的瓶颈
  • 💡 如何决策:权衡收益和风险
  • 🛠️ 如何实施:渐进式优化,充分测试
  • 📊 如何验证:用数据说话
  • 🔄 如何持续:建立监控和预算

记住:

  • 优化不是一次性的工作,而是持续的过程
  • 不要为了优化而优化,要关注用户体验
  • 数据驱动决策,不要凭感觉
  • 保持代码可维护性,不要过度优化

下一站: 微前端架构?边缘计算?还是自定义组件库?

敬请期待下一篇: 《从 Element Plus 到自定义组件库:一次大胆的尝试》


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

有任何问题欢迎在评论区讨论,我会尽快回复!


关键词: Vue 3、Vite、性能优化、Element Plus、包体积优化、深度优化

标签: #Vue3 #Vite #性能优化 #ElementPlus #前端工程化 #深度优化

Dayjs跨年获取周获取错误

众所周知,前端可以通过Dayjs和Momentjs对时间进行格式化和计算等操作。最近恰逢26年跨年我在使用这两个库获取周的时候发现了下面的问题。

dayjs.locale('en')
moment.locale('en')

dayjs('2025-12-31').format('YYYY年w周') // 2025年1周
moment('2025-12-31').format('YYYY年w周') // 2025年1周

dayjs.locale('en-gb')
dayjs('2025-12-28').format('YYYY年w周') // 2025年1周
dayjs('2025-12-31').format('YYYY年w周') // 2025年1周

我发现25年最后这几天格式化出来周是25年第一周,那么问题来了正确获取到的年/周应该是多少呢?我去小查了一下资料,下面是我的一些总结。

下面的总结是我通过查询资料的出来的一些感受,具体应该获取为第几周还需要根据业务来确定。

时间计算周主要分为两种<font style="color:rgb(10, 10, 10);">ISO 8601 标准</font><font style="color:rgb(10, 10, 10);">北美通用习惯</font>,且在计算时主要注意两个问题:<font style="color:rgb(10, 10, 10);">年第一周怎么算</font>/<font style="color:rgb(10, 10, 10);">每周的起始日</font>

ISO 8601 标准

  • 本年度第一个星期四所在的星期;
  • 1月4日所在的星期;
  • 本年度第一个至少有4天在 同一星期内的星期;
  • 星期一在去年12月29日至今年1月4日以内的星期;
  • 每周周一为起始日

北美通用习惯

  • 1月1日所在的周就是第一周
  • 每周周天为起始日

根据上面的规则,可以得到下面的答案

// ISO 8601
dayjs('2025-12-28').format('YYYY年w周') // 2025年52周
dayjs('2025-12-31').format('YYYY年w周') // 2026年1周
// 北美
dayjs('2025-12-28').format('YYYY年w周') // 2026年1周
dayjs('2025-12-31').format('YYYY年w周') // 2026年1周

我在dayjs的github上我提了一个issuepr,因为是第一次提pr不太熟悉规则,小弟有什么犯错的地方欢迎大佬们指教。

Vue 3 项目包体积优化实战:从 227KB 到精细化分包

通过精细化分包策略,优化缓存效率,提升加载性能

🎯 优化目标

在完成构建速度优化后,我们发现包体积也有优化空间:

  • Element Plus 占 787 KB(40.8%)- 过大
  • Vendor 包 227 KB - 包含多个库,缓存效率低
  • 总体积 1.93 MB - 需要优化

📊 优化前后对比

分包策略对比

包名 优化前 优化后 变化
element-plus 787.16 KB 787.19 KB ≈ 0
framework 180.42 KB 180.42 KB ≈ 0
vendor 226.66 KB 157.37 KB ↓ 30.6% 🎉
lodash - 27.61 KB 新增
axios - 38.96 KB 新增
dayjs - 18.25 KB 新增
crypto - 69.90 KB 新增

关键改进

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB(减少 69 KB
  2. 精细化分包:将常用库独立打包,提升缓存效率
  3. 并行加载:多个小包可以并行下载,提升加载速度

🔧 优化实施

优化 1:精细化分包策略

问题分析

原来的配置将所有工具库打包到一个 utils chunk:

// ❌ 优化前:粗粒度分包
if (normalized.includes('/lodash') || 
    normalized.includes('/dayjs') || 
    normalized.includes('/axios')) {
  return 'utils'  // 所有工具库打包在一起
}

问题:

  • 单个文件过大(包含 lodash + dayjs + axios)
  • 任何一个库更新,整个 chunk 缓存失效
  • 不常用的库也会被加载

优化方案

// ✅ 优化后:细粒度分包
// 工具库细分 - 提升缓存效率
if (normalized.includes('/lodash')) {
  return 'lodash'  // lodash 单独打包
}
if (normalized.includes('/dayjs')) {
  return 'dayjs'   // dayjs 单独打包
}
if (normalized.includes('/axios')) {
  return 'axios'   // axios 单独打包
}

// 大型库单独打包
if (normalized.includes('/xlsx')) {
  return 'xlsx'
}
if (normalized.includes('/crypto-js')) {
  return 'crypto'
}
if (normalized.includes('/dompurify')) {
  return 'dompurify'
}

优化效果

缓存效率提升:

  • 场景 1:只更新业务代码

    • 优化前:vendor (227 KB) 缓存失效
    • 优化后:只有 vendor (157 KB) 缓存失效,lodash/axios/dayjs 仍然有效
  • 场景 2:升级 axios

    • 优化前:整个 utils chunk 缓存失效
    • 优化后:只有 axios (39 KB) 缓存失效

并行加载:

  • 浏览器可以同时下载多个小文件
  • HTTP/2 多路复用,并行下载更高效

优化 2:Element Plus 自动导入优化

问题分析

Element Plus 占 787 KB,虽然已经使用了按需导入,但仍然很大。

优化方案

// 1. 在 AutoImport 中也添加 Element Plus resolver
AutoImport({
  imports: ["vue", "vue-router", "pinia", "vue-i18n"],
  resolvers: [
    ElementPlusResolver(),  // 自动导入 Element Plus API
  ],
})

// 2. 在 Components 中配置
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: "sass",
      directives: false,  // 不自动导入指令,减少体积
    }),
  ],
})

预期效果

  • 更精确的按需导入
  • 避免导入未使用的 API 和指令
  • 预计可减少 10-15% 的 Element Plus 体积

📈 性能提升分析

1. 缓存命中率提升

场景模拟:

假设每月发版 4 次,每次更新:

  • 业务代码更新:100%
  • 依赖库更新:10%

优化前:

  • 每次发版,用户需要重新下载 vendor (227 KB)
  • 月流量:227 KB × 4 = 908 KB

优化后:

  • 业务代码更新:vendor (157 KB)
  • 依赖更新(10% 概率):lodash/axios/dayjs 之一 (约 30 KB)
  • 月流量:157 KB × 4 + 30 KB × 0.4 = 640 KB

节省流量: 268 KB/月/用户(减少 29.5%

2. 首屏加载优化

并行下载优势:

优化前(串行):
[====== vendor 227KB ======] 2.27s (假设 100KB/s)

优化后(并行):
[== vendor 157KB ==] 1.57s
[= lodash 28KB =] 0.28s
[= axios 39KB ==] 0.39s
[= dayjs 18KB ==] 0.18s
总时间:max(1.57, 0.28, 0.39, 0.18) = 1.57s

加载时间减少: 0.7s(提升 30.8%

3. 用户体验提升

指标 优化前 优化后 提升
首次加载 ~3.5s ~2.8s ↓ 20%
二次访问 ~1.2s ~0.8s ↓ 33%
更新后访问 ~2.0s ~1.4s ↓ 30%

🎓 深度解析:为什么这样优化有效?

1. HTTP/2 的多路复用

现代浏览器支持 HTTP/2,可以:

  • 在单个连接上并行传输多个文件
  • 避免队头阻塞
  • 更高效的资源利用

最佳实践:

  • 单个文件大小:20-100 KB
  • 文件数量:5-15 个
  • 避免过度分割(< 10 KB 的文件)

2. 浏览器缓存策略

浏览器缓存基于文件名(包含 hash):

  • 文件内容不变 → hash 不变 → 使用缓存
  • 文件内容改变 → hash 改变 → 重新下载

精细化分包的优势:

  • 减少缓存失效的范围
  • 提高缓存命中率
  • 降低用户流量消耗

3. 关键渲染路径优化

首屏渲染需要:
1. HTML
2. 关键 CSS
3. 关键 JS(framework + main)
4. 非关键 JS(vendor + 其他库)

优化策略:
- 关键资源:内联或优先加载
- 非关键资源:延迟加载或并行加载

🛠️ 实战技巧

技巧 1:分析包体积

# 生成可视化报告
VITE_ANALYZE=true npm run build:dev

# 查看 stats.html
open dist/stats.html

关注指标:

  • 单个 chunk 大小(建议 < 200 KB)
  • 重复依赖(应该为 0)
  • 未使用的代码(通过 Tree Shaking 移除)

技巧 2:合理的分包粒度

// 🎯 最佳实践
const chunkSizeMap = {
  'element-plus': 787,  // 大型 UI 库,单独打包
  'framework': 180,     // 核心框架,单独打包
  'vendor': 157,        // 其他依赖,合并打包
  'lodash': 28,         // 常用工具库,单独打包
  'axios': 39,          // HTTP 库,单独打包
  'dayjs': 18,          // 日期库,单独打包
  'crypto': 70,         // 加密库,单独打包
}

// ❌ 过度分割
const chunkSizeMap = {
  'lodash-debounce': 2,    // 太小,不值得单独打包
  'lodash-throttle': 2,    // 太小,不值得单独打包
  'lodash-cloneDeep': 3,   // 太小,不值得单独打包
}

技巧 3:监控包体积变化

// package.json
{
  "scripts": {
    "build:analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "size-limit:check": "size-limit --why"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "250 KB"
    },
    {
      "path": "dist/assets/js/vendor-*.js",
      "limit": "160 KB"
    }
  ]
}

📋 优化检查清单

分包策略

  • 大型库(> 100 KB)单独打包
  • 常用库(20-100 KB)单独打包
  • 小型库(< 20 KB)合并打包
  • 避免过度分割(< 10 KB)

缓存策略

  • 使用 contenthash 命名
  • 稳定的 chunk 名称
  • 合理的缓存时间
  • CDN 配置正确

性能监控

  • 定期生成包体积报告
  • 设置体积预算
  • 监控首屏加载时间
  • 跟踪缓存命中率

🎯 下一步优化方向

1. Element Plus 深度优化

当前状态: 787 KB(Gzip: 242 KB)

优化方向:

  • 分析实际使用的组件
  • 移除未使用的组件
  • 考虑使用更轻量的替代方案

预期收益: 减少 150-200 KB

2. 动态导入优化

当前状态: 所有路由组件都在首屏加载

优化方向:

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/dashboard/index.vue'),
  },
  {
    path: '/settings',
    component: () => import('@/views/settings/index.vue'),
  },
]

预期收益: 首屏减少 30-40%

3. Tree Shaking 优化

当前状态: 可能存在未使用的代码

优化方向:

  • 检查 lodash-es 导入方式
  • 使用具名导入
  • 配置 sideEffects

预期收益: 减少 50-100 KB

📊 ROI 分析

投入时间: 2 小时

收益:

  • 包体积优化:69 KB(vendor)
  • 缓存效率提升:29.5%
  • 加载时间减少:30.8%
  • 用户体验提升:20-33%

长期收益:

  • 每月节省流量:268 KB × 用户数
  • 提升用户留存率
  • 降低服务器带宽成本

🎬 总结

通过精细化分包策略,我们实现了:

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB
  2. 缓存效率提升:29.5% 的流量节省
  3. 加载速度提升:30.8% 的时间减少
  4. 更好的可维护性:清晰的依赖关系

核心原则

  1. 合理分包:根据更新频率和大小分包
  2. 提升缓存:减少缓存失效范围
  3. 并行加载:利用 HTTP/2 多路复用
  4. 持续监控:定期检查包体积变化

最后的建议

  • DO:定期分析包体积
  • DO:设置体积预算
  • DO:监控性能指标
  • DON'T:过度分割
  • DON'T:忽视缓存策略
  • DON'T:盲目追求极致

关键词: Vite 包体积优化、代码分割、缓存策略、性能优化、Vue 3

标签: #Vite #包体积优化 #性能优化 #前端工程化


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

更多前端性能优化技巧,请关注我的专栏《前端性能优化实战》

React 那么多状态管理库,到底选哪个?如果非要焊死一个呢?这篇文章解决你的选择困难症

前言

各位 React 开发者们,是不是还在为状态管理头疼?在我的这篇文章中:

🚀别再卷 Redux 了!Zustand 才是 React 状态管理的躺平神器

有掘友问到:React那么多状态库,能不能直接焊死一个?

image.png

那就简单聊下我的看法(仅供参考)。篇幅比较长,中间的代码示例大家可以跳着阅读。

📊 主流状态管理库分类

1. 客户端状态管理

  • Redux Toolkit (RTK) - 最成熟,企业级首选
  • Zustand - 轻量简洁,API 友好
  • Jotai - 原子化状态,React 风格
  • Recoil - Facebook 出品,实验性
  • Valtio - 代理基础,可变语法

2. 服务端状态管理

  • TanStack Query (React Query) - 异步数据王者
  • SWR - Vercel 出品,轻量
  • RTK Query - Redux 生态内

3. 全栈/框架集成

  • Next.js - 内置多种方案
  • Remix - 基于 loader/action
  • Nuxt (Vue)- 类比参考

🎯 我的建议:焊死这个组合

对于大多数项目,如果非要焊死一个的话,我推荐:Zustand + TanStack Query。React 太多状态管理库了,如果非要焊死一个,我目前推荐这个王炸组合。

🌈 为什么选择这个组合

一、先搞懂:为什么要分开处理两种状态?

在开始安利组合之前,我们得先明确一个核心认知:React 项目中的状态,从来都不是一锅炖的,而是分为两种截然不同的类型,需要区别对待。

  1. 客户端本地状态(UI 状态) 比如:按钮的禁用状态、侧边栏的展开 / 折叠、导航栏的当前选中项、用户的本地偏好设置等。这类状态的特点是:同步更新、无需缓存、仅在客户端生效、数据量较小
  2. 服务端异步状态(接口数据) 比如:从后端获取的用户列表、文章数据、商品信息等。这类状态的特点是:异步获取、需要处理 loading/error 状态、需要缓存、可能需要后台刷新、支持分页 / 无限加载

过去我们总想着用一套方案搞定所有状态,结果就是既要又要还要,最后搞得不伦不类。而 Zustand + TanStack Query 的组合,正是精准切中了这两种状态的需求,各司其职、强强联合。

二、 Zustand:客户端状态管理的「极简天花板」

Zustand 是一款轻量、简洁、API 友好的客户端状态管理库,它的核心理念就是少即是多—— 没有繁琐的概念,没有多余的模板代码,甚至不需要 Provider 包裹整个应用,上手即用。

1. 核心优势:为什么放弃 Redux 选择 Zustand?

  • 🚀 代码量减少 70% :无需写 Action、Reducer,无需配置 Provider,直接创建 Store 即可使用。
  • 🎉 无需 Provider 包裹:告别顶层嵌套的 Provider 地狱,尤其是在大型项目中,能极大简化组件树结构。
  • 🔒 TypeScript 支持完美:内置 TypeScript 类型推导,无需额外写大量类型声明,写起来丝滑流畅。
  • JavaScript 无缝兼容:无需额外配置类型,原生 JS 写起来丝滑流畅,新手也能快速上手。
  • 💪 足够应对 95% 的客户端状态需求:支持中间件、持久化、状态切片,扩展性拉满,小型项目和中型项目都能 hold 住。
  • 📦 超小体积:核心体积不到 1KB,对项目打包体积几乎没有影响,堪称「轻量王者」。

2. 代码示例:5 分钟上手 Zustand

第一步:安装依赖
npm install zustand 
# 或 yarn add zustand 
# 或 pnpm add zustand
第二步:创建第一个 Store

我们来写一个最简单的计数器,感受一下 Zustand 的简洁:

// src/store/count.store.js
import { create } from 'zustand';

// 创建计数器 Store
const useCountStore = create((set) => ({
    // 定义状态数据
    count: 0,
    
    // 定义修改状态的方法(无需 Action,直接修改)
    increase: () => set((state) => ({ count: state.count + 1 })),
    decrease: () => set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
    
    // 支持传入参数修改状态
    setCount: (num) => set({ count: num }),
}));

export default useCountStore;
// scr/App.jsx
import useCountStore from './store/count.store.js'
export default function App() {
    return (
        <div>
            <h1>Count: {useCountStore((state) => state.count)}</h1>
        </div>
    )
}

image.png

第三步:在组件中使用 Store

无需任何额外配置,直接导入使用,就是这么简单:

// src/components/CountComponent.jsx
import useCountStore from '../store/count.store';

const CountComponent = () => {
    // 按需获取状态和方法(支持解构,不会触发不必要的重渲染)
    const count = useCountStore((state) => state.count);
    const { increase, decrease, reset } = useCountStore();

    return (
        <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px' }}>
            <h3 style={{ color: '#1890ff' }}>Zustand 计数器示例</h3>
            <p style={{ fontSize: '24px', margin: '20px 0' }}>当前计数:{count}</p>
            <div>
                <button
                    onClick={increase}
                    style={{ marginRight: '10px', padding: '8px 16px', cursor: 'pointer' }}
                >
                    +1
                </button>
                <button
                    onClick={decrease}
                    style={{ marginRight: '10px', padding: '8px 16px', cursor: 'pointer' }}
                >
                    -1
                </button>
                <button
                    onClick={reset}
                    style={{ padding: '8px 16px', cursor: 'pointer', backgroundColor: '#f5f5f5' }}
                >
                    重置
                </button>
            </div>
        </div>
    );
};

export default CountComponent;

image.png

进阶示例: Zustand 持久化(本地存储用户偏好)

如果需要将状态持久化到 localStorage(比如用户的侧边栏偏好),Zustand 也能轻松实现,只需借助内置的中间件:

// src/store/ui.store.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 创建 UI 状态 Store,并开启持久化
const useUiStore = create(
    persist(
        (set) => ({
            // 侧边栏展开状态
            sidebarCollapsed: false,
            // 切换侧边栏状态
            toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
        }),
        {
            // 持久化的 key(用于 localStorage 中存储的键名)
            name: 'ui-preferences',
        }
    )
);

export default useUiStore;

使用起来和普通 Store 毫无区别,但是状态会自动同步到 localStorage,页面刷新后也不会丢失,个人觉得还是挺方便的。

三、 TanStack Query:服务端状态管理的「异步王者」

如果说 Zustand 是客户端状态的极简天花板,那么 TanStack Query(原 React Query)就是服务端状态的异步王者

它的核心作用是:帮你封装了所有服务端数据处理的繁琐逻辑,让你像使用本地状态一样使用异步接口数据。你再也不用手动处理 loading、error、缓存、重试这些问题,只需专注于编写接口请求函数即可。

1. 核心优势:为什么选择 TanStack Query?

  • 🚀 自动缓存:请求的数据会自动缓存,相同的请求不会重复发送,极大减少接口请求次数。
  • 🎉 自动处理 loading/error 状态:内置 loading、error、data 状态,无需手动声明和更新。
  • 💪 后台数据同步:支持后台刷新数据,页面在前台时自动更新最新数据,无需用户手动刷新。
  • 📦 内置分页 / 无限加载 / 乐观更新:提供丰富的 Hooks 支持复杂的异步数据场景,无需自己造轮子。
  • 🔄 自动重试:请求失败时可以配置自动重试,提高接口的容错性。
  • 🧰 强大的 DevTools:配套的开发者工具,能清晰看到请求的缓存、状态、历史记录,调试更方便。

2. 代码示例:5 分钟上手 TanStack Query

第一步:安装依赖
npm install @tanstack/react-query @tanstack/react-query-devtools
# 或 yarn add @tanstack/react-query @tanstack/react-query-devtools
# 或 pnpm add @tanstack/react-query @tanstack/react-query-devtools
第二步:全局配置 TanStack Query

首先需要在项目入口文件中配置 QueryClientQueryClientProvider,这是唯一需要全局配置的步骤:

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// 创建 QueryClient 实例
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            // 默认开启缓存,5 分钟内不重复请求
            staleTime: 5 * 60 * 1000,
            // 请求失败时自动重试 3 次
            retry: 3,
            // 关闭无限加载(可选)
            refetchOnWindowFocus: false,
        },
    },
});

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <QueryClientProvider client={queryClient}>
        <App />
        {/* 挂载 DevTools(开发环境开启,生产环境可移除) */}
        <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
);
第三步:封装接口请求 Hook

我们来封装一个获取待办事项的 Hook,感受一下 TanStack Query 的强大:

// src/api/todos.api.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 1. 定义接口请求函数
const fetchTodos = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');
    if (!response.ok) {
        throw new Error('获取待办事项失败');
    }
    return response.json();
};

// 2. 定义新增待办事项的函数(纯 JavaScript)
const addTodo = async (newTodo) => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(newTodo),
    });
    if (!response.ok) {
        throw new Error('新增待办事项失败');
    }
    return response.json();
};

// 3. 封装获取待办事项的 Hook(使用 useQuery,纯 JavaScript)
export const useTodosQuery = () => {
    return useQuery({
        // queryKey:缓存的唯一标识,必须是数组类型(支持依赖项传递)
        queryKey: ['todos'],
        // queryFn:接口请求函数
        queryFn: fetchTodos,
    });
};

// 4. 封装新增待办事项的 Hook(使用 useMutation,处理 POST/PUT/DELETE 请求)
export const useAddTodoMutation = () => {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: addTodo,
        // 新增成功后,自动刷新待办事项列表(乐观更新)
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
    });
};
第四步:在组件中使用接口 Hook

无需手动处理 loading 和 error,直接解构使用即可,代码简洁到飞起:

// src/components/TodoComponent.jsx
import React, { useState } from 'react';
import { useTodosQuery, useAddTodoMutation } from '../api/todos.api';

const TodoComponent = () => {
    const [title, setTitle] = useState('');
    // 获取待办事项数据
    const { data: todos, isLoading, isError, error } = useTodosQuery();
    // 新增待办事项
    const { mutate: addTodo, isPending: isAdding } = useAddTodoMutation();

    // 处理新增待办事项提交
    const handleSubmit = (e) => {
        e.preventDefault();
        if (!title.trim()) return;
        addTodo({ title, completed: false });
        setTitle('');
    };

    // 加载中状态
    if (isLoading) {
        return <div style={{ padding: '20px' }}>正在获取待办事项...</div>;
    }

    // 错误状态
    if (isError) {
        return <div style={{ padding: '20px', color: '#ff4d4f' }}>获取失败:{error.message}</div>;
    }

    return (
        <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px', marginTop: '20px' }}>
            <h3 style={{ color: '#1890ff' }}>TanStack Query 待办事项示例(JSX)</h3>

            {/* 新增待办事项表单 */}
            <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
                <input
                    type="text"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    placeholder="请输入待办事项"
                    style={{ padding: '8px', width: '300px', marginRight: '10px' }}
                />
                <button
                    type="submit"
                    disabled={isAdding}
                    style={{ padding: '8px 16px', cursor: 'pointer', backgroundColor: '#1890ff', color: '#fff', border: 'none', borderRadius: '4px' }}
                >
                    {isAdding ? '新增中...' : '新增待办'}
                </button>
            </form>

            {/* 待办事项列表 */}
            <div>
                <h4>待办列表(前 10 条)</h4>
                <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
                    {todos?.slice(0, 10).map((todo) => (
                        <li
                            key={todo.id}
                            style={{
                                padding: '10px',
                                borderBottom: '1px solid #f5f5f5',
                                textDecoration: todo.completed ? 'line-through' : 'none',
                                color: todo.completed ? '#999' : '#333',
                            }}
                        >
                            {todo.title}
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    );
};

export default TodoComponent;

运行代码后,你会发现:请求自动发送、加载状态自动处理、新增数据后列表自动刷新、相同请求不会重复发送 —— 这一切,都是 TanStack Query 帮你做好的,你只需要专注于业务逻辑即可。

四、 王炸组合落地:项目结构最佳实践

看完了两个库的单独使用,我们再来看看如何在实际项目中整合 Zustand + TanStack Query,打造一个清晰、可维护的项目结构。

src/
├── store/           # Zustand 客户端状态存储目录
│   ├── auth.store.js  # 认证相关状态(登录状态、用户信息)
│   ├── ui.store.js    # UI 相关状态(侧边栏、主题、导航)
│   └── index.js       # Store 导出汇总(方便组件导入)
├── api/            # TanStack Query 接口 Hook 目录
│   ├── todos.js       # 待办事项相关接口
│   ├── users.js       # 用户相关接口
│   └── index.js       # 接口 Hook 导出汇总
├── components/     # 公共组件目录
├── pages/          # 页面组件目录
└── App.jsx         # 根组件

🚀 快速决策指南

可能有人会问:我的项目很小,需要用这套组合吗?我的项目是大型企业级项目,这套组合够用吗?

别急,我让DeepSeek给大家整理了一份懒人快速决策指南,对应不同场景选择最合适的方案:

  1. 超简单状态(单个组件内、无需共享):直接用 useState 即可,无需引入任何状态库,简单直接。
  2. 小型项目 / 简单共享状态(少量组件共享状态):可以用 React Context + useReducer,或者直接用 Zustand(上手更快,代码更简洁)。
  3. 中型项目(推荐,90% 的项目场景):直接焊死 Zustand + TanStack Query,开发体验最佳,覆盖 99% 的场景,后期维护成本低。
  4. 大型企业级项目(需要强架构、可追溯、团队协作):可以选择 Redux Toolkit + RTK Query,支持时间旅行调试、丰富的中间件生态,适合对架构有严格要求的大型项目。
  5. 超极简需求(只需要原子化状态):可以选择 Jotainanostores,原子化状态管理,按需更新,体积更小。

📝 具体落地建议

// 1. 安装核心依赖
"dependencies": {
  "zustand": "^4.0.0",
  "@tanstack/react-query": "^5.0.0",
  "@tanstack/react-query-devtools": "^5.0.0"
}

// 2. 项目结构
src/
├── store/           # Zustand stores
│   ├── auth.store.ts
│   ├── ui.store.ts
│   └── index.ts
├── api/            # TanStack Query hooks
│   ├── todos.ts
│   └── users.ts
└── components/

🔄 迁移策略

如果你现在用 Redux,逐步迁移:

  1. 新功能用 Zustand
  2. 旧功能保持 Redux
  3. 两者可以共存

💡 黄金法则

  1. 先判断状态类型

    • 服务器数据?→ TanStack Query
    • 客户端 UI 状态?→ Zustand
    • 表单状态?→ React Hook Form + Zustand
  2. 避免过度设计

    • 能用 useState 就别用状态库
    • 组件内状态优先
    • 共享状态才提升
  3. 技术选型标准

    • 团队熟悉度
    • 维护活跃度
    • TypeScript 支持
    • Bundle 大小

🎖️ 最终答案

非要焊死的话,那我推荐这个组合:Zustand + TanStack Query

这个组合能覆盖:

  • ✅ 客户端状态(Zustand)
  • ✅ 服务端状态(TanStack Query)
  • ✅ 表单状态(React Hook Form)
  • ✅ URL 状态(React Router)

对于 90% 的 React 项目,这套组合是最佳实践。除非你有特殊需求(如需要 Redux 中间件生态或时间旅行调试)。

结语

到这里,相信大家已经对 Zustand + TanStack Query 这套王炸组合有了全面的了解。

这套组合的核心魅力就在于:简洁、高效、各司其职。Zustand 搞定客户端本地状态,让你告别繁琐的 Provider 和模板代码;TanStack Query 搞定服务端异步数据,让你告别手动处理 loading/error/ 缓存的烦恼。

祝大家编码愉快,少写 bug,多摸鱼~ 🚀

团队协作新范式:用Cursor构建智能前端工作流

当AI编程助手从个人工具升级为团队基础设施,前端开发的协作模式正在发生根本性变革

前言:从“个人加速器”到“团队增强器”

在前两篇文章中,我们已经探索了Cursor如何改变个人开发体验和重构工作流。然而,真正的生产力革命发生在团队层面——当每个人都使用AI助手时,如何确保协作的一致性、代码质量的统一性和知识的有效传递?

一家中型电商团队的经历颇具代表性:最初只是几位工程师尝试使用Cursor,很快发现各自生成的代码风格迥异,缺乏统一的模式和规范。两个月后,他们建立了一套共享的Cursor配置和团队规范,代码审查时间减少了40%,新成员上手速度提升了60%。

本篇将深入探讨如何将Cursor从个人生产力工具,转变为团队协作的基础设施,打造真正智能化的前端工作流。

一、建立团队统一的Cursor配置系统

1.1 团队级.cursorrules配置规范

与个人使用不同,团队协作需要一套共享的“AI编程规范”。这不仅仅是编码风格,更是团队技术决策的体现。

团队配置文件示例

# .cursorrules/team-guidelines.md
# ===============================
# 团队AI协作规范 v2.1
# 适用于所有使用Cursor的团队成员

## 架构决策记录(ADR)
- 状态管理:统一使用Zustand,禁止新增Redux代码
- 样式方案:Tailwind CSS + CSS Modules组合方案
- 组件库:内部组件库前缀统一为 `App` (如AppButton)
- API客户端:统一使用基于axios封装的httpClient

## 代码生成约束
### 禁止生成的模式
- 避免生成内联样式对象,除非是动态计算值
- 禁止使用`any`类型,必须显式定义接口
- 避免生成超过100行的单个组件文件

### 推荐模式
- 优先生成函数组件而非类组件
- 使用TypeScript严格模式
- 遵循React Hooks最佳实践

## 项目特定规则
### 电商模块
- 价格计算统一使用`formatPrice`工具函数
- 商品SKU验证逻辑必须通过`validateSKU`函数
- 购物车状态必须与用户会话绑定

### 用户系统
- 权限检查使用`usePermissions`自定义Hook
- 用户数据流必须经过清理和验证

配置同步策略

# 将团队配置纳入版本控制
git add .cursorrules/
git commit -m "chore: 更新团队Cursor规范v2.1"

# 使用Husky钩子确保配置同步
# 在.husky/pre-commit中添加
if [ -f ".cursorrules/team-guidelines.md" ]; then
  echo "检查Cursor配置版本..."
  # 验证本地配置与远程一致
fi

1.2 智能代码片段库:团队的“集体智慧”

Cursor的强大之处在于能够学习团队的代码模式。建立一个共享的智能代码片段库,可以确保最佳实践在团队中传播。

创建团队片段库的方法

// .cursor/snippets/README.md
// 团队共享代码片段库

// 1. 常用业务模式
// =================
// 电商价格展示组件模式
/**
 * @snippet price-display
 * @desc 统一的价格展示组件,支持折扣、原价显示
 * @tags 电商,价格,组件
 */
const PriceDisplay: React.FC<PriceDisplayProps> = ({ 
  price, 
  originalPrice,
  currency = 'CNY'
}) => {
  // 团队统一的格式化逻辑
  const formattedPrice = formatPrice(price, currency);
  const hasDiscount = originalPrice && originalPrice > price;
  
  return (
    <div className="price-container">
      <span className="current-price">{formattedPrice}</span>
      {hasDiscount && (
        <span className="original-price">
          {formatPrice(originalPrice, currency)}
        </span>
      )}
    </div>
  );
};

// 2. API请求模式
/**
 * @snippet api-hook-pattern
 * @desc 标准的API请求Hook模式
 * @tags api,hook,请求
 */
export const useApiResource = <T,>(endpoint: string, initialData: T) => {
  const [data, setData] = useState<T>(initialData);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const fetchData = useCallback(async (params?: Record<string, any>) => {
    setLoading(true);
    try {
      const response = await httpClient.get(endpoint, { params });
      setData(response.data);
      setError(null);
    } catch (err) {
      setError('请求失败');
      console.error(`API错误 [${endpoint}]:`, err);
    } finally {
      setLoading(false);
    }
  }, [endpoint]);
  
  return { data, loading, error, fetchData, setData };
};

二、智能化代码审查与质量保证

2.1 Cursor辅助的代码审查工作流

传统的代码审查往往关注语法细节,而有了Cursor,审查可以更专注于架构和业务逻辑。

智能审查指令集

# 代码提交前的自动审查
# 在package.json中配置
"scripts": {
  "cursor-review": "cursor --review-changes --rules .cursorrules/team-guidelines.md",
  "precommit-review": "cursor --staged --output review-report.md"
}

# 常用的审查指令
指令:"审查这段代码,重点关注:
1. 是否符合团队的状态管理规范
2. 是否有潜在的性能问题
3. 错误处理是否完整
4. 可访问性是否达标"

# 生成审查报告
指令:"生成详细的代码审查报告,包括:
- 架构符合度评分
- 潜在风险列表
- 具体改进建议
- 重构优先级"

集成到现有工作流

# .github/workflows/cursor-review.yml
name: AI-Assisted Code Review
on: [pull_request]

jobs:
  cursor-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: 设置Cursor环境
        uses: actions/setup-node@v3
        
      - name: 运行AI辅助审查
        run: |
          npx cursor-review@latest \
            --github-token ${{ secrets.GITHUB_TOKEN }} \
            --pr-number ${{ github.event.pull_request.number }} \
            --rules .cursorrules/team-guidelines.md
          
      - name: 发布审查报告
        uses: actions/github-script@v6
        with:
          script: |
            // 将审查结果发布到PR评论

2.2 自定义审查规则的进阶应用

Cursor允许团队定义自己的审查规则,这些规则可以捕捉团队特定的问题模式。

// .cursor/custom-rules/performance-rules.js
// 自定义性能审查规则

module.exports = {
  rules: {
    '避免大组件': {
      pattern: /const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*{[^}]{200,}}/gs,
      message: '组件超过200行,建议拆分为更小的子组件',
      severity: 'warning'
    },
    
    'useEffect依赖项检查': {
      pattern: /useEffect\s*\(\s*\(\)\s*=>\s*\{[^}]+?\}\s*,\s*\[\s*\]\s*\)/g,
      message: 'useEffect缺少依赖项,可能导致过时闭包问题',
      severity: 'error'
    },
    
    '图片优化提醒': {
      pattern: /<img[^>]*src=["']([^"']+)["'][^>]*>/g,
      check: async (match, filePath) => {
        const src = match[1];
        // 检查是否为WebP格式,是否设置合适尺寸
        if (!src.includes('.webp') && !src.startsWith('data:')) {
          return '建议使用WebP格式图片以提高性能';
        }
        return null;
      }
    }
  }
};

三、团队知识管理与智能文档

3.1 项目文档的自动生成与维护

文档与代码脱节是团队常见问题。Cursor可以基于代码和注释,自动生成和更新文档。

自动化文档工作流

// 1. 智能组件文档生成
/**
 * @component UserProfile
 * @description 用户个人资料展示组件
 * @prop {User} user - 用户数据对象
 * @prop {boolean} editable - 是否可编辑模式
 * @prop {Function} onUpdate - 资料更新回调
 * @example
 * <UserProfile 
 *   user={currentUser}
 *   editable={true}
 *   onUpdate={handleUserUpdate}
 * />
 */
const UserProfile: React.FC<UserProfileProps> = ({ user, editable, onUpdate }) => {
  // 组件实现...
};

// 让Cursor基于组件生成Markdown文档
指令:"基于以上组件代码和注释,生成完整的组件文档,包括:
- Props详细说明表格
- 使用示例(包括TypeScript示例)
- 常见问题解答
- 相关组件链接"

// 2. API文档自动同步
指令:"扫描所有API service文件,生成统一的API文档:
- 每个端点的URL和方法
- 请求参数类型和示例
- 响应数据格式
- 错误码说明"

3.2 团队知识库的AI增强

建立一个“活”的知识库,可以回答团队的技术问题。

# 团队知识库:AI增强版

## 架构决策查询
问:“我们为什么选择Zustand而不是Redux Toolkit?”
AI回答:“根据2023年10月的架构决策记录(ADR-003),选择Zustand的主要原因是:
1. 更简单的API,学习成本降低60%
2. 包体积减少42%
3. 与React 18并发特性兼容更好
4. 具体性能对比数据见 `/docs/decisions/state-management-choice.md`## 代码模式查询
问:“如何处理表单提交的loading状态和错误?”
AI回答:“团队标准模式是使用 `useSubmitHandler` Hook:
```typescript
// 示例代码
const { submit, isLoading, error } = useSubmitHandler(
  formData => api.post('/endpoint', formData),
  { successMessage: '提交成功' }
);

完整示例见 /examples/form-handling/advanced-form.tsx

故障排除指南

问:“页面首次加载白屏3秒,如何排查?” AI回答:“按照团队性能排查清单:

  1. 检查Bundle大小:npm run analyze
  2. 检查代码分割:确保路由使用React.lazy
  3. 检查API响应时间:使用Performance面板
  4. 查看已知问题:/docs/troubleshooting/white-screen.md 请运行诊断脚本:npm run diagnose:performance

## 四、智能化的团队协作工作流

### 4.1 统一的任务分解与估算

Cursor可以帮助团队更准确地进行任务分解和工作量估算。

**智能任务拆解模板**:
```markdown
# 功能开发任务卡

## 任务描述
{{任务描述}}

## AI辅助拆解

指令:“将以下功能需求拆解为具体的开发任务: {{粘贴需求文档}}

要求:

  1. 按前端组件粒度拆解
  2. 估算每个任务的理想人时(考虑团队平均速度)
  3. 识别技术风险和依赖项
  4. 推荐开发顺序

## 拆解结果
### 阶段1:基础架构(预计:8人时)
- [ ] 创建数据模型和TypeScript接口(2人时)
- [ ] 设置API service层(3人时)
- [ ] 配置状态管理store(3人时)

### 阶段2:核心组件(预计:12人时)
- [ ] 主列表组件(4人时)
- [ ] 详情弹窗组件(4人时)
- [ ] 搜索筛选组件(4人时)

### 阶段3:集成与优化(预计:6人时)
- [ ] 路由集成(2人时)
- [ ] 性能优化(2人时)
- [ ] 错误处理(2人时)

## 技术风险
1. API响应格式可能与预期不符
2. 大数据量下的列表性能需要关注

4.2 新人入职的AI加速

为新成员配置专门的Cursor规则,可以大幅缩短上手时间。

# .cursorrules/onboarding-guide.md
# 新人专属配置

## 学习路径引导
欢迎使用团队AI助手!以下是你第一个月应该关注的内容:

### 第一周:了解基础
- 运行 `npm run explore:architecture` 查看项目结构
- 使用指令:“解释项目的状态管理架构”
- 完成交互式教程:`npm run tutorial:core-concepts`

### 第二周:动手实践
- 使用代码生成模板创建你的第一个组件
- 指令:“创建一个商品卡片组件,参考 `components/product/ProductCard.tsx` 的模式”
- 运行自动代码审查了解团队标准

### 第三周:深度参与
- 尝试重构一个小模块
- 使用指令:“优化这个组件,使其更容易测试”
- 查看团队的代码审查记录学习最佳实践

### 第四周:独立贡献
- 认领一个简单的功能需求
- 使用任务拆解功能规划工作
- 提交你的第一个Pull Request

## 新人常见问题快速通道
问:“如何开始开发新功能?”
答:运行 `npm run create:feature feature-name` 使用标准模板

问:“遇到问题应该问谁?”
答:1. 首先问AI助手 2. 查看知识库 3. 在团队频道提问

问:“如何确保我的代码符合规范?”
答:每次提交前运行 `npm run precommit-check`

五、挑战与解决方案:团队引入Cursor的实战经验

5.1 常见挑战与应对策略

挑战 现象 解决方案
代码风格碎片化 不同人生成的代码风格迥异 建立团队统一的.cursorrules配置,定期同步更新
过度依赖 成员不加思考地接受AI建议 设置“AI生成代码必须标注出处”规则,定期进行代码审查会
知识孤岛 AI学习个人习惯而非团队模式 建立共享的代码片段库和审查规则库
审查负担加重 AI生成大量代码增加审查难度 实现自动化的预审查,只将关键问题提交人工审查

5.2 实施路线图建议

graph LR
    A[第一阶段:试点] --> B[第二阶段:标准化]
    B --> C[第三阶段:集成化]
    C --> D[第四阶段:智能化]
    
    subgraph A
        A1[2-3名早期使用者]
        A2[个人规则摸索]
        A3[收集使用场景]
    end
    
    subgraph B
        B1[团队基础规范]
        B2[共享配置库]
        B3[基础代码审查规则]
    end
    
    subgraph C
        C1[CI/CD集成]
        C2[自动化文档]
        C3[知识库增强]
    end
    
    subgraph D
        D1[预测性建议]
        D2[智能任务分配]
        D3[自适应学习系统]
    end

5.3 关键成功指标

团队引入Cursor后,应该跟踪以下指标:

interface TeamAIMetrics {
  // 开发效率
  featureLeadTime: number; // 功能从开始到交付的时间
  codeReviewCycleTime: number; // 代码审查周期
  
  // 代码质量
  bugRate: number; // 每千行代码的bug数
  technicalDebtIndex: number; // 技术债务指数
  
  // 团队协作
  onboardingTime: number; // 新成员上手时间
  knowledgeSharingScore: number; // 知识共享评分
  
  // AI使用效果
  aiAdoptionRate: number; // AI建议采纳率
  aiGeneratedCodeQuality: number; // AI生成代码质量评分
}

// 月度检查点示例
const checkCursorAdoption = (metrics: TeamAIMetrics) => {
  console.log(`AI采用报告:
  1. 开发效率提升: ${((1 - metrics.featureLeadTime / baseline) * 100).toFixed(1)}%
  2. 代码审查时间减少: ${((1 - metrics.codeReviewCycleTime / baseline) * 100).toFixed(1)}%
  3. 新人上手速度提升: ${((baseline / metrics.onboardingTime - 1) * 100).toFixed(1)}%
  4. AI代码质量评分: ${metrics.aiGeneratedCodeQuality}/10`);
};

六、未来展望:AI增强团队的进化路径

6.1 下一阶段:预测性协作

未来的团队AI助手将不仅响应指令,还能主动提出建议:

// 预测性建议示例
interface PredictiveSuggestion {
  type: 'refactor' | 'optimization' | 'documentation' | 'testing';
  priority: 'high' | 'medium' | 'low';
  description: string;
  estimatedImpact: {
    timeSaved: string; // 预计节省时间
    qualityImprovement: string; // 质量提升
    riskReduction: string; // 风险降低
  };
  action: {
    command: string; // 执行的命令
    autoApply: boolean; // 是否自动应用
  };
}

// AI可能主动建议:
{
  type: 'optimization',
  priority: 'high',
  description: '检测到商品列表组件在移动端有性能问题,建议虚拟滚动',
  estimatedImpact: {
    timeSaved: '首次加载减少1.2秒',
    qualityImprovement: '移动端FCP提升40%',
    riskReduction: '低内存设备崩溃率降低'
  },
  action: {
    command: 'cursor --optimize ProductList --strategy virtual-scroll',
    autoApply: false
  }
}

6.2 团队AI文化培育

最终,Cursor不仅是一个工具,更是团队文化的一部分:

  1. 透明化AI决策:记录重要的AI建议和采纳原因
  2. 集体学习机制:定期分享AI使用技巧和发现的最佳实践
  3. 伦理与责任框架:明确AI生成代码的责任归属和质量标准
  4. 持续进化心态:随着AI能力提升,不断调整团队工作方式

结语:重新定义“团队智慧”

Cursor等AI编程助手的出现,正在重新定义“团队智慧”的含义。传统意义上的团队智慧依赖于资深成员的指导和知识传递,而现在,这种智慧可以被编码、共享和增强。

真正的团队AI协作不是让人像机器一样工作,而是让机器像最佳团队成员一样辅助人工作。

当每个团队成员都拥有一个理解项目上下文、掌握团队规范、记得所有历史决策的AI搭档时,团队的集体智慧将被放大到前所未有的程度。


下篇预告:在第四篇中,我们将探索Cursor与现代前端技术栈的深度结合,包括Next.js 14应用架构、React Server Components、边缘计算等前沿领域的实战应用,展示AI如何帮助团队保持在技术浪潮的前沿。

团队实践挑战:在你们团队中选择一个小的协作痛点(如代码审查、知识传递、新人培训),尝试用本文的方法设计一个AI增强的解决方案,并在评论区分享你的设计和实施结果!

❌