阅读视图

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

我尝试将TinyPro集成TinyEngine低代码设计器了

本文由TinyPro贡献者宋子文原创。

TinyProTinyEngine 是 OpenTiny 开源生态的重要组成部分:

  • TinyPro 提供企业级后台系统模板
  • TinyEngine 提供灵活强大的低代码引擎

本项目在 TinyPro 中深度集成了基于 TinyEngine 的低代码设计器,通过 插件化架构 构建出可扩展的低代码开发平台。

借助它,你只需在可视化设计器中完成页面设计,就能一键导入 TinyPro,并自动生成菜单、权限及国际化配置,实现真正的 “所见即所得” 式开发体验。

整体架构

lowcode-designer/
├── src/
│   ├── main.js              # 应用入口
│   ├── composable/          # 可组合逻辑
│   ├── configurators/       # 配置器
├── registry.js              # 插件注册表
├── engine.config.js         # 引擎配置
└── vite.config.js          # 构建配置

image.png

核心组成部分

  1. TinyEngine 核心:提供低代码设计器的基础能力
  2. 插件系统:通过插件扩展功能
  3. 注册表机制:统一管理插件和服务
  4. 配置器系统:自定义组件属性配置

核心特性

  • 智能代码生成:基于可视化设计自动生成符合 TinyPro 规范的 Vue 3 + TypeScript 代码
  • 🔐 自动认证管理:智能获取和管理 API Token,支持多种认证方式
  • 🎯 一键集成:自动创建菜单、配置权限、添加国际化词条
  • 🛠️ 代码转换:将 TinyEngine 生成的代码自动转换为 TinyPro 项目兼容格式
  • 💾 本地保存:支持将生成的文件保存到本地文件系统
  • 🎨 可视化配置:提供友好的 UI 界面进行菜单和路由配置

快速开始

安装

使用 TinyCli 可以快速初始化 TinyPro 模版

tiny init pro 

image 1.png

启动低代码设计器

cd lowcode-designer
pnpm install
pnpm dev

启动前端与后端

cd web
pnpm install
pnpm start

cd nestJs
pnpm install
pnpm start

启动完成后,访问 👉 http://localhost:8090 即可体验低代码设计器。

使用流程

image 2.png

设计页面:在 TinyEngine 可视化编辑器中设计页面

image 3.png

点击出码按钮:点击工具栏中的”出码”按钮

image 4.png

配置菜单信息:在弹出的对话框中填写菜单配置信息

生成预览:点击”生成预览”查看将要生成的文件

image 5.png

完成集成:点击”完成集成”自动创建菜单、分配权限并保存文件

image 6.png

接下来我们就可以直接去 TinyPro 直接看到页面效果

image 7.png

TinyPro Generate Code 插件解析

插件目录结构

generate-code-tinypro/
├── package.json              # 插件包配置
├── src/
│   ├── index.js             # 插件入口
│   ├── meta.js              # 元数据定义
│   ├── Main.vue             # 主组件
│   ├── SystemIntegration.vue # 功能组件
│   ├── components/          # 通用组件
│   │   ├── ToolbarBase.vue
│   │   ├── ToolbarBaseButton.vue
│   │   └── ToolbarBaseIcon.vue
│   ├── composable/          # 可组合逻辑
│   │   ├── index.js
│   │   └── useSaveLocal.js
│   └── http.js              # HTTP 服务
├── vite.config.js           # 构建配置
└── README.md                # 文档

代码生成流程

const generatePreview = async () => {
  // 1. 获取当前页面的 Schema
  const currentSchema = getSchema();

  // 2. 获取应用元数据(i18n、dataSource、utils等)
  const metaData = await fetchMetaData(params);

  // 3. 获取页面列表和区块信息
  const pageList = await fetchPageList(appId);
  const blockSchema = await getAllNestedBlocksSchema();

  // 4. 调用代码生成引擎
  const result = await generateAppCode(appSchema);

  // 5. 过滤和转换生成的代码
  const transformedFiles = filteredFiles.map((file) => ({
    ...file,
    fileContent: transformForTinyPro(file.fileContent),
  }));
};

TinyPro 与 TinyEngine 通信

当用户在低代码设计器中点击“完成集成”时,插件首先通过 Token Manager 向认证接口 /api/auth/api-token 请求并获取访问凭证(Token),随后利用该 Token 调用一系列后台接口,包括国际化 API、菜单 API 和角色 API。插件通过这些接口自动完成 页面国际化词条创建、菜单注册、角色查询与权限分配 等步骤。整个过程中,HTTP Client 统一负责与后端通信,而返回的数据(菜单信息、角色信息、权限配置等)会实时更新到本地,最终实现了从页面设计到系统集成的一键闭环,使 TinyEngine 生成的页面能无缝接入 TinyPro 系统。

image 8.png

总结

通过 TinyPro 与 TinyEngine 的深度融合,我们实现了从「可视化设计」到「系统集成」的完整闭环,让不会写代码的用户也能轻松构建出高质量的前端页面

用户只需拖拽组件、填写配置、点击“出码”,插件便会自动生成符合 TinyPro 标准的代码,并完成菜单、权限、国际化等系统级配置。

这一过程无需手动修改代码或后台配置,就能一键完成页面创建、接口绑定与权限分配,实现真正意义上的「低门槛、高效率、可扩展」的前端开发体验。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyPro、TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~

如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

🔥 Vue3 + TypeScript 实现高性能图片懒加载v-lazyLoad指令(开箱即用)

🔥 Vue3 图片懒加载指令终极版:支持重试、自定义配置、TypeScript 全类型支持

在现代前端开发中,图片懒加载是提升页面加载性能的核心手段之一。原生的 loading="lazy" 虽然简单,但缺乏灵活的配置和错误重试机制。本文将分享一个生产级别的 Vue3 图片懒加载指令,基于 IntersectionObserver API 实现,支持失败重试、自定义占位图、样式控制等丰富功能,且全程使用 TypeScript 开发,类型提示完善。

在这里插入图片描述

🎯 核心特性

  • ✅ 基于 IntersectionObserver 实现,性能优异
  • ✅ 支持图片加载失败自动重试(指数退避策略)
  • ✅ 自定义占位图、错误图、加载状态样式类
  • ✅ 全 TypeScript 开发,类型定义完善
  • ✅ 支持指令参数灵活配置(字符串/对象)
  • ✅ 提供手动触发/重置加载的方法
  • ✅ 自动清理定时器和观察器,无内存泄漏
  • ✅ 支持跨域图片加载

📁 完整代码实现(优化版)

// lazyLoad.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 懒加载配置接口
 */
export interface LazyLoadOptions {
  root?: Element | Document | null          // 观察器根元素
  rootMargin?: string                       // 根元素边距
  threshold?: number | number[]             // 可见性阈值
  placeholder?: string                      // 占位图地址
  error?: string                            // 加载失败图地址
  loadingClass?: string                     // 加载中样式类
  loadedClass?: string                      // 加载完成样式类
  errorClass?: string                       // 加载失败样式类
  attempt?: number                          // 最大重试次数
  observerOptions?: IntersectionObserverInit // 观察器额外配置
  src?: string                              // 图片地址
}

/**
 * 指令绑定值类型:支持字符串(仅图片地址)或完整配置对象
 */
type LazyLoadBindingValue = string | LazyLoadOptions

/**
 * 元素加载状态枚举
 */
enum LoadStatus {
  PENDING = 'pending',   // 待加载
  LOADING = 'loading',   // 加载中
  LOADED = 'loaded',     // 加载完成
  ERROR = 'error'        // 加载失败
}

/**
 * 扩展元素属性:存储懒加载相关状态
 */
interface LazyElement extends HTMLElement {
  _lazyLoad?: {
    src: string
    options: LazyLoadOptions
    observer: IntersectionObserver | null
    status: LoadStatus
    attemptCount: number          // 已失败次数(从0开始)
    retryTimer?: number           // 重试定时器ID
    cleanup: () => void           // 清理函数
  }
}

/**
 * 默认配置:合理的默认值,兼顾通用性和易用性
 */
const DEFAULT_OPTIONS: LazyLoadOptions = {
  root: null,
  rootMargin: '0px',
  threshold: 0.1,
  // 透明占位图(最小体积)
  placeholder: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3C/svg%3E',
  // 错误占位图(带❌标识)
  error: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3Ctext x="0.5" y="0.5" font-size="0.1" text-anchor="middle"%3E❌%3C/text%3E%3C/svg%3E',
  loadingClass: 'lazy-loading',
  loadedClass: 'lazy-loaded',
  errorClass: 'lazy-error',
  attempt: 3,  // 默认重试3次
  observerOptions: {}
}

// 全局观察器缓存:避免重复创建,提升性能
let globalObserver: IntersectionObserver | null = null
const observerCallbacks = new WeakMap<Element, () => void>()

/**
 * 创建/获取全局IntersectionObserver实例
 * @param options 懒加载配置
 * @returns 观察器实例
 */
const getObserver = (options: LazyLoadOptions): IntersectionObserver => {
  const observerOptions: IntersectionObserverInit = {
    root: options.root,
    rootMargin: options.rootMargin,
    threshold: options.threshold,
    ...options.observerOptions
  }

  if (!globalObserver) {
    globalObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const callback = observerCallbacks.get(entry.target)
          if (callback) {
            callback()
            globalObserver?.unobserve(entry.target)
            observerCallbacks.delete(entry.target)
          }
        }
      })
    }, observerOptions)
  }

  return globalObserver
}

/**
 * 核心加载逻辑:封装图片加载、重试、状态管理
 * @param el 目标元素
 * @param src 图片地址
 * @param options 配置项
 */
const loadImage = (el: LazyElement, src: string, options: LazyLoadOptions) => {
  const lazyData = el._lazyLoad
  if (!lazyData) return

  // 防止重复加载
  if (lazyData.status === LoadStatus.LOADING || lazyData.status === LoadStatus.LOADED) {
    return
  }

  // 更新状态:标记为加载中
  lazyData.status = LoadStatus.LOADING
  el.setAttribute('data-lazy-status', LoadStatus.LOADING)
  el.classList.add(options.loadingClass!)
  el.classList.remove(options.loadedClass!, options.errorClass!)

  // 创建新图片对象(每次重试创建新实例,避免缓存问题)
  const image = new Image()
  image.crossOrigin = 'anonymous'  // 支持跨域图片

  /**
   * 失败处理:指数退避重试 + 最终失败处理
   */
  const handleFail = () => {
    // 清除当前重试定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 累加失败次数
    lazyData.attemptCount += 1

    // 还有重试次数:指数退避策略(1s → 2s → 4s,最大5s)
    if (lazyData.attemptCount < options.attempt!) {
      const delay = Math.min(1000 * Math.pow(2, lazyData.attemptCount - 1), 5000)
      lazyData.retryTimer = window.setTimeout(() => {
        lazyData.status = LoadStatus.PENDING
        loadImage(el, src, options)
      }, delay) as unknown as number
    } 
    // 重试耗尽:标记失败状态
    else {
      lazyData.status = LoadStatus.ERROR
      el.setAttribute('data-lazy-status', LoadStatus.ERROR)
      el.classList.remove(options.loadingClass!)
      el.classList.add(options.errorClass!)
      
      // 设置错误图片
      if (options.error) {
        (el as HTMLImageElement).src = options.error
      }
      
      // 触发自定义错误事件
      el.dispatchEvent(new CustomEvent('lazy-error', {
        detail: { src, element: el, attempts: lazyData.attemptCount }
      }))
    }
  }

  /**
   * 成功处理:更新状态 + 替换图片
   */
  const handleSuccess = () => {
    // 清除重试定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 更新状态:标记为加载完成
    lazyData.status = LoadStatus.LOADED
    el.setAttribute('data-lazy-status', LoadStatus.LOADED)
    el.classList.remove(options.loadingClass!)
    if (el.classList) {
        el.classList.add(options.loadedClass!)
    }
    
    // 替换为目标图片
    (el as HTMLImageElement).src = src
    
    // 触发自定义成功事件
    el.dispatchEvent(new CustomEvent('lazy-loaded', {
      detail: { src, element: el }
    }))
  }

  // 绑定事件(once: true 确保只触发一次)
  image.addEventListener('load', handleSuccess, { once: true })
  image.addEventListener('error', handleFail, { once: true })

  // 开始加载(放在最后,避免事件绑定前触发)
  image.src = src
}

/**
 * 懒加载指令核心实现
 */
export const lazyLoad: ObjectDirective<LazyElement, LazyLoadBindingValue> = {
  /**
   * 指令挂载时:初始化配置 + 注册观察器
   */
  mounted(el: LazyElement, binding: DirectiveBinding<LazyLoadBindingValue>) {
    // 1. 解析配置和图片地址
    let src: string = ''
    const options: LazyLoadOptions = { ...DEFAULT_OPTIONS }

    if (typeof binding.value === 'string') {
      src = binding.value
    } else {
      Object.assign(options, binding.value)
      src = options.src || el.dataset.src || el.getAttribute('data-src') || ''
    }

    // 校验图片地址
    if (!src) {
      console.warn('[v-lazy-load] 缺少图片地址,请设置src或data-src属性')
      return
    }

    // 2. 初始化元素状态
    el.setAttribute('data-lazy-status', LoadStatus.PENDING)
    el.classList.add(options.loadingClass!)
    if (options.placeholder) {
      (el as HTMLImageElement).src = options.placeholder
    }

    // 3. 创建观察器
    const observer = getObserver(options)
    
    // 4. 定义清理函数:统一管理资源释放
    const cleanup = () => {
      observer.unobserve(el)
      observerCallbacks.delete(el)
      
      // 清理定时器
      if (el._lazyLoad?.retryTimer) {
        clearTimeout(el._lazyLoad.retryTimer)
        el._lazyLoad.retryTimer = undefined
      }

      // 清理样式和属性
      el.classList.remove(options.loadingClass!, options.loadedClass!, options.errorClass!)
      el.removeAttribute('data-lazy-status')
    }

    // 5. 保存核心状态
    el._lazyLoad = {
      src,
      options,
      observer,
      status: LoadStatus.PENDING,
      attemptCount: 0,
      retryTimer: undefined,
      cleanup
    }

    // 6. 注册观察回调
    observerCallbacks.set(el, () => loadImage(el, src, options))
    observer.observe(el)
  },

  /**
   * 指令更新时:处理图片地址变化
   */
  updated(el: LazyElement, binding: DirectiveBinding<LazyLoadBindingValue>) {
    const lazyData = el._lazyLoad
    if (!lazyData) return

    // 清理旧定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 解析新地址
    let newSrc: string = ''
    if (typeof binding.value === 'string') {
      newSrc = binding.value
    } else {
      newSrc = binding.value.src || el.dataset.src || el.getAttribute('data-src') || ''
    }

    // 地址变化:重新初始化
    if (newSrc && newSrc !== lazyData.src) {
      lazyData.cleanup()
      lazyLoad.mounted(el, binding)
    }
  },

  /**
   * 指令卸载时:彻底清理资源
   */
  unmounted(el: LazyElement) {
    const lazyData = el._lazyLoad
    if (lazyData) {
      clearTimeout(lazyData.retryTimer)
      lazyData.cleanup()
      delete el._lazyLoad // 释放内存
    }
  }
}

/**
 * 手动触发图片加载(无需等待元素进入视口)
 * @param el 目标元素
 */
export const triggerLoad = (el: HTMLElement) => {
  const lazyEl = el as LazyElement
  const callback = observerCallbacks.get(lazyEl)
  if (callback) {
    callback()
    lazyEl._lazyLoad?.observer?.unobserve(lazyEl)
    observerCallbacks.delete(lazyEl)
  }
}

/**
 * 重置图片加载状态(重新开始懒加载)
 * @param el 目标元素
 */
export const resetLoad = (el: HTMLElement) => {
  const lazyEl = el as LazyElement
  const lazyData = lazyEl._lazyLoad
  
  if (lazyData) {
    // 清理旧状态
    clearTimeout(lazyData.retryTimer)
    lazyData.cleanup()
    delete lazyEl._lazyLoad
    
    // 重新注册观察器
    const observer = getObserver(lazyData.options)
    observerCallbacks.set(lazyEl, () => loadImage(lazyEl, lazyData.src, lazyData.options))
    observer.observe(lazyEl)
    
    // 重置样式和占位图
    lazyEl.setAttribute('data-lazy-status', LoadStatus.PENDING)
    lazyEl.classList.add(lazyData.options.loadingClass!)
    if (lazyData.options.placeholder) {
      (lazyEl as HTMLImageElement).src = lazyData.options.placeholder
    }
  }
}

/**
 * 全局注册懒加载指令
 * @param app Vue应用实例
 */
export const setupLazyLoadDirective = (app: App) => {
  app.directive('lazy-load', lazyLoad)
  // 挂载全局方法:方便在组件内调用
  app.config.globalProperties.$lazyLoad = {
    triggerLoad,
    resetLoad
  }
}

// TS类型扩展:增强类型提示
declare module 'vue' {
  export interface ComponentCustomProperties {
    $lazyLoad: {
      triggerLoad: typeof triggerLoad
      resetLoad: typeof resetLoad
    }
  }
}

declare global {
  interface HTMLElement {
    dataset: DOMStringMap & {
      src?: string
      lazyStatus?: string
    }
  }
}

🚀 使用指南

1. 全局注册指令

main.ts 中注册指令:

import { createApp } from 'vue'
import { setupLazyLoadDirective } from './directives/lazyLoad'
import App from './App.vue'

const app = createApp(App)
// 注册懒加载指令
setupLazyLoadDirective(app)
app.mount('#app')

2. 基础使用(字符串形式)

<template>
  <!-- 最简单的用法:直接传图片地址 -->
  <img v-lazy-load="imageUrl" alt="示例图片" />
</template>

<script setup lang="ts">
const imageUrl = 'https://example.com/your-image.jpg'
</script>

3. 高级使用(对象配置)

<template>
  <!-- 自定义配置 -->
  <img 
    v-lazy-load="{
      src: imageUrl,
      placeholder: 'https://example.com/placeholder.png',
      error: 'https://example.com/error.png',
      attempt: 5,  // 重试5次
      loadingClass: 'my-loading',
      rootMargin: '50px'
    }"
    @lazy-loaded="handleLoaded"
    @lazy-error="handleError"
    alt="高级示例"
  />
</template>

<script setup lang="ts">
const imageUrl = 'https://example.com/your-image.jpg'

// 加载成功回调
const handleLoaded = (e: CustomEvent) => {
  console.log('图片加载成功', e.detail)
}

// 加载失败回调
const handleError = (e: CustomEvent) => {
  console.error('图片加载失败', e.detail)
}
</script>

<style>
/* 自定义加载样式 */
.my-loading {
  background: #f5f5f5;
  filter: blur(2px);
}

.lazy-loaded {
  transition: filter 0.3s ease;
  filter: blur(0);
}

.lazy-error {
  border: 1px solid #ff4444;
}
</style>

4. 手动控制加载

在组件内手动触发/重置加载:

<template>
  <img ref="imageRef" v-lazy-load="imageUrl" alt="手动控制" />
  <button @click="handleTriggerLoad">手动加载</button>
  <button @click="handleResetLoad">重置加载</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { triggerLoad, resetLoad } from './directives/lazyLoad'

const imageRef = ref<HTMLImageElement>(null)
const imageUrl = 'https://example.com/your-image.jpg'

// 手动触发加载
const handleTriggerLoad = () => {
  if (imageRef.value) {
    triggerLoad(imageRef.value)
  }
}

// 重置加载状态
const handleResetLoad = () => {
  if (imageRef.value) {
    resetLoad(imageRef.value)
  }
}
</script>

🔧 核心优化点说明

1. 性能优化

  • 全局观察器缓存:避免为每个元素创建独立的 IntersectionObserver 实例,减少内存占用
  • WeakMap 存储回调:自动回收无用的回调函数,防止内存泄漏
  • 统一清理函数:在元素卸载/更新时,彻底清理定时器、观察器、样式类

2. 重试机制优化

  • 指数退避策略:重试间隔从 1s 开始,每次翻倍(1s → 2s → 4s),最大不超过 5s,避免频繁重试占用资源
  • 每次重试创建新 Image 实例:避免浏览器缓存导致的重试无效问题
  • 状态锁机制:防止重复加载/重试,确保状态一致性

3. 易用性优化

  • 灵活的参数格式:支持字符串(仅图片地址)和对象(完整配置)两种绑定方式
  • 全局方法挂载:通过 $lazyLoad 可以在任意组件内调用手动控制方法
  • 完善的类型提示:TypeScript 类型扩展,开发时自动提示配置项和方法

4. 健壮性优化

  • 状态标记:通过 data-lazy-status 属性标记元素状态,方便调试和样式控制
  • 自定义事件:触发 lazy-loaded/lazy-error 事件,方便业务层处理回调
  • 跨域支持:默认设置 crossOrigin = 'anonymous',支持跨域图片加载

📋 关键配置项说明

配置项 类型 默认值 说明
root Element/Document/null null 观察器的根元素,null 表示视口
rootMargin string '0px' 根元素的边距,用于扩展/收缩观察区域
threshold number/number[] 0.1 元素可见比例阈值(0-1)
placeholder string 透明SVG 加载前的占位图
error string 带❌的SVG 加载失败后的占位图
loadingClass string 'lazy-loading' 加载中样式类
loadedClass string 'lazy-loaded' 加载完成样式类
errorClass string 'lazy-error' 加载失败样式类
attempt number 3 最大重试次数
src string - 目标图片地址

🎨 样式示例

可以根据元素的 data-lazy-status 属性或样式类定制加载动画:

/* 加载中动画 */
.lazy-loading {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

/* 加载完成过渡 */
.lazy-loaded {
  transition: opacity 0.3s ease;
  opacity: 1;
}

/* 初始状态 */
img[data-lazy-status="pending"] {
  opacity: 0;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

📌 总结

本文实现的 Vue3 懒加载指令具备以下核心优势:

  1. 高性能:基于 IntersectionObserver,相比滚动监听性能提升显著
  2. 高可用:内置失败重试机制,提升图片加载成功率
  3. 高灵活:支持丰富的自定义配置,适配不同业务场景
  4. 高可维护:TypeScript 全类型支持,代码结构清晰,易于扩展
  5. 无内存泄漏:完善的资源清理逻辑,适配组件生命周期

这个指令可以直接用于生产环境,覆盖大部分图片懒加载场景。如果需要进一步扩展,可以在此基础上增加:

  • 支持背景图懒加载
  • 支持视频懒加载
  • 加载进度显示
  • 批量加载控制

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

react和vue多个组件在一个页面展示不同内容都是请求一个接口,如何优化提升率性能

一、问题本质(先说清楚)

多个组件在同一页面,各自请求同一个接口,会造成:

  • ❌ 重复网络请求
  • ❌ 重复数据解析
  • ❌ 多次触发渲染
  • ❌ 状态不一致风险

👉 本质是:数据源分散 + 请求不可控


二、核心优化思想(一句话)

请求只发一次,数据集中管理,组件只负责“消费数据”。


三、最核心的 5 种优化方案(重点 ⭐⭐⭐)


✅ 方案一:请求上移(最重要,90% 场景适用)

思路

  • 接口请求放在父组件
  • 子组件通过 props 接收

React 示例

function Page() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchApi().then(setData);
  }, []);

  return (
    <>
      <CompA data={data} />
      <CompB data={data} />
    </>
  );
}

Vue 示例

<script setup>
const data = ref(null);
onMounted(async () => {
  data.value = await fetchApi();
});
</script>

<CompA :data="data" />
<CompB :data="data" />

最简单、最推荐


✅ 方案二:全局状态管理(中大型项目)

适合

  • 页面复杂
  • 多层组件共享
  • 多页面复用

技术选型

  • React:Redux / Zustand / Jotai
  • Vue:Pinia / Vuex

优点

  • 自动去重
  • 数据统一
  • 可缓存

✅ 方案三:请求缓存(非常重要 ⭐)

核心思想

同一个接口 + 参数 → 只请求一次


React 推荐:React Query / SWR

useQuery(['userInfo'], fetchApi);

特性:

  • 自动缓存
  • 请求去重
  • 失效更新
  • 并发合并

Vue 推荐:Vue Query / SWR

useQuery(['data'], fetchApi);

✅ 方案四:接口聚合(后端配合,性能最佳)

思路

  • 后端返回“页面所需完整数据”
  • 前端不拆接口
{
  "header": {},
  "list": [],
  "chart": {}
}

优点:

  • 网络请求最少
  • 首屏最快

✅ 方案五:请求锁 / Promise 复用(高级)

场景

  • 不能改结构
  • 多组件同时触发

实现思路

let cachePromise = null;

function fetchOnce() {
  if (!cachePromise) {
    cachePromise = fetchApi();
  }
  return cachePromise;
}

👉 多个组件共享同一个 Promise


四、性能提升点总结(你可以直接说)

优化点 效果
请求合并 减少网络开销
数据集中 避免重复计算
缓存 防止重复请求
减少渲染 提升 FPS
状态统一 防 bug

五、React / Vue 额外性能细节(加分)

React

  • 使用 React.memo
  • useMemo 派生数据
  • 避免 props 引用变化

Vue

  • computed 缓存
  • v-memo(Vue 3.3+)
  • 合理拆分 reactive

六、真实项目中的标准优化流程(面试非常加分)

发现重复请求
 → 抽离请求到父组件 / store
 → 加缓存层(React Query / Pinia)
 → 子组件只消费数据

七、30 秒面试标准回答(直接背)

多组件请求同一接口时,我会将请求上移到父组件或全局状态中统一管理;
同时引入请求缓存机制,确保相同参数只发一次请求;
组件只负责展示数据,从而减少重复请求、避免多余渲染并提升整体性能。


八、一句话终极总结

不要让组件“自己拿数据”,而要让数据“主动供给组件”。


vue3 源代码reactive 到底干了什么?

packages\reactivity\src\reactive.ts

在 reactive.ts 里搜索 export function reactive(大概在 80 行左右)。


function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>,
) {
  if (!isObject(target)) {
    if (__DEV__) {
      warn(
        `value cannot be made ${isReadonly ? 'readonly' : 'reactive'}: ${String(
          target,
        )}`,
      )
    }
    return target
  }
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  if (
    target[ReactiveFlags.RAW] &&
    !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
  ) {
    return target
  }
  // only specific value types can be observed.
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  // target already has corresponding Proxy
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
  )
  proxyMap.set(target, proxy)
  return proxy
}

我把它拆解为 5 个关键步骤。

第一步:身份检查(只加工对象)

if (!isObject(target)) {
  if (__DEV__) {
    warn(`value cannot be made ...`)
  }
  return target
}

大白话:Proxy 只能代理“对象”(Object、Array、Map、Set 等)。如果你传个数字 123 或字符串 "hello" 进来,Vue 直接无视你,把原值返回,并在开发环境下弹个警告。

这就是为什么:如果你想让一个基本类型变成响应式,得用 ref 而不是 reactive。

第二步:防止重复加工(已经是 Proxy 了吗?)

if (
  target[ReactiveFlags.RAW] &&
  !(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
  return target
}

大白话:如果你拿一个已经是 Proxy 的东西再丢给 reactive(),它会直接还给你。

细节:target[ReactiveFlags.RAW] 是一个特殊的标记。如果 target 是 Proxy,它能识别这个标记并返回原对象。

例外:有一种情况允许重复加工——把一个响应式对象变成“只读”对象(即 readonly(reactive(obj)) 是允许的)。

第三步:黑名单检查(它能被加工吗?)

const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
  return target
}

大白话:有些对象是 Vue 不想触碰的。

谁在黑名单里?

被 Object.freeze() 冻结的对象。

特殊的内置对象(如 RegExp, Date, Promise)。

带有 __v_skip: true 标记的对象(你可以手动让某个大对象跳过响应式)。

结论:如果是在黑名单里的对象,原样返回。

第四步:查缓存(别重复造轮子)

const existingProxy = proxyMap.get(target)
if (existingProxy) {
  return existingProxy
}

大白话:Vue 内部维护了一张表(proxyMap,是一个 WeakMap)。

场景


const obj = { name: 'vue' }
const p1 = reactive(obj)
const p2 = reactive(obj) // 再次调用

当第二次调用 reactive(obj) 时,Vue 发现 obj 已经在表里了,直接把上次造好的 p1 返回给你。这样能保证 p1 === p2,节省性能。

第五步:正式合体(最关键的一步)

const proxy = new Proxy(
  target,
  targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
)
proxyMap.set(target, proxy)
return proxy

大白话:这是真正干活的地方。

new Proxy(target, handlers):

target: 原始对象。

handlers: 这是响应式的灵魂。它定义了当你“读取”或“修改”对象时,要做什么操作(比如追踪依赖、触发更新)。

分支逻辑:

如果是 Map, Set, WeakMap, WeakSet,使用 collectionHandlers。

如果是 普通对象或数组,使用 baseHandlers(也就是你在入口看到的 mutableHandlers)。

最后:把新生成的 proxy 存入缓存表,并返回它。

总结:reactive 到底干了啥? 你只需要记住这个流程图:

传个东西进来 -> 是对象吗?(不是就滚粗)

看一眼 -> 已经是 Proxy 了吗?(是就直接还你)

再看一眼 -> 在黑名单里吗?(是就原样返回)

查查表 -> 以前加工过吗?(加工过就把旧的给你)

动手 -> 披上一层 Proxy 的外套(注入 handlers),存进表里,收工!

vue3开发容易忽略的地方

工作了很多年,使用vue3也有3年了,但是一直像一个螺丝钉一样复制粘贴,没有学习什么东西,能熟练梳理业务逻辑,但是思路和方法跟用vue2没有什么区别。决定重新刷一遍文档,把没用过的,不太熟悉的地方罗列出来,希望能在开发中多多使用。

ref在模板中解包

  • 在模板渲染上下文中,只有顶级ref才会被解包.
const count = ref(1);
const obj = ref({id: ref(1)}) // obj.id 在模板中不能被解包

{{obj.id + 1}} // 输出错误:[object object]1
{{obj.id}}     // 输出正确:1
  • {{ obj.id }} 与 {{ obj.id.value }}等价,前者是文本插值的便利特性。

reactive

-不推荐使用 reactive() 的泛型参数,因为处理了深层次 ref 解包的返回值与泛型参数的类型不同。 -对解构操作不友好,我们解构reative时,解构后的变量与原变量失去连接。

// 不使用reative泛型参数
const book:Book = reative({name: 'xxx'})

let {count} = reativeDemo;
count++;  // 此时,reativeDemo.count不会发生变化。

computed

  • computed属性会基于响应式依赖缓存:一个computed属性只会在他的响应式依赖更新时重新计算。
  • computed可以获取上一次的值computed((pre) => {return count.value > 0 ? count.value : pre})
  • 可以重写computed的get和set方法,但是不建议。

v-show/v-if

  • v-show不支持在template上使用(v-if可以)
  • v-if有惰性,一开始为false则不会渲染,直到为true才第一次渲染

v-for

  • 循环使用item in item或者item of items一样
  • (value, key, index) in obj
  • v-for与v-if不建议同时使用,v-if优先级更高,所以v-if中不能使用item的值

函数ts类型

function handleChange(event: Event) { 
    console.log((event.target as HTMLInputElement).value) 
}

watch/watchEffect

  • deep属性可以为数字,表示监听的深度。
  • once属性,只监听一次。
  • watchEffect: 用法类似computed,不需要指定监听对象。在同步执行期间,自动追踪所有能访问到的响应式数据,在异步执行期间,只有在第一个await正常工作之前的响应式数据能被追踪。
  • onWatcherCleanup: 注册一个清理函数,当监听器失效并准备重新运行时调用(eg:在watch中id变化调用接口,接口未返回数据时,id又发生了变化,则需要取消正在进行的接口调用,根据新的id重新发起接口)(3.5+)
  • watch方法会在dom更新之前调用,如果想在dom更新之后调用则需要增加属性{flush: 'post'},watch方法在任何更新之前调用,则使用{flush: 'sync}(3.5+)
watch(source, callback, {flush: 'post'})
watchEffect(callback, {flush: 'post'})
watchPostEffect(callback)

// 在响应数据变化时同步执行
watch(source, callback, {flush: 'sync'})
watchEffect(callback, {flush: 'sync'})
watchSyncEffect(callback)
  • 中止监听器,定义一个变量接受watch或者watchEffect的返回值
const unwatch = watch(source, callback)
unwatch() // 中止监听

模板引用useTemplateRef(3.5+)

  • 会自动推断ref的数据类型
  • 如果自动推断失败可以自定义类型,使用InstanceType(typeof MyComponent) / HTMLInputElement
const inputRef = useTemplateRef('my-input');
inputRef.focus();
  • v-for上使用ref,则useTemplateRef生成的是一个数组,数组的顺序不一定与源数组顺序相同。
<div ref="itemsRef" v-for="item in items" >{{ item }}</div>

const itemsRef = useTemplateRef('itemsRef')

props

  • 解构props时,对象数组等不需要在函数中返回。(3.5+)
  • 给props赋值的时候使用definedProps<>()
  • 定义props分编译时和运行时,运行时可以验证一些复杂数据类型以及定义动态数据。编译时可以使用泛型等复杂数据结构,ts中更推荐使用编译时。
  • 给组件传递一个对象的所有属性可以使用v-bind写法
  • 子组件不能修改父组件传递的props,可以重新定义一个变量。
  • boolean类型的props,为了更贴近原生html,有简介写法。
// 解构props时,对象数组等不需要在函数中返回。(3.5+)
const {name: '', age: 0, hobby: []} = defineProps<{name: string, age: number, hobby: string[]}>();

// 3.5以下版本 
// withDefaults中设置默认值时,对象数组等需要在函数中返回,确保多个实例时,每个实例都有自己的副本。
const props = widthDefaults(defineProps<{name: string, age: number, hobby: string[]}>(), {
name: '',
age: 0,
hobby: () => []
})

// 运行时
const props = defineProps({
name: {
    type: String,
    default: '',
    validate: (val) => {...}
}
})

// 编译时
const defineProps<{name: string, age: number, hobby: string[]}>

// boolean类型的props
const props = defineProps({disabled: Boolean})
<myComponent disabled /> // 相当于 :disabled="true",不写默认为false

emit

  • 可以带校验,校验通过再emit,否则不emit
  • 更简洁的定义语法(3.3+)
// 校验
const emits = defineEmits({
    submit: (name: string, id: number) => {
        if (name && id) { return true }
        else { return false;}
    }
})


const emits = defineEmits<{
    update: [id: number],
    success: [],
}>()

// v-bind
const post = {name: '', id: 0}
<myComponent v-bind="post" />
<myComponent :id="post.id" :name="post.name" />

defineModel

  • 定义了一个名为modelValue的prop,本地的ref与其同步
  • 定义了一个update:modelValue方法,本地ref变化时触发(.set方法中实现)
// 第一个参数不写默认与ref名相同
const modelValue = defineModel('value', {require: truedefault0})

attribute透传

  • 组件接收的所有未在props中声明的属性会透传
  • 多根节点默认不会透传,除非显示透传,使用$attrs
  • 可以通过vue提供的方法获取所有透传的attrs,attrs不是响应式的,需要响应式的需要props定义。
import { useAttrs } from 'vue'
const attrs = useAttrs();
  • 想要父组件的属性透传给自己的子组件可以使用$attrs, attrs对象包含了除组件所声明的 props 和 emits 之外的所有其他 attribute,例如 classstylev-on 监听器等等。
  • 想要不透传,或者不接受透传可以设置
<childComponent v-bind="$attrs">

// 不透传设置
<scripte setup>
defineOptions({
    inheritAttrs: false
})
</script>

slot

  • 组件插槽可以传递参数,参数名不能为name,name为插槽名。
// Child
<div class="card">
    <slot name="cardContent" value="cardInfo">
</div>
// Parent
<template #cardContent="cardInfo">
    {{cardInfo}}
</template>

依赖注入

  • 可以inject一个ref的响应式数据,数据变化会同步。在子孙组件修改会同步父组件,在父组件修改会同步到子组件。但是建议所有的更改都在父组件中执行,可以通过传递一个update方法给子孙组件更新。
import { readonly } from 'vue'
// 父组件
const location = ref('');
function updateLocation() {
    location.value = "ww"
}
privide('location', {
    location,
    updateLocation,
})

// 子孙组件
const {location, updateLocation} = inject('location')
  • 不想被子孙组件更改的数据使用readonly方法包裹
privide('readonlyLocation', readonly(location))
  • vue推荐当依赖注入较多时或写公用组件,使用Symbol防止重名,将所有Symbol定义的key放到一个文件中
// key.js
const locationkey  = Symbol();
// parent
import { locationkey } from './key.js'
privide(locationKey, {...})
// child
import { locationkey } from './key.js'
const location = inject(locationkey);

组合式函数

  • 方法中使用了vue的组合式API(如ref,watch等)来封装和复用的有状态逻辑的函数。
  • 约定
    • 通常使用useXxx命名
    • 返回的数据一般都为ref,这样在组件中被解构后仍为响应式数据。
    const x = ref(1);
    const y = ref(2);
    return {
        x, y
    }
    
  • 在不同组件中使用useXxx都会重新创建新的数据,各个数据互不影响。

自定义指令

  • 由一个包含类似组件生命周期钩子的对象来定义。在ts setup中,v开头的方法都可以直接作为指令使用
  • 指令不建议在组件上使用,组件可能有多个子组件,指令不会通过$attrs透传。
<script setup>
const vHighlight = {
    mounted: (el) => {
        el.classList.add('high-light')
    }
}
</script>
<template>
<div v-highlight>aaaaaaaa</div>
</template>

vue官方推荐

  • 使用vscode编辑器
  • vue official:vscode插件,提供实时语言服务
  • 浏览器开发者插件:Vue DevTools
  • vue-tsc: 在编译阶段进行类型检查。
  • 代码规范:在项目中安装eslint-plugin-vue; 在IDE中装ESlint插件;在项目中装lint-staged代码提交规范
  • 格式化:prettier,IDE中安装插件,项目中安装prettier,写代码格式化时IDE中的prettier会优先找项目中的prettier配置进行格式化,如果项目中没有则会使用IDE自己的配置。
  • @vitejs/plugin-vue:为 Vite 提供 Vue 单文件组件支持的官方插件。

状态管理

  • 用响应式API(ref,reative, watch等)做状态管理。
  • 定义一个store文件,将多个组件需要公用的数据放进去,在组件中引用
  • 复杂的使用pinia

3. 避坑+实战|Vue3 hoistStatic静态提升,让渲染速度翻倍的秘密

在 Vue3 的编译优化体系中,静态提升(hoistStatic) 是核心性能优化手段之一。它通过在编译阶段识别并提取模板中的静态内容,避免每次组件渲染时重复创建、比对这些不变的节点,大幅减少运行时开销。本文将从“做了什么”“怎么做”“优化效果”三个维度,彻底讲清 hoistStatic 的底层逻辑与实际价值。

一、先搞懂:什么是“静态内容”?

在分析静态提升前,需明确 Vue3 对“静态内容”的定义:

  • 静态节点:内容完全固定、不会随响应式数据变化的节点(如 <div>Hello Vue3</div>);
  • 静态属性:值固定的属性(如 class="title"id="box");
  • 静态树:由多个静态节点组成的完整子树(如纯静态的导航栏、页脚)。

这些内容的特征是:组件生命周期内永远不会变化,若不做优化,每次组件重新渲染(如响应式数据更新)时,Vue 会重复创建这些节点的 VNode,并参与虚拟 DOM 比对,造成无意义的性能消耗。

二、hoistStatic 核心动作:3类静态内容的提升逻辑

Vue3 的编译器在开启 hoistStatic(默认开启)后,会对不同类型的静态内容执行针对性提升,核心目标是“将静态内容移出渲染函数,仅创建一次,复用多次”。

1. 基础动作:静态节点提升至渲染函数外部

核心逻辑:将单个静态节点的 VNode 创建逻辑,从组件的渲染函数(_render)中提取到外部,仅在组件初始化时创建一次,后续渲染直接复用该 VNode。

优化前(未开启静态提升)

每次渲染都会重新创建静态节点的 VNode:

// 编译后的渲染函数(简化版)
function render() {
  return createVNode('div', null, [
    // 静态节点:每次渲染都重新创建
    createVNode('p', { class: 'static' }, '静态文本'),
    // 动态节点:随数据变化
    createVNode('p', null, ctx.msg)
  ])
}

优化后(开启静态提升)

静态节点被提升到渲染函数外部,仅初始化一次:

// 静态节点被提升到外部,仅创建一次
const _hoisted_1 = createVNode('p', { class: 'static' }, '静态文本')

// 渲染函数中直接复用
function render() {
  return createVNode('div', null, [
    _hoisted_1, // 复用已创建的静态 VNode
    createVNode('p', null, ctx.msg)
  ])
}

2. 进阶动作:静态属性提升

对于静态属性(如固定的 classidstyle),Vue3 会将其提取为常量,避免每次创建 VNode 时重复创建属性对象。

优化示例

// 优化前:每次渲染创建新的属性对象
createVNode('div', { class: 'header', id: 'nav' }, '导航栏')

// 优化后:静态属性提升为常量
const _hoisted_2 = { class: 'header', id: 'nav' }
// 渲染时复用属性对象
createVNode('div', _hoisted_2, '导航栏')

3. 深度动作:静态树整体提升

若模板中存在连续的静态节点组成的“静态树”(如整个页脚、纯静态的侧边栏),hoistStatic 会将整个静态树作为一个整体提升,而非单个节点拆分,进一步减少内存占用和创建开销。

优化示例(静态树)

<!-- 模板中的静态树 -->
<footer>
  <div class="footer-logo">Vue3</div>
  <div class="footer-text">版权所有 © 2026</div>
</footer>

<!-- 编译后:整个静态树被提升为单个 VNode 常量 -->
const _hoisted_3 = createVNode('footer', null, [
  createVNode('div', { class: 'footer-logo' }, 'Vue3'),
  createVNode('div', { class: 'footer-text' }, '版权所有 © 2026')
])

// 渲染函数中直接复用整棵树
function render() {
  return createVNode('div', null, [
    // 其他动态内容
    _hoisted_3 // 复用静态树
  ])
}

三、静态提升的额外优化:跳过虚拟 DOM 比对

Vue3 的虚拟 DOM 比对(patch)过程中,若识别到节点是“静态提升节点”,会直接跳过比对逻辑——因为已知这些节点不会变化,无需消耗性能检查属性、子节点是否更新。

核心逻辑伪代码:

function patch(n1, n2) {
  // 静态节点:直接跳过比对,复用即可
  if (n2.shapeFlag & ShapeFlags.STATIC) {
    return
  }
  // 动态节点:执行常规比对逻辑
  // ...
}

四、hoistStatic 的生效规则与避坑点

1. 生效条件

  • 仅对编译时能确定的静态内容生效(如固定文本、固定属性),含动态插值({{ msg }})、动态指令(v-if/v-for)的节点不参与提升;
  • Vue3 默认为生产环境开启 hoistStatic,开发环境可通过 compilerOptions 手动配置;
  • 单个静态节点需满足“非根节点且无动态绑定”,才会被提升(根节点提升无意义)。

2. 常见坑点

  • 误区1:认为“所有静态内容都会被提升”——Vue3 对极短的静态节点(如单个 <span>123</span>)可能不提升,因为提升的内存开销大于收益;
  • 误区2:静态内容中混入动态指令(如 v-on:click)——含动态指令的节点会被判定为动态节点,无法提升;
  • 误区3:手动关闭 hoistStatic——除非有特殊编译需求,否则不要关闭,会显著降低渲染性能。

五、实战验证:静态提升的性能收益

以一个包含 100 个静态节点 + 1 个动态节点的组件为例:

  • 未开启静态提升:每次渲染需创建 101 个 VNode,执行 101 次虚拟 DOM 比对;
  • 开启静态提升:每次渲染仅创建 1 个动态 VNode,100 个静态 VNode 复用,且跳过 100 次比对。

实测数据(Vue3 官方基准测试):

  • 渲染耗时降低约 30%~50%;
  • 内存占用减少约 20%(避免重复创建 VNode 和属性对象)。

总结

关键点回顾

  1. hoistStatic 核心是编译阶段提取静态内容,将其移出渲染函数,仅初始化一次、渲染时复用;
  2. 优化维度包括:静态节点、静态属性、静态树的提升,以及跳过静态节点的虚拟 DOM 比对;
  3. 仅对编译时确定的静态内容生效,含动态逻辑的节点无法提升,且需避免过度依赖静态提升优化动态场景。

Vue3 的静态提升看似是“细节优化”,实则是从编译层面减少运行时无意义的计算,这也是 Vue3 相比 Vue2 渲染性能大幅提升的核心原因之一。理解其底层逻辑,能帮助你在开发中更合理地编写模板(如拆分静态/动态内容),最大化利用该优化特性。

# 手把手教你实现“左右拖动布局”:打造丝滑的 Vue 分屏体验

在开发像考试系统、代码编辑器或者对比工具这类 Web 应用时,左右分屏且可自由拖动调整宽度的布局是一种非常常见且高频的需求。

效果演示

我们需要实现的效果如下:

  1. 页面分为“左侧内容区”、“中间拖动条”、“右侧内容区”。
  2. 用户按住中间的拖动条(Resizer)左右拖动,实时改变左右两边的宽度比例。
  3. 拖动过程中禁止文字选中,避免体验跳脱。
  4. 设置最小/最大宽度限制,防止某一边被挤压得看不见。

核心思路

我们的实现核心在于 Flex 布局 配合 百分比宽度

  • 布局:父容器使用 display: flex

  • 宽度控制:使用一个响应式变量 leftPercentage 来控制左侧容器的宽度。右侧容器的宽度就是 100% - leftPercentage

  • 交互逻辑

    1. mousedown:在拖动条上按下鼠标,标记开始拖动,并注册全局 mousemove 和 mouseup 事件。
    2. mousemove:计算鼠标当前位置相对于父容器的百分比,动态更新 leftPercentage
    3. mouseup:松开鼠标,移除事件监听,结束拖动。

代码实现

以下以 Vue 2 为例(Vue 3 原理完全相同,只是语法稍有区别)。

1. HTML 结构

结构非常简单,经典的“三明治”夹心结构。

<template>
  <!-- 父容器 -->
  <div ref="splitPane" class="split-pane-container">
    
    <!-- 左侧面板 -->
    <div class="left-pane" :style="{ width: leftPercentage + '%' }">
      <div class="content">
        <!-- 插槽或具体内容 -->
        <slot name="left">Left Content</slot>
      </div>
    </div>
    <!-- 拖动条 (Resizer) -->
    <div class="resizer" @mousedown="startResize">
      <!-- 可以放一个拖拽图标,增加可识别性 -->
      <img src="@/assets/icon_handler.png" class="icon-handler" />
    </div>
    <!-- 右侧面板 -->
    <div class="right-pane" :style="{ width: (100 - leftPercentage) + '%' }">
      <div class="content">
         <slot name="right">Right Content</slot>
      </div>
    </div>
  </div>
</template>

2. CSS 样式

关键点在于 resize 的样式设置,以及 cursor: col-resize 提示用户可以左右拖动。

.split-pane-container {
  display: flex;
  height: 100vh; /* 或指定高度 */
  overflow: hidden;
}
.left-pane, .right-pane {
  overflow-y: auto; /* 内容溢出滚动 */
  height: 100%;
}
/* 拖动条样式 */
.resizer {
  width: 14px;  /* 拖动条宽度 */
  cursor: col-resize; /* 鼠标样式变为左右拖动箭头 */
  background-color: #f5f7fa;
  border-left: 1px solid #e4e7ed;
  border-right: 1px solid #e4e7ed;
  
  /* 居中内部图标 */
  display: flex;
  justify-content: center;
  align-items: center;
  
  /* 防止 Flex 压缩拖动条宽度 */
  flex-shrink: 0; 
  
  transition: background-color 0.3s;
}
.resizer:hover {
  background-color: #e6e8eb; /* 悬停高亮 */
}
.icon-handler {
  width: 20px;
  pointer-events: none; /* 防止拖动图片本身 */
  user-select: none;
}

3. JavaScript 核心逻辑

这里是灵魂所在。特别需要注意的是事件监听必须绑定在 document 上,而不是 resizer 上。因为用户拖动过快时,鼠标可能会移出拖动条范围,如果绑定在 resizer 上会导致拖动断触。

export default {
  data() {
    return {
      leftPercentage: 50, // 初始左侧宽度占比 50%
      isResizing: false,  // 是否正在拖动标志位
    }
  },
  methods: {
    // 1. 开始拖动
    startResize() {
      this.isResizing = true
      
      // 添加全局事件监听
      document.addEventListener('mousemove', this.doResize)
      document.addEventListener('mouseup', this.stopResize)
      
      // 关键优化:拖动时禁止选中文字,避免变蓝
      document.body.style.userSelect = 'none'
      document.body.style.cursor = 'col-resize' // 强制全局鼠标样式
    },
    // 2. 执行拖动
    doResize(e) {
      if (!this.isResizing) return
      const splitPane = this.$refs.splitPane
      if (!splitPane) return
      // 获取父容器的位置信息
      const containerRect = splitPane.getBoundingClientRect()
      const containerWidth = containerRect.width
      const containerLeft = containerRect.left
      // 计算鼠标相对于父容器左侧的距离 (X轴)
      const mouseX = e.clientX - containerLeft
      // 转换为百分比
      let newLeftPercentage = (mouseX / containerWidth) * 100
      // 边界限制:建议设置 20% ~ 80%,防止某一边被完全遮挡
      if (newLeftPercentage < 20) newLeftPercentage = 20
      if (newLeftPercentage > 80) newLeftPercentage = 80
      this.leftPercentage = newLeftPercentage
    },
    // 3. 结束拖动
    stopResize() {
      this.isResizing = false
      
      // 移除事件监听
      document.removeEventListener('mousemove', this.doResize)
      document.removeEventListener('mouseup', this.stopResize)
      
      // 恢复样式
      document.body.style.userSelect = ''
      document.body.style.cursor = ''
    }
  }
}

遇到的“坑”与优化点

1. 拖动卡顿与丢帧

问题:如果在 

doResize 中做复杂的 DOM 操作,会导致拖动卡顿。 

解决方案:我们只改变了一个响应式变量 leftPercentage,Vue 的 Diff 算法足够快。如果依然卡顿,可以使用 requestAnimationFrame 进行节流。

2. 鼠标移出拖动条失效

问题:鼠标拖得太快,离开了 .resizer 元素,拖动就停止了。 

解决方案:如上代码所示,使用 document.addEventListener 监听 mousemove,确保鼠标在页面任何位置都能响应。

3. 文字选中干扰

问题:拖动时如果不小心选中了左右两边的文字,体验非常差,甚至会自动触发浏览器的原生拖拽。 

解决方案:在 startResize 中设置 document.body.style.userSelect = 'none',拖动结束后恢复。

4. Iframe 遮挡问题(进阶)

问题:如果你的左右面板里嵌入了 <iframe>(例如显示 PDF 或外部网页),鼠标滑过 iframe 时,mousemove 事件会被 iframe 吞掉,导致拖动失效。 

解决方案:在 startResize 时,给所有的 iframe 上面覆盖一层透明的 div,或者设置 pointer-events: none,拖动结束后恢复。

总结

通过简单的 Flex 布局和数十行 JS 代码,我们就能实现一个高性能、兼容性好的分屏组件。这个方案不依赖任何重型第三方库(如 split.js),非常适合不仅需要轻量,又需要高度定制 UI 的场景。

若依框架实现表格列按需显示功能

一、项目背景

本文基于若依框架(RuoYi v3.9.1)进行二次开发,实现表格列的按需显示功能。若依框架是一个基于SpringBoot+Vue3前后端分离的Java快速开发框架,前端技术栈采用Vue3 + Element Plus + Vite。

技术栈版本信息

  • 框架版本:RuoYi v3.9.1
  • Vue版本:3.5.16
  • Element Plus版本:2.10.7
  • Vite版本:6.3.5

二、问题描述

在若依框架的优惠券管理模块中,默认显示所有表格列,包括序号、优惠券名称、面值、有效期开始、有效期结束、状态、使用说明等。但在实际业务场景中,用户可能只需要关注部分字段,过多的列会影响表格的可读性和用户体验。

参考若依框架内置的用户管理页面,发现该页面已经实现了表格列的按需显示功能,用户可以通过右上角的列配置按钮自由选择要显示的列。因此,我们需要在优惠券管理页面中实现相同的功能。

三、解决方案

通过分析用户管理页面的实现方式,我们发现若依框架已经内置了列显示/隐藏的组件RightToolbar,只需要进行以下三步改造:

  1. RightToolbar组件中传入columns属性
  2. 定义列配置对象columns
  3. 为每个表格列添加v-if条件控制显示/隐藏

四、实现步骤

步骤1:修改RightToolbar组件,传入columns属性

在优惠券管理页面的工具栏区域,找到right-toolbar组件,添加:columns="columns"属性。

修改位置src/views/feature/coupon/index.vue 第92行

修改前

<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>

修改后

<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>

步骤2:定义columns响应式对象

在script setup部分,定义columns响应式对象,配置每个列的标签和显示状态。

修改位置src/views/feature/coupon/index.vue 第187-193行

添加代码

const columns = ref({
  couponName: { label: '优惠券名称', visible: true },
  couponValue: { label: '面值', visible: true },
  startTime: { label: '有效期开始', visible: true },
  endTime: { label: '有效期结束', visible: true },
  status: { label: '状态', visible: true },
  remark: { label: '使用说明', visible: true }
})

说明

  • 每个属性对应一个表格列的key
  • label属性用于在列配置面板中显示列名
  • visible属性控制列的默认显示状态(true为显示,false为隐藏)

步骤3:为表格列添加v-if条件

为每个需要控制显示/隐藏的表格列添加v-if条件,绑定到columns对象中对应列的visible属性。

修改位置src/views/feature/coupon/index.vue 第97-115行

修改前

<el-table-column label="优惠券名称" align="center" prop="couponName" />
<el-table-column label="面值" align="center" prop="couponValue" />
<el-table-column label="有效期开始" align="center" prop="startTime" width="180">
  <template #default="scope">
    <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="有效期结束" align="center" prop="endTime" width="180">
  <template #default="scope">
    <span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status">
  <template #default="scope">
    <dict-tag :options="common_status" :value="scope.row.status"/>
  </template>
</el-table-column>
<el-table-column label="使用说明" align="center" prop="remark" />

修改后

<el-table-column label="优惠券名称" align="center" prop="couponName" v-if="columns.couponName.visible" />
<el-table-column label="面值" align="center" prop="couponValue" v-if="columns.couponValue.visible" />
<el-table-column label="有效期开始" align="center" prop="startTime" width="180" v-if="columns.startTime.visible">
  <template #default="scope">
    <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="有效期结束" align="center" prop="endTime" width="180" v-if="columns.endTime.visible">
  <template #default="scope">
    <span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" v-if="columns.status.visible">
  <template #default="scope">
    <dict-tag :options="common_status" :value="scope.row.status"/>
  </template>
</el-table-column>
<el-table-column label="使用说明" align="center" prop="remark" v-if="columns.remark.visible" />

注意

  • 序号列和操作列不需要添加v-if条件,因为它们应该始终显示
  • 复选框列(type="selection")也不需要添加v-if条件

五、完整代码示例

以下是优惠券管理页面的完整代码,重点标注了修改部分:

<template>
  <div class="app-container">
    <!-- 搜索表单区域 -->
    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="88px">
      <!-- ... 搜索表单内容 ... -->
    </el-form>

    <!-- 操作按钮栏 -->
    <el-row :gutter="10" class="mb8">
      <!-- ... 操作按钮 ... -->
      <!-- 修改点1:添加columns属性 -->
      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
    </el-row>

    <!-- 数据表格 -->
    <el-table v-loading="loading" :data="couponList" @selection-change="handleSelectionChange">
      <el-table-column type="selection" width="55" align="center" />
      <el-table-column label="序号" type="index" align="center" width="80" :index="indexMethod" />
      
      <!-- 修改点2:为每个列添加v-if条件 -->
      <el-table-column label="优惠券名称" align="center" prop="couponName" v-if="columns.couponName.visible" />
      <el-table-column label="面值" align="center" prop="couponValue" v-if="columns.couponValue.visible" />
      <el-table-column label="有效期开始" align="center" prop="startTime" width="180" v-if="columns.startTime.visible">
        <template #default="scope">
          <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
        </template>
      </el-table-column>
      <el-table-column label="有效期结束" align="center" prop="endTime" width="180" v-if="columns.endTime.visible">
        <template #default="scope">
          <span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
        </template>
      </el-table-column>
      <el-table-column label="状态" align="center" prop="status" v-if="columns.status.visible">
        <template #default="scope">
          <dict-tag :options="common_status" :value="scope.row.status"/>
        </template>
      </el-table-column>
      <el-table-column label="使用说明" align="center" prop="remark" v-if="columns.remark.visible" />
      
      <!-- 操作列始终显示 -->
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
        <template #default="scope">
          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['feature:coupon:edit']">修改</el-button>
          <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['feature:coupon:remove']">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页组件 -->
    <pagination
      v-show="total>0"
      :total="total"
      v-model:page="queryParams.pageNum"
      v-model:limit="queryParams.pageSize"
      @pagination="getList"
    />

    <!-- 对话框 -->
    <el-dialog :title="title" v-model="open" width="500px" append-to-body>
      <!-- ... 对话框内容 ... -->
    </el-dialog>
  </div>
</template>

<script setup name="Coupon">
import { listCoupon, getCoupon, delCoupon, addCoupon, updateCoupon } from "@/api/feature/coupon"

const { proxy } = getCurrentInstance()
const { common_status } = proxy.useDict('common_status')

// 修改点3:定义columns响应式对象
const columns = ref({
  couponName: { label: '优惠券名称', visible: true },
  couponValue: { label: '面值', visible: true },
  startTime: { label: '有效期开始', visible: true },
  endTime: { label: '有效期结束', visible: true },
  status: { label: '状态', visible: true },
  remark: { label: '使用说明', visible: true }
})

const couponList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")

const data = reactive({
  form: {},
  queryParams: {
    pageNum: 1,
    pageSize: 10,
    couponName: null,
    couponValue: null,
    startTime: null,
    endTime: null,
    status: null,
  },
  rules: {
  }
})

const { queryParams, form, rules } = toRefs(data)

/** 序号计算方法 */
function indexMethod(index) {
  return (queryParams.value.pageNum - 1) * queryParams.value.pageSize + index + 1
}

/** 查询优惠券管理列表 */
function getList() {
  loading.value = true
  listCoupon(queryParams.value).then(response => {
    couponList.value = response.rows
    total.value = response.total
    loading.value = false
  })
}

// ... 其他方法 ...
</script>

六、功能说明

实现完成后,优惠券管理页面将具备以下功能:

  1. 列配置按钮:在表格右上角工具栏中会显示一个列配置图标
  2. 列显示/隐藏:点击列配置按钮,会弹出列配置面板,用户可以勾选或取消勾选要显示的列
  3. 实时生效:勾选状态变化后,表格列会立即显示或隐藏,无需刷新页面
  4. 默认全部显示:所有列默认visible: true,用户可以根据需要隐藏不需要的列
  5. 固定列:序号列、复选框列和操作列始终显示,不受列配置控制

七、扩展应用

这个功能可以轻松应用到若依框架的其他列表页面中,只需按照上述三个步骤进行修改:

  1. right-toolbar组件中添加:columns="columns"属性
  2. 定义columns响应式对象,配置需要控制的列
  3. 为对应的表格列添加v-if="columns.列名.visible"条件

八、注意事项

  1. 列名一致性columns对象中的属性名必须与表格列的prop属性保持一致
  2. 默认显示状态:根据业务需求设置visible的默认值,通常建议默认显示所有列
  3. 固定列:序号列、复选框列和操作列等关键列不建议添加显示/隐藏控制
  4. 响应式数据columns必须使用ref定义,确保响应式更新

九、总结

通过参考若依框架内置的用户管理页面,我们成功为优惠券管理页面添加了表格列按需显示功能。该实现方式简单高效,充分利用了若依框架已有的组件和功能,避免了重复开发。用户可以根据自己的需求自由选择要显示的列,提升了表格的可读性和用户体验。

这种实现方式具有良好的可复用性和扩展性,可以快速应用到其他类似的列表页面中,是若依框架二次开发中的一个实用技巧。

raychart 基于 Vue 的 3D 图表可视化实践:移动端优先从交互到性能的一次系统升级 🚀

raychart 基于 Vue 的 3D 图表可视化实践:移动端优先从交互到性能的一次系统升级 🚀

先说结论:做 3D 图表可视化编辑器时,移动端优先并不是“迁就小屏”,而是帮你把复杂度收敛到正确的地方。3D 图表的信息密度高、交互多、渲染重,如果按桌面端思维直接堆功能,结果通常是功能齐全但体验割裂。我们在 raychart 的 Vue 技术栈里把编辑器先打磨成“手机也能顺畅使用”的体验,再向桌面扩展,最终移动端更好用、桌面端也更清晰。

为什么 3D 图表可视化更需要移动端优先 📱

移动端限制不是坏事,而是设计的天然约束。小屏幕会迫使你重新定义“最重要的交互路径”,也会倒逼你把功能收敛成清晰的层级结构。对基于 Vue 的 3D 图表可视化来说,这正是体验提升的关键。

  • 交互路径更短:移动端需要“少步骤完成关键任务”
  • 信息层级更清晰:控制项必须分组、折叠、聚焦
  • 性能更稳:移动端的硬件限制驱动我们优化默认值

RayChart 预览地址: https://chart3js.netlify.app/#/ ScreenShot_2026-01-26_092915_641.png

项目中如何落地:从控制模块的边界开始 🧩

我们把图表设置拆分为多个独立控制模块,每个模块只负责一个清晰的任务边界,例如:

  • 数据控制:输入与编辑数据点 🧮
  • 坐标控制:管理坐标系与维度映射 🧭
  • 光照控制:灯光类型、强度与阴影 💡
  • 主题控制:配色方案和默认颜色 🎨

这种结构直接服务移动端优先的目标:把复杂系统拆成可理解的小块,让用户在小屏幕上也能顺畅完成配置。桌面端只是展示空间更大,但交互路径依然保持简洁。

ScreenShot_2026-01-26_092942_569.png

ScreenShot_2026-01-26_092954_321.png

ScreenShot_2026-01-26_092925_249.png

移动端优先带来的三类收益 ✅

1. 交互体验更“可预期” 🤝

移动端用户通常只关心最核心的操作:切换图表、调参数、预览效果。我们把这些动作放在最短路径上,次要配置收进折叠区域。结果是桌面端也同样清爽,用户更快找到关键设置。

2. 设计一致性更强 🧱

为了适配小屏幕,我们统一了组件大小、间距、配色和状态样式,形成稳定的一致性。这种一致性一旦建立,桌面端也同步获得更可靠的视觉体验。

3. 性能优化更有效 ⚡

3D 图表可视化的性能瓶颈多在光照、阴影、材质等环节。移动端优先的限制迫使我们设置更合理的默认值,例如:

  • 默认灯光数量更克制
  • 阴影为可选项而非强制项
  • 材质细节更收敛

这些优化也让桌面端获得更稳定的帧率和更低的功耗。

ScreenShot_2026-01-26_094809_782.png

总结:移动端优先不是妥协,而是效率策略 🎯

移动端优先的价值不在于“做个能在手机上跑的版本”,而在于用它作为一种强约束,让系统更简单、更清晰、更高效。对 3D 图表这种复杂交互场景来说,它可以帮助我们:

  • 快速收敛需求边界
  • 建立明确的控制层级
  • 统一 UI 的风格与节奏
  • 同时提升移动端与桌面端体验

如果你也在做 3D 图表、Vue 可视化编辑器或复杂配置系统,建议试着从移动端出发设计一次,你会更快发现真正有价值的交互路径。

关键词 🔎

3D 图表、3D 可视化、Vue、Vue3、前端可视化、图表编辑器、移动端优先、交互设计、性能优化

解决 Vite 开发服务器启动报错:spawn EPERM

问题现象

在 Windows 系统下运行 Vite 开发服务器时,控制台报错:

Error: spawn EPERM 
     at ChildProcess.spawn (node:internal/child_process:420:11) 
     at Object.spawn (node:child_process:787:9) 
     at baseOpen (file:///E:/标签溯源/code/TraceGuard-UI/node_modules/vite/dist/node/chunks/dep-DBxKXgDP.js:26487:36)

开发服务器无法正常启动,或者启动后立即崩溃。

发生原因

这个错误通常由以下几个因素导致:

1. Vite 自动打开浏览器功能

vite.config.js 配置文件中,server.open 选项被设置为 true

server: {
  port: 80,
  host: true,
  open: true,  // 这里是问题所在
  // ...
}

open: true 时,Vite 会在服务器启动后自动调用系统默认浏览器打开开发地址。

2. Windows 权限限制

在 Windows 系统上,Node.js 的 child_process.spawn() 方法尝试启动浏览器进程时,可能会遇到以下权限问题:

  • 权限不足:当前进程没有足够的权限启动外部应用程序
  • 安全策略限制:Windows Defender 或企业安全策略阻止了进程创建
  • 路径问题:浏览器可执行文件路径无法正确解析

3. 端口占用(次要原因)

如果配置的端口(如 80)已被其他进程占用,也可能导致类似的错误。

解决方案

方案一:禁用自动打开浏览器(推荐)

修改 vite.config.js 文件,将 open 选项设置为 false

// vite.config.js
export default defineConfig(({ mode, command }) => {
  return {
    server: {
      port: 80,
      host: true,
      open: false,  // 改为 false,禁用自动打开浏览器
      proxy: {
        // ... 其他配置
      }
    },
    // ... 其他配置
  }
})

优点

  • 彻底解决权限问题
  • 不影响开发服务器功能
  • 可以手动选择浏览器访问

使用方式: 启动开发服务器后,在浏览器中手动访问 http://localhost:80

方案二:指定浏览器路径

如果确实需要自动打开浏览器,可以指定浏览器可执行文件的完整路径:

server: {
  port: 80,
  host: true,
  open: 'chrome',  // 或者指定完整路径
  // 或者
  open: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
}

注意:这种方式仍然可能遇到权限问题,不如方案一稳定。

方案三:以管理员身份运行

以管理员身份运行终端或 IDE,然后执行 npm run dev 命令。

缺点

  • 不是长期解决方案
  • 可能带来安全风险
  • 不推荐作为常规做法

验证修复

修改配置后,重新启动开发服务器:

npm run dev

应该看到类似以下的成功输出:

  VITE v6.3.5  ready in xxx ms

  ➜  Local:   http://localhost:80/
  ➜  Network: http://192.168.x.x:80/
  ➜  press h + enter to show help

然后在浏览器中手动访问 http://localhost:80 即可。

总结

spawn EPERM 错误是 Windows 系统下 Vite 开发服务器的常见问题,主要原因是自动打开浏览器功能触发了权限限制。最简单有效的解决方案是禁用自动打开浏览器功能,改为手动访问开发地址。

这种方式不仅解决了权限问题,还提供了更好的开发体验——你可以自由选择浏览器和打开时机。

相关资源

一次 scrollIntoView 在 Android 企微中失效的踩坑实录

这是一个看起来“理所当然”,却足以让你怀疑人生的Bug。

它不会在你本地出现,不会在 iOS 上出现,甚至在大多数 Android 浏览器上也“表现正常”。

但它会在 Android 企业微信 里,悄无声息地让你的页面—— 滚动不到指定位置

1、事情的起点:一个再正常不过的需求

故事要从一个移动端项目说起。

页面很常见:

  • 使用 Vant 组件库
  • 一个 Form 表单
  • 若干个输入项

需求也很常见:

提交表单时触发校验,校验失败就自动滚动到对应的表单项位置。

做过 PC 或移动端表单的人都知道,这几乎是“标配能力”。

在 Vant 中,对应的实现路径也非常清晰,校验失败后,调用滚动方法

const formRef = ref(null);
formRef.value.validate().then(()=> {
    // TODO
}).cathc(err=> {
    const name = err?.[0]?.name ?? '';
    name && formRef.value.scrollToField(name)
})

PC 端,这种体验甚至已经“理所当然”。

2、测试的一句话,让事情开始变味

提测之后,测试小姐姐提了一个非常合理、也非常人性化的建议

「现在滚动是瞬间跳过去的,能不能加个过渡?看起来有点生硬。」

听起来是不是很简单?👉 “加个平滑滚动而已。”

我第一时间翻了 Vant 官方文档

文档里对 scrollToField 的描述是这样的:

image.png

类似:

scrollToField(name: string, alignToTop?: boolean)

但问题在于:

  • 文档没有提平滑滚动
  • 没有提是否支持更复杂的滚动配置

不过,作为一个习惯 “不完全相信文档”的前端,我做了一件很自然的事——👉 去看源码。

3、源码一看:这不就有戏了吗?

在 Vant 的源码里,我很快找到了实现:

// packages/vant/src/form/Form.tsx
const scrollToField = (
  name: string,
  options?: boolean | ScrollIntoViewOptions,
) => {
  children.some((item) => {
    if (item.name === name) {
      item.$el.scrollIntoView(options);
      return true;
    }
    return false;
  });
};

看到这里,好家伙,这不是直接透传 scrollIntoView 吗?

也就是说:

  • 不仅能传 boolean
  • 还能直接传 ScrollIntoViewOptions

那事情就简单了。

const formRef = ref(null);
formRef.value.validate().then(()=> {
    // TODO 校验通过
}).cathc(err=> {
    const name = err?.[0]?.name ?? '';
    name && formRef.value.scrollToField(name, {
      behavior: 'smooth',
      block: 'center'
    })
});

本地一测:

  • ✅ 滚动顺滑
  • ✅ 居中展示
  • ✅ 体验明显提升

4、Bug 来了,而且来得很“安静”

没过多久,测试小姐姐提了一个 Bug。

描述非常简短:

「 现在触发校验之后,页面好像滚动不过去了 」

我第一反应是:

不可能吧?我刚刚还测过。

于是我拿起,🍎 iPhone 16 Pro 手机,点击表单提交按钮,触发校验

  • 一切正常
  • 平滑滚动
  • 定位精准

🤔 我心想:

那这是啥问题?「 于是我换了测试同款手机 」

真凶现身:Android + 企业微信 测试环境复现条件逐渐清晰:

  • Android 手机
  • 企业微信内置浏览器
  • 特定系统版本

关键信息最终锁定为:

  • MagicOS 8.0(荣耀 / 华为系,基于 Android 14)
  • 企业微信 5.0.3 (内置 X5 / 系统 WebView)

现象也非常“诡异”:

  • scrollToField 被调用了
  • 页面没有任何报错
  • 但页面就是没有滚动

5、真相:Android WebView 并不“讲武德”

深入排查后,问题逐渐明朗:

(1)Android WebView 对 scrollIntoView 支持并不完整

在 Android WebView / X5 内核 中:

  • scrollIntoView() 基本可用
  • block: 'center' 经常被忽略
  • behavior: 'smooth' 在复杂布局中,会被打断或失效

(2) 企业微信 Android 端不是“纯浏览器”

企业微信 Android 端:

  • 使用的是系统 WebView 或 X5 内核
  • 滚动是原生 + JS 混合实现
  • smooth 滚动有「动画被中断」的情况

而 iOS WKWebView:

  • scrollIntoView({ block: 'center' }) 支持是规范级别的
  • 滚动计算非常稳定

👉 所以看到的是:「 苹果:完美 ; 安卓:玄学 」

(3)Android 对「center」的计算有 Bug(尤其 Android 13+)

在 Android 12+,特别是 14:

  • block: 'center' 的中心点
  • 忽略滚动容器 padding
  • 或错误使用 offsetParent

这在 企微 + MagicOS 组合下非常容易触发。

6、最终方案:别再指望 scrollIntoView 了

问题明确后,解决思路也就清晰了。

方案一:Android 端不使用 smooth

const isAndroid = /Android/i.test(window.navigator.userAgent);

element.scrollIntoView({
  behavior: isAndroid ? 'auto' : 'smooth',
  block: 'center'
});

方案二(最稳):自己计算滚动距离

核心思想只有一句话: 自己算 scrollTop,别把命运交给 WebView。

示例:

/**
 * 将目标元素滚动到容器中间
 * @param container 滚动元素
 * @param target 目标元素
 */
const scrollToCenter(container, target) => {
  const containerRect = container.getBoundingClientRect();
  const targetRect = target.getBoundingClientRect();

  const offset =
    targetRect.top -
    containerRect.top -
    container.clientHeight / 2 +
    target.clientHeight / 2;

  container.scrollTo({
    top: container.scrollTop + offset,
    behavior: 'smooth'
  });
}

usage

const formRef = ref(null);
formRef.value.validate().then(()=> {
    // TODO 校验通过
}).cathc(err=> {
    const name = err?.[0]?.name ?? '';
    const container = document.getElementById('app');
    const target = document.getElementsByClassName('van-field__error-message')?.[0]
    scrollToCenter(container, target);
});

上线测试:

  • ✅ Android 企业微信
  • ✅ iOS
  • ✅ 本地浏览器

全部通过。

测试小姐姐给了一个评价:「这次的体验很好 👍」 那一刻,真的值了。

7、踩坑总结

如果你也在做类似的事情,建议直接收藏:

  • 不要在 Android 企业微信中过度依赖 scrollIntoView 的高级配置项

  • 尤其是:

    • behavior: 'smooth'
    • block: 'center'
  • iOS 正常 ≠ 代码在所有环境都正确

这类问题的本质往往不是:

你写错了代码*,

而是:

你刚好踩到了 WebView 的能力边界了。

从零开始:手把手教你创建 Vue 3 + TypeScript 项目

本文将带你一步步创建完整的 Vue 3 项目,包含现代化的开发工具链、代码规范和最佳实践。适合前端开发者学习和参考。

前言

Vue 3 带来了 Composition API、更好的 TypeScript 支持和性能优化,是现代前端开发的较广泛的选择。本教程将从环境搭建开始,逐步创建完整的 Vue 3 项目。

环境准备

1. Node.js 环境

Vue 3 项目需要 Node.js 环境,推荐使用 LTS 版本。

Windows 系统

# 访问 Node.js 官网下载安装包
# https://nodejs.org/

# 或使用 nvm-windows 管理多个版本
# 1. 下载 nvm-windows: https://github.com/coreybutler/nvm-windows/releases
# 2. 安装并重启命令行
nvm install 20.19.0
nvm use 20.19.0

macOS 系统

# 使用 Homebrew 安装
brew install node

# 或使用 nvm 管理版本
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.bashrc
nvm install 20.19.0
nvm use 20.19.0

验证安装

# 检查版本
node --version  # 应显示 v20.19.0 或更高
npm --version   # 应显示 10.x.x 或更高

2. 包管理工具

推荐使用 npm 或 pnpm,yarn 也可以。

# 全局安装 pnpm (可选)
npm install -g pnpm

# 检查版本
pnpm --version

3. IDE 配置

推荐使用 VS Code,并安装 Vue 相关插件。

必需插件

  • Vue (Official) - Vue 官方插件,提供语法高亮和智能提示
  • TypeScript Importer - 自动导入 TypeScript 类型
  • Prettier - 代码格式化
  • ESLint - 代码检查

VS Code 设置

// .vscode/settings.json
{
  "typescript.preferences.importModuleSpecifier": "relative",
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.vscode-prettier",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

项目创建

方法一:使用 Vite 官方模板

Vite 是 Vue 官方推荐的构建工具,速度快,开发体验好。

# 创建项目
npm create vue@latest vue-course

# 进入项目目录
cd vue-course

配置选项

在创建过程中选择以下配置:

✅ TypeScript
✅ JSX
✅ Vue Router
✅ Pinia
✅ ESLint
✅ Prettier

方法二:手动创建项目

如果你喜欢自定义配置,可以手动创建。

# 创建项目目录
mkdir vue-course
cd vue-course

# 初始化 package.json
npm init -y

# 安装核心依赖
npm install vue@latest @vue/compiler-sfc@latest
npm install -D @vitejs/plugin-vue@latest vite@latest
npm install -D typescript@latest vue-tsc@latest

项目配置

1. Vite 配置

创建 vite.config.ts 文件:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 5173,
    host: true,
    open: true
  },
  build: {
    target: 'esnext',
    minify: 'esbuild',
    outDir: 'dist'
  }
})

2. TypeScript 配置

创建 tsconfig.json

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
  "exclude": ["src/**/__tests__/*"],
  "compilerOptions": {
    "composite": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["vite/client"]
  }
}

创建 tsconfig.node.json

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["vite.config.*"],
  "compilerOptions": {
    "composite": true,
    "types": ["node"]
  }
}

创建 env.d.ts

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

3. ESLint 配置

创建 eslint.config.js

import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'

export default [
  {
    name: 'app/files-to-lint',
    files: ['**/*.{ts,mts,tsx,vue}'],
  },

  {
    name: 'app/files-to-ignore',
    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
  },

  js.configs.recommended,
  ...vue.configs['flat/essential'],

  {
    name: 'app/vue-rules',
    files: ['**/*.vue'],
    languageOptions: {
      parser: vue.parser,
      parserOptions: {
        parser: typescriptParser,
        extraFileExtensions: ['.vue'],
        sourceType: 'module',
      },
    },
  },

  {
    name: 'app/typescript-rules',
    files: ['**/*.{ts,mts,tsx,vue}'],
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        ecmaVersion: 2020,
        sourceType: 'module',
      },
    },
    rules: {
      ...typescript.configs.recommended.rules,
      '@typescript-eslint/no-unused-vars': 'error',
      '@typescript-eslint/explicit-function-return-type': 'off',
    },
  },
]

4. Prettier 配置

创建 .prettierrc.json

{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "endOfLine": "lf"
}

项目结构搭建

1. 基础文件结构

vue-course/
├── public/
│   ├── favicon.ico
├── src/
│   ├── assets/
│   │   ├── base.css
│   │   ├── main.css
│   │   └── logo.svg
│   ├── components/
│   │   ├── HelloWorld.vue
│   │   ├── TheWelcome.vue
│   │   ├── WelcomeItem.vue
│   │   └── icons/
│   ├── views/
│   │   ├── HomeView.vue
│   │   └── AboutView.vue
│   ├── router/
│   │   └── index.ts
│   ├── stores/
│   │   └── counter.ts
│   ├── App.vue
│   ├── main.ts
│   └── mcp-server.ts
├── .vscode/
│   └── settings.json
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── env.d.ts
├── eslint.config.ts
├── .prettierrc.json
└── README.md

2. 入口文件

src/main.ts

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

import './assets/main.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

index.html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue Course</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

3. 根组件

src/App.vue

<template>
  <div id="app">
    <header class="app-header">
      <h1>Vue 3 项目</h1>
      <nav>
        <router-link to="/">首页</router-link>
        <router-link to="/about">关于</router-link>
      </nav>
    </header>

    <main class="app-main">
      <router-view />
    </main>
  </div>
</template>

<script setup lang="ts">
// 组件逻辑
</script>

<style scoped>
.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background: #f8f9fa;
  border-bottom: 1px solid #e9ecef;
}

.app-main {
  padding: 2rem;
  min-height: calc(100vh - 80px);
}
</style>

路由配置

1. 安装 Vue Router

npm install vue-router@4

2. 路由配置

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/HomeView.vue'),
    meta: { title: '首页' }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/AboutView.vue'),
    meta: { title: '关于' }
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

// 路由守卫
router.beforeEach((to, from, next) => {
  document.title = `${to.meta.title} - Vue Course`
  next()
})

export default router

3. 创建页面组件

src/views/HomeView.vue

<template>
  <div class="home">
    <h2>欢迎使用 Vue 3</h2>
    <p>当前计数: {{ count }}</p>
    <button @click="increment" class="btn">增加</button>
    <button @click="decrement" class="btn">减少</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => count.value++
const decrement = () => count.value--
</script>

<style scoped>
.home {
  text-align: center;
  padding: 2rem;
}

.btn {
  margin: 0 0.5rem;
  padding: 0.5rem 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  cursor: pointer;
}

.btn:hover {
  background: #f0f0f0;
}
</style>

状态管理

1. 安装 Pinia

npm install pinia

2. 配置 Pinia

已经在 main.ts 中配置了。

3. 创建 Store

src/stores/counter.ts

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  // 状态
  const count = ref(0)

  // 计算属性
  const doubleCount = computed(() => count.value * 2)

  // 动作
  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const reset = () => {
    count.value = 0
  }

  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  }
})

4. 在组件中使用

<template>
  <div>
    <p>计数: {{ counter.count }}</p>
    <p>双倍: {{ counter.doubleCount }}</p>
    <button @click="counter.increment">+</button>
    <button @click="counter.decrement">-</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

样式配置

1. 全局样式

src/assets/main.css(项目中使用的是main.css):

#app {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  font-weight: normal;
}

a,
.green {
  text-decoration: none;
  color: hsla(160, 100%, 37%, 1);
  transition: 0.4s;
}

@media (hover: hover) {
  a:hover {
    background-color: hsla(160, 100%, 37%, 0.2);
  }
}

@media (min-width: 1024px) {
  body {
    display: flex;
    place-items: center;
  }

  #app {
    display: grid;
    grid-template-columns: 1fr 1fr;
    padding: 0 2rem;
  }
}

src/assets/base.css

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 16px;
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  padding: 0;
  color: #333;
  background-color: #fff;
}

h1, h2, h3, h4, h5, h6 {
  margin: 0 0 1rem 0;
  font-weight: 600;
  line-height: 1.2;
}

p {
  margin: 0 0 1rem 0;
}

a {
  color: #007acc;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

button {
  font-family: inherit;
  border: none;
  cursor: pointer;
}

img {
  max-width: 100%;
  height: auto;
}

开发和构建

1. 开发服务器

# 启动开发服务器
npm run dev

# 或使用自定义端口
npm run dev -- --port 3000

2. 构建生产版本

# 构建生产版本
npm run build

# 预览构建结果
npm run preview

3. 代码检查

# 代码格式化
npm run format

# 代码检查
npm run lint

# 类型检查
npm run type-check

部署选项

1. 静态站点部署

Vercel

npm i -g vercel
vercel

Netlify

npm i -g netlify-cli
netlify deploy

常见问题

1. 依赖安装失败

# 清理缓存
npm cache clean --force
rm -rf node_modules package-lock.json

# 使用国内镜像
npm config set registry https://registry.npmmirror.com

# 重新安装
npm install

2. TypeScript 错误

确保 tsconfig.json 配置正确:

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"]
}

3. 热重载不工作

检查 Vite 配置:

export default defineConfig({
  server: {
    hmr: true
  }
})

总结

通过本教程,你已经创建了一个完整的 Vue 3 项目,包含:

  • ✅ 现代化的开发工具链
  • ✅ TypeScript 支持
  • ✅ 组件化架构
  • ✅ 状态管理
  • ✅ 路由系统
  • ✅ 代码规范
  • ✅ 静态站点部署

这个项目结构可以作为你开发 Vue 3 应用的起点。根据实际需求,你可以继续添加更多功能,如国际化、权限管理、数据可视化等。

资源链接


发布时间: 2026年1月 技术标签: Vue.js, TypeScript, Vite, 前端开发

希望这篇教程对你有帮助!如果有问题欢迎在评论区交流。

Vue 3中watch如何高效监听多数据源、计算结果与数组变化?

多数据源监听

在Vue 3中,watch 允许我们同时监听多个响应式数据源,当其中任意一个数据源发生变化时,都会触发回调函数。这在需要同步处理多个数据变化的场景中非常实用,比如表单多字段联动验证、多条件组合筛选等。

基本用法

我们可以将多个数据源(ref、reactive对象或getter函数)放入一个数组中,作为watch的第一个参数。回调函数的第一个参数是所有数据源的新值组成的数组,第二个参数是旧值组成的数组。

import { ref, watch } from 'vue'

// 定义多个响应式数据
const username = ref('')
const password = ref('')
const rememberMe = ref(false)

// 同时监听三个数据源
watch(
  [username, password, rememberMe],
  ([newUsername, newPassword, newRememberMe], [oldUsername, oldPassword, oldRememberMe]) => {
    console.log(`用户名从 ${oldUsername} 变为 ${newUsername}`)
    console.log(`密码从 ${oldPassword} 变为 ${newPassword}`)
    console.log(`记住我状态从 ${oldRememberMe} 变为 ${newRememberMe}`)
    
    // 实际场景中可以在这里进行表单验证
    if (newUsername && newPassword) {
      console.log('表单字段已填写完整')
    }
  }
)

执行流程

flowchart LR
A[定义多个响应式数据] --> B[将数据源放入数组作为watch的监听源]
B --> C[任意数据源发生变化]
C --> D[触发回调函数]
D --> E[解构新值和旧值数组,处理业务逻辑]

Getter函数监听

当我们需要监听的目标不是直接的响应式数据,而是基于响应式数据计算出的值时,可以使用getter函数作为watch的监听源。这种方式让我们能够灵活定义监听的计算逻辑。

基本用法

Getter函数需要返回我们想要监听的计算结果,当这个结果发生变化时,watch就会触发回调函数。

import { reactive, watch } from 'vue'

// 定义响应式状态对象
const cart = reactive({
  items: [
    { id: 1, name: 'Vue 3 实战教程', price: 59, quantity: 1 },
    { id: 2, name: 'Vuex 从入门到精通', price: 39, quantity: 2 }
  ]
})

// 监听购物车的总金额
watch(
  // Getter函数:计算总金额
  () => cart.items.reduce((total, item) => total + item.price * item.quantity, 0),
  (newTotal, oldTotal) => {
    console.log(`购物车总金额从 ${oldTotal} 元变为 ${newTotal} 元`)
    
    // 实际场景中可以在这里更新结算按钮状态或显示优惠信息
    if (newTotal >= 100) {
      console.log('满足满减条件,可享受10元优惠')
    }
  }
)

// 修改购物车商品数量,触发watch
cart.items[0].quantity = 2

执行流程

flowchart LR
A[定义响应式对象] --> B[创建getter函数,返回计算后的值]
B --> C[将getter函数作为watch的监听源]
C --> D[计算值发生变化]
D --> E[触发回调函数]
E --> F[处理新的计算结果]

数组监听

在Vue 3中监听数组需要注意一些细节,因为Vue的响应式系统对数组的处理和普通对象有所不同。默认情况下,watch会监听数组的引用变化和数组方法(如pushpopsplice等)的调用,但不会监听数组元素的直接索引修改。

往期文章归档
免费好用的热门在线工具

监听数组整体变化

当使用数组方法修改数组时,watch会自动触发:

import { ref, watch } from 'vue'

const todoList = ref(['学习Vue 3', '编写项目实战'])

// 监听数组整体变化
watch(todoList, (newList, oldList) => {
  console.log('待办事项列表发生变化:', newList)
})

// 使用数组方法修改数组,触发watch
todoList.value.push('优化代码性能')
todoList.value.pop()

监听数组内部元素变化

如果需要监听数组元素的直接修改(如arr[0] = '新值'),需要开启deep选项:

import { ref, watch } from 'vue'

const numbers = ref([1, 2, 3, 4])

// 开启deep选项,监听数组内部元素变化
watch(numbers, (newNumbers, oldNumbers) => {
  console.log('数组元素发生变化:', newNumbers)
}, { deep: true })

// 直接修改数组元素,触发watch
numbers.value[0] = 100

执行流程

flowchart LR
A[定义响应式数组] --> B[使用watch监听数组,可选开启deep]
B --> C[修改数组]
C --> D{修改方式?}
D -->|数组方法| E[触发watch回调]
D -->|索引修改| F{是否开启deep?}
F -->|是| E
F -->|否| G[不触发watch回调]

课后Quiz

问题1

如何在Vue 3中同时监听多个响应式数据的变化?请写出代码示例。

答案解析: 可以将多个数据源放入数组中作为watch的第一个参数,回调函数会接收新值数组和旧值数组:

import { ref, watch } from 'vue'

const name = ref('')
const age = ref(0)

watch(
  [name, age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log(`姓名从 ${oldName} 变为 ${newName}`)
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

问题2

当需要监听响应式对象中多个属性的计算结果时,应该使用什么方式?请写出代码示例。

答案解析: 使用getter函数作为watch的监听源,在getter函数中计算需要监听的结果:

import { reactive, watch } from 'vue'

const product = reactive({
  stock: 100,
  sales: 30
})

// 监听剩余库存
watch(
  () => product.stock - product.sales,
  (newStock, oldStock) => {
    console.log(`剩余库存从 ${oldStock} 变为 ${newStock}`)
  }
)

问题3

为什么直接修改数组的索引元素时,watch默认不会触发?如何解决这个问题?

答案解析: Vue的响应式系统默认不会监听数组的索引修改,因为这在性能上是低效的。解决方法有两种:

  1. 开启deep选项,深度监听数组内部元素变化
  2. 使用Vue提供的数组方法(如pushsplice等)来修改数组

常见报错解决方案

报错1:watch source must be a ref, reactive object, getter function, or array of these

  • 原因watch的监听源类型不正确,不是Vue支持的响应式数据源类型。
  • 解决方法:确保监听源是ref、reactive对象、getter函数或这些类型的数组。例如,如果你想监听普通变量,需要先将其转换为ref:
// 错误用法:监听普通变量
let count = 0
watch(count, () => { /* ... */ })

// 正确用法:转换为ref
const count = ref(0)
watch(count, () => { /* ... */ })

报错2:数组元素修改后watch不触发

  • 原因:直接修改数组索引元素,Vue默认不监听这种变化。
  • 解决方法:开启deep选项,或者使用数组方法修改数组:
// 方法1:开启deep选项
watch(numbers, () => { /* ... */ }, { deep: true })

// 方法2:使用数组方法
numbers.value.splice(0, 1, 100)

报错3:Cannot read property 'value' of undefined

  • 原因:在getter函数或回调函数中访问了未定义的响应式属性。
  • 解决方法:确保所有访问的属性都已正确定义,或者添加可选链操作符:
// 错误用法:访问未定义的属性
watch(() => user.address.city, () => { /* ... */ })

// 正确用法:添加可选链
watch(() => user?.address?.city, () => { /* ... */ })

参考链接

参考链接:vuejs.org/guide/essen…

1. Vue3必学:defineAsyncComponent四大配置全攻略,组件懒加载秒上手

在 Vue 3 中,defineAsyncComponent 是实现组件懒加载的核心 API,它能帮助我们按需加载组件、优化应用首屏加载速度,尤其适用于大型应用中组件数量多、体积大的场景。本文将从基础用法入手,详细拆解其 loading、error、delay、timeout 四大配置的功能与实践,帮你彻底掌握组件异步加载的精髓。

一、defineAsyncComponent 基础用法

组件懒加载的核心逻辑是“在需要时才加载组件代码”,而非应用初始化时一次性加载所有组件。Vue 3 提供了 defineAsyncComponent 方法封装异步组件,支持两种基础用法:简单语法和完整配置语法。

1. 简单语法(仅指定加载函数)

最简洁的用法是传入一个返回 Promise 的加载函数,该函数内部通过动态 import 加载组件。当组件被渲染时,会自动执行加载函数,加载完成后渲染组件。


// 引入 defineAsyncComponent
import { defineAsyncComponent } from 'vue'

// 定义异步组件(简单语法)
const AsyncDemo = defineAsyncComponent(() => 
  // 动态 import 加载组件,返回 Promise
  import('./components/AsyncDemo.vue')
)

// 在组件中正常使用
export default {
  components: {
    AsyncDemo
  }
}

注意:动态 import() 是 ES 语法,会返回一个 Promise 对象,Vue 内部会自动处理 Promise 的成功与失败状态。

2. 完整配置语法(支持加载/错误状态等配置)

当需要自定义加载状态、错误处理、加载延迟等场景时,可传入一个配置对象,这也是实际开发中更常用的方式。完整配置包含 loaderloadingComponenterrorComponentdelaytimeout 等属性,后续将逐一详解。


const AsyncDemo = defineAsyncComponent({
  // 加载函数(必选),同简单语法的加载函数
  loader: () => import('./components/AsyncDemo.vue'),
  // 加载中显示的组件
  loadingComponent: Loading,
  // 加载失败显示的组件
  errorComponent: Error,
  // 延迟显示加载组件的时间(毫秒)
  delay: 200,
  // 加载超时时间(毫秒)
  timeout: 3000,
  // 其他可选配置...
})

二、四大核心配置详解

下面针对完整配置中的四大核心属性(loading/error/delay/timeout),结合场景与实例逐一拆解,说明其作用、用法及注意事项。

1. loadingComponent:加载中状态组件

当异步组件正在加载时,Vue 会渲染 loadingComponent 指定的组件,用于提示用户“加载中”(如骨架屏、加载动画等)。

使用要点:

  • loadingComponent 需是一个已定义的 Vue 组件,可全局注册或局部引入。
  • 加载成功后,加载组件会自动被替换为目标异步组件。
  • 若加载时间极短(如小于 delay 配置的时间),加载组件可能不会显示,避免频繁切换导致的闪烁。

实例:


// 引入加载组件和错误组件
import Loading from './components/Loading.vue'
import Error from './components/Error.vue'

const AsyncDemo = defineAsyncComponent({
  loader: () => import('./components/AsyncDemo.vue'),
  // 加载中显示 Loading 组件
  loadingComponent: Loading,
  // 加载失败显示 Error 组件
  errorComponent: Error
})

Loading.vue 示例(简单加载动画):


<template>
  <div class="loading">
    <span>加载中...</span>
  </div>
</template>

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

2. errorComponent:加载失败状态组件

当异步组件加载失败(如网络错误、组件路径错误)时,Vue 会渲染 errorComponent 指定的组件,用于提示用户加载失败,并可提供重试等交互。

使用要点:

  • 加载失败的原因包括:网络中断、动态 import 路径错误、组件内部报错等。
  • Vue 会向 errorComponent 传递一个 error 属性,包含错误信息,可在组件中使用。
  • 可在错误组件中提供“重试加载”按钮,通过调用 error.retry() 重新触发加载函数;调用 error.fail() 标记加载失败(不再重试)。

实例(带重试功能的错误组件):


// Error.vue
<template>
  <div class="error">
    <p>组件加载失败:{{ error.message }}</p>
    <button @click="error.retry()">重试加载</button>
    <button @click="error.fail()">确认失败</button>
  </div>
</template>

<script setup>
// 接收 Vue 传递的 error 属性
const props = defineProps({
  error: {
    type: Object,
    required: true
  }
})
</script>

<style scoped>
.error {
  text-align: center;
  padding: 20px;
  color: #ff4d4f;
}
button {
  margin: 0 8px;
  padding: 4px 12px;
}
</style>

3. delay:延迟显示加载组件的时间

delay 用于设置“延迟多久后显示加载组件”,单位为毫秒(默认值为 200)。其核心作用是避免“加载组件闪烁”——若组件加载速度极快(如本地资源、缓存资源),加载组件仅显示几毫秒就消失,会给用户带来不良体验。

逻辑说明:

  • 若组件加载时间 ≤ delay:不显示加载组件,直接渲染目标组件。
  • 若组件加载时间 > delay:从加载开始经过 delay 毫秒后,显示加载组件,直到加载完成或失败。

实例:


const AsyncDemo = defineAsyncComponent({
  loader: () => import('./components/AsyncDemo.vue'),
  loadingComponent: Loading,
  errorComponent: Error,
  // 延迟 300 毫秒显示加载组件,避免快速加载时的闪烁
  delay: 300
})

4. timeout:加载超时时间

timeout 用于设置组件加载的超时时间,单位为毫秒(默认无超时限制)。若加载时间超过设定值,Vue 会判定为加载失败,渲染 errorComponent

使用要点:

  • 若网络环境较差,建议设置合理的超时时间(如 5000 毫秒),避免用户长时间等待无反馈。
  • 超时后触发的错误,可通过错误组件的 error.retry() 重试加载。
  • 若需禁用超时限制,可设置 timeout: Infinity

实例:


const AsyncDemo = defineAsyncComponent({
  loader: () => import('./components/AsyncDemo.vue'),
  loadingComponent: Loading,
  errorComponent: Error,
  delay: 300,
  // 加载超时时间设为 5 秒,超过则显示错误组件
  timeout: 5000
})

三、进阶用法与注意事项

1. 结合 Suspense 使用

Vue 3 的 Suspense 组件可与 defineAsyncComponent 配合,实现更灵活的异步组件控制。Suspense 提供 <template #default>(异步组件成功渲染内容)和 <template #fallback>(加载中内容),此时可省略 loadingComponent配置。


<template>
  <Suspense>
    <template #default>
      <AsyncDemo />
    </template>
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

<script setup>
import { defineAsyncComponent, Suspense } from 'vue'
const AsyncDemo = defineAsyncComponent(() => import('./components/AsyncDemo.vue'))
</script>

2. 动态控制加载函数

加载函数可根据条件动态返回不同的组件,实现“按需加载不同组件”的场景(如根据用户权限加载不同组件)。


const AsyncComponent = defineAsyncComponent(() => {
  // 根据权限动态加载组件
  if (userRole === 'admin') {
    return import('./components/AdminComponent.vue')
  } else {
    return import('./components/UserComponent.vue')
  }
})

3. 注意事项

  • 异步组件不能直接在 <script setup> 中通过 import 引入后立即使用,需通过defineAsyncComponent 封装。
  • 加载函数返回的 Promise 若被 reject,会触发加载失败,渲染错误组件。
  • 生产环境中,动态 import() 会被打包工具(如 Vite、Webpack)分割为独立的代码块,实现真正的按需加载。

四、总结

defineAsyncComponent 是 Vue 3 优化应用性能的重要工具,通过基础加载函数实现组件懒加载,再结合 loading、error、delay、timeout 四大配置,可覆盖绝大多数异步组件的使用场景:loading 组件提升用户等待体验,error 组件处理加载异常,delay 避免组件闪烁,timeout 防止无限等待。

在实际开发中,建议根据组件的体积、加载场景(如首屏、弹窗)合理配置参数,结合 Suspense 组件实现更灵活的异步控制,让应用加载更快、体验更优。

Vue2 的响应式原理

Vue2 的响应式原理

1.pngVue2 生命周期.png

Object.defineProperty

Object.defineProperty(obj, prop, descriptor)
  obj:必需。要定义或修改的属性的对象。
  prop:必需。要定义或修改的属性的属性名。
  descriptor:必需。要定义或修改的属性的描述符。

存取器 getter/setter

var obj = {}
var value = 'hello'

Object.defineProperty(obj, 'key', {
  // 当获取 obj['key'] 值的时候触发该函数。
  get: function() {
    return value
  },
  // 当设置 obj['key'] 值的时候触发该函数。
  set: function(newValue) {
    value = newValue
  }
})

注意:不要在 getter 中获取该属性的值,也不要在 setter 中设置该属性的值,否则会发生栈溢出。

实现数据代理和劫持

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
      // 数据劫持逻辑。
      let value = data[keys[i]]
      Object.defineProperty(data, keys[i], {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
          console.log(`获取了 data 的 ${keys[i]} 值。`)
          return value
        },
        set: function reactiveSetter(newValue) {
          console.log(`设置了 data 的 ${keys[i]} 值。`)
          value = newValue
        }
      })
    }
  }
}

实现数据代理和递归劫持

首先将数据递归劫持逻辑抽离到 observe 工厂函数中;然后新定义一个 Observer 类,为后续的工作做铺垫。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  new Observer(data)
}

// TODO:数组的观察逻辑暂时还没实现。
class Observer {
  constructor(data) {
    this.walk(data)
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  // 深度优先遍历。
  observe(value)

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log(`获取了 ${key} 值。`)
      return value
    },
    set: function reactiveSetter(newValue) {
      console.log(`设置了 ${key} 值。`)
      observe(newValue)
      value = newValue
    }
  })
}

实现 watch 监听

下面是 Vue 中的 watch 选项与 $watch 方法的实现原理。(暂时只实现了对 vm.$options.data 对象的第一层属性的监听。)

每个响应式属性都有一个属于自己的“筐”。在该响应式属性被其他回调函数依赖的时候,Vue 会通过这个“筐”的 depend 方法把这些回调函数添加到这个“筐”的 subs 属性中。在该响应式属性的值发生变化的时候,Vue 会通过这个“筐”的 notify 方法把这个“筐”的 subs 属性中的这些回调函数取出来全部执行。

在 Vue 中,“筐”被抽象成了 Dep 实例,回调函数被包装成了 Watcher 实例。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  new Observer(data)
}

// TODO:数组的观察逻辑暂时还没实现。
class Observer {
  constructor(data) {
    this.walk(data)
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  observe(value)

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      observe(newValue)
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.run()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
class Watcher {
  constructor(vm, exp, cb) {
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    this.cb.call(this.vm)
  }
}

在 Vue 中:1、被包装成 Watcher 实例的回调函数是被异步调用的;2、在该回调函数被异步调用之后和实际执行之前的这个过程中,如果触发该回调函数的响应式属性的值又被修改了,那么这些后续的修改操作将无法再次触发该回调函数的调用。所以 Watcher 类的实现原理,实际如下代码所示:

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
class Watcher {
  constructor(vm, exp, cb) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

仍然存在的问题

至此,基本实现了 Vue 中基于发布订阅的 watch 监听逻辑。但目前仍然存在以下问题:1、对象的新增属性没有被添加数据劫持逻辑;2、数组元素的数据劫持逻辑还存在问题。因此在对对象的新增属性和数组元素添加监听逻辑时也会存在问题。

实现 $set 方法

在 Vue 中,如果响应式属性的值是一个对象(包括数组),那么在该响应式属性上就会被挂载一个 _ ob _ 属性,该 _ ob _ 属性的值是一个 Observer 实例,该 Observer 实例的 dep 属性的值是一个 Dep 实例,该 Dep 实例是和 defineReactive 方法的闭包中的 Dep 实例不同的与该响应式属性绑定的另外一个“筐”。

当响应式属性的值是一个对象(包括数组)时,Vue 会把触发该响应式属性的 getter 的 watchers 额外收集一份在该响应式属性的 _ ob _ 属性的 dep 属性的 subs 属性中。这样开发者就可以通过代码命令式地去触发这个响应式属性的 watchers 了。

2.png

$set 方法的实现思路基本如下:

1、在创建 Observer 对象的实例去观察响应式属性时,同时也创建一个 Dep 对象的实例。先将该 Dep 对象的实例挂载到该 Observer 对象的实例上,然后把该 Observer 对象的实例挂载到它自己观察的响应式属性上。

2、当响应式属性的 getter 被触发时,把与该响应式属性绑定的“筐”的 depend 方法调用一遍。响应式属性的值为对象或数组时,有两个筐;响应式属性的值不为对象和数组时,有一个筐。

3、当用户调用 $set 方法时,如果 target 为对象,则 Vue 先调用 defineReactive 方法把设置的属性也定义为响应式,然后调用 target._ ob _.dep.notify 方法触发 target 的 watchers。(target 为数组的情况暂时未实现。)

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  // TODO:暂时只实现了 target 为对象的情况,target 为数组的情况还未实现。
  $set(target, key, value) {
    defineReactive(target, key, value)
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

// TODO:数组的观察逻辑暂时还没实现。
class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    this.walk(data)
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.run()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
class Watcher {
  constructor(vm, exp, cb) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

实现数组方法的重写

Vue 对数组的处理思路基本如下:

1、对数组本身不使用 Object.defineProperty 方法进行数据劫持,对数组元素依次使用 observe 方法进行数据观察。因此,数组元素不具有响应性,数组元素的属性仍然具有响应性

2、对数组的 push、pop、shift、unshift、splice、sort、reverse 实例方法进行重写。在这些重写的实例方法中,Vue 先调用数组的原始同名实例方法,然后再调用 this._ ob _.dep.notify 方法去触发该数组的 watchers。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arratProto = Array.prototype
      observe(value)
      arratProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.run()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
class Watcher {
  constructor(vm, exp, cb) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.get()
  }

  get() {
    Dep.target = this
    this.vm[this.exp]
    Dep.target = null
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

实现 computed 计算属性

Vue 中 computed 计算属性的特性:

1、计算属性不存在于 data 选项中,因此计算属性需要单独进行初始化。

2、计算属性的值是一个函数运行之后的返回值。

3、计算属性的值“只能取,不能存”,即计算属性的 setter 无效。

4、计算属性所依赖的响应式属性的值,一旦发生变化,便会引起该计算属性的值,一同发生变化。

5、计算属性是惰性的:计算属性所依赖的响应式属性的值发生变化时,不会立即引起该计算属性的值一同发生变化,而是等到该计算属性的值被获取时才会使得 Vue 对它的值进行重新计算。

6、计算属性是缓存的:如果计算属性所依赖的响应式属性的值没有发生变化,即使多次获取该计算属性的值,Vue 也不会对该计算属性的值进行重新计算。

注:对于计算属性 A 依赖计算属性 B 的情况,下面的代码好像已经实现了,但还需进一步的测试验证。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initComputed()
    this.initWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arratProto = Array.prototype
      observe(value)
      arratProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

Vue 模板响应式更新的原理

Vue 对模板的响应式更新,就是如同代码中的 initRenderWatch 方法这样做的。在 Vue 中,响应式更新模板的 watcher 被称为 render watcher,该 watcher 的求值函数比代码中的 initRenderWatch 方法中的 watcher 的求值函数复杂的多。

class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  initRenderWatch() {
    new Watcher(
      this,
      () => {
        document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
      },
      () => {}
    )
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arrayProto = Array.prototype
      observe(value)
      arrayProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

Vue 对模板响应式更新的处理思路基本如下:

1、**模板编译:**如果传入了 template 或者 DOM,那么在 Vue 实例化的过程中,Vue 首先会把模板字符串转化成渲染函数(vm.$options.render)。

2、**虚拟 DOM:**Vue 借助这个渲染函数去响应式更新模板的时候,如果 Vue 直接去操作 DOM,那么会极大的消耗浏览器的性能。于是 Vue 引入 Virtual-DOM (虚拟 DOM),借助它来实现对 DOM 的按需更新。

实现模板编译

如果传入了 template 或者 DOM,那么在 Vue 实例化的过程中,Vue 首先会把模板字符串转化成渲染函数(vm.$options.render),这个过程就是 Vue 的模板编译

Vue 模板编译的整体逻辑主要分为三个步骤:1、解析器:模板字符串转换成 AST。2、**优化器:**对 AST 进行静态节点标记。主要是为了优化渲染性能。(这里不做介绍)3、**代码生成器:**将 AST 转换成 render 函数。

5.png

AST

AST,即抽象语法树,是源代码语法结构的抽象表示。JS AST 在线生成

Vue 中 AST 的代码示例如下:

{
  children: [{…}], // 叶子节点没有 children 属性。
  parent: {}, // 根节点的 parent 属性的值为 undefined。
  tag: "div", // 元素节点的专属属性。
  type: 1, // 1:元素节点。2:带变量的文本节点。3:纯文本节点。
  expression:'"姓名:" + _s(name)', // 文本节点的专属属性。如果 type 值是 3,则 expression 值为 ''。
  text:'姓名:{{name}}' // 文本节点的专属属性。text 值为文本节点编译前的字符串。
}

解析器(parser)

源代码被解析成 AST 的过程一般包含两个步骤:词法分析和语法分析。

6.png

Vue 中的解析器对模板字符串进行解析时,是每产生一个 token 便会立即对该 token 进行处理,即词法分析和语法分析同时进行,或者说没有词法分析只有语法分析。

下面以最单纯的 HTML 模板为例,阐述 Vue 中的解析器将模板字符串转换成 AST 的原理。(v-model、v-bind、v-if、v-for、@click 以及 HTML 中的单标签元素、DOM 属性、HTML 注释等情况都不予以考虑。)

**解析思路:**以 < 为标识符,代表开始标签或结束标签。使用栈结构去维护当前模板被解析到的层级。如果是开始标签,代表 AST 的层级 push 了一层;如果是结束标签,代表 AST 的层级 pop 了一层。

function parse(template) {
  let root = null
  let currentParent
  const stack = []

  // 跳过空白字符串。
  template = template.trim()

  while (template) {
    const ltIndex = template.indexOf('<')
    // ltIndex === -1 的情况不会出现。
    // ltIndex > 0 标签前面有文本节点。
    // ltIndex === 0 && template[ltIndex + 1] !== '/' 开始标签。
    // ltIndex === 0 && template[ltIndex + 1] === '/' 结束标签。
    if (ltIndex > 0) {
      // type:1-元素节点;2-带变量的文本节点;3-纯文本节点。
      const text = template.slice(0, ltIndex)
      const element = parseText(text)
      element.parent = currentParent
      if (!currentParent.children) {
        currentParent.children = []
      }
      currentParent.children.push(element)
      template = template.slice(ltIndex)
    } else if (ltIndex === 0 && template[ltIndex + 1] !== '/') {
      const gtIndex = template.indexOf('>')
      const element = {
        parent: currentParent, // 根节点的 parent 属性值为 undefined。
        tag: template.slice(ltIndex + 1, gtIndex), // 只考虑开始标签中没有任何属性的情况。
        type: 1
      }
      if (currentParent) {
        if (!currentParent.children) {
          currentParent.children = []
        }
        currentParent.children.push(element)
      } else {
        root = element
      }
      currentParent = element
      stack.push(element)
      template = template.slice(gtIndex + 1)
    } else if (ltIndex === 0 && template[ltIndex + 1] === '/') {
      const gtIndex = template.indexOf('>')
      // parse 函数执行完毕后 stack 值被设置为 []。
      stack.pop()
      // parse 函数执行完毕后 currentParent 值被设置为 undefined。
      currentParent = stack[stack.length - 1]
      // parse 函数执行完毕后 template 值被设置为 ''。
      template = template.slice(gtIndex + 1)
    }
  }

  return root
}

// 以 {{ 和 }} 为标识符,把 text 中的 '姓名:{{name}}' 转换成 '"姓名:" + _s(name)'。
function parseText(text) {
  const originText = text
  const tokens = []
  // type:2-带变量的文本节点;3-纯文本节点。
  let type = 3

  while (text) {
    const start = text.indexOf('{{')
    const end = text.indexOf('}}')
    if (start !== -1) {
      type = 2
      if (start > 0) {
        tokens.push(JSON.stringify(text.slice(0, start)))
      }
      const exp = text.slice(start + 2, end)
      tokens.push(`_s(${exp})`)
      text = text.slice(end + 2)
    } else {
      tokens.push(JSON.stringify(text))
      text = ''
    }
  }

  const element = {
    parent: null,
    type,
    expression: type === 2 ? tokens.join(' + ') : '',
    text: originText
  }

  return element
}

render

本小结生成的 render 函数的函数体字符串是这样的:

'with (this) { return _c("div", {}, [_c("p", {}, [_v("姓名:" + _s(name))])]) }'。

其中 _c 函数的第三个参数的空值是 [],不是 undefined。

代码生成器(codegenerator)

**生成思路:**1、遍历 AST,对 AST 中的每个节点进行处理。2、遇到元素节点生成 _c("标签名", 属性对象, 后代数组) 格式的字符串。(后代数组为空时为 [],而不是 undefined。)3、遇到纯文本节点生成 _v("文本字符串") 格式的字符串。4、遇到带变量的文本节点生成 _v(_s(变量名)) 格式的字符串。5、为了让字符串中的变量能够在 render 函数中被正常取值,在遍历完 AST 后, 将生成的字符串整体外包一层 with(this)。6、将经过 with(this) 包装处理后的字符串作为函数体,生成一个 render 函数,并将这个 render 函数挂载到 vm.$options 上。

// 将 AST 转换为 render 函数的函数体的字符串。
function generate(ast) {
  const code = genNode(ast)
  return {
    render: `with (this) { return ${code} }`
  }
}

// 转换节点。
function genNode(node) {
  if (node.type === 1) {
    return genElement(node)
  } else {
    return genText(node)
  }
}

// 转换元素节点。
function genElement(node) {
  const children = genChildren(node)
  // children 的空值是 '[]',不是 'undefined'。
  const code = `_c(${JSON.stringify(node.tag)}, {}, ${children})`
  return code
}

// 转换文本节点。
function genText(node) {
  if (node.type === 2) {
    return `_v(${node.expression})`
  } else if (node.type === 3) {
    return `_v(${JSON.stringify(node.text)})`
  }
}

// 转换元素节点的子节点列表中的子节点。
function genChildren(node) {
  // node.children 的空值为 undefined。
  if (node.children) {
    return '[' + node.children.map(child => genNode(child)).join(', ') + ']'
  } else {
    return '[]'
  }
}
class Vue {
  constructor(options) {
    this.$options = options
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderFunction()
    // this.initRenderWatch()
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  initRenderFunction() {
    let template
    if (this.$options.template) {
      template = this.$options.template
    } else {
      template = document.querySelector(this.$options.el).outerHtml
    }
    const ast = parse(template)
    const code = generate(ast).render
    // eslint-disable-next-line no-new-func
    this.$options.render = new Function(code)
  }

  initRenderWatch() {
    new Watcher(
      this,
      () => {
        document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
      },
      () => {}
    )
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arratProto = Array.prototype
      observe(value)
      arratProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

实现虚拟 DOM

什么是虚拟 DOM?

真实 DOM。

<ul>
  <li>1</li>
  <li>2</li>
</ul>

虚拟 DOM。

{
  tag: 'ul',
  attrs: {},
  children: [
    {
      tag: 'li',
      attrs: {},
      children: [
        {
          tag: null,
          attrs: {},
          children: [], // children 的空值为 []。
          text: '1'
        }
      ]
    },
    ......
  ]
}

虚拟 DOM 有什么用?

1、**性能优化:**当数据发生变化时,Vue 会先在内存中构建虚拟 DOM 树,然后通过比较新旧虚拟 DOM 树的差异,最终只更新必要的部分到真实 DOM 树中。虚拟 DOM 的使用减少了 Vue 操作真实 DOM 的次数,从而提高了 Vue 渲染页面的性能。

2、**跨平台能力:**虚拟 DOM 是一个与平台无关的抽象层,它的使用使得 Vue 可以在浏览器、移动端和服务端(例如服务端渲染时)等多个环境中运行。

由渲染函数生成虚拟 DOM

定义一个简单的 VNode 类,并实现渲染函数中的 _c、_v、_s 函数。然后运行 vm.$options.render.call(vm) 即可得到虚拟 DOM。

class VNode {
  constructor(tag, attrs, children, text) {
    this.tag = tag
    this.attrs = attrs
    this.children = children // children 的空值为 []。
    this.text = text
    this.elm = undefined
  }
}

class Vue {
  ......

  _c(tag, attrs, children) {
    return new VNode(tag, attrs, children)
  }

  _v(text) {
    return new VNode(undefind, undefind, undefind, text)
  }

  _s(value) {
    if (value === null || value === undefined) {
      return ''
    } else if (typeof value === 'object') {
      return JSON.stringify(value)
    } else {
      return String(value)
    }
  }

  ......
}

实现 Diff 和 Patch

在 Vue2 中,Diff 和 Patch 是虚拟 DOM 算法的两个关键步骤:1、**Diff(差异计算):**Diff 是指将新旧虚拟 DOM 树进行比较,进而找出它们之间的差异;2、**Patch(补丁应用):**Patch 是指将这些差异映射到真实 DOM 树上,使得真实 DOM 树与新的虚拟 DOM 树保持一致。

通过 Diff 和 Patch 的配合,Vue 可以凭借较少次数的真实 DOM 操作来实现高效地页面更新。

注意,Vue2 中的虚拟 DOM 算法是基于全量比较的,即每次页面更新都会对整个虚拟 DOM 树进行比较,这在大型应用中可能会导致性能问题。为了解决这个问题,Vue3 引入了基于静态分析的编译优化,使用了更高效的增量更新算法。

class Vue {
  constructor(options) {
    this.$options = options
    this.$el = undefined
    this._vnode = undefined
    this._watcher = undefined
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderFunction()
    this.$mount(options.el)
  }

  ......

  initRenderFunction() {
    let template
    if (this.$options.template) {
      template = this.$options.template
    } else {
      template = document.querySelector(this.$options.el).outerHtml
    }
    const ast = parse(template)
    const code = generate(ast).render
    // eslint-disable-next-line no-new-func
    this.$options.render = new Function(code)
  }

  ......

  $mount(el) {
    this.$el = document.querySelector(el)
    this._watcher = new Watcher(
      this,
      () => {
        this._update(this.$options.render.call(this))
      },
      () => {}
    )
  }

  ......

  _update(vnode) {
    if (this._vnode) {
      patch(this._vnode, vnode)
    } else {
      patch(this.$el, vnode)
    }
    this._vnode = vnode
  }

  ......
}

Vue 对虚拟 DOM 进行 patch 的逻辑基于 snabbdom 算法。patch 函数接受两个参数:旧的虚拟 DOM 和新的虚拟 DOM。(以下代码不考虑节点的属性和节点的 key。)

function patch(oldVnode, newVnode) {
  const el = oldVnode.elm
  const parent = el.parentNode

  const isRealElement = oldVnode.nodeType
  if (isRealElement) {
    parent.replaceChild(createElement(newVnode), oldVnode)
    return
  }

  if (!newVnode) {
    parent.removeChild(el)
  } else if (isChange(newVnode, oldVnode)) {
    newVnode.elm = createElement(newVnode)
    parent.replaceChild(newVnode.elm, el)
  } else if (!isChange(newVnode, oldVnode)) {
    // 渲染性能的提升就在这里。
    newVnode.elm = el
    const newLength = newVnode.children.length
    const oldLength = oldVnode.children.length
    for (let i = 0; i < newLength || i < oldLength; i++) {
      if (i >= oldLength) {
        el.appendChild(createElement(newVnode.children[i]))
      } else {
        patch(oldVnode.children[i], newVnode.children[i])
      }
    }
  }
}

// 由虚拟 DOM 创建真实 DOM。
function createElement(vnode) {
  // 文本节点。
  if (!vnode.tag) {
    const el = document.createTextNode(vnode.text)
    vnode.elm = el
    return el
  }

  // 元素节点。
  const el = document.createElement(vnode.tag)
  vnode.elm = el
  // 在父子真实 DOM 之间建立关系。
  vnode.children.map(createElement).forEach(subEl => {
    el.appendChild(subEl)
  })
  return el
}

// 判断新的虚拟 DOM 相对于旧的虚拟 DOM 是否发生了变化。
function isChange(newVnode, oldVnode) {
  return newVnode.tag !== oldVnode.tag || newVnode.text !== oldVnode.text
}

虚拟 DOM 代码总结

class Vue {
  constructor(options) {
    this.$options = options
    this.$el = undefined
    this._vnode = undefined
    this._watcher = undefined
    this.initData()
    this.initComputed()
    this.initWatch()
    this.initRenderFunction()
    this.$mount(options.el)
  }

  initData() {
    // TODO:this.$options.data 还可能是一个函数。
    const data = (this._data = this.$options.data)
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      // 数据代理逻辑。
      Object.defineProperty(this, keys[i], {
        enumerable: true,
        configurable: true,
        get: function proxyGetter() {
          return data[keys[i]]
        },
        set: function proxySetter(newValue) {
          data[keys[i]] = newValue
        }
      })
    }

    observe(data)
  }

  initComputed() {
    const computed = this.$options.computed
    if (computed) {
      const keys = Object.keys(computed)
      for (let i = 0; i < keys.length; i++) {
        const watcher = new Watcher(this, computed[keys[i]], function() {}, { lazy: true })
        Object.defineProperty(this, keys[i], {
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            if (watcher.dirty) {
              watcher.get()
              watcher.dirty = false
            }
            watcher.deps.forEach(dep => {
              dep.depend()
            })
            return watcher.value
          },
          set: function computedSetter() {
            console.warn('请不要给计算属性赋值!')
          }
        })
      }
    }
  }

  initWatch() {
    const watch = this.$options.watch
    if (watch) {
      const keys = Object.keys(watch)
      for (let i = 0; i < keys.length; i++) {
        this.$watch(keys[i], watch[keys[i]])
      }
    }
  }

  initRenderFunction() {
    let template
    if (this.$options.template) {
      template = this.$options.template
    } else {
      template = document.querySelector(this.$options.el).outerHtml
    }
    const ast = parse(template)
    const code = generate(ast).render
    // eslint-disable-next-line no-new-func
    this.$options.render = new Function(code)
  }

  $watch(exp, cb) {
    new Watcher(this, exp, cb)
  }

  $set(target, key, value) {
    const type = Object.prototype.toString.call(target)
    if (type !== '[object Object]' && type !== '[object Array]') {
      return
    }
    if (type === '[object Array]') {
      const arrayProto = Array.prototype
      observe(value)
      arrayProto.splice.call(target, key, 1, value)
    } else if (type === '[object Object]') {
      defineReactive(target, key, value)
    }
    // 触发依赖 target 的 watchers。
    target.__ob__.dep.notify()
  }

  $mount(el) {
    this.$el = document.querySelector(el)
    this._watcher = new Watcher(
      this,
      () => {
        this._update(this.$options.render.call(this))
      },
      () => {}
    )
  }

  _update(vnode) {
    if (this._vnode) {
      patch(this._vnode, vnode)
    } else {
      patch(this.$el, vnode)
    }
    this._vnode = vnode
  }

  _c(tag, attrs, children) {
    return new VNode(tag, attrs, children)
  }

  _v(text) {
    return new VNode(undefind, undefind, undefind, text)
  }

  _s(value) {
    if (value === null || value === undefined) {
      return ''
    } else if (typeof value === 'object') {
      return JSON.stringify(value)
    } else {
      return String(value)
    }
  }
}

// 观察 data 数据。
function observe(data) {
  const type = Object.prototype.toString.call(data)
  if (type !== '[object Object]' && type !== '[object Array]') {
    return
  }
  return new Observer(data)
}

class Observer {
  constructor(data) {
    this.dep = new Dep()
    Object.defineProperty(data, '__ob__', {
      enumerable: false,
      configurable: false,
      value: this,
      writable: true
    })
    const type = Object.prototype.toString.call(data)
    if (type === '[object Array]') {
      data.__proto__ = arrayMethods
      this.observeArray(data)
    } else if (type === '[object Object]') {
      this.walk(data)
    }
  }

  walk(data) {
    const keys = Object.keys(data)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(data, keys[i], data[keys[i]])
    }
  }

  observeArray(array) {
    for (let i = 0; i < array.length; i++) {
      observe(array[i])
    }
  }
}

// 定义 obj 对象的 key 属性的响应式。
// 这里利用了闭包,使得 value 变量 和 dep 常量一直没有被垃圾回收。
function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 深度优先遍历。
  const objKeyOb = observe(value)

  if (objKeyOb && obj[key] && obj[key].__ob__) {
    objKeyOb.dep = obj[key].__ob__.dep
  }

  // 数据劫持逻辑。
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 订阅逻辑。
      dep.depend()
      if (objKeyOb) {
        objKeyOb.dep.depend()
      }
      return value
    },
    set: function reactiveSetter(newValue) {
      if (value === newValue) {
        return
      }
      // 发布逻辑。
      dep.notify()
      const objKeyOb = observe(newValue)
      if (objKeyOb && obj[key] && obj[key].__ob__) {
        objKeyOb.dep = obj[key].__ob__.dep
      }
      value = newValue
    }
  })
}

// “筐”被抽象成了 Dep 实例。
class Dep {
  // 响应式属性当前要订阅的 watcher。
  static target = null

  constructor() {
    // 响应式属性已订阅的 watcher 列表。
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  addSub(watcher) {
    if (this.subs.indexOf(watcher) !== -1) {
      return
    }
    this.subs.push(watcher)
  }

  notify() {
    this.subs.forEach(watcher => {
      watcher.update()
    })
  }
}

// 回调函数被包装成了 Watcher 实例。
// TODO:暂时只实现了对 vm.$options.data 对象的第一层属性的监听。
const watcherQueue = []
let watcherId = 0
const targetStack = []
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.id = ++watcherId
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.deps = []
    this.value = null
    this.lazy = this.dirty = !!options.lazy
    if (!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    targetStack.push(this)
    if (typeof this.exp === 'function') {
      this.value = this.exp.call(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    targetStack.pop()
    if (targetStack.length > 0) {
      Dep.target = targetStack[targetStack.length - 1]
    } else {
      Dep.target = null
    }
  }

  addDep(dep) {
    if (this.deps.indexOf(dep) !== -1) {
      return
    }
    this.deps.push(dep)
    dep.addSub(this)
  }

  update() {
    if (this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if (watcherQueue.indexOf(this.id) !== -1) {
      // 类似于 JavaScript 中的防抖逻辑。
      return
    }
    watcherQueue.push(this.id)
    const index = watcherQueue.length - 1
    Promise.resolve().then(() => {
      // 更新 this.value(watcher.value) 的值。
      this.get()
      this.cb.call(this.vm)
      watcherQueue.splice(index, 1)
    })
  }
}

const mutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
mutationMethods.forEach(method => {
  arrayMethods[method] = function(...args) {
    if (method === 'push' || method === 'unshift' || method === 'splice') {
      this.__ob__.observeArray(args)
    }
    const result = arrayProto[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})

function parse(template) {
  let root = null
  let currentParent
  const stack = []

  // 跳过空白字符串。
  template = template.trim()

  while (template) {
    const ltIndex = template.indexOf('<')
    // ltIndex === -1 的情况不会出现。
    // ltIndex > 0 标签前面有文本节点。
    // ltIndex === 0 && template[ltIndex + 1] !== '/' 开始标签。
    // ltIndex === 0 && template[ltIndex + 1] === '/' 结束标签。
    if (ltIndex > 0) {
      // type:1-元素节点;2-带变量的文本节点;3-纯文本节点。
      const text = template.slice(0, ltIndex)
      const element = parseText(text)
      element.parent = currentParent
      if (!currentParent.children) {
        currentParent.children = []
      }
      currentParent.children.push(element)
      template = template.slice(ltIndex)
    } else if (ltIndex === 0 && template[ltIndex + 1] !== '/') {
      const gtIndex = template.indexOf('>')
      const element = {
        parent: currentParent, // 根节点的 parent 属性值为 undefined。
        tag: template.slice(ltIndex + 1, gtIndex), // 只考虑开始标签中没有任何属性的情况。
        type: 1
      }
      if (currentParent) {
        if (!currentParent.children) {
          currentParent.children = []
        }
        currentParent.children.push(element)
      } else {
        root = element
      }
      currentParent = element
      stack.push(element)
      template = template.slice(gtIndex + 1)
    } else if (ltIndex === 0 && template[ltIndex + 1] === '/') {
      const gtIndex = template.indexOf('>')
      // parse 函数执行完毕后 stack 值被设置为 []。
      stack.pop()
      // parse 函数执行完毕后 currentParent 值被设置为 undefined。
      currentParent = stack[stack.length - 1]
      // parse 函数执行完毕后 template 值被设置为 ''。
      template = template.slice(gtIndex + 1)
    }
  }

  return root
}

// 以 {{ 和 }} 为标识符,把 text 中的 '姓名:{{name}}' 转换成 '"姓名:" + _s(name)'。
function parseText(text) {
  const originText = text
  const tokens = []
  // type:2-带变量的文本节点;3-纯文本节点。
  let type = 3

  while (text) {
    const start = text.indexOf('{{')
    const end = text.indexOf('}}')
    if (start !== -1) {
      type = 2
      if (start > 0) {
        tokens.push(JSON.stringify(text.slice(0, start)))
      }
      const exp = text.slice(start + 2, end)
      tokens.push(`_s(${exp})`)
      text = text.slice(end + 2)
    } else {
      tokens.push(JSON.stringify(text))
      text = ''
    }
  }

  const element = {
    parent: null,
    type,
    expression: type === 2 ? tokens.join(' + ') : '',
    text: originText
  }

  return element
}

// 将 AST 转换为 render 函数的函数体的字符串。
function generate(ast) {
  const code = genNode(ast)
  return {
    render: `with (this) { return ${code} }`
  }
}

// 转换节点。
function genNode(node) {
  if (node.type === 1) {
    return genElement(node)
  } else {
    return genText(node)
  }
}

// 转换元素节点。
function genElement(node) {
  const children = genChildren(node)
  // children 的空值是 '[]',不是 'undefined'。
  const code = `_c(${JSON.stringify(node.tag)}, {}, ${children})`
  return code
}

// 转换文本节点。
function genText(node) {
  if (node.type === 2) {
    return `_v(${node.expression})`
  } else if (node.type === 3) {
    return `_v(${JSON.stringify(node.text)})`
  }
}

// 转换元素节点的子节点列表中的子节点。
function genChildren(node) {
  // node.children 的空值为 undefined。
  if (node.children) {
    return '[' + node.children.map(child => genNode(child)).join(', ') + ']'
  } else {
    return '[]'
  }
}

class VNode {
  constructor(tag, attrs, children, text) {
    this.tag = tag
    this.attrs = attrs
    this.children = children // children 的空值为 []。
    this.text = text
    this.elm = undefined
  }
}

function patch(oldVnode, newVnode) {
  const el = oldVnode.elm
  const parent = el.parentNode

  const isRealElement = oldVnode.nodeType
  if (isRealElement) {
    parent.replaceChild(createElement(newVnode), oldVnode)
    return
  }

  if (!newVnode) {
    parent.removeChild(el)
  } else if (isChange(newVnode, oldVnode)) {
    newVnode.elm = createElement(newVnode)
    parent.replaceChild(newVnode.elm, el)
  } else if (!isChange(newVnode, oldVnode)) {
    // 渲染性能的提升就在这里。
    newVnode.elm = el
    const newLength = newVnode.children.length
    const oldLength = oldVnode.children.length
    for (let i = 0; i < newLength || i < oldLength; i++) {
      if (i >= oldLength) {
        el.appendChild(createElement(newVnode.children[i]))
      } else {
        patch(oldVnode.children[i], newVnode.children[i])
      }
    }
  }
}

// 由虚拟 DOM 创建真实 DOM。
function createElement(vnode) {
  // 文本节点。
  if (!vnode.tag) {
    const el = document.createTextNode(vnode.text)
    vnode.elm = el
    return el
  }

  // 元素节点。
  const el = document.createElement(vnode.tag)
  vnode.elm = el
  // 在父子真实 DOM 之间建立关系。
  vnode.children.map(createElement).forEach(subEl => {
    el.appendChild(subEl)
  })
  return el
}

// 判断新的虚拟 DOM 相对于旧的虚拟 DOM 是否发生了变化。
function isChange(newVnode, oldVnode) {
  return newVnode.tag !== oldVnode.tag || newVnode.text !== oldVnode.text
}

🔥 Vue3 实现超丝滑打字机效果组件(可复用、高定制)

在前端开发中,打字机效果能极大提升页面的交互趣味性和视觉体验,比如 AI 聊天回复、个性化介绍页等场景都非常适用。本文将分享一个基于 Vue3 + Composition API 开发的高性能、高定制化打字机组件,支持打字/删除循环、光标闪烁、自定义样式等核心功能,且代码结构清晰、易于扩展。

🎯 组件特性

  • ✅ 自定义打字速度、删除速度、循环延迟
  • ✅ 支持光标显示/隐藏、闪烁效果开关
  • ✅ 打字完成后自动删除(可选)+ 循环播放
  • ✅ 完全自定义样式(字体、颜色、大小等)
  • ✅ 暴露控制方法(开始/暂停),支持手动干预
  • ✅ 无第三方依赖,纯原生 Vue3 实现
  • ✅ 性能优化:组件卸载自动清理定时器,避免内存泄漏 在这里插入图片描述

📝 完整组件代码

<template>
  <div class="typewriter-container" :style="fontsConStyle">
    <!-- 打字文本 - 逐字符渲染 -->
    <span class="typewriter-text">
      <span 
        v-for="(char, index) in displayedText" 
        :key="index" 
        class="character"
        :data-index="index"
      >
        {{ char }}
      </span>
    </span>
    
    <!-- 光标 - 精准控制显示/闪烁 -->
    <span 
      v-if="showCursor && isCursorVisible"
      class="cursor"
      :class="{ 'blink': showBlinkCursor }"
      aria-hidden="true"
    />
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch, watchEffect } from "vue";

// 组件 Props 定义(带完整校验)
const props = defineProps({
  // 要显示的文本内容
  text: {
    type: String,
    required: true,
    default: ""
  },
  // 打字速度(ms/字符)
  speed: {
    type: Number,
    default: 80,
    validator: (value) => value > 0
  },
  // 是否显示光标
  showCursor: {
    type: Boolean,
    default: true
  },
  // 光标是否闪烁
  blinkCursor: {
    type: Boolean,
    default: true
  },
  // 是否自动开始打字
  autoStart: {
    type: Boolean,
    default: true
  },
  // 是否循环播放
  loop: {
    type: Boolean,
    default: false
  },
  // 循环延迟(打字完成后等待时间)
  loopDelay: {
    type: Number,
    default: 1000,
    validator: (value) => value >= 0
  },
  // 容器样式(自定义字体、颜色等)
  fontsConStyle: {
    type: Object,
    default: () => ({
      fontSize: "2rem",
      fontFamily: "'Courier New', monospace",
      color: "#333",
      lineHeight: "1.5"
    })
  },
  // 是否开启删除效果
  deleteEffect: {
    type: Boolean,
    default: false
  },
  // 删除速度(ms/字符)
  deleteSpeed: {
    type: Number,
    default: 30,
    validator: (value) => value > 0
  },
  // 字符入场动画开关
  charAnimation: {
    type: Boolean,
    default: true
  }
});

// 响应式状态
const displayedText = ref("");    // 当前显示的文本
const currentIndex = ref(0);      // 当前字符索引
const isPlaying = ref(false);     // 是否正在播放
const isDeleting = ref(false);    // 是否正在删除
const isCursorVisible = ref(true);// 光标是否显示

// 定时器标识(用于清理)
let intervalId = null;
let timeoutId = null;
let cursorTimer = null;

// 计算属性:控制光标闪烁状态
const showBlinkCursor = computed(() => {
  return props.blinkCursor && 
         !isDeleting.value &&
         (displayedText.value.length === props.text.length || displayedText.value.length === 0);
});

// 工具函数:清除所有定时器
const clearAllTimers = () => {
  if (intervalId) clearInterval(intervalId);
  if (timeoutId) clearTimeout(timeoutId);
  if (cursorTimer) clearInterval(cursorTimer);
  intervalId = null;
  timeoutId = null;
  cursorTimer = null;
};

// 核心方法:开始打字
const startTyping = () => {
  // 重置状态
  clearAllTimers();
  isPlaying.value = true;
  isDeleting.value = false;
  currentIndex.value = 0;
  displayedText.value = "";
  isCursorVisible.value = true;
  
  // 打字逻辑
  intervalId = setInterval(() => {
    if (currentIndex.value < props.text.length) {
      displayedText.value = props.text.substring(0, currentIndex.value + 1);
      currentIndex.value++;
    } else {
      // 打字完成
      clearInterval(intervalId);
      isPlaying.value = false;
      
      // 循环逻辑
      if (props.loop) {
        if (props.deleteEffect) {
          timeoutId = setTimeout(startDeleting, props.loopDelay);
        } else {
          timeoutId = setTimeout(startTyping, props.loopDelay);
        }
      }
    }
  }, props.speed);
};

// 核心方法:开始删除
const startDeleting = () => {
  clearAllTimers();
  isDeleting.value = true;
  
  intervalId = setInterval(() => {
    if (currentIndex.value > 0) {
      currentIndex.value--;
      displayedText.value = props.text.substring(0, currentIndex.value);
    } else {
      // 删除完成
      clearInterval(intervalId);
      isDeleting.value = false;
      
      // 循环打字
      if (props.loop) {
        timeoutId = setTimeout(startTyping, props.loopDelay);
      } else {
        isCursorVisible.value = false; // 非循环模式下删除完成隐藏光标
      }
    }
  }, props.deleteSpeed);
};

// 监听文本变化:自动重启打字(适配动态文本场景)
watch(() => props.text, (newText) => {
  if (newText && props.autoStart) {
    startTyping();
  }
}, { immediate: true });

// 初始化光标闪烁(非闪烁模式下固定显示)
watchEffect(() => {
  if (props.showCursor && !cursorTimer) {
    cursorTimer = setInterval(() => {
      if (!showBlinkCursor.value) {
        isCursorVisible.value = true;
      } else {
        isCursorVisible.value = !isCursorVisible.value;
      }
    }, 500);
  }
});

// 组件挂载:自动开始打字
onMounted(() => {
  if (props.autoStart && props.text) {
    startTyping();
  }
});

// 组件卸载:清理所有定时器(避免内存泄漏)
onBeforeUnmount(() => {
  clearAllTimers();
});

// 暴露组件方法(供父组件调用)
defineExpose({
  start: startTyping,        // 手动开始
  pause: clearAllTimers,     // 暂停
  restart: () => {           // 重启
    clearAllTimers();
    startTyping();
  },
  isPlaying,                 // 当前播放状态
  isDeleting                 // 当前删除状态
});
</script>

<style scoped>
/* 容器样式 - 适配行内/块级显示 */
.typewriter-container {
  display: inline-flex;
  align-items: center;
  position: relative;
  font-size: inherit;
  line-height: inherit;
  font-family: inherit;
  white-space: pre-wrap; /* 支持换行符 */
  word-break: break-all; /* 防止长文本溢出 */
}

/* 文本容器 */
.typewriter-text {
  display: inline;
  font-size: inherit;
  line-height: inherit;
  font-family: inherit;
  color: inherit;
}

/* 字符样式 - 入场动画 */
.character {
  display: inline-block;
  animation: typeIn 0.1s ease-out forwards;
  opacity: 0;
}

/* 字符入场动画 */
@keyframes typeIn {
  0% {
    transform: translateY(5px);
    opacity: 0;
  }
  100% {
    transform: translateY(0);
    opacity: 1;
  }
}

/* 光标样式 - 垂直居中优化 */
.cursor {
  display: inline-block;
  width: 2px;
  height: 1.2em; /* 匹配字体高度 */
  background-color: currentColor;
  margin-left: 2px;
  vertical-align: middle;
  position: relative;
  top: 0;
  opacity: 1;
}

/* 光标闪烁动画 */
.cursor.blink {
  animation: blink 1s infinite step-end;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* 禁用字符动画的样式 */
:deep(.character) {
  animation: none !important;
  opacity: 1 !important;
}
</style>

🚀 核心优化点说明

1. 功能增强

  • 新增 charAnimation 属性:可开关字符入场动画,适配不同场景
  • 支持动态文本:监听 text 属性变化,文本更新自动重启打字
  • 优化光标逻辑:非闪烁模式下光标固定显示,避免闪烁干扰
  • 新增 restart 方法:支持手动重启打字效果
  • 文本换行支持:添加 white-space: pre-wrap,兼容带换行符的文本

2. 性能优化

  • 定时器统一管理:所有定时器集中清理,避免内存泄漏
  • 减少不必要渲染:通过 watchEffect 精准控制光标定时器创建/销毁
  • 样式优化:使用 currentColor 继承文本颜色,光标颜色与文本一致
  • 边界处理:添加 word-break: break-all,防止长文本溢出

3. 代码健壮性

  • 完善 Prop 校验:所有数值类型添加范围校验,避免非法值
  • 状态重置:每次开始打字前重置所有状态,避免多轮执行冲突
  • 注释完善:关键逻辑添加注释,提升代码可读性

📖 使用示例

基础使用

<template>
  <Typewriter 
    text="Hello Vue3! 这是一个超丝滑的打字机效果组件✨"
    speed="50"
  />
</template>

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

高级使用(循环+删除效果)

<template>
  <div>
    <Typewriter 
      ref="typewriterRef"
      text="Vue3 打字机组件 | 支持循环删除 | 自定义样式"
      :speed="60"
      :deleteSpeed="40"
      :loop="true"
      :deleteEffect="true"
      :loopDelay="1500"
      :fontsConStyle="{
        fontSize: '1.5rem',
        color: '#409eff',
        fontFamily: '微软雅黑'
      }"
    />
    <button @click="handleRestart">重启打字</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Typewriter from './components/Typewriter.vue';

const typewriterRef = ref();

// 手动重启打字
const handleRestart = () => {
  typewriterRef.value.restart();
};
</script>

🎨 样式自定义说明

属性 说明 默认值
fontSize 字体大小 2rem
fontFamily 字体 'Courier New', monospace
color 文本颜色 #333
lineHeight 行高 1.5

你可以通过 fontsConStyle 属性完全自定义组件样式,例如:

fontsConStyle: {
  fontSize: "18px",
  color: "#e6a23c",
  fontWeight: "bold",
  background: "#f5f7fa",
  padding: "10px 15px",
  borderRadius: "8px"
}

🛠️ 扩展方向

  1. 自定义字符动画:通过 Prop 传入动画类名,支持不同的字符入场效果
  2. 分段打字:支持数组形式的文本,分段打字+间隔
  3. 速度渐变:实现打字速度由快到慢/由慢到快的效果
  4. 暂停/继续:扩展暂停后继续打字的功能(记录当前索引)
  5. 结合 AI 流式响应:对接 AI 接口的流式返回,实时更新打字文本

📌 总结

这个打字机组件基于 Vue3 Composition API 开发,具备高复用性、高定制性的特点,核心优化点如下:

  1. 完善的定时器管理,避免内存泄漏
  2. 精准的状态控制,支持打字/删除/循环全流程
  3. 灵活的样式自定义,适配不同业务场景
  4. 暴露控制方法,支持父组件手动干预

组件可直接集成到 Vue3 项目中,适用于 AI 聊天、个人主页、产品介绍等需要打字机效果的场景,开箱即用!

npm install 核心流程

npm install 核心流程

作为前端开发,npm install 天天用,但这行简单的命令背后,npm 其实按固定流程把依赖安装的事安排得明明白白!不用深究底层原理,这篇文章用最直白的话讲清核心步骤,看完秒懂,轻松解决日常安装依赖的小问题~

第一步:先找配置,定好安装规则

执行 npm install 后,npm 第一步不下载,先查找项目和系统的配置文件(比如.npmrc),确定这些关键信息:

  • 依赖从哪下载(镜像源,比如国内常用的淘宝镜像)
  • 下载的包存在哪(缓存目录,避免重复下载)
  • 安装到哪个路径(默认项目根目录的node_modules

简单说,就是先“定规矩”,再开始干活~

第二步:核心分支判断!有没有package-lock.json?

这是整个安装流程的关键分叉口,npm 会先检查项目根目录有没有package-lock.json文件(依赖版本快照,记录上一次安装的精确依赖信息),分两种情况处理,核心都是为了保证版本一致、提升安装速度

情况1:有package-lock.json文件

  1. 先校验版本一致性 检查lock文件里的依赖版本,是否符合package.json里的版本范围(比如package.json^2.0.0,lock文件里2.1.0、2.2.0都算符合)。 符合:按lock文件的精确版本继续; 不符合:忽略旧lock文件,按package.json重新处理。

  2. 拉取包信息,构建并扁平化依赖树 按lock文件的信息,从镜像源获取依赖的元数据,接着构建依赖树(项目依赖的包是一级依赖,包又依赖的包是二级依赖,以此类推)。 关键操作扁平化处理:把能共享的子依赖提升到node_modules根目录,避免层级过深、重复安装,省空间又快!

  3. 缓存判断,安装依赖+更新lock文件

    • 有缓存:直接把缓存里的包解压到node_modules,不用重新下载;
    • 无缓存:从镜像源下载包→检查文件完整性(防止损坏)→存入缓存(下次用)→解压到node_modules; 最后更新lock文件,保证快照最新。

情况2:没有package-lock.json文件

没有lock文件就简单了,直接按package.json来,步骤少了版本校验,其余和上面一致: 拉取远程包信息→构建并扁平化依赖树→缓存判断(有则解压,无则下载+存缓存)→解压到node_modules→生成全新的lock文件,为下一次安装留好精确版本快照。

核心流程一句话总结

输入 npm install → 查找并加载配置文件(.npmrc 等)
→ 检查项目根目录是否有 package-lock.json?
  → 是 → 校验 lock 文件与 package.json 版本是否一致?
    → 一致 → 拉取远程包信息 → 构建依赖树(扁平化)→ 检查缓存?
      → 有 → 解压缓存到 node_modules → 更新 lock 文件
      → 无 → 下载依赖 → 校验完整性 → 存入缓存 → 解压到 node_modules → 更新 lock 文件
    → 不一致 → 按 package.json 重新拉取包信息 → 构建依赖树(扁平化)→ 缓存判断与安装 → 生成/更新 lock 文件
  → 否 → 拉取远程包信息(基于 package.json)→ 构建依赖树(扁平化)→ 缓存判断与安装 → 生成 lock 文件
→ 安装完成 

npm install 核心流程.png

日常开发

1. 缓存超有用,出问题清一在这里插入图片描述

下 缓存是npm提速的关键,第一次下载的包会存起来,后续安装直接复用。如果遇到安装报错、包损坏,执行npm cache clean --force强制清缓存,重新安装大概率解决。

2. package-lock.json别随便删/改

这个文件是团队协作、生产环境的“版本保障”,删了重新安装可能导致依赖版本变化,项目出问题。真要改版本,先改package.json,再重新npm install自动更新lock文件。

后台管理系统 Vite + elementPlus

弄这个项目缘由

本想学习下vue3 的后台管理项目, 借鉴了vbtn-admin github地址 线上地址, 颜值在线, 但是封装太骚了改代码太累。就自己额外处理了下。

做到简单易懂 开箱即用

这是一个前后端分离的 monorepo 示例项目,使用 pnpm workspace 管理前端(Vite + Tailwind + shadcn-ui)和后端(Node/Express 或自定义后端)。

github源码地址

当前项目公网地址

项目结构

.
├── 📁 backend/                    # 后端项目
├── 📁 frontend/                   # 前端项目
├── 📄 deploy.sh                   # 部署脚本
├── 📄 pnpm-lock.yaml              # pnpm 锁文件
└── 📄 pnpm-workspace.yaml         # pnpm 工作区配置

🚀 快速开始

1. 克隆仓库

git clone https://github.com/hangfengnice/vite-admin-ele.git
cd vite-admin-ele

2. 安装依赖

npm install -g pnpm
pnpm install

3. 启动项目

#同时启动前后端
pnpm run all

#启动前端
pnpm run dev
# 或者
pnpm --filter frontend dev

#启动后端
pnpm run back
# 或者
pnpm --filter backend dev

4. 一键部署阿里云

# 使用 chmod 添加执行权限(第一次)
chmod +x deploy.sh

# 部署
./deploy.sh

5. 开发配置

阿里云地址 获取服务器 付费

阿里云需要装

# 镜像 Ubuntu Server 24.04 LTS

# 安装 Node.js 24 LTS
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
sudo apt install -y nodejs

# 安装 PM2
sudo npm install -g pm2

# 安装 Nginx
sudo apt install -y nginx

# 安装 myswl
sudo apt install -y mysql-server

6.本地配置

# 电脑 mac

# node -v
# v24.12.0

# pnpm -v
# 10.28.1

# 本地额外装了mysql
❌