阅读视图

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

Vue 组件中访问根实例的完整指南

Vue 组件中访问根实例的完整指南 在 Vue 组件开发中,有时需要访问根实例来调用全局方法、访问全局状态或触发全局事件。下面详细介绍各种访问根实例的方法及其应用场景。 一、直接访问根实例的方法 1.

使用ThreeJS绘制东方明珠塔模型

    最近在看ThreeJS这块,学习了规则立体图形的绘制,想着找一个现实的建筑用ThreeJS做一个模型,这里选择了东方明珠电视塔。 看着相对比较简单一些,简单来看由直立圆柱、倾斜圆柱、球、圆锥这四种几何体组成。直立圆柱与倾斜圆柱的绘制略有不同,下面会一一介绍。
    东方明珠塔主要包括底座、塔身、塔尖三部分组成,以坐标原点为中心进行绘制。

底座

    底座的主要组成是直立的圆柱、倾斜的圆柱和圆球,直立的圆柱和圆直接使用ThreeJS提供的方法即可, 这里主要介绍三个直立圆柱坐标和倾斜的圆柱。

底座的坐标

    底座的三个圆柱、倾斜的圆柱所在位置当成等边三角形的三个顶点即可。等边三角形的中心到三个顶点之间的距离是一致的,可以理解同一个半径的圆上的三个顶点。这里可以提出一个公共方法,以原点为中心,输入半径,自动计算出三个顶点坐标。代码如下:

export function calculateEqulateralTriangleVertex(sideLength: number): THREE.Vector3[] {
    // 计算等边三角形的半径
    const circumradius = sideLength / Math.sqrt(3)
    // 角度为45度为第一个点
    const angles = [Math.PI / 4, (11 * Math.PI) / 12, (19 * Math.PI) / 12]

    const vertices = angles.map((angle) => {
    const x = circumradius _ Math.cos(angle)
    const z = circumradius _ Math.sin(angle)
    return new THREE.Vector3(x, 0, z)
    })

    return vertices
}

    这里可以根据计算出的三个顶点坐标,绘制出三个直立圆柱。倾斜圆柱的绘制需要计算出倾斜圆柱顶面和底面的坐标,然后设置不同的Y值即可。计算倾斜圆柱的坐标代码如下:

export function calculateIntersectionsVertex(sideLength: number): THREE.Vector3[] {
  // 1、计算外接圆半径(原点到各顶点的距离)
  const circumradius = sideLength / Math.sqrt(3)

  // 2、定义三个顶点
  const angles = [Math.PI / 4, (11 * Math.PI) / 12, (19 * Math.PI) / 12] as const

  // 3、计算顶点坐标
  const p1 = new THREE.Vector3(
    circumradius * Math.cos(angles[0]),
    0,
    circumradius * Math.sin(angles[0]),
  )
  const p2 = new THREE.Vector3(
    circumradius * Math.cos(angles[1]),
    0,
    circumradius * Math.sin(angles[1]),
  )
  const p3 = new THREE.Vector3(
    circumradius * Math.cos(angles[2]),
    0,
    circumradius * Math.sin(angles[2]),
  )
  // 3. 计算三条边的中点(垂线交点,纯number运算,无undefined)
  const intersection1 = new THREE.Vector3((p1.x + p2.x) / 2, (p1.y + p2.y) / 2, (p1.z + p2.z) / 2)
  const intersection2 = new THREE.Vector3((p2.x + p3.x) / 2, (p2.y + p3.y) / 2, (p2.z + p3.z) / 2)
  const intersection3 = new THREE.Vector3((p3.x + p1.x) / 2, (p3.y + p1.y) / 2, (p3.z + p1.z) / 2)

  return [intersection1, intersection2, intersection3]
}

    这里根据计算出的三个顶点坐标,绘制出三个倾斜的圆柱。计算逻辑与绘制直立圆柱略有不同,下面将进行介绍。

倾斜的圆柱

    倾斜的圆柱顶面和底面不是一个圆,而是一个椭圆。不能使用常规绘制圆柱的方法来绘制。这里需要创建一个组来包含椭圆柱,椭圆柱包含顶面、底面、侧面三个部分。

顶面

    因为是一个椭圆,这里假设顶面椭圆是32边形,使用三角函数计算出各个点的坐标。代码如下:

const topVertices: number[] = []
// radialSegments 32
for (let i = 0; i <= radialSegments; i++) {
  const theta = thetaStart + (i / radialSegments) * thetaLength
  const x = radius * Math.cos(theta)
  const z = radius * Math.sin(theta)
  topVertices.push(topCenter.x + x, topCenter.y, topCenter.z + z)
}

    计算出各个边的顶点之后,还需要计算出点与点之间是如何连接成三角形的,不然各个边的顶点始终是孤立的点。因为Three.js最终是渲染的三角形。代码如下:

const topIndices: number[] = []
// radialSegments 32
for (let i = 0; i < radialSegments; i++) {
  topIndices.push(0, ((i + 1) % radialSegments) + 1, i + 1)
}

    坐标和索引计算完成之后,需要在Three.js中创建一个BufferGeometry对象,并设置顶点坐标、索引等。代码如下:

const topGeometry = new BufferGeometry()
// 定义那些顶点连接成三角形面,用于形成顶部盖子的扇形结构
topCapGeometry.setIndex(topIndices)
// 设置顶点位置属性
topCapGeometry.setAttribute('position', new THREE.Float32BufferAttribute(topVertices, 3))
// 计算法向量 决定了材质如何与光源交互,影响渲染效果
topCapGeometry.computeVertexNormals()

    接下来需要创建一个材质对象,并设置材质属性。这里使用的使用MeshPhotonMaterial(一种支持高光反射的材质类型),并设置材质属性。然后把几何体参数和材质传给Mesh对象,并添加到场景中。代码如下:

const topCapMaterial = new THREE.MeshPhongMaterial({
  color: topCapColor, // 材质颜色
  wireframe: false, // 是否是线框
  side: THREE.DoubleSide, // 材质双面可见
})

const topCap = new THREE.Mesh(topCapGeometry, topCapMaterial)
group.add(topCap)

底面

    底面的创建与顶面基本一致,主要的区别在于生成索引的顺序不同。顶面的法向量朝向Y轴正方向,形成三角形面时的索引是顺时针顺序,而底面的法向量朝向Y轴负方向,形成三角形面时的索引是逆时针顺序。其他保持一致。

侧面

    侧面的绘制与顶面和底面基本一致,主要区别是计算索引和侧面的坐标计算不一致。     侧面坐标需要计算三角形面的顶部坐标、底部坐标,如果侧面是由多段组成的,还需要计算每段之间的坐标。代码如下:

// 存储顶点和索引
const vertices: number[] = []

// 计算顶点
for (let y = 0; y <= heightSegments; y++) {
  const t = y / heightSegments
  const currentY = bottomCenter.y + t * (topCenter.y - bottomCenter.y)

  for (let i = 0; i <= radialSegments; i++) {
    const theta = thetaStart + (i / radialSegments) * thetaLength

    // 椭圆参数方程
    const x = radius * Math.cos(theta)
    const z = radius * Math.sin(theta)

    // 底部椭圆顶点
    if (y === 0) {
      vertices.push(bottomCenter.x + x, currentY, bottomCenter.z + z)
    }
    // 顶部椭圆顶点
    else if (y === heightSegments) {
      vertices.push(topCenter.x + x, currentY, topCenter.z + z)
    }
    // 中间部分顶点(线性插值)
    else {
      const interpolatedX = bottomCenter.x + x + (topCenter.x + x - (bottomCenter.x + x)) * t
      const interpolatedZ = bottomCenter.z + z + (topCenter.z + z - (bottomCenter.z + z)) * t

      vertices.push(interpolatedX, currentY, interpolatedZ)
    }
  }
}

    计算完各个顶点之后,需要计算各个顶点之间的索引。与侧面计算方式一样,如果侧面是由多段组成的,还需要计算每段之间的索引。代码如下:

const indicles: number[] = []
// 生成索引
for (let y = 0; y < heightSegments; y++) {
  for (let i = 0; i < radialSegments; i++) {
    const a = y * (radialSegments + 1) + i
    const b = y * (radialSegments + 1) + (i + 1)
    const c = (y + 1) * (radialSegments + 1) + i
    const d = (y + 1) * (radialSegments + 1) + (i + 1)
    // 生成两个三角形
    indices.push(a, b, d)
    indices.push(a, d, c)
  }
}

    坐标和索引计算完成之后,接下来的步骤和创建侧面和顶面的步骤一致。需要在Three.js中创建一个BufferGeometry对象,并设置顶点坐标、索引等。代码如下:

// 设置几何体属性
sideGeometry.setIndex(indices)
sideGeometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))

// 计算法向量
sideGeometry.computeVertexNormals()

// 创建侧面网格材质
const sideMaterial = new THREE.MeshPhongMaterial({
  color, // 侧面颜色
  wireframe: false,
  side: THREE.DoubleSide,
})

const ellipticalCylinder = new THREE.Mesh(sideGeometry, sideMaterial)

圆球

    如果直接创建一个圆球,看上去像是一个椭圆,为了增加立体感,这里通过创建两个半球和一个圆柱来实现,创建半球是只需要这只不同的开始和结束角度即可,主要参数是thetaStart和thetaLength,代码如下:

// 创建底部上半球 S
const solidTopSphere = createSphere({
  radius: 8,
  thetaLength: Math.PI / 2,
  material: new THREE.MeshBasicMaterial({
    color: 0xffffff,
    transparent: false, // 不透明
    opacity: 1,
    side: THREE.DoubleSide,
  }),
  position: new THREE.Vector3(0, 20, 0), // 左侧位置
  rotation: new THREE.Euler(0, 0, 0),
  addToContainer: true,
})

// 创建底部上半球 E

// 创建中间链接圆柱 S
const midCylinderGeometry = new THREE.CylinderGeometry(7, 7, 1, 32)
const midCylinderMaterial = new THREE.MeshBasicMaterial({
  color: 0x1577ff, // 颜色
  opacity: 0.5,
  side: THREE.DoubleSide, // 是否显示半圆的底面
})
const midCylinder = new THREE.Mesh(midCylinderGeometry, midCylinderMaterial)
midCylinder.position.copy(new THREE.Vector3(0, 19.5, 0))
// 创建中间链接圆柱 E

// 创建底部下半球 S
const solidBottomSphere = createSphere({
  radius: 8,
  thetaStart: Math.PI / 2,
  thetaLength: Math.PI / 2,
  material: new THREE.MeshBasicMaterial({
    color: 0xffffff, // 白色
    transparent: false, // 不透明
    opacity: 1,
    side: THREE.DoubleSide, // 是否显示半圆的底面
  }),
  position: new THREE.Vector3(0, 19, 0), // 左侧位置
  rotation: new THREE.Euler(0, 0, 0),
})
// 创建底部下半球 E

塔身

    塔身的主要组成部分是圆柱和球,圆柱是创建底座时的圆柱,设置一个较大的高度即可。圆柱和球都是ThreeJS提供的标准几何体,创建圆球是只需要设置不同的Y坐标即可,这里不在赘述。

塔尖

    塔尖的主要组成部分是圆球、圆柱、圆锥体,这些都是标准的几何体,创建起来也比较简单。唯一一个可能得难点是塔尖下面有一个相对大的平台,平台上有一些直立的半径很小的圆柱,这里我们假设为8个。这些圆柱的坐标我们可以理解为正八边形中八个点的坐标。类似于计算等边三角形的坐标,我们可以通过计算正八边形中每个点的坐标来得到这些圆柱的坐标。代码如下:

/**
 * 拓展:计算正多边形顶点坐标(支持自定义起始角度和圆心偏移,3D 版本)
 * @param radius 外接圆半径(>0)
 * @param sides 边数(≥3)
 * @param startAngle 起始角度(弧度制,默认 0,即 X 轴正方向)
 * @param center 圆心偏移量(默认 (0,0,0),即原点)
 * @returns 正多边形顶点坐标数组
 */
export function calculateRegularPolygonVertices3DExtended(
  radius: number,
  sides: number,
  startAngle: number = 0,
  center: THREE.Vector3 = new THREE.Vector3(0, 0, 0),
): THREE.Vector3[] {
  // 复用基础校验逻辑
  if (typeof radius !== 'number' || isNaN(radius) || radius <= 0) {
    throw new Error('外接圆半径必须是大于 0 的有效数字')
  }
  if (typeof sides !== 'number' || isNaN(sides) || sides < 3 || !Number.isInteger(sides)) {
    throw new Error('正多边形边数必须是大于或等于 3 的整数')
  }

  const vertices: THREE.Vector3[] = []
  const angleStep = (2 * Math.PI) / sides

  for (let i = 0; i < sides; i++) {
    const currentAngle = startAngle + i * angleStep // 叠加起始角度
    const x = center.x + radius * Math.cos(currentAngle) // 叠加圆心 X 偏移
    const y = center.y + 0 // 保持 Y=0(可自定义修改)
    const z = center.z + radius * Math.sin(currentAngle) // 叠加圆心 Z 偏移
    vertices.push(new THREE.Vector3(x, y, z))
  }

  return vertices
}

    有了这些坐标,我们就可以创建这些圆柱了。创建一个简单的模型主要的点在于计算坐标,坐标算出来了,剩下的就是使用THREE.js提供的几何体绘制方法像拼积木搭建即可。

完整代码

stackblitz.com/edit/vitejs…

Vue Router 404页面配置:从基础到高级的完整指南

Vue Router 404页面配置:从基础到高级的完整指南

前言:为什么需要精心设计404页面?

404页面不只是"页面不存在"的提示,它还是:

  • 🚨 用户体验的救生艇:用户迷路时的导航站
  • 🔍 SEO优化的重要部分:正确处理404状态码
  • 🎨 品牌展示的机会:体现产品设计的一致性
  • 📊 数据分析的入口:了解用户访问的"死胡同"

今天,我们将从基础到高级,全面掌握Vue Router中的404页面配置技巧。

一、基础配置:创建你的第一个404页面

1.1 最简单的404页面配置

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import NotFound from '../views/NotFound.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/About.vue')
  },
  // 404路由 - 必须放在最后
  {
    path: '/:pathMatch(.*)*', // Vue 3 新语法
    name: 'NotFound',
    component: NotFound
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
<!-- views/NotFound.vue -->
<template>
  <div class="not-found">
    <div class="error-code">404</div>
    <h1 class="error-title">页面不存在</h1>
    <p class="error-message">
      抱歉,您访问的页面可能已被删除或暂时不可用。
    </p>
    <div class="action-buttons">
      <router-link to="/" class="btn btn-primary">
        返回首页
      </router-link>
      <button @click="goBack" class="btn btn-secondary">
        返回上一页
      </button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'NotFound',
  methods: {
    goBack() {
      if (window.history.length > 1) {
        this.$router.go(-1)
      } else {
        this.$router.push('/')
      }
    }
  }
}
</script>

<style scoped>
.not-found {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 80vh;
  text-align: center;
  padding: 2rem;
}

.error-code {
  font-size: 8rem;
  font-weight: 900;
  color: #e0e0e0;
  line-height: 1;
  margin-bottom: 1rem;
}

.error-title {
  font-size: 2rem;
  margin-bottom: 1rem;
  color: #333;
}

.error-message {
  font-size: 1.1rem;
  color: #666;
  margin-bottom: 2rem;
  max-width: 500px;
}

.action-buttons {
  display: flex;
  gap: 1rem;
}

.btn {
  padding: 0.75rem 1.5rem;
  border-radius: 4px;
  text-decoration: none;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s ease;
}

.btn-primary {
  background-color: #1890ff;
  color: white;
  border: none;
}

.btn-primary:hover {
  background-color: #40a9ff;
}

.btn-secondary {
  background-color: transparent;
  color: #666;
  border: 1px solid #d9d9d9;
}

.btn-secondary:hover {
  border-color: #1890ff;
  color: #1890ff;
}
</style>

1.2 路由匹配模式详解

// Vue Router 的不同匹配模式
const routes = [
  // Vue 3 推荐:匹配所有路径并捕获参数
  {
    path: '/:pathMatch(.*)*', // 捕获路径到 params.pathMatch
    component: NotFound
  },
  
  // Vue 2 或 Vue 3 兼容
  {
    path: '*', // 旧版本语法,Vue 3 中仍然可用
    component: NotFound
  },
  
  // 捕获特定模式
  {
    path: '/user-:userId(.*)', // 匹配 /user-xxx
    component: UserProfile,
    beforeEnter: (to) => {
      // 可以在这里验证用户ID是否存在
      if (!isValidUserId(to.params.userId)) {
        return { path: '/404' }
      }
    }
  },
  
  // 嵌套路由中的404
  {
    path: '/dashboard',
    component: DashboardLayout,
    children: [
      {
        path: '', // 默认子路由
        component: DashboardHome
      },
      {
        path: 'settings',
        component: DashboardSettings
      },
      {
        path: ':pathMatch(.*)*', // 仪表板内的404
        component: DashboardNotFound
      }
    ]
  }
]

二、中级技巧:智能404处理

2.1 动态404页面(根据错误类型显示不同内容)

<!-- views/NotFound.vue -->
<template>
  <div class="not-found">
    <!-- 根据错误类型显示不同内容 -->
    <template v-if="errorType === 'product'">
      <ProductNotFound :product-id="productId" />
    </template>
    
    <template v-else-if="errorType === 'user'">
      <UserNotFound :username="username" />
    </template>
    
    <template v-else>
      <GenericNotFound />
    </template>
  </div>
</template>

<script>
import GenericNotFound from '@/components/errors/GenericNotFound.vue'
import ProductNotFound from '@/components/errors/ProductNotFound.vue'
import UserNotFound from '@/components/errors/UserNotFound.vue'

export default {
  name: 'NotFound',
  components: {
    GenericNotFound,
    ProductNotFound,
    UserNotFound
  },
  computed: {
    // 从路由参数分析错误类型
    errorType() {
      const path = this.$route.params.pathMatch?.[0] || ''
      
      if (path.includes('/products/')) {
        return 'product'
      } else if (path.includes('/users/')) {
        return 'user'
      } else if (path.includes('/admin/')) {
        return 'admin'
      }
      return 'generic'
    },
    
    // 提取ID参数
    productId() {
      const match = this.$route.params.pathMatch?.[0].match(/\/products\/(\d+)/)
      return match ? match[1] : null
    },
    
    username() {
      const match = this.$route.params.pathMatch?.[0].match(/\/users\/(\w+)/)
      return match ? match[1] : null
    }
  }
}
</script>

2.2 全局路由守卫中的404处理

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // ... 其他路由
    {
      path: '/404',
      name: 'NotFoundPage',
      component: () => import('@/views/NotFound.vue')
    },
    {
      path: '/:pathMatch(.*)*',
      redirect: (to) => {
        // 可以在重定向前记录日志
        log404Error(to.fullPath)
        
        // 如果是API路径,返回API 404
        if (to.path.startsWith('/api/')) {
          return {
            path: '/api/404',
            query: { originalPath: to.fullPath }
          }
        }
        
        // 否则返回普通404页面
        return {
          path: '/404',
          query: { originalPath: to.fullPath }
        }
      }
    }
  ]
})

// 全局前置守卫
router.beforeEach((to, from, next) => {
  // 检查用户权限
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next('/login')
    return
  }
  
  // 检查路由是否存在(动态路由验证)
  if (!isRouteValid(to)) {
    // 重定向到404页面,并传递原始路径
    next({
      path: '/404',
      query: { 
        originalPath: to.fullPath,
        timestamp: new Date().getTime()
      }
    })
    return
  }
  
  next()
})

// 全局后置守卫 - 用于分析和埋点
router.afterEach((to, from) => {
  // 记录页面访问
  analytics.trackPageView(to.fullPath)
  
  // 如果是404页面,记录访问
  if (to.name === 'NotFoundPage') {
    track404Error({
      path: to.query.originalPath,
      referrer: from.fullPath,
      userAgent: navigator.userAgent
    })
  }
})

2.3 异步路由验证

// 动态验证路由是否存在
async function isRouteValid(to) {
  // 对于动态路由,需要验证参数是否有效
  if (to.name === 'ProductDetail') {
    try {
      const productId = to.params.id
      const isValid = await validateProductId(productId)
      return isValid
    } catch {
      return false
    }
  }
  
  // 对于静态路由,检查路由表
  const matchedRoutes = router.getRoutes()
  return matchedRoutes.some(route => 
    route.path === to.path || route.regex.test(to.path)
  )
}

// 路由配置示例
const routes = [
  {
    path: '/products/:id',
    name: 'ProductDetail',
    component: () => import('@/views/ProductDetail.vue'),
    // 路由独享的守卫
    beforeEnter: async (to, from, next) => {
      try {
        const productId = to.params.id
        
        // 验证产品是否存在
        const productExists = await checkProductExists(productId)
        
        if (productExists) {
          next()
        } else {
          // 产品不存在,重定向到404
          next({
            name: 'ProductNotFound',
            params: { productId }
          })
        }
      } catch (error) {
        // API错误,重定向到错误页面
        next({
          name: 'ServerError',
          query: { from: to.fullPath }
        })
      }
    }
  },
  
  // 产品404页面(不是通用404)
  {
    path: '/products/:productId/not-found',
    name: 'ProductNotFound',
    component: () => import('@/views/ProductNotFound.vue'),
    props: true
  }
]

三、高级配置:企业级404解决方案

3.1 多层404处理架构

// router/index.js - 企业级路由配置
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // 公共路由
    {
      path: '/',
      component: () => import('@/layouts/PublicLayout.vue'),
      children: [
        { path: '', component: () => import('@/views/Home.vue') },
        { path: 'about', component: () => import('@/views/About.vue') },
        { path: 'contact', component: () => import('@/views/Contact.vue') },
        // 公共404
        { path: ':pathMatch(.*)*', component: () => import('@/views/PublicNotFound.vue') }
      ]
    },
    
    // 仪表板路由
    {
      path: '/dashboard',
      component: () => import('@/layouts/DashboardLayout.vue'),
      meta: { requiresAuth: true },
      children: [
        { path: '', component: () => import('@/views/dashboard/Home.vue') },
        { path: 'profile', component: () => import('@/views/dashboard/Profile.vue') },
        { path: 'settings', component: () => import('@/views/dashboard/Settings.vue') },
        // 仪表板内404
        { path: ':pathMatch(.*)*', component: () => import('@/views/dashboard/DashboardNotFound.vue') }
      ]
    },
    
    // 管理员路由
    {
      path: '/admin',
      component: () => import('@/layouts/AdminLayout.vue'),
      meta: { requiresAuth: true, requiresAdmin: true },
      children: [
        { path: '', component: () => import('@/views/admin/Dashboard.vue') },
        { path: 'users', component: () => import('@/views/admin/Users.vue') },
        { path: 'analytics', component: () => import('@/views/admin/Analytics.vue') },
        // 管理员404
        { path: ':pathMatch(.*)*', component: () => import('@/views/admin/AdminNotFound.vue') }
      ]
    },
    
    // 特殊错误页面
    {
      path: '/403',
      name: 'Forbidden',
      component: () => import('@/views/errors/Forbidden.vue')
    },
    {
      path: '/500',
      name: 'ServerError',
      component: () => import('@/views/errors/ServerError.vue')
    },
    {
      path: '/maintenance',
      name: 'Maintenance',
      component: () => import('@/views/errors/Maintenance.vue')
    },
    
    // 全局404 - 必须放在最后
    {
      path: '/:pathMatch(.*)*',
      name: 'GlobalNotFound',
      component: () => import('@/views/errors/GlobalNotFound.vue')
    }
  ]
})

// 错误处理中间件
router.beforeEach(async (to, from, next) => {
  // 维护模式检查
  if (window.__MAINTENANCE_MODE__ && to.path !== '/maintenance') {
    next('/maintenance')
    return
  }
  
  // 权限检查
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
  const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
  
  if (requiresAuth && !store.state.user.isAuthenticated) {
    next('/login')
    return
  }
  
  if (requiresAdmin && !store.state.user.isAdmin) {
    next('/403')
    return
  }
  
  // 动态路由验证
  if (to.name === 'ProductDetail') {
    const isValid = await validateProductRoute(to.params.id)
    if (!isValid) {
      // 重定向到产品专用404
      next({
        name: 'ProductNotFound',
        params: { productId: to.params.id }
      })
      return
    }
  }
  
  next()
})

3.2 SEO友好的404配置

<!-- views/errors/NotFound.vue -->
<template>
  <div class="not-found">
    <!-- 结构化数据,帮助搜索引擎理解 -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "WebPage",
      "name": "404 Page Not Found",
      "description": "The page you are looking for does not exist.",
      "url": "https://yourdomain.com/404",
      "isPartOf": {
        "@type": "WebSite",
        "name": "Your Site Name",
        "url": "https://yourdomain.com"
      }
    }
    </script>
    
    <!-- 页面内容 -->
    <div class="container">
      <h1 class="error-title">404 - Page Not Found</h1>
      
      <!-- 搜索建议 -->
      <div class="search-suggestions" v-if="suggestions.length > 0">
        <p>Were you looking for one of these?</p>
        <ul class="suggestion-list">
          <li v-for="suggestion in suggestions" :key="suggestion.path">
            <router-link :to="suggestion.path">
              {{ suggestion.title }}
            </router-link>
          </li>
        </ul>
      </div>
      
      <!-- 热门内容 -->
      <div class="popular-content">
        <h3>Popular Pages</h3>
        <div class="popular-grid">
          <router-link 
            v-for="page in popularPages" 
            :key="page.path"
            :to="page.path"
            class="popular-card"
          >
            {{ page.title }}
          </router-link>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'

export default {
  name: 'NotFound',
  setup() {
    const route = useRoute()
    const suggestions = ref([])
    const popularPages = ref([
      { path: '/', title: 'Home' },
      { path: '/products', title: 'Products' },
      { path: '/about', title: 'About Us' },
      { path: '/contact', title: 'Contact' }
    ])

    // 分析路径,提供智能建议
    onMounted(() => {
      const path = route.query.originalPath || ''
      
      // 提取可能的搜索关键词
      const keywords = extractKeywords(path)
      
      // 查找相关页面
      if (keywords.length > 0) {
        suggestions.value = findRelatedPages(keywords)
      }
      
      // 发送404事件到分析工具
      send404Analytics({
        path,
        referrer: document.referrer,
        suggestions: suggestions.value.length
      })
    })

    return {
      suggestions,
      popularPages
    }
  }
}
</script>

<style scoped>
/* 确保搜索引擎不会索引404页面 */
.not-found {
  /* 设置适当的HTTP状态码需要服务器端配合 */
}

/* 对于客户端渲染,可以在头部添加meta标签 */
</style>
// server.js - Node.js/Express 示例
const express = require('express')
const { createServer } = require('http')
const { renderToString } = require('@vue/server-renderer')
const { createApp } = require('./app')

const server = express()

// 为404页面设置正确的HTTP状态码
server.get('*', async (req, res, next) => {
  const { app, router } = createApp()
  
  await router.push(req.url)
  await router.isReady()
  
  const matchedComponents = router.currentRoute.value.matched
  
  if (matchedComponents.length === 0) {
    // 设置404状态码
    res.status(404)
  } else if (matchedComponents.some(comp => comp.name === 'NotFound')) {
    // 明确访问/404页面时,也设置404状态码
    res.status(404)
  }
  
  const html = await renderToString(app)
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>${router.currentRoute.value.name === 'NotFound' ? '404 - Page Not Found' : 'My App'}</title>
        <meta name="robots" content="noindex, follow">
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
  `)
})

3.3 404页面数据分析与监控

// utils/errorTracking.js
class ErrorTracker {
  constructor() {
    this.errors = []
    this.maxErrors = 100
  }

  // 记录404错误
  track404(path, referrer = '') {
    const error = {
      type: '404',
      path,
      referrer,
      timestamp: new Date().toISOString(),
      userAgent: navigator.userAgent,
      screenResolution: `${window.screen.width}x${window.screen.height}`,
      language: navigator.language
    }

    this.errors.push(error)
    
    // 限制存储数量
    if (this.errors.length > this.maxErrors) {
      this.errors.shift()
    }

    // 发送到分析服务器
    this.sendToAnalytics(error)
    
    // 存储到localStorage
    this.saveToLocalStorage()
    
    console.warn(`404 Error: ${path} from ${referrer}`)
  }

  // 发送到后端分析
  async sendToAnalytics(error) {
    try {
      await fetch('/api/analytics/404', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(error)
      })
    } catch (err) {
      console.error('Failed to send 404 analytics:', err)
    }
  }

  // 获取404统计
  get404Stats() {
    const last24h = Date.now() - 24 * 60 * 60 * 1000
    
    return {
      total: this.errors.length,
      last24h: this.errors.filter(e => 
        new Date(e.timestamp) > last24h
      ).length,
      commonPaths: this.getMostCommonPaths(),
      commonReferrers: this.getMostCommonReferrers()
    }
  }

  // 获取最常见的404路径
  getMostCommonPaths(limit = 10) {
    const pathCounts = {}
    
    this.errors.forEach(error => {
      pathCounts[error.path] = (pathCounts[error.path] || 0) + 1
    })
    
    return Object.entries(pathCounts)
      .sort(([,a], [,b]) => b - a)
      .slice(0, limit)
      .map(([path, count]) => ({ path, count }))
  }

  // 保存到本地存储
  saveToLocalStorage() {
    try {
      localStorage.setItem('404_errors', JSON.stringify(this.errors))
    } catch (err) {
      console.error('Failed to save 404 errors:', err)
    }
  }

  // 从本地存储加载
  loadFromLocalStorage() {
    try {
      const saved = localStorage.getItem('404_errors')
      if (saved) {
        this.errors = JSON.parse(saved)
      }
    } catch (err) {
      console.error('Failed to load 404 errors:', err)
    }
  }
}

// 在Vue中使用
export default {
  install(app) {
    const tracker = new ErrorTracker()
    tracker.loadFromLocalStorage()
    
    app.config.globalProperties.$errorTracker = tracker
    
    // 路由错误处理
    app.config.errorHandler = (err, instance, info) => {
      console.error('Vue error:', err, info)
      tracker.trackError(err, info)
    }
  }
}

四、实用组件库:可复用的404组件

4.1 基础404组件

<!-- components/errors/Base404.vue -->
<template>
  <div class="base-404" :class="variant">
    <div class="illustration">
      <slot name="illustration">
        <Default404Illustration />
      </slot>
    </div>
    
    <div class="content">
      <h1 class="title">
        <slot name="title">
          {{ title }}
        </slot>
      </h1>
      
      <p class="description">
        <slot name="description">
          {{ description }}
        </slot>
      </p>
      
      <div class="actions">
        <slot name="actions">
          <BaseButton 
            variant="primary" 
            @click="goHome"
          >
            返回首页
          </BaseButton>
          <BaseButton 
            variant="outline" 
            @click="goBack"
          >
            返回上一页
          </BaseButton>
        </slot>
      </div>
      
      <div v-if="showSearch" class="search-container">
        <SearchBar @search="handleSearch" />
      </div>
    </div>
  </div>
</template>

<script>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import BaseButton from '../ui/BaseButton.vue'
import SearchBar from '../ui/SearchBar.vue'
import Default404Illustration from './illustrations/Default404Illustration.vue'

export default {
  name: 'Base404',
  components: {
    BaseButton,
    SearchBar,
    Default404Illustration
  },
  props: {
    variant: {
      type: String,
      default: 'default',
      validator: (value) => ['default', 'compact', 'full'].includes(value)
    },
    title: {
      type: String,
      default: '页面不存在'
    },
    description: {
      type: String,
      default: '抱歉,您访问的页面可能已被删除或暂时不可用。'
    },
    showSearch: {
      type: Boolean,
      default: true
    }
  },
  setup(props, { emit }) {
    const router = useRouter()
    
    const containerClass = computed(() => ({
      'base-404--compact': props.variant === 'compact',
      'base-404--full': props.variant === 'full'
    }))
    
    const goHome = () => {
      emit('go-home')
      router.push('/')
    }
    
    const goBack = () => {
      emit('go-back')
      if (window.history.length > 1) {
        router.go(-1)
      } else {
        goHome()
      }
    }
    
    const handleSearch = (query) => {
      emit('search', query)
      router.push(`/search?q=${encodeURIComponent(query)}`)
    }
    
    return {
      containerClass,
      goHome,
      goBack,
      handleSearch
    }
  }
}
</script>

<style scoped>
.base-404 {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 60vh;
  padding: 2rem;
  text-align: center;
}

.base-404--compact {
  min-height: 40vh;
  padding: 1rem;
}

.base-404--full {
  min-height: 80vh;
  padding: 3rem;
}

.illustration {
  margin-bottom: 2rem;
  max-width: 300px;
}

.base-404--compact .illustration {
  max-width: 150px;
  margin-bottom: 1rem;
}

.base-404--full .illustration {
  max-width: 400px;
  margin-bottom: 3rem;
}

.content {
  max-width: 500px;
}

.title {
  font-size: 2rem;
  margin-bottom: 1rem;
  color: #333;
}

.base-404--compact .title {
  font-size: 1.5rem;
}

.base-404--full .title {
  font-size: 2.5rem;
}

.description {
  font-size: 1.1rem;
  color: #666;
  margin-bottom: 2rem;
  line-height: 1.6;
}

.actions {
  display: flex;
  gap: 1rem;
  justify-content: center;
  margin-bottom: 2rem;
}

.search-container {
  max-width: 400px;
  margin: 0 auto;
}
</style>

4.2 智能404组件(带内容推荐)

<!-- components/errors/Smart404.vue -->
<template>
  <Base404 :variant="variant" :title="title" :description="description">
    <template #illustration>
      <Animated404Illustration />
    </template>
    
    <template v-if="suggestions.length > 0" #description>
      <div class="smart-description">
        <p>{{ description }}</p>
        
        <div class="suggestions">
          <h3 class="suggestions-title">您是不是想找:</h3>
          <ul class="suggestions-list">
            <li 
              v-for="suggestion in suggestions" 
              :key="suggestion.id"
              @click="navigateTo(suggestion.path)"
              class="suggestion-item"
            >
              {{ suggestion.title }}
              <span v-if="suggestion.category" class="suggestion-category">
                {{ suggestion.category }}
              </span>
            </li>
          </ul>
        </div>
      </div>
    </template>
    
    <template #actions>
      <div class="smart-actions">
        <BaseButton variant="primary" @click="goHome">
          返回首页
        </BaseButton>
        <BaseButton variant="outline" @click="goBack">
          返回上一页
        </BaseButton>
        <BaseButton 
          v-if="canReport" 
          variant="ghost" 
          @click="reportError"
        >
          报告问题
        </BaseButton>
      </div>
    </template>
  </Base404>
</template>

<script>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import Base404 from './Base404.vue'
import Animated404Illustration from './illustrations/Animated404Illustration.vue'

export default {
  name: 'Smart404',
  components: {
    Base404,
    Animated404Illustration
  },
  props: {
    variant: {
      type: String,
      default: 'default'
    }
  },
  setup(props, { emit }) {
    const route = useRoute()
    const suggestions = ref([])
    const isLoading = ref(false)
    
    const originalPath = computed(() => 
      route.query.originalPath || route.params.pathMatch?.[0] || ''
    )
    
    const title = computed(() => {
      if (originalPath.value.includes('/products/')) {
        return '商品未找到'
      } else if (originalPath.value.includes('/users/')) {
        return '用户不存在'
      }
      return '页面不存在'
    })
    
    const description = computed(() => {
      if (originalPath.value.includes('/products/')) {
        return '您查找的商品可能已下架或不存在。'
      }
      return '抱歉,您访问的页面可能已被删除或暂时不可用。'
    })
    
    const canReport = computed(() => {
      // 允许用户报告内部链接错误
      return originalPath.value.startsWith('/') && 
             !originalPath.value.includes('//')
    })
    
    onMounted(async () => {
      isLoading.value = true
      
      try {
        // 根据访问路径获取智能建议
        suggestions.value = await fetchSuggestions(originalPath.value)
      } catch (error) {
        console.error('Failed to fetch suggestions:', error)
      } finally {
        isLoading.value = false
      }
      
      // 发送分析事件
      emit('page-not-found', {
        path: originalPath.value,
        referrer: document.referrer,
        suggestionsCount: suggestions.value.length
      })
    })
    
    const fetchSuggestions = async (path) => {
      // 模拟API调用
      return new Promise(resolve => {
        setTimeout(() => {
          const mockSuggestions = [
            { id: 1, title: '热门商品推荐', path: '/products', category: '商品' },
            { id: 2, title: '用户帮助中心', path: '/help', category: '帮助' },
            { id: 3, title: '最新活动', path: '/promotions', category: '活动' }
          ]
          resolve(mockSuggestions)
        }, 500)
      })
    }
    
    const navigateTo = (path) => {
      emit('suggestion-click', path)
      window.location.href = path
    }
    
    const reportError = () => {
      emit('report-error', {
        path: originalPath.value,
        timestamp: new Date().toISOString()
      })
      
      // 显示反馈表单
      showFeedbackForm()
    }
    
    const goHome = () => emit('go-home')
    const goBack = () => emit('go-back')
    
    return {
      suggestions,
      isLoading,
      originalPath,
      title,
      description,
      canReport,
      navigateTo,
      reportError,
      goHome,
      goBack
    }
  }
}
</script>

五、最佳实践总结

5.1 配置检查清单

// router/config-validation.js
export function validateRouterConfig(router) {
  const warnings = []
  const errors = []
  
  const routes = router.getRoutes()
  
  // 检查是否有404路由
  const has404Route = routes.some(route => 
    route.path === '/:pathMatch(.*)*' || route.path === '*'
  )
  
  if (!has404Route) {
    errors.push('缺少404路由配置')
  }
  
  // 检查404路由是否在最后
  const lastRoute = routes[routes.length - 1]
  if (!lastRoute.path.includes('(.*)') && lastRoute.path !== '*') {
    warnings.push('404路由应该放在路由配置的最后')
  }
  
  // 检查是否有重复的路由路径
  const pathCounts = {}
  routes.forEach(route => {
    if (route.path) {
      pathCounts[route.path] = (pathCounts[route.path] || 0) + 1
    }
  })
  
  Object.entries(pathCounts).forEach(([path, count]) => {
    if (count > 1 && !path.includes(':')) {
      warnings.push(`发现重复的路由路径: ${path}`)
    }
  })
  
  return { warnings, errors }
}

5.2 性能优化建议

// 404页面懒加载优化
const routes = [
  // 其他路由...
  {
    path: '/404',
    component: () => import(
      /* webpackChunkName: "error-pages" */
      /* webpackPrefetch: true */
      '@/views/errors/NotFound.vue'
    )
  },
  {
    path: '/:pathMatch(.*)*',
    component: () => import(
      /* webpackChunkName: "error-pages" */
      '@/views/errors/CatchAllNotFound.vue'
    )
  }
]

// 或者使用动态导入函数
function lazyLoadErrorPage(type = '404') {
  return () => import(`@/views/errors/${type}.vue`)
}

5.3 国际化和多语言支持

<!-- 多语言404页面 -->
<template>
  <div class="not-found">
    <h1>{{ $t('errors.404.title') }}</h1>
    <p>{{ $t('errors.404.description') }}</p>
    
    <!-- 根据语言显示不同的帮助内容 -->
    <div class="localized-help">
      <h3>{{ $t('errors.404.help.title') }}</h3>
      <ul>
        <li v-for="tip in localizedTips" :key="tip">
          {{ tip }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

export default {
  name: 'LocalizedNotFound',
  setup() {
    const { locale, t } = useI18n()
    
    const localizedTips = computed(() => {
      const tips = {
        'en': ['Check the URL', 'Use search', 'Visit homepage'],
        'zh': ['检查网址', '使用搜索', '访问首页'],
        'ja': ['URLを確認', '検索を使う', 'ホームページへ']
      }
      return tips[locale.value] || tips.en
    })
    
    return {
      localizedTips
    }
  }
}
</script>

六、常见问题与解决方案

Q1: 为什么我的404页面返回200状态码?

原因:客户端渲染的应用默认返回200,需要服务器端配合。

解决方案

// Nuxt.js 解决方案
// nuxt.config.js
export default {
  render: {
    // 为404页面设置正确的状态码
    ssr: true
  },
  router: {
    // 自定义错误页面
    extendRoutes(routes, resolve) {
      routes.push({
        name: '404',
        path: '*',
        component: resolve(__dirname, 'pages/404.vue')
      })
    }
  }
}

// 在页面组件中
export default {
  asyncData({ res }) {
    if (res) {
      res.statusCode = 404
    }
    return {}
  },
  head() {
    return {
      title: '404 - Page Not Found'
    }
  }
}

Q2: 如何测试404页面?

// tests/router/404.spec.js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createTestingPinia } from '@pinia/testing'
import NotFound from '@/views/NotFound.vue'

describe('404 Page', () => {
  it('should display 404 page for unknown routes', async () => {
    const router = createRouter({
      history: createWebHistory(),
      routes: [
        { path: '/', component: { template: '<div>Home</div>' } },
        { path: '/:pathMatch(.*)*', component: NotFound }
      ]
    })
    
    const wrapper = mount(NotFound, {
      global: {
        plugins: [router, createTestingPinia()]
      }
    })
    
    // 导航到不存在的路由
    await router.push('/non-existent-page')
    
    expect(wrapper.find('.error-code').text()).toBe('404')
    expect(wrapper.find('.error-title').text()).toBe('页面不存在')
  })
  
  it('should have back button functionality', async () => {
    const router = createRouter({
      history: createWebHistory(),
      routes: [
        { path: '/', component: { template: '<div>Home</div>' } },
        { path: '/about', component: { template: '<div>About</div>' } },
        { path: '/:pathMatch(.*)*', component: NotFound }
      ]
    })
    
    // 模拟浏览器历史
    Object.defineProperty(window, 'history', {
      value: {
        length: 2
      }
    })
    
    const wrapper = mount(NotFound, {
      global: {
        plugins: [router]
      }
    })
    
    // 测试返回按钮
    const backButton = wrapper.find('.btn-secondary')
    await backButton.trigger('click')
    
    // 应该返回到上一页
    expect(router.currentRoute.value.path).toBe('/')
  })
})

总结:Vue Router 404配置的最佳实践

  1. 正确配置路由:使用 /:pathMatch(.*)* 作为最后的catch-all路由
  2. 服务器状态码:确保404页面返回正确的HTTP 404状态码
  3. 用户体验:提供有用的导航选项和内容建议
  4. SEO优化:设置正确的meta标签,避免搜索引擎索引404页面
  5. 监控分析:跟踪404错误,了解用户访问路径
  6. 多语言支持:为国际化应用提供本地化的404页面
  7. 性能考虑:使用懒加载,避免影响主包大小
  8. 测试覆盖:确保404功能在各种场景下正常工作

记住:一个好的404页面不仅是错误处理,更是用户体验的重要组成部分。精心设计的404页面可以转化流失的用户,提供更好的品牌体验。

Vue 中的 MVVM、MVC 和 MVP:现代前端架构模式深度解析

Vue 中的 MVVM、MVC 和 MVP:现代前端架构模式深度解析

前言:架构模式的演变之旅

在 Vue 开发中,我们经常听到 MVVM、MVC 这些术语,但它们到底意味着什么?为什么 Vue 选择了 MVVM?这些模式如何影响我们的代码结构?今天,让我们抛开教科书式的定义,从实际 Vue 开发的角度,深入探讨这些架构模式的本质区别。

一、MVC:经典的王者(但已不再适合前端)

1.1 MVC 的核心三要素

// 模拟一个传统的 MVC 结构(不是 Vue,但可以帮助理解)
class UserModel {
  constructor() {
    this.users = []
    this.currentUser = null
  }
  
  addUser(user) {
    this.users.push(user)
  }
  
  setCurrentUser(user) {
    this.currentUser = user
  }
}

class UserView {
  constructor(controller) {
    this.controller = controller
    this.userList = document.getElementById('user-list')
    this.userForm = document.getElementById('user-form')
    
    // 手动绑定事件
    this.userForm.addEventListener('submit', (e) => {
      e.preventDefault()
      const name = document.getElementById('name').value
      const email = document.getElementById('email').value
      this.controller.addUser({ name, email })
    })
  }
  
  renderUsers(users) {
    this.userList.innerHTML = users.map(user => 
      `<li>${user.name} (${user.email})</li>`
    ).join('')
  }
}

class UserController {
  constructor(model) {
    this.model = model
    this.view = new UserView(this)
  }
  
  addUser(userData) {
    this.model.addUser(userData)
    this.view.renderUsers(this.model.users)
  }
}

// 使用
const app = new UserController(new UserModel())

1.2 MVC 在 Vue 中的"遗迹"

虽然 Vue 不是 MVC,但我们能看到 MVC 的影子:

<!-- 这种写法有 MVC 的影子 -->
<template>
  <!-- View:负责展示 -->
  <div>
    <h1>{{ title }}</h1>
    <ul>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
    <button @click="loadUsers">加载用户</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      // Model:数据状态
      title: '用户列表',
      users: []
    }
  },
  methods: {
    // Controller:业务逻辑
    async loadUsers() {
      try {
        const response = await fetch('/api/users')
        this.users = await response.json()
      } catch (error) {
        console.error('加载失败', error)
      }
    }
  }
}
</script>

MVC 的关键问题在前端

  • 视图和控制器紧密耦合:DOM 操作和业务逻辑混杂
  • 双向依赖:视图依赖控制器,控制器也依赖视图
  • 状态管理困难:随着应用复杂,状态散落在各处

二、MVP:试图改进的中间者

2.1 MVP 的核心改进

// 一个 MVP 模式的示例
class UserModel {
  constructor() {
    this.users = []
  }
  
  fetchUsers() {
    return fetch('/api/users').then(r => r.json())
  }
}

class UserView {
  constructor() {
    this.userList = document.getElementById('user-list')
    this.loadButton = document.getElementById('load-btn')
  }
  
  bindLoadUsers(handler) {
    this.loadButton.addEventListener('click', handler)
  }
  
  displayUsers(users) {
    this.userList.innerHTML = users.map(user => 
      `<li>${user.name}</li>`
    ).join('')
  }
  
  showLoading() {
    this.userList.innerHTML = '<li>加载中...</li>'
  }
}

class UserPresenter {
  constructor(view, model) {
    this.view = view
    this.model = model
    
    // Presenter 初始化时绑定事件
    this.view.bindLoadUsers(() => this.onLoadUsers())
  }
  
  async onLoadUsers() {
    this.view.showLoading()
    try {
      const users = await this.model.fetchUsers()
      this.view.displayUsers(users)
    } catch (error) {
      console.error('加载失败', error)
    }
  }
}

// 使用
const view = new UserView()
const model = new UserModel()
new UserPresenter(view, model)

2.2 MVP 的特点

  1. Presenter 作为中间人:协调 View 和 Model
  2. View 被动:只负责显示,不包含业务逻辑
  3. 解耦更好:View 和 Model 不知道彼此存在
  4. 但仍有问题:Presenter 可能变得臃肿,测试仍复杂

三、MVVM:Vue 的选择与实现

3.1 MVVM 的核心:数据绑定

<!-- 这是典型的 MVVM,Vue 自动处理了绑定 -->
<template>
  <!-- View:声明式模板 -->
  <div class="user-management">
    <input 
      v-model="newUser.name" 
      placeholder="用户名"
      @keyup.enter="addUser"
    >
    <button @click="addUser">添加用户</button>
    
    <ul>
      <li v-for="user in filteredUsers" :key="user.id">
        {{ user.name }}
        <button @click="removeUser(user.id)">删除</button>
      </li>
    </ul>
    
    <input v-model="searchQuery" placeholder="搜索用户...">
  </div>
</template>

<script>
export default {
  data() {
    return {
      // Model/ViewModel:响应式数据
      users: [
        { id: 1, name: '张三' },
        { id: 2, name: '李四' },
        { id: 3, name: '王五' }
      ],
      newUser: { name: '' },
      searchQuery: ''
    }
  },
  
  computed: {
    // ViewModel:派生状态
    filteredUsers() {
      return this.users.filter(user =>
        user.name.toLowerCase().includes(this.searchQuery.toLowerCase())
      )
    }
  },
  
  methods: {
    // ViewModel:操作方法
    addUser() {
      if (this.newUser.name.trim()) {
        this.users.push({
          id: Date.now(),
          name: this.newUser.name.trim()
        })
        this.newUser.name = ''
      }
    },
    
    removeUser(id) {
      this.users = this.users.filter(user => user.id !== id)
    }
  }
}
</script>

3.2 Vue 如何实现 MVVM

让我们看看 Vue 的底层实现:

// 简化的 Vue 响应式系统
class Vue {
  constructor(options) {
    this.$options = options
    this._data = options.data()
    
    // 1. 数据劫持(核心)
    this.observe(this._data)
    
    // 2. 编译模板
    this.compile(options.template)
  }
  
  observe(data) {
    Object.keys(data).forEach(key => {
      let value = data[key]
      const dep = new Dep() // 依赖收集
      
      Object.defineProperty(data, key, {
        get() {
          // 收集依赖
          if (Dep.target) {
            dep.addSub(Dep.target)
          }
          return value
        },
        set(newVal) {
          if (newVal !== value) {
            value = newVal
            // 通知更新
            dep.notify()
          }
        }
      })
    })
  }
  
  compile(template) {
    // 将模板转换为渲染函数
    // 建立 View 和 ViewModel 的绑定
  }
}

class Dep {
  constructor() {
    this.subs = []
  }
  
  addSub(sub) {
    this.subs.push(sub)
  }
  
  notify() {
    this.subs.forEach(sub => sub.update())
  }
}

// Watcher 观察数据变化
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm
    this.key = key
    this.cb = cb
    
    Dep.target = this
    this.value = vm._data[key] // 触发 getter,收集依赖
    Dep.target = null
  }
  
  update() {
    const newValue = this.vm._data[this.key]
    if (newValue !== this.value) {
      this.value = newValue
      this.cb(newValue)
    }
  }
}

四、三种模式的深度对比

4.1 通信流对比

graph TD
    subgraph "MVC"
        A[View] -->|用户输入| B[Controller]
        B -->|更新| C[Model]
        C -->|通知| B
        B -->|渲染| A
    end
    
    subgraph "MVP"
        D[View] -->|委托| E[Presenter]
        E -->|更新| F[Model]
        F -->|返回数据| E
        E -->|更新视图| D
    end
    
    subgraph "MVVM"
        G[View] <-->|双向绑定| H[ViewModel]
        H -->|操作| I[Model]
        I -->|响应数据| H
    end

4.2 代码结构对比

<!-- 同一个功能,三种模式的不同实现 -->

<!-- MVC 风格(不推荐) -->
<template>
  <div>
    <input id="username" type="text">
    <button id="save-btn">保存</button>
    <div id="output"></div>
  </div>
</template>

<script>
export default {
  mounted() {
    // Controller 逻辑散落在各处
    document.getElementById('save-btn').addEventListener('click', () => {
      const username = document.getElementById('username').value
      this.saveUser(username)
    })
  },
  methods: {
    saveUser(username) {
      // Model 操作
      this.$store.commit('SET_USERNAME', username)
      // View 更新
      document.getElementById('output').textContent = `用户: ${username}`
    }
  }
}
</script>

<!-- MVP 风格 -->
<template>
  <div>
    <input v-model="username" type="text">
    <button @click="presenter.save()">保存</button>
    <div>{{ displayText }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      displayText: ''
    }
  },
  created() {
    // Presenter 处理所有逻辑
    this.presenter = {
      save: () => {
        this.$store.commit('SET_USERNAME', this.username)
        this.displayText = `用户: ${this.username}`
      }
    }
  }
}
</script>

<!-- MVVM 风格(Vue 原生) -->
<template>
  <div>
    <!-- 双向绑定自动处理 -->
    <input v-model="username" type="text">
    <button @click="saveUser">保存</button>
    <!-- 自动响应式更新 -->
    <div>用户: {{ username }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      username: ''
    }
  },
  methods: {
    saveUser() {
      // 数据改变,视图自动更新
      this.$store.commit('SET_USERNAME', this.username)
    }
  }
}
</script>

4.3 实际项目中的体现

// 一个真实的 Vuex + Vue 项目结构

// Model 层:Vuex Store
// store/modules/user.js
export default {
  state: {
    users: [],
    currentUser: null
  },
  mutations: {
    SET_USERS(state, users) {
      state.users = users
    },
    ADD_USER(state, user) {
      state.users.push(user)
    }
  },
  actions: {
    async fetchUsers({ commit }) {
      const users = await api.getUsers()
      commit('SET_USERS', users)
    }
  },
  getters: {
    activeUsers: state => state.users.filter(u => u.isActive)
  }
}

// ViewModel 层:Vue 组件
// UserList.vue
<template>
  <!-- View:声明式模板 -->
  <div>
    <UserFilter @filter-change="setFilter" />
    <UserTable :users="filteredUsers" />
    <UserPagination 
      :current-page="currentPage"
      @page-change="changePage"
    />
  </div>
</template>

<script>
import { mapState, mapGetters, mapActions } from 'vuex'

export default {
  data() {
    return {
      // 组件本地状态
      currentPage: 1,
      filter: ''
    }
  },
  computed: {
    // 连接 Model (Vuex) 和 View
    ...mapState('user', ['users']),
    ...mapGetters('user', ['activeUsers']),
    
    // ViewModel:计算属性
    filteredUsers() {
      return this.activeUsers.filter(user => 
        user.name.includes(this.filter)
      )
    }
  },
  methods: {
    ...mapActions('user', ['fetchUsers']),
    
    // ViewModel:方法
    setFilter(filter) {
      this.filter = filter
      this.currentPage = 1 // 重置分页
    },
    
    changePage(page) {
      this.currentPage = page
      this.fetchUsers({ page, filter: this.filter })
    }
  },
  created() {
    this.fetchUsers()
  }
}
</script>

五、Vue 3 组合式 API:MVVM 的进化

5.1 传统 Options API 的问题

<!-- Options API:逻辑分散 -->
<script>
export default {
  data() {
    return {
      users: [],
      filter: '',
      page: 1
    }
  },
  computed: {
    filteredUsers() { /* ... */ }
  },
  watch: {
    filter() { /* 过滤逻辑 */ },
    page() { /* 分页逻辑 */ }
  },
  methods: {
    fetchUsers() { /* ... */ },
    handleFilter() { /* ... */ }
  },
  mounted() {
    this.fetchUsers()
  }
}
</script>

5.2 组合式 API:更好的逻辑组织

<!-- Composition API:逻辑聚合 -->
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'

// 用户搜索功能
const { 
  users, 
  searchUsers, 
  isLoading: usersLoading 
} = useUserSearch()

// 分页功能  
const {
  currentPage,
  pageSize,
  paginatedData,
  changePage
} = usePagination(users)

// 筛选功能
const {
  filter,
  filteredData,
  setFilter
} = useFilter(paginatedData)

// 生命周期
onMounted(() => {
  searchUsers()
})

// 响应式监听
watch(filter, () => {
  currentPage.value = 1
})
</script>

<template>
  <!-- View 保持不变 -->
  <div>
    <input v-model="filter" placeholder="搜索...">
    <UserTable :data="filteredData" />
    <Pagination 
      :current-page="currentPage"
      @change="changePage"
    />
  </div>
</template>

5.3 自定义组合函数

// composables/useUserManagement.js
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores/user'

export function useUserManagement() {
  const userStore = useUserStore()
  const localUsers = ref([])
  const filter = ref('')
  const currentPage = ref(1)
  const pageSize = 10

  // 计算属性:ViewModel
  const filteredUsers = computed(() => {
    return localUsers.value.filter(user =>
      user.name.toLowerCase().includes(filter.value.toLowerCase())
    )
  })

  const paginatedUsers = computed(() => {
    const start = (currentPage.value - 1) * pageSize
    return filteredUsers.value.slice(start, start + pageSize)
  })

  // 方法:ViewModel
  const addUser = (user) => {
    localUsers.value.push(user)
    userStore.addUser(user)
  }

  const removeUser = (id) => {
    localUsers.value = localUsers.value.filter(u => u.id !== id)
  }

  return {
    // 暴露给 View
    users: paginatedUsers,
    filter,
    currentPage,
    addUser,
    removeUser,
    setFilter: (value) => { filter.value = value }
  }
}

六、现代 Vue 生态中的架构模式

6.1 Pinia:更现代的"Model"层

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

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    currentUser: null
  }),
  
  actions: {
    async fetchUsers() {
      const { data } = await api.get('/users')
      this.users = data
    },
    
    addUser(user) {
      this.users.push(user)
    }
  },
  
  getters: {
    activeUsers: (state) => state.users.filter(u => u.isActive),
    userCount: (state) => state.users.length
  }
})

// 组件中使用
<script setup>
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const { users, activeUsers } = storeToRefs(userStore)

// MVVM 清晰分层:
// Model: userStore
// ViewModel: 组件中的 computed/methods
// View: template
</script>

6.2 基于特性的架构

src/
├── features/
│   ├── user/
│   │   ├── components/     # View
│   │   ├── composables/    # ViewModel
│   │   ├── stores/         # Model
│   │   └── types/          # 类型定义
│   └── product/
│       ├── components/
│       ├── composables/
│       └── stores/
├── shared/
│   ├── components/
│   ├── utils/
│   └── api/
└── App.vue

6.3 服务器状态管理(TanStack Query)

<script setup>
import { useQuery, useMutation } from '@tanstack/vue-query'

// Model:服务器状态
const { data: users, isLoading } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers
})

// ViewModel:本地状态和逻辑
const filter = ref('')
const filteredUsers = computed(() => {
  return users.value?.filter(u => 
    u.name.includes(filter.value)
  ) || []
})

// Mutation:修改服务器状态
const { mutate: addUser } = useMutation({
  mutationFn: createUser,
  onSuccess: () => {
    // 自动重新获取 users
  }
})
</script>

<template>
  <!-- View -->
  <div>
    <input v-model="filter" placeholder="搜索用户">
    <UserList :users="filteredUsers" />
  </div>
</template>

七、如何选择合适的模式

7.1 决策矩阵

场景 推荐模式 理由 Vue 实现
小型项目 MVVM(Vue 原生) 简单直接,上手快 Options API
中型项目 MVVM + 状态管理 需要共享状态 Vuex/Pinia
大型项目 组合式 MVVM 逻辑复用,类型安全 Composition API + TypeScript
复杂业务逻辑 领域驱动设计 业务逻辑复杂 特性文件夹 + Clean Architecture
实时应用 MVVM + 响应式增强 需要复杂响应式 Vue + RxJS/Signals

7.2 架构演进示例

// 阶段1:简单 MVVM(适合 todo 应用)
export default {
  data() {
    return { todos: [], newTodo: '' }
  },
  methods: {
    addTodo() {
      this.todos.push({ text: this.newTodo, done: false })
      this.newTodo = ''
    }
  }
}

// 阶段2:加入状态管理(适合电商网站)
// store/todos.js + TodoList.vue + TodoItem.vue

// 阶段3:组合式架构(适合 SaaS 平台)
// features/todo/
//   ├── useTodoList.js
//   ├── useTodoFilter.js
//   ├── TodoStore.js
//   └── components/

// 阶段4:微前端架构(适合大型企业应用)
// app-todo/ + app-user/ + app-order/ + 主应用

7.3 代码质量检查清单

// 好的 MVVM 代码应该:
// 1. View(模板)保持简洁,只负责展示
<template>
  <!-- ✅ 好:声明式 -->
  <button @click="handleSubmit">提交</button>
  
  <!-- ❌ 不好:包含逻辑 -->
  <button @click="validate() && submit()">提交</button>
</template>

// 2. ViewModel(脚本)处理所有逻辑
<script>
export default {
  methods: {
    // ✅ 好:逻辑在 ViewModel
    handleSubmit() {
      if (this.validate()) {
        this.submit()
      }
    },
    
    // ❌ 不好:直接操作 DOM
    badMethod() {
      document.getElementById('btn').disabled = true
    }
  }
}
</script>

// 3. Model(数据)清晰分层
// ✅ 好:状态管理集中
state: {
  users: [], // 原始数据
  ui: {      // UI 状态
    loading: false,
    error: null
  }
}

// ❌ 不好:状态混杂
data() {
  return {
    apiData: [],     // API 数据
    isLoading: false, // UI 状态
    localData: {}     // 本地状态
  }
}

八、总结:Vue 架构模式的核心要义

8.1 三种模式的本质区别

模式 核心思想 Vue 中的体现 适用场景
MVC 关注点分离,但耦合度高 早期 jQuery 时代 传统后端渲染
MVP Presenter 中介,View 被动 某些 Vue 2 项目 需要严格测试
MVVM 数据绑定,自动同步 Vue 核心设计 现代前端应用

8.2 Vue 为什么选择 MVVM?

  1. 开发效率:数据绑定减少样板代码
  2. 维护性:响应式系统自动处理更新
  3. 可测试性:ViewModel 可以独立测试
  4. 渐进式:可以从简单开始,逐步复杂化

8.3 现代 Vue 开发的最佳实践

  1. 拥抱 MVVM:理解并善用响应式系统
  2. 合理分层
    • View:只负责展示,尽量简单
    • ViewModel:处理业务逻辑和状态
    • Model:管理数据和业务规则
  3. 组合优于继承:使用组合式 API 组织代码
  4. 状态管理:在需要时引入 Pinia/Vuex
  5. 关注点分离:按特性组织代码,而非技术

8.4 记住的关键点

  • Vue 不是严格的 MVVM,但受其启发
  • 架构模式是工具,不是教条,根据项目选择
  • 代码组织比模式名称更重要
  • 渐进式是 Vue 的核心优势,可以从简单开始

最后,无论你使用哪种模式,记住 Vue 的核心原则:让开发者专注于业务逻辑,而不是框架细节。这才是 Vue 成功的真正原因。


思考题:在你的 Vue 项目中,你是如何组织代码的?有没有遇到过架构选择上的困惑?或者有什么独特的架构实践想要分享?欢迎在评论区交流讨论!

Vue 的 <template> 标签:不仅仅是包裹容器

Vue 的 <template> 标签:不仅仅是包裹容器

前言:被低估的 <template> 标签

很多 Vue 开发者只把 <template> 当作一个"必需的包裹标签",但实际上它功能强大、用途广泛,是 Vue 模板系统的核心元素之一。今天我们就来深入探索 <template> 标签的各种妙用,从基础到高级,让你彻底掌握这个 Vue 开发中的"瑞士军刀"。

一、基础篇:为什么需要 <template>

1.1 Vue 的单根元素限制

<!-- ❌ 错误:多个根元素 -->
<div>标题</div>
<div>内容</div>

<!-- ✅ 正确:使用根元素包裹 -->
<div>
  <div>标题</div>
  <div>内容</div>
</div>

<!-- ✅ 更好:使用 <template> 作为根(Vue 3)-->
<template>
  <div>标题</div>
  <div>内容</div>
</template>

Vue 2 vs Vue 3

  • Vue 2:模板必须有单个根元素
  • Vue 3:可以使用 <template> 作为片段根,支持多根节点

1.2 <template> 的特殊性

<!-- 普通元素会在 DOM 中渲染 -->
<div class="wrapper">
  <span>内容</span>
</div>
<!-- 渲染结果:<div class="wrapper"><span>内容</span></div> -->

<!-- <template> 不会在 DOM 中渲染 -->
<template>
  <span>内容</span>
</template>
<!-- 渲染结果:<span>内容</span> -->

关键特性<template>虚拟元素,不会被渲染到真实 DOM 中,只起到逻辑包裹的作用。

二、实战篇:<template> 的五大核心用途

2.1 条件渲染(v-ifv-else-ifv-else

<template>
  <div class="user-profile">
    <!-- 多个元素的条件渲染 -->
    <template v-if="user.isLoading">
      <LoadingSpinner />
      <p>加载中...</p>
    </template>
    
    <template v-else-if="user.error">
      <ErrorIcon />
      <p>{{ user.error }}</p>
      <button @click="retry">重试</button>
    </template>
    
    <template v-else>
      <UserAvatar :src="user.avatar" />
      <UserInfo :user="user" />
      <UserActions :user="user" />
    </template>
    
    <!-- 单个元素通常不需要 template -->
    <!-- 但这样写更清晰 -->
    <template v-if="showWelcome">
      <WelcomeMessage />
    </template>
  </div>
</template>

优势:可以条件渲染一组元素,而不需要额外的包装 DOM 节点。

2.2 列表渲染(v-for

<template>
  <div class="shopping-cart">
    <!-- 渲染复杂列表项 -->
    <template v-for="item in cartItems" :key="item.id">
      <!-- 列表项 -->
      <div class="cart-item">
        <ProductImage :product="item" />
        <ProductInfo :product="item" />
        <QuantitySelector 
          :quantity="item.quantity"
          @update="updateQuantity(item.id, $event)"
        />
      </div>
      
      <!-- 分隔线(除了最后一个) -->
      <hr v-if="item !== cartItems[cartItems.length - 1]" />
      
      <!-- 促销提示 -->
      <div 
        v-if="item.hasPromotion" 
        class="promotion-tip"
      >
        🎉 此商品参与活动
      </div>
    </template>
    
    <!-- 空状态 -->
    <template v-if="cartItems.length === 0">
      <EmptyCartIcon />
      <p>购物车是空的</p>
      <button @click="goShopping">去逛逛</button>
    </template>
  </div>
</template>

注意<template v-for> 需要手动管理 key,且 key 不能放在 <template> 上:

<!-- ❌ 错误 -->
<template v-for="item in items" :key="item.id">
  <div>{{ item.name }}</div>
</template>

<!-- ✅ 正确 -->
<template v-for="item in items">
  <div :key="item.id">{{ item.name }}</div>
</template>

<!-- 或者为每个子元素指定 key -->
<template v-for="item in items">
  <ProductCard :key="item.id" :product="item" />
  <PromotionBanner 
    v-if="item.hasPromotion" 
    :key="`promo-${item.id}`" 
  />
</template>

2.3 插槽(Slots)系统

基础插槽
<!-- BaseCard.vue -->
<template>
  <div class="card">
    <!-- 具名插槽 -->
    <header class="card-header">
      <slot name="header">
        <!-- 默认内容 -->
        <h3>默认标题</h3>
      </slot>
    </header>
    
    <!-- 默认插槽 -->
    <div class="card-body">
      <slot>
        <!-- 默认内容 -->
        <p>请添加内容</p>
      </slot>
    </div>
    
    <!-- 作用域插槽 -->
    <footer class="card-footer">
      <slot name="footer" :data="footerData">
        <!-- 默认使用作用域数据 -->
        <button @click="handleDefault">
          {{ footerData.buttonText }}
        </button>
      </slot>
    </footer>
  </div>
</template>

<script>
export default {
  data() {
    return {
      footerData: {
        buttonText: '默认按钮',
        timestamp: new Date()
      }
    }
  }
}
</script>
使用插槽
<template>
  <BaseCard>
    <!-- 使用 template 指定插槽 -->
    <template #header>
      <div class="custom-header">
        <h2>自定义标题</h2>
        <button @click="close">×</button>
      </div>
    </template>
    
    <!-- 默认插槽内容 -->
    <p>这是卡片的主要内容...</p>
    <img src="image.jpg" alt="示例">
    
    <!-- 作用域插槽 -->
    <template #footer="{ data }">
      <div class="custom-footer">
        <span>更新时间: {{ formatTime(data.timestamp) }}</span>
        <button @click="customAction">
          {{ data.buttonText }}
        </button>
      </div>
    </template>
  </BaseCard>
</template>
高级插槽模式
<!-- DataTable.vue -->
<template>
  <table class="data-table">
    <thead>
      <tr>
        <!-- 动态列头 -->
        <th v-for="column in columns" :key="column.key">
          <slot :name="`header-${column.key}`" :column="column">
            {{ column.title }}
          </slot>
        </th>
      </tr>
    </thead>
    <tbody>
      <template v-for="(row, index) in data" :key="row.id">
        <tr :class="{ 'selected': isSelected(row) }">
          <!-- 动态单元格 -->
          <td v-for="column in columns" :key="column.key">
            <slot 
              :name="`cell-${column.key}`" 
              :row="row" 
              :value="row[column.key]"
              :index="index"
            >
              {{ row[column.key] }}
            </slot>
          </td>
        </tr>
        
        <!-- 可展开的行详情 -->
        <template v-if="isExpanded(row)">
          <tr class="row-details">
            <td :colspan="columns.length">
              <slot 
                name="row-details" 
                :row="row" 
                :index="index"
              >
                默认详情内容
              </slot>
            </td>
          </tr>
        </template>
      </template>
    </tbody>
  </table>
</template>

2.4 动态组件与 <component>

<template>
  <div class="dashboard">
    <!-- 动态组件切换 -->
    <component :is="currentComponent">
      <!-- 向动态组件传递插槽 -->
      <template #header>
        <h2>{{ componentTitle }}</h2>
      </template>
      
      <!-- 默认插槽内容 -->
      <p>这是所有组件共享的内容</p>
    </component>
    
    <!-- 多个动态组件 -->
    <div class="widget-container">
      <template v-for="widget in activeWidgets" :key="widget.id">
        <component 
          :is="widget.component"
          :config="widget.config"
          class="widget"
        >
          <!-- 为每个组件传递不同的插槽 -->
          <template v-if="widget.type === 'chart'" #toolbar>
            <ChartToolbar :chart-id="widget.id" />
          </template>
        </component>
      </template>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentComponent: 'UserProfile',
      activeWidgets: [
        { id: 1, component: 'StatsWidget', type: 'stats' },
        { id: 2, component: 'ChartWidget', type: 'chart' },
        { id: 3, component: 'TaskListWidget', type: 'list' }
      ]
    }
  },
  computed: {
    componentTitle() {
      const titles = {
        UserProfile: '用户资料',
        Settings: '设置',
        Analytics: '分析'
      }
      return titles[this.currentComponent] || '未知'
    }
  }
}
</script>

2.5 过渡与动画(<transition><transition-group>

<template>
  <div class="notification-center">
    <!-- 单个元素过渡 -->
    <transition name="fade" mode="out-in">
      <template v-if="showWelcome">
        <WelcomeMessage />
      </template>
      <template v-else>
        <DailyTip />
      </template>
    </transition>
    
    <!-- 列表过渡 -->
    <transition-group 
      name="list" 
      tag="div"
      class="notification-list"
    >
      <!-- 每组通知使用 template -->
      <template v-for="notification in notifications" :key="notification.id">
        <!-- 通知项 -->
        <div class="notification-item">
          <NotificationContent :notification="notification" />
          <button 
            @click="dismiss(notification.id)"
            class="dismiss-btn"
          >
            ×
          </button>
        </div>
        
        <!-- 分隔线(过渡效果更好) -->
        <hr v-if="shouldShowDivider(notification)" :key="`divider-${notification.id}`" />
      </template>
    </transition-group>
    
    <!-- 复杂的多阶段过渡 -->
    <transition
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
      :css="false"
    >
      <template v-if="showComplexAnimation">
        <div class="complex-element">
          <slot name="animated-content" />
        </div>
      </template>
    </transition>
  </div>
</template>

<script>
export default {
  methods: {
    beforeEnter(el) {
      el.style.opacity = 0
      el.style.transform = 'translateY(30px)'
    },
    enter(el, done) {
      // 使用 GSAP 或 anime.js 等库
      this.$gsap.to(el, {
        opacity: 1,
        y: 0,
        duration: 0.5,
        onComplete: done
      })
    },
    leave(el, done) {
      this.$gsap.to(el, {
        opacity: 0,
        y: -30,
        duration: 0.3,
        onComplete: done
      })
    }
  }
}
</script>

<style>
/* CSS 过渡类 */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to {
  opacity: 0;
}

.list-enter-active, .list-leave-active {
  transition: all 0.5s;
}
.list-enter, .list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
.list-move {
  transition: transform 0.5s;
}
</style>

三、高级篇:<template> 的进阶技巧

3.1 指令组合使用

<template>
  <div class="product-list">
    <!-- v-for 和 v-if 的组合(正确方式) -->
    <template v-for="product in products">
      <!-- 使用 template 包裹条件判断 -->
      <template v-if="shouldShowProduct(product)">
        <ProductCard 
          :key="product.id" 
          :product="product"
          @add-to-cart="addToCart"
        />
        
        <!-- 相关推荐 -->
        <template v-if="showRecommendations">
          <RelatedProducts 
            :product-id="product.id"
            :key="`related-${product.id}`"
          />
        </template>
      </template>
      
      <!-- 占位符(骨架屏) -->
      <template v-else-if="isLoading">
        <ProductSkeleton :key="`skeleton-${product.id}`" />
      </template>
    </template>
    
    <!-- 多重指令组合 -->
    <template v-if="user.isPremium">
      <template v-for="feature in premiumFeatures">
        <PremiumFeature 
          v-show="feature.isEnabled"
          :key="feature.id"
          :feature="feature"
          v-tooltip="feature.description"
        />
      </template>
    </template>
  </div>
</template>

3.2 渲染函数与 JSX 对比

<!-- 模板语法 -->
<template>
  <div class="container">
    <template v-if="hasHeader">
      <header class="header">
        <slot name="header" />
      </header>
    </template>
    
    <main class="main">
      <slot />
    </main>
  </div>
</template>

<!-- 等价的渲染函数 -->
<script>
export default {
  render(h) {
    const children = []
    
    if (this.hasHeader) {
      children.push(
        h('header', { class: 'header' }, [
          this.$slots.header
        ])
      )
    }
    
    children.push(
      h('main', { class: 'main' }, [
        this.$slots.default
      ])
    )
    
    return h('div', { class: 'container' }, children)
  }
}
</script>

<!-- 等价的 JSX -->
<script>
export default {
  render() {
    return (
      <div class="container">
        {this.hasHeader && (
          <header class="header">
            {this.$slots.header}
          </header>
        )}
        <main class="main">
          {this.$slots.default}
        </main>
      </div>
    )
  }
}
</script>

3.3 性能优化:减少不必要的包装

<!-- 优化前:多余的 div 包装 -->
<div class="card">
  <div v-if="showImage">
    <img :src="imageUrl" alt="图片">
  </div>
  <div v-if="showTitle">
    <h3>{{ title }}</h3>
  </div>
  <div v-if="showContent">
    <p>{{ content }}</p>
  </div>
</div>

<!-- 优化后:使用 template 避免额外 DOM -->
<div class="card">
  <template v-if="showImage">
    <img :src="imageUrl" alt="图片">
  </template>
  <template v-if="showTitle">
    <h3>{{ title }}</h3>
  </template>
  <template v-if="showContent">
    <p>{{ content }}</p>
  </template>
</div>

<!-- 渲染结果对比 -->
<!-- 优化前:<div><div><img></div><div><h3></h3></div></div> -->
<!-- 优化后:<div><img><h3></h3></div> -->

3.4 与 CSS 框架的集成

<template>
  <!-- Bootstrap 网格系统 -->
  <div class="container">
    <div class="row">
      <template v-for="col in gridColumns" :key="col.id">
        <!-- 动态列宽 -->
        <div :class="['col', `col-md-${col.span}`]">
          <component :is="col.component" :config="col.config">
            <!-- 传递具名插槽 -->
            <template v-if="col.slots" v-for="(slotContent, slotName) in col.slots">
              <template :slot="slotName">
                {{ slotContent }}
              </template>
            </template>
          </component>
        </div>
      </template>
    </div>
  </div>
  
  <!-- Tailwind CSS 样式 -->
  <div class="space-y-4">
    <template v-for="item in listItems" :key="item.id">
      <div 
        :class="[
          'p-4 rounded-lg',
          item.isActive ? 'bg-blue-100' : 'bg-gray-100'
        ]"
      >
        <h3 class="text-lg font-semibold">{{ item.title }}</h3>
        <p class="text-gray-600">{{ item.description }}</p>
      </div>
    </template>
  </div>
</template>

四、Vue 3 新特性:<template> 的增强

4.1 多根节点支持(Fragments)

<!-- Vue 2:需要包装元素 -->
<template>
  <div> <!-- 多余的 div -->
    <header>标题</header>
    <main>内容</main>
    <footer>页脚</footer>
  </div>
</template>

<!-- Vue 3:可以使用多根节点 -->
<template>
  <header>标题</header>
  <main>内容</main>
  <footer>页脚</footer>
</template>

<!-- 或者使用 template 作为逻辑分组 -->
<template>
  <template v-if="layout === 'simple'">
    <header>简洁标题</header>
    <main>主要内容</main>
  </template>
  
  <template v-else>
    <header>完整标题</header>
    <nav>导航菜单</nav>
    <main>详细内容</main>
    <aside>侧边栏</aside>
    <footer>页脚信息</footer>
  </template>
</template>

4.2 <script setup> 语法糖

<!-- 组合式 API 的简洁写法 -->
<script setup>
import { ref, computed } from 'vue'
import MyComponent from './MyComponent.vue'

const count = ref(0)
const doubleCount = computed(() => count.value * 2)
</script>

<template>
  <!-- 可以直接使用导入的组件 -->
  <MyComponent :count="count" />
  
  <!-- 条件渲染 -->
  <template v-if="count > 0">
    <p>计数大于 0: {{ count }}</p>
  </template>
  
  <!-- 具名插槽简写 -->
  <slot name="header" />
  
  <!-- 作用域插槽 -->
  <slot name="footer" :data="{ count, doubleCount }" />
</template>

4.3 v-memo 指令优化

<template>
  <!-- 复杂的渲染优化 -->
  <div class="data-grid">
    <template v-for="row in largeDataset" :key="row.id">
      <!-- 使用 v-memo 避免不必要的重新渲染 -->
      <div 
        v-memo="[row.id, row.version, selectedRowId === row.id]"
        :class="['row', { 'selected': selectedRowId === row.id }]"
      >
        <template v-for="cell in row.cells" :key="cell.key">
          <!-- 单元格内容 -->
          <div class="cell">
            <slot 
              name="cell" 
              :row="row" 
              :cell="cell"
              :value="cell.value"
            />
          </div>
        </template>
      </div>
    </template>
  </div>
</template>

五、最佳实践与性能考量

5.1 何时使用 <template>

场景 使用 <template> 不使用
条件渲染多个元素
列表渲染复杂项
插槽定义与使用
单个元素条件渲染 可选
简单的列表项 可选
需要样式/事件的容器 ✅(用 div)

5.2 性能优化建议

<!-- 避免深度嵌套 -->
<!-- ❌ 不推荐:多层嵌套 -->
<template v-if="condition1">
  <template v-if="condition2">
    <template v-for="item in list">
      <div>{{ item }}</div>
    </template>
  </template>
</template>

<!-- ✅ 推荐:简化逻辑 -->
<template v-if="condition1 && condition2">
  <div v-for="item in list" :key="item.id">
    {{ item }}
  </div>
</template>

<!-- 缓存复杂计算 -->
<template>
  <!-- 使用计算属性缓存 -->
  <template v-if="shouldShowSection">
    <ExpensiveComponent />
  </template>
  
  <!-- 使用 v-once 静态内容 -->
  <template v-once>
    <StaticContent />
  </template>
</template>

<script>
export default {
  computed: {
    shouldShowSection() {
      // 复杂计算,结果会被缓存
      return this.complexCondition1 && 
             this.complexCondition2 &&
             !this.isLoading
    }
  }
}
</script>

5.3 可维护性建议

<!-- 组件化复杂模板 -->
<template>
  <!-- 主模板保持简洁 -->
  <div class="page">
    <PageHeader />
    
    <template v-if="isLoggedIn">
      <UserDashboard />
    </template>
    <template v-else>
      <GuestWelcome />
    </template>
    
    <PageFooter />
  </div>
</template>

<!-- 复杂的部分提取为独立组件 -->
<template>
  <div class="complex-section">
    <!-- 使用组件替代复杂的模板逻辑 -->
    <DataTable 
      :columns="tableColumns"
      :data="tableData"
    >
      <template #header-name="{ column }">
        <div class="custom-header">
          {{ column.title }}
          <HelpTooltip :content="column.description" />
        </div>
      </template>
      
      <template #cell-status="{ value }">
        <StatusBadge :status="value" />
      </template>
    </DataTable>
  </div>
</template>

六、常见问题与解决方案

问题1:<template> 上的 key 属性

<!-- 错误:key 放在 template 上无效 -->
<template v-for="item in items" :key="item.id">
  <div>{{ item.name }}</div>
</template>

<!-- 正确:key 放在实际元素上 -->
<template v-for="item in items">
  <div :key="item.id">{{ item.name }}</div>
</template>

<!-- 多个元素需要各自的 key -->
<template v-for="item in items">
  <ProductCard :key="`card-${item.id}`" :product="item" />
  <ProductActions 
    v-if="showActions" 
    :key="`actions-${item.id}`" 
    :product="item" 
  />
</template>

问题2:作用域插槽的 v-slot 简写

<!-- 完整写法 -->
<template v-slot:header>
  <div>标题</div>
</template>

<!-- 简写 -->
<template #header>
  <div>标题</div>
</template>

<!-- 动态插槽名 -->
<template #[dynamicSlotName]>
  <div>动态内容</div>
</template>

<!-- 作用域插槽 -->
<template #item="{ data, index }">
  <div>索引 {{ index }}: {{ data }}</div>
</template>

问题3:<template> 与 CSS 作用域

<!-- CSS 作用域对 template 无效 -->
<template>
  <!-- 这里的 class 不受 scoped CSS 影响 -->
  <div class="content">
    <p>内容</p>
  </div>
</template>

<style scoped>
/* 只会作用于实际渲染的元素 */
.content p {
  color: red;
}
</style>

<!-- 如果需要作用域样式,使用实际元素 -->
<div class="wrapper">
  <template v-if="condition">
    <p class="scoped-text">受作用域影响的文本</p>
  </template>
</div>

<style scoped>
.scoped-text {
  /* 现在有作用域了 */
  color: blue;
}
</style>

七、总结:<template> 的核心价值

<template> 的六大用途

  1. 条件渲染多个元素:避免多余的包装 DOM
  2. 列表渲染复杂结构:包含额外元素和逻辑
  3. 插槽系统的基础:定义和使用插槽内容
  4. 动态组件容器:包裹动态组件和插槽
  5. 过渡动画包装:实现复杂的动画效果
  6. 模板逻辑分组:提高代码可读性和维护性

版本特性总结

特性 Vue 2 Vue 3 说明
多根节点 Fragment 支持
<script setup> 语法糖简化
v-memo 性能优化
编译优化 基础 增强 更好的静态提升

最佳实践清单

  1. 合理使用:只在需要时使用,避免过度嵌套
  2. 保持简洁:复杂逻辑考虑提取为组件
  3. 注意性能:避免在大量循环中使用复杂模板
  4. 统一风格:团队保持一致的模板编写规范
  5. 利用新特性:Vue 3 中善用 Fragments 等新功能

记住:<template> 是 Vue 模板系统的骨架,它让模板更加灵活、清晰和高效。掌握好 <template> 的使用,能让你的 Vue 代码质量提升一个档次。


思考题:在你的 Vue 项目中,<template> 标签最让你惊喜的用法是什么?或者有没有遇到过 <template> 相关的坑?欢迎在评论区分享你的经验!

为什么 Vue 中的 data 必须是一个函数?深度解析与实战指南

为什么 Vue 中的 data 必须是一个函数?深度解析与实战指南

前言:一个令人困惑的设计决策

如果你是 Vue 开发者,一定对下面的写法非常熟悉:

export default {
  data() {
    return {
      message: 'Hello Vue!',
      count: 0
    }
  }
}

但你有没有想过:为什么 data 必须是一个函数,而不是一个简单的对象?

今天我们就来彻底揭开这个 Vue 核心设计背后的奥秘,看看这个看似简单的决策如何影响着你的每一个 Vue 应用。

一、问题根源:组件复用时的数据污染

1.1 如果 data 是对象:灾难的开始

让我们先看看如果 data 是一个对象会发生什么:

// 假设 Vue 允许这样写(实际上不允许)
const sharedData = {
  count: 0
}

const ComponentA = {
  data: sharedData,  // 引用同一个对象!
  template: '<button @click="count++">A: {{ count }}</button>'
}

const ComponentB = {
  data: sharedData,  // 还是同一个对象!
  template: '<button @click="count++">B: {{ count }}</button>'
}

// 使用这两个组件
new Vue({
  el: '#app',
  components: { ComponentA, ComponentB },
  template: `
    <div>
      <component-a />
      <component-b />
    </div>
  `
})

实际效果

  • 点击 ComponentA 的按钮:A 显示 1,B 也显示 1
  • 点击 ComponentB 的按钮:A 显示 2,B 也显示 2
  • 两个组件共享同一个数据对象!😱

1.2 现实中的场景演示

<!-- 一个商品列表页面 -->
<div id="app">
  <!-- 使用同一个 ProductCard 组件 -->
  <product-card v-for="product in products" :key="product.id" />
</div>

<script>
// 如果 data 是对象
const productCardData = {
  isFavorite: false,
  quantity: 1,
  selectedColor: null
}

Vue.component('ProductCard', {
  data: productCardData,  // 所有商品卡片共享同一个对象!
  props: ['product'],
  template: `
    <div class="product-card">
      <h3>{{ product.name }}</h3>
      <button @click="isFavorite = !isFavorite">
        {{ isFavorite ? '取消收藏' : '收藏' }}
      </button>
      <input v-model="quantity" type="number" min="1">
    </div>
  `
})

new Vue({
  el: '#app',
  data: {
    products: [
      { id: 1, name: 'iPhone 13' },
      { id: 2, name: 'MacBook Pro' },
      { id: 3, name: 'AirPods Pro' }
    ]
  }
})

结果:当你收藏第一个商品时,所有商品都会显示为已收藏!💥

二、源码揭秘:Vue 如何实现数据隔离

2.1 Vue 2 源码分析

让我们看看 Vue 2 是如何处理 data 选项的:

// 简化版 Vue 2 源码
function initData(vm) {
  let data = vm.$options.data
  
  // 关键代码:判断 data 类型
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)  // 如果是函数,调用它获取新对象
    : data || {}         // 如果是对象,直接使用(会有警告)
  
  // 如果是对象,开发环境会警告
  if (process.env.NODE_ENV !== 'production') {
    if (!isPlainObject(data)) {
      warn(
        'data functions should return an object',
        vm
      )
    }
    
    // 检查 data 是不是对象(组件会报错)
    if (data && data.__ob__) {
      warn(
        'Avoid using observed data object as data root',
        vm
      )
    }
  }
  
  // 代理 data 到 vm 实例
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    const key = keys[i]
    proxy(vm, `_data`, key)
  }
  
  // 响应式处理
  observe(data, true /* asRootData */)
}

// getData 函数:执行 data 函数
function getData(data, vm) {
  try {
    return data.call(vm, vm)  // 关键:每次调用都返回新对象
  } catch (e) {
    handleError(e, vm, `data()`)
    return {}
  }
}

2.2 Vue 3 源码对比

Vue 3 在 Composition API 中采用了不同的方式:

// Vue 3 Composition API
import { reactive } from 'vue'

export default {
  setup() {
    // 每个实例都有自己的响应式对象
    const state = reactive({
      count: 0,
      message: 'Hello'
    })
    
    return { state }
  }
}

// 或者使用 ref
import { ref } from 'vue'

export default {
  setup() {
    // 每个 ref 都是独立的
    const count = ref(0)
    const message = ref('Hello')
    
    return { count, message }
  }
}

Vue 3 的本质:每个组件实例在 setup() 中创建自己的响应式数据,自然避免了共享问题。

三、函数式 data 的多种写法与最佳实践

3.1 基本写法

// 写法1:传统函数
export default {
  data() {
    return {
      count: 0,
      message: 'Hello',
      todos: [],
      user: null
    }
  }
}

// 写法2:箭头函数(注意 this 指向问题)
export default {
  data: (vm) => ({
    count: 0,
    // 可以访问 props
    fullName: vm.firstName + ' ' + vm.lastName
  }),
  props: ['firstName', 'lastName']
}

// 写法3:使用外部函数
const getInitialData = () => ({
  count: 0,
  message: 'Default message'
})

export default {
  data() {
    return {
      ...getInitialData(),
      // 可以添加实例特定的数据
      instanceId: Math.random()
    }
  }
}

3.2 依赖 props 的动态数据

export default {
  props: {
    initialCount: {
      type: Number,
      default: 0
    },
    userType: {
      type: String,
      default: 'guest'
    }
  },
  
  data() {
    return {
      // 基于 props 初始化数据
      count: this.initialCount,
      
      // 根据 props 计算初始状态
      permissions: this.getPermissionsByType(this.userType),
      
      // 组件内部状态
      isLoading: false,
      error: null
    }
  },
  
  methods: {
    getPermissionsByType(type) {
      const permissions = {
        admin: ['read', 'write', 'delete'],
        user: ['read', 'write'],
        guest: ['read']
      }
      return permissions[type] || []
    }
  }
}

3.3 工厂函数模式

// 创建可复用的数据工厂
function createFormData(initialValues = {}) {
  return {
    values: { ...initialValues },
    errors: {},
    touched: {},
    isSubmitting: false,
    isValid: false
  }
}

function createPaginatedData() {
  return {
    items: [],
    currentPage: 1,
    pageSize: 10,
    totalItems: 0,
    isLoading: false
  }
}

// 在组件中使用
export default {
  props: ['initialProduct'],
  
  data() {
    return {
      // 组合多个数据工厂
      ...createFormData(this.initialProduct),
      ...createPaginatedData(),
      
      // 组件特有数据
      selectedCategory: null,
      uploadedImages: []
    }
  }
}

四、特殊场景:根实例的 data 可以是对象

为什么根实例可以是对象?

// 根实例可以是对象
new Vue({
  el: '#app',
  data: {  // 这里可以是对象!
    message: 'Hello Root',
    count: 0
  }
})

// 原因:根实例不会被复用
// 整个应用只有一个根实例

源码中的区别对待

// Vue 源码中的判断
function initData(vm) {
  let data = vm.$options.data
  
  // 关键判断:根实例可以是对象,组件必须是函数
  if (!vm.$parent) {
    // 根实例,允许是对象
    // 但仍然推荐使用函数式写法保持一致性
  } else {
    // 组件实例,必须是函数
    if (typeof data !== 'function') {
      warn(
        'The "data" option should be a function ' +
        'that returns a per-instance value in component ' +
        'definitions.',
        vm
      )
      data = {}
    }
  }
}

一致性建议

尽管根实例可以是对象,但强烈建议始终使用函数形式

// ✅ 推荐:始终使用函数
new Vue({
  el: '#app',
  data() {
    return {
      message: 'Hello Vue!',
      user: null,
      loading: false
    }
  }
})

// ❌ 不推荐:混合风格
new Vue({
  el: '#app',
  data: {  // 这里是对象
    message: 'Hello'
  },
  components: {
    ChildComponent: {
      data() {  // 这里是函数
        return { count: 0 }
      }
    }
  }
})

五、TypeScript 中的类型安全

5.1 Vue 2 + TypeScript

import Vue from 'vue'

interface ComponentData {
  count: number
  message: string
  todos: Todo[]
  user: User | null
}

export default Vue.extend({
  data(): ComponentData {  // 明确的返回类型
    return {
      count: 0,
      message: '',
      todos: [],
      user: null
    }
  }
})

5.2 Vue 3 + Composition API

import { defineComponent, ref, reactive } from 'vue'

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

export default defineComponent({
  setup() {
    // 每个响应式变量都有明确的类型
    const count = ref<number>(0)
    const message = ref<string>('')
    const user = ref<User | null>(null)
    
    const formState = reactive({
      username: '',
      password: '',
      rememberMe: false
    })
    
    return {
      count,
      message,
      user,
      formState
    }
  }
})

5.3 复杂的类型推导

// 使用泛型工厂函数
function createPaginatedData<T>(): PaginatedData<T> {
  return {
    items: [] as T[],
    currentPage: 1,
    pageSize: 10,
    totalItems: 0,
    isLoading: false
  }
}

function createFormData<T extends object>(initialData: T): FormData<T> {
  return {
    values: { ...initialData },
    errors: {} as Record<keyof T, string>,
    touched: {} as Record<keyof T, boolean>,
    isSubmitting: false,
    isValid: false
  }
}

// 在组件中使用
export default defineComponent({
  props: {
    product: {
      type: Object as PropType<Product>,
      required: true
    }
  },
  
  setup(props) {
    // 类型安全的初始化
    const productForm = createFormData<Product>(props.product)
    const reviewsData = createPaginatedData<Review>()
    
    return {
      productForm,
      reviewsData
    }
  }
})

六、高级模式:数据初始化策略

6.1 异步数据初始化

export default {
  data() {
    return {
      user: null,
      posts: [],
      isLoading: false,
      error: null
    }
  },
  
  async created() {
    await this.initializeData()
  },
  
  methods: {
    async initializeData() {
      this.isLoading = true
      try {
        const [user, posts] = await Promise.all([
          this.fetchUser(),
          this.fetchPosts()
        ])
        
        // 直接赋值,Vue 会响应式更新
        this.user = user
        this.posts = posts
      } catch (err) {
        this.error = err.message
      } finally {
        this.isLoading = false
      }
    }
  }
}

6.2 数据重置功能

export default {
  data() {
    return this.getInitialData()
  },
  
  methods: {
    getInitialData() {
      return {
        form: {
          username: '',
          email: '',
          agreeTerms: false
        },
        submitted: false,
        errors: {}
      }
    },
    
    resetForm() {
      // 重置到初始状态
      Object.assign(this.$data, this.getInitialData())
    },
    
    submitForm() {
      this.submitted = true
      // 提交逻辑...
    }
  }
}

6.3 数据持久化与恢复

export default {
  data() {
    const savedData = localStorage.getItem(this.storageKey)
    
    return {
      count: 0,
      theme: 'light',
      preferences: {},
      ...(savedData ? JSON.parse(savedData) : {})
    }
  },
  
  computed: {
    storageKey() {
      return `app-state-${this.$options.name || 'default'}`
    }
  },
  
  watch: {
    // 深度监视数据变化
    '$data': {
      handler(newData) {
        localStorage.setItem(this.storageKey, JSON.stringify(newData))
      },
      deep: true
    }
  }
}

七、常见错误与解决方案

错误1:箭头函数的 this 问题

// ❌ 错误:箭头函数中的 this 不是 Vue 实例
export default {
  props: ['initialCount'],
  data: () => ({
    count: this.initialCount  // this 是 undefined!
  })
}

// ✅ 正确:使用普通函数
export default {
  props: ['initialCount'],
  data() {
    return {
      count: this.initialCount  // this 是 Vue 实例
    }
  }
}

// ✅ 正确:使用带参数的箭头函数
export default {
  props: ['initialCount'],
  data: (vm) => ({
    count: vm.initialCount  // 通过参数访问
  })
}

错误2:直接修改 props 作为 data

// ❌ 错误:直接使用 props
export default {
  props: ['user'],
  data() {
    return {
      // 如果 user 是对象,这仍然是引用!
      localUser: this.user
    }
  },
  watch: {
    user(newUser) {
      // 需要手动更新
      this.localUser = { ...newUser }
    }
  }
}

// ✅ 正确:创建深拷贝
export default {
  props: ['user'],
  data() {
    return {
      // 创建新对象,避免引用问题
      localUser: JSON.parse(JSON.stringify(this.user))
    }
  }
}

// ✅ 更好的方案:使用计算属性
export default {
  props: ['user'],
  data() {
    return {
      // 只存储用户可修改的部分
      editableFields: {
        name: this.user.name,
        email: this.user.email
      }
    }
  }
}

错误3:复杂的异步初始化

// ❌ 错误:在 data 中执行异步操作
export default {
  data() {
    return {
      user: null,
      // 不能在 data 中执行异步!
      // asyncData: await fetchData()  // 语法错误
    }
  }
}

// ✅ 正确:在 created/mounted 中初始化
export default {
  data() {
    return {
      user: null,
      loading: false
    }
  },
  async created() {
    this.loading = true
    this.user = await this.fetchUser()
    this.loading = false
  }
}

八、性能优化与最佳实践

8.1 数据结构的优化

export default {
  data() {
    return {
      // ✅ 扁平化数据结构
      form: {
        username: '',
        email: '',
        password: ''
      },
      
      // ✅ 数组使用对象索引快速访问
      users: [],
      userIndex: {}, // { [id]: user }
      
      // ✅ 避免深层嵌套
      // ❌ 不好:user.profile.contact.address.street
      // ✅ 好:userAddress: { street, city, zip }
      
      // ✅ 分离频繁变更的数据
      uiState: {
        isLoading: false,
        isMenuOpen: false,
        activeTab: 'home'
      },
      
      businessData: {
        products: [],
        orders: [],
        customers: []
      }
    }
  }
}

8.2 数据冻结与性能

export default {
  data() {
    return {
      // 配置数据,不会变化,可以冻结
      config: Object.freeze({
        apiUrl: 'https://api.example.com',
        maxItems: 100,
        theme: 'light'
      }),
      
      // 频繁变化的数据
      items: [],
      filter: ''
    }
  }
}

8.3 按需初始化大型数据

export default {
  data() {
    return {
      // 延迟初始化大型数据
      largeDataset: null,
      isDatasetLoaded: false
    }
  },
  
  methods: {
    async loadDatasetIfNeeded() {
      if (!this.isDatasetLoaded) {
        this.largeDataset = await this.fetchLargeDataset()
        this.isDatasetLoaded = true
      }
    }
  },
  
  computed: {
    // 计算属性按需访问
    processedData() {
      if (!this.largeDataset) {
        this.loadDatasetIfNeeded()
        return []
      }
      return this.process(this.largeDataset)
    }
  }
}

九、总结:为什么 data 必须是函数?

原因 说明 示例
组件复用 每个实例需要独立的数据副本 多个 Counter 组件各自计数
数据隔离 避免组件间意外共享状态 商品卡片独立收藏状态
内存安全 防止内存泄漏和意外修改 组件销毁时数据自动回收
响应式系统 Vue 需要为每个实例建立响应式 每个实例有自己的依赖收集
测试友好 可以轻松创建干净的测试实例 每个测试用例有独立状态
可预测性 组件行为一致,无副作用 相同的输入产生相同输出

核心原理回顾

  1. 函数调用创建新对象:每次组件实例化时,data() 被调用,返回全新的数据对象
  2. 闭包保持独立性:每个实例的数据在闭包中,互不干扰
  3. 响应式绑定隔离:Vue 的响应式系统为每个数据对象单独建立依赖追踪

终极建议

  1. 始终使用函数形式:即使根实例也推荐使用函数
  2. 保持 data 简洁:只包含组件内部状态
  3. 合理组织数据结构:扁平化、按功能分组
  4. 考虑性能影响:避免在 data 中创建大型对象
  5. 拥抱 TypeScript:为 data 提供明确的类型定义
  6. 理解响应式原理:知道什么会被响应式追踪

记住:data() 函数是 Vue 组件数据隔离的基石。这个设计决策虽然增加了些许代码量,但它保证了组件系统的可靠性和可预测性,是 Vue 组件化架构成功的关键因素之一。


思考题:在你的 Vue 项目中,有没有遇到过因为数据共享导致的问题?或者有没有什么独特的数据初始化模式想要分享?欢迎在评论区交流讨论!

Vue 模板中保留 HTML 注释的完整指南

Vue 模板中保留 HTML 注释的完整指南

前言:注释的艺术

在 Vue 开发中,我们经常需要在模板中添加注释。这些注释可能是:

  • 📝 开发者备注:解释复杂逻辑
  • 🏷️ 代码标记:TODO、FIXME 等
  • 🔧 模板占位符:为后续开发留位置
  • 📄 文档生成:自动生成 API 文档
  • 🎨 设计系统标注:设计意图说明

但是,你可能会发现 Vue 默认会移除模板中的所有 HTML 注释!今天我们就来深入探讨如何在 Vue 中保留这些有价值的注释。

一、Vue 默认行为:为什么移除注释?

源码视角

// 简化版 Vue 编译器处理
function compile(template) {
  // 默认情况下,注释节点会被移除
  const ast = parse(template, {
    comments: false // 默认不保留注释
  })
  
  // 生产环境优化:移除所有注释
  if (process.env.NODE_ENV === 'production') {
    removeComments(ast)
  }
}

Vue 移除注释的原因

  1. 性能优化:减少 DOM 节点数量
  2. 安全性:避免潜在的信息泄露
  3. 代码精简:减少最终文件体积
  4. 标准做法:与主流框架保持一致

默认行为演示

<template>
  <div>
    <!-- 这个注释在最终渲染中会被移除 -->
    <h1>Hello World</h1>
    
    <!-- 
      多行注释
      也会被移除
    -->
    
    <!-- TODO: 这里需要添加用户头像 -->
    <div class="user-info">
      {{ userName }}
    </div>
  </div>
</template>

编译结果

<div>
  <h1>Hello World</h1>
  <div class="user-info">
    John Doe
  </div>
</div>

所有注释都不见了!

二、配置 Vue 保留注释的 4 种方法

方法1:Vue 编译器配置(全局)

Vue 2 配置
// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        return {
          ...options,
          compilerOptions: {
            comments: true // 保留注释
          }
        }
      })
  }
}

// 或 webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            comments: true
          }
        }
      }
    ]
  }
}
Vue 3 配置
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          comments: true // 保留注释
        }
      }
    })
  ]
})

// 或 vue.config.js (Vue CLI)
module.exports = {
  configureWebpack: {
    module: {
      rules: [
        {
          test: /\.vue$/,
          use: [
            {
              loader: 'vue-loader',
              options: {
                compilerOptions: {
                  comments: true
                }
              }
            }
          ]
        }
      ]
    }
  }
}

方法2:单文件组件配置(Vue 3 特有)

<template>
  <!-- 这个注释会被保留 -->
  <div>
    <!-- 组件说明:用户信息展示 -->
    <UserProfile />
  </div>
</template>

<script>
export default {
  // Vue 3 可以在组件级别配置
  compilerOptions: {
    comments: true
  }
}
</script>

方法3:运行时编译(仅开发环境)

// 使用完整版 Vue(包含编译器)
import Vue from 'vue/dist/vue.esm.js'

new Vue({
  el: '#app',
  template: `
    <div>
      <!-- 运行时编译会保留注释 -->
      <h1>Hello</h1>
    </div>
  `,
  compilerOptions: {
    comments: true
  }
})

方法4:使用 <script type="text/x-template">

<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  
  <!-- 模板定义,注释会被保留 -->
  <script type="text/x-template" id="my-template">
    <div>
      <!-- 用户信息区域 -->
      <div class="user-info">
        {{ userName }}
      </div>
      
      <!-- TODO: 添加用户权限展示 -->
    </div>
  </script>
  
  <script>
  new Vue({
    el: '#app',
    template: '#my-template',
    data: {
      userName: 'John'
    },
    // 可能需要额外配置
    compilerOptions: {
      comments: true
    }
  })
  </script>
</body>
</html>

三、注释的最佳实践与用例

用例1:组件文档生成

<template>
  <!-- 
    UserCard 组件
    @prop {Object} user - 用户对象
    @prop {Boolean} showDetails - 是否显示详情
    @slot default - 自定义内容
    @slot avatar - 自定义头像
    @event click - 点击事件
  -->
  <div class="user-card" @click="$emit('click', user)">
    <!-- 用户头像 -->
    <div class="avatar">
      <slot name="avatar">
        <img :src="user.avatar" alt="头像">
      </slot>
    </div>
    
    <!-- 用户基本信息 -->
    <div class="info">
      <h3>{{ user.name }}</h3>
      <p v-if="showDetails">{{ user.bio }}</p>
      
      <!-- 自定义内容区域 -->
      <slot />
    </div>
    
    <!-- FIXME: 这里应该显示用户标签 -->
  </div>
</template>

<script>
export default {
  name: 'UserCard',
  props: {
    user: {
      type: Object,
      required: true
    },
    showDetails: {
      type: Boolean,
      default: false
    }
  }
}
</script>

用例2:设计系统标注

<template>
  <!-- 
    Design System: Button Component
    Type: Primary Button
    Color: Primary Blue (#1890ff)
    Spacing: 8px vertical, 16px horizontal
    Border Radius: 4px
    States: Default, Hover, Active, Disabled
  -->
  <button 
    class="btn btn-primary"
    :disabled="disabled"
    @click="handleClick"
  >
    <!-- 
      Button Content Guidelines:
      1. 使用动词开头
      2. 不超过4个汉字
      3. 保持简洁明了
    -->
    <slot>{{ label }}</slot>
  </button>
  
  <!-- 
    Design Tokens Reference:
    --color-primary: #1890ff;
    --spacing-md: 8px;
    --radius-sm: 4px;
  -->
</template>

用例3:协作开发标记

<template>
  <div class="checkout-page">
    <!-- TODO: @前端小王 - 添加优惠券选择功能 -->
    <div class="coupon-section">
      优惠券功能开发中...
    </div>
    
    <!-- FIXME: @前端小李 - 修复移动端支付按钮布局 -->
    <div class="payment-section">
      <button class="pay-btn">立即支付</button>
    </div>
    
    <!-- OPTIMIZE: @性能优化小组 - 图片懒加载优化 -->
    <div class="recommendations">
      <img 
        v-for="img in productImages" 
        :key="img.id"
        :src="img.thumbnail"
        :data-src="img.fullSize"
        class="lazy-image"
      >
    </div>
    
    <!-- HACK: @前端小张 - 临时解决Safari兼容性问题 -->
    <div v-if="isSafari" class="safari-fix">
      <!-- Safari specific fixes -->
    </div>
  </div>
</template>

<script>
export default {
  computed: {
    isSafari() {
      return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
    }
  }
}
</script>

四、环境差异化配置

开发环境 vs 生产环境

// vue.config.js
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        const compilerOptions = {
          ...options.compilerOptions
        }
        
        // 只在开发环境保留注释
        if (process.env.NODE_ENV === 'development') {
          compilerOptions.comments = true
        } else {
          compilerOptions.comments = false
        }
        
        return {
          ...options,
          compilerOptions
        }
      })
  }
}

按需保留特定类型注释

// 自定义注释处理器
const commentPreserver = {
  // 只保留特定前缀的注释
  shouldPreserveComment(comment) {
    const preservedPrefixes = [
      'TODO:',
      'FIXME:', 
      'HACK:',
      'OPTIMIZE:',
      '@design-system',
      '@api'
    ]
    
    return preservedPrefixes.some(prefix => 
      comment.trim().startsWith(prefix)
    )
  }
}

// 在配置中使用
module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        return {
          ...options,
          compilerOptions: {
            whitespace: 'preserve',
            // 自定义注释处理
            comments: (comment) => commentPreserver.shouldPreserveComment(comment)
          }
        }
      })
  }
}

五、高级用法:注释数据处理

用例1:自动提取 API 文档

<template>
  <!-- 
    @component UserProfile
    @description 用户个人资料展示组件
    @version 1.2.0
    @author 开发团队
    @prop {String} userId - 用户ID
    @prop {Boolean} editable - 是否可编辑
    @event save - 保存事件
    @event cancel - 取消事件
  -->
  <div class="user-profile">
    <!-- @section 基本信息 -->
    <div class="basic-info">
      {{ user.name }}
    </div>
    
    <!-- @section 联系信息 -->
    <div class="contact-info">
      {{ user.email }}
    </div>
  </div>
</template>
// 注释提取脚本
const fs = require('fs')
const path = require('path')
const parser = require('@vue/compiler-sfc')

function extractCommentsFromVue(filePath) {
  const content = fs.readFileSync(filePath, 'utf-8')
  const { descriptor } = parser.parse(content)
  
  const comments = []
  const template = descriptor.template
  
  if (template) {
    // 解析模板中的注释
    const ast = parser.compile(template.content, {
      comments: true
    }).ast
    
    traverseAST(ast, (node) => {
      if (node.type === 3 && node.isComment) {
        comments.push({
          content: node.content,
          line: node.loc.start.line,
          file: path.basename(filePath)
        })
      }
    })
  }
  
  return comments
}

// 生成文档
const componentComments = extractCommentsFromVue('./UserProfile.vue')
console.log(JSON.stringify(componentComments, null, 2))

用例2:代码质量检查

// eslint-plugin-vue-comments
module.exports = {
  rules: {
    'require-todo-comment': {
      create(context) {
        return {
          'VElement'(node) {
            const comments = context.getSourceCode()
              .getAllComments()
              .filter(comment => comment.type === 'HTML')
            
            // 检查是否有 TODO 注释
            const hasTodo = comments.some(comment => 
              comment.value.includes('TODO:')
            )
            
            if (!hasTodo && node.rawName === 'div') {
              context.report({
                node,
                message: '复杂 div 元素需要添加 TODO 注释说明'
              })
            }
          }
        }
      }
    }
  }
}

六、与 JSX/渲染函数的对比

Vue 模板 vs JSX

// Vue 模板(支持 HTML 注释)
const template = `
  <div>
    <!-- 这个注释会被处理 -->
    <h1>Title</h1>
  </div>
`

// JSX(使用 JS 注释)
const jsx = (
  <div>
    {/* JSX 中的注释 */}
    <h1>Title</h1>
    {
      // 也可以使用单行注释
    }
  </div>
)

// Vue 渲染函数
export default {
  render(h) {
    // 渲染函数中无法添加 HTML 注释
    // 只能使用 JS 注释,但不会出现在 DOM 中
    return h('div', [
      // 这是一个 JS 注释,不会出现在 DOM 中
      h('h1', 'Title')
    ])
  }
}

在 JSX 中模拟 HTML 注释

// 自定义注释组件
const Comment = ({ text }) => (
  <div 
    style={{ display: 'none' }}
    data-comment={text}
    aria-hidden="true"
  />
)

// 使用
const Component = () => (
  <div>
    <Comment text="TODO: 这里需要优化" />
    <h1>内容</h1>
  </div>
)

七、注意事项与常见问题

问题1:性能影响

// 保留大量注释的性能测试
const testData = {
  withComments: `
    <div>
      ${Array(1000).fill().map((_, i) => 
        `<!-- 注释 ${i} -->\n<div>Item ${i}</div>`
      ).join('\n')}
    </div>
  `,
  withoutComments: `
    <div>
      ${Array(1000).fill().map((_, i) => 
        `<div>Item ${i}</div>`
      ).join('\n')}
    </div>
  `
}

// 测试结果
// 有注释:虚拟DOM节点数 2000
// 无注释:虚拟DOM节点数 1000
// 内存占用增加约 30-50%

建议:只在开发环境保留注释,生产环境移除。

问题2:安全性考虑

<template>
  <!-- 危险:可能泄露敏感信息 -->
  <!-- API密钥:sk_test_1234567890 -->
  <!-- 数据库连接:mysql://user:pass@localhost -->
  <!-- 内部接口:https://internal-api.company.com -->
  
  <!-- 安全:使用占位符 -->
  <!-- 使用环境变量:{{ apiEndpoint }} -->
</template>

问题3:SSR(服务端渲染)兼容性

// server.js
const Vue = require('vue')
const renderer = require('@vue/server-renderer')

const app = new Vue({
  template: `
    <div>
      <!-- SSR注释 -->
      <h1>服务端渲染</h1>
    </div>
  `
})

// SSR 渲染
const html = await renderer.renderToString(app, {
  // 需要显式启用注释
  template: {
    compilerOptions: {
      comments: true
    }
  }
})

console.log(html)
// 输出:<div><!-- SSR注释 --><h1>服务端渲染</h1></div>

八、最佳实践总结

配置文件模板

// vue.config.js - 完整配置示例
module.exports = {
  chainWebpack: config => {
    // Vue 文件处理
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        const isDevelopment = process.env.NODE_ENV === 'development'
        const isProduction = process.env.NODE_ENV === 'production'
        
        return {
          ...options,
          compilerOptions: {
            // 开发环境:保留所有注释
            // 生产环境:移除注释,或只保留特定注释
            comments: isDevelopment ? true : (comment) => {
              const importantPrefixes = [
                'TODO:',
                'FIXME:',
                '@design-system',
                '@api-docs'
              ]
              
              return importantPrefixes.some(prefix => 
                comment.trim().startsWith(prefix)
              )
            },
            
            // 其他编译选项
            whitespace: isProduction ? 'condense' : 'preserve',
            delimiters: ['{{', '}}']
          }
        }
      })
  }
}

注释编写规范

<template>
  <!-- 
    良好的注释规范:
    1. 使用清晰的标题
    2. 使用标准标记(TODO, FIXME等)
    3. @作者 和 @日期
    4. 保持注释简洁
  -->
  
  <!-- 
    SECTION: 用户信息展示
    TODO: 添加用户角色徽章 - @前端小李 - 2024-01
    FIXME: 移动端头像大小需要调整 - @UI设计师 - 2024-01
    @design-system: 使用 DS-Button 组件
    @api: 用户数据来自 /api/user/:id
  -->
  <div class="user-profile">
    <!-- 基本信息区域 -->
    <div class="basic-info">
      <!-- 用户头像 -->
      <img :src="user.avatar" alt="头像">
    </div>
  </div>
</template>

各场景推荐方案

场景 推荐方案 配置方式 备注
开发调试 保留所有注释 comments: true 便于调试
生产环境 移除所有注释 comments: false 性能优化
文档生成 保留特定注释 自定义过滤函数 提取 API 文档
设计系统 保留设计注释 comments: /@design-system/ 设计标注
团队协作 保留 TODO/FIXME 正则匹配保留 任务跟踪

总结

在 Vue 中保留 HTML 注释需要明确的配置,但这对于开发效率、团队协作、文档维护都大有裨益。关键点:

  1. 理解默认行为:Vue 为性能优化默认移除注释
  2. 按需配置:根据环境选择是否保留注释
  3. 规范注释:制定团队统一的注释规范
  4. 考虑性能:生产环境谨慎保留注释
  5. 探索高级用法:注释可以用于文档生成、代码分析等

记住:好的注释是代码的路标,而不仅仅是装饰。合理配置和使用注释,能让你的 Vue 项目更加可维护、可协作。


思考题:在你的项目中,注释主要用来做什么?有没有因为注释问题导致的沟通成本增加?或者有没有什么特别的注释技巧想要分享?欢迎在评论区交流讨论!

❌