阅读视图
Vue 自定义指令完全指南:定义与应用场景详解
Vue 动态路由完全指南:定义与参数获取详解
Vue Router 完全指南:作用与组件详解
Vue 中使用 this 的完整指南与注意事项
Vue 插槽(Slot)完全指南:组件内容分发的艺术
Vue 组件中访问根实例的完整指南
Vue 的 v-cloak 和 v-pre 指令详解
Vue Router 中获取路由参数的全面指南
Vue 过滤器:优雅处理数据的艺术
解决 Vue 2 大数据量表单首次交互卡顿 10s 的性能问题
使用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提供的几何体绘制方法像拼积木搭建即可。
完整代码
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配置的最佳实践
-
正确配置路由:使用
/:pathMatch(.*)*作为最后的catch-all路由 - 服务器状态码:确保404页面返回正确的HTTP 404状态码
- 用户体验:提供有用的导航选项和内容建议
- SEO优化:设置正确的meta标签,避免搜索引擎索引404页面
- 监控分析:跟踪404错误,了解用户访问路径
- 多语言支持:为国际化应用提供本地化的404页面
- 性能考虑:使用懒加载,避免影响主包大小
- 测试覆盖:确保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 的特点
- Presenter 作为中间人:协调 View 和 Model
- View 被动:只负责显示,不包含业务逻辑
- 解耦更好:View 和 Model 不知道彼此存在
- 但仍有问题: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?
- 开发效率:数据绑定减少样板代码
- 维护性:响应式系统自动处理更新
- 可测试性:ViewModel 可以独立测试
- 渐进式:可以从简单开始,逐步复杂化
8.3 现代 Vue 开发的最佳实践
- 拥抱 MVVM:理解并善用响应式系统
-
合理分层:
- View:只负责展示,尽量简单
- ViewModel:处理业务逻辑和状态
- Model:管理数据和业务规则
- 组合优于继承:使用组合式 API 组织代码
- 状态管理:在需要时引入 Pinia/Vuex
- 关注点分离:按特性组织代码,而非技术
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-if、v-else-if、v-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> 的六大用途
- 条件渲染多个元素:避免多余的包装 DOM
- 列表渲染复杂结构:包含额外元素和逻辑
- 插槽系统的基础:定义和使用插槽内容
- 动态组件容器:包裹动态组件和插槽
- 过渡动画包装:实现复杂的动画效果
- 模板逻辑分组:提高代码可读性和维护性
版本特性总结
| 特性 | Vue 2 | Vue 3 | 说明 |
|---|---|---|---|
| 多根节点 | ❌ | ✅ | Fragment 支持 |
<script setup> |
❌ | ✅ | 语法糖简化 |
v-memo |
❌ | ✅ | 性能优化 |
| 编译优化 | 基础 | 增强 | 更好的静态提升 |
最佳实践清单
- 合理使用:只在需要时使用,避免过度嵌套
- 保持简洁:复杂逻辑考虑提取为组件
- 注意性能:避免在大量循环中使用复杂模板
- 统一风格:团队保持一致的模板编写规范
- 利用新特性: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 需要为每个实例建立响应式 | 每个实例有自己的依赖收集 |
| 测试友好 | 可以轻松创建干净的测试实例 | 每个测试用例有独立状态 |
| 可预测性 | 组件行为一致,无副作用 | 相同的输入产生相同输出 |
核心原理回顾
-
函数调用创建新对象:每次组件实例化时,
data()被调用,返回全新的数据对象 - 闭包保持独立性:每个实例的数据在闭包中,互不干扰
- 响应式绑定隔离:Vue 的响应式系统为每个数据对象单独建立依赖追踪
终极建议
- 始终使用函数形式:即使根实例也推荐使用函数
- 保持 data 简洁:只包含组件内部状态
- 合理组织数据结构:扁平化、按功能分组
- 考虑性能影响:避免在 data 中创建大型对象
- 拥抱 TypeScript:为 data 提供明确的类型定义
- 理解响应式原理:知道什么会被响应式追踪
记住: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 移除注释的原因:
- 性能优化:减少 DOM 节点数量
- 安全性:避免潜在的信息泄露
- 代码精简:减少最终文件体积
- 标准做法:与主流框架保持一致
默认行为演示
<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 注释需要明确的配置,但这对于开发效率、团队协作、文档维护都大有裨益。关键点:
- 理解默认行为:Vue 为性能优化默认移除注释
- 按需配置:根据环境选择是否保留注释
- 规范注释:制定团队统一的注释规范
- 考虑性能:生产环境谨慎保留注释
- 探索高级用法:注释可以用于文档生成、代码分析等
记住:好的注释是代码的路标,而不仅仅是装饰。合理配置和使用注释,能让你的 Vue 项目更加可维护、可协作。
思考题:在你的项目中,注释主要用来做什么?有没有因为注释问题导致的沟通成本增加?或者有没有什么特别的注释技巧想要分享?欢迎在评论区交流讨论!