普通视图

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

Vue2 和 Vue3 中 watch 用法和原理详解

作者 木易士心
2025年11月17日 14:42

@TOC

1. Vue2 中的 watch

1. 基本用法

在 Vue2 中,watch 是一个对象,其键是要观察的表达式,值是对应的回调函数或包含选项的对象。

// 对象写法
export default {
  data() {
    return {
      count: 0,
      user: {
        name: 'John',
        age: 25
      }
    }
  },
  watch: {
    // 监听基本数据类型
    count(newVal, oldVal) {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    },
    
    // 深度监听对象
    user: {
      handler(newVal, oldVal) {
        console.log('user changed:', newVal)
      },
      deep: true, // 深度监听
      immediate: true // 立即执行
    },
    
    // 监听对象特定属性
    'user.name': function(newVal, oldVal) {
      console.log(`name changed from ${oldVal} to ${newVal}`)
    }
  }
}

2. 程序式监听

Vue2 也提供了 $watch API,可以在实例的任何地方监听数据变化。

export default {
  mounted() {
    // 使用 $watch API
    const unwatch = this.$watch(
      'count',
      (newVal, oldVal) => {
        console.log(`count changed: ${oldVal} -> ${newVal}`)
      },
      {
        immediate: true,
        deep: false
      }
    )
    
    // 取消监听
    // unwatch()
  }
}

2. Vue3 中的 watch

1. 组合式 API 用法

Vue3 的 watch 更加灵活,支持监听 ref、reactive 对象、getter 函数等多种数据源。

import { ref, reactive, watch, watchEffect } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const user = reactive({
      name: 'John',
      age: 25
    })
    
    // 监听 ref
    watch(count, (newVal, oldVal) => {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    })
    
    // 监听 reactive 对象
    watch(
      () => user.name, // getter 函数
      (newVal, oldVal) => {
        console.log(`name changed from ${oldVal} to ${newVal}`)
      }
    )
    
    // 深度监听对象
    watch(
      () => user,
      (newVal, oldVal) => {
        console.log('user changed:', newVal)
      },
      { deep: true }
    )
    
    // 监听多个源
    watch(
      [() => count.value, () => user.name],
      ([newCount, newName], [oldCount, oldName]) => {
        console.log(`count: ${oldCount}->${newCount}, name: ${oldName}->${newName}`)
      }
    )
    
    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      console.log(`count is ${count.value}, name is ${user.name}`)
    })
    
    return {
      count,
      user
    }
  }
}

2. 选项式 API 用法

Vue3 也支持在选项式 API 中使用 watch,与 Vue2 的用法类似。

import { watch } from 'vue'

export default {
  data() {
    return {
      count: 0,
      user: {
        name: 'John',
        age: 25
      }
    }
  },
  watch: {
    count(newVal, oldVal) {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    }
  },
  created() {
    // 使用 watch 函数
    watch(
      () => this.user.name,
      (newVal, oldVal) => {
        console.log(`name changed from ${oldVal} to ${newVal}`)
      }
    )
  }
}

3.核心原理分析

1. Vue2 的 Watch 原理

Vue2 的 watch 基于响应式系统的依赖收集和派发更新机制。

  • 在组件实例初始化阶段,遍历 watch 对象的每一个属性,为每一个监听表达式创建一个 watcher 实例。
  • watcher 的创建过程:解析表达式,生成 getter 函数;执行 getter 函数,触发依赖收集;保存旧值,等待数据变化。
  • 当被监听的数据发生变化时,触发 setter,通知对应的 watcher 更新;watcher 执行 getter 获取新值,比较新值和旧值,如果不同则执行回调函数。

2. Vue3 的 Watch 原理

Vue3 的 watch 基于 effect 机制实现。

  • 将回调函数包装成一个 effect,当被监听的数据发生变化时,effect 会重新执行。
  • 通过 track 函数进行依赖收集,trigger 函数触发更新。
  • 使用调度器 scheduler 控制 effect 的执行时机,实现异步更新和 flush 选项。

4. 主要差异对比

1. 差异总结

  • Vue2 的 watch 语法较为简单直观,适合选项式 API;Vue3 的 watch 更加灵活,适合组合式 API。
  • Vue3 的 watch 基于 effect 机制实现,提供了更好的性能和更丰富的配置选项。
  • 两者都支持深度监听、立即执行、异步回调等特性,但在语法和使用方式上有所不同。

2. 特性对比

特性 Vue2 Vue3
API 形式 选项式 组合式 + 选项式
监听 reactive 不支持 原生支持
深度监听 需要显式配置 reactive 对象默认深度监听
多源监听 不支持 支持监听多个数据源
清理副作用 不支持 支持 cleanup 函数
性能 相对较低 基于 Proxy,性能更好

5. 使用建议

1. 性能优化

避免不必要的深度监听,只监听需要的属性。

// Vue3 - 避免不必要的深度监听
const largeObject = reactive({ /* 大量数据 */ })

// 不好的做法
watch(largeObject, () => {
  // 任何属性变化都会触发
})

// 好的做法 - 只监听需要的属性
watch(
  () => largeObject.importantProp,
  () => {
    // 只有 importantProp 变化时触发
  }
)

2. 清理副作用

Vue3 支持在 watch 中清理副作用,避免内存泄漏。

// Vue3 - 清理副作用
watch(
  data,
  async (newVal, oldVal, onCleanup) => {
    let cancelled = false
    onCleanup(() => {
      cancelled = true
    })
    
    const result = await fetchData(newVal)
    if (!cancelled) {
      // 处理结果
    }
  }
)

3. 防抖处理

使用防抖函数避免频繁触发 watch 回调。

import { debounce } from 'lodash-es'

// Vue3 防抖监听
watch(
  searchQuery,
  debounce((newVal) => {
    searchAPI(newVal)
  }, 300)
)

6.常见问题解答

1. Vue2 和 Vue3 的 watch 混用?

在 Vue3 的选项式 API 中,可以继续使用 Vue2 风格的 watch 选项,但不建议混用。

2. 什么时候用 watch,什么时候用 computed?

watch 用于执行副作用(如 API 调用、DOM 操作),computed 用于派生数据。

3. watchEffect 和 watch 的区别?

watchEffect 自动追踪依赖,立即执行;watch 需要明确指定监听源,默认懒执行。

通过深入理解 Vue2 和 Vue3 中 watch 的用法和原理,可以更好地根据项目需求选择合适的监听方式,并编写出更高效、可维护的代码。

vue3.x 使用vue3-tree-org实现组织架构图 + 自定义模版内容 - 附完整示例

作者 bug爱好者
2025年11月17日 14:08

组织树形结构架构图,如果是vue2项目,请移步www.cnblogs.com/10ve/p/1257…

本文主要讲解在vue3项目中使用,废话不多说,直接上代码。

实际完成效果图

a106a8797916fda0c2faf2501698f655.png

官方文档:sangtian152.github.io/vue3-tree-o…

image.png

安装

npm i vue3-tree-org -S
# or
yarn add vue3-tree-org

安装版本号

"vue3-tree-org": "^4.2.2",

全局使用共有两种方法:

  1. main.js直接使用:

import { createApp } from 'vue'
import vue3TreeOrg from 'vue3-tree-org';
import "vue3-tree-org/lib/vue3-tree-org.css";
 
const app = createApp(App)
 
app.use(vue3TreeOrg)
app.mount('#app')
  1. main.js封装使用(推荐):
import { createApp } from 'vue';
import App from './App.vue';
import router, { setupRouter } from '@/router';
import { setupStore } from '@/store';
import { setupDirectives } from '@/directives';
import setupPlugins from '@/plugins';

// 引入动画
import 'animate.css/animate.min.css';
import 'animate.css/animate.compat.css';
import '@/styles/common/base.scss';
import '@/styles/common/element_edit_after.scss';
import '@/styles/common/el-button.scss';

async function appInit() {
  const app = createApp(App);

  // 挂载状态管理
  setupStore(app);

  // 挂载路由
  setupRouter(app);

  // 挂载插件
  setupPlugins(app);

  // 自定义指令
  setupDirectives(app);

  // 路由准备就绪后挂载APP实例
  await router.isReady();

  // 挂载到页面
  app.mount('#app', true);
}

void appInit();

3. plugins文件下的treeOrg.ts

import { App } from 'vue'

import vue3TreeOrg from 'vue3-tree-org';
import "vue3-tree-org/lib/vue3-tree-org.css";

export function setupTreeOrg(app: App) {
  app.use(vue3TreeOrg)
}

整体文件对应图

image.png

如果不需要自定义内容,可以这样使用


<template>
  <div class="tree-wrap" style="height: 400px">
    <div class="search-box">
      <span>搜索:</span>
      <input type="text" v-model="keyword" placeholder="请输入搜索内容" @keydown.enter="filter" />
    </div>
    <vue3-tree-org
      ref="treeRef"
      :data="data"
      :horizontal="horizontal"
      :collapsable="collapsable"
      :label-style="style"
      :node-draggable="true"
      :scalable="false"
      :only-one-node="onlyOneNode"
      :default-expand-level="1"
      :filter-node-method="filterNodeMethod"
      :clone-node-drag="cloneNodeDrag"
      @on-restore="restore"
      @on-contextmenu="onMenus"
      @on-node-click="onNodeClick"
    />
  </div>
</template>
 
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
 
const treeRef = ref()
const data = ref({
  id: 1,
  label: 'xxx科技有限公司',
  children: [
    {
      id: 2,
      pid: 1,
      label: '产品研发部',
      style: { color: '#fff', background: '#108ffe' },
      children: [
        { id: 6, pid: 2, label: '禁止编辑节点', disabled: true },
        { id: 8, pid: 2, label: '禁止拖拽节点', noDragging: true },
        { id: 10, pid: 2, label: '测试' }
      ]
    },
    {
      id: 3,
      pid: 1,
      label: '客服部',
      children: [
        { id: 11, pid: 3, label: '客服一部' },
        { id: 12, pid: 3, label: '客服二部' }
      ]
    },
    { id: 4, pid: 1, label: '业务部' }
  ]
})
const keyword = ref('')
const horizontal = ref(false)
const collapsable = ref(true)
const onlyOneNode = ref(true)
const cloneNodeDrag = ref(true)
const expandAll = ref(true)
const style = ref({
  background: '#fff',
  color: '#5e6d82'
})
 
const onMenus = ({ node, command }) => {
  console.log(node, command)
}
const restore = () => {
  console.log('restore')
}
const filter = () => {
  treeRef.value.filter(keyword.value)
}
const filterNodeMethod = (value, data) => {
  console.log(value, data)
  if (!value) return true
  return data.label.indexOf(value) !== -1
}
const onNodeClick = (e, data) => {
  ElMessage.info(data.label)
}
const expandChange = () => {
  toggleExpand(data.value, expandAll.value)
}
</script>
<style lang="scss" scoped>
.tree-wrap {
  position: relative;
  padding-top: 52px;
}
.search-box {
  padding: 8px 15px;
  position: absolute;
  top: 0;
  left: 0;
  input {
    width: 200px;
    height: 32px;
    border: 1px solid #ddd;
    outline: none;
    border-radius: 5px;
    padding-left: 10px;
  }
}
.tree-org-node__text {
  text-align: left;
  font-size: 14px;
  .custom-content {
    padding-bottom: 8px;
    margin-bottom: 8px;
    border-bottom: 1px solid currentColor;
  }
}

效果图为:

image.png

如果需要自定义,可以这样使用

<template v-slot="{node}">
    <div class="tree-org-node__text node-label">
        <div class="custom-content">自定义内容</div>
        <div>节点ID:{{node.id}}</div>
        <div>节点名称:{{node.label}}</div>
    </div>
</template>

注意:

  1. 这样只能只能取id和label
  2. 如果你有其他的,如createTime,gross这些额外的字段,在使用node.createTime,或node.gross,将不会生效,你需要使用$$data字段进行解析
  3. 由来$$data::render-content函数进行渲染打印,你会得到:
<template>
    <vue3-tree-org 
        :render-content="renderContent"
    >
    </vue3-tree-org>
</template>

const renderContent = (h: any, node: any) => {
  console.log(node, '11111111111111111')
}

image.png

  1. 此时你就可以这样使用:
<template v-slot="{node}"> 
    <div class="tree-org-node__text node-label">
        <div class="custom-content">自定义内容</div> 
        <div>节点ID:{{node.$$data.id}}</div> 
        <div>节点名称:{{node.$$data.label}}</div>
        <div>节点时间:{{node.$$data.createTime}}</div>
        <div>节点增长:{{node.$$data.gross}}</div>
    </div> 
</template>

注意:如果你在使用renderContent函数进行数据渲染打印时,控制台无输出,请暂时删掉template模版内所有内容后重试,原因如官网所示:

image.png

项目中使用(完整代码)

<template>
  <div class="tree-wrap">
    <vue3-tree-org
      center
      ref="treeRef"
      :data="treeData"
      :horizontal="horizontal"
      :collapsable="collapsable"
      :label-style="style"
      :node-draggable="true"
      :scalable="scalable"
      :only-one-node="onlyOneNode"
      :default-expand-level="1"
      :clone-node-drag="cloneNodeDrag"
      :before-drag-end="beforeDragEnd"
    >
    <!-- 自定义节点内容,实现可配置 -->
    <template v-slot="{node}">
        <div class="tree-org-node__text">
          <div class="mb12">提煤计划号:{{ node.$$data.no || '--' }}</div>
          <div class="mb12 myb-cursor-pointer">
            <span class="no-cursor-pointer">转发张数:{{node.$$data.num || 0}}</span>
            <span class="ml15" @click="getClick('4', node.$$data.id)">接单张数:<span class="c409eff">{{node.$$data.receive || 0}}</span></span>
            <span class="ml15" @click="getClick('7', node.$$data.id)">过空张数:<span class="c409eff">{{node.$$data.tare || 0}}</span></span>
            <span class="ml15" @click="getClick('8', node.$$data.id)">过重张数:<span class="c409eff">{{node.$$data.gross || 0}}</span></span>
            <span class="ml15" @click="getClick('9', node.$$data.id)">作废张数:<span class="c409eff">{{node.$$data.cancel || 0}}</span></span>
          </div>
          <div class="mb5 box">
            <span class="myb-ellipsis-1">转出方:{{ node.$$data.partyBname || '--' }}</span>
            <span class="ml15">转出时间:{{ formatTime(node.$$data.createTime, node.$$data.partyBname) }}</span>
          </div>
          <div class="mb5 box">
            <span class="myb-ellipsis-1">转入方:{{ node.$$data.preName || '--' }}</span>
            <span class="ml15">转入时间:{{ formatTime(node.$$data.createTime, node.$$data.preName) }}</span>
          </div>
        </div>
      </template>
      <!-- 节点展开数量 -->
      <!-- <template v-slot:expand="{node}">
        <div>{{node.children.length}}</div>
      </template> -->
    </vue3-tree-org>
  </div>
</template>

<script setup lang="ts">
import moment from 'moment'
import { ref, onBeforeMount, h } from 'vue'
import { coalPlanTreeDetail, statByCoalPlan } from "@/service-api/coalDeliveryNote";

const props = defineProps({
 coalPlanId: {
    // 提煤计划id
    type: [String, Number],
    default: "",
  },
});

const treeData = ref({})
const scalable = ref(false) // 是否可缩放
const horizontal = ref(false) // 是否水平布局
const collapsable = ref(true) // 是否可折叠
const onlyOneNode = ref(true) // 是否仅拖动当前节点,如果true,仅拖动当前节点,子节点自动添加到当前节点父节点,如果false,则当前节点及子节点一起拖动
const cloneNodeDrag = ref(true)  // 是否拷贝节点拖拽
// tree整体样式配置
const style = ref({
  background: '#fff',
  color: '#606266'
})

// 递归获取所有节点ID
const getAllNodeIds = (node: any): number[] => {
  let ids: number[] = [];
  if (node.id) {
    ids.push(node.id);
  }
  if (node.children && node.children.length > 0) {
    node.children.forEach((child: any) => {
      ids = ids.concat(getAllNodeIds(child));
    });
  }
  return ids;
};

// 递归更新节点数据
const updateNodeData = (node: any, statDataMap: Map<number, any>) => {
  if (node.id !== undefined && statDataMap.has(node.id)) {
    const statData = statDataMap.get(node.id);
    node.receive = statData.receive;
    node.tare = statData.tare;
    node.gross = statData.gross;
    node.cancel = statData.cancel;
  }
  if (node.children && node.children.length > 0) {
    node.children.forEach((child: any) => {
      updateNodeData(child, statDataMap);
    });
  }
};

const getTreeData = async () => {
  const { data } = await coalPlanTreeDetail({
    // id: props.coalPlanId
    id: 35
  });

  // 注意:因为本项目中的接单张数,过空张数,过重张数,作废张数,是在接口请求之后,在同步请求statByCoalPlan接口,将数据同步到节点数据中,如果你的项目部需要这步骤,则直接用下面代码:
  if (data) {
    // 获取所有节点ID
    const allIds = getAllNodeIds(data);
    const statDataMap = new Map<number, any>();
    // 并行请求所有统计数据
    const promises = allIds.map(id => statByCoalPlan({ coalPlanId: id }));
    const results = await Promise.all(promises);
    // 将结果存入映射
    results.forEach((result, index) => {
      if (result.data) {
        statDataMap.set(allIds[index], result.data);
      }
    });
    // 更新节点数据
    updateNodeData(data, statDataMap);
    treeData.value = data;
  }

  // 如果不需要这个步骤,则直接使用下面代码:
  treeData.value = data;
};

const beforeDragEnd = (node: any, targetNode: any) => {
  return new Promise<void>((resolve, reject) => {
    if (!targetNode) reject()
    if (node.id === targetNode.id) {
      reject()
    } else {
      resolve()
    }
  })
};

const emit = defineEmits(['handleSwitchTab'])
const getClick = (type: string, id: string) => {
  emit('handleSwitchTab', type, id)
};

const formatTime = (time: string, name: string) => {
  return time && name ? moment(time).format("YYYY-MM-DD HH:mm:ss") : '--';
};

onBeforeMount(() => {
  getTreeData();
});

</script>

<style lang="scss" scoped>
.tree-wrap {
  height: 500px;
  position: relative;
  :deep(.zm-tree-org) {
    padding: 15px 0 0 0;
    .zoom-container {
      overflow: auto; // 在允许视图滚动的同时,影藏未满足滚动时的滚动槽 
      .tree-org>.tree-org-node {
        padding: 3px 0 10px 0;  // tree-org 组件节点间距调整
        .tree-org-node__children {
          display: flex;
        }
      }
      .tree-org-node {
        flex-shrink: 0; // 防止盒子被压缩
        .tree-org-node__text {
          text-align: left;
          .myb-ellipsis-1 {
            max-width: 370px;
            display: inline-block;
          }
          .no-cursor-pointer {
            cursor: default;
          }
        }
      }
    }
  }
}
// 影藏放大图标
:deep(.zoom-out) {
  display: none;
}
// 影藏缩小图标
:deep(.zoom-in) {
  display: none;
}
</style>

END...

Vue3图片放大镜从原理到实现,电商级细节展示方案

作者 刘大华
2025年11月17日 10:20

大家好!今天分享一个非常实用的前端功能:图片放大镜效果。这个效果在电商网站、图片展示平台中非常常见,比如查看商品细节时特别好用。

效果预览

先来看看最终实现的效果:

在这里插入图片描述

  • 鼠标移动到图片上会出现一个放大镜框
  • 右侧会显示放大后的局部细节
  • 支持自定义放大倍数、镜片大小和放大区域尺寸
  • 实现了像素级精准的放大效果
  • 带有详细的调试信息展示

核心原理

图片放大镜效果的核心原理其实很简单:通过计算鼠标位置,在原始图片上确定一个查看区域,然后将这个区域按比例放大显示

听起来简单,但实现起来有几个关键点需要特别注意:

1.坐标映射:如何将鼠标在显示图片上的位置,精确映射到原始图片上的对应位置 2.比例计算:处理图片原始尺寸和显示尺寸之间的比例关系 3.边界处理:确保放大镜不会跑出图片范围 4.性能优化:保证交互的流畅性

代码实现详解

HTML 结构

<div class="magnifier-container">
  <!-- 原始图片区域 -->
  <div class="image-section">
    <div class="original-image-container" 
         @mousemove="handleMouseMove" 
         @mouseleave="isVisible = false"
         @mouseenter="isVisible = true">
      <img ref="originalImage" src="图片地址" @load="handleImageLoad" />
      <div class="zoom-lens" :style="镜片样式"></div>
    </div>
  </div>
  
  <!-- 放大区域 -->
  <div class="zoomed-section">
    <div class="zoomed-image-container">
      <div v-if="!isVisible" class="placeholder">
        <p>将鼠标悬停在左侧图片上查看放大效果</p>
      </div>
      <div v-else class="zoomed-image" :style="放大图片样式"></div>
    </div>
  </div>
</div>

这个结构分为两个主要部分:

  • 左侧是原始图片和跟随鼠标的放大镜镜片
  • 右侧是放大后的图片显示区域

Vue3 响应式数据

setup() {
  // 图片相关引用和尺寸数据
  const originalImage = ref(null);
  const originalWidth = ref(0);   // 图片原始宽度
  const originalHeight = ref(0);  // 图片原始高度
  const displayWidth = ref(0);    // 图片显示宽度
  const displayHeight = ref(0);   // 图片显示高度
  
  // 放大镜状态
  const lensPosition = ref({ x: 0, y: 0 });  // 镜片位置
  const isVisible = ref(false);              // 是否显示放大镜
  const imageLoaded = ref(false);            // 图片是否加载完成
  
  // 配置参数
  const lensSize = ref(150);     // 镜片大小
  const zoomedSize = ref(400);   // 放大区域大小
  const zoomLevel = ref(2);      // 放大倍数
}

关键技术点解析

1. 比例计算

这是整个功能最核心的部分!当图片在网页上显示时,它的显示尺寸可能不等于原始尺寸(比如响应式布局中图片会自适应容器大小)。我们需要精确计算这个比例关系:

const scaleX = computed(() => {
  if (!imageLoaded.value) return 1;
  return originalWidth.value / displayWidth.value;
});

const scaleY = computed(() => {
  if (!imageLoaded.value) return 1;
  return originalHeight.value / displayHeight.value;
});

举个例子:

  • 如果图片原始宽度是 1200px,显示宽度是 600px
  • 那么 scaleX 就是 2,意味着显示图片上的 1 像素对应原始图片的 2 像素

2. 鼠标位置追踪

const handleMouseMove = (e) => {
  if (!originalImage.value || !imageLoaded.value) return;
  
  // 获取图片相对于视口的位置
  const rect = originalImage.value.getBoundingClientRect();
  
  // 计算鼠标在图片内的相对位置
  const mouseX = e.clientX - rect.left;
  const mouseY = e.clientY - rect.top;
  
  // 计算镜片位置(让镜片中心对准鼠标)
  let x = mouseX - lensSize.value / 2;
  let y = mouseY - lensSize.value / 2;
  
  // 边界限制,防止镜片跑出图片外
  const maxX = Math.max(0, displayWidth.value - lensSize.value);
  const maxY = Math.max(0, displayHeight.value - lensSize.value);
  
  x = Math.max(0, Math.min(x, maxX));
  y = Math.max(0, Math.min(y, maxY));
  
  lensPosition.value = { 
    x: Math.round(x * 1000) / 1000,  // 高精度数值
    y: Math.round(y * 1000) / 1000 
  };
};

3. 原始图片位置计算

有了鼠标在显示图片上的位置,我们需要找到它在原始图片上的对应位置:

const originalX = computed(() => {
  if (!imageLoaded.value) return 0;
  // 计算镜片中心在显示图片上的位置
  const lensCenterX = lensPosition.value.x + lensSize.value / 2;
  // 映射到原始图片上的位置
  const pos = lensCenterX * scaleX.value;
  return Math.max(0, Math.min(pos, originalWidth.value));
});

4. 放大区域背景定位

这是实现放大效果的关键:我们通过 CSS 的 background-position 来移动背景图片,创造出放大效果:

const backgroundPosition = computed(() => {
  if (!imageLoaded.value) return { x: 0, y: 0 };
  
  // 计算在放大视图中的目标中心点
  const targetCenterX = originalX.value * zoomLevel.value;
  const targetCenterY = originalY.value * zoomLevel.value;
  
  // 计算背景位置,使目标点出现在放大区域中心
  let bgX = targetCenterX - zoomedSize.value / 2;
  let bgY = targetCenterY - zoomedSize.value / 2;
  
  // 边界处理
  const maxBgX = Math.max(0, originalWidth.value * zoomLevel.value - zoomedSize.value);
  const maxBgY = Math.max(0, originalHeight.value * zoomLevel.value - zoomedSize.value);
  
  bgX = Math.max(0, Math.min(bgX, maxBgX));
  bgY = Math.max(0, Math.min(bgY, maxBgY));
  
  return { x: bgX, y: bgY };
});

const getZoomedImageStyle = () => {
  const bgSize = `${originalWidth.value * zoomLevel.value}px ${originalHeight.value * zoomLevel.value}px`;
  const bgPosition = `-${backgroundPosition.value.x}px -${backgroundPosition.value.y}px`;
  
  return {
    backgroundImage: `url(${imageUrl.value})`,
    backgroundSize: bgSize,        // 设置背景图片大小为放大后的尺寸
    backgroundPosition: bgPosition, // 移动背景图片来显示正确区域
    transform: `translateZ(0)`,    // 开启硬件加速,提高性能
  };
};

图片加载处理

我们需要在图片完全加载后获取其真实尺寸:

const handleImageLoad = () => {
  originalWidth.value = originalImage.value.naturalWidth;
  originalHeight.value = originalImage.value.naturalHeight;
  displayWidth.value = originalImage.value.clientWidth;
  displayHeight.value = originalImage.value.clientHeight;
  imageLoaded.value = true;
};

样式设计要点

镜片样式

.zoom-lens {
  position: absolute;
  border: 2px solid white;
  background-color: rgba(52, 152, 219, 0.2);  /* 半透明蓝色 */
  box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);    /* 阴影增强视觉效果 */
  pointer-events: none;  /* 重要!防止镜片干扰鼠标事件 */
  z-index: 10;
}

放大区域样式

.zoomed-image-container {
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
  background: #f8f9fa;  /* 默认背景色 */
  height: 400px;
  display: flex;
  align-items: center;
  justify-content: center;
}

调试和优化技巧

我们的实现中包含了一个实用的调试面板,可以实时显示各种计算数据:

  • 比例因子:显示原始图片与显示图片的尺寸比例
  • 位置信息:显示鼠标位置、镜片位置和背景位置
  • 计算精度:评估坐标映射的准确度
  • 像素偏差:显示实际位置与理想位置的偏差

这些调试信息在开发过程中非常有用,可以帮助我们快速定位问题。

常见问题及解决方案

1. 图片闪烁或跳动

原因:计算精度不够或边界处理不当 解决:使用更高精度的计算(我们代码中使用了三位小数),并仔细处理所有边界情况

2. 性能问题

原因:mousemove 事件触发频率很高 解决

  • 使用 Vue 的响应式系统,它已经做了优化
  • 避免在 mousemove 中执行复杂操作
  • 使用 transform: translateZ(0) 开启硬件加速

3. 图片加载问题

原因:在图片加载完成前就进行计算 解决:使用 @load 事件确保图片完全加载后再初始化功能

总结

通过这篇文章,我们不仅实现了一个功能完整的图片放大镜效果,还深入理解了其背后的原理和实现细节。关键点在于:

  • 精确的坐标映射和比例计算
  • 完善的边界处理
  • 利用 CSS 背景定位实现放大效果
  • 良好的用户体验和性能优化

希望这篇文章对你有帮助!如果你有任何问题或建议,欢迎在评论区留言讨论。

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue3 图片放大镜效果</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <style>
    body {
      padding-top: 20px;
    }
    .container {
      max-width: 1200px;
      margin: 0 auto;
    }
    header {
      text-align: center;
      margin-bottom: 30px;
    }
    h1 {
      color: #2c3e50;
      margin-bottom: 10px;
      font-size: 2.2rem;
    }
    .magnifier-app {
      background: white;
      border-radius: 12px;
      box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
      padding: 25px;
      margin-bottom: 30px;
    }
    .config-info {
      text-align: center;
      margin-bottom: 25px;
      padding: 15px;
      background: #f8f9fa;
      border-radius: 8px;
      color: #495057;
    }
    .magnifier-container {
      display: flex;
      flex-wrap: wrap;
      gap: 30px;
      justify-content: center;
    }
    .image-section {
      flex: 1;
      min-width: 300px;
    }
    .original-image-container {
      position: relative;
      cursor: crosshair;
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
    }
    .original-image-container img {
      display: block;
      width: 100%;
      height: auto;
    }
    .zoom-lens {
      position: absolute;
      border: 2px solid white;
      background-color: rgba(52, 152, 219, 0.2);
      box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
      pointer-events: none;
      z-index: 10;
    }
    .zoomed-section {
      flex: 1;
      min-width: 300px;
    }
    .zoomed-image-container {
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
      background: #f8f9fa;
      height: 400px;
      display: flex;
      align-items: center;
      justify-content: center;
      position: relative;
    }
    .zoomed-image {
      width: 100%;
      height: 100%;
      background-repeat: no-repeat;
      image-rendering: -webkit-optimize-contrast;
      image-rendering: crisp-edges;
    }
    .placeholder {
      color: #7f8c8d;
      text-align: center;
      padding: 20px;
    }
    .instructions {
      text-align: center;
      margin-top: 20px;
      color: #7f8c8d;
      font-style: italic;
    }
    .status {
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
      gap: 20px;
      margin-top: 15px;
      font-size: 0.9rem;
      color: #7f8c8d;
    }
    .debug-info {
      background: #f8f9fa;
      padding: 15px;
      border-radius: 8px;
      margin-top: 20px;
      font-family: monospace;
      font-size: 0.85rem;
    }
    .pixel-grid {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-image: 
        linear-gradient(rgba(0,0,0,0.1) 1px, transparent 1px),
        linear-gradient(90deg, rgba(0,0,0,0.1) 1px, transparent 1px);
      background-size: 10px 10px;
      pointer-events: none;
      opacity: 0.3;
    }
    
    @media (max-width: 768px) {
      .magnifier-container {
        flex-direction: column;
      }
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="container">
      <header>
        <h1>Vue3 图片放大镜效果</h1>
      </header>
      
      <div class="magnifier-app">
        <div class="config-info">
          <p>当前配置:镜片大小 {{ lensSize }}px | 放大区域 {{ zoomedSize }}px | 放大倍数 {{ zoomLevel }}x</p>
        </div>
        
        <div class="magnifier-container">
          <div class="image-section">
            <div class="original-image-container" 
                 @mousemove="handleMouseMove" 
                 @mouseleave="isVisible = false"
                 @mouseenter="isVisible = true">
              <img 
                ref="originalImage" 
                src="https://picsum.photos/600/400" 
                alt="Original Image" 
                @load="handleImageLoad"
              />
              <div 
                class="zoom-lens" 
                :style="{
                  width: lensSize + 'px',
                  height: lensSize + 'px',
                  left: lensPosition.x + 'px',
                  top: lensPosition.y + 'px',
                  display: isVisible && imageLoaded ? 'block' : 'none'
                }"
              ></div>
            </div>
          </div>
          
          <div class="zoomed-section">
            <div 
              class="zoomed-image-container" 
              :style="{
                width: zoomedSize + 'px',
                height: zoomedSize + 'px'
              }"
            >
              <div v-if="!isVisible || !imageLoaded" class="placeholder">
                <p>将鼠标悬停在左侧图片上查看放大效果</p>
              </div>
              <div 
                v-else
                class="zoomed-image" 
                :style="getZoomedImageStyle()"
              ></div>
              <div class="pixel-grid" v-if="isVisible && imageLoaded"></div>
            </div>
          </div>
        </div>
        
        <div class="status">
          <div>图片原始尺寸: {{ originalWidth }} × {{ originalHeight }}px</div>
          <div>图片显示尺寸: {{ displayWidth }} × {{ displayHeight }}px</div>
          <div>放大镜位置: X:{{ Math.round(lensPosition.x * 1000) / 1000 }}, Y:{{ Math.round(lensPosition.y * 1000) / 1000 }}</div>
        </div>
        
        <div class="debug-info" v-if="imageLoaded">
          <div>比例因子: X={{ scaleX.toFixed(8) }}, Y={{ scaleY.toFixed(8) }}</div>
          <div>原始图片位置: X={{ Math.round(originalX * 1000) / 1000 }}, Y={{ Math.round(originalY * 1000) / 1000 }}</div>
          <div>背景位置: X:{{ Math.round(backgroundPosition.x * 1000) / 1000 }}, Y:{{ Math.round(backgroundPosition.y * 1000) / 1000 }}</div>
          <div>计算精度: {{ (calculationAccuracy * 100).toFixed(6) }}%</div>
          <div>像素偏差: X:{{ Math.abs(pixelDeviation.x).toFixed(3) }}px, Y:{{ Math.abs(pixelDeviation.y).toFixed(3) }}px</div>
        </div>
      </div>
    </div>
  </div>

  <script>
    const { createApp, ref, computed } = Vue;
    
    createApp({
      setup() {
        const originalImage = ref(null);
        const originalWidth = ref(0);
        const originalHeight = ref(0);
        const displayWidth = ref(0);
        const displayHeight = ref(0);
        const lensPosition = ref({ x: 0, y: 0 });
        const isVisible = ref(false);
        const imageLoaded = ref(false);
        const imageUrl = ref('https://picsum.photos/600/400');
        
        // 使用最精准的默认参数
        const lensSize = ref(150);
        const zoomedSize = ref(400);
        const zoomLevel = ref(2);

        // 计算比例因子 - 使用超高精度计算
        const scaleX = computed(() => {
          if (!imageLoaded.value) return 1;
          const scale = originalWidth.value / displayWidth.value;
          return scale;
        });
        
        const scaleY = computed(() => {
          if (!imageLoaded.value) return 1;
          const scale = originalHeight.value / displayHeight.value;
          return scale;
        });

        // 计算原始图片上的精确位置 - 超高精度版本
        const originalX = computed(() => {
          if (!imageLoaded.value) return 0;
          const lensCenterX = lensPosition.value.x + lensSize.value / 2;
          const pos = lensCenterX * scaleX.value;
          return Math.max(0, Math.min(pos, originalWidth.value));
        });
        
        const originalY = computed(() => {
          if (!imageLoaded.value) return 0;
          const lensCenterY = lensPosition.value.y + lensSize.value / 2;
          const pos = lensCenterY * scaleY.value;
          return Math.max(0, Math.min(pos, originalHeight.value));
        });

        // 像素级偏差计算
        const pixelDeviation = computed(() => {
          if (!imageLoaded.value) return { x: 0, y: 0 };
          
          // 计算理论上的完美位置
          const perfectBgX = originalX.value * zoomLevel.value - zoomedSize.value / 2;
          const perfectBgY = originalY.value * zoomLevel.value - zoomedSize.value / 2;
          
          return {
            x: backgroundPosition.value.x - perfectBgX,
            y: backgroundPosition.value.y - perfectBgY
          };
        });

        // 计算精度评估 - 更严格的评估标准
        const calculationAccuracy = computed(() => {
          if (!imageLoaded.value) return 0;
          
          const maxDeviation = Math.max(zoomedSize.value * 0.01, 2); // 允许1%或2像素的偏差
          const xAccuracy = Math.max(0, 1 - Math.abs(pixelDeviation.value.x) / maxDeviation);
          const yAccuracy = Math.max(0, 1 - Math.abs(pixelDeviation.value.y) / maxDeviation);
          
          return (xAccuracy + yAccuracy) / 2;
        });

        // 超精准背景位置计算算法
        const backgroundPosition = computed(() => {
          if (!imageLoaded.value) return { x: 0, y: 0 };
          
          // 核心算法:确保像素级精确对应
          const targetCenterX = originalX.value * zoomLevel.value;
          const targetCenterY = originalY.value * zoomLevel.value;
          
          // 计算背景位置,使放大区域中心精确显示目标位置
          let bgX = targetCenterX - zoomedSize.value / 2;
          let bgY = targetCenterY - zoomedSize.value / 2;
          
          // 精确的边界处理
          const maxBgX = Math.max(0, originalWidth.value * zoomLevel.value - zoomedSize.value);
          const maxBgY = Math.max(0, originalHeight.value * zoomLevel.value - zoomedSize.value);
          
          // 使用更精确的边界检查
          bgX = Math.max(0, Math.min(bgX, maxBgX));
          bgY = Math.max(0, Math.min(bgY, maxBgY));
          
          // 强制像素对齐 - 消除亚像素渲染问题
          bgX = Math.round(bgX * 1000) / 1000;
          bgY = Math.round(bgY * 1000) / 1000;
          
          return { x: bgX, y: bgY };
        });

        const handleImageLoad = () => {
          originalWidth.value = originalImage.value.naturalWidth;
          originalHeight.value = originalImage.value.naturalHeight;
          displayWidth.value = originalImage.value.clientWidth;
          displayHeight.value = originalImage.value.clientHeight;
          imageLoaded.value = true;
          
          console.log('=== 超高精度图片加载信息 ===');
          console.log('原始尺寸:', `${originalWidth.value}x${originalHeight.value}`);
          console.log('显示尺寸:', `${displayWidth.value}x${displayHeight.value}`);
          console.log('比例因子:', `X=${scaleX.value.toFixed(8)}, Y=${scaleY.value.toFixed(8)}`);
        };

        const handleMouseMove = (e) => {
          if (!originalImage.value || !imageLoaded.value) return;
          
          const rect = originalImage.value.getBoundingClientRect();
          
          // 超高精度的鼠标位置计算
          const mouseX = e.clientX - rect.left;
          const mouseY = e.clientY - rect.top;
          
          // 计算放大镜位置(中心对齐)
          let x = mouseX - lensSize.value / 2;
          let y = mouseY - lensSize.value / 2;
          
          // 精确的边界限制
          const maxX = Math.max(0, displayWidth.value - lensSize.value);
          const maxY = Math.max(0, displayHeight.value - lensSize.value);
          
          x = Math.max(0, Math.min(x, maxX));
          y = Math.max(0, Math.min(y, maxY));
          
          // 使用更高精度的数值
          lensPosition.value = { 
            x: Math.round(x * 1000) / 1000, 
            y: Math.round(y * 1000) / 1000 
          };
        };

        const getZoomedImageStyle = () => {
          const bgSize = `${originalWidth.value * zoomLevel.value}px ${originalHeight.value * zoomLevel.value}px`;
          const bgPosition = `-${backgroundPosition.value.x}px -${backgroundPosition.value.y}px`;
          
          return {
            backgroundImage: `url(${imageUrl.value})`,
            backgroundSize: bgSize,
            backgroundPosition: bgPosition,
            transform: `translateZ(0)`, // 硬件加速
            backgroundOrigin: 'border-box'
          };
        };

        return {
          originalImage,
          originalWidth,
          originalHeight,
          displayWidth,
          displayHeight,
          lensPosition,
          backgroundPosition,
          isVisible,
          imageLoaded,
          imageUrl,
          lensSize,
          zoomedSize,
          zoomLevel,
          scaleX,
          scaleY,
          originalX,
          originalY,
          calculationAccuracy,
          pixelDeviation,
          handleImageLoad,
          handleMouseMove,
          getZoomedImageStyle
        };
      }
    }).mount('#app');
  </script>
</body>
</html>

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《MySQL 为什么不推荐用雪花ID 和 UUID 做主键?》

《Vue3 + Element Plus 动态菜单实现:一套代码完美适配多角色权限系统》

《SpringBoot+Vue3 整合 SSE 实现实时消息推送》

Unibest开发避坑指南:20+常见问题与解决方案

作者 宇余
2025年11月17日 09:14

作为基于UniApp + Vue3 + TypeScript的明星开发框架,Unibest凭借其开箱即用的工程化配置、丰富的内置组件和跨端能力,成为越来越多开发者的首选。但在实际开发中,从环境搭建到多端编译,难免会遇到各类"拦路虎"。本文整理了Unibest开发中最高频的20+问题,涵盖环境配置、编译构建、多平台兼容等核心场景,附带详细解决方案和原理分析,帮你少走弯路,专注业务开发。

一、环境配置篇:打好基础是关键

环境问题往往是开发的第一道门槛,版本不兼容、依赖安装失败等问题频繁出现,掌握以下解决方案能让你快速破局。

1. Node.js版本不兼容导致启动失败

症状:运行pnpm dev时出现Error: Cannot find module或版本警告,甚至直接闪退。

解决方案:Unibest对Node.js版本有明确要求,推荐使用18.x版本。通过版本管理工具快速切换:


# 检查当前Node版本
node -v 
# 使用nvm管理Node版本(推荐)
nvm install 18
nvm use 18 
# 或者使用fnm
fnm use 18

原理分析:Vite5依赖Node.js 18+的特性,而Unibest基于Vite5构建,低版本Node会导致依赖解析失败。

2. pnpm安装依赖超时或权限错误

症状:执行pnpm i时出现网络超时、403权限错误或依赖下载不完整。

解决方案:切换国内镜像源并清理缓存:


# 使用国内镜像源
pnpm config set registry https://registry.npmmirror.com/ 
# 清除缓存重新安装
pnpm store prune
pnpm install --force 
# 备选方案:使用cnpm
npm install -g cnpm --registry=https://registry.npmmirror.com
cnpm install

二、编译构建篇:解决工程化痛点

Unibest采用自动生成配置文件的设计,这在提升开发效率的同时,也带来了一些配置认知差异问题。

1. pages.json/manifest.json手动修改被覆盖

症状:手动修改pages.jsonmanifest.json后,重新编译文件内容被清空或重置。

解决方案:Unibest通过插件自动生成这两个文件,需在对应TS配置文件中修改:

  • pages.json → 全局配置在pages.config.ts,页面路由在Vue文件的route-block中配置

  • manifest.json → 修改manifest.config.ts文件


<!-- 页面路由配置示例 -->
<route lang="json">
{
  "path": "/pages/index",
  "style": {
    "navigationBarTitleText": "首页"
  }
}
</route>

2. 首次运行pnpm:mp报错缺少manifest.json

症状:执行pnpm:mp时出现Error: ENOENT: no such file or directory, open 'src/manifest.json'

解决方案:首次运行非H5端需先执行依赖安装命令生成配置文件:


pnpm i # 生成manifest.json
pnpm:mp # 再次运行小程序编译

3. Vite热更新失效

症状:修改代码后页面不自动刷新,需手动重启服务。

解决方案:检查端口占用或更换端口:


# 检查9000端口是否被占用并杀死进程
lsof -ti:9000 | xargs kill -9 
# 更换端口运行
VITE_APP_PORT=9001 pnpm dev:h5

三、多平台兼容篇:跨端开发不再头疼

Unibest主打跨端能力,但不同平台的差异性仍会导致各种兼容问题,以下是小程序和App端的高频问题。

1. 支付宝小程序运行报错

症状:支付宝开发者工具中运行报错,提示ES5转译相关错误。

解决方案:开启"本地开发跳过ES5转译"选项:

  1. 打开支付宝开发者工具

  2. 进入项目设置 → 本地设置

  3. 勾选"本地开发跳过ES5转译"选项

  4. 重新编译项目

2. 微信小程序编译报错

症状:提示"找不到页面"或路由配置错误。

解决方案:检查分包配置和首页设置:


// vite.config.ts中分包配置
UniPages({
  exclude: ('**/components/**/**.*'),
  subPackages: ('src/pages-sub'), // 分包目录,支持数组配置多个
}),

设置首页:在目标Vue文件的route-block中添加"type": "home",确保项目中只有一个首页配置。

3. App平台打包失败

症状:执行pnpm build:app时出现证书错误或配置缺失。

解决方案

  • 检查manifest.config.ts中的AppID和证书配置

  • 清理缓存重新构建:


rm -rf dist/build/app
pnpm build:app

四、进阶问题篇:TypeScript与依赖管理

Unibest强推TypeScript开发,类型问题和依赖冲突也是开发者常遇的难点。

1. TypeScript类型找不到模块声明

症状vue-tsc类型检查失败,提示"Could not find a declaration file for module"。

解决方案:在tsconfig.json中添加类型声明:


{
  "compilerOptions": {
    "types": (
      "@dcloudio/types",
      "@uni-helper/uni-types",
      "unplugin-auto-import/types"
    )
  }
}

2. 依赖版本冲突

症状pnpm install时出现版本冲突警告,或运行时出现"Cannot read property of undefined"。

解决方案:使用resolutions字段强制指定版本:


{
  "resolutions": {
    "vue": "3.4.21",
    "pinia": "2.0.36"
  }
}

查看冲突依赖:pnpm why <package-name>

五、实用技巧总结

  • 使用import.meta.env替代process.env获取环境变量

  • 升级UniApp:执行npx @dcloudio/uvm@latest

  • 跳过git提交校验:git commit -m "feat: xxx" --no-verify,或删除.husky文件夹

  • 多平台适配用条件编译:#ifdef MP-WEIXIN ... #endif

Unibest社区和官方文档会持续更新问题解决方案,建议收藏官方FAQ页面,并关注GitHub仓库获取最新动态。如果遇到本文未覆盖的问题,欢迎在评论区留言交流,一起完善这份避坑指南!

Vue的响应式系统是怎么实现的

作者 程序员ys
2025年11月16日 22:31

Vue的响应式系统是其核心特性之一,通过数据劫持完成依赖收集和触发更新,实现了数据变化时自动更新视图的功能。

我们自然地联想到观察者模式:当一个对象的状态发生改变时,所有依赖它的对象都得到通知并自动更新

其中,数据是被观察者(subject),视图是观察者(observer),视图对象被注册到它依赖的数据对象,当数据变更时会调用视图对象的更新函数。

一、Vue 2 的响应式系统(基于  Object.defineProperty )

Vue2的响应式系统是基于 Object.defineProperty 实现的,通过修改对象属性行为,劫持对data对象属性的读写操作,完成依赖收集和触发更新,从而实现响应式的功能。

1.数据劫持

Vue 2 使用Object.defineProperty修改对象属性行为,劫持data对象属性的读写操作:

  • Getter:当访问data对象属性时,收集依赖该属性的视图对象。
  • Setter:当修改data对象属性值时,通知依赖该属性的视图对象重新渲染。

2.依赖收集

  • 每个data属性维护一个依赖列表,存放依赖该属性的视图对象。
  • 当data属性被读取时(组件渲染、watch),触发Getter。
  • Getter将当前的视图对象添加到依赖列表中。

3.触发更新

  • 当属性值被修改时,Setter会通知依赖该属性的视图重新渲染。

4.核心代码

// 修改对象属性行为,劫持data对象属性的读写操作
function observe(obj) { 
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key]);
  });
});

function defineReactive(obj, key, val) {
  const dep = new Dep(); // 依赖管理器
  
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      if (Dep.target) {
        dep.addSub(Dep.target);
      }
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 触发更新
      dep.notify();
    }
  });
}
// 依赖管理器
class Dep {
  constructor() {
    this.subs = [];
  }
  
  addSub(sub) {
    this.subs.push(sub);
  }
  
  notify() {
    this.subs.forEach(sub => sub.update());
  }
}

Dep.target = null; // 当前正在渲染的Watcher

// 视图对象
class Watcher {
  constructor(vm, expOrFn,cb) {
    this.vm = vm;
    this.getter = expOrFn;  // 渲染函数
    this.cb = cb;
    this.value = this.get();
  }
  
  get() {
    Dep.target = this;
    const value = this.getter.call(this.vm);
    Dep.target = null;
    return value;
  }
  
  update() {
    const oldValue = this.value;
    this.value = this.get();
    this.cb.call(this.vm, this.value, oldValue);
  }
}

5. Object.defineProperty的不足

Object.defineProperty只能劫持对象属性,无法劫持整个对象,这是其不足之处的根本原因。

  • 动态添加的属性不具有响应式

Object.defineProperty只能劫持现有的对象属性,这意味在初始化data对象时,如果某个属性不存在,后续动态添加的属性将不具有响应式功能。

const vm = new Vue({ data: { obj: { a: 1 } } });
vm.obj.b = 2; // ❌ 新增属性b,非响应式
delete vm.obj.a; // ❌ 删除属性a,非响应式

// 必须使用 Vue.set/Vue.delete
Vue.set(vm.user, 'gender', 'male')
Vue.delete(vm.user, 'age')

在动态表单场景中,新增或删除表单字段时,无法实现响应式。

  • 无法监听数组索引赋值和数组长度的变化
vm.items[0] = 'hi'; // ❌ 索引赋值,非响应式
vm.items.length = 0; // ❌ 修改数组长度,非响应式

// 必须使用vue方法或 Vue.set
vm.list.splice(0, 1, 999)  // 正确方式
Vue.set(vm.list, 0, 999)   // 正确方式
  • 初始化性能

在初始化Vue实例的时候,会递归遍历所有 data 属性,为每个属性设置 getter 和 setter。当处理大对象或深层嵌套结构时会带来严重的性能问题。

  • 内存占用

每个data属性都有一个依赖列表,占用了大量的内存。

二、Vue 3的响应式系统(基于Proxy)

Vue3 的响应式系统是基于 ES6 的Proxy API 实现的,相比 Vue2 的Object.defineProperty,它实现了更彻底的响应式能力。其核心原理可以概括为:通过 Proxy 代理目标对象,劫持对象的读、写、删除、添加属性等操作,完成依赖收集和触发更新,从而实现响应式的功能。

1.数据劫持

Vue 3 使用Proxy API创建代理对象,劫持data对象的各种操作,包括读、写、删除、添加属性等操作:

  • get:当访问数据时,收集依赖该属性的effect函数。
  • set:当修改、新增数据时,通知依赖该属性的effect函数重新计算。
  • deleteProperty:当删除数据时,通知依赖该属性的effect函数重新计算。

2.依赖收集

  • 维护一个全局的依赖映射表,存放所有响应式对象的所有响应式属性的依赖列表。
  • 当属性被读取时(比如在组件渲染、watch),触发get拦截器。
  • 将当前的effect函数添加到该属性的依赖列表中。

3.触发更新

当响应式对象的属性被修改、新增或删除时(触发set或deleteProperty拦截器),Vue3 会通知依赖该属性的所有effect函数重新执行,从而实现视图更新或监听响应。

4.核心代码

// 创建代理对象,劫持data对象的各种操作,包括读、写、删除、新增属性等操作:
function createReactiveObject(target) {
  const proxy = new Proxy(target,  {
    // 拦截读操作
    get(target, key, receiver) {
      // 依赖收集
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    // 拦截写、新增操作
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      // 触发更新
      if (newVal !== val) {
        trigger(target, key);
      }
      return result;
    }
    // 拦截删除操作
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key);
      trigger(target, key);
      return result;
  }
  });
  return proxy;
}
// 全局的依赖映射表:target -> key -> effects
const targetMap = new WeakMap();

function track(target, key) {
  if (!activeEffect) return;
  
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  
  dep.add(activeEffect);
}

// 触发更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  
  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => {
      if (effect.scheduler) {
        effect.scheduler();
      } else {
        effect.run();
      }
    });
  }
}
let activeEffect = null;

class ReactiveEffect {
  constructor(fn, scheduler = null) {
    this.fn = fn;
    this.scheduler = scheduler;
  }
  
  run() {
    activeEffect = this;
    const result = this.fn();
    activeEffect = null;
    return result;
  }
}

function effect(fn, options = {}) {
  const _effect = new ReactiveEffect(fn, options.scheduler);
  _effect.run();
  return _effect;
}

vue过滤器filter的详解及和computed的区别

作者 鹏多多
2025年11月17日 09:00

1. 过滤器filter

vue中的过滤器,主要用于文本的格式化,或者数组数据的过滤与排序等。其本质是一个函数,它不改变原始数据,只是对数据进行加工处理后返回过滤后的数据。过滤器可以用在两个地方:双花括号插值和v-bind表达式,使用时通过管道符 | 添加到表达式的尾部使用。语法如下:

  • 花括号
<div>{{ Id | formatId }}</div>
  • v-bind
<div v-bind:id="Id | formatId"></div>

2. 局部过滤器

如下例子:通过filters选项,定义了一个叫filterName的过滤器,它默认管道符前面的值作为第一个参数,filterName会返回一个格式化后的字符串。

<template>
  <div class="home_box">
    <p>{{ this.name | filterName }}</p>
  </div>
</template>

<script>
export default {
  name: 'Home',
  filters: {
    filterName(value) {
      return `${value}-过滤器`
    }
  },
  data() {
    return {
      name: '王五'
    }
  }
}
</script>

3. 全局过滤器

首先在src目录下新建一个filters.js文件,用来装所有的filter。然后在main.js中导入,挂载到vue实例上,即可在所有组件中使用。

  • filters.js
/**
 * @description 首字母大写
 */
export const capitalize = (value) => {
  if (!value) return ''
  value = value.toString()
  return value.charAt(0).toUpperCase() + value.slice(1)
}
/**
 * @description 双倍
 */
export const double = (value) => {
  return value * 2
}
  • main.js
import Vue from 'vue'
import ElementUI from 'element-ui'
import App from './App.vue'
import * as filterEnum from '@/utils/filters.js'

Vue.use(ElementUI)

Object.keys(filterEnum).forEach(key => {
  Vue.filter(key, filterEnum[key])
})

const app = new Vue({
  render: h => h(App)
})

app.$mount('#app')

4. 传递参数

filter也是可以传递参数的,如下例子,过滤器filterFn接收了三个参数。其中 message 的值作为第一个参数,arg1 作为第二个参数,arg2 作为第三个参数。

<div>{{ message | filterFn('arg1', 'arg2') }}</div>

5. 过滤器串联

过滤器可以串联,如下例子,filterA 被定义为接收单个参数的过滤器函数,表达式 message 的值将作为参数传入到函数中。然后继续调用同样被定义为接收单个参数的过滤器函数 filterB,将 filterA 的结果传递到 filterB。

<div>{{ message | filterA | filterB }}</div>

5. 使用场景

过滤器使用场景很多。比如:

  1. 后端返回了一个状态,值为false/true或0/1,需要你转成文字是和否
  2. 数字/日期需要格式化等

如下例子,根据result的值,显示不同的文字。

<template>
  <div class="home_box">
    <h1>{{ result | checkcCloudless }}</h1>
  </div>
</template>

<script>
export default {
  name: 'Home',
  filters: {
    checkcCloudless(value) {
      return value ? '晴天' : '阴天'
    }
  },
  data() {
    return {
      result: false
    }
  }
}
</script>

6. filter和computed的区别

  • computed:
    • 主要用来逻辑运算,防止模板过重
    • 有缓存 只有依赖的其他数据项变化它才变
    • 监听,有get和set两个方法,get必须return
    • 只有依赖的其他数据项变化它才变
    • 在模板外面直接this.使用
  • filter:
    • 主要用做数据格式化的处理
    • 无法缓存 每次渲染都会执行
    • 定义方式的区别,filter可以通过filters和vue.filter定义
    • 在模板外面使用必须用this.$options.filters['filter名字']

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

还在为异步组件加载烦恼?这招让你的Vue应用更丝滑!

2025年11月17日 07:33
你是不是也遇到过这样的场景?用户点开某个功能模块,页面却卡在那里转圈圈,既没有加载提示,也没有错误反馈。用户一脸茫然,不知道是网络问题还是程序bug,最后只能无奈刷新页面。 这种情况在前端开发中太常见

try...catch 核心与生态协作全解析

2025年11月16日 21:32

一、try...catch 本质:为何需要它?(从程序失控到可控)

在 JavaScript 执行过程中,代码常因变量未定义、类型错误、API 调用失败等问题中断。若缺乏异常处理,同步代码会直接崩溃,异步代码会陷入不可预知状态。try...catch 的核心价值是将 “不可控的错误中断” 转化为 “可控的逻辑处理” ,避免程序崩溃并提供补救机会,是保障代码健壮性的基础机制。

1.1 无 try...catch 时的问题

代码报错后直接终止,后续逻辑无法执行,影响用户体验。

const data = JSON.parse('invalid json'); // 报错:Unexpected token i in JSON at position 0
console.log('程序继续执行'); // 不会执行,代码中断

1.2 有 try...catch 时的优化

错误被捕获后,可执行补救逻辑,程序正常流转。

try {
  const data = JSON.parse('invalid json');
} catch (error) {
  console.log('解析失败,使用默认数据'); // 执行补救操作
  const data = { default: 'value' }; 
}
console.log('程序继续执行'); // 正常执行,无中断

二、try...catch 与核心技术的关联:从基础到扩展

try...catch 并非仅与 Promiseaxios 相关,而是贯穿 JavaScript 全场景的通用机制,以下从核心关联、跨界场景两方面详细解析。

2.1 与 Promise、async/await 的底层关联

try...catch 本质是同步错误捕获工具,而 Promise 处理异步操作,二者需配合实现 “同步 + 异步” 全场景错误处理。

2.1.1 Promise 为何需要独立错误处理?

异步代码(如定时器、网络请求)的错误发生在 “当前事件循环之外”,try...catch 无法直接捕获。Promise 设计 .catch() 方法,专门捕获异步执行中的错误(包括 reject 和执行器内同步错误)。

// try...catch 无法捕获 Promise 内部异步错误
try {
  new Promise((resolve, reject) => {
    setTimeout(() => {
      throw new Error('异步错误'); // 错误发生在定时器回调,属异步
    }, 100);
  });
} catch (error) {
  console.log('捕获不到', error); // 不执行
}

// 需用 Promise 的 .catch() 捕获
new Promise((resolve, reject) => {
  setTimeout(() => {
    throw new Error('异步错误');
  }, 100);
}).catch(error => {
  console.log('捕获到', error); // 正常执行
});

2.1.2 async/await 如何让 try...catch 接管异步错误?

async/await 是 Promise 语法糖,能将异步代码 “伪装” 成同步执行顺序,使 try...catch 可同时捕获 “同步错误” 和 “await 后的 Promise 错误”(await 等待 Promise 状态变更时,异步错误转化为 “等待阶段错误”)。

async function fetchData() {
  try {
    // 同步错误:未定义变量
    const invalid = undefinedVariable; 
    // 异步错误:axios 请求失败(返回 rejected Promise)
    const response = await axios.get('/invalid-api'); 
  } catch (error) {
    // 同步、异步错误均被捕获
    console.log('捕获到所有错误:', error); 
  }
}

2.2 与 axios 的协作逻辑

axios 是基于 Promise 的 HTTP 客户端,其错误分两类,需通过 try...catch 或 .catch() 统一处理:

  • 网络错误(如断网):直接触发 reject
  • HTTP 错误(如 404/500):默认触发 reject,可通过 validateStatus 配置修改。

2.2.1 用 .catch () 处理 axios 错误

axios.get('/api/data')
  .then(response => {
    console.log('请求成功:', response.data);
  })
  .catch(error => {
    // 捕获网络错误或 HTTP 错误
    if (error.response) {
      console.log('HTTP 错误状态码:', error.response.status);
    } else if (error.request) {
      console.log('网络错误,无响应:', error.request);
    }
  });

2.2.2 用 try...catch 处理 axios 错误(async/await 场景)

async function getUserData(userId) {
  try {
    const response = await axios.get(`/api/users/${userId}`);
    return response.data;
  } catch (error) {
    // 统一捕获并细化处理
    if (error.response?.status === 404) {
      console.log('用户不存在');
      return null;
    }
    console.log('获取用户数据失败:', error);
    throw error; // 需上层处理时重新抛出
  }
}

2.3 跨界关联场景:不止于 Promise、axios

try...catch 可捕获 “当前执行上下文” 中所有同步错误,覆盖 DOM 操作、Node.js 核心模块、第三方库等场景。

2.3.1 与 DOM 操作的协作

DOM 操作易因 “元素不存在”“修改只读属性” 出错,try...catch 可避免页面功能瘫痪。

function renderUserList(users) {
  try {
    const list = document.getElementById('user-list');
    // 若 list 不存在或 users 格式异常,直接报错
    users.forEach(user => {
      const item = document.createElement('li');
      item.textContent = user.name; // 若 user 无 name 属性,触发错误
      list.appendChild(item);
    });
  } catch (error) {
    console.error('渲染失败:', error);
    // 降级处理:显示错误提示而非白屏
    document.body.innerHTML = '<p>加载用户列表失败,请刷新重试</p>';
  }
}

2.3.2 与 Node.js 核心模块的配合

Node.js 中同步 API(如 fs.readFileSync)的错误需 try...catch 捕获,否则导致进程崩溃。

const fs = require('fs');

function readConfig() {
  try {
    // 同步读取文件,文件不存在/权限不足时抛出错误
    const content = fs.readFileSync('./config.json', 'utf8');
    return JSON.parse(content); // 解析失败也被捕获
  } catch (error) {
    console.error('配置文件读取失败:', error);
    // 返回默认配置,保证程序正常启动
    return { default: 'config' }; 
  }
}

2.3.3 与第三方库的兼容

第三方库(如 momentRedux)的同步方法可能因无效参数抛出错误,try...catch 是通用防护手段。

import moment from 'moment';

function formatDate(dateString) {
  try {
    // 若 dateString 格式无效,moment 格式化会报错
    return moment(dateString).format('YYYY-MM-DD');
  } catch (error) {
    console.error('日期格式化失败:', error);
    // 友好提示,避免暴露技术错误
    return '无效日期'; 
  }
}

三、复杂项目场景:try...catch 的实战应用

在多任务依赖、并行执行等复杂场景中,try...catch 与 Promise 组合可实现 “错误隔离”“流程可控”,避免局部错误影响全局。

3.1 分步依赖任务:串联式任务队列

场景:先获取 Token → 用 Token 拉取用户 ID → 提交表单,某一步出错需中断并提示。

// 任务1:获取 Token
function getToken() {
  return axios.post('/auth')
    .then(res => res.data.token)
    .catch(err => {
      throw new Error(`获取 Token 失败:${err.message}`); // 包装错误上下文
    });
}

// 任务2:用 Token 获取用户 ID
async function getUserId(token) {
  try {
    const res = await axios.get('/user', { headers: { token } });
    return res.data.id;
  } catch (err) {
    throw new Error(`获取用户 ID 失败:${err.message}`); // 补充错误信息
  }
}

// 任务3:提交表单(依赖用户 ID)
async function submitForm(userId, formData) {
  try {
    await axios.post('/submit', { ...formData, userId });
    console.log('提交成功');
  } catch (err) {
    throw new Error(`提交表单失败:${err.message}`);
  }
}

// 主流程:统一控制,汇总错误
async function main(formData) {
  try {
    const token = await getToken();
    const userId = await getUserId(token);
    await submitForm(userId, formData);
  } catch (error) {
    // 所有步骤错误汇总到此处,统一提示+上报
    console.error('流程中断:', error.message);
    alert(`操作失败:${error.message}`);
    // 上报错误到监控系统(附带用户/时间等上下文)
    logErrorToServer({
      message: error.message,
      stack: error.stack,
      context: { userId: 'xxx', time: new Date() }
    });
  }
}

3.2 并行任务:错误隔离不中断整体

场景:页面同时渲染 3 个独立组件(用户信息、订单列表、消息通知),一个组件出错不影响其他组件。

// 组件1:渲染用户信息
function renderUserInfo(userId) {
  return new Promise(async (resolve, reject) => {
    try {
      const user = await axios.get(`/api/user/${userId}`);
      document.getElementById('user-info').innerHTML = `<p>${user.data.name}</p>`;
      resolve();
    } catch (err) {
      reject(`用户信息组件失败:${err.message}`);
    }
  });
}

// 组件2:渲染订单列表
function renderOrderList(userId) {
  return new Promise(async (resolve, reject) => {
    try {
      const orders = await axios.get(`/api/orders/${userId}`);
      // 渲染逻辑...
      resolve();
    } catch (err) {
      reject(`订单列表组件失败:${err.message}`);
    }
  });
}

// 主流程:并行渲染,错误隔离
async function renderAllComponents(userId) {
  // 用 Promise.allSettled 捕获所有结果,成功/失败均不中断
  const results = await Promise.allSettled([
    renderUserInfo(userId),
    renderOrderList(userId),
    renderMessageList(userId) // 组件3:渲染消息通知
  ]);

  // 处理失败结果,单独提示
  results.forEach((result, index) => {
    if (result.status === 'rejected') {
      console.error(`组件${index+1}渲染失败:`, result.reason);
      // 标记失败组件,不影响其他组件展示
      document.querySelectorAll('.component')[index].classList.add('error');
    }
  });
}

四、try...catch 的局限性:并非万能

尽管 try...catch 是基础机制,但存在明显短板,需结合其他方案规避。

4.1 无法捕获的错误类型

  • 语法错误与解析错误:代码存在语法问题(如括号不匹配)或解析阶段错误(如 import 不存在的模块),脚本加载时直接报错,try...catch 无法捕获。

    try {
      // 语法错误:缺少右括号
      const a = 1 + 2;
      console.log(a; 
    } catch (error) {
      // 不执行,脚本解析阶段已报错
    }
    
  • 非 Promise 异步错误:不基于 Promise 的异步操作(如回调函数),错误无法被 try...catch 捕获。

    try {
      setTimeout(() => {
        throw new Error('定时器错误'); // 异步回调错误,无法捕获
      }, 100);
    } catch (error) {
      console.log('捕获不到', error); // 不执行
    }
    

4.2 功能上的不足

  • 过度捕获掩盖逻辑错误:包裹大段代码时,会捕获 “预期外错误”(如变量拼写错误),增加调试难度。

    try {
      // 逻辑错误:变量名拼写错误(应为 user.name)
      console.log(uesr.name); 
    } catch (error) {
      console.log('出错了,但无法定位是拼写错还是其他错'); // 掩盖真实问题
    }
    
  • 性能损耗try...catch 会影响 JavaScript 引擎优化(如 V8 即时编译),高频执行场景(如循环)中过度使用会导致性能下降。

    // 反例:循环中频繁使用 try...catch,性能损耗明显
    for (let i = 0; i < 10000; i++) {
      try {
        processData(i); // 高频执行,不建议包裹
      } catch (error) {
        // ...
      }
    }
    

五、try...catch 与其他机制的生态协作

现代开发中,try...catch 需与全局错误监听、框架错误边界等配合,形成多层防护体系。

5.1 与全局错误监听的配合

window.onerror 或 window.addEventListener('error') 可捕获 try...catch 未处理的 “漏网之鱼”(如未被捕获的 Promise 错误、跨域脚本错误),作为最后兜底。

// 全局错误兜底,捕获未处理错误
window.addEventListener('error', (event) => {
  // 过滤资源加载错误(如图片加载失败),只处理脚本错误
  if (event.message !== 'Script error.') {
    console.error('全局未捕获错误:', event.error);
    // 上报到监控系统,避免错误静默
    logErrorToServer(event.error);
  }
});

// 捕获未处理的 Promise 错误
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的 Promise 错误:', event.reason);
  event.preventDefault(); // 阻止浏览器默认提示
  logErrorToServer(event.reason);
});

5.2 与框架错误边界的协作

在 React、Vue 等框架中,组件渲染错误需用 “错误边界” 处理,try...catch 负责逻辑错误,错误边界负责渲染错误,分工明确。

5.2.1 React 错误边界示例

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  // 捕获子组件渲染错误
  static getDerivedStateFromError() {
    return { hasError: true }; // 更新状态,显示降级 UI
  }

  // 日志上报
  componentDidCatch(error, errorInfo) {
    console.error('组件渲染错误:', error, errorInfo);
    logErrorToServer({ error, errorInfo });
  }

  render() {
    if (this.state.hasError) {
      return <p>组件加载失败,请刷新重试</p>; // 降级 UI
    }
    return this.props.children;
  }
}

// 使用:包裹可能出错的组件
<ErrorBoundary>
  <UserProfile userId={123} />
</ErrorBoundary>

六、总结

6.1 核心定位

try...catch 是 JavaScript 错误处理的 “基础设施” ,而非 “最优解”:

  • 基础作用:捕获同步错误,配合 async/await 捕获 Promise 异步错误;
  • 生态角色:与 Promise.catch()、全局监听、框架错误边界配合,形成 “多层防护”;
  • 价值核心:保障程序可控性,实现优雅降级与错误上报。

6.2 最佳应用

  1. 精准捕获,避免过度包裹:只包裹 “预期可能出错的代码段”(如网络请求、JSON 解析),不包裹大段逻辑;

  2. 保留错误上下文:捕获错误后需 throw error 重新抛出(需上层处理时),避免丢失错误栈信息;

  3. 结合场景选择处理方式

    • 同步代码:直接用 try...catch
    • 纯 Promise 异步:用 .catch()
    • async/await 场景:用 try...catch 统一处理;
    • 并行任务:用 Promise.allSettled 配合 try...catch 隔离错误;
  4. 补充错误上下文:抛出错误时添加业务信息(如用户 ID、订单号),便于排查;

  5. 兜底机制不可少:全局错误监听 + 框架错误边界,覆盖 try...catch 未处理的场景。

七、实际应用场景举例

1. 同步代码场景(基础)

适用:JSON 解析、变量类型转换、同步函数调用等。模板

function syncOperation(data) {
  try {
    // 可能出错的同步操作(如解析、类型转换)
    const parsed = JSON.parse(data); // 可能抛错
    const result = parsed.value.toUpperCase(); // 可能抛错(若 parsed.value 不存在)
    return result;
  } catch (error) {
    // 1. 补充上下文(必做)
    error.context = { data, operation: 'syncOperation' };
    // 2. 可处理则补救,否则抛出
    if (error.name === 'SyntaxError') {
      console.warn('数据格式错误,使用默认值');
      return 'default';
    } else {
      throw error; // 传递给上层处理
    }
  }
}

2. DOM 操作场景

适用:动态创建元素、修改 DOM 属性、事件绑定等(易因元素不存在 / 权限问题出错)。

function updateDOM(elementId, content) {
  try {
    const el = document.getElementById(elementId);
    if (!el) throw new Error(`元素 ${elementId} 不存在`); // 主动抛错,明确上下文
    
    // 可能出错的 DOM 操作
    el.textContent = content; 
    el.classList.add('active'); // 若 classList 不支持(极旧浏览器)会抛错
  } catch (error) {
    console.error('DOM 更新失败:', error.message);
    // 降级处理:显示错误提示,不影响页面其他功能
    const errorEl = document.createElement('div');
    errorEl.className = 'error';
    errorEl.textContent = `加载失败:${error.message}`;
    document.body.appendChild(errorEl);
  }
}

3. Node.js 同步 API 场景

适用:文件读写(fs.readFileSync)、路径处理(path.resolve)等同步操作(出错会导致进程崩溃)。

const fs = require('fs');
const path = require('path');

function readLocalFile(filePath) {
  try {
    const fullPath = path.resolve(filePath); // 路径解析可能抛错
    const content = fs.readFileSync(fullPath, 'utf8'); // 文件不存在/权限不足会抛错
    return content;
  } catch (error) {
    // 区分错误类型,针对性处理
    if (error.code === 'ENOENT') {
      console.warn(`文件不存在:${filePath},返回空内容`);
      return '';
    } else if (error.code === 'EACCES') {
      console.error(`权限不足,无法读取 ${filePath}`);
      throw new Error('文件访问权限不足,请检查配置'); // 上层需处理的严重错误
    } else {
      throw error;
    }
  }
}

4. 异步场景(Promise + async/await

4.1 单异步任务(async/await

适用:单个网络请求、异步 API 调用(如 axios 请求)。

async function fetchSingleData(url) {
  try {
    const response = await axios.get(url);
    // 主动校验业务错误(如接口返回 code 非 0)
    if (response.data.code !== 0) {
      throw new Error(`业务错误:${response.data.msg}`);
    }
    return response.data.data;
  } catch (error) {
    // 区分网络错误和业务错误
    let errorMsg;
    if (error.response) {
      // HTTP 错误(404/500 等)
      errorMsg = `请求失败[${error.response.status}]:${url}`;
    } else if (error.request) {
      // 网络错误(无响应)
      errorMsg = `网络错误,无法连接:${url}`;
    } else {
      // 业务错误或其他
      errorMsg = error.message;
    }
    console.error(errorMsg);
    // 非致命错误可返回默认值,避免流程中断
    return null; 
  }
}

4.2 多依赖异步任务(串联)

适用:任务 A → 任务 B(依赖 A 的结果)→ 任务 C(依赖 B 的结果)。模板

async function taskA() { /* ... */ } // 返回 Promise
async function taskB(resultA) { /* ... */ }
async function taskC(resultB) { /* ... */ }

async function runTasks() {
  try {
    const resA = await taskA();
    console.log('任务 A 完成');
    
    const resB = await taskB(resA);
    console.log('任务 B 完成');
    
    const resC = await taskC(resB);
    console.log('所有任务完成');
    return resC;
  } catch (error) {
    // 任何一步失败都会中断,统一处理
    console.error(`任务中断:${error.message}`);
    // 记录中断位置(通过 error 上下文)
    error.task = error.task || '未知任务'; // 可在子任务中添加 task 字段
    logToMonitor(error); // 上报监控
    throw error; // 允许上层重试
  }
}

4.3 多独立异步任务(并行)

适用:多个无依赖的异步任务(如同时渲染多个组件),需隔离错误。

async function componentA() { /* ... */ }
async function componentB() { /* ... */ }
async function componentC() { /* ... */ }

async function renderAll() {
  // 用 Promise.allSettled 确保所有任务执行完毕(无论成功失败)
  const results = await Promise.allSettled([
    componentA().catch(err => ({ error: err, component: 'A' })),
    componentB().catch(err => ({ error: err, component: 'B' })),
    componentC().catch(err => ({ error: err, component: 'C' }))
  ]);

  // 处理失败结果
  results.forEach(result => {
    if (result.value?.error) { // 捕获到的错误
      const { error, component } = result.value;
      console.error(`组件 ${component} 失败:`, error.message);
      // 单独标记失败组件,不影响其他
      document.getElementById(`comp-${component}`).classList.add('failed');
    }
  });
}

5. 第三方库调用场景

适用:调用外部库(如 momentlodash)的同步方法(可能因参数错误抛错)。

import moment from 'moment';

function formatWithLibrary(dateInput) {
  try {
    // 第三方库可能对无效参数抛错(如 moment(null) 格式化)
    const formatted = moment(dateInput).format('YYYY-MM-DD HH:mm');
    // 主动校验库返回的异常结果(如 moment 无效日期返回 'Invalid date')
    if (formatted === 'Invalid date') {
      throw new Error(`无效日期:${dateInput}`);
    }
    return formatted;
  } catch (error) {
    console.error('格式化失败:', error.message);
    // 降级为原生方法
    return new Date(dateInput).toLocaleString() || '无法解析的日期';
  }
}

6. 框架场景(以 React 为例)

适用:组件逻辑错误(try...catch)与渲染错误(错误边界)分工:

// 1. 组件内逻辑错误用 try...catch
function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    async function loadUser() {
      try {
        const res = await axios.get(`/api/user/${userId}`);
        setUser(res.data);
      } catch (error) {
        console.error('加载用户失败:', error);
        setUser({ error: '用户信息加载失败' }); // 状态降级
      }
    }
    loadUser();
  }, [userId]);

  // 2. 渲染错误交给错误边界处理(不直接用 try...catch 包裹 JSX)
  return (
    <div>
      {user?.error ? <p>{user.error}</p> : <h3>{user.name}</h3>}
    </div>
  );
}

// 错误边界组件(处理渲染错误)
class ErrorBoundary extends React.Component { /* ... */ }

// 使用:错误边界包裹可能渲染失败的组件
<ErrorBoundary>
  <UserProfile userId={123} />
</ErrorBoundary>
昨天 — 2025年11月16日首页

17. Vue3 业务组件库按需加载的实现原理

作者 Cobyte
2025年11月16日 17:53

前言

最近在公司实现一个业务组件库按需加载的需求。简单来说,有两个需求,第一个是实现业务组件库的按需加载,第二,因为业务组件库里面有引用了类似 Element Plus 的第三方组件库,所以在实现业务组件库按需加载的同时,业务组件库里面的引用的第三方组件库也要实现按需加载。

作为一个编程技术人员,即便有了AI,也需要研究底层的技术原理,甚至需要比没有AI的时代,需要更加深入研究,在AI时代,基础的都通过AI实现了,只有AI解决不了的问题,最终还得靠你自己的专业知识去解决,而这将是你的核心竞争力的体现,所以在AI时代对技术人员的技术素养要求将更加的高。

扯远了,我们回到业务组件库按需加载的实现原理的主题上来。

一般在项目中如果没有进行组件库按需加载配置,都是一开始就全量加载进行全局组件注册,这样就等于整个组件库在初始化的时候就全部加载了,如果在追求性能的项目中,这是不可接受的。这时我们就要实现组件库的按需加载,来提高性能。

按需加载的基本实现原理

首先什么是按需加载?

所谓按需加载,顾名思义就是有需要就加载,不需要就不加载,比如 Element Plus 组件库有几十个组件,可能在我们的项目只用到了到了其中一个组件 <el-button>,那么我们就希望只加载跟这个按钮组件相关的代码,从而达到减少打包体积的效果。

按需加载最简单的实现方式就是手动设置,实现如下:

<template>
  <el-button>按钮</el-button>
</template>

<script>
import { ElButton } from 'element-plus/es/components/button'
import 'element-plus/es/components/button/style/index'

export default {
  components: { ElButton },
}
</script>

我们像上述例子这样手动引用第三方组件库的话,在打包的时候就只会打包引用到的组件,因为目前的开源组件库基本都实现了利于 Tree Shaking 的 ESM 模块化实现。

如果每个业务组件都需要进行上述设置,其实还是挺繁琐的,所以我们希望只在 template 中直接调用就好,其他什么设置都不需要,就像全局注册组件那样使用。

<template>
  <el-button>按钮</el-button>
</template>

而剩下部分的代码,我们希望在打包或者运行的时候自动设置上去。主要是以下部分的代码:

import { ElButton } from 'element-plus/es/components/button'
import 'element-plus/es/components/button/style/index'

上述部分的代码,希望自动加载,而不需要手动设置。整个所谓按需加载所需要实现的就是上述的功能。

那么怎么实现呢?

首先上述模板代码的编译结果如下:

import { createTextVNode as _createTextVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_el_button = _resolveComponent("el-button")

  return (_openBlock(), _createElementBlock("template", null, [
    _createVNode(_component_el_button, null, {
      default: _withCtx(() => [
        _createTextVNode("按钮")
      ], undefined, true),
      _: 1 /* STABLE */
    })
  ]))
}

我们只需要找到 Vue3 的内置函数 _resolveComponent("el-button") 部分,然后替换成对应的组件代码即可。例如:

+ import { ElButton } from 'element-plus/es/components/button'
+ import 'element-plus/es/components/button/style/index'
import { createTextVNode as _createTextVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
-  const _component_el_button = _resolveComponent("el-button")
+ const _component_el_button = ElButton

  return (_openBlock(), _createElementBlock("template", null, [
    _createVNode(_component_el_button, null, {
      default: _withCtx(() => [
        _createTextVNode("按钮")
      ], undefined, true),
      _: 1 /* STABLE */
    })
  ]))
}

上述就是组件库按需加载的基本实现原理。

使用 Vite 打包组件库

为了更好还原实际场景,我们快速创建一个组件库项目并且通过 Vite 进行打包。 首先创建一个 cobyte-vite-ui 的组件库目录,在根目录下初始化 Node 项目,执行 pnpm init, 会自动生成 package.json 文件,内容如下:

{
  "name": "cobyte-vite-ui",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.20.0",
}

在根目录新建 pnpm-workspace.yaml 文件进行 Monorepo 项目配置:

packages:
  - packages/*
  - play

总的目录结构如下:

├── packages
│   ├── components
│   ├── hooks
│   └── utils
├── play
├── package.json
└── pnpm-workspace.yaml

接着我们安装一些必要的依赖:

pnpm add vite typescript @vitejs/plugin-vue sass @types/node -D -w

接着我们安装一下 vue 依赖:

pnpm add vue -w

基础依赖安装完毕,我们设置一下 TS 的配置,因为我们这个项目是一个 TS 的项目,在根目录创建一个 tsconfig.json,配置内容可以简单设置如下:

{
    "compilerOptions": {
      "target": "ESNext",
      "module": "NodeNext",
      "sourceMap": true,  // 关键:启用源映射
      "outDir": "./dist", // 可选:指定输出目录
      "esModuleInterop": true
    }
}

接着我们就在 packages/components 目录下创建一个测试按钮组件

目录路径:packages/components/button/button.vue,内容如下:

<template>
    <button>测试按钮</button>
</template>
<script setup lang="ts">
defineOptions({
  name: 'co-button',
});
</script>
<style lang="scss" scoped>
button {
  color: red;
}
</style>

目录路径:packages/components/button/index.ts,内容如下:

import button from "./button.vue";
export const CoButton = button;
export default CoButton;

目录路径:packages/components/components.ts,内容如下:

import { CoButton } from './button';
export default [
    CoButton
]

将所有组件集中在一个数组中统一导出,方便批量管理和使用。

目录路径:packages/components/defaults.ts,内容如下:

import { App } from 'vue';
import components from './components';

const install = function (app: App) {
    components.forEach(component => {
        app.component(component.name, component);
    });
};

export default {
    install
};

目录路径:packages/components/index.ts,内容如下:

export * from './button';

import install from  './defaults';
export default install;

我们再配置一个测试文件,目录路径:packages/utils/index.ts,内容如下:

export function testUtils() {
    console.log('testUtils');
}

如果大家对创建组件库比较有经验的话,就知道上述步骤,是 Vue3 组件库的基础设置,各大组件库的实现虽然差异很大,但最核心机制都可以简单归纳为上述设置内容。 大家如果想详细了解更多也可以看看本栏目前面章节的内容。

接着我们就到了我们最核心的组件库打包的环节了,我们在根本目录创建一个 vite.config.ts,设置内容如下:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path, { resolve } from "path";
import fs from "fs";

// 动态获取组件目录列表
const componentsDir = resolve(__dirname, "./packages/components");
const modules = fs.readdirSync(componentsDir).filter((name) => {
    const fullPath = path.join(componentsDir, name);
    // 只获取目录,排除文件
    return fs.statSync(fullPath).isDirectory();
});

const entryArr = {
    // 主入口
    index: resolve(__dirname, "./packages/components/index.ts"),

    // 工具入口
    utils: resolve(__dirname, "./packages/utils/index.ts"),
};

// 为每个组件创建独立入口
modules.forEach((name) => {
    entryArr[`components/${name}/index`] = resolve(__dirname, `./packages/components/${name}/index.ts`);
});

export default defineConfig(({ command, mode }) => {
    // 主构建配置
    return {
        plugins: [
            vue(),
        ],
        build: {
            lib: {
                entry: entryArr,
                formats: ["es"], // 只构建 ES 模块
                cssFileName: "style",
            },
            rollupOptions: {
                external: [
                    "vue",
                ],
                output: {
                    format: "es",
                    preserveModules: true,
                },
            },
        },
    };
});

设置完 Vite 配置文件后,我们还要设置 packages.json 中的打包命令脚本配置,设置如下:

  "scripts": {
    "build": "vite build"
  },

这样我们就可以在根目录运行打包命令了:pnpm build

运行结果如下,我们成功打包了我们的组件库。

image.png

通过 pnpm 安装本地 npm 包

接着我们在根目录下创建一个测试项目:

pnpm create vite play --template vue-ts

上述 play 就是测试项目目录,我们原本就建了一个 play 目录,现在这条命令会直接在 play 目录中生成一个使用 Vite 创建的 Vue 项目。

接着我们修改根目录的 package.json 文件:

- "main": "index.js",
+ "module": "/dist/index.mjs",

接着我们进入 play 目录,通过 pnpm 安装本地 npm 包,命令如下:

pnpm add ../

运行完上述命令,我们可以看到 ./play/packages.json 文件变化如下:

image.png

可以看到我们成功把我们本地的 npm 包安装到 play 测试项目中了。

接着修改 ./play/main.ts 内容如下:

import { createApp } from 'vue'
import App from './App.vue'
import CobyteViteUI from 'cobyte-vite-ui'
import 'cobyte-vite-ui/dist/style.css'

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

我们直接引用我们本地创建的 npm 包。

接着修改 ./play/App.vue 内容如下:

<template>
  <co-button></co-button>
</template>
<script setup lang="ts">

</script>

最后我们运行 play 测试项目,结果如下:

image.png

我们可以看到成功运行了本地组件库的 npm 包。

接下来我们希望不进行完整引入组件库:

import { createApp } from 'vue'
import App from './App.vue'
- import CobyteViteUI from 'cobyte-vite-ui'
- import 'cobyte-vite-ui/dist/style.css'

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

即便这样我们同样可以在测试项目中使用我们的测试组件。

通过静态分析实现按需加载

根据上文我们知道 App.vue 的模板内容会被编译成:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_co_button = _resolveComponent("co-button")

  return (_openBlock(), _createElementBlock("template", null, [
    _createVNode(_component_co_button)
  ]))
}

那么根据上文我们知道需要把 _resolveComponent("co-button") 部分替换成对应的组件对象,内容如下:

+ import CoButton from 'cobyte-vite-ui/dist/components/button'
+ import 'cobyte-vite-ui/dist/style.css'
import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
-  const _component_co_button = _resolveComponent("co-button")
+  const _component_co_button = CoButton

  return (_openBlock(), _createElementBlock("template", null, [
    _createVNode(_component_co_button)
  ]))
}

那么要实现上述功能,我们得通过 Vite 插件来实现,我们在上面安装了一个 @vitejs/plugin-vue 插件,这个 Vite 插件的主要功能就是把 .vue 文件编译成上述的 js 内容。那么我们这样在它的后面继续添加一个插件在编译后的 js 内容中去实现上述替换功能即可。

我们在 ./packages/utils/index.ts 文件中实现这个自动加载组件的 Vite 插件,实现如下:

import MagicString from 'magic-string';

export default function VitePluginAutoComponents() {
  return {
    // 插件名称,用于调试和错误信息
    name: 'vite-plugin-auto-component',

    // transform 钩子函数,在转换模块时调用
    // code: 文件内容,id: 文件路径
    transform(code, id) {
      // 使用正则表达式检查文件是否为.vue文件
      // 如果不是.vue文件,不进行处理
      if(/\.vue$/.test(id)) {
          // 创建 MagicString 实例,用于高效地修改字符串并生成 source map
          const s = new MagicString(code)
          // 初始化结果数组,用于存储匹配到的组件信息
          const results = []

          // 使用 matchAll 方法查找所有匹配的 resolveComponent 调用
          // 正则表达式解释:
          // _?resolveComponent\d* - 匹配可能的函数名变体(可能带下划线或数字后缀)
          // \("(.+?)"\) - 匹配括号内的字符串参数
          // g - 全局匹配
          for (const match of code.matchAll(/_?resolveComponent\d*\("(.+?)"\)/g)) {
              // match[1] 是第一个捕获组,即组件名称字符串
              const matchedName = match[1]
              // 检查匹配是否有效:
              // match.index != null - 确保有匹配位置
              // matchedName - 确保捕获到组件名
              // !matchedName.startsWith('_') - 确保组件名不以_开头(可能是内部组件)
              if (match.index != null && matchedName && !matchedName.startsWith('_')) {
                  // 计算匹配字符串的起始位置
                  const start = match.index
                  // 计算匹配字符串的结束位置
                  const end = start + match[0].length
                  // 将匹配信息存入结果数组
                  results.push({
                      rawName: matchedName,  // 原始组件名称
                      // 创建替换函数,使用 MagicString 的 overwrite 方法替换指定范围的文本
                      replace: resolved => s.overwrite(start, end, resolved),
                  })
              }
          }

          // 遍历所有匹配结果进行处理
          for (const { rawName, replace } of results) {
              // 定义要替换的变量名(这里暂时编码为 CoButton)
              const varName = `CoButton`
              // 在代码开头添加导入语句:
              // 1. 导入 CoButton 组件
              // 2. 导入样式文件
              s.prepend(`import CoButton from 'cobyte-vite-ui/dist/components/button';\nimport 'cobyte-vite-ui/dist/style.css';\n`)

              // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名
              replace(varName)
          }

          // 返回转换后的代码
          return {
              code: s.toString(),  // 转换后的代码字符串
              map: null, 
          }
      }
    },
  }
}

我们在上述 Vite 插件中使用到了一个新工具库 magic-string,我们需要安装一下它的依赖:

pnpm add magic-string -D -w

magic-string 是一个专注于字符串操作,主要作用是对源代码可以进行精准的插入、删除、替换等操作

上述编写的 Vite 的插件主要是实现在.vue 文件中查找所有形如 resolveComponent("xxx") 的函数调用,对于每一个找到的调用,它会在文件顶部添加一个固定的导入语句,例如导入 CoButton 组件和样式。最后把找到的resolveComponent("xxx") 替换成对应的组件,例如 CoButton

然后我们在根目录重新打包,接着在 play 目录中的 vite.config.ts 文件中进行以下修改:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
+ import AutoComponents from 'cobyte-vite-ui/dist/utils'

// https://vite.dev/config/
export default defineConfig({
-  plugins: [vue()],
+  plugins: [vue(), AutoComponents()],
})

接着我们再次重启 play 测试项目,我们可以看到即便我们不导入任何我们编写的组件库设置,我们依然可以在 play 项目中成功使用 CoButton 组件。

image.png

同时我们在网络窗口可以查看到 App.vue 文件的内容变化如下:

image.png

可以看到我们通过静态分析代码,识别并替换 Vue3 的组件解析函数,成功实现了组件的自动导入功能。但上述实现为了快速验证功能,无论匹配到的组件名是什么,都导入 CoButton 组件,并替换为 CoButton。这显然是不正确的,应该根据匹配到的组件名动态导入对应的组件。

自动化路径解析

因为我们的组件编译后的调用变成 _resolveComponent("co-button"),组件名称变成了 co-button,而我们在导入的语句是这样的 import CoButton from 'cobyte-vite-ui/dist/components/button',组件名称又需要变成 CoButton,所以我们需要把匹配到的 co-button 变成 CoButton

代码迭代如下:

+ // 将字符串转换为帕斯卡命名(即大驼峰,每个单词首字母大写)
+ export function pascalCase(str: string) {
+    return capitalize(camelCase(str))
+ }
+ // 将字符串转换为驼峰命名  
+ export function camelCase(str: string) {
+    return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))
+ }
+ // 将字符串的首字母大写,使用 charAt(0) 获取第一个字符并转换为大写,然后加上剩余字符串(从索引1开始)
+ export function capitalize(str: string) {
+    return str.charAt(0).toUpperCase() + str.slice(1)
+ }

export default function VitePluginAutoComponents() {
    return {
      // 插件名称,用于调试和错误信息
      name: 'vite-plugin-auto-component',
  
      // transform 钩子函数,在转换模块时调用
      // code: 文件内容,id: 文件路径
      transform(code, id) {
        // 使用正则表达式检查文件是否为.vue文件
        // 如果不是.vue文件,不进行处理
        if(/\.vue$/.test(id)) {
            // 省略...
  
            // 遍历所有匹配结果进行处理
            for (const { rawName, replace } of results) {
+                // 将字符串转换为大驼峰
+                const name = pascalCase(rawName)
+                // 只处理 Co 开头的组件
+                if (!name.match(/^Co[A-Z]/)) return
                // 定义要替换的变量名
-                const varName = `CoButton`
+                const varName = name
                // 在代码开头添加导入语句:
                // 1. 导入 CoButton 组件
                // 2. 导入样式文件
                s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/button';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
  
                // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名
                replace(varName)
            }
  
            // 返回转换后的代码
            return {
                code: s.toString(),  // 转换后的代码字符串
                map: null, 
            }
        }
      },
    }
}

经过上述实现还是存在以下问题,无论 rawName 是什么,组件都是从 'cobyte-vite-ui/dist/components/button' 这个固定路径导入。这意味着即使使用了 resolveComponent("CoTable"),插件依然会尝试从 button 文件导入,这显然是不正确的。理想情况下,导入路径应根据组件名动态生成。所以我们继续实现动态组件路径,例如 CoTableColumn 组件映射到 'cobyte-vite-ui/dist/components/table-column'

我们上述的组件是 "CoButton",那么转换过程则是:
"CoButton" -> 去掉"Co" -> "Button" -> kebabCase -> "button"。

我们通过实现一个 kebabCase 函数进行组件路径转换解析,实现如下:

// 省略...

+ // 将驼峰命名的字符串转换为短横线分隔的字符串(即kebab-case)
+ export function kebabCase(key: string) {
+    const result = key.replace(/([A-Z])/g, ' $1').trim()
+    return result.split(' ').join('-').toLowerCase()
+ }

export default function VitePluginAutoComponents() {
    return {
      // 插件名称,用于调试和错误信息
      name: 'vite-plugin-auto-component',
  
      // transform 钩子函数,在转换模块时调用
      // code: 文件内容,id: 文件路径
      transform(code, id) {
        // 使用正则表达式检查文件是否为.vue文件
        // 如果不是.vue文件,不进行处理
        if(/\.vue$/.test(id)) {
            // 省略...
  
            // 遍历所有匹配结果进行处理
            for (const { rawName, replace } of results) {
                // 将字符串转换为大驼峰
                const name = pascalCase(rawName)
                // 只处理 Co 开头的组件
                if (!name.match(/^Co[A-Z]/)) return
+                // 组件路径转换
+                const partialName = kebabCase(name.slice(2))
                // 定义要替换的变量名
                const varName = name
                // 在代码开头添加导入语句:
                // 1. 导入 CoButton 组件
                // 2. 导入样式文件
-                s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/button';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
+                s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/${partialName}';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
  
                // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名
                replace(varName)
            }
  
            // 返回转换后的代码
            return {
                code: s.toString(),  // 转换后的代码字符串
                map: null, 
            }
        }
      },
    }
}

经过上述迭代后,我们重新打包,重新启动 play 测试项目,我们发现我们的代码是能够正常运行的,说明我们上述的迭代是没有问题的。至此我们为组件自动导入提供了核心的路径解析能力

引入解析器 (Resolver) 概念

当前插件硬编码了组件库的路径和样式文件,只能用于特定的组件库(cobyte-vite-ui)。我们可以通过引入解析器(Resolver),让插件支持不同的组件库,用户可以根据需要配置不同的解析器。

解析器的作用是根据组件名返回一个解析结果,包括组件的导入路径和样式文件路径以及组件原始名称。这样,插件就可以通过解析器返回的对象信息动态获取组件的导入信息,而不是固定写死。

在实现解析器之前,我们先设计解析器返回的对象结构如下:

const component = {
    name, // 组件原始名称
    from: `cobyte-vite-ui/dist/components/${partialName}`, // 组件的导入路径
    sideEffects: ['cobyte-vite-ui/dist/style.css'] // 组件的样式文件路径
}

为什么要这样设计?

  1. 组件名 (name):
    用于在导入语句中作为标识符。这里使用的是帕斯卡命名,因为它在 Vue 中通常用于组件注册和模板中。
  2. 导入路径 (from):
    这里使用模板字符串动态构建导入路径。其中,partialName 是通过将组件名去掉前两个字符(即去掉"Co")并转换为 kebab-case 得到的。
    例如,组件名 "CoTableColumn" 转换为 "table-column",然后拼接成路径 'cobyte-vite-ui/dist/components/table-column'。
    这样设计是因为组件库的目录结构可能是按照 kebab-case 命名的,而组件在代码中是以帕斯卡命名使用的。
  3. 副作用 (sideEffects):
    这是一个数组,指定在导入组件时需要同时导入的样式文件或其他资源。这里指定了组件库的全局样式文件。
    注意:这个样式文件是全局的,也就是说,不管导入哪个组件,都会导入整个组件库的样式。这可能会造成样式冗余。
    更精细的做法是为每个组件指定其对应的样式文件,例如:
    sideEffects: [cobyte-vite-ui/dist/components/${partialName}/style.css]

但是,我们当前组件库没有为每个组件单独提供样式文件,我们只提供了固定的全局样式文件。

上面设计解析器返回的对象封装了组件的完整导入信息,作为数据载体传递给后续处理函数,我们可以基于此进行迭代:

// 省略...

+ // 根据传入的信息生成对应的导入语句字符串
+ export function stringifyImport(info) {
+    if (typeof info === 'string')
+      return `import '${info}'`
+    if (!info.as)
+      return `import '${info.from}'`
+    else if (info.name)
+      return `import { ${info.name} as ${info.as} } from '${info.from}'`
+    else
+      return `import ${info.as} from '${info.from}'`
+ }
+ // 根据组件的导入信息生成完整的导入语句,包括组件本身的导入和其副作用(如样式文件)的导入。
+ export function stringifyComponentImport({ as: name, from: path, name: importName, sideEffects }) {
+    const imports = [
+      // 生成组件导入语句
+      stringifyImport({ as: name, from: path, name: importName }),
+    ]
  
+    if (sideEffects) {
+      // 生成副作用导入语句
+      sideEffects.forEach(i => imports.push(stringifyImport(i)))
+    }
  
+    return imports.join(';')
+ }

  export default function VitePluginAutoComponents() {
    return {
      // 插件名称,用于调试和错误信息
      name: 'vite-plugin-auto-component',
  
      // transform 钩子函数,在转换模块时调用
      // code: 文件内容,id: 文件路径
      transform(code, id) {
        // 使用正则表达式检查文件是否为.vue文件
        // 如果不是.vue文件,不进行处理
        if(/\.vue$/.test(id)) {
            // 省略...
+            let no = 0
            // 遍历所有匹配结果进行处理
            for (const { rawName, replace } of results) {
                // 将字符串转换为大驼峰
                const name = pascalCase(rawName)
                // 只处理 Co 开头的组件
                if (!name.match(/^Co[A-Z]/)) return
                // 组件路径转换
                const partialName = kebabCase(name.slice(2))
+                // 封装了组件的完整导入信息,作为数据载体传递给后续处理函数
+                const component = {
+                    name,
+                    from: `cobyte-vite-ui/dist/components/${partialName}`,
+                    sideEffects: ['cobyte-vite-ui/dist/style.css']
+                }
-                // 定义要替换的变量名(这里暂时编码为 CoButton)
-                const varName = name
+                // 使用特殊前缀减少与用户变量的冲突,以及使用递增的序号,保证唯一性,避免变量名冲突
+                const varName = `__unplugin_components_${no}`
                // 在代码开头添加导入语句:
                // 1. 导入 CoButton 组件
                // 2. 导入样式文件
-                 s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/${partialName}';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
+                // 这里将 component 对象展开,并添加 as: varName 参数,形成完整的导入配置
+                s.prepend(`${stringifyComponentImport({ ...component, as: varName })};\n`)
+                no += 1
                // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名
                replace(varName)
            }
  
            // 返回转换后的代码
            return {
                code: s.toString(),  // 转换后的代码字符串
                map: null, 
            }
        }
      },
    }
}

我们添加了根据传入的信息生成对应的导入语句字符串的 stringifyImport 函数和根据组件的导入信息生成完整的导入语句,包括组件本身的导入和其副作用(如样式文件)的导入的 stringifyComponentImport 函数。其中 stringifyImport 处理单一导入语句,stringifyComponentImport 处理组合多个相关导入,实现了职责分离和配置灵活的设计优势。这两个函数共同构成了一个灵活的导入语句生成系统,为自动导入插件提供了强大的代码生成能力。

我们设计了一个解析结果包括:name(组件名)、from(导入路径)、sideEffects(样式等副作用导入)的数据结构对象 component 作为数据载体传递给后续处理函数,后续程序基于此来生成导入语句和替换代码。

其中变量名生成策略使用特殊前缀减少与用户变量的冲突从而避免污染,同时使用递增序号来保证唯一性

最终我们实现了一个基于数据驱动的架构,将来解析器只负责识别组件和返回路径的数据信息,然后导入生成器函数,也就是上述的 stringifyComponentImportstringifyImport 负责根据配置生成导入代码,我们整体的 Vite 插件就只负责协调流程和代码修改。

这种架构为后续引入真正的多解析器支持奠定了良好基础,只需要将硬编码的解析逻辑替换为可配置的解析器数组即可。

实现解析器 (Resolver)

我们引入解析器是为了提高插件的灵活性和可扩展性。当前插件硬编码了组件库的路径和样式文件,只能用于特定的组件库(cobyte-vite-ui)。通过引入解析器,我们可以让插件支持不同的组件库,用户可以根据需要配置不同的解析器。

解析器的作用是根据组件名返回一个解析结果,包括组件的导入路径和样式文件路径等。这样,插件就可以通过解析器来动态获取组件的导入信息,而不是固定写死。

改造步骤:

  1. 修改插件函数,使其可以接受一个选项对象,选项中包含解析器数组。
  2. 在插件内部,遍历解析器数组,对每个组件名尝试使用解析器进行解析。
  3. 如果某个解析器返回了结果,则使用该结果来生成导入语句。
  4. 如果没有解析器匹配,可以跳过该组件,也可以根据需求做其他处理。

参考 NaiveUi 基于 unplugin-vue-components 实现的解析器结构:

export function NaiveUiResolver(): ComponentResolver {
  return {
    type: 'component',
    resolve: (name: string) => {
      if (name.match(/^(N[A-Z]|n-[a-z])/))
        return { name, from: 'naive-ui', sideEffects: [] }
    },
  }
}
  • 解析器是一个对象,包含一个resolve方法,该方法接收组件名,返回一个解析结果对象或undefined。
  • 解析结果对象包括:name(组件名,可选,默认使用原始名),from(导入路径),sideEffects(样式文件路径等,可选)

我们还可以支持多种解析器,这样插件可以同时支持多个组件库。

下面我们按照这个思路改造插件代码。首先基于上述 NaiveUi 的解析器实现我们的测试组件的解析器,./packages/utils/index.ts 新增代码如下:

// 解析器函数
export function CobyteViteUiResolver() {
  return {
    type: 'component',
    resolve: (name: string) => {
      // 只处理 Co 开头的组件
      if (name.match(/^Co[A-Z]/)) {
        const partialName = kebabCase(name.slice(2)) // CoTableColumn -> table-column
        return { 
          name, 
          from: `cobyte-vite-ui/dist/components/${partialName}`, 
          sideEffects: ['cobyte-vite-ui/dist/style.css'] 
        }
      }
    },
  }
}

接下来修改插件函数,使其可以接受一个选项对象,选项中包含解析器数组,采用上下文管理,因此我们引入 Context 类,创建 Context 类来管理插件配置和解析器,并缓存解析结果,接着在 transform 钩子中,使用 Context 实例来查找组件,而不是硬编码解析逻辑。

+ export class Context {
+  options: any;
+  private _componentNameMap = {} // 组件缓存
+  constructor(private rawOptions: any) {
+    this.options = rawOptions
+  }

+  async findComponent(name: string) {
+    // 1. 检查缓存中是否有该组件的信息
+    let info = this._componentNameMap[name]
+    if (info) {
+      return info // 缓存命中,直接返回
+    }
+    // 2. 遍历所有解析器
+    for (const resolver of this.options.resolvers) {
+      const result = await resolver.resolve(name)
+      // 3. 判断解析器是否返回了结果
+      if (!result) {
+        continue
+      }
+      // 4. 构建完整组件信息
+      info = {
+        as: name, // 添加别名
+        ...result,
+      }
+      // 5. 存入缓存
+      this._componentNameMap[name] = info
+      return info
+    }
+    // 所有解析器都不匹配,返回 undefined
+  }
+ }

-  export default function VitePluginAutoComponents() {
+  export default function VitePluginAutoComponents(options = {}) {
+    // 创建 Context 实例,用于存储插件配置和组件信息
+    const ctx = new Context(options)
    return {
      // 插件名称,用于调试和错误信息
      name: 'vite-plugin-auto-component',
  
      // transform 钩子函数,在转换模块时调用
      // code: 文件内容,id: 文件路径
      async transform(code, id) {
        // 使用正则表达式检查文件是否为.vue文件
        // 如果不是.vue文件,不进行处理
        if(/\.vue$/.test(id)) {
            // 省略...
            
            let no = 0
            // 遍历所有匹配结果进行处理
            for (const { rawName, replace } of results) {
                // 将字符串转换为大驼峰
                const name = pascalCase(rawName)
-                // 只处理 Co 开头的组件
-                 if (!name.match(/^Co[A-Z]/)) return
-                // 组件路径转换
-                 const partialName = kebabCase(name.slice(2))
-                // 封装了组件的完整导入信息,作为数据载体传递给后续处理函数
-                 const component = {
-                     name,
-                     from: `cobyte-vite-ui/dist/components/${partialName}`,
-                     sideEffects: ['cobyte-vite-ui/dist/style.css']
-                 }
+                const component = await ctx.findComponent(name)
+                if (component) {
                  // 定义要替换的变量名(这里暂时编码为 CoButton)
                  // const varName = name
                  // 使用特殊前缀减少与用户变量的冲突,以及使用递增的序号,保证唯一性,避免变量名冲突
                  const varName = `__unplugin_components_${no}`
                  // 在代码开头添加导入语句:
                  // 1. 导入 CoButton 组件
                  // 2. 导入样式文件
                  // s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/${partialName}';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
                  // 这里将 component 对象展开,并添加 as: varName 参数,形成完整的导入配置
                  s.prepend(`${stringifyComponentImport({ ...component, as: varName })};\n`)
                  no += 1
                  // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名
                  replace(varName)
+                }
            }
  
            // 返回转换后的代码
            return {
                code: s.toString(),  // 转换后的代码字符串
                map: null, 
            }
        }
      },
    }
}

插件初始化时,创建 Context 实例,传入options,其中包含解析器 resolvers。Context 类提供了一个findComponent方法,用于根据组件名查找组件信息。该方法会先查看缓存,如果缓存中没有,则依次调用每个解析器的 resolve 方法,直到有一个解析器返回结果。然后将结果缓存起来。在 transform 钩子中,使用 Context 实例的findComponent 方法来查找组件信息,而不再是硬编码解析逻辑。这次迭代使插件从单一组件库的支持扩展到多组件库,使用缓存提高性能,通过解析器模式提高扩展性,并且通过异步解析查找组件信息、为未来异步解析预留接口。

经过此次的迭代,我们的插件实现了真正的解耦,插件核心只负责流程控制,解析逻辑则完全由解析器处理,配置管理则由 Context 统一管理,标准化了解析器的接口,这样所有解析器都遵循相同的接口,由此实现了强大的拓展性。

接着我们更新 play 项目中的 Vite 配置文件 vite.config.ts,更新如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
- import AutoComponents from 'cobyte-vite-ui/dist/utils'
+ import AutoComponents, { CobyteViteUiResolver} from 'cobyte-vite-ui/dist/utils'

export default defineConfig({
-  plugins: [vue(), AutoComponents()]
+  plugins: [vue(), AutoComponents({
+    resolvers: [CobyteViteUiResolver()]
+  })],
})

接着重新打包组件库,再重启 play 项目,我们发现依然正常,说明我们上述的改动是正确的。

多解析器配置

上文说了我们实现了插件从单一组件库的支持扩展到多组件库的按需加载解析,那么下面就让我们来测试一下。 首先我们往 packages/utils/index.ts 文件添加 Naive UI 的解析器,代码如下:

/**
 * Resolver for Naive UI
 *
 * @link https://www.naiveui.com/
 */
export function NaiveUiResolver() {
  return {
    type: 'component',
    resolve: (name: string) => {
      console.log('NaiveUiResolver', name, name.match(/^(N[A-Z]|n-[a-z])/));
      if (name.match(/^(N[A-Z]|n-[a-z])/))
        return { name, from: 'naive-ui' }
    },
  }
}

这个解析器是完全从 unplugin-vue-components 插件中搬过来的,我们测试一下是否能够在我们实现的插件中使用。

接着我们在 play 项目中安装 Naive UI 的依赖:

pnpm add naive-ui

然后在 App.vue 文件中引用 Naive UI 的组件:

<template>
  <co-button></co-button>
  <n-button type="primary">naive-ui</n-button>
</template>

接着修改 ./play/vite.config.ts 文件中的配置。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
- import AutoComponents, { CobyteViteUiResolver } from 'cobyte-vite-ui/dist/utils'
+ import AutoComponents, { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), AutoComponents({
-   resolvers: [CobyteViteUiResolver()]
+    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]
  })],
})

接着我们重新打包我们的测试组件库,再重启 play 测试项目,测试结果如下:

01.png

我们可以看到成功验证了我们上述的结论:我们实现了插件从单一组件库的支持扩展到多组件库的按需加载解

我们上面所实现的插件其实就是 unplugin-vue-components 库的实现原理,在 Vue 技术栈中都是通过这个库来实现组件按需加载的。

业务组件库按需加载实践

我们在 play 项目中安装 unplugin-vue-components 库来替换我们手写的插件。安装命令如下:

pnpm add unplugin-vue-components -D 

接着修改 play 项目中的 vite.config.ts 文件。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
- import AutoComponent, { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'
+ import { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'
+ import AutoComponents from 'unplugin-vue-components/vite';

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), AutoComponents({
    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]
  })],
})

然后重启 play 项目,我们发现我们的测试例子依然是正常运行的。所以我们一般的组件库或者业务组件库想要实现按需加载,只需要参考 unplugin-vue-components 库中提供的解析器,写一个符合自己的组件库的解析器再配合 unplugin-vue-components 即可。当然还有一个重要的前提,你的组件库得设计成模块化,即一个组件一个模块,互不关联或者弱关联。

业务组件库引用第三方组件库的按需加载

我们知道所谓业务组件库,就是一些基于第三方组件库开发的组件库,比如基于 Element Plus、Naive UI 开发的组件库。那么我们修改一下我们的测试组件库 ./packages/components/button,让它使用 Naive UI 的 button 组件,修改如下:

<template>
  <n-button type="warning">
    Warning
  </n-button>
</template>
<script setup lang="ts">
defineOptions({
  name: 'co-button',
});
</script>
<style lang="scss" scoped>
button {
  color: red;
}
</style>

接着我们重新打包测试组件库,然后重启 play 项目。我们发现测试组件库中 Naive UI 的按钮 Button 并没有生效。并且在浏览器的控制台报以下警报:

Failed to resolve component: n-button

这是因为我们使用 Vite 来打包组件库,Vite 默认会把代码进行压缩混淆。我们可以看一下打包后的测试 button 组件的代码。可以看到原本应该是 _resolveComponent("n-button") 的代码,因为 Vite 进行了压缩混淆而变成了 o("n-button")。

02.png

而我们的插件是基于 _resolveComponent 为前缀进行匹配的,现在前缀被压缩了也就肯定匹配到不到了。所以简单的处理方法就是修改 Vite 打包配置,让其不进行压缩混淆,毕竟 Element Plus、Naive UI 这些开源组件库打包后的产物也没有进行压缩混淆。所以我们修改 Vite 打包配置禁止构建压缩混淆。修改根目录下的 vite.config.ts 如下:

// 省略...

export default defineConfig(({ command, mode }) => {
    // 主构建配置
    return {
        // 省略...
        build: {
+            minify: false, // 禁止压缩混淆
            // 省略...
        },
    };
});

我们发现打包后的组件代码不压缩混淆了,但还是不生效,这是因为我们写的插件只解析 .vue 文件,而我们打包后的文件变成了 .mjs 了,所以我们要修改一下 play 项目的 Vite 配置让 .mjs 文件也可以被解析。修改 ./play/vite.config.ts 文件如下:

  // 省略...
export default defineConfig({
  plugins: [vue(), AutoComponents({
+    include: [
+      /\.vue$/,
+      /\.mjs$/
+    ],
    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]
  })],
})

经过上述修改后我们重启 play 项目,发现基于 Naive UI 二次开发的组件可以成功加载了。

03.png

依赖预构建配置

我们知道 Vite 会在第一次启动的时候把依赖预构建并缓存到 node_modules/.vite 目录中。主要有以下几个原因:

  1. 模块格式转换

许多 npm 包使用的是 CommonJS 或 UMD 格式,而 Vite 在开发环境中使用的是原生 ES 模块(ESM)。预构建会将这些包转换为 ESM 格式,使其能够在浏览器中直接运行。

  1. 性能优化 - 减少 HTTP 请求

某些包会有很多内部模块,如果不预构建,浏览器可能需要发起数百个 HTTP 请求。预构建会将这些模块打包成一个或少数几个文件。

典型例子:

  • lodash-es 有超过 600 个内置模块
  • 如果不预构建,会导致 600+ 个 HTTP 请求
  • 预构建后只需要 1-2 个请求
  1. 提升页面加载速度

预构建使用 esbuild(用 Go 编写),速度比传统 JavaScript 打包工具快 10-100 倍。通过将依赖预先打包并缓存,可以显著提升开发服务器的启动速度和模块热更新(HMR)的响应速度。

默认的时候 Vite 是通过 import 语句进行分析需要预构建的依赖的,但我们使用按需加载的插件之后,在代码中就有些 npm 包不存在 import 语句了,所以需要我们手动通过 optimizeDeps.include 选项设置预构建。

我们对 ./play/vite.config.ts 设置如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'
import AutoComponents from 'unplugin-vue-components/vite';
+ import pkg from './package.json';
+ const dependencies = Object.keys(pkg.dependencies);

export default defineConfig({
  plugins: [vue(), AutoComponents({
    include: [
      /\.vue$/,
      /\.mjs$/
    ],
    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]
  })],
+  optimizeDeps: {
+    include: [...dependencies]
+  }
})

由于 Node 不处理虚拟链接,同时为了更真实验证真实场景,我们把测试组件库改成更加真实,首先修改 ./play/packages.json

{
  // 省略...
  "dependencies": {
-    "cobyte-vite-ui": "link:..",
    "cobyte-vite-ui": "^1.0.0",
    "naive-ui": "^2.43.1",
    "vue": "^3.5.22"
  },
  // 省略...
}

同时删掉 ./play/node_modules 目录中的 cobyte-vite-ui 虚拟目录,再重新创建一个 cobyte-vite-ui 目录,同时把根目录下的 ./dist 目录中的内容和根目录下的 packages.json 文件复制到刚刚新创建的 cobyte-vite-ui 目录中,这相当于手动安装我们创建的测试组件库的依赖了。之后我们再删掉 ./play/node_modules/.vite 目录的预构建缓存,再重启 play 项目。这时我们发现 cobyte-vite-ui 组件库中引用的 Naive UI 的 button 组件不生效了。这是因为我们把 cobyte-vite-ui 进行预构建后,它的内容就会被预构建后缓存到 ./play/node_modules/.vite 目录中了,而 unplugin-vue-components 插件默认是不解析 node_modules 目录中的文件的,所以我们可以修改 unplugin-vue-components 插件的配置让其可以解析 node_modules 目录中的文件,但这不是最优的方案。最优的方案是在打包 cobyte-vite-ui 组件库的时候就 进行按需打包。我们在根目录下安装 unplugin-vue-components 依赖。

pnpm add unplugin-vue-components -D -w

我们在安装上述依赖的时候,可能会报以下错误:

04.png

这是因为我们刚刚把 play 目录中的测试组件库 cobyte-vite-ui 改了正式库一样的依赖,我们可以暂时把它改回虚拟依赖。

"dependencies": {
-    "cobyte-vite-ui": "^1.0.0",
+    "cobyte-vite-ui": "link:..",
    "naive-ui": "^2.43.1",
    "vue": "^3.5.22"
},

再进行安装即可。

然后新增根目录下的 vite.config.ts 文件的配置:

// 省略...
+ import AutoComponents from 'unplugin-vue-components/vite';
+ import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';

// 省略...

export default defineConfig(({ command, mode }) => {
    // 主构建配置
    return {
        plugins: [
            vue(),
+            AutoComponents({
+                resolvers: [NaiveUiResolver()]
+            })
        ],
        build: {
            // 省略...
            rollupOptions: {
                external: [
                    "vue", 
+                    "naive-ui", // 排除打包
                ],
            // 省略..
            },
        },
    };
});

配置完后,重新打包我们的测试组件库,打包完后,重新删掉 ./play/node_modules/.vite 中的缓存,和 ./play/node_modules/cobyte-vite-ui 中的内容,重新把刚刚新打包的根目录下的 ./dist 目录中的内容和根目录下的 packages.json 文件复制到 ./play/node_modules/cobyte-vite-ui 中,同时恢复修改的 ./play/packages.json 文件,然后重启 play 项目。

这时我们就发现测试项目可以正常渲染了。

至此我们业务组件库按需加载的实现原理就都讲得差不多了,有什么可以在评论区交流。

总结

看完了全篇文章相信你会觉得,所谓组件库按需加载或者业务组件库按需加载其实很简单,首先组件库的每一个组件都得设计成独立的模块,并且可以按模块导入,也就是 ESM 化,可以进行 Tree Shaking,只有这样按需加载才有意义,才能达到减小包体积的作用。

全局组件在模板中使用被编译后会通过一个内置函数 resolveComponent 来调用组件,按需加载的实现原理就是通过插件进行正则匹配查找编译后的模板代码中的 resolveComponent 函数的相关代码来找到需要按需加载的组件,然后自动按编译后的代码的头部添加需要加载的组件的导入语句代码以及替换掉 resolveComponent 函数的相关代码为对应的组件对象。

而业务组件实现按需加载的关键是需要在业务组件库打包的时候也进行按需加载配置。虽然这个关键步骤很简单,但由于这是一个低频且跨项目的需求,所以AI对低频的需求的实现和给的解决方案都不尽人意,至少本人解决上述问题时,AI提供方案没有一个可以实现的,虽然最后的实现其实很简单。

最后,再说说个人对AI的一些感悟吧,个人觉得在AI时代,就编程这个领域而言对个人的专业要求会比以前更加的高,至少你得有能力去解决AI不会的问题。

上述组件库测试代码地址:github.com/amebyte/cob…

欢迎关注本专栏,了解更多 Element Plus 组件库知识

本专栏文章:

1. Vue3 组件库的设计和实现原理

2. 组件库工程化实战之 Monorepo 架构搭建

3. ESLint 核心原理剖析

4. ESLint 技术原理与实战及代码规范自动化详解

5. 从终端命令解析器说起谈谈 npm 包管理工具的运行原理

6. CSS 架构模式之 BEM 在组件库中的实践

7. 组件实现的基本流程及 Icon 组件的实现

8. 为什么组件库或插件需要定义 peerDependencies

9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

10. CSS 系统颜色和暗黑模式的关系及意义

11. 深入理解组件库中SCSS和CSS变量的架构应用和实践

12. 组件 v-model 的封装实现原理及 Input 组件的核心实现

13. 深入理解 Vue3 的 v-model 及自定义指令的实现原理

14. React 和 Vue 都离不开的表单验证工具库 async-validator 之策略模式的应用

15. Form 表单的设计与实现

16. 组件库的打包原理与实践详解

17. Vue3 业务组件库按需加载的实现原理

昨天以前首页

🧸 前端不是只会写管理后台,我用 400 行代码画了一个 LABUBU !

作者 xiaohe0601
2025年11月13日 20:12

注意看,这个男人叫小何,别小看他,每天晚上 9 点 59 分他都准时打开泡泡玛特小程序蹲守 LABUBU 抢购。就在刚才,屏幕时钟倒计时又到 00:00:00 了,他立刻开始狂戳屏幕上的「立即购买」按钮,切换「购买方式」反复刷新库存,熟练的让人心疼。

可是,现实却从来没有什么“功夫不负有心人”,有的只是无数“黄牛”挥舞着自己的“科技”与小何同台竞技。毫无意外,今天的小何依然没有胜利,看着屏幕上的「已售罄」陷入了沉思 ……

拼尽全力也无法战胜吗?

空气里漂泊着手机屏幕反射的冷光,小何指尖的汗渍在「已售罄」三个字上洇出淡淡的印子。屏幕里 LABUBU 的笑脸还在倔强 —— 那只顶着毛茸茸耳朵、圆眼圆腮的小家伙,本该是用来治愈生活的,此刻却成了科技与欲望“厮杀”后,留给普通人的一道冷疤。

技术从来都该是温柔的,当“黄牛”用它筑起壁垒时,或许我该用同样的东西,造一扇窗!

我是一名前端开发工程师,不是切图仔,不是只会写管理后台,今天势必要夺回失去的一切!

是的,我画了一个专属于自己的 LABUBU !

👉 在线体验:labubu.xiaohe.ink

✍️ 开始创作

LeaferJS 是一款好用的 Canvas 引擎,革新的开发体验,可用于高效绘图 、UI 交互、图形编辑。

Leafer Vue 是由 @FliPPeDround 基于 LeaferJS 创建的项目,可以使用 Vue 组件化轻松构建 Leafer 应用,具有以下特性:

  • 使用 Vue 构建 Leafer 应用,高性能
  • 生态统一,完全兼容 Leafer 插件
  • 由 TypeScript 编写,提供强大的类型支持
  • 提供在线演练场,即开即用、畅享创作

现在,我们将使用 Leafer Vue 一起来完成这个作品!

一半茶叶蛋

首先是 LABUBU 的脑袋,看起来有点像被切开的茶叶蛋,可以用两段二次贝塞尔曲线来绘制一个非对称椭圆表示。

我们先编写 createBezierEllipsePath 工具方法,用于生成更自然流畅的椭圆路径:

import { PathCreator } from "leafer-ui";

interface Point {
  x: number;
  y: number;
}

/**
 * 以控制点 cp 为中心反射生成点 p 关于它的对称点
 */
function reflect(p: Point, cp: Point) {
  return {
    x: p.x + (p.x - cp.x),
    y: p.y + (p.y - cp.y)
  };
}

/**
 * 创建非对称椭圆路径
 */
export function createBezierEllipsePath(p1: Point, p2: Point, ox: number, oy: number) {
  const cp1 = { x: p1.x + ox, y: p1.y + oy };
  const cp2 = { x: p2.x - ox, y: p2.y + oy };

  // 通过反射生成另外两个控制点
  const cp3 = reflect(p2, cp2);
  const cp4 = reflect(p1, cp1);

  return new PathCreator()
    .moveTo(p1.x, p1.y)
    // 第 1 段贝塞尔曲线
    .bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y)
    // 第 2 段贝塞尔曲线
    .bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p1.x, p1.y)
    .closePath()
    .path;
}

然后调用 createBezierEllipsePath 创建头部和脸部的路径:

const headPath = createBezierEllipsePath(
  { x: 40, y: 240 },
  { x: 260, y: 240 },
  28,
  -120
);

const facePath = createBezierEllipsePath(
  { x: 60, y: 260 },
  { x: 240, y: 260 },
  -10,
  80
);

使用 Path 标签传入路径,再加上填充色和描边:

<!-- 头 -->
<Path
  :path="headPath"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 脸 -->
<Path
  :path="facePath"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="3"
></Path>

✨ 脑袋部分完成啦!

一个魔丸

画好了脑袋,现在开始画五官。光看五官 LABUBU 跟“魔丸”哪吒是不是有点神似?哪吒和泡泡玛特甚至推出过联名款!

眼睛画起来很简单,直接使用 Ellipse 标签绘制几个椭圆组合起来就好,至于眉毛就用 Line 标签画一条曲线吧 ~

<!-- 左眼白 -->
<Ellipse
  :x="93"
  :y="228"
  :width="40"
  :height="60"
  fill="#f9f9f9"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 左上眼睑 -->
<Ellipse
  :x="96"
  :y="206"
  :width="44"
  :height="26"
  :rotation="10"
  :start-angle="20"
  :end-angle="154"
  fill="#ffd9d0"
></Ellipse>

<!-- 左眉毛 -->
<Line
  :points="[96, 226, 104, 233, 124, 235, 134, 232]"
  curve
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
></Line>

<!-- 左眼球 -->
<Ellipse
  :x="100"
  :y="242"
  :width="28"
  :height="45"
  fill="#000000"
></Ellipse>

<!-- 左眼光 -->
<Ellipse
  :x="111"
  :y="245"
  :width="6"
  :height="10"
  fill="#ffffff"
></Ellipse>

<!-- 右眼白 -->
<Ellipse
  :x="165"
  :y="228"
  :width="40"
  :height="60"
  fill="#f9f9f9"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 右上眼睑 -->
<Ellipse
  :x="158"
  :y="214"
  :width="44"
  :height="26"
  :rotation="-10"
  :start-angle="24"
  :end-angle="158"
  fill="#ffd9d0"
></Ellipse>

<!-- 右眉毛 -->
<Line
  :points="[164, 232, 176, 236, 194, 233, 202, 226]"
  curve
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
></Line>

<!-- 右眼球 -->
<Ellipse
  :x="171"
  :y="242"
  :width="28"
  :height="45"
  fill="#000000"
></Ellipse>

<!-- 右眼光 -->
<Ellipse
  :x="181"
  :y="245"
  :width="6"
  :height="10"
  fill="#ffffff"
></Ellipse>

鼻子也是一个非对称椭圆,可以用之前编写的 createBezierEllipsePath 创建一个小小的椭圆:

const nosePath = createBezierEllipsePath(
  { x: 141, y: 275 },
  { x: 157, y: 275 },
  2,
  9
);
<!-- 鼻子 -->
<Path
  :path="nosePath"
  fill="#ff0154"
  stroke="#000000"
  :stroke-width="2"
></Path>

嘴巴是一条 0.76 曲率的曲线,使用 Path 标签的 curve 参数可以轻松实现。

但是牙齿画起来就比较麻烦了,因为要紧密贴合嘴巴曲线,所以我们需要编写一个方法将嘴巴的曲率转换为三次贝塞尔曲线,再根据传入牙齿的数量和大小沿曲线切线方向排布并生成对应的路径数组。

方法的具体实现如下:

// 嘴巴曲线
const mouthPoints = [76, 266, 150, 304, 224, 266];
// 嘴巴曲率
const mouthCurve = 0.76;

/**
 * 创建牙齿路径
 */
function createTeethPaths(
  count: number,
  toothWidth: number,
  toothHeight: number,
  curve: number
) {
  const p1 = { x: mouthPoints[0], y: mouthPoints[1] };
  const c0 = { x: mouthPoints[2], y: mouthPoints[3] };
  const p2 = { x: mouthPoints[4], y: mouthPoints[5] };

  function lerp(a: number, b: number, t: number) {
    return a + (b - a) * t;
  }

  // 贝塞尔曲线中间控制点
  const c1 = {
    x: lerp(p1.x, c0.x, 0.5) - curve * 20,
    y: lerp(p1.y, c0.y, 0.5) + curve * 43
  };
  const c2 = {
    x: lerp(c0.x, p2.x, 0.5) + curve * 20,
    y: lerp(c0.y, p2.y, 0.5) + curve * 43
  };

  /**
   * 三次贝塞尔计算
   */
  function cubic(t: number): [number, number] {
    return [
      (1 - t) ** 3 * p1.x + 3 * (1 - t) ** 2 * t * c1.x + 3 * (1 - t) * t ** 2 * c2.x + t ** 3 * p2.x,
      (1 - t) ** 3 * p1.y + 3 * (1 - t) ** 2 * t * c1.y + 3 * (1 - t) * t ** 2 * c2.y + t ** 3 * p2.y
    ];
  }

  /**
   * 贝塞尔切线
   */
  function derivative(t: number): [number, number] {
    return [
      3 * (1 - t) ** 2 * (c1.x - p1.x) + 6 * (1 - t) * t * (c2.x - c1.x) + 3 * t ** 2 * (p2.x - c2.x),
      3 * (1 - t) ** 2 * (c1.y - p1.y) + 6 * (1 - t) * t * (c2.y - c1.y) + 3 * t ** 2 * (p2.y - c2.y)
    ];
  }

  const value: number[][] = [];

  for (let i = 0; i < count; i += 1) {
    const t = i / (count - 1);

    const [cx, cy] = cubic(t);
    const [dx, dy] = derivative(t);

    const length = Math.sqrt(dx * dx + dy * dy);

    // 法向量
    const nx = -dy / length;
    const ny = dx / length;

    const halfWidth = toothWidth / 2;

    const x1 = cx - halfWidth * dx / length;
    const y1 = cy - halfWidth * dy / length;
    const x2 = cx + halfWidth * dx / length;
    const y2 = cy + halfWidth * dy / length;

    const xt = cx + toothHeight * nx;
    const yt = cy + toothHeight * ny;

    const path = new PathCreator()
      .moveTo(x1, y1)
      .quadraticCurveTo(xt, yt, x2, y2)
      .closePath()
      .path;

    value.push(path);
  }

  return value;
}

const teethPaths = createTeethPaths(11, 16, 18, mouthCurve);

然后使用 v-for 循环生成牙齿:

<!-- 嘴巴 -->
<Line
  :points="mouthPoints"
  :curve="mouthCurve"
  stroke="#000000"
  :stroke-width="2"
  stroke-cap="round"
></Line>

<!-- 牙齿 -->
<Path
  v-for="(item, index) in teethPaths"
  :key="index"
  :path="item"
  fill="#ffffff"
  stroke="#000000"
  :stroke-width="2"
></Path>

🥳 我们完成了整个作品中最困难的部分!

滑稽兔耳朵

LABUBU 的耳朵跟滑稽兔很像,画起来也比较容易,用 Ellipse 标签绘制两个纵向的扁椭圆:

<!-- 左耳 -->
<Ellipse
  :x="74"
  :y="56"
  :width="65"
  :height="150"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Ellipse>

<!-- 右耳 -->
<Ellipse
  :x="156"
  :y="56"
  :width="65"
  :height="150"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Ellipse>

再用两个 Ellipse 标签绘制不同颜色的小椭圆表示内耳和耳蜗:

<!-- 左内耳 -->
<Ellipse
  :x="82"
  :y="72"
  :width="50"
  :height="120"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 左耳蜗 -->
<Ellipse
  :x="95"
  :y="118"
  :width="26"
  :height="60"
  fill="#ffbbbf"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 右内耳 -->
<Ellipse
  :x="164"
  :y="72"
  :width="50"
  :height="120"
  fill="#ffd9d0"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

<!-- 右耳蜗 -->
<Ellipse
  :x="176"
  :y="118"
  :width="26"
  :height="60"
  fill="#ffbbbf"
  stroke="#000000"
  :stroke-width="2"
></Ellipse>

🐰 整个头部都完成啦!

像个布娃娃

身体部分需要花一些心思,我们这里使用两段二次贝塞尔曲线(手臂)和两段三次贝塞尔曲线(腿)组合完成:

const bodyPath = new PathCreator()
  .moveTo(84, 316)
  .quadraticCurveTo(40, 374, 90, 368)
  .bezierCurveTo(74, 460, 140, 440, 147, 430)
  .bezierCurveTo(154, 444, 224, 454, 204, 368)
  .quadraticCurveTo(254, 374, 210, 316)
  .closePath()
  .path;

再加上填充色和描边就形成了身体:

<!-- 身体 -->
<Path
  :path="bodyPath"
  fill="#984628"
  stroke="#000000"
  :stroke-width="3"
></Path>

🐻 是不是很像一个布娃娃?可爱捏!

加上小手和小脚

终于到了作品的最后一部分,使用多段二次贝塞尔曲线组合绘制出 LABUBU 的小手和小脚:

const leftHandPath = new PathCreator()
  .moveTo(68, 352)
  .quadraticCurveTo(48, 348, 59, 360)
  .quadraticCurveTo(42, 372, 58, 370)
  .quadraticCurveTo(50, 386, 66, 372)
  .quadraticCurveTo(68, 392, 76, 366)
  .closePath()
  .path;

const rightHandPath = new PathCreator()
  .moveTo(226, 352)
  .quadraticCurveTo(246, 348, 235, 360)
  .quadraticCurveTo(252, 372, 236, 370)
  .quadraticCurveTo(244, 386, 228, 372)
  .quadraticCurveTo(226, 392, 218, 366)
  .closePath()
  .path;

const leftFootPath = new PathCreator()
  .moveTo(104, 430)
  .quadraticCurveTo(103, 456, 115, 444)
  .quadraticCurveTo(122, 456, 128, 444)
  .quadraticCurveTo(144, 456, 140, 430)
  .closePath()
  .path;

const rightFootPath = new PathCreator()
  .moveTo(191, 430)
  .quadraticCurveTo(192, 456, 180, 444)
  .quadraticCurveTo(173, 456, 167, 444)
  .quadraticCurveTo(151, 456, 155, 430)
  .closePath()
  .path;
<!-- 左手 -->
<Path
  :path="leftHandPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 右手 -->
<Path
  :path="rightHandPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 左脚 -->
<Path
  :path="leftFootPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

<!-- 右脚 -->
<Path
  :path="rightFootPath"
  fill="#ffdbd7"
  stroke="#000000"
  :stroke-width="3"
></Path>

🎉 LABUBU 诞生!

🖥️ 源码

项目的完整代码可以在 leafer-labubu 仓库中查看。

赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!

🍬 感谢

项目灵感及图形创意来源于 LABUBU 简笔画教程 - Thomas

🍵 写在最后

我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。

欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!

前端高频面试题之Vuex篇

2025年11月14日 21:15

1、Vuex 是什么?什么情况下应该使用 Vuex?

Vuex 是专门为 Vue.js 应用提供状态管理模式的一个库,也是 Vue.js 官方推荐的状态管理方案,它将所有数据集中存储到一个全局 store 对象中,并制定了一定的规则,保证状态以预期的方式发生变化。

它的核心概念有:

  • state:存储状态,并提供响应式能力。
  • getter: 从 state 中派生出一些状态,相当于 Vue.js 中的计算属性 computed。
  • mutation: 通过提交 mutation,是 Vuex 中修改 state 的推荐方式。
  • action:可以包括异步操作,异步操作处理完后,通过提交 mutation 修改状态。
  • module: 模块化,可以将 store 分割成一个个小模块,每个模块拥有自己的 state、getter、mutation、action,甚至是嵌套子模块。

在构建中大型单页应用时,各组件和模块的状态流转逻辑会相当复杂,这时候就可以使用 Vuex 进行全局状态管理,并且里面用严格的 mutation 保证了状态的预期流转,使得项目的数据流变得清晰,提高了项目可维护性。

2、如何解决页面刷新后 Vuex 的数据丢失问题?

数据丢失原因:Vuex 中的状态 state 是存储在内存中的,刷新页面会导致内存清空,所以数据丢失。

解决方案:

2.1 第一步:使用持久化存储保存数据

将 Vuex 的数据在合适时机(比如监听 window 的beforeunload 事件)保存到浏览器的本地存储(localStoragesessionStorage),也可以直接采用 vuex-persistedstate 持久化插件(默认会存储到 localStorage 中,可通过配置修改)进行本地存储。

2.2 第二步:初始化应用,替换状态

应用初始化加载时,获取存储中的状态进行替换。Vuex 给我们提供了一个 replaceState(state: Object) API,可以很方便进行状态替换。

2.3 第三步:检查数据,发起请求

在状态替换后,还需要检查 Vuex 中的数据是否存在,如果不存在则可以在 action 中发送接口请求拿到数据,通过提交 mutation 修改状态把数据存储到 store 中。

2.4 第四步:状态同步

状态变化后将状态同步到浏览器存储中,保证本地存储中状态的实时性。

不过要注意的是,如果把数据持久化到 localStorage 或者 sessionStorage 中,会有一定的安全风险:

  1. 数据直接全部暴露在 storage 可通过控制台的 Application 选项卡进行查看,数据容易泄漏。持久化的数据毕竟没有内存中的数据安全。
  2. 用户可以直接在控制台 Application 中直接修改数据,从而可能绕过某些权限校验,看到一些预期外的界面和交互。

3、mutation 和 action 的区别有哪些?

  • 作用不同:action 是用来处理异步逻辑或者业务逻辑,而 mutation 是用来修改状态的。
  • 使用限制:action 中可以调用 mutation 或者其他 action,而 mutation 中则只能修改 state。
  • 返回值不同dispatch 时会将 action 包装成 promise,而 mutation 则没进行包装。
  • 严格模式下的差异:在 Vuex 开启严格模式 strict: true 后,任何非 mutation 函数修改的状态,将会抛出错误。

扩展:vuex 严格模式是如何监听非 mutation 函数修改状态的?

其核心思路如下:

  1. this._committing 表示程序是否处于 commit 执行过程。
  2. 用同步 watch(同步监听的意思是,一旦数据发生变化会立即调用回调,而不是在 下一次 Tick 中调用) 监听 store 中的 state 状态(深度监听)。
  3. 如果在 commit 执行过程中,state 发生了变化,在开发环境会报错。
class Store {
  commit(_type, _payload, _options) {
    this._withCommit(() => {
      // commit 中的处理
      entry.forEach(function commitIterator(handler) {
        handler(payload);
      });
    });
  }
  _withCommit(fn) {
    const committing = this._committing;
    this._committing = true;
    fn(); // 如果函数内部有异步修改状态逻辑,则下面的 watch 时会报错
    this._committing = committing;
  }
}
function enableStrictMode(store) {
  watch(
    () => store._state.data,
    () => {
      if (__DEV__) { // 开发环境报错
        assert(
          store._committing,
          `do not mutate vuex store state outside mutation handlers.`
        );
      }
    },
    { deep: true, flush: "sync" } // 定义同步的 watcher 进行同步监控
  );
}

4、Vuex 的 module 在什么情况下会使用?

用官方的话来说就是,“使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得非常复杂时,store 对象就有可能变得相当臃肿。”

所以我们在开发复杂应用时,可以按照业务逻辑将应用状态进行 modules 拆分,比如:

  1. 用户模块 user;
  2. 订单模块 order;
  3. 课程模块 course;
  4. ...等其它模块。

这样在开发应用和维护状态时更加精细和清晰,可维护性更强。

5、Vuex 和 Pinia 的区别?

Pinia 是以 Vuex 5 为原型,由 Vue.js 官方团队开发的新一代 Vue 官方推荐的状态管理方案。

它对比 Vuex 有以下区别:

5.1 API 设计和使用方式

  • Vuex:采用单一 store 结构,需要严格区分 mutation(同步修改状态)和 action(异步操作)。状态修改必须通过 commit mutations 进行,虽然让数据流向更清晰,但也会让代码更加冗长。
  • Pinia:更简单的 API 设计,所见即所得,也提供了符合组合式 API 风格的 API(比如用 defineStore 定义 store)。去掉了 mutation,直接在 actions 中修改 state(支持同步/异步)。

5.2 模块化和结构

  • Vuex:支持模块化(modules),但需要在单一 store 中组织,可能导致大型项目 store 膨胀。
  • Pinia:天生模块化,每个 store 独立定义和导入,支持动态注册和热重载。更适合大型应用,便于拆分成小 store。

5.3 TypeScript 支持

  • Vuex:TypeScript 支持一般,需要额外配置;
  • Pinia:本身源码就是用 TypeScript 编写,所以对TypeScript 支持十分友好,具备自动推断类型、类型安全和代码补全。

5.4 性能和集成

  • Vuex:Vuex4 在 Vue3 中可用,但与 Composition API 集成不够顺畅,可能需要额外的适配;
  • Pinia:更轻量(体积小,约1kb),性能更好;完美支持 Vue 3 的 Composition API 和 reactivity 系统。

6、Pinia 和 Vuex 如何选择?

  • 新项目:强烈推荐用 Vue3 + Pinia
  • 老 Vue2 项目:如果不把项目升级到 Vue3 还是建议用 Vuex,如果需要升级到 vue3,就可以逐步把 Vuex 替换为 Pinia,Vuex 和 Pinia 是可以同时安装在同一个项目中,这也为项目升级提供了一定的便利。当然,由 Vuex -> Pinia,是一次,无疑和 Vue2 -> Vue3 一样,是一次大的破坏性升级,工作量还是相当大的。

结语

以上是整理的 Vuex 的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vue-router 相关面试题。

《uni-app跨平台开发完全指南》- 07 - 数据绑定与事件处理

2025年11月14日 17:38

引言:在上一章节中,我们详细介绍了页面路由与导航的相关知识点。今天我们讨论的是数据绑定与事件处理,深入研究数据是如何流动、用户交互如何响应的问题。我们平时用的app比如说输入框中打字,下方实时显示输入内容。这个看似简单的交互背后,隐藏着前端框架的核心思想——数据驱动视图

对比:传统DOM操作 vs 数据驱动

graph TB
    A[传统DOM操作] --> B[手动选择元素]
    B --> C[监听事件]
    C --> D[直接修改DOM]
    
    E[数据驱动模式] --> F[修改数据]
    F --> G[框架自动更新DOM]
    G --> H[视图同步更新]

在传统开发中,我们需要:

// 传统方式
const input = document.getElementById('myInput');
const display = document.getElementById('display');

input.addEventListener('input', function(e) {
    // 手动更新DOM
    display.textContent = e.target.value; 
});

而在 uni-app 中:

<template>
  <input v-model="message">
  <div>{{ message }}</div>
</template>

<script>
export default {
  data() {
    return {
      // 只需关注数据,DOM自动更新
      message: '' 
    }
  }
}
</script>

这种模式的转变,正是现代前端框架的核心突破。下面让我们深入研究其实现原理。


一、响应式数据绑定

1.1 数据劫持

Vue 2.x 使用 Object.defineProperty 定义对象属性实现数据响应式,让我们通过一段代码来加深理解这个机制:

// 响应式原理
function defineReactive(obj, key, val) {
  // 每个属性都有自己的依赖收集器
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log(`读取属性 ${key}: ${val}`)
      // 依赖收集:记录当前谁在读取这个属性
      dep.depend()
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log(`设置属性 ${key}: ${newVal}`)
      if (newVal === val) return
      val = newVal
      // 通知更新:值改变时通知所有依赖者
      dep.notify()
    }
  })
}

// 测试
const data = {}
defineReactive(data, 'message', 'Hello')
data.message = 'World'    // 控制台输出:设置属性 message: World
console.log(data.message) // 控制台输出:读取属性 message: World

1.2 完整的响应式系统架构

graph LR
    A[数据变更] --> B[Setter 触发]
    B --> C[通知 Dep]
    C --> D[Watcher 更新]
    D --> E[组件重新渲染]
    E --> F[虚拟DOM Diff]
    F --> G[DOM 更新]
    
    H[模板编译] --> I[收集依赖]
    I --> J[建立数据与视图关联]

原理说明

  • 当对响应式数据进行赋值操作时,会触发通过Object.defineProperty定义的setter方法。
  • setter首先比较新旧值是否相同,如果相同则直接返回,避免不必要的更新。
  • 如果值发生变化,则更新数据,并通过依赖收集器(Dep)通知所有观察者(Watcher)进行更新。
  • 这个过程是同步的,但实际的DOM更新是异步的,通过队列进行批量处理以提高性能。

1.3 v-model 的双向绑定原理

v-model 不是魔法,而是语法糖:

<!-- 这行代码: -->
<input v-model="username">

<!-- 等价于: -->
<input 
  :value="username" 
  @input="username = $event.target.value"
>

原理分解:

sequenceDiagram
    participant U as 用户
    participant I as Input元素
    participant V as Vue实例
    participant D as DOM视图
    
    U->>I: 输入文字
    I->>V: 触发input事件,携带新值
    V->>V: 更新data中的响应式数据
    V->>D: 触发重新渲染
    D->>I: 更新input的value属性

1.4 不同表单元素的双向绑定

文本输入框

<template>
  <view class="example">
    <text class="title">文本输入框绑定</text>
    <input 
      type="text" 
      v-model="textValue" 
      placeholder="请输入文本"
      class="input"
    />
    <text class="display">实时显示: {{ textValue }}</text>
    
    <!-- 原理展示 -->
    <view class="principle">
      <text class="principle-title">实现原理:</text>
      <input 
        :value="textValue" 
        @input="textValue = $event.detail.value"
        placeholder="手动实现的v-model"
        class="input"
      />
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      textValue: ''
    }
  }
}
</script>

<style scoped>
.example {
  padding: 20rpx;
  border: 2rpx solid #eee;
  margin: 20rpx;
  border-radius: 10rpx;
}
.title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
}
.display {
  color: #007AFF;
  font-size: 28rpx;
}
.principle {
  background: #f9f9f9;
  padding: 20rpx;
  border-radius: 8rpx;
  margin-top: 30rpx;
}
.principle-title {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-bottom: 15rpx;
}
</style>

单选按钮组

<template>
  <view class="example">
    <text class="title">单选按钮组绑定</text>
    
    <radio-group @change="onGenderChange" class="radio-group">
      <label class="radio-item">
        <radio value="male" :checked="gender === 'male'" /></label>
      <label class="radio-item">
        <radio value="female" :checked="gender === 'female'" /></label>
    </radio-group>
    
    <text class="display">选中: {{ gender }}</text>
    
    <!-- 使用v-model -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <radio-group v-model="simpleGender" class="radio-group">
      <label class="radio-item">
        <radio value="male" /></label>
      <label class="radio-item">
        <radio value="female" /></label>
    </radio-group>
    
    <text class="display">选中: {{ simpleGender }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      gender: 'male',
      simpleGender: 'male'
    }
  },
  methods: {
    onGenderChange(e) {
      this.gender = e.detail.value
    }
  }
}
</script>

<style scoped>
.radio-group {
  display: flex;
  gap: 40rpx;
  margin: 20rpx 0;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

复选框数组

<template>
  <view class="example">
    <text class="title">复选框数组绑定</text>
    
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          :checked="selectedHobbies.includes(hobby.value)"
          @change="onHobbyChange($event, hobby.value)"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ selectedHobbies }}</text>
    
    <!-- v-model简化版 -->
    <text class="title" style="margin-top: 40rpx;">v-model简化版</text>
    <view class="checkbox-group">
      <label 
        v-for="hobby in hobbyOptions" 
        :key="hobby.value"
        class="checkbox-item"
      >
        <checkbox 
          :value="hobby.value" 
          v-model="simpleHobbies"
        /> 
        {{ hobby.name }}
      </label>
    </view>
    
    <text class="display">选中: {{ simpleHobbies }}</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      hobbyOptions: [
        { name: '篮球', value: 'basketball' },
        { name: '阅读', value: 'reading' },
        { name: '音乐', value: 'music' },
        { name: '旅行', value: 'travel' }
      ],
      selectedHobbies: ['basketball'],
      simpleHobbies: ['basketball']
    }
  },
  methods: {
    onHobbyChange(event, value) {
      const checked = event.detail.value.length > 0
      if (checked) {
        if (!this.selectedHobbies.includes(value)) {
          this.selectedHobbies.push(value)
        }
      } else {
        const index = this.selectedHobbies.indexOf(value)
        if (index > -1) {
          this.selectedHobbies.splice(index, 1)
        }
      }
    }
  }
}
</script>

<style scoped>
.checkbox-group {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
</style>

二、事件处理

2.1 事件流:从点击到响应

浏览器中的事件流包含三个阶段:

graph TB
    A[事件发生] --> B[捕获阶段 Capture Phase]
    B --> C[目标阶段 Target Phase]
    C --> D[冒泡阶段 Bubble Phase]
    
    B --> E[从window向下传递到目标]
    C --> F[在目标元素上触发]
    D --> G[从目标向上冒泡到window]
解释说明:

第一阶段: 捕获阶段(事件从window向下传递到目标元素) 传递路径:Window → Document → HTML → Body → 父元素 → 目标元素; 监听方式:addEventListener(event, handler, true)第三个参数设为true;

第二阶段: 目标阶段(事件在目标元素上触发处理程序) 事件处理:在目标元素上执行绑定的事件处理函数,无论是否使用捕获模式; 执行顺序:按照事件监听器的注册顺序执行,与捕获/冒泡设置无关;

第三阶段: 冒泡阶段(事件从目标元素向上冒泡到window) 传递路径:目标元素 → 父元素 → Body → HTML → Document → Window; 默认行为:大多数事件都会冒泡,但focus、blur等事件不会冒泡;

2.2 事件修饰符原理详解

事件修饰符

.stop 修饰符原理

// .stop 修饰符的实现原理
function handleClick(event) {
  // 没有.stop时,事件正常冒泡
  console.log('按钮被点击')
  // 事件会继续向上冒泡,触发父元素的事件处理函数
}

function handleClickWithStop(event) {
  console.log('按钮被点击,但阻止了冒泡')
  event.stopPropagation() 
  // 事件不会继续向上冒泡
}

事件修饰符对照表

修饰符 原生JS等价操作 作用 使用场景
.stop event.stopPropagation() 阻止事件冒泡 点击按钮不触发父容器点击事件
.prevent event.preventDefault() 阻止默认行为 阻止表单提交、链接跳转
.capture addEventListener(..., true) 使用捕获模式 需要在捕获阶段处理事件
.self if (event.target !== this) return 仅元素自身触发 忽略子元素触发的事件
.once 手动移除监听器 只触发一次 一次性提交按钮

2.3 综合案例

<template>
  <view class="event-demo">
    <!-- 1. .stop修饰符 -->
    <view class="demo-section">
      <text class="section-title">1. .stop 修饰符 - 阻止事件冒泡</text>
      <view class="parent-box" @click="handleParentClick">
        <text>父容器 (点击这里会触发)</text>
        <button @click="handleButtonClick">普通按钮</button>
        <button @click.stop="handleButtonClickWithStop">使用.stop的按钮</button>
      </view>
      <text class="log">日志: {{ logs }}</text>
    </view>

    <!-- 2. .prevent修饰符 -->
    <view class="demo-section">
      <text class="section-title">2. .prevent 修饰符 - 阻止默认行为</text>
      <form @submit="handleFormSubmit">
        <input type="text" v-model="formData.name" placeholder="请输入姓名" />
        <button form-type="submit">普通提交</button>
        <button form-type="submit" @click.prevent="handlePreventSubmit">
          使用.prevent的提交
        </button>
      </form>
    </view>

    <!-- 3. .self修饰符 -->
    <view class="demo-section">
      <text class="section-title">3. .self 修饰符 - 仅自身触发</text>
      <view class="self-demo">
        <view @click.self="handleSelfClick" class="self-box">
          <text>点击这个文本(自身)会触发</text>
          <button>点击这个按钮(子元素)不会触发</button>
        </view>
      </view>
    </view>

    <!-- 4. 修饰符串联 -->
    <view class="demo-section">
      <text class="section-title">4. 修饰符串联使用</text>
      <view @click="handleChainParent">
        <button @click.stop.prevent="handleChainClick">
          同时使用.stop和.prevent
        </button>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      logs: [],
      formData: {
        name: ''
      }
    }
  },
  methods: {
    handleParentClick() {
      this.addLog('父容器被点击')
    },
    handleButtonClick() {
      this.addLog('普通按钮被点击 → 会触发父容器事件')
    },
    handleButtonClickWithStop() {
      this.addLog('使用.stop的按钮被点击 → 不会触发父容器事件')
    },
    handleFormSubmit(e) {
      this.addLog('表单提交,页面可能会刷新')
    },
    handlePreventSubmit(e) {
      this.addLog('使用.prevent,阻止了表单默认提交行为')
      // 这里可以执行自定义的提交逻辑
      this.submitForm()
    },
    handleSelfClick() {
      this.addLog('.self: 只有点击容器本身才触发')
    },
    handleChainParent() {
      this.addLog('父容器点击事件')
    },
    handleChainClick() {
      this.addLog('按钮点击,但阻止了冒泡和默认行为')
    },
    addLog(message) {
      this.logs.unshift(`${new Date().toLocaleTimeString()}: ${message}`)
      // 只保留最近5条日志
      if (this.logs.length > 5) {
        this.logs.pop()
      }
    },
    submitForm() {
      uni.showToast({
        title: '表单提交成功',
        icon: 'success'
      })
    }
  }
}
</script>

<style scoped>
.event-demo {
  padding: 20rpx;
}
.demo-section {
  margin-bottom: 40rpx;
  padding: 20rpx;
  border: 1rpx solid #e0e0e0;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
  font-size: 28rpx;
}
.parent-box {
  background: #f5f5f5;
  padding: 20rpx;
  border-radius: 8rpx;
}
.log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 15rpx;
  border-radius: 6rpx;
  font-family: monospace;
  font-size: 24rpx;
  margin-top: 15rpx;
  max-height: 200rpx;
  overflow-y: auto;
}
.self-box {
  background: #e3f2fd;
  padding: 30rpx;
  border: 2rpx dashed #2196f3;
}
</style>

三、表单数据处理

3.1 复杂表单设计

graph TB
    A[表单组件] --> B[表单数据模型]
    B --> C[验证规则]
    B --> D[提交处理]
    
    C --> E[即时验证]
    C --> F[提交验证]
    
    D --> G[数据预处理]
    D --> H[API调用]
    D --> I[响应处理]
    
    E --> J[错误提示]
    F --> J

3.2 表单案例

<template>
  <view class="form-container">
    <text class="form-title">用户注册</text>
    
    <!-- 用户名 -->
    <view class="form-item" :class="{ error: errors.username }">
      <text class="label">用户名</text>
      <input 
        type="text" 
        v-model="formData.username" 
        placeholder="请输入用户名"
        @blur="validateField('username')"
        class="input"
      />
      <text class="error-msg" v-if="errors.username">{{ errors.username }}</text>
    </view>

    <!-- 邮箱 -->
    <view class="form-item" :class="{ error: errors.email }">
      <text class="label">邮箱</text>
      <input 
        type="text" 
        v-model="formData.email" 
        placeholder="请输入邮箱"
        @blur="validateField('email')"
        class="input"
      />
      <text class="error-msg" v-if="errors.email">{{ errors.email }}</text>
    </view>

    <!-- 密码 -->
    <view class="form-item" :class="{ error: errors.password }">
      <text class="label">密码</text>
      <input 
        type="password" 
        v-model="formData.password" 
        placeholder="请输入密码"
        @blur="validateField('password')"
        class="input"
      />
      <text class="error-msg" v-if="errors.password">{{ errors.password }}</text>
    </view>

    <!-- 性别 -->
    <view class="form-item">
      <text class="label">性别</text>
      <radio-group v-model="formData.gender" class="radio-group">
        <label class="radio-item" v-for="item in genderOptions" :key="item.value">
          <radio :value="item.value" /> {{ item.label }}
        </label>
      </radio-group>
    </view>

    <!-- 兴趣爱好 -->
    <view class="form-item">
      <text class="label">兴趣爱好</text>
      <view class="checkbox-group">
        <label 
          class="checkbox-item" 
          v-for="hobby in hobbyOptions" 
          :key="hobby.value"
        >
          <checkbox :value="hobby.value" v-model="formData.hobbies" /> 
          {{ hobby.label }}
        </label>
      </view>
    </view>

    <!-- 提交按钮 -->
    <button 
      @click="handleSubmit" 
      :disabled="!isFormValid"
      class="submit-btn"
      :class="{ disabled: !isFormValid }"
    >
      {{ isSubmitting ? '提交中...' : '注册' }}
    </button>

    <!-- 表单数据预览 -->
    <view class="form-preview">
      <text class="preview-title">表单数据预览</text>
      <text class="preview-data">{{ JSON.stringify(formData, null, 2) }}</text>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      },
      errors: {
        username: '',
        email: '',
        password: ''
      },
      isSubmitting: false,
      genderOptions: [
        { label: '男', value: 'male' },
        { label: '女', value: 'female' },
        { label: '其他', value: 'other' }
      ],
      hobbyOptions: [
        { label: '运动', value: 'sports' },
        { label: '阅读', value: 'reading' },
        { label: '音乐', value: 'music' },
        { label: '旅行', value: 'travel' },
        { label: '游戏', value: 'gaming' }
      ]
    }
  },
  computed: {
    isFormValid() {
      return (
        !this.errors.username &&
        !this.errors.email &&
        !this.errors.password &&
        this.formData.username &&
        this.formData.email &&
        this.formData.password &&
        !this.isSubmitting
      )
    }
  },
  methods: {
    validateField(fieldName) {
      const value = this.formData[fieldName]
      
      switch (fieldName) {
        case 'username':
          if (!value) {
            this.errors.username = '用户名不能为空'
          } else if (value.length < 3) {
            this.errors.username = '用户名至少3个字符'
          } else {
            this.errors.username = ''
          }
          break
          
        case 'email':
          if (!value) {
            this.errors.email = '邮箱不能为空'
          } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
            this.errors.email = '邮箱格式不正确'
          } else {
            this.errors.email = ''
          }
          break
          
        case 'password':
          if (!value) {
            this.errors.password = '密码不能为空'
          } else if (value.length < 6) {
            this.errors.password = '密码至少6个字符'
          } else {
            this.errors.password = ''
          }
          break
      }
    },
    
    async handleSubmit() {
      // 提交前验证所有字段
      this.validateField('username')
      this.validateField('email')
      this.validateField('password')
      
      // 报错直接返回
      if (this.errors.username || this.errors.email || this.errors.password) {
        uni.showToast({
          title: '请正确填写表单',
          icon: 'none'
        })
        return
      }
      
      this.isSubmitting = true
      
      try {
        // 接口调用
        await this.mockApiCall()
        
        uni.showToast({
          title: '注册成功',
          icon: 'success'
        })
        
        // 重置表单
        this.resetForm()
        
      } catch (error) {
        uni.showToast({
          title: '注册失败',
          icon: 'error'
        })
      } finally {
        this.isSubmitting = false
      }
    },
    
    mockApiCall() {
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log('提交的数据:', this.formData)
          resolve()
        }, 2000)
      })
    },
    
    resetForm() {
      this.formData = {
        username: '',
        email: '',
        password: '',
        gender: 'male',
        hobbies: ['sports']
      }
      this.errors = {
        username: '',
        email: '',
        password: ''
      }
    }
  }
}
</script>

<style scoped>
.form-container {
  padding: 30rpx;
  max-width: 600rpx;
  margin: 0 auto;
}
.form-title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
}
.form-item {
  margin-bottom: 30rpx;
}
.label {
  display: block;
  margin-bottom: 15rpx;
  font-weight: 500;
  color: #333;
}
.input {
  border: 2rpx solid #e0e0e0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-size: 28rpx;
}
.form-item.error .input {
  border-color: #ff4757;
}
.error-msg {
  color: #ff4757;
  font-size: 24rpx;
  margin-top: 8rpx;
  display: block;
}
.radio-group {
  display: flex;
  gap: 40rpx;
}
.radio-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
}
.checkbox-group {
  display: flex;
  flex-wrap: wrap;
  gap: 20rpx;
}
.checkbox-item {
  display: flex;
  align-items: center;
  gap: 10rpx;
  min-width: 150rpx;
}
.submit-btn {
  background: #007AFF;
  color: white;
  border: none;
  padding: 25rpx;
  border-radius: 10rpx;
  font-size: 32rpx;
  margin-top: 40rpx;
}
.submit-btn.disabled {
  background: #ccc;
  color: #666;
}
.form-preview {
  margin-top: 50rpx;
  padding: 30rpx;
  background: #f9f9f9;
  border-radius: 10rpx;
}
.preview-title {
  font-weight: bold;
  margin-bottom: 20rpx;
  display: block;
}
.preview-data {
  font-family: monospace;
  font-size: 24rpx;
  color: #666;
  word-break: break-all;
}
</style>

四、组件间通信-自定义事件

4.1 自定义事件原理

4.2 以计数器组件为例

<!-- 子组件:custom-counter.vue -->
<template>
  <view class="custom-counter">
    <text class="counter-title">{{ title }}</text>
    
    <view class="counter-controls">
      <button 
        @click="decrement" 
        :disabled="currentValue <= min"
        class="counter-btn"
      >
        -
      </button>
      
      <text class="counter-value">{{ currentValue }}</text>
      
      <button 
        @click="increment" 
        :disabled="currentValue >= max"
        class="counter-btn"
      >
        +
      </button>
    </view>
    
    <view class="counter-stats">
      <text>最小值: {{ min }}</text>
      <text>最大值: {{ max }}</text>
      <text>步长: {{ step }}</text>
    </view>
    
    <!-- 操作 -->
    <view class="quick-actions">
      <button @click="reset" size="mini">重置</button>
      <button @click="setToMax" size="mini">设为最大</button>
      <button @click="setToMin" size="mini">设为最小</button>
    </view>
  </view>
</template>

<script>
export default {
  name: 'CustomCounter',
  props: {
    // 当前值
    value: {
      type: Number,
      default: 0
    },
    // 最小值
    min: {
      type: Number,
      default: 0
    },
    // 最大值
    max: {
      type: Number,
      default: 100
    },
    // 步长
    step: {
      type: Number,
      default: 1
    },
    // 标题
    title: {
      type: String,
      default: '计数器'
    }
  },
  data() {
    return {
      currentValue: this.value
    }
  },
  watch: {
    value(newVal) {
      this.currentValue = newVal
    },
    currentValue(newVal) {
      // 设置限制范围
      if (newVal < this.min) {
        this.currentValue = this.min
      } else if (newVal > this.max) {
        this.currentValue = this.max
      }
    }
  },
  methods: {
    increment() {
      const newValue = this.currentValue + this.step
      if (newValue <= this.max) {
        this.updateValue(newValue)
      }
    },
    
    decrement() {
      const newValue = this.currentValue - this.step
      if (newValue >= this.min) {
        this.updateValue(newValue)
      }
    },
    
    updateValue(newValue) {
      this.currentValue = newValue
      
      // 触发自定义事件,通知父组件
      this.$emit('input', newValue)  // 用于 v-model
      this.$emit('change', {         // 用于普通事件监听
        value: newValue,
        oldValue: this.value,
        type: 'change'
      })
    },
    
    reset() {
      this.updateValue(0)
      this.$emit('reset', { value: 0 })
    },
    
    setToMax() {
      this.updateValue(this.max)
      this.$emit('set-to-max', { value: this.max })
    },
    
    setToMin() {
      this.updateValue(this.min)
      this.$emit('set-to-min', { value: this.min })
    }
  }
}
</script>

<style scoped>
.custom-counter {
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  padding: 30rpx;
  margin: 20rpx 0;
  background: white;
}
.counter-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 25rpx;
  color: #333;
}
.counter-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 30rpx;
  margin-bottom: 25rpx;
}
.counter-btn {
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 36rpx;
  font-weight: bold;
}
.counter-value {
  font-size: 48rpx;
  font-weight: bold;
  color: #007AFF;
  min-width: 100rpx;
  text-align: center;
}
.counter-stats {
  display: flex;
  justify-content: space-around;
  margin-bottom: 25rpx;
  padding: 15rpx;
  background: #f8f9fa;
  border-radius: 8rpx;
}
.counter-stats text {
  font-size: 24rpx;
  color: #666;
}
.quick-actions {
  display: flex;
  justify-content: center;
  gap: 15rpx;
}
</style>

4.3 父组件使用

<!-- 父组件:parent-component.vue -->
<template>
  <view class="parent-container">
    <text class="main-title">自定义计数器组件演示</text>
    
    <!-- 方式1:使用 v-model -->
    <view class="demo-section">
      <text class="section-title">1. 使用 v-model 双向绑定</text>
      <custom-counter 
        v-model="counter1" 
        title="基础计数器"
        :min="0" 
        :max="10"
        :step="1"
      />
      <text class="value-display">当前值: {{ counter1 }}</text>
    </view>
    
    <!-- 方式2:监听 change 事件 -->
    <view class="demo-section">
      <text class="section-title">2. 监听 change 事件</text>
      <custom-counter 
        :value="counter2"
        title="高级计数器"
        :min="-10"
        :max="20"
        :step="2"
        @change="onCounterChange"
      />
      <text class="value-display">当前值: {{ counter2 }}</text>
      <text class="event-log">事件日志: {{ eventLog }}</text>
    </view>
    
    <!-- 方式3:监听多个事件 -->
    <view class="demo-section">
      <text class="section-title">3. 监听多个事件</text>
      <custom-counter 
        v-model="counter3"
        title="多功能计数器"
        @reset="onCounterReset"
        @set-to-max="onSetToMax"
        @set-to-min="onSetToMin"
      />
      <text class="value-display">当前值: {{ counter3 }}</text>
    </view>
    
    
    <view class="demo-section">
      <text class="section-title">4. 计数器联动</text>
      <custom-counter 
        v-model="masterCounter"
        title="主计数器"
        @change="onMasterChange"
      />
      <custom-counter 
        :value="slaveCounter"
        title="从计数器"
        :min="0"
        :max="50"
        readonly
      />
    </view>
  </view>
</template>

<script>
import CustomCounter from '@/components/custom-counter.vue'

export default {
  components: {
    CustomCounter
  },
  data() {
    return {
      counter1: 5,
      counter2: 0,
      counter3: 10,
      masterCounter: 0,
      slaveCounter: 0,
      eventLog: ''
    }
  },
  methods: {
    onCounterChange(event) {
      console.log('计数器变化事件:', event)
      this.counter2 = event.value
      this.addEventLog(`计数器变化: ${event.oldValue}${event.value}`)
    },
    
    onCounterReset(event) {
      console.log('计数器重置:', event)
      this.addEventLog(`计数器重置为: ${event.value}`)
    },
    
    onSetToMax(event) {
      console.log('设置为最大值:', event)
      this.addEventLog(`设置为最大值: ${event.value}`)
    },
    
    onSetToMin(event) {
      console.log('设置为最小值:', event)
      this.addEventLog(`设置为最小值: ${event.value}`)
    },
    
    onMasterChange(event) {
      this.slaveCounter = Math.floor(event.value / 2)
    },
    
    addEventLog(message) {
      const timestamp = new Date().toLocaleTimeString()
      this.eventLog = `${timestamp}: ${message}\n${this.eventLog}`
      
      // 增进日志长度
      if (this.eventLog.split('\n').length > 5) {
        this.eventLog = this.eventLog.split('\n').slice(0, 5).join('\n')
      }
    }
  }
}
</script>

<style scoped>
.parent-container {
  padding: 30rpx;
  max-width: 700rpx;
  margin: 0 auto;
}
.main-title {
  font-size: 40rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
  color: #333;
  display: block;
}
.demo-section {
  margin-bottom: 50rpx;
  padding: 30rpx;
  border: 2rpx solid #e0e0e0;
  border-radius: 15rpx;
  background: #fafafa;
}
.section-title {
  font-size: 28rpx;
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 25rpx;
}
.value-display {
  display: block;
  text-align: center;
  font-size: 28rpx;
  margin-top: 20rpx;
  color: #333;
}
.event-log {
  display: block;
  background: #333;
  color: #0f0;
  padding: 20rpx;
  border-radius: 8rpx;
  font-family: monospace;
  font-size: 22rpx;
  margin-top: 15rpx;
  white-space: pre-wrap;
  max-height: 200rpx;
  overflow-y: auto;
}
</style>

五、性能优化

5.1 数据绑定性能优化

graph TB
    A[性能问题] --> B[大量数据响应式]
    A --> C[频繁的重新渲染]
    A --> D[内存泄漏]
    
    B --> E[Object.freeze 冻结数据]
    B --> F[虚拟滚动]
    
    C --> G[计算属性缓存]
    C --> H[v-once 单次渲染]
    C --> I[合理使用 v-if vs v-show]
    
    D --> J[及时销毁事件监听]
    D --> K[清除定时器]

5.2 优化技巧

<template>
  <view class="optimization-demo">
    <text class="title">性能优化</text>
    
    <!-- 1. 计算属性缓存 -->
    <view class="optimization-section">
      <text class="section-title">1. 计算属性 vs 方法</text>
      <input v-model="filterText" placeholder="过滤文本" class="input" />
      
      <view class="result">
        <text>过滤后数量(计算属性): {{ filteredListLength }}</text>
        <text>过滤后数量(方法调用): {{ getFilteredListLength() }}</text>
      </view>
      
      <button @click="refreshCount">刷新计数</button>
      <text class="hint">打开控制台查看调用次数</text>
    </view>
    
    <!-- 2. v-once 静态内容优化 -->
    <view class="optimization-section">
      <text class="section-title">2. v-once 静态内容</text>
      <view v-once class="static-content">
        <text>这个内容只渲染一次: {{ staticTimestamp }}</text>
      </view>
      <button @click="updateStatic">更新静态内容(不会变化)</button>
    </view>
    
    <!-- 3. 大数据列表优化 -->
    <view class="optimization-section">
      <text class="section-title">3. 大数据列表渲染</text>
      <button @click="loadBigData">加载1000条数据</button>
      <button @click="loadOptimizedData">加载优化后的数据</button>
      
      <!-- 普通渲染 -->
      <view v-if="showNormalList">
        <text>普通渲染({{ normalList.length }}条):</text>
        <view v-for="item in normalList" :key="item.id" class="list-item">
          <text>{{ item.name }}</text>
        </view>
      </view>
      
      <!-- 虚拟滚动优化 -->
      <view v-if="showOptimizedList">
        <text>虚拟滚动渲染({{ optimizedList.length }}条):</text>
        <view class="virtual-list">
          <view 
            v-for="item in visibleItems" 
            :key="item.id" 
            class="list-item optimized"
          >
            <text>{{ item.name }}</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      filterText: '',
      refreshCount: 0,
      staticTimestamp: new Date().toLocaleTimeString(),
      normalList: [],
      optimizedList: [],
      showNormalList: false,
      showOptimizedList: false,
      visibleItems: [],
      bigData: []
    }
  },
  computed: {
    // 计算属性会自动缓存,只有依赖变化时才重新计算
    filteredListLength() {
      console.log('计算属性被执行')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    }
  },
  methods: {
    // 方法每次调用都会执行
    getFilteredListLength() {
      console.log('方法被调用')
      const list = this.generateTestList()
      return list.filter(item => 
        item.name.includes(this.filterText)
      ).length
    },
    
    generateTestList() {
      return Array.from({ length: 100 }, (_, i) => ({
        id: i,
        name: `项目 ${i}`
      }))
    },
    
    refreshCount() {
      this.refreshCount++
    },
    
    updateStatic() {
      this.staticTimestamp = new Date().toLocaleTimeString()
    },
    
    loadBigData() {
      this.showNormalList = true
      this.showOptimizedList = false
      
      // 生成大量数据
      this.normalList = Array.from({ length: 1000 }, (_, i) => ({
        id: i,
        name: `数据项 ${i}`,
        value: Math.random() * 1000
      }))
    },
    
    loadOptimizedData() {
      this.showNormalList = false
      this.showOptimizedList = true
      
      // 使用 Object.freeze 避免不必要的响应式
      this.optimizedList = Object.freeze(
        Array.from({ length: 1000 }, (_, i) => ({
          id: i,
          name: `数据项 ${i}`,
          value: Math.random() * 1000
        }))
      )
      
      // 虚拟滚动:只渲染可见项
      this.updateVisibleItems()
    },
    
    updateVisibleItems() {
      // 简化的虚拟滚动实现
      this.visibleItems = this.optimizedList.slice(0, 20)
    },
    
    // 防抖函数优化频繁触发的事件
    debounce(func, wait) {
      let timeout
      return function executedFunction(...args) {
        const later = () => {
          clearTimeout(timeout)
          func(...args)
        }
        clearTimeout(timeout)
        timeout = setTimeout(later, wait)
      }
    }
  },
  
  // 组件销毁时清理资源
  beforeDestroy() {
    this.normalList = []
    this.optimizedList = []
    this.visibleItems = []
  }
}
</script>

<style scoped>
.optimization-demo {
  padding: 30rpx;
}
.title {
  font-size: 36rpx;
  font-weight: bold;
  text-align: center;
  display: block;
  margin-bottom: 40rpx;
}
.optimization-section {
  margin-bottom: 40rpx;
  padding: 30rpx;
  border: 1rpx solid #ddd;
  border-radius: 10rpx;
}
.section-title {
  font-weight: bold;
  color: #007AFF;
  display: block;
  margin-bottom: 20rpx;
}
.input {
  border: 1rpx solid #ccc;
  padding: 15rpx;
  border-radius: 6rpx;
  margin-bottom: 15rpx;
}
.result {
  margin: 15rpx 0;
}
.result text {
  display: block;
  margin: 5rpx 0;
}
.hint {
  font-size: 24rpx;
  color: #666;
  display: block;
  margin-top: 10rpx;
}
.static-content {
  background: #e8f5e8;
  padding: 20rpx;
  border-radius: 6rpx;
  margin: 15rpx 0;
}
.list-item {
  padding: 10rpx;
  border-bottom: 1rpx solid #eee;
}
.list-item.optimized {
  background: #f0f8ff;
}
.virtual-list {
  max-height: 400rpx;
  overflow-y: auto;
}
</style>

总结

通过以上学习,我们深入掌握了 uni-app 中数据绑定与事件处理的核心概念:

  1. 响应式原理:理解了 Vue 2.x 基于 Object.defineProperty 的数据劫持机制
  2. 双向绑定v-model 的本质是 :value + @input 的语法糖
  3. 事件系统:掌握了事件流、修饰符及其底层实现原理
  4. 组件通信:通过自定义事件实现子父组件间的数据传递
  5. 性能优化:学会了计算属性、虚拟滚动等优化技巧

至此数据绑定与时间处理就全部介绍完了,如果觉得这篇文章对你有帮助,别忘了一键三连~~~ 遇到任何问题,欢迎在评论区留言讨论。Happy Coding!

vue3学习笔记

作者 乐一李
2025年11月14日 15:37

1. Vue3简介

1695089947298-161c1b47-eb86-42fb-b1f8-d6a4fcab8ee2.png

1.1. 【性能的提升】

  • 打包大小减少41%

  • 初次渲染快55%, 更新渲染快133%

  • 内存减少54%

1.2.【 源码的升级】

  • 使用Proxy代替defineProperty实现响应式。

  • 重写虚拟DOM的实现和Tree-Shaking

1.3. 【拥抱TypeScript】

  • Vue3可以更好的支持TypeScript

1.4. 【新的特性】

  1. Composition API(组合API):

    • setup

    • refreactive

    • computedwatch

      ......

  2. 新的内置组件:

    • Fragment

    • Teleport

    • Suspense

      ......

  3. 其他改变:

    • 新的生命周期钩子

    • data 选项应始终被声明为一个函数

    • 移除keyCode支持作为 v-on 的修饰符

      ......

2. 创建Vue3工程

2.1. 【基于 vue-cli 创建】

点击查看官方文档

备注:目前vue-cli已处于维护模式,官方推荐基于 Vite 创建项目。

## 查看@vue/cli版本,确保@vue/cli版本在4.5.0以上
vue --version

## 安装或者升级你的@vue/cli 
npm install -g @vue/cli

## 执行创建命令
vue create vue_test

##  随后选择3.x
##  Choose a version of Vue.js that you want to start the project with (Use arrow keys)
##  > 3.x
##    2.x

## 启动
cd vue_test
npm run serve

2.2. 【基于 vite 创建】(推荐)

vite 是新一代前端构建工具,官网地址:vitejs.cnvite的优势如下:

  • 轻量快速的热重载(HMR),能实现极速的服务启动。
  • TypeScriptJSXCSS 等支持开箱即用。
  • 真正的按需编译,不再等待整个应用编译完成。
  • webpack构建 与 vite构建对比图如下: webpack构建转存失败,建议直接上传图片文件vite构建转存失败,建议直接上传图片文件
## 1.创建命令
npm create vue@latest

## 2.具体配置
## 配置项目名称
√ Project name: vue3_test
## 是否添加TypeScript支持
√ Add TypeScript?  Yes
## 是否添加JSX支持
√ Add JSX Support?  No
## 是否添加路由环境
√ Add Vue Router for Single Page Application development?  No
## 是否添加pinia环境
√ Add Pinia for state management?  No
## 是否添加单元测试
√ Add Vitest for Unit Testing?  No
## 是否添加端到端测试方案
√ Add an End-to-End Testing Solution? » No
## 是否添加ESLint语法检查
√ Add ESLint for code quality?  Yes
## 是否添加Prettiert代码格式化
√ Add Prettier for code formatting?  No

自己动手编写一个App组件

<template>
  <div class="app">
    <h1>你好啊!</h1>
  </div>
</template>

<script lang="ts">
  export default {
    name:'App' //组件名
  }
</script>

<style>
  .app {
    background-color: #ddd;
    box-shadow: 0 0 10px;
    border-radius: 10px;
    padding: 20px;
  }
</style>

安装官方推荐的vscode插件:

volar.png

image-20231218085906380.png 总结:

  • Vite 项目中,index.html 是项目的入口文件,在项目最外层。
  • 加载index.html后,Vite 解析 <script type="module" src="xxx"> 指向的JavaScript
  • Vue3**中是通过 **createApp 函数创建一个应用实例。

2.3. 【一个简单的效果】

Vue3向下兼容Vue2语法,且Vue3中的模板中可以没有根标签

<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script lang="ts">
  export default {
    name:'App',
    data() {
      return {
        name:'张三',
        age:18,
        tel:'13888888888'
      }
    },
    methods:{
      changeName(){
        this.name = 'zhang-san'
      },
      changeAge(){
        this.age += 1
      },
      showTel(){
        alert(this.tel)
      }
    },
  }
</script>

3. Vue3核心语法

3.1. 【OptionsAPI 与 CompositionAPI】

  • Vue2API设计是Options(配置)风格的。
  • Vue3API设计是Composition(组合)风格的。

Options API 的弊端

Options类型的 API,数据、方法、计算属性等,是分散在:datamethodscomputed中的,若想新增或者修改一个需求,就需要分别修改:datamethodscomputed,不便于维护和复用。

1696662197101-55d2b251-f6e5-47f4-b3f1-d8531bbf9279.gif

1696662200734-1bad8249-d7a2-423e-a3c3-ab4c110628be.gif

Composition API 的优势

可以用函数的方式,更加优雅的组织代码,让相关功能的代码更加有序的组织在一起。

1696662249851-db6403a1-acb5-481a-88e0-e1e34d2ef53a.gif

1696662256560-7239b9f9-a770-43c1-9386-6cc12ef1e9c0.gif

说明:以上四张动图原创作者:大帅老猿

3.2. 【拉开序幕的 setup】

setup 概述

setupVue3中一个新的配置项,值是一个函数,它是 Composition API “表演的舞台***”***,组件中所用到的:数据、方法、计算属性、监视......等等,均配置在setup中。

特点如下:

  • setup函数返回的对象中的内容,可直接在模板中使用。
  • setup中访问thisundefined
  • setup函数会在beforeCreate之前调用,它是“领先”所有钩子执行的。
<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script lang="ts">
  export default {
    name:'Person',
    setup(){
      // 数据,原来写在data中(注意:此时的name、age、tel数据都不是响应式数据)
      let name = '张三'
      let age = 18
      let tel = '13888888888'

      // 方法,原来写在methods中
      function changeName(){
        name = 'zhang-san' //注意:此时这么修改name页面是不变化的
        console.log(name)
      }
      function changeAge(){
        age += 1 //注意:此时这么修改age页面是不变化的
        console.log(age)
      }
      function showTel(){
        alert(tel)
      }

      // 返回一个对象,对象中的内容,模板中可以直接使用
      return {name,age,tel,changeName,changeAge,showTel}
    }
  }
</script>

setup 的返回值

  • 若返回一个对象:则对象中的:属性、方法等,在模板中均可以直接使用**(重点关注)。**
  • 若返回一个函数:则可以自定义渲染内容,代码如下:
setup(){
  return ()=> '你好啊!'
}

setup 与 Options API 的关系

  • Vue2 的配置(datamethos......)中可以访问到 setup中的属性、方法。
  • 但在setup不能访问到Vue2的配置(datamethos......)。
  • 如果与Vue2冲突,则setup优先。

setup 语法糖

setup函数有一个语法糖,这个语法糖,可以让我们把setup独立出去,代码如下:

<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changName">修改名字</button>
    <button @click="changAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script lang="ts">
  export default {
    name:'Person',
  }
</script>

<!-- 下面的写法是setup语法糖 -->
<script setup lang="ts">
  console.log(this) //undefined
  
  // 数据(注意:此时的name、age、tel都不是响应式数据)
  let name = '张三'
  let age = 18
  let tel = '13888888888'

  // 方法
  function changName(){
    name = '李四'//注意:此时这么修改name页面是不变化的
  }
  function changAge(){
    console.log(age)
    age += 1 //注意:此时这么修改age页面是不变化的
  }
  function showTel(){
    alert(tel)
  }
</script>

扩展:上述代码,还需要编写一个不写setupscript标签,去指定组件名字,比较麻烦,我们可以借助vite中的插件简化

  1. 第一步:npm i vite-plugin-vue-setup-extend -D
  2. 第二步:vite.config.ts
import { defineConfig } from 'vite'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'

export default defineConfig({
  plugins: [ VueSetupExtend() ]
})
  1. 第三步:<script setup lang="ts" name="Person">

3.3. 【ref 创建:基本类型的响应式数据】

  • **作用:**定义响应式变量。
  • 语法:let xxx = ref(初始值)
  • **返回值:**一个RefImpl的实例对象,简称ref对象refref对象的value属性是响应式的
  • 注意点:
    • JS中操作数据需要:xxx.value,但模板中不需要.value,直接使用即可。
    • 对于let name = ref('张三')来说,name不是响应式的,name.value是响应式的。
<template>
  <div class="person">
    <h2>姓名:{{name}}</h2>
    <h2>年龄:{{age}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">年龄+1</button>
    <button @click="showTel">点我查看联系方式</button>
  </div>
</template>

<script setup lang="ts" name="Person">
  import {ref} from 'vue'
  // name和age是一个RefImpl的实例对象,简称ref对象,它们的value属性是响应式的。
  let name = ref('张三')
  let age = ref(18)
  // tel就是一个普通的字符串,不是响应式的
  let tel = '13888888888'

  function changeName(){
    // JS中操作ref对象时候需要.value
    name.value = '李四'
    console.log(name.value)

    // 注意:name不是响应式的,name.value是响应式的,所以如下代码并不会引起页面的更新。
    // name = ref('zhang-san')
  }
  function changeAge(){
    // JS中操作ref对象时候需要.value
    age.value += 1 
    console.log(age.value)
  }
  function showTel(){
    alert(tel)
  }
</script>

3.4. 【reactive 创建:对象类型的响应式数据】

  • 作用:定义一个响应式对象(基本类型不要用它,要用ref,否则报错)
  • 语法:let 响应式对象= reactive(源对象)
  • **返回值:**一个Proxy的实例对象,简称:响应式对象。
  • 注意点:reactive定义的响应式数据是“深层次”的。
<template>
  <div class="person">
    <h2>汽车信息:一台{{ car.brand }}汽车,价值{{ car.price }}万</h2>
    <h2>游戏列表:</h2>
    <ul>
      <li v-for="g in games" :key="g.id">{{ g.name }}</li>
    </ul>
    <h2>测试:{{obj.a.b.c.d}}</h2>
    <button @click="changeCarPrice">修改汽车价格</button>
    <button @click="changeFirstGame">修改第一游戏</button>
    <button @click="test">测试</button>
  </div>
</template>

<script lang="ts" setup name="Person">
import { reactive } from 'vue'

// 数据
let car = reactive({ brand: '奔驰', price: 100 })
let games = reactive([
  { id: 'ahsgdyfa01', name: '英雄联盟' },
  { id: 'ahsgdyfa02', name: '王者荣耀' },
  { id: 'ahsgdyfa03', name: '原神' }
])
let obj = reactive({
  a:{
    b:{
      c:{
        d:666
      }
    }
  }
})

function changeCarPrice() {
  car.price += 10
}
function changeFirstGame() {
  games[0].name = '流星蝴蝶剑'
}
function test(){
  obj.a.b.c.d = 999
}
</script>

3.5. 【ref 创建:对象类型的响应式数据】

  • 其实ref接收的数据可以是:基本类型对象类型
  • ref接收的是对象类型,内部其实也是调用了reactive函数。
<template>
  <div class="person">
    <h2>汽车信息:一台{{ car.brand }}汽车,价值{{ car.price }}万</h2>
    <h2>游戏列表:</h2>
    <ul>
      <li v-for="g in games" :key="g.id">{{ g.name }}</li>
    </ul>
    <h2>测试:{{obj.a.b.c.d}}</h2>
    <button @click="changeCarPrice">修改汽车价格</button>
    <button @click="changeFirstGame">修改第一游戏</button>
    <button @click="test">测试</button>
  </div>
</template>

<script lang="ts" setup name="Person">
import { ref } from 'vue'

// 数据
let car = ref({ brand: '奔驰', price: 100 })
let games = ref([
  { id: 'ahsgdyfa01', name: '英雄联盟' },
  { id: 'ahsgdyfa02', name: '王者荣耀' },
  { id: 'ahsgdyfa03', name: '原神' }
])
let obj = ref({
  a:{
    b:{
      c:{
        d:666
      }
    }
  }
})

console.log(car)

function changeCarPrice() {
  car.value.price += 10
}
function changeFirstGame() {
  games.value[0].name = '流星蝴蝶剑'
}
function test(){
  obj.value.a.b.c.d = 999
}
</script>

3.6. 【ref 对比 reactive】

宏观角度看:

  1. ref用来定义:基本类型数据对象类型数据

  2. reactive用来定义:对象类型数据

  • 区别:
  1. ref创建的变量必须使用.value(可以使用volar插件自动添加.value)。

自动补充value.png

  1. reactive重新分配一个新对象,会失去响应式(可以使用Object.assign去整体替换)。
  • 使用原则:
  1. 若需要一个基本类型的响应式数据,必须使用ref
  2. 若需要一个响应式对象,层级不深,refreactive都可以。
  3. 若需要一个响应式对象,且层级较深,推荐使用reactive

3.7. 【toRefs 与 toRef】

  • 作用:将一个响应式对象中的每一个属性,转换为ref对象。
  • 备注:toRefstoRef功能一致,但toRefs可以批量转换。
  • 语法如下:
<template>
  <div class="person">
    <h2>姓名:{{person.name}}</h2>
    <h2>年龄:{{person.age}}</h2>
    <h2>性别:{{person.gender}}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changeGender">修改性别</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref,reactive,toRefs,toRef} from 'vue'

  // 数据
  let person = reactive({name:'张三', age:18, gender:'男'})

  // 通过toRefs将person对象中的n个属性批量取出,且依然保持响应式的能力
  let {name,gender} =  toRefs(person)

  // 通过toRef将person对象中的gender属性取出,且依然保持响应式的能力
  let age = toRef(person,'age')

  // 方法
  function changeName(){
    name.value += '~'
  }
  function changeAge(){
    age.value += 1
  }
  function changeGender(){
    gender.value = '女'
  }
</script>

3.8. 【computed】

作用:根据已有数据计算出新数据(和Vue2中的computed作用一致)。

computed.gif

<template>
  <div class="person">
    姓:<input type="text" v-model="firstName"> <br>
    名:<input type="text" v-model="lastName"> <br>
    全名:<span>{{fullName}}</span> <br>
    <button @click="changeFullName">全名改为:li-si</button>
  </div>
</template>

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

  let firstName = ref('zhang')
  let lastName = ref('san')

  // 计算属性——只读取,不修改
  /* let fullName = computed(()=>{
    return firstName.value + '-' + lastName.value
  }) */


  // 计算属性——既读取又修改
  let fullName = computed({
    // 读取
    get(){
      return firstName.value + '-' + lastName.value
    },
    // 修改
    set(val){
      console.log('有人修改了fullName',val)
      firstName.value = val.split('-')[0]
      lastName.value = val.split('-')[1]
    }
  })

  function changeFullName(){
    fullName.value = 'li-si'
  } 
</script>

3.9.【watch】

  • 作用:监视数据的变化(和Vue2中的watch作用一致)
  • 特点:Vue3中的watch只能监视以下四种数据
  1. ref定义的数据。
  2. reactive定义的数据。
  3. 函数返回一个值(getter函数)。
  4. 一个包含上述内容的数组。

我们在Vue3中使用watch的时候,通常会遇到以下几种情况:

* 情况一

监视ref定义的【基本类型】数据:直接写数据名即可,监视的是其value值的改变。

<template>
  <div class="person">
    <h1>情况一:监视【ref】定义的【基本类型】数据</h1>
    <h2>当前求和为:{{sum}}</h2>
    <button @click="changeSum">点我sum+1</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref,watch} from 'vue'
  // 数据
  let sum = ref(0)
  // 方法
  function changeSum(){
    sum.value += 1
  }
  // 监视,情况一:监视【ref】定义的【基本类型】数据
  const stopWatch = watch(sum,(newValue,oldValue)=>{
    console.log('sum变化了',newValue,oldValue)
    if(newValue >= 10){
      stopWatch()
    }
  })
</script>

* 情况二

监视ref定义的【对象类型】数据:直接写数据名,监视的是对象的【地址值】,若想监视对象内部的数据,要手动开启深度监视。

注意:

  • 若修改的是ref定义的对象中的属性,newValueoldValue 都是新值,因为它们是同一个对象。

  • 若修改整个ref定义的对象,newValue 是新值, oldValue 是旧值,因为不是同一个对象了。

<template>
  <div class="person">
    <h1>情况二:监视【ref】定义的【对象类型】数据</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changePerson">修改整个人</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref,watch} from 'vue'
  // 数据
  let person = ref({
    name:'张三',
    age:18
  })
  // 方法
  function changeName(){
    person.value.name += '~'
  }
  function changeAge(){
    person.value.age += 1
  }
  function changePerson(){
    person.value = {name:'李四',age:90}
  }
  /* 
    监视,情况一:监视【ref】定义的【对象类型】数据,监视的是对象的地址值,若想监视对象内部属性的变化,需要手动开启深度监视
    watch的第一个参数是:被监视的数据
    watch的第二个参数是:监视的回调
    watch的第三个参数是:配置对象(deep、immediate等等.....) 
  */
  watch(person,(newValue,oldValue)=>{
    console.log('person变化了',newValue,oldValue)
  },{deep:true})
  
</script>

* 情况三

监视reactive定义的【对象类型】数据,且默认开启了深度监视。

<template>
  <div class="person">
    <h1>情况三:监视【reactive】定义的【对象类型】数据</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changePerson">修改整个人</button>
    <hr>
    <h2>测试:{{obj.a.b.c}}</h2>
    <button @click="test">修改obj.a.b.c</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {reactive,watch} from 'vue'
  // 数据
  let person = reactive({
    name:'张三',
    age:18
  })
  let obj = reactive({
    a:{
      b:{
        c:666
      }
    }
  })
  // 方法
  function changeName(){
    person.name += '~'
  }
  function changeAge(){
    person.age += 1
  }
  function changePerson(){
    Object.assign(person,{name:'李四',age:80})
  }
  function test(){
    obj.a.b.c = 888
  }

  // 监视,情况三:监视【reactive】定义的【对象类型】数据,且默认是开启深度监视的
  watch(person,(newValue,oldValue)=>{
    console.log('person变化了',newValue,oldValue)
  })
  watch(obj,(newValue,oldValue)=>{
    console.log('Obj变化了',newValue,oldValue)
  })
</script>

* 情况四

监视refreactive定义的【对象类型】数据中的某个属性,注意点如下:

  1. 若该属性值不是【对象类型】,需要写成函数形式。
  2. 若该属性值是依然是【对象类型】,可直接编,也可写成函数,建议写成函数。

结论:监视的要是对象里的属性,那么最好写函数式,注意点:若是对象监视的是地址值,需要关注对象内部,需要手动开启深度监视。

<template>
  <div class="person">
    <h1>情况四:监视【ref】或【reactive】定义的【对象类型】数据中的某个属性</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <h2>汽车:{{ person.car.c1 }}、{{ person.car.c2 }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changeC1">修改第一台车</button>
    <button @click="changeC2">修改第二台车</button>
    <button @click="changeCar">修改整个车</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {reactive,watch} from 'vue'

  // 数据
  let person = reactive({
    name:'张三',
    age:18,
    car:{
      c1:'奔驰',
      c2:'宝马'
    }
  })
  // 方法
  function changeName(){
    person.name += '~'
  }
  function changeAge(){
    person.age += 1
  }
  function changeC1(){
    person.car.c1 = '奥迪'
  }
  function changeC2(){
    person.car.c2 = '大众'
  }
  function changeCar(){
    person.car = {c1:'雅迪',c2:'爱玛'}
  }

  // 监视,情况四:监视响应式对象中的某个属性,且该属性是基本类型的,要写成函数式
  /* watch(()=> person.name,(newValue,oldValue)=>{
    console.log('person.name变化了',newValue,oldValue)
  }) */

  // 监视,情况四:监视响应式对象中的某个属性,且该属性是对象类型的,可以直接写,也能写函数,更推荐写函数
  watch(()=>person.car,(newValue,oldValue)=>{
    console.log('person.car变化了',newValue,oldValue)
  },{deep:true})
</script>

* 情况五

监视上述的多个数据

<template>
  <div class="person">
    <h1>情况五:监视上述的多个数据</h1>
    <h2>姓名:{{ person.name }}</h2>
    <h2>年龄:{{ person.age }}</h2>
    <h2>汽车:{{ person.car.c1 }}、{{ person.car.c2 }}</h2>
    <button @click="changeName">修改名字</button>
    <button @click="changeAge">修改年龄</button>
    <button @click="changeC1">修改第一台车</button>
    <button @click="changeC2">修改第二台车</button>
    <button @click="changeCar">修改整个车</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {reactive,watch} from 'vue'

  // 数据
  let person = reactive({
    name:'张三',
    age:18,
    car:{
      c1:'奔驰',
      c2:'宝马'
    }
  })
  // 方法
  function changeName(){
    person.name += '~'
  }
  function changeAge(){
    person.age += 1
  }
  function changeC1(){
    person.car.c1 = '奥迪'
  }
  function changeC2(){
    person.car.c2 = '大众'
  }
  function changeCar(){
    person.car = {c1:'雅迪',c2:'爱玛'}
  }

  // 监视,情况五:监视上述的多个数据
  watch([()=>person.name,person.car],(newValue,oldValue)=>{
    console.log('person.car变化了',newValue,oldValue)
  },{deep:true})

</script>

3.10. 【watchEffect】

  • 官网:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行该函数。

  • watch对比watchEffect

    1. 都能监听响应式数据的变化,不同的是监听数据变化的方式不同

    2. watch:要明确指出监视的数据

    3. watchEffect:不用明确指出监视的数据(函数中用到哪些属性,那就监视哪些属性)。

  • 示例代码:

    <template>
      <div class="person">
        <h1>需求:水温达到50℃,或水位达到20cm,则联系服务器</h1>
        <h2 id="demo">水温:{{temp}}</h2>
        <h2>水位:{{height}}</h2>
        <button @click="changePrice">水温+1</button>
        <button @click="changeSum">水位+10</button>
      </div>
    </template>
    
    <script lang="ts" setup name="Person">
      import {ref,watch,watchEffect} from 'vue'
      // 数据
      let temp = ref(0)
      let height = ref(0)
    
      // 方法
      function changePrice(){
        temp.value += 10
      }
      function changeSum(){
        height.value += 1
      }
    
      // 用watch实现,需要明确的指出要监视:temp、height
      watch([temp,height],(value)=>{
        // 从value中获取最新的temp值、height值
        const [newTemp,newHeight] = value
        // 室温达到50℃,或水位达到20cm,立刻联系服务器
        if(newTemp >= 50 || newHeight >= 20){
          console.log('联系服务器')
        }
      })
    
      // 用watchEffect实现,不用
      const stopWtach = watchEffect(()=>{
        // 室温达到50℃,或水位达到20cm,立刻联系服务器
        if(temp.value >= 50 || height.value >= 20){
          console.log(document.getElementById('demo')?.innerText)
          console.log('联系服务器')
        }
        // 水温达到100,或水位达到50,取消监视
        if(temp.value === 100 || height.value === 50){
          console.log('清理了')
          stopWtach()
        }
      })
    </script>
    

3.11. 【标签的 ref 属性】

作用:用于注册模板引用。

  • 用在普通DOM标签上,获取的是DOM节点。

  • 用在组件标签上,获取的是组件实例对象。

用在普通DOM标签上:

<template>
  <div class="person">
    <h1 ref="title1">尚硅谷</h1>
    <h2 ref="title2">前端</h2>
    <h3 ref="title3">Vue</h3>
    <input type="text" ref="inpt"> <br><br>
    <button @click="showLog">点我打印内容</button>
  </div>
</template>

<script lang="ts" setup name="Person">
  import {ref} from 'vue'

  let title1 = ref()
  let title2 = ref()
  let title3 = ref()

  function showLog(){
    // 通过id获取元素
    const t1 = document.getElementById('title1')
    // 打印内容
    console.log((t1 as HTMLElement).innerText)
    console.log((<HTMLElement>t1).innerText)
    console.log(t1?.innerText)
    
/************************************/

    // 通过ref获取元素
    console.log(title1.value)
    console.log(title2.value)
    console.log(title3.value)
  }
</script>

用在组件标签上:

<!-- 父组件App.vue -->
<template>
  <Person ref="ren"/>
  <button @click="test">测试</button>
</template>

<script lang="ts" setup name="App">
  import Person from './components/Person.vue'
  import {ref} from 'vue'

  let ren = ref()

  function test(){
    console.log(ren.value.name)
    console.log(ren.value.age)
  }
</script>


<!-- 子组件Person.vue中要使用defineExpose暴露内容 -->
<script lang="ts" setup name="Person">
  import {ref,defineExpose} from 'vue'
// 数据
  let name = ref('张三')
  let age = ref(18)
  /****************************/
  /****************************/
  // 使用defineExpose将组件中的数据交给外部
  defineExpose({name,age})
</script>

3.12. 【props】

// 定义一个接口,限制每个Person对象的格式
export interface PersonInter {
 id:string,
 name:string,
    age:number
   }
   
// 定义一个自定义类型Persons
export type Persons = Array<PersonInter>

App.vue中代码:

<template>
<Person :list="persons"/>
</template>
  
<script lang="ts" setup name="App">
  import Person from './components/Person.vue'
  import {reactive} from 'vue'
    import {type Persons} from './types'
  
    let persons = reactive<Persons>([
     {id:'e98219e12',name:'张三',age:18},
      {id:'e98219e13',name:'李四',age:19},
       {id:'e98219e14',name:'王五',age:20}
     ])
   </script>
  

Person.vue中代码:

<template>
<div class="person">
 <ul>
     <li v-for="item in list" :key="item.id">
        {{item.name}}--{{item.age}}
      </li>
    </ul>
   </div>
   </template>
  
<script lang="ts" setup name="Person">
import {defineProps} from 'vue'
import {type PersonInter} from '@/types'
  
  // 第一种写法:仅接收
// const props = defineProps(['list'])
  
  // 第二种写法:接收+限制类型
// defineProps<{list:Persons}>()
  
  // 第三种写法:接收+限制类型+指定默认值+限制必要性
let props = withDefaults(defineProps<{list?:Persons}>(),{
     list:()=>[{id:'asdasg01',name:'小猪佩奇',age:18}]
  })
   console.log(props)
  </script>

3.13. 【生命周期】

  • 概念:Vue组件实例在创建时要经历一系列的初始化步骤,在此过程中Vue会在合适的时机,调用特定的函数,从而让开发者有机会在特定阶段运行自己的代码,这些特定的函数统称为:生命周期钩子

  • 规律:

    生命周期整体分为四个阶段,分别是:创建、挂载、更新、销毁,每个阶段都有两个钩子,一前一后。

  • Vue2的生命周期

    创建阶段:beforeCreatecreated

    挂载阶段:beforeMountmounted

    更新阶段:beforeUpdateupdated

    销毁阶段:beforeDestroydestroyed

  • Vue3的生命周期

    创建阶段:setup

    挂载阶段:onBeforeMountonMounted

    更新阶段:onBeforeUpdateonUpdated

    卸载阶段:onBeforeUnmountonUnmounted

  • 常用的钩子:onMounted(挂载完毕)、onUpdated(更新完毕)、onBeforeUnmount(卸载之前)

  • 示例代码:

    <template>
      <div class="person">
        <h2>当前求和为:{{ sum }}</h2>
        <button @click="changeSum">点我sum+1</button>
      </div>
    </template>
    
    <!-- vue3写法 -->
    <script lang="ts" setup name="Person">
      import { 
        ref, 
        onBeforeMount, 
        onMounted, 
        onBeforeUpdate, 
        onUpdated, 
        onBeforeUnmount, 
        onUnmounted 
      } from 'vue'
    
      // 数据
      let sum = ref(0)
      // 方法
      function changeSum() {
        sum.value += 1
      }
      console.log('setup')
      // 生命周期钩子
      onBeforeMount(()=>{
        console.log('挂载之前')
      })
      onMounted(()=>{
        console.log('挂载完毕')
      })
      onBeforeUpdate(()=>{
        console.log('更新之前')
      })
      onUpdated(()=>{
        console.log('更新完毕')
      })
      onBeforeUnmount(()=>{
        console.log('卸载之前')
      })
      onUnmounted(()=>{
        console.log('卸载完毕')
      })
    </script>
    

3.14. 【自定义hook】

  • 什么是hook?—— 本质是一个函数,把setup函数中使用的Composition API进行了封装,类似于vue2.x中的mixin

  • 自定义hook的优势:复用代码, 让setup中的逻辑更清楚易懂。

示例代码:

  • useSum.ts中内容如下:

    import {ref,onMounted} from 'vue'
    
    export default function(){
      let sum = ref(0)
    
      const increment = ()=>{
        sum.value += 1
      }
      const decrement = ()=>{
        sum.value -= 1
      }
      onMounted(()=>{
        increment()
      })
    
      //向外部暴露数据
      return {sum,increment,decrement}
    }
    
  • useDog.ts中内容如下:

    import {reactive,onMounted} from 'vue'
    import axios,{AxiosError} from 'axios'
    
    export default function(){
      let dogList = reactive<string[]>([])
    
      // 方法
      async function getDog(){
        try {
          // 发请求
          let {data} = await axios.get('https://dog.ceo/api/breed/pembroke/images/random')
          // 维护数据
          dogList.push(data.message)
        } catch (error) {
          // 处理错误
          const err = <AxiosError>error
          console.log(err.message)
        }
      }
    
      // 挂载钩子
      onMounted(()=>{
        getDog()
      })
    
      //向外部暴露数据
      return {dogList,getDog}
    }
    
  • 组件中具体使用:

    <template>
      <h2>当前求和为:{{sum}}</h2>
      <button @click="increment">点我+1</button>
      <button @click="decrement">点我-1</button>
      <hr>
      <img v-for="(u,index) in dogList.urlList" :key="index" :src="(u as string)"> 
      <span v-show="dogList.isLoading">加载中......</span><br>
      <button @click="getDog">再来一只狗</button>
    </template>
    
    <script lang="ts">
      import {defineComponent} from 'vue'
    
      export default defineComponent({
        name:'App',
      })
    </script>
    
    <script setup lang="ts">
      import useSum from './hooks/useSum'
      import useDog from './hooks/useDog'
    
      let {sum,increment,decrement} = useSum()
      let {dogList,getDog} = useDog()
    </script>
    

4. 路由

4.1. 【对路由的理解】

image-20231018144351536.png

4.2. 【基本切换效果】

  • Vue3中要使用vue-router的最新版本,目前是4版本。

  • 路由配置文件代码如下:

    import {createRouter,createWebHistory} from 'vue-router'
    import Home from '@/pages/Home.vue'
    import News from '@/pages/News.vue'
    import About from '@/pages/About.vue'
    
    const router = createRouter({
    history:createWebHistory(),
    routes:[
    {
    path:'/home',
    component:Home
    },
    {
    path:'/about',
    component:About
    }
    ]
    })
    export default router
    
  • main.ts代码如下:

    import router from './router/index'
    app.use(router)
    
    app.mount('#app')
    
  • App.vue代码如下

    <template>
      <div class="app">
        <h2 class="title">Vue路由测试</h2>
        <!-- 导航区 -->
        <div class="navigate">
          <RouterLink to="/home" active-class="active">首页</RouterLink>
          <RouterLink to="/news" active-class="active">新闻</RouterLink>
          <RouterLink to="/about" active-class="active">关于</RouterLink>
        </div>
        <!-- 展示区 -->
        <div class="main-content">
          <RouterView></RouterView>
        </div>
      </div>
    </template>
    
    <script lang="ts" setup name="App">
      import {RouterLink,RouterView} from 'vue-router'  
    </script>
    

4.3. 【两个注意点】

  1. 路由组件通常存放在pagesviews文件夹,一般组件通常存放在components文件夹。

  2. 通过点击导航,视觉效果上“消失” 了的路由组件,默认是被卸载掉的,需要的时候再去挂载

4.4.【路由器工作模式】

  1. history模式

    优点:URL更加美观,不带有#,更接近传统的网站URL

    缺点:后期项目上线,需要服务端配合处理路径问题,否则刷新会有404错误。

    const router = createRouter({
      history:createWebHistory(), //history模式
      /******/
    })
    
  2. hash模式

    优点:兼容性更好,因为不需要服务器端处理路径。

    缺点:URL带有#不太美观,且在SEO优化方面相对较差。

    const router = createRouter({
      history:createWebHashHistory(), //hash模式
      /******/
    })
    

4.5. 【to的两种写法】

<!-- 第一种:to的字符串写法 -->
<router-link active-class="active" to="/home">主页</router-link>

<!-- 第二种:to的对象写法 -->
<router-link active-class="active" :to="{path:'/home'}">Home</router-link>

4.6. 【命名路由】

作用:可以简化路由跳转及传参(后面就讲)。

给路由规则命名:

routes:[
  {
    name:'zhuye',
    path:'/home',
    component:Home
  },
  {
    name:'xinwen',
    path:'/news',
    component:News,
  },
  {
    name:'guanyu',
    path:'/about',
    component:About
  }
]

跳转路由:

<!--简化前:需要写完整的路径(to的字符串写法) -->
<router-link to="/news/detail">跳转</router-link>

<!--简化后:直接通过名字跳转(to的对象写法配合name属性) -->
<router-link :to="{name:'guanyu'}">跳转</router-link>

4.7. 【嵌套路由】

  1. 编写News的子路由:Detail.vue

  2. 配置路由规则,使用children配置项:

    const router = createRouter({
      history:createWebHistory(),
    routes:[
    {
    name:'zhuye',
    path:'/home',
    component:Home
    },
    {
    name:'xinwen',
    path:'/news',
    component:News,
    children:[
    {
    name:'xiang',
    path:'detail',
    component:Detail
    }
    ]
    },
    {
    name:'guanyu',
    path:'/about',
    component:About
    }
    ]
    })
    export default router
    
  3. 跳转路由(记得要加完整路径):

    <router-link to="/news/detail">xxxx</router-link>
    <!-- 或 -->
    <router-link :to="{path:'/news/detail'}">xxxx</router-link>
    
  4. 记得去Home组件中预留一个<router-view>

    <template>
      <div class="news">
        <nav class="news-list">
          <RouterLink v-for="news in newsList" :key="news.id" :to="{path:'/news/detail'}">
            {{news.name}}
          </RouterLink>
        </nav>
        <div class="news-detail">
          <RouterView/>
        </div>
      </div>
    </template>
    

4.8. 【路由传参】

query参数

  1. 传递参数

    <!-- 跳转并携带query参数(to的字符串写法) -->
    <router-link to="/news/detail?a=1&b=2&content=欢迎你">
    跳转
    </router-link>
    
    <!-- 跳转并携带query参数(to的对象写法) -->
    <RouterLink 
      :to="{
        //name:'xiang', //用name也可以跳转
        path:'/news/detail',
        query:{
          id:news.id,
          title:news.title,
          content:news.content
        }
      }"
    >
      {{news.title}}
    </RouterLink>
    
  2. 接收参数:

    import {useRoute} from 'vue-router'
    const route = useRoute()
    // 打印query参数
    console.log(route.query)
    

params参数

  1. 传递参数

    <!-- 跳转并携带params参数(to的字符串写法) -->
    <RouterLink :to="`/news/detail/001/新闻001/内容001`">{{news.title}}</RouterLink>
    
    <!-- 跳转并携带params参数(to的对象写法) -->
    <RouterLink 
      :to="{
        name:'xiang', //用name跳转
        params:{
          id:news.id,
          title:news.title,
          content:news.title
        }
      }"
    >
      {{news.title}}
    </RouterLink>
    
  2. 接收参数:

    import {useRoute} from 'vue-router'
    const route = useRoute()
    // 打印params参数
    console.log(route.params)
    

备注1:传递params参数时,若使用to的对象写法,必须使用name配置项,不能用path

备注2:传递params参数时,需要提前在规则中占位。

4.9. 【路由的props配置】

作用:让路由组件更方便的收到参数(可以将路由参数作为props传给组件)

{
name:'xiang',
path:'detail/:id/:title/:content',
component:Detail,

  // props的对象写法,作用:把对象中的每一组key-value作为props传给Detail组件
  // props:{a:1,b:2,c:3}, 

  // props的布尔值写法,作用:把收到了每一组params参数,作为props传给Detail组件
  // props:true
  
  // props的函数写法,作用:把返回的对象中每一组key-value作为props传给Detail组件
  props(route){
    return route.query
  }
}

4.10. 【 replace属性】

  1. 作用:控制路由跳转时操作浏览器历史记录的模式。

  2. 浏览器的历史记录有两种写入方式:分别为pushreplace

    • push是追加历史记录(默认值)。
    • replace是替换当前记录。
  3. 开启replace模式:

    <RouterLink replace .......>News</RouterLink>
    

4.11. 【编程式导航】

路由组件的两个重要的属性:$route$router变成了两个hooks

import {useRoute,useRouter} from 'vue-router'

const route = useRoute()
const router = useRouter()

console.log(route.query)
console.log(route.parmas)
console.log(router.push)
console.log(router.replace)

4.12. 【重定向】

  1. 作用:将特定的路径,重新定向到已有路由。

  2. 具体编码:

    {
        path:'/',
        redirect:'/about'
    }
    

5. pinia

5.1【准备一个效果】

pinia_example.gif

5.2【搭建 pinia 环境】

第一步:npm install pinia

第二步:操作src/main.ts

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

/* 引入createPinia,用于创建pinia */
import { createPinia } from 'pinia'

/* 创建pinia */
const pinia = createPinia()
const app = createApp(App)

/* 使用插件 */{}
app.use(pinia)
app.mount('#app')

此时开发者工具中已经有了pinia选项

5.3【存储+读取数据】

  1. Store是一个保存:状态业务逻辑 的实体,每个组件都可以读取写入它。

  2. 它有三个概念:stategetteraction,相当于组件中的: datacomputedmethods

  3. 具体编码:src/store/count.ts

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useCountStore = defineStore('count',{
      // 动作
      actions:{},
      // 状态
      state(){
        return {
          sum:6
        }
      },
      // 计算
      getters:{}
    })
    
  4. 具体编码:src/store/talk.ts

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useTalkStore = defineStore('talk',{
      // 动作
      actions:{},
      // 状态
      state(){
        return {
          talkList:[
            {id:'yuysada01',content:'你今天有点怪,哪里怪?怪好看的!'},
         {id:'yuysada02',content:'草莓、蓝莓、蔓越莓,你想我了没?'},
            {id:'yuysada03',content:'心里给你留了一块地,我的死心塌地'}
          ]
        }
      },
      // 计算
      getters:{}
    })
    
  5. 组件中使用state中的数据

    <template>
      <h2>当前求和为:{{ sumStore.sum }}</h2>
    </template>
    
    <script setup lang="ts" name="Count">
      // 引入对应的useXxxxxStore
      import {useSumStore} from '@/store/sum'
      
      // 调用useXxxxxStore得到对应的store
      const sumStore = useSumStore()
    </script>
    
    <template>
    <ul>
        <li v-for="talk in talkStore.talkList" :key="talk.id">
          {{ talk.content }}
        </li>
      </ul>
    </template>
    
    <script setup lang="ts" name="Count">
      import axios from 'axios'
      import {useTalkStore} from '@/store/talk'
    
      const talkStore = useTalkStore()
    </script>
    

5.4.【修改数据】(三种方式)

  1. 第一种修改方式,直接修改

    countStore.sum = 666
    
  2. 第二种修改方式:批量修改

    countStore.$patch({
      sum:999,
      school:'atguigu'
    })
    
  3. 第三种修改方式:借助action修改(action中可以编写一些业务逻辑)

    import { defineStore } from 'pinia'
    
    export const useCountStore = defineStore('count', {
      /*************/
      actions: {
        //加
        increment(value:number) {
          if (this.sum < 10) {
            //操作countStore中的sum
            this.sum += value
          }
        },
        //减
        decrement(value:number){
          if(this.sum > 1){
            this.sum -= value
          }
        }
      },
      /*************/
    })
    
  4. 组件中调用action即可

    // 使用countStore
    const countStore = useCountStore()
    
    // 调用对应action
    countStore.incrementOdd(n.value)
    

5.5.【storeToRefs】

  • 借助storeToRefsstore中的数据转为ref对象,方便在模板中使用。
  • 注意:pinia提供的storeToRefs只会将数据做转换,而VuetoRefs会转换store中数据。
<template>
<div class="count">
<h2>当前求和为:{{sum}}</h2>
</div>
</template>

<script setup lang="ts" name="Count">
  import { useCountStore } from '@/store/count'
  /* 引入storeToRefs */
  import { storeToRefs } from 'pinia'

/* 得到countStore */
  const countStore = useCountStore()
  /* 使用storeToRefs转换countStore,随后解构 */
  const {sum} = storeToRefs(countStore)
</script>

5.6.【getters】

  1. 概念:当state中的数据,需要经过处理后再使用时,可以使用getters配置。

  2. 追加getters配置。

    // 引入defineStore用于创建store
    import {defineStore} from 'pinia'
    
    // 定义并暴露一个store
    export const useCountStore = defineStore('count',{
      // 动作
      actions:{
        /************/
      },
      // 状态
      state(){
        return {
          sum:1,
          school:'atguigu'
        }
      },
      // 计算
      getters:{
        bigSum:(state):number => state.sum *10,
        upperSchool():string{
          return this. school.toUpperCase()
        }
      }
    })
    
  3. 组件中读取数据:

    const {increment,decrement} = countStore
    let {sum,school,bigSum,upperSchool} = storeToRefs(countStore)
    

5.7.【$subscribe】

通过 store 的 $subscribe() 方法侦听 state 及其变化

talkStore.$subscribe((mutate,state)=>{
  console.log('LoveTalk',mutate,state)
  localStorage.setItem('talk',JSON.stringify(talkList.value))
})

5.8. 【store组合式写法】

import {defineStore} from 'pinia'
import axios from 'axios'
import {nanoid} from 'nanoid'
import {reactive} from 'vue'

export const useTalkStore = defineStore('talk',()=>{
  // talkList就是state
  const talkList = reactive(
    JSON.parse(localStorage.getItem('talkList') as string) || []
  )

  // getATalk函数相当于action
  async function getATalk(){
    // 发请求,下面这行的写法是:连续解构赋值+重命名
    let {data:{content:title}} = await axios.get('https://api.uomg.com/api/rand.qinghua?format=json')
    // 把请求回来的字符串,包装成一个对象
    let obj = {id:nanoid(),title}
    // 放到数组中
    talkList.unshift(obj)
  }
  return {talkList,getATalk}
})

6. 组件通信

Vue3组件通信和Vue2的区别:

  • 移出事件总线,使用mitt代替。
  • vuex换成了pinia
  • .sync优化到了v-model里面了。
  • $listeners所有的东西,合并到$attrs中了。
  • $children被砍掉了。

常见搭配形式:

image-20231119185900990.png

6.1. 【props】

概述:props是使用频率最高的一种通信方式,常用与 :父 ↔ 子

  • 父传子:属性值是非函数
  • 子传父:属性值是函数

父组件:

<template>
  <div class="father">
    <h3>父组件,</h3>
<h4>我的车:{{ car }}</h4>
<h4>儿子给的玩具:{{ toy }}</h4>
<Child :car="car" :getToy="getToy"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
// 数据
const car = ref('奔驰')
const toy = ref()
// 方法
function getToy(value:string){
toy.value = value
}
</script>

子组件

<template>
  <div class="child">
    <h3>子组件</h3>
<h4>我的玩具:{{ toy }}</h4>
<h4>父给我的车:{{ car }}</h4>
<button @click="getToy(toy)">玩具给父亲</button>
  </div>
</template>

<script setup lang="ts" name="Child">
import { ref } from "vue";
const toy = ref('奥特曼')

defineProps(['car','getToy'])
</script>

6.2. 【自定义事件】

  1. 概述:自定义事件常用于:子 => 父。
  2. 注意区分好:原生事件、自定义事件。
  • 原生事件:
    • 事件名是特定的(clickmosueenter等等)
    • 事件对象$event: 是包含事件相关信息的对象(pageXpageYtargetkeyCode
  • 自定义事件:
    • 事件名是任意名称
    • 事件对象$event: 是调用emit时所提供的数据,可以是任意类型!!!
  1. 示例:

    <!--在父组件中,给子组件绑定自定义事件:-->
    <Child @send-toy="toy = $event"/>
    
    <!--注意区分原生事件与自定义事件中的$event-->
    <button @click="toy = $event">测试</button>
    
    //子组件中,触发事件:
    this.$emit('send-toy', 具体数据)
    

6.3. 【mitt】

概述:与消息订阅与发布(pubsub)功能类似,可以实现任意组件间通信。

安装mitt

npm i mitt

新建文件:src\utils\emitter.ts

// 引入mitt 
import mitt from "mitt";

// 创建emitter
const emitter = mitt()

/*
  // 绑定事件
  emitter.on('abc',(value)=>{
    console.log('abc事件被触发',value)
  })
  emitter.on('xyz',(value)=>{
    console.log('xyz事件被触发',value)
  })

  setInterval(() => {
    // 触发事件
    emitter.emit('abc',666)
    emitter.emit('xyz',777)
  }, 1000);

  setTimeout(() => {
    // 清理事件
    emitter.all.clear()
  }, 3000); 
*/

// 创建并暴露mitt
export default emitter

接收数据的组件中:绑定事件、同时在销毁前解绑事件:

import emitter from "@/utils/emitter";
import { onUnmounted } from "vue";

// 绑定事件
emitter.on('send-toy',(value)=>{
  console.log('send-toy事件被触发',value)
})

onUnmounted(()=>{
  // 解绑事件
  emitter.off('send-toy')
})

【第三步】:提供数据的组件,在合适的时候触发事件

import emitter from "@/utils/emitter";

function sendToy(){
  // 触发事件
  emitter.emit('send-toy',toy.value)
}

注意这个重要的内置关系,总线依赖着这个内置关系

6.4.【v-model】

  1. 概述:实现 父↔子 之间相互通信。

  2. 前序知识 —— v-model的本质

    <!-- 使用v-model指令 -->
    <input type="text" v-model="userName">
    
    <!-- v-model的本质是下面这行代码 -->
    <input 
      type="text" 
      :value="userName" 
      @input="userName =(<HTMLInputElement>$event.target).value"
    >
    
  3. 组件标签上的v-model的本质::moldeValueupdate:modelValue事件。

    <!-- 组件标签上使用v-model指令 -->
    <AtguiguInput v-model="userName"/>
    
    <!-- 组件标签上v-model的本质 -->
    <AtguiguInput :modelValue="userName" @update:model-value="userName = $event"/>
    

    AtguiguInput组件中:

    <template>
      <div class="box">
        <!--将接收的value值赋给input元素的value属性,目的是:为了呈现数据 -->
    <!--给input元素绑定原生input事件,触发input事件时,进而触发update:model-value事件-->
        <input 
           type="text" 
           :value="modelValue" 
           @input="emit('update:model-value',$event.target.value)"
        >
      </div>
    </template>
    
    <script setup lang="ts" name="AtguiguInput">
      // 接收props
      defineProps(['modelValue'])
      // 声明事件
      const emit = defineEmits(['update:model-value'])
    </script>
    
  4. 也可以更换value,例如改成abc

    <!-- 也可以更换value,例如改成abc-->
    <AtguiguInput v-model:abc="userName"/>
    
    <!-- 上面代码的本质如下 -->
    <AtguiguInput :abc="userName" @update:abc="userName = $event"/>
    

    AtguiguInput组件中:

    <template>
      <div class="box">
        <input 
           type="text" 
           :value="abc" 
           @input="emit('update:abc',$event.target.value)"
        >
      </div>
    </template>
    
    <script setup lang="ts" name="AtguiguInput">
      // 接收props
      defineProps(['abc'])
      // 声明事件
      const emit = defineEmits(['update:abc'])
    </script>
    
  5. 如果value可以更换,那么就可以在组件标签上多次使用v-model

    <AtguiguInput v-model:abc="userName" v-model:xyz="password"/>
    

6.5.【$attrs 】

  1. 概述:$attrs用于实现当前组件的父组件,向当前组件的子组件通信(祖→孙)。

  2. 具体说明:$attrs是一个对象,包含所有父组件传入的标签属性。

    注意:$attrs会自动排除props中声明的属性(可以认为声明过的 props 被子组件自己“消费”了)

父组件:

<template>
  <div class="father">
    <h3>父组件</h3>
<Child :a="a" :b="b" :c="c" :d="d" v-bind="{x:100,y:200}" :updateA="updateA"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
let a = ref(1)
let b = ref(2)
let c = ref(3)
let d = ref(4)

function updateA(value){
a.value = value
}
</script>

子组件:

<template>
<div class="child">
<h3>子组件</h3>
<GrandChild v-bind="$attrs"/>
</div>
</template>

<script setup lang="ts" name="Child">
import GrandChild from './GrandChild.vue'
</script>

孙组件:

<template>
<div class="grand-child">
<h3>孙组件</h3>
<h4>a:{{ a }}</h4>
<h4>b:{{ b }}</h4>
<h4>c:{{ c }}</h4>
<h4>d:{{ d }}</h4>
<h4>x:{{ x }}</h4>
<h4>y:{{ y }}</h4>
<button @click="updateA(666)">点我更新A</button>
</div>
</template>

<script setup lang="ts" name="GrandChild">
defineProps(['a','b','c','d','x','y','updateA'])
</script>

6.6. 【refsrefs、parent】

  1. 概述:

    • $refs用于 :父→子。
    • $parent用于:子→父。
  2. 原理如下:

    属性 说明
    $refs 值为对象,包含所有被ref属性标识的DOM元素或组件实例。
    $parent 值为对象,当前组件的父组件实例对象。

6.7. 【provide、inject】

  1. 概述:实现祖孙组件直接通信

  2. 具体使用:

    • 在祖先组件中通过provide配置向后代组件提供数据
    • 在后代组件中通过inject配置来声明接收数据
  3. 具体编码:

    【第一步】父组件中,使用provide提供数据

    <template>
      <div class="father">
        <h3>父组件</h3>
        <h4>资产:{{ money }}</h4>
        <h4>汽车:{{ car }}</h4>
        <button @click="money += 1">资产+1</button>
        <button @click="car.price += 1">汽车价格+1</button>
        <Child/>
      </div>
    </template>
    
    <script setup lang="ts" name="Father">
      import Child from './Child.vue'
      import { ref,reactive,provide } from "vue";
      // 数据
      let money = ref(100)
      let car = reactive({
        brand:'奔驰',
        price:100
      })
      // 用于更新money的方法
      function updateMoney(value:number){
        money.value += value
      }
      // 提供数据
      provide('moneyContext',{money,updateMoney})
      provide('car',car)
    </script>
    

    注意:子组件中不用编写任何东西,是不受到任何打扰的

    【第二步】孙组件中使用inject配置项接受数据。

    <template>
      <div class="grand-child">
        <h3>我是孙组件</h3>
        <h4>资产:{{ money }}</h4>
        <h4>汽车:{{ car }}</h4>
        <button @click="updateMoney(6)">点我</button>
      </div>
    </template>
    
    <script setup lang="ts" name="GrandChild">
      import { inject } from 'vue';
      // 注入数据
     let {money,updateMoney} = inject('moneyContext',{money:0,updateMoney:(x:number)=>{}})
      let car = inject('car')
    
```

6.8. 【pinia】

参考之前pinia部分的讲解

6.9. 【slot】

1. 默认插槽

img

父组件中:
        <Category title="今日热门游戏">
          <ul>
            <li v-for="g in games" :key="g.id">{{ g.name }}</li>
          </ul>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <!-- 默认插槽 -->
            <slot></slot>
          </div>
        </template>

2. 具名插槽

父组件中:
        <Category title="今日热门游戏">
          <template v-slot:s1>
            <ul>
              <li v-for="g in games" :key="g.id">{{ g.name }}</li>
            </ul>
          </template>
          <template #s2>
            <a href="">更多</a>
          </template>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <slot name="s1"></slot>
            <slot name="s2"></slot>
          </div>
        </template>

3. 作用域插槽

  1. 理解:数据在组件的自身,但根据数据生成的结构需要组件的使用者来决定。(新闻数据在News组件中,但使用数据所遍历出来的结构由App组件决定)

  2. 具体编码:

    父组件中:
          <Game v-slot="params">
          <!-- <Game v-slot:default="params"> -->
          <!-- <Game #default="params"> -->
            <ul>
              <li v-for="g in params.games" :key="g.id">{{ g.name }}</li>
            </ul>
          </Game>
    
    子组件中:
          <template>
            <div class="category">
              <h2>今日游戏榜单</h2>
              <slot :games="games" a="哈哈"></slot>
            </div>
          </template>
    
          <script setup lang="ts" name="Category">
            import {reactive} from 'vue'
            let games = reactive([
              {id:'asgdytsa01',name:'英雄联盟'},
              {id:'asgdytsa02',name:'王者荣耀'},
              {id:'asgdytsa03',name:'红色警戒'},
              {id:'asgdytsa04',name:'斗罗大陆'}
            ])
          </script>
    

7. 其它 API

7.1.【shallowRef 与 shallowReactive 】

shallowRef

  1. 作用:创建一个响应式数据,但只对顶层属性进行响应式处理。

  2. 用法:

    let myVar = shallowRef(initialValue);
    
  3. 特点:只跟踪引用值的变化,不关心值内部的属性变化。

shallowReactive

  1. 作用:创建一个浅层响应式对象,只会使对象的最顶层属性变成响应式的,对象内部的嵌套属性则不会变成响应式的

  2. 用法:

    const myObj = shallowReactive({ ... });
    
  3. 特点:对象的顶层属性是响应式的,但嵌套对象的属性不是。

总结

通过使用 shallowRef()shallowReactive() 来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理,避免了对每一个内部属性做响应式所带来的性能成本,这使得属性的访问变得更快,可提升性能。

7.2.【readonly 与 shallowReadonly】

readonly

  1. 作用:用于创建一个对象的深只读副本。

  2. 用法:

    const original = reactive({ ... });
    const readOnlyCopy = readonly(original);
    
  3. 特点:

    • 对象的所有嵌套属性都将变为只读。
    • 任何尝试修改这个对象的操作都会被阻止(在开发模式下,还会在控制台中发出警告)。
  4. 应用场景:

    • 创建不可变的状态快照。
    • 保护全局状态或配置不被修改。

shallowReadonly

  1. 作用:与 readonly 类似,但只作用于对象的顶层属性。

  2. 用法:

    const original = reactive({ ... });
    const shallowReadOnlyCopy = shallowReadonly(original);
    
  3. 特点:

    • 只将对象的顶层属性设置为只读,对象内部的嵌套属性仍然是可变的。

    • 适用于只需保护对象顶层属性的场景。

7.3.【toRaw 与 markRaw】

toRaw

  1. 作用:用于获取一个响应式对象的原始对象, toRaw 返回的对象不再是响应式的,不会触发视图更新。

    官网描述:这是一个可以用于临时读取而不引起代理访问/跟踪开销,或是写入而不触发更改的特殊方法。不建议保存对原始对象的持久引用,请谨慎使用。

    何时使用? —— 在需要将响应式对象传递给非 Vue 的库或外部系统时,使用 toRaw 可以确保它们收到的是普通对象

  2. 具体编码:

    import { reactive,toRaw,markRaw,isReactive } from "vue";
    
    /* toRaw */
    // 响应式对象
    let person = reactive({name:'tony',age:18})
    // 原始对象
    let rawPerson = toRaw(person)
    
    
    /* markRaw */
    let citysd = markRaw([
      {id:'asdda01',name:'北京'},
      {id:'asdda02',name:'上海'},
      {id:'asdda03',name:'天津'},
      {id:'asdda04',name:'重庆'}
    ])
    // 根据原始对象citys去创建响应式对象citys2 —— 创建失败,因为citys被markRaw标记了
    let citys2 = reactive(citys)
    console.log(isReactive(person))
    console.log(isReactive(rawPerson))
    console.log(isReactive(citys))
    console.log(isReactive(citys2))
    

markRaw

  1. 作用:标记一个对象,使其永远不会变成响应式的。

    例如使用mockjs时,为了防止误把mockjs变为响应式对象,可以使用 markRaw 去标记mockjs

  2. 编码:

    /* markRaw */
    let citys = markRaw([
      {id:'asdda01',name:'北京'},
      {id:'asdda02',name:'上海'},
      {id:'asdda03',name:'天津'},
      {id:'asdda04',name:'重庆'}
    ])
    // 根据原始对象citys去创建响应式对象citys2 —— 创建失败,因为citys被markRaw标记了
    let citys2 = reactive(citys)
    

7.4.【customRef】

作用:创建一个自定义的ref,并对其依赖项跟踪和更新触发进行逻辑控制。

实现防抖效果(useSumRef.ts):

import {customRef } from "vue";

export default function(initValue:string,delay:number){
  let msg = customRef((track,trigger)=>{
    let timer:number
    return {
      get(){
        track() // 告诉Vue数据msg很重要,要对msg持续关注,一旦变化就更新
        return initValue
      },
      set(value){
        clearTimeout(timer)
        timer = setTimeout(() => {
          initValue = value
          trigger() //通知Vue数据msg变化了
        }, delay);
      }
    }
  }) 
  return {msg}
}

组件中使用:

8. Vue3新组件

8.1. 【Teleport】

  • 什么是Teleport?—— Teleport 是一种能够将我们的组件html结构移动到指定位置的技术。
<teleport to='body' >
    <div class="modal" v-show="isShow">
      <h2>我是一个弹窗</h2>
      <p>我是弹窗中的一些内容</p>
      <button @click="isShow = false">关闭弹窗</button>
    </div>
</teleport>

8.2. 【Suspense】

  • 等待异步组件时渲染一些额外内容,让应用有更好的用户体验
  • 使用步骤:
    • 异步引入组件
    • 使用Suspense包裹组件,并配置好defaultfallback
import { defineAsyncComponent,Suspense } from "vue";
const Child = defineAsyncComponent(()=>import('./Child.vue'))
<template>
    <div class="app">
        <h3>我是App组件</h3>
        <Suspense>
          <template v-slot:default>
            <Child/>
          </template>
          <template v-slot:fallback>
            <h3>加载中.......</h3>
          </template>
        </Suspense>
    </div>
</template>

8.3.【全局API转移到应用对象】

  • app.component
  • app.config
  • app.directive
  • app.mount
  • app.unmount
  • app.use

8.4.【其他】

  • 过渡类名 v-enter 修改为 v-enter-from、过渡类名 v-leave 修改为 v-leave-from

  • keyCode 作为 v-on 修饰符的支持。

  • v-model 指令在组件上的使用已经被重新设计,替换掉了 v-bind.sync。

  • v-ifv-for 在同一个元素身上使用时的优先级发生了变化。

  • 移除了$on$off$once 实例方法。

  • 移除了过滤器 filter

  • 移除了$children 实例 propert

    ......

Vue3实现拖拽排序

2025年11月14日 13:54

Vue3 + Element Plus + SortableJS 实现表格拖拽排序功能

📋 目录

功能概述

在管理后台系统中,表格数据的排序功能是一个常见的需求。本文介绍如何使用 Vue3、Element Plus 和 SortableJS 实现一个完整的表格拖拽排序功能,支持:

  • ✅ 通过拖拽图标对表格行进行排序
  • ✅ 实时更新数据顺序
  • ✅ 支持数据过滤后的排序
  • ✅ 切换标签页时自动初始化
  • ✅ 优雅的动画效果

先看实现效果: 在这里插入图片描述

技术栈

  • Vue 3 - 渐进式 JavaScript 框架
  • Element Plus - Vue 3 组件库
  • SortableJS - 轻量级拖拽排序库
  • TypeScript - 类型安全的 JavaScript 超集

实现思路

1. 整体架构

用户拖拽表格行
    ↓
SortableJS 监听拖拽事件
    ↓
触发 onEnd 回调
    ↓
更新 Vue 响应式数据
    ↓
表格自动重新渲染

2. 关键步骤

  1. 安装依赖:引入 SortableJS 库
  2. 获取 DOM:获取表格 tbody 元素
  3. 初始化 Sortable:创建拖拽实例
  4. 处理回调:在拖拽结束时更新数据
  5. 生命周期管理:在适当时机初始化和销毁实例

代码实现

1. 安装依赖

npm install sortablejs
# 或
pnpm add sortablejs

2. 导入必要的模块

import { ref, nextTick, watch, onMounted } from "vue";
import Sortable from "sortablejs";
import { Operation } from "@element-plus/icons-vue";//图标

3. 定义数据结构

interface TypeItem {
  id: string;
  name: string;
  enabled: boolean;
  sortOrder: number;
}

const typeData = ref<TypeItem[]>([
  { id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 },
  { id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 },
  // ... 更多数据
]);

4. 模板结构

<template>
  <el-table ref="typeTableRef" :data="filteredTypeData" stripe row-key="id">
    <!-- 排序列:显示拖拽图标 -->
    <el-table-column label="排序" width="131">
      <template #default>
        <el-icon class="drag-handle">
          <Operation />
        </el-icon>
      </template>
    </el-table-column>
    
    <!-- 其他列 -->
    <el-table-column prop="name" label="名称" />
    <el-table-column prop="enabled" label="启用/禁用">
      <template #default="{ row }">
        <el-switch v-model="row.enabled" />
      </template>
    </el-table-column>
  </el-table>
</template>

5. 核心实现代码

// 表格引用
const typeTableRef = ref<InstanceType<typeof ElTable>>();

// Sortable 实例(用于后续销毁)
let sortableInstance: Sortable | null = null;

/**
 * 初始化拖拽排序功能
 */
const initSortable = () => {
  // 1. 销毁旧实例,避免重复创建
  if (sortableInstance) {
    sortableInstance.destroy();
    sortableInstance = null;
  }

  // 2. 等待 DOM 更新完成
  nextTick(() => {
    // 3. 获取表格的 tbody 元素
    const tbody = typeTableRef.value?.$el?.querySelector(
      ".el-table__body-wrapper tbody"
    );
    
    if (!tbody) return;

    // 4. 创建 Sortable 实例
    sortableInstance = Sortable.create(tbody, {
      // 指定拖拽手柄(只能通过拖拽图标来拖拽)
      handle: ".drag-handle",
      
      // 动画时长(毫秒)
      animation: 300,
      
      // 拖拽结束回调
      onEnd: ({ newIndex, oldIndex }) => {
        // 5. 更新数据顺序
        if (
          newIndex !== undefined &&
          oldIndex !== undefined &&
          filterStatus.value === "all" // 只在"全部"状态下允许排序
        ) {
          // 获取被移动的项
          const movedItem = typeData.value[oldIndex];
          
          // 从原位置删除
          typeData.value.splice(oldIndex, 1);
          
          // 插入到新位置
          typeData.value.splice(newIndex, 0, movedItem);
          
          // 更新排序字段
          typeData.value.forEach((item, index) => {
            item.sortOrder = index + 1;
          });
        }
      }
    });
  });
};

6. 生命周期管理

/**
 * 监听标签页切换,初始化拖拽
 */
const watchActiveTab = () => {
  if (activeTab.value === "type") {
    // 延迟初始化,确保表格已完全渲染
    setTimeout(() => {
      initSortable();
    }, 300);
  }
};

// 组件挂载时初始化
onMounted(() => {
  watchActiveTab();
});

// 监听标签页切换
watch(activeTab, () => {
  watchActiveTab();
});

// 监听过滤器变化,重新初始化拖拽
watch(filterStatus, () => {
  if (activeTab.value === "type") {
    setTimeout(() => {
      initSortable();
    }, 100);
  }
});

7. 样式定义

/* 拖拽手柄样式 */
.drag-handle {
  color: #909399;
  cursor: move;
  font-size: 18px;
  transition: color 0.3s;
}

.drag-handle:hover {
  color: #1890ff;
}

/* 表格样式 */
.type-table {
  margin-top: 0;
}

:deep(.type-table .el-table__header-wrapper) {
  background-color: #f9fafc;
}

:deep(.type-table .el-table th) {
  background-color: #f9fafc;
  font-size: 14px;
  font-weight: 500;
  color: #33425cfa;
  font-family: PingFang SC;
  border-bottom: 1px solid #dcdfe6;
}

核心要点

1. 实例管理

问题:如果不管理 Sortable 实例,切换标签页或过滤器时会创建多个实例,导致拖拽行为异常。

解决:使用变量保存实例引用,在创建新实例前先销毁旧实例。

let sortableInstance: Sortable | null = null;

const initSortable = () => {
  // 先销毁旧实例
  if (sortableInstance) {
    sortableInstance.destroy();
    sortableInstance = null;
  }
  // 再创建新实例
  // ...
};

2. DOM 获取时机

问题:如果直接获取 DOM,可能表格还未渲染完成,导致获取失败。

解决:使用 nextTick 等待 Vue 完成 DOM 更新,或使用 setTimeout 延迟执行。

nextTick(() => {
  const tbody = typeTableRef.value?.$el?.querySelector(
    ".el-table__body-wrapper tbody"
  );
  // ...
});

3. 拖拽手柄

问题:如果不指定拖拽手柄,整行都可以拖拽,可能与其他交互冲突(如点击编辑按钮)。

解决:使用 handle 选项指定只有拖拽图标可以触发拖拽。

Sortable.create(tbody, {
  handle: ".drag-handle", // 只允许通过 .drag-handle 元素拖拽
  // ...
});

4. 数据更新策略

问题:直接操作 DOM 顺序不会更新 Vue 的响应式数据。

解决:在 onEnd 回调中手动更新数据数组的顺序。

onEnd: ({ newIndex, oldIndex }) => {
  const movedItem = typeData.value[oldIndex];
  typeData.value.splice(oldIndex, 1);
  typeData.value.splice(newIndex, 0, movedItem);
  // 更新排序字段
  typeData.value.forEach((item, index) => {
    item.sortOrder = index + 1;
  });
}

5. 过滤状态处理

问题:当表格数据被过滤后,拖拽的索引可能不准确。

解决:只在"全部"状态下允许排序,或根据过滤后的数据计算正确的索引。

onEnd: ({ newIndex, oldIndex }) => {
  if (filterStatus.value === "all") {
    // 只在全部状态下允许排序
    // ...
  }
}

常见问题

Q1: 拖拽后数据没有更新?

A: 检查是否正确更新了响应式数据。SortableJS 只负责 DOM 操作,不会自动更新 Vue 数据。

Q2: 切换标签页后拖拽失效?

A: 需要在标签页切换时重新初始化 Sortable 实例,因为 DOM 已经重新渲染。

Q3: 拖拽时整行都可以拖,如何限制?

A: 使用 handle 选项指定拖拽手柄元素。

Q4: 拖拽动画不流畅?

A: 调整 animation 参数的值,通常 200-300ms 效果较好。

Q5: 如何保存排序结果?

A: 在 onEnd 回调中,将更新后的数据发送到后端 API。

onEnd: ({ newIndex, oldIndex }) => {
  // 更新本地数据
  // ...
  
  // 保存到后端
  saveSortOrder(typeData.value.map(item => ({
    id: item.id,
    sortOrder: item.sortOrder
  })));
}

完整示例代码

<template>
  <div class="type-setting">
    <!-- 过滤器 -->
    <div class="filter-actions">
      <el-button
        :type="filterStatus === 'all' ? 'primary' : ''"
        @click="filterStatus = 'all'"
      >
        全部
      </el-button>
      <el-button
        :type="filterStatus === 'enabled' ? 'primary' : ''"
        @click="filterStatus = 'enabled'"
      >
        启用
      </el-button>
    </div>

    <!-- 表格 -->
    <el-table
      ref="typeTableRef"
      :data="filteredTypeData"
      stripe
      row-key="id"
    >
      <el-table-column label="排序" width="131">
        <template #default>
          <el-icon class="drag-handle">
            <Operation />
          </el-icon>
        </template>
      </el-table-column>
      <el-table-column prop="name" label="名称" />
      <el-table-column prop="enabled" label="启用/禁用">
        <template #default="{ row }">
          <el-switch v-model="row.enabled" />
        </template>
      </el-table-column>
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button type="primary" link @click="handleEdit(row)">
            编辑
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, watch, onMounted } from "vue";
import { ElTable } from "element-plus";
import Sortable from "sortablejs";
import { Operation } from "@element-plus/icons-vue";

interface TypeItem {
  id: string;
  name: string;
  enabled: boolean;
  sortOrder: number;
}

const typeData = ref<TypeItem[]>([
  { id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 },
  { id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 },
  { id: "3", name: "楼宇性质3", enabled: false, sortOrder: 3 },
]);

const filterStatus = ref<"all" | "enabled" | "disabled">("all");
const typeTableRef = ref<InstanceType<typeof ElTable>>();
let sortableInstance: Sortable | null = null;

const filteredTypeData = computed(() => {
  if (filterStatus.value === "all") return typeData.value;
  if (filterStatus.value === "enabled") {
    return typeData.value.filter(item => item.enabled);
  }
  return typeData.value.filter(item => !item.enabled);
});

const initSortable = () => {
  if (sortableInstance) {
    sortableInstance.destroy();
    sortableInstance = null;
  }

  nextTick(() => {
    const tbody = typeTableRef.value?.$el?.querySelector(
      ".el-table__body-wrapper tbody"
    );
    if (!tbody) return;

    sortableInstance = Sortable.create(tbody, {
      handle: ".drag-handle",
      animation: 300,
      onEnd: ({ newIndex, oldIndex }) => {
        if (
          newIndex !== undefined &&
          oldIndex !== undefined &&
          filterStatus.value === "all"
        ) {
          const movedItem = typeData.value[oldIndex];
          typeData.value.splice(oldIndex, 1);
          typeData.value.splice(newIndex, 0, movedItem);
          typeData.value.forEach((item, index) => {
            item.sortOrder = index + 1;
          });
        }
      }
    });
  });
};

onMounted(() => {
  setTimeout(() => initSortable(), 300);
});

watch(filterStatus, () => {
  setTimeout(() => initSortable(), 100);
});
</script>

<style scoped>
.drag-handle {
  color: #909399;
  cursor: move;
  font-size: 18px;
}

.drag-handle:hover {
  color: #1890ff;
}
</style>

总结

通过本文的介绍,我们实现了一个完整的表格拖拽排序功能。关键点包括:

  1. 正确的实例管理:避免重复创建和内存泄漏
  2. 合适的初始化时机:确保 DOM 已完全渲染
  3. 数据同步更新:手动更新 Vue 响应式数据
  4. 良好的用户体验:指定拖拽手柄,添加动画效果
  5. 完善的错误处理:处理边界情况

这个方案可以轻松应用到其他需要拖拽排序的场景,如菜单管理、分类排序等。希望本文对您有所帮助!


由于vite版本不一致,导致vue组件引入报错

2025年11月14日 10:26

最近开发了一个vue3的瀑布流插件,但是发现插件在部分项目无法正常展示。

报错信息: Uncaught (in promise) TypeError: Cannot read properties of null (reading 'ce')

image.png

这个错误信息“Uncaught (in promise) TypeError: Cannot read properties of null (reading 'ce') ” 是 Vue 3 中一个常见但信息模糊的报错,通常与组件未正确挂载、异步组件加载失败、或虚拟 DOM 渲染异常有关。

'ce' 是 Vue 内部压缩后的属性名,通常指向 组件的虚拟节点(vnode)或渲染上下文,当 Vue 试图访问一个已卸载或未挂载的组件实例时,就会抛出这个错误。

✅ 常见原因与排查方向

1. 组件未正确挂载就访问其 DOM 或实例

比如你在 onMounted 之前就访问了 refthis.$el,或者组件被条件渲染(v-if)控制,导致挂载失败。

2. 异步组件加载失败或返回 null

如果你使用了 defineAsyncComponentimport(),但组件加载失败或返回了 null,Vue 会尝试渲染一个无效的 vnode。

3. 组件在 v-if 或 v-show 中频繁切换,导致卸载时访问旧实例

比如你在 onUnmountedwatchEffect 中访问了已销毁的 DOM 或组件实例。

4. 使用了不兼容的库或插件

某些第三方库(如旧版本的 vue-router, pinia, element-plus)在 Vue 3.3+ 中可能存在兼容性问题,导致内部访问失败。

5. 组件依赖的devDependencies库和项目devDependencies版本不一致

由于组件依赖的运行时库在打包的时候不会编译进入dist包,项目本地运行时双方依赖版本不一致就会导致报错。

经过排查后发现我组件的vite版本和项目的vite版本差距太大。

//项目依赖库版本
"devDependencies": {
    "@vitejs/plugin-vue": "^3.0.3",
    "vite": "^3.0.7"
 }

//组件库依赖版本
"devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "vite": "^7.2.2"
 }

解决方案:升级项目的依赖库版本即可正常展示;

image.png

vue3+vite实现瀑布流效果 vue3-waterfall-x

还在死磕模板语法?Vue渲染函数+JSX让你开发效率翻倍!

2025年11月14日 07:28

开篇:被模板限制的烦恼时刻

你是不是也遇到过这样的场景?产品经理拿着设计稿过来,说要做一个超级灵活的动态表单,每个字段的类型、验证规则、布局方式都可能随时变化。你看着那复杂的条件渲染,心里默默计算着要写多少v-if、v-switch,还有那些嵌套很深的组件结构,光是想想就头大。

或者,你需要封装一个高度可复用的业务组件,但使用模板时总觉得有些逻辑表达起来不够直接,尤其是在处理动态组件、递归组件这些高级用法时,模板语法显得有点力不从心。

别担心,今天我要跟你分享的Vue渲染函数和JSX,就是专门为解决这些问题而生的利器。它们能让你在Vue开发中拥有更大的灵活性,特别是在那些模板难以应对的动态场景里。

学完今天的内容,你会掌握如何用JSX写出更简洁直观的组件代码,理解渲染函数的工作原理,还能在实际项目中灵活运用这些技术解决复杂问题。

为什么需要超越模板?

先来说说模板的局限性。Vue的模板语法确实很友好,声明式、易上手,但在处理特别复杂的动态逻辑时,模板会变得冗长且难以维护。

想象一下这样的需求:根据后端返回的配置对象,动态渲染一个完整的页面结构。配置里可能包含按钮、输入框、表格等各种组件,还有它们之间的嵌套关系。用模板的话,你可能要写一大堆v-if和动态组件,代码可读性直线下降。

这时候渲染函数和JSX的优势就体现出来了。它们本质上都是JavaScript,能够利用JS完整的编程能力来表达组件结构。循环、条件判断、递归,这些在JS里都很自然,但在模板里就需要各种指令配合。

不过要说明的是,我并不是说模板不好。在大多数常规场景下,模板依然是最佳选择。只有在真正需要更大灵活性的动态场景中,才需要考虑使用渲染函数或JSX。

初识渲染函数:用JavaScript描述UI

先来看一个最简单的例子。平时我们用模板写一个按钮组件可能是这样的:

<template>
  <button :class="['btn', `btn-${type}`]" @click="handleClick">
    {{ text }}
  </button>
</template>

如果用渲染函数来写,会是这样:

export default {
  props: ['type', 'text'],
  methods: {
    handleClick() {
      this.$emit('click')
    }
  },
  render(h) {
    return h(
      'button',
      {
        class: ['btn', `btn-${this.type}`],
        on: {
          click: this.handleClick
        }
      },
      this.text
    )
  }
}

这里的h函数是创建虚拟DOM节点的工具,它接收三个参数:标签名、数据对象、子节点。数据对象可以包含class、style、props、on等属性。

可能你会觉得,这看起来比模板复杂啊?别急,这只是一个入门示例。当逻辑变得复杂时,渲染函数的优势才会真正显现。

JSX:更直观的写法

如果你觉得上面的渲染函数写法还是有些抽象,那么JSX可能会让你眼前一亮。JSX是一种JavaScript的语法扩展,它让我们能在JS中写类似HTML的结构。

同样的按钮组件,用JSX来写:

export default {
  props: ['type', 'text'],
  methods: {
    handleClick() {
      this.$emit('click')
    }
  },
  render() {
    return (
      <button 
        class={['btn', `btn-${this.type}`]}
        onClick={this.handleClick}
      >
        {this.text}
      </button>
    )
  }
}

是不是感觉亲切多了?JSX让渲染函数的写法更加直观,特别是对于有React经验的开发者来说,几乎可以无缝切换。

要在Vue项目中使用JSX,你需要配置相应的Babel插件。现在主流的Vue脚手架工具都支持这个功能,配置起来也很简单。

动态场景实战:可配置表单渲染器

让我们来看一个真实的业务场景。假设我们要做一个动态表单渲染器,根据JSON配置来渲染不同的表单字段。

首先定义配置结构:

const formConfig = [
  {
    type: 'input',
    name: 'username',
    label: '用户名',
    required: true,
    placeholder: '请输入用户名'
  },
  {
    type: 'select',
    name: 'gender',
    label: '性别',
    options: [
      { label: '男', value: 'male' },
      { label: '女', value: 'female' }
    ]
  },
  {
    type: 'checkbox',
    name: 'hobbies',
    label: '兴趣爱好',
    options: [
      { label: '读书', value: 'reading' },
      { label: '运动', value: 'sports' }
    ]
  }
]

如果用模板来实现,可能会是这样:

<template>
  <div class="form-renderer">
    <div v-for="field in config" :key="field.name">
      <label>{{ field.label }}</label>
      
      <input
        v-if="field.type === 'input'"
        :type="field.type"
        :name="field.name"
        :required="field.required"
        :placeholder="field.placeholder"
        v-model="formData[field.name]"
      >
      
      <select
        v-else-if="field.type === 'select'"
        :name="field.name"
        v-model="formData[field.name]"
      >
        <option
          v-for="option in field.options"
          :key="option.value"
          :value="option.value"
        >
          {{ option.label }}
        </option>
      </select>
      
      <div v-else-if="field.type === 'checkbox'">
        <label
          v-for="option in field.options"
          :key="option.value"
        >
          <input
            type="checkbox"
            :value="option.value"
            v-model="formData[field.name]"
          >
          {{ option.label }}
        </label>
      </div>
    </div>
  </div>
</template>

可以看到,模板里有很多条件判断,代码结构比较复杂。现在来看看用JSX如何实现:

export default {
  props: ['config'],
  data() {
    return {
      formData: {}
    }
  },
  render() {
    const renderField = (field) => {
      const commonProps = {
        name: field.name,
        value: this.formData[field.name],
        onInput: (value) => {
          this.formData[field.name] = value
        }
      }

      switch (field.type) {
        case 'input':
          return (
            <input
              {...commonProps}
              type="text"
              required={field.required}
              placeholder={field.placeholder}
            />
          )
        
        case 'select':
          return (
            <select {...commonProps}>
              {field.options.map(option => (
                <option value={option.value}>
                  {option.label}
                </option>
              ))}
            </select>
          )
        
        case 'checkbox':
          return (
            <div>
              {field.options.map(option => (
                <label>
                  <input
                    type="checkbox"
                    value={option.value}
                    checked={this.formData[field.name]?.includes(option.value)}
                    onChange={(e) => {
                      const values = this.formData[field.name] || []
                      if (e.target.checked) {
                        this.formData[field.name] = [...values, option.value]
                      } else {
                        this.formData[field.name] = values.filter(v => v !== option.value)
                      }
                    }}
                  />
                  {option.label}
                </label>
              ))}
            </div>
          )
        
        default:
          return null
      }
    }

    return (
      <div class="form-renderer">
        {this.config.map(field => (
          <div key={field.name}>
            <label>{field.label}</label>
            {renderField(field)}
          </div>
        ))}
      </div>
    )
  }
}

用JSX实现的代码结构更清晰,逻辑更集中。特别是当表单字段类型增多时,只需要在switch语句中添加新的case即可,扩展性更好。

高级技巧:递归组件与动态组件

渲染函数和JSX在处理递归组件和动态组件时尤其强大。比如我们要实现一个无限级嵌套的树形组件:

export default {
  name: 'TreeNode',
  props: {
    node: Object
  },
  render() {
    const renderNode = (node) => {
      // 如果有子节点,递归渲染
      if (node.children && node.children.length > 0) {
        return (
          <div class="tree-node">
            <div class="node-content">{node.name}</div>
            <div class="children">
              {node.children.map(child => (
                <TreeNode node={child} key={child.id} />
              ))}
            </div>
          </div>
        )
      }
      
      // 叶子节点
      return (
        <div class="tree-node leaf">
          <div class="node-content">{node.name}</div>
        </div>
      )
    }

    return renderNode(this.node)
  }
}

在JSX中,我们可以直接使用组件名来引用当前组件,实现递归渲染。这在模板中虽然也能实现,但写起来会比较别扭。

再看动态组件的例子。假设我们需要根据数据类型动态选择不同的展示组件:

const componentMap = {
  text: TextDisplay,
  image: ImageDisplay,
  video: VideoDisplay,
  chart: ChartDisplay
}

export default {
  props: ['data'],
  render() {
    const DynamicComponent = componentMap[this.data.type]
    
    if (!DynamicComponent) {
      return <div>未知数据类型</div>
    }

    return (
      <DynamicComponent 
        data={this.data}
        class="data-display"
      />
    )
  }
}

这种动态组件的选择逻辑在JSX中表达得非常自然,如果要用模板的话,需要配合<component :is="componentType">语法,但在复杂逻辑下不如JSX直观。

性能优化与最佳实践

使用渲染函数和JSX时,有几个性能优化的要点需要注意。

首先是正确的使用key。在循环渲染元素时,一定要提供稳定且唯一的key:

render() {
  return (
    <div>
      {this.items.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  )
}

其次是避免不必要的重新渲染。在复杂的渲染函数中,可以合理使用计算属性和方法来缓存一些中间结果:

export default {
  props: ['items'],
  computed: {
    processedItems() {
      // 复杂的处理逻辑放在计算属性中
      return this.items.map(item => ({
        ...item,
        processed: true
      }))
    }
  },
  render() {
    return (
      <div>
        {this.processedItems.map(item => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    )
  }
}

另外,在JSX中正确使用插槽。Vue的插槽在JSX中有对应的写法:

// 定义带插槽的组件
export default {
  render() {
    return (
      <div class="card">
        <div class="card-header">
          {this.$slots.header}
        </div>
        <div class="card-body">
          {this.$slots.default}
        </div>
        <div class="card-footer">
          {this.$slots.footer}
        </div>
      </div>
    )
  }
}

// 使用带插槽的组件
render() {
  return (
    <Card>
      <template slot="header">
        <h2>标题</h2>
      </template>
      
      <p>这里是主要内容</p>
      
      <template slot="footer">
        <button>确定</button>
      </template>
    </Card>
  )
}

与Composition API的完美结合

在Vue 3的Composition API中,渲染函数和JSX的配合更加默契。我们可以在setup函数中直接返回渲染函数:

import { ref, computed } from 'vue'

export default {
  props: ['items'],
  setup(props) {
    const searchQuery = ref('')
    
    const filteredItems = computed(() => {
      return props.items.filter(item =>
        item.name.includes(searchQuery.value)
      )
    })
    
    // 直接返回渲染函数
    return () => (
      <div>
        <input 
          vModel={searchQuery.value}
          placeholder="搜索..."
        />
        
        <div>
          {filteredItems.value.map(item => (
            <div key={item.id}>{item.name}</div>
          ))}
        </div>
      </div>
    )
  }
}

这种写法让逻辑和UI更加紧密地结合在一起,代码的组织方式更加灵活。

实战:封装一个高级表格组件

让我们用JSX封装一个功能丰富的高级表格组件,支持动态列、排序、筛选等功能:

export default {
  props: {
    data: Array,
    columns: Array,
    sortable: Boolean
  },
  data() {
    return {
      sortKey: '',
      sortOrder: 'asc',
      filters: {}
    }
  },
  computed: {
    processedData() {
      let result = [...this.data]
      
      // 应用筛选
      Object.entries(this.filters).forEach(([key, value]) => {
        if (value) {
          result = result.filter(item => 
            String(item[key]).toLowerCase().includes(value.toLowerCase())
          )
        }
      })
      
      // 应用排序
      if (this.sortKey) {
        result.sort((a, b) => {
          const aVal = a[this.sortKey]
          const bVal = b[this.sortKey]
          const modifier = this.sortOrder === 'asc' ? 1 : -1
          
          if (aVal < bVal) return -1 * modifier
          if (aVal > bVal) return 1 * modifier
          return 0
        })
      }
      
      return result
    }
  },
  methods: {
    handleSort(key) {
      if (this.sortKey === key) {
        this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'
      } else {
        this.sortKey = key
        this.sortOrder = 'asc'
      }
    },
    
    handleFilter(key, value) {
      this.$set(this.filters, key, value)
    }
  },
  render() {
    return (
      <div class="advanced-table">
        {/* 表头 */}
        <div class="table-header">
          {this.columns.map(column => (
            <div class="header-cell" key={column.key}>
              <span>{column.title}</span>
              
              {/* 排序按钮 */}
              {this.sortable && (
                <button 
                  class={`sort-btn ${this.sortKey === column.key ? 'active' : ''}`}
                  onClick={() => this.handleSort(column.key)}
                >
                  {this.sortKey === column.key && this.sortOrder === 'asc' ? '↑' : '↓'}
                </button>
              )}
              
              {/* 筛选输入框 */}
              <input
                class="filter-input"
                placeholder="筛选..."
                value={this.filters[column.key] || ''}
                onInput={(e) => this.handleFilter(column.key, e.target.value)}
              />
            </div>
          ))}
        </div>
        
        {/* 表格内容 */}
        <div class="table-body">
          {this.processedData.map((row, index) => (
            <div class="table-row" key={index}>
              {this.columns.map(column => (
                <div class="table-cell" key={column.key}>
                  {column.render ? column.render(row) : row[column.key]}
                </div>
              ))}
            </div>
          ))}
        </div>
      </div>
    )
  }
}

这个表格组件展示了JSX在复杂组件封装中的强大能力。我们可以很灵活地控制渲染逻辑,实现各种动态功能。

什么时候该用,什么时候不该用

虽然渲染函数和JSX很强大,但并不是所有场景都适合使用。这里给你一些实用的建议:

推荐使用渲染函数/JSX的场景:

  • 需要高度动态的组件结构
  • 复杂的条件渲染逻辑
  • 递归组件
  • 基于运行时条件动态选择组件
  • 需要更大编程灵活性的高级组件库

不推荐使用的场景:

  • 简单的静态布局
  • 团队对JSX不熟悉
  • 需要设计师或非技术人员参与模板修改
  • 已经用模板写得很好的常规业务组件

记住,技术选型的核心是选择合适的工具解决问题,而不是追求最新最潮的技术。

从模板平滑迁移到JSX

如果你决定在项目中尝试JSX,这里有一些平滑迁移的建议:

首先,可以从一些简单的组件开始尝试。比如先找一个逻辑比较复杂的组件,用JSX重写,感受一下差异。

其次,充分利用Vue Devtools。JSX组件在Devtools中的调试体验和模板组件基本一致,你可以正常查看组件层次、props、状态等信息。

另外,建立团队的代码规范。JSX给了我们更大的灵活性,但也需要相应的规范来保证代码质量。比如规定何时使用JSX、代码组织方式等。

最后,记住模板和JSX可以共存。你不需要一次性重写所有组件,可以在同一个项目中混合使用,根据每个组件的特性选择合适的技术。

结尾:拥抱更灵活的Vue开发方式

今天我们深入探讨了Vue渲染函数和JSX在动态场景中的应用。从基础的语法到高级的实战技巧,相信你已经感受到了这种开发方式的魅力。

记住,模板、渲染函数、JSX都是Vue生态中的重要组成部分,它们各有适用的场景。作为开发者,我们的目标是掌握各种工具,然后在合适的场景选择合适的技术。

JSX和渲染函数不是要取代模板,而是为我们提供了另一种解决问题的思路。当模板遇到瓶颈时,知道还有这样一条路可以走,这才是最重要的。

现在,你是否已经在想自己的哪个项目可以用上这些技术了?欢迎在评论区分享你的想法和问题,我们一起探讨Vue开发的更多可能性!

下次再见,希望你已经准备好用更灵活的方式编写Vue组件了!

前端高频面试题之Vue(高级篇)

2025年11月13日 21:00

1、说一下 Vue.js 的响应式原理

1.1 Vue2 响应式原理

核心原理就是通过 Object.defineProperty 对对象属性进行劫持,重新定义对象的 gettersetter,在 getter 取值时收集依赖,在 setter 修改值时触发依赖更新,更新页面。

Vue2 对数组和对象做了两种不同方式的处理。

监听对象变化:

针对对象来说,Vue 会循环遍历对象的每一个属性,用 defineReactive 重新定义 gettersetter


function defineReactive(target,key,value){
    observer(value);
    Object.defineProperty(target,key,{ ¸v
        get(){
            // ... 收集依赖逻辑
            return value;
        },
        set(newValue){
            if (value !== newValue) {
                value = newValue;
                observer(newValue) // 把新设置的值包装成响应式
            }
            // ...触发依赖更新逻辑
        }
    })
}
function observer(data) {
    if(typeof data !== 'object'){
        return data
    }
    for(let key in data){
        defineReactive(data,key,data[key]);
    }
}

监听数组变化:

我们都知道,数组其实也是对象,同样可以用 Object.defineProperty 劫持数组的每一项,但如果数组有100万项,那就要调用 Object.defineProperty 一百万次,这样的话性能太低了。鉴于平时我们操作数组大都是采用数组提供的原生方法,于是 Vue 对数组重写原型链,在调用7个能改变自身的原生方法(pushpopshiftunshiftsplicesortreverse)时,通知页面进行刷新,具体实现过程如下:

// 先拿到数组的原型
const oldArrayProtoMethods = Array.prototype
// 用Object.create创建一个以oldArrayProtoMethods为原型的对象
const arrayMethods = Object.create(oldArrayProtoMethods)
const methods = [
    'push',
    'pop',
    'shift',
    'unshift',
    'sort',
    'reverse',
    'splice'
]
methods.forEach(method => {
    // 给arrayMethods定义7个方法
    arrayMethods[method] = function (...args){
        // 先找到数组对应的原生方法进行调用
        const result = oldArrayProtoMethods[method].apply(this, args)
        // 声明inserted,用来保存数组新增的数据
        let inserted
        // __ob__是Observer类实例的一个属性,data中的每个对象都是一个Observer类的实例
        const ob = this.__ob__
        switch(method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
            default:
                break
        }
        // 比如有新增的数据,新增数据也要被定义为响应式
        if(inserted) ob.observeArray(inserted)
        // 通知页面更新
        ob.dep.notify()
        return result
    }
})

Object.defineProperty的缺点:

  1. 无法监听新增属性和删除属性的变化,需要通过 $set$delete 实现。
  2. 监测数组的索引性能太低,故而直接通过数组索引改值无法触发响应式。
  3. 初始化时需要一次性递归调用,性能较差。

1.2 Vue3 的响应式改进

Vue3 采用 Proxy + Reflect 配合实现响应式。能解决上述 Object.defineProperty 的所有缺陷,唯一缺点就是兼容性没有 Object.defineProperty 好。

let handler = {
  get(target, key) {
    if (typeof target[key] === "object") {
      return new Proxy(target[key], handler);
    }
    return Reflect.get(target, key);
  },
  set(target, key, value) {
    let oldValue = target[key];
    if (oldValue !== value) {
      return Reflect.set(target, key, value);
    }
    return true;
  },
};
let proxy = new Proxy(obj, handler);

2、介绍一下 Vue 中的 diff 算法?

Vue 的 diff 算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式 + 双指针的方式进行比较。

比较过程:

  1. 先比较是否是相同节点。
  2. 相同节点比较属性,并复用老节点。
  3. 比较儿子节点,考虑老节点和新节点儿子的情况。
  4. 优化比较:头头、尾尾、头尾、尾头。
  5. 比对查找进行复用。

Vue3 在这个比较过程的基础上增加了最长递增子序列实现diff算法。

  • 找出不需要移动的现有节点。
  • 只对需要移动的节点进行操作。
  • 最小化 DOM 操作次数。

3、Vue 的模板编译原理是什么?

Vue 中的模板编译就是把我们写的 template 转换为渲染函数(render function) 的过程,它主要经历3个步骤:

  1. 解析(Parse):将 template 模板转换成 ast 抽象语法树。
  2. 优化(Optimize):对静态节点做静态标记,减少 diff 过程中的比对。
  3. 生成(Generate):重新生成代码,将 ast 抽象语法数转化成可执行的渲染函数代码。

3.1 解析阶段

<div id="app">
  <p>{{ message }}</p>
</div>
  • 用 HTML 解析器将模板解析为 AST。
  • AST中用 js 对象描述模板,里面包含了元素类型、属性、子节点等信息。
  • 解析指令(v-for、v-if)和事件(@click)、插值表达式{{}}等 vue 语法。

3.2 优化阶段

  • 遍历上一步生成的 ast,标记静态节点,比如用 v-once 的节点,以及没有用到响应式数据的节点。
  • 标记静态根节点,避免不必要的渲染。

3.3 代码生成阶段

vue2 解析结果:

function render() {
  with(this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('p', [_v(_s(message))])])
  }
}
  • _c: 是 createElement 的别名,用于创建 VNode。
  • _v: 创建文本 VNode。
  • _s: 是 toString 的别名,用于将值转换为字符串。

vue3 解析结果:

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", { id: "app" }, [
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}
  • _openBlock: 开启一个"block"区域,用于收集动态子节点。
  • _createElementBlock: 创建一个块级虚拟 DOM 节点。
  • _createElementVNode: 创建一个普通虚拟 DOM 节点。
  • _toDisplayString: 将响应式数据 _ctx.message 转换为显示字符串,或者处理 null/undefined 等值,确保它们能正确渲染为空白字符串。

vue2在线编译:template-explorer.vuejs.org/

vue3在线编译:v2.template-explorer.vuejs.org/

运行时+编译(runtime-compiler) vs 仅运行时(runtime-only):

  1. 完整版(运行时+编译):
    • 包含编译模块,可以写 template 模版。
    • 体积较大(~30kb)。
  2. 仅运行时版本
    • 需要在打包时使用 vue-loader 进行编译。
    • 体积较小(~20kb)。

平时开发项目推荐使用仅运行时(runtime-only)版本。

编译后的特点:

  1. 虚拟DOM:渲染函数生成的是虚拟DOM节点(VNode)。
  2. 响应式绑定:渲染函数中的变量会自动建立依赖关系。
  3. 性能优化:通过静态节点标记减少不必要的更新。

4、v-show 和 v-if 的原理

简单来说,v-if 内部是通过一个三元表达式来实现的,而 v-show 则是通过控制 DOM 元素的 display 属性来实现的。

v-if 源码:

function genIfConditions (
    conditions: ASTIfConditions,
    state: CodegenState,
    altGen?: Function,
    altEmpty?: string
    ): string {
    if (!conditions.length) {
        return altEmpty || '_e()'
    }
    const condition = conditions.shift()
    if (condition.exp) {   // 如果有表达式
        return `(${condition.exp})?${ // 将表达式作为条件拼接成元素
        genTernaryExp(condition.block)
        }:${
        genIfConditions(conditions, state, altGen, altEmpty)
        }`
    } else {
        return `${genTernaryExp(condition.block)}` // 没有表达式直接生成元素 像v-else
    }

    // v-if with v-once should generate code like (a)?_m(0):_m(1)
    function genTernaryExp (el) {
        return altGen
        ? altGen(el, state)
        : el.once
            ? genOnce(el, state)
            : genElement(el, state)
    }
}

v-show 源码:

{
    bind (el: any, { value }: VNodeDirective, vnode: VNodeWithData) {
    const originalDisplay = el.__vOriginalDisplay =
        el.style.display === 'none' ? '' : el.style.display // 获取原始显示值
        el.style.display = value ? originalDisplay : 'none' // 根据属性控制显示或者隐藏
    }  
} 

5、v-if 和 v-for 哪个优先级更高?为什么?

  • vue2 中 v-for 的优先级比 v-if 高,它们作用于一个节点上会导致先循环后对每一项进行判断,浪费性能。
  • vue3 中 v-if 的优先级比 v-for 高,这就会导致 v-if 中的条件无法访问 v-for 作用域名中定义的变量别名。
<li v-for="item in arr" v-if="item.visible">
  {{ item}}
</li>

以上代码在 vue3 的编译结果如下:

import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createCommentVNode as _createCommentVNode } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_ctx.item.visible)
    ? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(_ctx.arr, (item) => {
        return (_openBlock(), _createElementBlock("li", null, _toDisplayString(item), 1 /* TEXT */))
      }), 256 /* UNKEYED_FRAGMENT */))
    : _createCommentVNode("v-if", true)
}

可以看出 vue3 在编译时会先判断 v-if,然后再走 v-for 的循环,所以在 v-if 中自然就无法访问 v-for 作用域名中定义的变量别名。

这样的写法在 vue3 中会抛出一个警告⚠️,[Vue warn]: Property "item" was accessed during render but is not defined on instance,导致渲染失败。

以上代码在 vue2 还不能直接编译,因为 vue2 的组件需要一个根节点,所以我们在外层加一个 div

<div>
  <li v-for="item in arr" v-if="item.visible">
    {{ item}}
  </li>
</div>

其编译结果如下:

function render() {
  with(this) {
    return _c('div', _l((arr), function (item) {
      return (item.visible) ? _c('li', [_v("\n    " + _s(item) + "\n  ")]) :
        _e()
    }), 0)
  }
}

很明显是先循环 arr,然后每一项再用 item.visible 去判断的,也印证了在 vue2 中, v-for 的优先级高于 v-if

所以不管是 vue2 还是 vue3,都不推荐同时使用 v-ifv-for,更好的方案是采用计算属性,或者在外层再包裹一个容器元素,将 v-if 作用在容器元素上。

6、nextTick 的原理是什么?

6.1 Vue2 的 nextTick:

  • 首选微任务:
    • Promise.resolve().then(flushCallbacks):最常见,使用 Promise 创建微任务。
    • MutationObserver:如果 Promise 不可用,创建一个文本节点,修改其内容触发 MutationObserver 的观察器回调。
  • 回退宏任务:
    • setImmediate:如果环境支持 setImmediate,比如 node 环境,则会优先使用 setImmediate 。
    • setTimeout(flushCallbacks, 0):最后使用定时器。

这里体现了优雅降级的思想。

6.2 Vue3 的 nextTick:

  • 由于 Vue3 不再考虑 promise 的兼容性,所以 nextTick 的实现原理就是 promise.then 方法。

7、Vue.set 方法是如何实现的?

Vue2的实现:在 Vue 2 中,Vue.set 的实现主要位于 src/core/observer/index.js 中:

export function set (target: Array | Object, key: any, val: any): any {
    // 1.如果是数组 Vue.set(array,1,100); 调用我们重写的splice方法 (这样可以更新视图)
    if (Array.isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key)
        target.splice(key, 1, val)
        return val
    }
    // 2.如果是对象本身的属性,则直接添加即可
    if (key in target && !(key in Object.prototype)) {
        target[key] = val
        return val
    }
    const ob = (target: any).__ob__
    // 3.如果是响应式的也不需要将其定义成响应式属性
    if (!ob) {
        target[key] = val
        return val
    }
    // 4.将属性定义成响应式的
    defineReactive(ob.value, key, val)
    // 5.通知视图更新
    ob.dep.notify()
    return val
}

Vue3 中 set 方法已经被移除,因为 proxy 天然弥补 vue2 响应式的缺陷。

8、Vue.use 是干什么的?原理是什么?

Vue.use 是用来使用插件的,我们可以在插件中扩展全局组件、指令、原型方法等。

Vue.use 源码:

Vue.use = function (plugin: Function | Object) {
    // 插件不能重复的加载
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    if (installedPlugins.indexOf(plugin) > -1) {
        return this
    }
    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)  // install方法的第一个参数是Vue的构造函数,其他参数是Vue.use中除了第一个参数的其他参数
    if (typeof plugin.install === 'function') { // 调用插件的install方法
        plugin.install.apply(plugin, args)  Vue.install = function(Vue,args){}
    } else if (typeof plugin === 'function') { // 插件本身是一个函数,直接让函数执行
        plugin.apply(null, args) 
    }
    installedPlugins.push(plugin) // 缓存插件
    return this
}

9、介绍下 Vue 中的 mixin,Vue3 为何不再推荐使用它?

mixin 是 Vue 2 中一种复用组件逻辑的方式,允许将可复用的配置(data、methods、computed、lifecycle hooks 等)抽离成一个对象,然后通过 mixins: [] 合并到组件中。支持全局注入和局部注入。

  • 作用:抽离公共的业务逻辑
  • 原理:类似“对象的继承”,当组件初始化时会调用 mergeOptions 方法进行合并,采用策略模式针对不同的属性进行合并。如果混入的数据和本身组件中的数据冲突,会采用“就近原则”以组件的数据为准。

mixin 的优点:

  • 复用逻辑(如表单验证、权限判断)。
  • 全局注入(如日志、埋点)。
  • 减少重复代码。

mixin 中有很多缺陷:

  • 命名冲突问题:mixin 中的变量、函数名可能会与组件中的重名。
  • 依赖问题:
  • 数据来源问题:

vue3 不再推荐使用它的理由如下:

问题 说明
1. 隐式依赖 & 数据来源不明确 组件行为来自多个 mixin,难以追踪 data、methods 是从哪里来的。
2. 命名冲突 多个 mixin 可能定义同名 data、methods,合并规则复杂(同名 methods 后者覆盖前者,data 合并为对象,同名 Key 后者覆盖前者)。
3. 调试和维护困难 父组件无法知道子组件内部有哪些 mixin 注入的属性,排查 bug 和调试困难。
4. 不利于 Tree-shaking 打包时难以移除未使用的 mixin 代码。
5. 与 Composition API 理念冲突 Mixin 是“横切关注点”,而 Composition API 强调显式、可组合的逻辑。

Vue 3 推荐替代方案:Composition API + 可复用函数(Composables)。

特性 Mixin Composables
数据来源明确 隐式 显式(import)
是否有命名冲突问题
逻辑封装 全局污染 按需引入
Tree-shaking 支持
TypeScript 支持

对于全局混入(Global Mixin),Vue3 虽然提供了 app.mixin(),但不推荐,推荐使用:

  1. app.config.globalProperties
  2. app.provide 在顶层提供数据,组件通过 inject 方法消费数据。

10、介绍下 Vue.js 中的函数式组件、异步组件和递归组件

10.1 函数式组件(Functional Components)

函数式组件是一种轻量级、无状态的组件形式。它们很像纯函数:接收 props,返回 虚拟 DOM(vnode)。函数式组件在渲染过程中不会创建组件实例 (也就是说,没有 this),也不会触发常规的组件生命周期钩子。没有响应式系统、生命周期和实例的开销,函数式组件自然在渲染上更加高效和快速。

总而言之,函数式组件有无状态无this无生命周期性能更高等特点。

使用场景:适合简单、静态的 UI 元素,如列表项或包装组件。

在 Vue 2 中,通过 functional: true 声明;在 Vue 3 中,函数式组件更简单,直接返回渲染函数。

Vue2 示例:

<template functional>
  <div>{{ props.msg }}</div>
</template>

或者 js 形式:

export default {
  functional: true,
  props: ['msg'],
  render(h, { props }) {
    return h('div', props.msg);
  }
};

Vue3 示例:

<script setup>
import { h } from 'vue';

const FunctionalComp = (props) => h('div', props.msg);
</script>

<template>
  <FunctionalComp msg="Hello Functional" />
</template>

10.2 异步组件(Async Components)

异步组件是一种懒加载(Lazy Loading)机制,用于按需加载组件代码,优化初始加载时间和性能。Vue 会动态导入组件,只有在使用时才下载和渲染,常用于路由或大型组件。

特点:

  • 通过 import() 动态加载,返回 Promise。
  • 支持加载中(loading)、错误(error)和超时(timeout)处理。
  • 在 Vue 3 中,使用 defineAsyncComponent 更规范,支持与 <Suspense> 结合(Vue 3 独有,用于统一处理异步)。

Vue2 示例:

<script>
export default {
  components: {
    AsyncComp: () => import('./AsyncComp.vue')
  }
};
</script>

<template>
  <AsyncComp />
</template>

Vue3 示例:

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

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

<template>
  <Suspense>
    <template #default>
      <AsyncComp />
    </template>
    <template #fallback>加载中...</template>
  </Suspense>
</template>

10.3 递归组件(Recursive Components)

递归组件是指组件内部调用自身,用于处理树形或嵌套数据结构,如菜单、树视图或评论回复。Vue 支持组件自引用,但需注意避免无限循环(通过条件终止递归)。

特点:

  • 组件需有名称(name 选项),才能自引用。
  • 常结合 v-for 和 props 传递数据。

Vue 2 示例:

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'Tree',  // 必须有 name
  props: ['tree']
};
</script>

Vue 3 示例:

<script setup>
import { defineAsyncComponent } from 'vue';  // 可选:异步加载避免循环

const Tree = defineAsyncComponent(() => import('./Tree.vue'));  // 自引用
defineProps(['tree']);
</script>

<template>
  <ul>
    <li v-for="item in tree" :key="item.id">
      {{ item.name }}
      <Tree v-if="item.children" :tree="item.children" />
    </li>
  </ul>
</template>

11、Vue.js 中的 vue-loader 是什么?

Vue-loader 是一个专为 Vue.js 设计的 Webpack loader(加载器),其主要作用是将 Vue 的单文件组件(Single-File Components,简称 SFC,即 .vue 文件)转换为可执行的 JavaScript 模块。 它允许开发者以一种结构化的方式编写组件,将模板(template)、脚本(script)和样式(style)封装在同一个文件中,便于管理和维护。

核心功能:

  • 解析 SFC 文件:Vue-loader 会自动处理 .vue 文件中的三个部分:
    • template 部分:编译为 Vue 的渲染函数(render function)。
    • script 部分:提取为组件的 JavaScript 逻辑,支持 ES 模块和 TypeScript。
    • style部分:处理 CSS,支持预处理器(如 Sass、Less)并可选地应用 scoped(作用域样式)或 CSS Modules。
  • 热重载(Hot Module Replacement,HMR):在开发模式下,支持组件的热更新,无需刷新页面即可看到变化,提高开发效率。
  • 自定义块(Custom Blocks):支持扩展,如添加 <docs> 或其他自定义标签,用于文档生成或其他工具集成。
  • 预处理器支持:无缝集成 Babel、PostCSS 等工具链。

12、Vue.extend 方法的作用?

Vue.extend方法可以作为基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

Vue2 示例:

var dialog = Vue.extend({
  template: "<div>{{hello}} {{world}}</div>",
  data: function () {
    return {
      hello: "hello",
      world: "world",
    };
  },
});
// 创建 dialog 实例,并手动挂载到一个元素上。
new dialog().$mount("#app");

注意:在 Vue.extend 中的 data 必须是一个函数,要不然会报错。

Vue3 示例:

Vue3 中不在使用 Vue.extend 方法,而是采用render方法进行手动渲染。

<!-- Modal.vue -->
<template>
  <div class="modal">这是一个弹窗</div>
</template>

<script>
export default {
  name: 'Modal',
}
</script>
<template>
  <div id="box"></div>
</template>

<script setup>
import { h, render, createApp, onMounted } from 'vue'
import Modal from './Modal.vue'

onMounted(() => {
  render(h(Modal), document.getElementById('box'));
})
</script>

13、keep-alive 的原理

<keep-alive> 是 Vue.js 的内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例,避免重复渲染和状态丢失,提高性能。它是一个抽象组件(abstract: true),不会渲染到 DOM,也不会出现在组件树中,而是通过插槽(slots)和自定义 render 函数实现缓存逻辑。

核心实现机制:

  1. 抽象组件与 Render 函数:
  • <keep-alive> 通过 render 函数处理包裹的内容(通常是动态组件,如 <component> 或 v-if 切换的组件)。
  • 在 render 中,它从插槽获取子组件的 VNode(虚拟节点)。如果子组件有 key(推荐使用),则用 key 作为缓存标识;否则用组件的 tag 或 cid(组件 ID)。
  • 如果组件已缓存,直接返回缓存的 VNode(设置 vnode.componentInstance.keepAlive = true 以标记缓存状态);否则,渲染新实例并存入缓存。
  1. 缓存存储:
  • 内部使用一个对象(this.cache)作为缓存 Map,以 key 为键,值为 VNode 对象(包含组件实例)。
  • 当组件首次渲染时,存入缓存;切换回时,从缓存取出,避免重新创建实例和执行 mounted 钩子。
  1. LRU 缓存算法:
  • 支持 max 属性设置最大缓存数量(Vue 2.5+)。
  • 使用 Least Recently Used(最近最少使用)算法:当缓存超出 max 时,删除最久未访问的组件(通过 this.keys 数组跟踪访问顺序)。
  • 访问组件时,将其 key 移到数组末尾(最近使用);超出时,删除数组开头的 key,并销毁对应实例(调用 $destroy)。
  1. 过滤规则(include/exclude):
  • 通过 include(白名单)和 exclude(黑名单)属性决定哪些组件缓存,支持字符串、正则、数组或函数。
  • 在 created 钩子中,监听这些 prop 的变化,并调用 pruneCache 更新缓存(移除不匹配的组件)。
  1. 生命周期钩子:
  • 缓存组件不会触发 destroyed/unmounted,而是使用 activated(激活时)和 deactivated(失活时)钩子。
  • 这允许开发者在切换时管理状态(如暂停定时器),而非完全销毁。

注意事项:

  • 只缓存一个直接子组件(插槽内容),不支持多个。
  • Vue 3 中原理类似,但优化了 VNode 处理和 Composition API 支持。
  • 潜在问题:缓存过多导致内存占用;需手动清理资源(如在 deactivated 中停止监听)。

14、Vue.js 中使用了哪些设计模式?

1. 观察者模式 (Observer Pattern)

  • 描述:Vue 的响应式系统使用观察者模式,通过 Proxy (Vue 3) 或 Object.defineProperty (Vue 2) 拦截对象属性的 get/set 操作。当数据变化时,通知订阅者(Watcher)更新视图。
  • 应用:在 reactive() 函数中,返回 Proxy 对象,get 陷阱用于依赖收集 (track),set 陷阱用于触发更新 (trigger)。
  • 优势:实现了细粒度的变更检测,避免全局重渲染。

2. 发布-订阅模式 (Publish-Subscribe Pattern)

  • 描述:Vue 的事件系统和响应式通知机制采用 Pub-Sub 模式。数据变化时发布事件,订阅者(如组件渲染函数)接收并响应。
  • 应用:在响应式系统中,trigger() 函数检索订阅者效果并调用它们;事件 API 如 emitemit 和 on 也基于此。
  • 优势:解耦了数据生产者和消费者,支持异步更新。

3. 代理模式 (Proxy Pattern)

描述:Vue 3 的响应式系统直接使用 ES6 Proxy 作为代理层,拦截对象操作,实现透明的响应式。 应用:reactive() 返回 Proxy 对象,代理目标对象的访问和修改。 优势:比 Vue 2 的 defineProperty 更强大,支持数组和 Map/Set 等类型。

4. 策略模式 (Strategy Pattern)

  • 描述:Vue 的虚拟 DOM diff 算法使用不同策略(如 key-based diff 或简单 patch)来优化更新。
  • 应用:在渲染过程中,根据节点类型选择 diff 策略。
  • 优势:最小化 DOM 操作,提高渲染效率。

5. 单例模式(Singleton Pattern)

  • 描述:整个程序中有且仅有一个实例。
  • 应用:vuex 的 store 和插件。
  • 优势:全局唯一、节约资源、便于管理。

6. 工厂模式(Factory Pattern)

  • 描述:提供了一种创建对象的方式,使得创建对象的过程与使用对象的过程分离。
  • 应用:Vue2 中的组件创建,传入参数给 createComponentInstance 就可以创建实例。
  • 优势:解藕,易于维护。

15、Vue.js 应用中常见的内存泄漏来源有哪些?

  1. 未清理的事件监听器、定时器、动画
<script setup>
import { onMounted, onUnmounted } from 'vue';

let timer = null;
let controller = null;
let raf = null;

onMounted(() => {
  // 定时器
  timer = setInterval(() => {}, 1000);
  // 动画
  raf = requestAnimationFrame(() => {});
  // 事件监听
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  clearInterval(timer);
  cancelAnimationFrame(this.raf);
  window.removeEventListener('resize', handleResize);
});
</script>
  1. 未移除的第三方库实例
<script setup>
import { onMounted, onUnmounted } from 'vue';

let chart = null;

onMounted(() => {
  chart = echarts.init(this.$refs.chart);
});

onUnmounted(() => {
  chart?.dispose();
});
</script>
  1. 事件总线(Event Bus)未解绑

vue2 用可以用 new Vue 全局创建一个事件总线实例,或者在组件中直接使用 this.$onthis.$emitthis.$off

vue3 则需要借助第三库,比如 mitt 来实现事件总线。

<script setup>
import { onMounted, onUnmounted } from 'vue';
import mitt from 'mitt';

// 创建事件总线实例
const emitter = mitt();

onMounted(() => {
  emitter.on('update', this.handler);
});

onUnmounted(() => {
  emitter.off('update', this.handler);
});
</script>

顺便提一下, vue3 为啥去掉 $on、$emit、$off 这些 API,主要有以下原因:

  1. 设计理念的调整

Vue 3 更加注重组件间通信的明确性和可维护性。$on 这类事件 API 本质上是一种 "发布 - 订阅" 模式,容易导致组件间关系模糊(多个组件可能监听同一个事件,难以追踪事件来源和流向)。Vue 3 推荐使用更明确的通信方式,如:

  • 父子组件通过 props 和 emit 通信
  • 跨组件通信使用 provide/inject 或 Pinia/Vuex 等状态管理库
  • 复杂场景可使用专门的事件总线库(如 mitt
  1. 与 Composition API 的适配

Vue 3 主推的 Composition API 强调逻辑的封装和复用,而 $on 基于选项式 API 的实例方法,与 Composition API 的函数式思维不太契合。移除后,开发者可以更自然地使用响应式变量或第三方事件库来实现类似功能。

  1. 减少潜在问题
  • $on 容易导致内存泄漏(忘记解绑事件)
  • 事件名称可能冲突(全局事件总线尤其明显)
  • 不利于 TypeScript 类型推断,难以实现类型安全

4. 未清理的 Watcher

Vue 本身不会泄漏内存,泄漏几乎都来自开发者未清理的副作用。养成“创建即清理”的习惯,使用 beforeDestroy 或者 onUnmounted 集中清理,在使用 keep-alive 的组件中,视情况在 deactivated 钩子中清理资源。

16、Vue.js 中的性能优化手段有哪些?

16.1 数据相关

  • Vue2 中数据层级不易过深,合理设置响应式数据;
  • Vue2 非响应式数据可以通过 Object.freeze()方法冻结属性;
  • 合理使用 computed,利用其缓存能力提高性能。

16.2 组件相关

  • 控制组件粒度(Vue 采用组件级更新);
  • 合适场景可使用函数式组件(函数式组件开销低);
  • 采用异步组件(借助构建工具的分包的能力,减少主包体积);
  • 在组件卸载或者非激活状态及时清除定时器、DOM事件、事件总线、三方库的实例等。
  • v-on 按需监听、使用动态 watch 和及时销毁 watch。

16.3 渲染相关

  • 合理设置 key 属性;
  • v-show 和 v-if 的选取;
  • 使用防抖、节流、分页、虚拟滚动、时间分片等策略;
  • 合理使用 keep-alive 、v-once、v-memo 进行逻辑优化。

结语

以上是整理的 Vue 高级的高频面试题,如有错误或者可以优化的地方欢迎评论区指正,后续还会更新 Vuex 和 Vue-router 相关面试题。

❌
❌