阅读视图

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

Vue3 组件生命周期详解

在上一篇文章中,我们深入探讨了组件从VNode到DOM的渲染过程。本篇文章将聚焦于组件的生命周期——这个贯穿组件从创建到销毁整个过程的时间轴。理解生命周期,不仅能帮助我们写出更可靠的代码,还能在合适的时机做合适的事情。

前言:为什么需要生命周期?

想象一下,我们正在搭建一座房子,一般需要经过以下几个阶段:

  1. 创建阶段:设计图纸、准备材料
  2. 挂载阶段:打地基、砌墙、安装门窗
  3. 更新阶段:翻新墙面、更换家具
  4. 卸载阶段:拆除房屋、清理场地

组件也是如此。在它的整个生命周期中,我们需要在不同的时间点执行不同的操作:

const Component = {
  // 创建时:初始化数据
  created() {
    this.fetchData();
  },
  
  // 挂载后:操作DOM
  mounted() {
    this.$el.focus();
  },

  // 更新时:页面刷新
  updated() {
    this.$el.scrollTop = this.$el.scrollHeight; 
  }
  
  // 卸载前:清理资源
  beforeUnmount() {
    clearInterval(this.timer);
  }
};

生命周期的完整图谱

生命周期全景图

下面这张图展示了 Vue3 组件的完整生命周期流程: 组件生命周期

各个阶段的核心任务

阶段 钩子 核心任务 注意事项
创建阶段 setup / beforeCreate / created 初始化数据、设置响应式 无法访问DOM
挂载阶段 beforeMount / mounted 渲染DOM、操作DOM 可以访问DOM
更新阶段 beforeUpdate / updated 响应数据变化 避免在更新钩子中修改数据
卸载阶段 beforeUnmount / unmounted 清理资源 清除定时器、取消订阅
缓存阶段 activated / deactivated 配合keep-alive 缓存组件的激活/失活

创建阶段:组件诞生

创建阶段的三个钩子

在 Vue3 中,创建阶段实际上由三个关键步骤组成:

  1. 最先执行:setup
  2. 然后执行:beforeCreate
  3. 最后执行:created
export default {
  // 1. 最先执行:setup
  setup() {
    console.log('1. setup 执行');
    
    const count = ref(0);
    
    // setup 中不能使用 this
    // console.log(this); // undefined
    
    return { count };
  },
  
  // 2. 然后执行:beforeCreate
  beforeCreate() {
    console.log('2. beforeCreate 执行');
    console.log('数据尚未初始化:', this.count); // undefined
    console.log('DOM 尚未创建:', this.$el);     // undefined
  },
  
  // 3. 最后执行:created
  created() {
    console.log('3. created 执行');
    console.log('数据已初始化:', this.count);    // 0
    console.log('DOM 尚未创建:', this.$el);      // undefined
  }
};

各钩子的数据访问能力

为了更好地理解每个阶段能做什么,我们用一个表格来展示:

钩子 访问 data 访问 props 访问 computed 访问 methods 访问 DOM 访问 $el
setup ❌ (尚未创建) ❌ (尚未创建)
beforeCreate
created

为什么需要三个创建钩子?

你可能有这样的疑问:为什么有了 setup 还要保留 beforeCreatecreated

这其实是为了兼容性和渐进迁移。在 Vue3 中,setup 实际上是 beforeCreatecreated 的替代品;但是 Vue3 为了向下兼容 Vue2 ,仍然保留了 beforeCreatecreated

Vue2 风格的创建钩子:

export default {
  beforeCreate() {
    // 初始化非响应式数据
    this.nonReactive = {};
  },
  created() {
    // 发起API请求
    this.fetchData();
  }
};

Vue3 组合式风格:

export default {
  setup() {
    // 初始化非响应式数据
    const nonReactive = {};
    
    // 发起API请求
    fetchData();
    
    return { nonReactive };
  }
};

挂载阶段:组件展现

挂载过程的内部机制

挂载阶段是组件第一次将虚拟 DOM 渲染为真实 DOM 的过程:

export default {
  beforeMount() {
    console.log('1. beforeMount 执行');
    console.log('此时已有编译好的模板,但尚未挂载到DOM');
    console.log('DOM 尚不存在:', this.$el);  // undefined
  },
  
  // render 函数在 beforeMount 之后、mounted 之前执行
  render() {
    console.log('2. render 执行');
    return h('div', 'Hello World');
  },
  
  mounted() {
    console.log('3. mounted 执行');
    console.log('DOM 已创建并挂载:', this.$el);     // <div>Hello World</div>
    console.log('可以安全地操作DOM了');
    
    // 可以访问DOM元素
    this.$el.querySelector('input')?.focus();
    
    // 可以集成第三方库
    new Chart(this.$el.querySelector('#chart'), {...});
  }
};

挂载阶段的时序图

挂载阶段的执行时序图

挂载阶段的典型应用场景

1. 操作DOM

this.$el.scrollTop = 100;

2. 获取元素尺寸

const width = this.$refs.box.offsetWidth;

3. 集成第三方库(需要DOM存在)

this.chart = new Chart(this.$refs.canvas, {
  type: 'line',
  data: this.chartData
});

4. 添加全局事件监听

window.addEventListener('resize', this.handleResize);

5. 启动定时器

this.timer = setInterval(this.refreshData, 5000);

6. 卸载前清理操作

window.removeEventListener('resize', this.handleResize);
clearInterval(this.timer);
this.chart?.destroy();

更新阶段:组件响应

更新阶段的时序图

更新阶段的时序图

更新阶段的注意事项

export default {
  beforeUpdate() {
    // ✅ 可以在DOM更新前访问旧状态
    const oldHeight = this.$refs.box.offsetHeight;
    console.log('旧高度:', oldHeight);
    
    // ❌ 不要在更新钩子中修改数据(可能造成死循环)
    // this.count++; // 会触发无限循环
    
    // ✅ 可以在这里手动操作DOM(不推荐)
    // 但要注意这些操作可能会被后续的更新覆盖
  },
  
  updated() {
    // ✅ 可以获取更新后的DOM信息
    const newHeight = this.$refs.box.offsetHeight;
    console.log('新高度:', newHeight);
    
    // ✅ 可以根据新状态调整其他非响应式内容
    if (newHeight > 500) {
      this.$refs.box.classList.add('overflow');
    }
    
    // ❌ 同样避免在这里修改数据
    // ❌ 避免直接操作DOM来"修复"样式,应该通过数据驱动
  }
};

卸载阶段:组件消亡

卸载的完整过程

export default {
  beforeUnmount() {
    console.log('1. beforeUnmount 执行');
    console.log('组件即将被卸载,但依然可以访问');
    console.log('DOM 仍然存在:', this.$el);
    
    // 清理工作
    this.cleanup();
  },
  
  unmounted() {
    console.log('2. unmounted 执行');
    console.log('组件已被卸载');
    console.log('DOM 已移除:', this.$el); // 被移除或置空
    
    // 最终清理
    this.finalCleanup();
  }
};

需要清理的典型资源

  1. 定时器:clearInterval(timer);
  2. 事件监听:window.removeEventListener('resize', handleResize);
  3. 观察者:observer.disconnect();
  4. 网络请求:controller.abort();
  5. 第三方库实例:chart.destroy();
  6. 手动订阅:subscription.unsubscribe();

缓存阶段:KeepAlive 的特殊生命周期

为什么需要缓存阶段?

当组件被 <KeepAlive> 包裹时,它的生命周期会发生变化: KeepAlive生命周期.png

activated 和 deactivated 的使用

const CacheComponent = {
  setup() {
    console.log('setup 执行'); // 只执行一次
    
    const count = ref(0);
    
    // 这些钩子会在组件被缓存时特殊处理
    onMounted(() => {
      console.log('mounted 执行'); // 只执行一次
    });
    
    onActivated(() => {
      console.log('activated 执行'); // 每次进入视图时执行
      
      // 恢复一些状态
      startAnimation();
      startPolling();
    });
    
    onDeactivated(() => {
      console.log('deactivated 执行'); // 每次离开视图时执行
      
      // 暂停一些操作,但不销毁
      stopAnimation();
      stopPolling();
    });
    
    onUnmounted(() => {
      console.log('unmounted 执行'); // 最终销毁时执行
    });
    
    return { count };
  }
};

父子组件的生命周期顺序

挂载阶段的执行顺序

当父子组件嵌套时,生命周期的执行顺序非常关键:

const Parent = {
  setup() { console.log('Parent setup'); },
  beforeCreate() { console.log('Parent beforeCreate'); },
  created() { console.log('Parent created'); },
  beforeMount() { console.log('Parent beforeMount'); },
  mounted() { console.log('Parent mounted'); },
  
  render() {
    return h('div', [
      h(Child)
    ]);
  }
};

const Child = {
  setup() { console.log('Child setup'); },
  beforeCreate() { console.log('Child beforeCreate'); },
  created() { console.log('Child created'); },
  beforeMount() { console.log('Child beforeMount'); },
  mounted() { console.log('Child mounted'); }
};

// 渲染输出顺序:
// 1. Parent setup
// 2. Parent beforeCreate
// 3. Parent created
// 4. Parent beforeMount
// 5. Child setup
// 6. Child beforeCreate
// 7. Child created
// 8. Child beforeMount
// 9. Child mounted
// 10. Parent mounted

更新阶段的执行顺序

当 Parent 的数据变化时:

  1. Parent beforeUpdate
  2. Child beforeUpdate
  3. Child updated
  4. Parent updated

卸载阶段的执行顺序

当父组件被移除时:

  1. Parent beforeUnmount
  2. Child beforeUnmount
  3. Child unmounted
  4. Parent unmounted

执行顺序的规律总结

阶段 执行顺序 原因
创建 父 → 子 父组件先创建,才能传递props给子组件
挂载 子 → 父 父组件需要等待所有子组件挂载完成
更新 父 → 子 父组件数据变化,传递给子组件
卸载 子 → 父 先拆除内部,再拆除外部

Vue3 中两种写法的生命周期对比

Vue3 同时支持两种写法:选项式 API组合式 API

选项式 API 生命周期

  • beforeCreate / created
  • beforeMount / mounted
  • beforeUpdate / updated
  • beforeUnmount / unmounted
  • activated / deactivated
  • errorCaptured
  • renderTracked / renderTriggered:新增的调试钩子

组合式 API 生命周期

  • setup
  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated
  • onErrorCaptured
  • onRenderTracked / onRenderTriggered

两种写法的对应关系表

选项式 API 组合式 API 执行时机
beforeCreate/created 直接在 setup 中编写代码 组件初始化前/组件初始化后
beforeMount/mounted onBeforeMount/onMounted DOM 挂载前/DOM 挂载后
beforeUpdate/updated onBeforeUpdate/onUpdated 数据更新、DOM 更新前/DOM 更新后
beforeUnmount/unmounted onBeforeUnmount/onUnmounted 组件卸载前/组件卸载后
activated/deactivated onActivated/onDeactivated keep-alive 组件激活/keep-alive 组件失活
errorCaptured onErrorCaptured 捕获后代组件错误

核心差异:setup 中的生命周期

setup 函数是最早的生命周期钩子,本身执行在 beforeCreatecreated 之前,属于 beforeCreatecreated 的替代品,因此在 setup 中编写的代码相当于在这两个钩子中执行:

export default {
  setup() {
    // 这些代码相当于在 beforeCreate 和 created 中执行
    
    console.log('相当于 beforeCreate/created');
    
    const count = ref(0);
    
    // 可以在这里执行初始化操作
    fetchData();
    
    // 注册生命周期钩子
    onMounted(() => {
      console.log('mounted');
    });
    
    return { count };
  }
};

<script setup> 的特殊性

<script setup> 的本质

<script setup> 是组合式 API 的语法糖,它在编译时会被转换为普通的 setup() 函数:

<!-- 源码写法 -->
<script setup>
import { ref, onMounted } from 'vue';

const count = ref(0);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('组件已挂载');
});
</script>
// 编译后
export default {
  setup() {
    const count = ref(0);
    
    function increment() {
      count.value++;
    }
    
    onMounted(() => {
      console.log('组件已挂载');
    });
    
    return { count, increment };
  }
};

<script setup> 中的生命周期变化

<script setup> 中,生命周期钩子的使用变得更加简洁:

  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated

<script setup> 的特殊特性

<script setup>
// 1. 自动返回顶层变量
const count = ref(0);           // 自动暴露给模板
function increment() {}          // 自动暴露给模板

// 2. 支持顶层 await
const data = await fetchData();  // 组件会等待异步操作完成

// 3. 使用 defineProps 和 defineEmits
const props = defineProps({
  title: String
});

const emit = defineEmits(['update']);

// 4. 使用 defineExpose 暴露方法
defineExpose({
  resetCount: () => count.value = 0
});

// 5. 生命周期钩子可以直接使用
onMounted(() => {
  console.log('mounted');
});
</script>

生命周期的最佳实践

各阶段适合做什么

阶段 适合的操作 不适合的操作
setup/created 初始化数据、设置响应式、发起API请求 操作DOM、访问$el
beforeMount 最后一次数据修改机会 操作DOM
mounted 操作DOM、集成第三方库、添加事件监听 同步修改数据(可能触发额外更新)
beforeUpdate 访问更新前的DOM 修改数据(可能死循环)
updated 执行依赖更新后DOM的操作 修改数据(可能死循环)
beforeUnmount 清理资源、移除事件监听 异步操作
unmounted 最终清理 访问已销毁的实例

生命周期调试技巧

使用生命周期追踪

<script setup>
import { onMounted, onUpdated, onRenderTracked, onRenderTriggered } from 'vue';

// 追踪渲染依赖
onRenderTracked((event) => {
  console.log('渲染依赖追踪:', event);
  // {
  //   key: 'count',      // 依赖的属性名
  //   target: {},        // 依赖的目标对象
  //   type: 'get',       // 操作类型
  // }
});

// 追踪渲染触发原因
onRenderTriggered((event) => {
  console.log('渲染触发原因:', event);
  // {
  //   key: 'count',
  //   target: {},
  //   type: 'set',
  //   oldValue: 0,
  //   newValue: 1
  // }
});

// 记录完整生命周期
onBeforeMount(() => console.log('🔄 beforeMount'));
onMounted(() => console.log('✅ mounted'));
onBeforeUpdate(() => console.log('🔄 beforeUpdate'));
onUpdated(() => console.log('✅ updated'));
onBeforeUnmount(() => console.log('🔄 beforeUnmount'));
onUnmounted(() => console.log('✅ unmounted'));
</script>

使用钩子组合

// 创建可复用的生命周期逻辑
function useLogger(componentName) {
  onBeforeMount(() => {
    console.log(`${componentName} 准备挂载`);
  });
  
  onMounted(() => {
    console.log(`${componentName} 已挂载`);
  });
  
  onBeforeUnmount(() => {
    console.log(`${componentName} 准备卸载`);
  });
  
  onUnmounted(() => {
    console.log(`${componentName} 已卸载`);
  });
}

// 在组件中使用
<script setup>
const props = defineProps({ name: String });
useLogger(props.name);
</script>

结语

理解生命周期,就像是掌握了组件从生到死的完整剧本。知道在每个阶段该做什么、不该做什么,才能写出既高效又可靠的Vue应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件渲染:从组件到DOM

在前面的文章中,我们深入探讨了虚拟DOM的创建和原生元素的挂载过程。但 Vue 真正的威力在于组件系统——它让我们能够将界面拆分成独立的、可复用的模块。本文将揭示 Vue3 如何将我们编写的组件,一步步渲染成真实的 DOM 节点。

前言:组件的魔法

当我们编写这样的Vue组件时:

<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <button @click="sayHello">打招呼</button>
  </div>
</template>

<script>
export default {
  props: ['user'],
  setup(props) {
    const sayHello = () => {
      alert(`你好,我是${props.user.name}`);
    };
    
    return { sayHello };
  }
}
</script>

Vue内部经历了一系列复杂而有序的过程: 组件渲染流程 本文将带你一步步拆解这个过程,理解组件从定义到 DOM 的完整旅程。

组件的VNode结构

组件VNode的特殊性

与原生元素不同,组件的 VNode 有其独特的结构:

const componentVNode = {
  type: UserCard,                  // 对象/函数:表示组件定义
  props: { user: { name: '张三' } }, // 传递给组件的props
  children: {                       // 插槽内容
    default: () => h('span', '默认插槽'),
    header: () => h('h1', '头部')
  },
  shapeFlag: ShapeFlags.STATEFUL_COMPONENT, // 标记为组件
  
  // 组件特有属性
  key: null,
  ref: null,
  component: null,                  // 组件实例(挂载后填充)
  suspense: null,
  scopeId: null,
  slotScopeIds: null
};

组件类型的多样性

Vue3中的组件类型更加丰富:

1. 有状态组件(最常用)

const StatefulComponent = {
  data() { return { count: 0 } },
  template: `<div>{{ count }}</div>`
};

2. 函数式组件(无状态)

const FunctionalComponent = (props) => {
  return h('div', props.message);
};

3. 异步组件

const AsyncComponent = defineAsyncComponent(() => 
  import('./MyComponent.vue')
);

4. 内置组件

const KeepAliveComponent = {
  type: KeepAlive,
  props: { include: 'a,b' }
};

shapeFlag 标志

const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,  // 2
  STATEFUL_COMPONENT = 1 << 2,     // 4
  COMPONENT = ShapeFlags.FUNCTIONAL_COMPONENT | ShapeFlags.STATEFUL_COMPONENT // 6
}

组件VNode的创建过程

import UserCard from './UserCard.vue';

// 这行代码背后
const vnode = h(UserCard, { user: userInfo }, {
  default: () => h('span', 'children')
});

// 实际执行的是
function createComponentVNode(component, props, children) {
  // 规范化props
  props = normalizeProps(props);
  
  // 提取key和ref
  const { key, ref } = props || {};
  
  // 处理插槽
  let slots = null;
  if (children) {
    slots = normalizeSlots(children);
  }
  
  // 创建VNode
  const vnode = {
    type: component,
    props: props || {},
    children: slots,
    key,
    ref,
    shapeFlag: isFunction(component) 
      ? ShapeFlags.FUNCTIONAL_COMPONENT 
      : ShapeFlags.STATEFUL_COMPONENT,
    
    // 组件实例(稍后填充)
    component: null,
    
    // 其他内部属性
    el: null,
    anchor: null,
    appContext: null
  };
  
  return vnode;
}

组件实例的设计

为什么需要组件实例?

组件实例是组件的"活"的体现,它包含了组件的所有状态和功能: 组件状态与功能

组件实例的结构

一个完整的组件实例包含以下核心部分:

class ComponentInstance {
  // 基础标识
  uid = ++uidCounter;           // 唯一ID
  type = null;                  // 组件定义对象
  parent = null;                 // 父组件实例
  appContext = null;             // 应用上下文
  
  // 状态相关
  props = null;                  // 解析后的props
  attrs = null;                  // 非prop属性
  slots = null;                  // 插槽
  emit = null;                   // 事件发射器
  
  // 响应式系统
  setupState = null;             // setup返回的状态
  data = null;                   // data选项
  computed = null;               // 计算属性
  refs = null;                   // 模板refs
  
  // 生命周期
  isMounted = false;              // 是否已挂载
  isUnmounted = false;            // 是否已卸载
  isDeactivated = false;          // 是否被keep-alive缓存
  
  // 渲染相关
  subTree = null;                // 渲染子树
  render = null;                  // 渲染函数
  proxy = null;                   // 渲染代理
  withProxy = null;               // 带with语句的代理
  
  // 依赖收集
  effects = null;                 // 组件级effects
  provides = null;                // 依赖注入
  components = null;              // 局部注册组件
  directives = null;              // 局部注册指令
  
  constructor(public vnode, parent) {
    this.type = vnode.type;
    this.parent = parent;
    this.appContext = parent ? parent.appContext : vnode.appContext;
    
    // 初始化空容器
    this.props = {};
    this.attrs = {};
    this.slots = {};
    this.setupState = {};
    
    // 创建代理
    this.proxy = new Proxy(this, PublicInstanceProxyHandlers);
  }
}

为什么需要代理?

组件实例的代理(proxy)是为了提供一个统一的访问接口:

// 实例代理处理函数
const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, props, data } = instance;
    
    // 优先从setupState获取
    if (key in setupState) {
      return setupState[key];
    }
    
    // 然后从props获取
    else if (key in props) {
      return props[key];
    }
    
    // 然后从data获取
    else if (data && key in data) {
      return data[key];
    }
    
    // 最后是内置属性
    else if (key === '$el') {
      return instance.subTree?.el;
    }
    // ... 其他内置属性
  },
  
  set({ _: instance }, key, value) {
    const { setupState, props, data } = instance;
    
    // 按照优先级设置
    if (key in setupState) {
      setupState[key] = value;
    } else if (key in props) {
      // props 是只读的
      console.warn(`Attempting to mutate prop "${key}"`);
      return false;
    } else if (data && key in data) {
      data[key] = value;
    }
    
    return true;
  }
};

这个代理让我们可以在模板中直接使用 count,而不需要写 $data.countsetupState.count

setup 函数的执行时机

setup 的执行时机图

setup的执行时机图

setup 参数解析

setup函数接收两个参数:

setup(props, context) {
  // props: 响应式的props对象
  console.log(props.title);  // 自动解包,无需.value
  
  // context: 一个对象,包含有用的方法
  const { 
    attrs,    // 非prop属性
    slots,    // 插槽
    emit,     // 事件发射
    expose    // 暴露公共方法
  } = context;
  
  // 返回对象,暴露给模板
  return {
    count: ref(0),
    increment() {
      this.count.value++;
    }
  };
}

setup 的内部实现

function setupComponent(instance) {
  const { type, props, children } = instance.vnode;
  const { setup } = type;
  
  if (setup) {
    // 创建setup上下文
    const setupContext = createSetupContext(instance);
    
    // 设置当前实例(用于getCurrentInstance)
    setCurrentInstance(instance);
    
    try {
      // 执行setup
      const setupResult = setup(
        props,           // 只读的props
        setupContext     // 上下文
      );
      
      // 处理返回值
      handleSetupResult(instance, setupResult);
    } finally {
      // 清理
      setCurrentInstance(null);
    }
  }
  
  // 完成组件初始化
  finishComponentSetup(instance);
}

function createSetupContext(instance) {
  return {
    // 非prop属性
    get attrs() {
      return instance.attrs;
    },
    
    // 插槽
    get slots() {
      return instance.slots;
    },
    
    // 事件发射
    emit: instance.emit,
    
    // 暴露公共方法
    expose: (exposed) => {
      instance.exposed = exposed;
    }
  };
}

function handleSetupResult(instance, setupResult) {
  if (setupResult && typeof setupResult === 'object') {
    // 返回对象:作为模板上下文
    instance.setupState = proxyRefs(setupResult);
  } else if (typeof setupResult === 'function') {
    // 返回函数:作为渲染函数
    instance.render = setupResult;
  }
}

render 函数的调用

从 setup 到 render

从setup到render到挂载流程图

render 函数的创建

Vue3 中,render 函数可以通过多种方式获得:

function finishComponentSetup(instance) {
  const Component = instance.type;
  
  // 1. 优先使用setup返回的render函数
  if (!instance.render) {
    if (Component.render) {
      // 2. 使用组件选项中的render
      instance.render = Component.render;
    } else if (Component.template) {
      // 3. 编译模板为render函数
      instance.render = compile(Component.template);
    }
  }
  
  // 对函数式组件的处理
  if (!Component.render && !Component.template) {
    // 如果组件本身是函数,当作render函数
    if (typeof Component === 'function') {
      instance.render = Component;
    }
  }
}

渲染代理的工作机制

渲染代理让模板可以轻松访问各种状态:

const PublicInstanceProxyHandlers = {
  get(target, key) {
    const instance = target._;
    const { setupState, props, data } = instance;
    
    // 1. 特殊处理以$开头的内置属性
    if (key[0] === '$') {
      switch (key) {
        case '$el': return instance.subTree?.el;
        case '$props': return props;
        case '$slots': return instance.slots;
        case '$parent': return instance.parent?.proxy;
        case '$root': return instance.root?.proxy;
        case '$emit': return instance.emit;
        case '$refs': return instance.refs;
      }
    }
    
    // 2. 普通状态查找
    if (setupState && key in setupState) {
      return setupState[key];
    }
    if (props && key in props) {
      return props[key];
    }
    if (data && key in data) {
      return data[key];
    }
    
    // 3. 没找到返回undefined
    return undefined;
  }
};

手写实现:mountComponent

mountComponent的整体流程

  1. 创建组件实例:const instance = createComponentInstance(vnode);
  2. 初始化并执行组件: setupComponent(instance);
  3. 设置渲染effect:setupRenderEffect(instance, container, anchor);
  4. 返回组件实例:return instance;

创建组件实例

let uidCounter = 0;

function createComponentInstance(vnode, parent = null) {
  const instance = {
    // 基础信息
    uid: ++uidCounter,
    vnode,
    type: vnode.type,
    parent,
    
    // 状态
    props: {},
    attrs: {},
    slots: {},
    setupState: {},
    
    // 渲染相关
    render: null,
    subTree: null,
    isMounted: false,
    
    // 生命周期
    isUnmounted: false,
    
    // 代理
    proxy: null,
    
    // emit函数
    emit: () => {},
    
    // 上下文
    appContext: parent ? parent.appContext : vnode.appContext,
    provides: parent ? Object.create(parent.provides) : {}
  };
  
  // 创建代理
  instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers);
  
  // 绑定emit
  instance.emit = createEmit(instance);
  
  return instance;
}

设置渲染 effect

function setupRenderEffect(instance, container, anchor) {
  // 创建组件更新函数
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // 首次挂载
      
      // 1. 执行render函数,生成子树VNode
      const subTree = instance.render.call(
        instance.proxy,    // this指向代理
        instance.proxy     // 第一个参数
      );
      
      // 2. 保存子树
      instance.subTree = subTree;
      
      // 3. 挂载子树
      patch(null, subTree, container, anchor);
      
      // 4. 保存根元素引用
      instance.vnode.el = subTree.el;
      
      // 5. 标记已挂载
      instance.isMounted = true;
      
      // 6. 触发mounted钩子
      invokeLifecycle(instance, 'mounted');
    } else {
      // 更新阶段
      
      // 1. 获取新子树
      const nextTree = instance.render.call(
        instance.proxy,
        instance.proxy
      );
      
      // 2. 保存旧子树
      const prevTree = instance.subTree;
      instance.subTree = nextTree;
      
      // 3. 执行更新
      patch(prevTree, nextTree, container, anchor);
      
      // 4. 更新元素引用
      instance.vnode.el = nextTree.el;
      
      // 5. 触发updated钩子
      invokeLifecycle(instance, 'updated');
    }
  };
  
  // 创建ReactiveEffect
  const effect = new ReactiveEffect(
    componentUpdateFn,
    // 调度器:异步更新
    () => queueJob(instance.update)
  );
  
  // 保存更新函数
  instance.update = effect.run.bind(effect);
  
  // 立即执行首次渲染
  instance.update();
}

完整的mountComponent实现

function mountComponent(vnode, container, anchor) {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode);
  
  // 2. 初始化 props 和 slots(如果有props 和 slots)
  initProps(instance, vnode.props);
  initSlots(instance, vnode.children);
  
  // 3. 初始化并执行组件
  setupComponent(instance);
  
  // 4. 创建渲染effect
  setupRenderEffect(instance, container, anchor);
  
  // 5. 返回组件实例
  return instance;
}

// 初始化props
function initProps(instance, rawProps) {
  const props = {};
  const attrs = {};
  
  const options = instance.type.props || {};
  
  // 根据组件定义的props进行过滤
  if (rawProps) {
    for (const key in rawProps) {
      if (options[key] !== undefined) {
        // 是定义的prop
        props[key] = rawProps[key];
      } else {
        // 是普通属性
        attrs[key] = rawProps[key];
      }
    }
  }
  
  instance.props = shallowReactive(props);
  instance.attrs = shallowReactive(attrs);
}

// 初始化slots
function initSlots(instance, children) {
  if (children) {
    instance.slots = normalizeSlots(children);
  }
}

// 规范化插槽
function normalizeSlots(children) {
  if (typeof children === 'function') {
    // 单个函数:默认插槽
    return { default: children };
  } else if (Array.isArray(children)) {
    // 数组:也是默认插槽
    return { default: () => children };
  } else if (typeof children === 'object') {
    // 对象:多个插槽
    const slots = {};
    for (const key in children) {
      const slot = children[key];
      slots[key] = (props) => normalizeSlot(slot, props);
    }
    return slots;
  }
  return {};
}

组件渲染的生命周期

完整的组件生命周期流程图

完整的组件生命周期流程图

生命周期钩子的触发时机

// 生命周期钩子的内部实现
const LifecycleHooks = {
  BEFORE_CREATE: 'bc',
  CREATED: 'c',
  BEFORE_MOUNT: 'bm',
  MOUNTED: 'm',
  BEFORE_UPDATE: 'bu',
  UPDATED: 'u',
  BEFORE_UNMOUNT: 'bum',
  UNMOUNTED: 'um'
};

function invokeLifecycle(instance, hook) {
  const handlers = instance.type[hook];
  if (handlers) {
    // 设置当前实例
    setCurrentInstance(instance);
    
    // 执行钩子函数
    if (Array.isArray(handlers)) {
      handlers.forEach(handler => handler.call(instance.proxy));
    } else {
      handlers.call(instance.proxy);
    }
    
    // 清理
    setCurrentInstance(null);
  }
}

一个完整示例的渲染过程

// 示例:父子组件
const Child = {
  props: ['message'],
  setup(props) {
    console.log('Child setup');
    return {};
  },
  render() {
    console.log('Child render');
    return h('div', '子组件: ' + this.message);
  }
};

const Parent = {
  setup() {
    console.log('Parent setup');
    const msg = ref('Hello');
    
    setTimeout(() => {
      msg.value = 'World';
    }, 1000);
    
    return { msg };
  },
  render() {
    console.log('Parent render');
    return h('div', [
      h('h1', '父组件'),
      h(Child, { message: this.msg })
    ]);
  }
};

// 挂载
const vnode = h(Parent);
render(vnode, document.getElementById('app'));

// 控制台输出顺序:
// Parent setup
// Child setup
// Parent render
// Child render
// (1秒后)
// Parent render
// Child render

结语

本文深入剖析了Vue3组件渲染的完整过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

渲染器核心:mount挂载过程

在上一篇文章中,我们深入探讨了虚拟 DOM 的设计与创建。现在,我们有了描述界面的 VNode,接下来要做的就是将它们渲染到真实的页面上。这个过程就是渲染器的职责。本文将深入剖析 Vue3 渲染器的挂载(mount)过程,看看虚拟 DOM 如何一步步变成真实 DOM。

前言:从虚拟 DOM 到真实 DOM

当我们编写这样的 Vue 组件时:

const App = {
  render() {
    return h('div', { class: 'container' }, [
      h('h1', 'Hello Vue3'),
      h('p', '这是渲染器的工作')
    ]);
  }
};

// 创建渲染器并挂载
createApp(App).mount('#app');

在这背后发生了一系列复杂而有序的操作: 渲染流图

本文将聚焦于首次渲染(mount)的过程。

渲染器的设计思想

为什么需要渲染器?

在深入了解代码之前,我们先思考一个问题:为什么 Vue 不直接将模板编译成 DOM 操作指令,而是要引入虚拟 DOM 和渲染器这一层?答案是:解耦跨平台

// 如果直接编译成 DOM 操作
function render() {
  const div = document.createElement('div');
  div.className = 'container';
  // ... 只能运行在浏览器
}

// 通过渲染器抽象
function render(vnode, container) {
  // 具体的创建操作由渲染器实现
  // 浏览器渲染器:document.createElement
  // 小程序渲染器:wx.createView
  // Native 渲染器:createNativeView
}

渲染器的三层架构

Vue3 的渲染器采用了清晰的分层设计: 渲染器三层架构 这种分层设计带来了极大的灵活性:

  • 渲染核心:实现 diff 算法、生命周期等通用逻辑
  • 平台操作层:提供统一的接口,由各平台实现
  • 目标平台:浏览器、小程序、Weex 等

渲染器的创建过程

创建渲染器工厂

渲染器本身是一个工厂函数,它接收平台操作作为参数,返回一个渲染器对象:

/**
 * 创建渲染器
 * @param {Object} options - 平台操作选项
 * @returns {Object} 渲染器对象
 */
function createRenderer(options) {
  // 解构平台操作
  const {
    createElement,  // 创建元素
    createText,     // 创建文本节点
    createComment,  // 创建注释节点
    insert,         // 插入节点
    setText,        // 设置文本内容
    setElementText, // 设置元素文本
    patchProp       // 更新属性
  } = options;

  // ... 渲染核心逻辑

  return {
    render,        // 渲染函数
    createApp      // 创建应用
  };
}

这种设计模式称为依赖注入,它将平台相关的操作从核心逻辑中抽离出来,使得渲染核心可以跨平台复用。

浏览器平台的实现

对于浏览器平台,Vue 提供了对应的 DOM 操作:

// 浏览器平台操作
const nodeOps = {
  // 创建元素:直接调用 document.createElement
  createElement(tag) {
    return document.createElement(tag);
  },
  
  // 创建文本节点
  createText(text) {
    return document.createTextNode(text);
  },
  
  // 创建注释节点
  createComment(text) {
    return document.createComment(text);
  },
  
  // 插入节点:使用 insertBefore 实现通用插入
  insert(child, parent, anchor = null) {
    parent.insertBefore(child, anchor);
  },
  
  // 设置元素文本内容
  setElementText(el, text) {
    el.textContent = text;
  },
  
  // 设置文本节点内容
  setText(node, text) {
    node.nodeValue = text;
  }
};

创建应用 API

渲染器还负责提供 createApp API,这是 Vue 应用的入口:

function createAppAPI(render) {
  return function createApp(rootComponent) {
    const app = {
      // 挂载方法
      mount(rootContainer) {
        // 1. 创建根 VNode
        const vnode = createVNode(rootComponent);
        
        // 2. 调用渲染器
        render(vnode, rootContainer);
        
        // 3. 返回组件实例
        return vnode.component;
      }
    };
    return app;
  };
}

首次渲染的完整流程

从 render 到 patch

当调用 app.mount('#app') 时,渲染器开始工作:

function render(vnode, container) {
  if (vnode) {
    // 存在新 VNode,进行 patch
    // container._vnode 存储上一次的 VNode,首次为 null
    patch(container._vnode || null, vnode, container);
  } else {
    // 没有新 VNode,卸载旧节点
    if (container._vnode) {
      unmount(container._vnode);
    }
  }
  // 保存当前 VNode
  container._vnode = vnode;
}

patch 的分发逻辑

patch 是整个渲染器的核心函数,它根据节点类型分发到不同的处理函数:

function patch(oldVNode, newVNode, container, anchor = null) {
  // 首次渲染,oldVNode 为 null
  if (oldVNode == null) {
    // 根据类型选择挂载方式
    const { type, shapeFlag } = newVNode;
    
    switch (type) {
      case Text:      // 文本节点
        mountText(newVNode, container, anchor);
        break;
      case Comment:   // 注释节点
        mountComment(newVNode, container, anchor);
        break;
      case Fragment:  // 片段
        mountFragment(newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 原生元素
          mountElement(newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 组件
          mountComponent(newVNode, container, anchor);
        }
    }
  }
}

下图展示了 patch 的分发流程: pacth 分发流程

为什么需要这么多类型?

不同类型的节点在 DOM 中的表现完全不同:

节点类型 真实 DOM 表示 特点
元素节点 HTMLElement 有标签名、属性、子节点
文本节点 TextNode 只有文本内容
注释节点 Comment 用于注释,不影响渲染
Fragment 无对应节点 多个根节点的容器

原生元素的挂载详解

mountElement 的四个步骤

挂载一个原生元素需要四个核心步骤:

  1. 创建 DOM 元素
  2. 保存 DOM 元素引用
  3. 处理子节点和属性
  4. 插入到容器
function mountElement(vnode, container, anchor) {
  const { type, props, shapeFlag } = vnode;
  
  // 步骤1:创建 DOM 元素
  const el = hostCreateElement(type);
  
  // 步骤2:保存 DOM 元素引用
  vnode.el = el;
  
  // 步骤3:处理子节点和属性
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 情况A:文本子节点
    hostSetElementText(el, vnode.children);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 情况B:数组子节点
    mountChildren(vnode.children, el);
  }
  
  if (props) {
    for (const key in props) {
      hostPatchProp(el, key, null, props[key]);
    }
  }
  
  // 步骤4:插入到容器
  hostInsert(el, container, anchor);
}

子节点的递归挂载

数组子节点的挂载是一个递归过程:

function mountChildren(children, container) {
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    // 递归调用 patch 挂载每个子节点
    // 注意:这里传入的 oldVNode 为 null
    patch(null, child, container);
  }
}

一个完整的挂载示例

让我们通过一个具体例子,观察挂载的全过程:

// 示例 VNode
const vnode = {
  type: 'div',
  props: {
    class: 'card',
    id: 'card-1',
    'data-index': 0
  },
  children: [
    {
      type: 'h2',
      props: { class: 'title' },
      children: '标题',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    },
    {
      type: 'p',
      props: { class: 'content' },
      children: '内容',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    }
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};

// 执行挂载
mountElement(vnode, document.getElementById('app'));

// 生成的真实 DOM:
// <div class="card" id="card-1" data-index="0">
//   <h2 class="title">标题</h2>
//   <p class="content">内容</p>
// </div>

属性的处理

属性的分类

在 Web 开发中,元素的属性分为以下几类:

  1. 普通属性:<div id="app" title="标题"></div>
  2. 类名:<div class="container active"></div>
  3. 样式:<div style="color: red; font-size: 16px"></div>
  4. 事件:<div onclick="handleClick"></div>
  5. DOM 属性:<div hidden disabled></div>

属性的设置方式

不同类型的属性,设置方式也不同:

类型 设置方式 示例
普通属性 setAttribute el.setAttribute('id', 'app')
类名 className el.className = 'container'
样式 style 对象 el.style.color = 'red'
事件 addEventListener el.addEventListener('click', handler)
DOM 属性 直接赋值 el.hidden = true

patchProp 的分发逻辑

Vue3 的 patchProp 函数需要处理以下这些情况:

  1. 处理事件:patchEvent(el, key, prevValue, nextValue);
  2. 处理 class:patchClass(el, nextValue);
  3. 处理 style:patchStyle(el, prevValue, nextValue);
  4. 处理 DOM 属性:patchDOMProp(el, key, nextValue);
  5. 处理普通属性:patchAttr(el, key, nextValue);

事件处理的优化

事件处理有一个重要的优化点:避免频繁添加/移除事件监听

不好的做法:每次更新都移除再添加

function patchEventBad(el, key, prevValue, nextValue) {
  const eventName = key.slice(2).toLowerCase();
  
  if (prevValue) {
    el.removeEventListener(eventName, prevValue);
  }
  if (nextValue) {
    el.addEventListener(eventName, nextValue);
  }
}

Vue3 的做法:使用 invoker 缓存

function patchEvent(el, rawKey, prevValue, nextValue) {
  const eventName = rawKey.slice(2).toLowerCase();
  
  // 使用 el._vei 存储事件调用器
  const invokers = el._vei || (el._vei = {});
  let invoker = invokers[eventName];
  
  if (nextValue && invoker) {
    // 有旧调用器:只更新值
    invoker.value = nextValue;
  } else if (nextValue && !invoker) {
    // 无旧调用器:创建新调用器
    invoker = createInvoker(nextValue);
    invokers[eventName] = invoker;
    el.addEventListener(eventName, invoker);
  } else if (!nextValue && invoker) {
    // 没有新值:移除监听
    el.removeEventListener(eventName, invoker);
    invokers[eventName] = null;
  }
}

function createInvoker(initialValue) {
  const invoker = (e) => {
    invoker.value(e);
  };
  invoker.value = initialValue;
  return invoker;
}

这种设计的优势在于:事件监听只添加一次,后续更新只改变回调函数: invoker 缓存

样式的合并处理

patchStyle 需要处理三种情况:

  1. 没有新样式:el.removeAttribute('style');
  2. 新样式是字符串:style.cssText = next;
  3. 新样式是对象:
// 设置新样式
for (const key in next) {
  setStyle(style, key, next[key]);
}

// 移除旧样式中不存在于新样式的属性
if (prev && typeof prev !== 'string') {
  for (const key in prev) {
    if (next[key] == null) {
      setStyle(style, key, '');
    }
  }
}

文本节点和注释节点

文本节点的处理

文本节点是最简单的节点类型:

// 文本节点的类型标识(Symbol 保证唯一性)
const Text = Symbol('Text');
function mountText(vnode, container, anchor) {
  // 1. 创建文本节点
  const textNode = document.createTextNode(vnode.children);
  
  // 2. 保存真实节点引用
  vnode.el = textNode;
  
  // 3. 插入到容器
  container.insertBefore(textNode, anchor);
}

文本节点在 DOM 中的表现:

<!-- 文本节点没有标签,只有内容 -->
Hello World

注释节点的处理

注释节点用于调试和特殊场景:

const Comment = Symbol('Comment');

function mountComment(vnode, container, anchor) {
  // 创建注释节点
  const commentNode = document.createComment(vnode.children);
  vnode.el = commentNode;
  container.insertBefore(commentNode, anchor);
}

注释节点在 DOM 中的表现:

<!-- 这是一个注释节点,不会显示在页面上 -->

Fragment 的处理

Fragment 是 Vue3 新增的特性,允许组件返回多个根节点:

const Fragment = Symbol('Fragment');

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    mountText(createTextVNode(children), container, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container);
  }
  
  // Fragment 本身没有真实 DOM
  // el 指向第一个子节点的 el
  vnode.el = children[0]?.el;
  // anchor 指向最后一个子节点的 el
  vnode.anchor = children[children.length - 1]?.el;
}

Fragment 的 DOM 表现:

<!-- 没有外层包裹元素 -->
<h1>标题</h1>
<p>段落1</p>
<p>段落2</p>

完整的渲染器实现

让我们将上述所有概念整合,实现一个可工作的简化版渲染器:

class Renderer {
  constructor(options) {
    // 注入平台操作
    this.createElement = options.createElement;
    this.createText = options.createText;
    this.createComment = options.createComment;
    this.insert = options.insert;
    this.setElementText = options.setElementText;
    this.patchProp = options.patchProp;
  }

  render(vnode, container) {
    if (vnode) {
      this.patch(null, vnode, container);
      container._vnode = vnode;
    } else if (container._vnode) {
      this.unmount(container._vnode);
    }
  }

  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    if (type === Text) {
      this.processText(oldVNode, newVNode, container, anchor);
    } else if (type === Comment) {
      this.processComment(oldVNode, newVNode, container, anchor);
    } else if (type === Fragment) {
      this.processFragment(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ELEMENT) {
      this.processElement(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.COMPONENT) {
      this.processComponent(oldVNode, newVNode, container, anchor);
    }
  }

  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountElement(newVNode, container, anchor);
    } else {
      this.patchElement(oldVNode, newVNode);
    }
  }

  mountElement(vnode, container, anchor) {
    // 1. 创建元素
    const el = this.createElement(vnode.type);
    vnode.el = el;
    
    // 2. 处理属性
    if (vnode.props) {
      for (const key in vnode.props) {
        this.patchProp(el, key, null, vnode.props[key]);
      }
    }
    
    // 3. 处理子节点
    const { shapeFlag, children } = vnode;
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 4. 插入容器
    this.insert(el, container, anchor);
  }

  mountChildren(children, container) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container);
    }
  }

  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.createText(newVNode.children);
      newVNode.el = textNode;
      this.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        el.nodeValue = newVNode.children;
      }
    }
  }

  processComment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const commentNode = this.createComment(newVNode.children);
      newVNode.el = commentNode;
      this.insert(commentNode, container, anchor);
    } else {
      newVNode.el = oldVNode.el;
    }
  }

  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const { shapeFlag, children } = newVNode;
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.patch(null, {
          type: Text,
          children
        }, container, anchor);
      } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.mountChildren(children, container);
      }
    } else {
      this.patchChildren(oldVNode, newVNode, container);
    }
  }

  unmount(vnode) {
    const parent = vnode.el.parentNode;
    if (parent) {
      parent.removeChild(vnode.el);
    }
  }
}

性能优化与最佳实践

避免不必要的挂载

在实际开发中,需要注意避免频繁的挂载和卸载:

// 不推荐:频繁切换导致反复挂载/卸载
function BadExample() {
  return show.value 
    ? h(HeavyComponent) 
    : null;
}

// 推荐:使用 keep-alive 缓存组件
function GoodExample() {
  return h(KeepAlive, null, [
    show.value ? h(HeavyComponent) : null
  ]);
}

合理使用 key

key 在 diff 算法中起着关键作用:

// 不推荐:使用索引作为 key
items.map((item, index) => 
  h('div', { key: index }, item.text)
);

// 推荐:使用唯一标识
items.map(item => 
  h('div', { key: item.id }, item.text)
);

为什么不推荐使用索引作为 key: 不推荐使用索引key

静态内容提升

对于不会变化的静态内容,应该避免重复创建 VNode:

// 编译器会自动优化
// 源码:
// <div>
//   <span>静态文本</span>
//   <span>{{ dynamic }}</span>
// </div>

// 编译后:
const _hoisted_1 = h('span', '静态文本');

function render(ctx) {
  return h('div', [
    _hoisted_1,  // 直接复用
    h('span', ctx.dynamic)
  ]);
}

事件委托优化

对于大量相似元素的交互,使用事件委托:

// 不推荐:每个元素独立事件
list.value.map(item => 
  h('button', {
    onClick: () => handleItem(item)
  }, item.name)
);

// 推荐:使用事件委托
function handleListClick(e) {
  const target = e.target;
  if (target.tagName === 'BUTTON') {
    const index = target.dataset.index;
    handleItem(list.value[index]);
  }
}

h('div', { onClick: handleListClick },
  list.value.map((item, index) => 
    h('button', { 
      'data-index': index 
    }, item.name)
  )
);

完整挂载流程图

下面是完整的挂载流程图: 完整挂载流程图

结语

本文主要介绍了 Vue3 渲染器的挂载全过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

虚拟DOM:VNode的设计与创建

经过前几篇文章的深入探索,我们完整地构建了 Vue3 的响应式系统。但响应式数据最终要渲染到页面上,这中间的桥梁就是虚拟DOM。今天,我们将深入 Vue3 虚拟 DOM 的设计与实现,看看它如何为高效的页面更新奠定基础。

前言:为什么需要虚拟DOM?

在传统的 jQuery 时代,我们直接操作真实 DOM:

$('#app').html('<div>Hello World</div>');

这种方式虽然直观,但有几个致命问题:

  • 性能开销大:DOM 操作是浏览器中最昂贵的操作之一,频繁的 DOM 操作会严重影响系统性能
  • 难以追踪:复杂应用的状态变化难以管理
  • 手动操作:开发者需要手动维护 DOM 与状态的一致性

虚拟 DOM 的出现解决了这些问题:

// 虚拟 DOM 描述
const vnode = {
  type: 'div',
  props: { class: 'container' },
  children: 'Hello World'
};

// 渲染器将虚拟 DOM 转换为真实 DOM
render(vnode, document.getElementById('app'));

虚拟 DOM 的本质:用 JavaScript 对象来描述真实 DOM 结构,通过比较新旧虚拟 DOM 的差异(diff),最小化地更新真实 DOM。

注:虚拟 DOM 相比真实 DOM 的优势在于:频繁操作 DOM 时,虚拟 DOM 可以先将操作收集,再一次性转成真实 DOM,渲染到页面上;而不需要每次操作都修改真实 DOM。

注:虚拟 DOM 不一定比真实 DOM 快,毕竟没有什么操作的性能能比 document.createElement('div') 更优了!

虚拟 DOM 的结构变化

Vue2 的 VNode 结构

// Vue2 的 VNode 结构(简化)
interface VNode {
  tag?: string;           // 标签名
  data?: VNodeData;       // 属性、事件等
  children?: VNode[];      // 子节点
  text?: string;          // 文本内容
  elm?: Node;             // 对应的真实 DOM
  key?: string | number;  // 唯一标识
  // ... 其他属性
}

Vue3 的 VNode 结构

// Vue3 的 VNode 结构(简化)
interface VNode {
  __v_isVNode: true;      // 标记为 VNode
  type: any;              // 类型:元素标签、组件、Fragment等
  props: any;             // 属性
  children: any;          // 子节点
  shapeFlag: number;      // 节点类型标志(位掩码)
  patchFlag: number;      // 优化标志(位掩码)
  dynamicProps: string[] | null;  // 动态属性列表
  staticCount: number;    // 静态节点计数
  
  key: any;               // 唯一标识
  ref: any;               // 引用
  el: HostNode | null;    // 真实 DOM 节点
  anchor: HostNode | null; // 锚点(Fragment 使用)
  
  // 组件相关
  component: any;         // 组件实例
  suspense: any;          // Suspense 相关
  ssContent: any;         // SSR 内容
  ssFallback: any;        // SSR 回退
  
  // 优化相关
  scopeId: string | null; // 作用域 ID
  slotScopeIds: string[] | null; // 插槽作用域 ID
}

Vue3 VNode 结构的主要变化

1. 更明确的类型标识

type: 'div' | 'span' | MyComponent | Fragment | Text | Comment | Static;

2. 使用 shapeFlag 位掩码标记类型

const enum ShapeFlags {
  ELEMENT = 1,              // 元素节点
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件
  STATEFUL_COMPONENT = 1 << 2,    // 状态组件
  TEXT_CHILDREN = 1 << 3,   // 文本子节点
  ARRAY_CHILDREN = 1 << 4,  // 数组子节点
  SLOTS_CHILDREN = 1 << 5,  // 插槽子节点
  TELEPORT = 1 << 6,        // Teleport
  SUSPENSE = 1 << 7,        // Suspense
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE = 1 << 9
}

3. 使用 patchFlag 标记动态内容

export const enum PatchFlags {
  TEXT = 1,                 // 动态文本内容
  CLASS = 1 << 1,           // 动态 class
  STYLE = 1 << 2,           // 动态 style
  PROPS = 1 << 3,           // 动态属性
  FULL_PROPS = 1 << 4,      // 全量比较
  HYDRATE_EVENTS = 1 << 5,  // 事件监听
  STABLE_FRAGMENT = 1 << 6, // 稳定 Fragment
  KEYED_FRAGMENT = 1 << 7,  // 带 key 的 Fragment
  UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment
  NEED_PATCH = 1 << 9,      // 需要非 props 比较
  DYNAMIC_SLOTS = 1 << 10,  // 动态插槽
  DEV_ROOT_FRAGMENT = 1 << 11, // 开发环境根 Fragment
  
  // 特殊标志
  HOISTED = -1,             // 静态提升节点
  BAIL = -2                 // 退出优化
}

VNode 的核心属性

1. type:节点类型

元素节点
const elementVNode = {
  type: 'div',
  props: { class: 'box' },
  children: 'Hello'
};
组件节点
const MyComponent = {
  setup() {
    return () => h('div', '组件内容');
  }
};

const componentVNode = {
  type: MyComponent,
  props: { title: '标题' }
};
文本节点
const textVNode = {
  type: Text,
  props: null,
  children: '文本内容'
};

Fragment(片段)
const fragmentVNode = {
  type: Fragment,
  children: [
    h('div', '子节点1'),
    h('div', '子节点2')
  ]
};
静态节点
const staticVNode = {
  type: 'div',
  props: { class: 'static' },
  children: '静态内容',
  patchFlag: PatchFlags.HOISTED // 标记为提升
};

2. props:属性

function createVNode(type, props, children) {
  const vnode = {
    type,
    props: props || {},
    children,
    // 提取关键属性
    key: props && props.key,
    ref: props && props.ref,
    // 清理 props 中的特殊属性
    ...normalizeProps(props)
  };
  
  return vnode;
}

function normalizeProps(props) {
  if (!props) return {};
  
  // 分离特殊属性
  const { key, ref, ...pureProps } = props;
  
  return {
    props: pureProps,
    key,
    ref
  };
}

3. children:子节点

文本子节点
const vnode1 = {
  type: 'div',
  children: '纯文本',
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
};
数组子节点
const vnode2 = {
  type: 'div',
  children: [
    h('span', '子节点1'),
    h('span', '子节点2')
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};
插槽子节点
const vnode3 = {
  type: MyComponent,
  children: {
    default: () => h('div', '默认插槽'),
    header: () => h('div', '头部插槽')
  },
  shapeFlag: ShapeFlags.COMPONENT | ShapeFlags.SLOTS_CHILDREN
};
空子节点
const vnode4 = {
  type: 'div',
  children: null,
  shapeFlag: ShapeFlags.ELEMENT
};

多种 VNode 类型

元素节点

function createElementVNode(tag, props, children) {
  const vnode = {
    type: tag,
    props,
    children,
    shapeFlag: ShapeFlags.ELEMENT
  };
  
  // 设置子节点类型标志
  if (typeof children === 'string') {
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN;
  } else if (Array.isArray(children)) {
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
  }
  
  return vnode;
}

组件节点

function createComponentVNode(component, props, children) {
  const vnode = {
    type: component,
    props,
    children,
    shapeFlag: ShapeFlags.STATEFUL_COMPONENT
  };
  
  // 处理插槽
  if (typeof children === 'object') {
    vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN;
  }
  
  // 组件实例(稍后填充)
  vnode.component = null;
  
  return vnode;
}

文本节点

const Text = Symbol('Text');

function createTextVNode(text) {
  return {
    type: Text,
    props: null,
    children: String(text),
    shapeFlag: ShapeFlags.TEXT_CHILDREN
  };
}

Fragment 节点

const Fragment = Symbol('Fragment');

function createFragmentVNode(children) {
  return {
    type: Fragment,
    props: null,
    children,
    shapeFlag: Array.isArray(children) 
      ? ShapeFlags.ARRAY_CHILDREN 
      : ShapeFlags.TEXT_CHILDREN
  };
}

静态节点

function createStaticVNode(content, count) {
  return {
    type: 'div',
    props: null,
    children: content,
    shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN,
    patchFlag: PatchFlags.HOISTED,
    staticCount: count
  };
}

静态提升(Static Hoisting)

静态提升的原理

我们先来看一段模版代码:

<div>
  <span>静态文本</span>
  <span>{{ dynamic }}</span>
</div>

没有静态提升下的渲染函数:

function render(ctx) {
  return h('div', [
    h('span', '静态文本'), // 每次渲染都创建
    h('span', ctx.dynamic)
  ]);
}

没有静态提升下,对于 <span>静态文本</span> 这段代码,每次渲染时都会创建。

静态提升下的渲染函数:

const _hoisted_1 = h('span', '静态文本'); // 提升到函数外

function render(ctx) {
  return h('div', [
    _hoisted_1, // 直接复用
    h('span', ctx.dynamic)
  ]);
}

静态提升下,对于 <span>静态文本</span> 这段代码,会将静态文本的 VNode 提升到函数外,在需要的时候直接复用即可!

实现静态提升

// 编译器生成的代码示例
import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"

// 静态节点提升
const _hoisted_1 = _createVNode("span", null, "静态文本", PatchFlags.HOISTED)
const _hoisted_2 = _createVNode("div", { class: "static-class" }, [
  _hoisted_1,
  _createVNode("span", null, "另一个静态节点", PatchFlags.HOISTED)
], PatchFlags.HOISTED)

export function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_2,  // 直接使用提升的节点
    _createVNode("span", null, _ctx.dynamic, PatchFlags.TEXT)
  ]))
}

Patch Flags 的作用

为什么要用 Patch Flags?

无 Patch Flags:需要全量比较:

function patch(oldVNode, newVNode) {
  // 比较所有属性
  if (oldVNode.props.class !== newVNode.props.class) {
    updateClass();
  }
  if (oldVNode.props.style !== newVNode.props.style) {
    updateStyle();
  }
  if (oldVNode.props.id !== newVNode.props.id) {
    updateId();
  }
  // ... 比较所有可能的属性
}

有 Patch Flags:只比较动态部分:

function patch(oldVNode, newVNode) {
  if (newVNode.patchFlag & PatchFlags.CLASS) {
    // 只有 class 是动态的
    updateClass();
  }
  if (newVNode.patchFlag & PatchFlags.STYLE) {
    // 只有 style 是动态的
    updateStyle();
  }
  // 只比较标记为动态的属性
}

Patch Flags 的实现

// 动态节点标记
function createVNodeWithFlags(type, props, children, flag) {
  const vnode = createVNode(type, props, children);
  vnode.patchFlag = flag;
  
  // 记录动态属性名
  if (flag & PatchFlags.PROPS) {
    vnode.dynamicProps = Object.keys(props).filter(
      key => !isStaticProperty(key)
    );
  }
  
  return vnode;
}

// 使用示例
const dynamicClassVNode = createVNodeWithFlags(
  'div',
  { class: dynamicClass }, // class 动态
  '内容',
  PatchFlags.CLASS
);

const dynamicTextVNode = createVNodeWithFlags(
  'span',
  null,
  dynamicText,
  PatchFlags.TEXT
);

const multipleDynamicsVNode = createVNodeWithFlags(
  'div',
  {
    class: dynamicClass,
    style: dynamicStyle,
    id: 'static-id' // 静态属性
  },
  '内容',
  PatchFlags.CLASS | PatchFlags.STYLE
);
// dynamicProps: ['class', 'style']

h 函数的实现

h 函数的基本实现

/**
 * h 函数:创建 VNode 的辅助函数
 * @param {string|object} type - 节点类型
 * @param {object} props - 属性
 * @param {array|string} children - 子节点
 * @returns {object} VNode
 */
function h(type, props, children) {
  // 处理参数重载
  const args = normalizeArgs(type, props, children);
  
  return createVNode(args.type, args.props, args.children);
}

function normalizeArgs(type, props, children) {
  // 如果没有 props
  if (arguments.length === 2) {
    if (isObject(props) && !isArray(props)) {
      // h('div', { class: 'box' })
      return { type, props, children: null };
    } else {
      // h('div', '文本内容')
      return { type, props: null, children: props };
    }
  }
  
  // 完整参数
  return { type, props, children };
}

function isObject(val) {
  return val !== null && typeof val === 'object';
}

function isArray(val) {
  return Array.isArray(val);
}

完整的 createVNode 实现

/**
 * 创建 VNode
 * @param {any} type - 节点类型
 * @param {object} props - 属性
 * @param {any} children - 子节点
 * @param {number} patchFlag - 优化标志
 * @param {object} dynamicProps - 动态属性列表
 * @returns {object} VNode
 */
function createVNode(type, props, children, patchFlag, dynamicProps) {
  // 处理 props
  props = normalizeProps(props);
  
  // 提取 key 和 ref
  const { key, ref } = props || {};
  
  // 计算 shapeFlag
  const shapeFlag = getShapeFlag(type, children);
  
  // 创建基础 VNode
  const vnode = {
    __v_isVNode: true,
    type,
    props: props || null,
    children,
    shapeFlag,
    
    // 优化相关
    patchFlag: patchFlag || 0,
    dynamicProps: dynamicProps || null,
    
    // 核心属性
    key,
    ref,
    
    // 运行时相关
    el: null,          // 真实 DOM
    anchor: null,      // 锚点(Fragment)
    component: null,   // 组件实例
    parent: null,      // 父 VNode
    
    // 其他
    scopeId: null,
    slotScopeIds: null
  };
  
  // 处理子节点
  normalizeChildren(vnode, children);
  
  // 如果有动态 children,记录
  if (shouldTrackDynamicChildren(vnode)) {
    vnode.dynamicChildren = [];
  }
  
  return vnode;
}

function normalizeProps(props) {
  if (!props) return null;
  
  // 移除 Vue 内部使用的特殊属性
  const { class: klass, style, ...rest } = props;
  
  // 合并 class
  if (klass) {
    rest.class = normalizeClass(klass);
  }
  
  // 合并 style
  if (style) {
    rest.style = normalizeStyle(style);
  }
  
  return rest;
}

function getShapeFlag(type, children) {
  let shapeFlag = 0;
  
  // 判断类型
  if (typeof type === 'string') {
    shapeFlag = ShapeFlags.ELEMENT;
  } else if (type === Text) {
    shapeFlag = ShapeFlags.TEXT_CHILDREN;
  } else if (type === Fragment) {
    shapeFlag = ShapeFlags.FRAGMENT;
  } else {
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT;
  }
  
  // 判断子节点类型
  if (children) {
    if (typeof children === 'string') {
      shapeFlag |= ShapeFlags.TEXT_CHILDREN;
    } else if (Array.isArray(children)) {
      shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
    } else if (isObject(children)) {
      shapeFlag |= ShapeFlags.SLOTS_CHILDREN;
    }
  }
  
  return shapeFlag;
}

function normalizeChildren(vnode, children) {
  if (!children) return;
  
  // 标准化文本子节点
  if (typeof children === 'string' || typeof children === 'number') {
    vnode.children = String(children);
  }
  
  // 标准化数组子节点
  if (Array.isArray(children)) {
    vnode.children = children.map(child => {
      if (typeof child === 'string') {
        return createTextVNode(child);
      }
      return child;
    });
  }
}

function shouldTrackDynamicChildren(vnode) {
  return vnode.patchFlag > 0 || 
         vnode.patchFlag === PatchFlags.HOISTED ||
         vnode.shapeFlag & ShapeFlags.COMPONENT;
}

// 工具函数:规范化 class
function normalizeClass(value) {
  if (typeof value === 'string') return value;
  if (Array.isArray(value)) {
    return value.map(normalizeClass).filter(Boolean).join(' ');
  }
  if (isObject(value)) {
    return Object.keys(value)
      .filter(key => value[key])
      .join(' ');
  }
  return '';
}

// 工具函数:规范化 style
function normalizeStyle(value) {
  if (typeof value === 'string') return value;
  if (Array.isArray(value)) {
    return Object.assign({}, ...value.map(normalizeStyle));
  }
  if (isObject(value)) return value;
  return {};
}

h 函数的完整版本

/**
 * 完整的 h 函数实现
 * 支持多种调用方式:
 * h('div')
 * h('div', { class: 'box' })
 * h('div', '文本')
 * h('div', {}, ['子节点1', '子节点2'])
 * h(Component, { props })
 */
function h(type, propsOrChildren, children) {
  const args = arguments.length;
  
  // h('div')
  if (args === 1) {
    return createVNode(type, null, null);
  }
  
  // h('div', {})
  if (args === 2) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 第二个参数是 props
      return createVNode(type, propsOrChildren, null);
    } else {
      // 第二个参数是 children
      return createVNode(type, null, propsOrChildren);
    }
  }
  
  // h('div', {}, '文本')
  // h('div', {}, [])
  // h('div', {}, h('span'))
  if (args === 3) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 有 props
      return createVNode(type, propsOrChildren, children);
    } else {
      // 无 props
      return createVNode(type, null, propsOrChildren);
    }
  }
  
  // 更多参数(不常见)
  const props = propsOrChildren;
  const _children = Array.from(arguments).slice(2);
  return createVNode(type, props, _children);
}

实战:使用 h 函数创建组件

// 定义组件
const MyComponent = {
  setup(props) {
    const count = ref(0);
    
    return () => h('div', { class: 'counter' }, [
      h('h3', props.title),
      h('p', `计数: ${count.value}`),
      h('button', {
        onClick: () => count.value++
      }, '增加')
    ]);
  }
};

// 创建 VNode
const vnode = h(MyComponent, {
  title: '我的计数器'
});

// 模拟渲染
function render(vnode, container) {
  if (typeof vnode.type === 'object') {
    // 组件
    const component = vnode.type;
    const subTree = component.setup(vnode.props);
    render(subTree, container);
  } else if (typeof vnode.type === 'string') {
    // 元素
    const el = document.createElement(vnode.type);
    
    // 设置属性
    if (vnode.props) {
      Object.entries(vnode.props).forEach(([key, value]) => {
        if (key.startsWith('on')) {
          el.addEventListener(key.slice(2).toLowerCase(), value);
        } else {
          el.setAttribute(key, value);
        }
      });
    }
    
    // 处理子节点
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children;
    } else if (Array.isArray(vnode.children)) {
      vnode.children.forEach(child => render(child, el));
    }
    
    container.appendChild(el);
    vnode.el = el;
  }
}

// 挂载
render(vnode, document.getElementById('app'));

结语

Vue3 的虚拟 DOM 在设计上进行了大量的优化,理解虚拟 DOM 的设计与实现,不仅帮助我们写出更高效的 Vue 应用,也为后续学习 diff 算法和渲染器打下坚实基础。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

响应式系统总结:从零到完整的闭环

经过前面几篇文章的深入探索,我们从最底层的 effect 开始,逐步构建起了 Vue3 响应式系统的完整图景。今天,我们将把所有组件串联起来,形成一个完整的闭环,并通过实战和性能分析,深入理解 Vue3 响应式系统的设计精髓。

前言:响应式系统的全景图

在开始整合之前,让我们先回顾一下整个响应式系统的架构: 响应式数据全景图 Vue3 中的响应式系统的核心思想可以概括为:在读取时收集依赖,在修改时触发更新。

串联所有组件:完整的响应式系统

1. 基础工具函数

// ============ 工具函数 ============
function isObject(value) {
    return value !== null && typeof value === 'object';
}

function isFunction(value) {
    return typeof value === 'function';
}

function isArray(value) {
    return Array.isArray(value);
}

function isRef(r) {
    return !!(r && r.__v_isRef === true);
}

function isReactive(value) {
    return !!(value && value.__v_isReactive === true);
}

function isArrayIndex(key) {
    if (typeof key !== 'string') return false;
    const keyAsNumber = Number(key);
    return Number.isInteger(keyAsNumber) &&
           keyAsNumber >= 0 &&
           keyAsNumber < Number.MAX_SAFE_INTEGER;
}

2. 依赖管理核心

// ============ 依赖管理 ============
const targetMap = new WeakMap();
let activeEffect = null;

// 操作类型枚举
const TrackOpTypes = {
    GET: 'get',
    HAS: 'has',
    ITERATE: 'iterate'
};

const TriggerOpTypes = {
    SET: 'set',
    ADD: 'add',
    DELETE: 'delete',
    CLEAR: 'clear'
};

// 特殊标识
const ITERATE_KEY = Symbol('iterate');

class ReactiveEffect {
    constructor(fn, scheduler = null) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.deps = [];
        this.active = true;
        this.runDepth = 0;
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        try {
            this.runDepth++;
            if (this.runDepth > 1000) {
                console.warn('检测到可能的无限循环');
                return;
            }
            
            activeEffect = this;
            return this.fn();
        } finally {
            this.runDepth--;
            activeEffect = null;
        }
    }
    
    stop() {
        if (this.active) {
            this.active = false;
            this.deps.forEach(dep => dep.delete(this));
            this.deps.length = 0;
        }
    }
}

function track(target, type, key) {
    if (!activeEffect) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
    }
    
    // 处理迭代操作
    let depKey = key;
    if (type === TrackOpTypes.ITERATE) {
        depKey = ITERATE_KEY;
    }
    
    let dep = depsMap.get(depKey);
    if (!dep) {
        depsMap.set(depKey, (dep = new Set()));
    }
    
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
    }
}

function trigger(target, type, key, newValue, oldValue) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    
    const effectsToRun = new Set();
    
    const add = (effectsToAdd) => {
        if (effectsToAdd) {
            effectsToAdd.forEach(effect => {
                if (effect !== activeEffect) {
                    effectsToRun.add(effect);
                }
            });
        }
    };
    
    // 处理普通 key
    if (key !== undefined) {
        add(depsMap.get(key));
    }
    
    // 处理数组特殊情况
    if (Array.isArray(target)) {
        if (key === 'length') {
            // length 变化需要触发所有索引 >= 新值的依赖
            const newLength = Number(newValue);
            depsMap.forEach((dep, key) => {
                if (isArrayIndex(key) && Number(key) >= newLength) {
                    add(dep);
                }
            });
        } else if (type === TriggerOpTypes.ADD && isArrayIndex(key)) {
            // 添加数组元素触发 length
            add(depsMap.get('length'));
        }
    } else {
        // 处理迭代操作
        if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
            add(depsMap.get(ITERATE_KEY));
        }
    }
    
    // 执行 effects
    effectsToRun.forEach(effect => {
        if (effect.scheduler) {
            effect.scheduler();
        } else {
            effect.run();
        }
    });
}

function effect(fn) {
    const _effect = new ReactiveEffect(fn);
    _effect.run();
    
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    return runner;
}

3. Reactive 实现

// ============ reactive ============
const reactiveHandlers = {
    get(target, key, receiver) {
        // 内部标记
        if (key === '__v_isReactive') return true;
        
        const value = Reflect.get(target, key, receiver);
        
        // 依赖收集
        track(target, TrackOpTypes.GET, key);
        
        // 嵌套响应式
        if (isObject(value)) {
            return reactive(value);
        }
        
        return value;
    },
    
    set(target, key, value, receiver) {
        const oldValue = target[key];
        const hadKey = target.hasOwnProperty(key);
        const oldLength = Array.isArray(target) ? target.length : undefined;
        
        const result = Reflect.set(target, key, value, receiver);
        
        if (!hadKey) {
            // 新增属性
            trigger(target, TriggerOpTypes.ADD, key, value);
        } else if (oldValue !== value) {
            // 修改属性
            trigger(target, TriggerOpTypes.SET, key, value, oldValue);
        }
        
        // 数组 length 隐式变化
        if (Array.isArray(target) && oldLength !== target.length) {
            trigger(target, TriggerOpTypes.SET, 'length', target.length);
        }
        
        return result;
    },
    
    deleteProperty(target, key) {
        const hadKey = target.hasOwnProperty(key);
        const oldValue = target[key];
        
        const result = Reflect.deleteProperty(target, key);
        
        if (result && hadKey) {
            trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue);
        }
        
        return result;
    },
    
    has(target, key) {
        track(target, TrackOpTypes.HAS, key);
        return Reflect.has(target, key);
    },
    
    ownKeys(target) {
        track(target, TrackOpTypes.ITERATE, ITERATE_KEY);
        return Reflect.ownKeys(target);
    }
};

function reactive(target) {
    if (!isObject(target)) return target;
    if (target.__v_isReactive) return target;
    
    return new Proxy(target, reactiveHandlers);
}

4. Ref 实现

// ============ ref ============
class RefImpl {
    constructor(value, isShallow = false) {
        this._rawValue = value;
        this._value = isShallow ? value : toReactive(value);
        this.__v_isRef = true;
        this._isShallow = isShallow;
    }
    
    get value() {
        track(this, TrackOpTypes.GET, 'value');
        return this._value;
    }
    
    set value(newValue) {
        if (newValue !== this._rawValue) {
            this._rawValue = newValue;
            this._value = this._isShallow ? newValue : toReactive(newValue);
            trigger(this, TriggerOpTypes.SET, 'value', newValue);
        }
    }
}

function toReactive(value) {
    return isObject(value) ? reactive(value) : value;
}

function ref(value) {
    if (isRef(value)) return value;
    return new RefImpl(value);
}

function shallowRef(value) {
    return new RefImpl(value, true);
}

function isRef(r) {
    return !!(r && r.__v_isRef === true);
}

function unref(ref) {
    return isRef(ref) ? ref.value : ref;
}

function toReactive(value) {
    return isObject(value) ? reactive(value) : value;
}

class ObjectRefImpl {
    constructor(_object, _key) {
        this._object = _object;
        this._key = _key;
        this.__v_isRef = true;
    }
    
    get value() {
        return this._object[this._key];
    }
    
    set value(newValue) {
        this._object[this._key] = newValue;
    }
}

function toRef(object, key) {
    return new ObjectRefImpl(object, key);
}

function toRefs(object) {
    const result = {};
    for (const key in object) {
        if (object.hasOwnProperty(key)) {
            result[key] = toRef(object, key);
        }
    }
    return result;
}

5. Computed 实现

// ============ computed ============
class ComputedRefImpl {
    constructor(getter, setter) {
        this.getter = getter;
        this.setter = setter;
        this._dirty = true;
        this._value = undefined;
        this.__v_isRef = true;
        
        this.effect = new ReactiveEffect(getter, () => {
            if (!this._dirty) {
                this._dirty = true;
                trigger(this, TriggerOpTypes.SET, 'value');
            }
        });
    }
    
    get value() {
        track(this, TrackOpTypes.GET, 'value');
        
        if (this._dirty) {
            this._dirty = false;
            this._value = this.effect.run();
        }
        
        return this._value;
    }
    
    set value(newValue) {
        if (this.setter) {
            this.setter(newValue);
        } else {
            console.warn('计算属性是只读的');
        }
    }
}

function computed(getterOrOptions) {
    let getter, setter;
    
    if (isFunction(getterOrOptions)) {
        getter = getterOrOptions;
        setter = null;
    } else {
        getter = getterOrOptions.get;
        setter = getterOrOptions.set;
    }
    
    return new ComputedRefImpl(getter, setter);
}

6. Watch 实现

// ============ watch ============
function traverse(value, seen = new Set()) {
    if (!isObject(value) || seen.has(value)) return value;
    
    seen.add(value);
    
    if (Array.isArray(value)) {
        value.forEach(item => traverse(item, seen));
    } else if (value instanceof Map) {
        value.forEach((v, k) => {
            traverse(v, seen);
            traverse(k, seen);
        });
    } else if (value instanceof Set) {
        value.forEach(v => traverse(v, seen));
    } else {
        Object.keys(value).forEach(key => traverse(value[key], seen));
    }
    
    return value;
}

function watch(source, cb, options = {}) {
    let getter;
    
    if (isRef(source)) {
        getter = () => source.value;
    } else if (isReactive(source)) {
        getter = () => source;
        options.deep = options.deep ?? true;
    } else if (Array.isArray(source)) {
        getter = () => source.map(s => {
            if (isRef(s)) return s.value;
            if (isReactive(s)) return traverse(s);
            if (isFunction(s)) return s();
            return s;
        });
    } else if (isFunction(source)) {
        if (cb) {
            getter = source;
        } else {
            return watchEffect(source, options);
        }
    }
    
    if (options.deep) {
        const baseGetter = getter;
        getter = () => traverse(baseGetter());
    }
    
    let oldValue;
    let cleanup;
    
    function onInvalidate(fn) {
        cleanup = fn;
    }
    
    const scheduler = () => {
        if (cleanup) cleanup();
        
        const newValue = getter();
        
        if (newValue !== oldValue) {
            cb(newValue, oldValue, onInvalidate);
        }
        
        oldValue = newValue;
    };
    
    const _effect = new ReactiveEffect(getter, scheduler);
    
    if (options.immediate) {
        scheduler();
    } else {
        oldValue = _effect.run();
    }
    
    return () => {
        _effect.stop();
        if (cleanup) cleanup();
    };
}

function watchEffect(effect, options = {}) {
    const scheduler = () => {
        if (options.flush === 'post') {
            Promise.resolve().then(() => _effect.run());
        } else {
            _effect.run();
        }
    };
    
    const _effect = new ReactiveEffect(effect, scheduler);
    _effect.run();
    
    return () => {
        _effect.stop();
    };
}

常见面试题解析

面试题 1:Vue3 的响应式原理是什么?

核心原理:Proxy + 依赖收集:

  1. 通过 Proxy 代理对象的所有操作
  2. 在 get 中通过 track 收集依赖
  3. 在 set 中通过 trigger 触发更新
  4. 使用 WeakMap + Map + Set 三层结构存储依赖
  5. 通过 effect 管理系统中的副作用
┌─────────────────────────────────────────────────────────────┐
│                    1. Proxy代理对象                          │
│                                                             │
│   ┌──────────────┐         ┌──────────────────────┐         │
│   │   原始对象     │        │     Proxy代理         │         │
│   │  {            │  代理  │   get: track收集      │         │
│   │    count: 0,  │◄───────┤   set: trigger触发    │        │
│   │    name: 'vue'│        │   deleteProperty     │         │
│   │  }            │        │   has...             │         │
│   └──────────────┘         └──────────────────────┘         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│             2. 依赖收集 (track)                              │
│                                                             │
│   get操作触发 ──→ track函数 ──→ 查找依赖存储                    │
│                                                             │
│   ┌──────────────────────────────────────────┐              │
│   │        3. 三层依赖存储结构                  │              │
│   │                                          │              │
│   │   WeakMap          Map          Set      │              │
│   │   ┌─────┐        ┌─────┐      ┌─────┐    │              │
│   │   │target│──────►│key1 │─────►│effect1│  │              │
│   │   └─────┘        ├─────┤      ├─────┤    │              │
│   │                  │key2 │─┐    │effect2│  │              │
│   │                  └─────┘ │    └─────┘    │              │
│   │                           └───►┌─────┐   │              │
│   │                                │effect3│ │              │
│   │                                └─────┘   │              │
│   └──────────────────────────────────────────┘              │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│             4. 副作用管理 (effect)                            │
│                                                             │
│   ┌────────────────────────────────────────┐                │
│   │  effect(() => {                        │                │
│   │    console.log(obj.count)  // 依赖收集  │                │
│   │  })                                    │                │
│   └────────────────────────────────────────┘                │
│                                                             │
│   ┌──────────┐    ┌──────────┐    ┌──────────┐              │
│   │  effect1 │    │  effect2 │    │  effect3 │              │
│   │ (更新UI) │     │(计算属性)│     │ (watch)  │              │
│   └──────────┘    └──────────┘    └──────────┘              │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│             5. 触发更新 (trigger)                             │
│                                                              │
│   set操作触发 ──→ trigger函数 ──→ 从存储结构中查找依赖            │
│                                          │                   │
│                                          ▼                   │
│   ┌──────────────────────────────────────────────────┐       │
│   │           执行所有相关的副作用函数                   │       │
│   │                                                  │       │
│   │   obj.count = 1                                  │       │
│   │        │                                         │       │
│   │        ▼                                         │       │
│   │   触发更新 ──→ 执行effect1 ──→ 更新UI               │       │
│   │            └─→ 执行effect2 ──→ 重新计算            │       │
│   └──────────────────────────────────────────────────┘       │
└──────────────────────────────────────────────────────────────┘

面试题 2:Vue2 的 Object.defineProperty 和 Vue3 的 Proxy 有什么区别?

  1. Proxy 可以监听新增/删除属性;defineProperty 不能
  2. Proxy 可以监听数组索引和 length;defineProperty 不行
  3. Proxy 需要递归代理;defineProperty 初始化递归
  4. Proxy 性能更好,拦截操作更丰富

面试题 3:为什么 ref 需要 .value 而 reactive 不需要?

因为 Proxy 无法代理原始值,对于原始值的代理需要通过 value 包裹成对象:

let count = 0; // 原始值,无法代理
// ref 包装成对象
const countRef = {
    value: 0
};
// 现在可以对 countRef 进行代理

面试题 4:computed 和 watch 有什么区别?

对比维度 computed watch
概念 计算属性,基于依赖缓存的计算值 侦听器,执行副作用操作
缓存机制 有缓存,只有依赖变化时才重新计算 无缓存,每次监听到变化都执行
返回值 必须返回一个值,模板中可直接使用 通常不返回值,用于执行逻辑操作
依赖追踪 自动追踪响应式依赖 手动指定要侦听的数据源
执行时机 懒执行,只有访问时才重新计算 立即执行(可配置)或数据变化时执行
异步操作 不支持异步 支持异步操作
性能特点 适合衍生状态,避免重复计算 适合处理开销大的操作或异步逻辑
使用场景 1. 模板中复杂表达式
2. 依赖其他数据的衍生值
3. 需要缓存的场景
1. 数据变化时执行异步操作
2. 操作DOM
3. 执行开销大的操作
访问方式 作为属性访问:state.count 通过回调函数执行
深度监听 自动深度追踪依赖 需要手动配置 deep: true
立即执行 自动计算 需要配置 immediate: true

面试题 5:Vue3 的响应式系统如何避免循环依赖?

  1. activeEffect 守卫:
function trigger(target, key) {
    const effects = depsMap.get(key);
    effects.forEach(effect => {
        // 跳过当前正在执行的 effect
        if (effect !== activeEffect) {
            effect.run();
        }
    });
}
  1. 使用 Set 避免重复收集
dep.add(activeEffect); // 自动去重
  1. 递归深度限制
class ReactiveEffect {
    run() {
        this.runDepth++;
        if (this.runDepth > 1000) {
            console.warn('检测到无限循环');
            return;
        }
        // ... 执行逻辑
        this.runDepth--;
    }
}

性能分析:Vue3 响应式比 Vue2 快在哪里?

1. 初始化性能对比

// Vue2:递归遍历所有属性
function vue2Init(obj) {
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key);
    });
    return obj;
}

// Vue3:代理整个对象,懒递归
function vue3Init(obj) {
    return new Proxy(obj, handlers); // 不递归
    // 只有在访问嵌套对象时才递归转换
}

性能差异:

  • Vue2: O(n) 初始化时间,n 为所有属性数量
  • Vue3: O(1) 初始化时间,只创建代理

2. 内存占用对比

// Vue2:为每个属性创建闭包
function defineReactive(obj, key) {
    let value = obj[key];
    const dep = new Dep(); // 每个属性一个 dep
    
    Object.defineProperty(obj, key, {
        get() {
            dep.depend(); // 闭包引用
            return value;
        },
        set(newVal) {
            dep.notify();
        }
    });
}

// Vue3:共享 handlers,使用 WeakMap 存储依赖
const targetMap = new WeakMap(); // 依赖统一存储
const handlers = {}; // 单例,不重复创建

性能差异:

  • Vue2: 每个属性都有独立的 getter/setter 和闭包
  • Vue3: 所有对象共享 handlers,依赖集中存储

3. 数组操作性能

// Vue2:重写数组方法
const arrayMethods = ['push', 'pop', 'shift', 'unshift'];
arrayMethods.forEach(method => {
    const original = Array.prototype[method];
    Object.defineProperty(array, method, {
        value: function(...args) {
            const result = original.apply(this, args);
            // 额外触发更新
            return result;
        }
    });
});

// Vue3:Proxy 直接拦截
const arr = new Proxy([], {
    set(target, key, value) {
        target[key] = value;
        // 统一处理更新
        return true;
    }
});

性能差异:

  • Vue2: 需要拦截每个方法,有额外开销
  • Vue3: 统一通过 set 拦截,更高效

4. 编译时优化

Vue3 性能提升:

  • 静态节点只创建一次
  • 更新时只比较动态部分
  • 减少了不必要的 VNode 创建

5. 批量更新机制

// Vue2:同步更新
state.count++;
state.name = '张三'; // 触发两次更新

// Vue3:异步批量更新
state.count++;
state.name = '张三';
// 只触发一次更新

性能差异:

  • Vue2: 多次同步更新导致多次渲染
  • Vue3: 批量处理,减少渲染次数

结语

经过多篇文章的深入探索,我们完成了 Vue3 响应式系统的完整学习。响应式系统的设计思想,不仅适用于 Vue,也为我们理解和构建响应式应用提供了宝贵的参考。从底层原理到上层 API,每一层都是精心设计的结果,共同构成了这个优雅而强大的系统。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

响应式系统核心难题:数组与集合

在前面的文章中,我们已经实现了对象类型的响应式代理。但当面对数组、Map、Set 这些特殊的数据结构时,普通的 Proxy 代理会暴露出各种问题:无限递归、方法重写、内部插槽等。本文将深入探讨这些难题,并给出完整的解决方案。

前言:为什么数组和集合是特殊的存在?

当我们用 reactive 包装一个数组时:

const arr = reactive([1, 2, 3]);
arr.push(4); // 这到底发生了什么?
arr[0] = 100; // 能触发响应式吗?
arr.length = 0; // 又会发生什么?

表面上看,数组和对象都是“引用类型”,用 Proxy 代理应该没什么区别。但实际上,数组有几个让 Proxy 头疼的特性:

  • 索引访问:arr[0] 既是属性访问,又可能改变 length,因此可能触发两次更新。
  • length 属性:改变 length 会隐式删除元素。
  • 变异方法:push、pop 等方法会同时修改数组内容和 length。

更麻烦的是 Map、Set 这类集合,它们的操作方式(set、delete、add)和普通对象完全不同。

数组的特殊性

为什么数组代理会死循环?

让我们先看一个看似完美的数组代理实现:

const arr = [1, 2, 3];
const proxy = new Proxy(arr, {
  get(target, key) {
    console.log(`读取属性: ${key}`);
    const value = target[key];
    
    // 如果是方法,需要绑定 this
    if (typeof value === 'function') {
      return value.bind(target);
    }
    return value;
  },
  
  set(target, key, value) {
    console.log(`设置属性: ${key} = ${value}`);
    target[key] = value;
    return true;
  }
});

proxy.push(4);

运行这段代码,我们会看到类似这样的输出:

读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
... (无限循环)

为什么会死循环? 关键在于 push 方法的内部机制:

  1. proxy.push → 触发 get,返回数组原生的 push 方法。
  2. push 方法内部会读取 length → 触发 get('length')。
  3. push 方法会设置索引 arr[3] = 4 → 触发 set(3, 4)。
  4. 设置索引后,push 内部会自动更新 length → 触发 set('length', 4)。

那么问题来了:在 set('length') 触发时,数组内部机制会导致重新读取 push 方法的某些元数据,于是又回到步骤 1,形成死循环。

Vue3 的解决方案:重写数组方法

Vue3 采用了巧妙的方式:拦截数组的变异方法,用自定义实现替代原生方法:

// 需要拦截的数组变异方法
const arrayMethods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

// 保存原生方法
const arrayProto = Array.prototype;
const arrayMethodsProto = Object.create(arrayProto);

// 重写变异方法
arrayMethods.forEach(method => {
  const original = arrayProto[method];
  
  Object.defineProperty(arrayMethodsProto, method, {
    value: function(...args) {
      console.log(`调用变异方法: ${method}`);
      
      // 先调用原生方法
      const result = original.apply(this, args);
      
      // 获取依赖并触发更新
      const dep = this.__ob__?.dep;
      if (dep) {
        dep.notify();
      }
      
      return result;
    },
    enumerable: false,
    writable: true,
    configurable: true
  });
});

深入数组代理的实现

索引访问与 length 的响应式处理

function createArrayReactive(target) {
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      // 追踪依赖
      track(target, key);
      
      // 如果是数组的变异方法,返回重写后的版本
      if (arrayMethods.includes(key)) {
        return arrayMethodsProto[key].bind(receiver);
      }
      
      // 其他属性正常返回
      const value = Reflect.get(target, key, receiver);
      
      // 如果是对象,需要递归响应式
      if (isObject(value)) {
        return reactive(value);
      }
      
      return value;
    },
    
    set(target, key, value, receiver) {
      const oldLength = target.length;
      const oldValue = target[key];
      
      // 设置值
      const result = Reflect.set(target, key, value, receiver);
      
      // 判断是否需要触发更新
      if (target.length !== oldLength) {
        // length 属性改变,需要触发 length 的更新
        trigger(target, 'length');
      }
      
      if (key !== 'length' && oldValue !== value) {
        // 普通索引变化
        trigger(target, key);
      }
      
      return result;
    }
  });
  
  return proxy;
}

追踪数组变化的关键点

数组的响应式追踪有三个核心:

  1. 追踪索引访问:arr[0] = 100; 触发 set(0, 100);
  2. 追踪 length 变化:arr.length = 0; 触发 set('length', 0);
  3. 追踪变异方法:arr.push(4); 触发 push 方法拦截

数组代理的完整实现

class ArrayReactiveHandler {
  constructor(_isShallow = false) {
    this._isShallow = _isShallow;
  }
  
  get(target, key, receiver) {
    // 追踪依赖
    track(target, key);
    
    // 处理数组变异方法
    if (arrayMethods.includes(key)) {
      return arrayMethodsProto[key].bind(receiver);
    }
    
    const value = Reflect.get(target, key, receiver);
    
    // 浅响应式不需要递归
    if (this._isShallow) {
      return value;
    }
    
    // 嵌套对象需要转为响应式
    if (isObject(value)) {
      return reactive(value);
    }
    
    return value;
  }
  
  set(target, key, value, receiver) {
    const oldLength = target.length;
    const oldValue = target[key];
    const keyIsArrayIndex = isArrayIndex(key);
    
    // 设置值
    const result = Reflect.set(target, key, value, receiver);
    
    // 判断触发更新的类型
    if (key === 'length') {
      // length 直接变化
      trigger(target, 'length');
    } else if (keyIsArrayIndex) {
      // 索引变化可能影响 length
      if (oldValue !== value) {
        trigger(target, key);
      }
      
      if (target.length !== oldLength) {
        trigger(target, 'length');
      }
    } else {
      // 普通属性
      if (oldValue !== value) {
        trigger(target, key);
      }
    }
    
    return result;
  }
  
  deleteProperty(target, key) {
    const hadKey = key in target;
    const oldLength = target.length;
    
    const result = Reflect.deleteProperty(target, key);
    
    if (result && hadKey) {
      trigger(target, key);
      
      // 删除索引可能改变 length
      if (isArrayIndex(key) && target.length !== oldLength) {
        trigger(target, 'length');
      }
    }
    
    return result;
  }
}

// 判断是否为数组索引
function isArrayIndex(key) {
  const keyAsNumber = Number(key);
  return Number.isInteger(keyAsNumber) && 
     keyAsNumber >= 0 && 
     keyAsNumber < Number.MAX_SAFE_INTEGER;
}

Map 和 Set 的代理

为什么 Map/Set 需要特殊处理?

Map 和 Set 的操作方式与普通对象完全不同:

const map = new Map();
map.set('key', 'value'); // 不是通过属性赋值
map.get('key'); // 不是通过属性读取
map.delete('key'); // 不是通过 delete 操作符

普通的 Proxy 无法拦截这些方法调用,我们必须重写这些方法。

拦截集合方法的思路

Vue3 通过创建自定义的集合处理器,重写所有会修改集合的方法:

// 需要拦截的 Map/Set 方法
const mutableInstrumentations = {
  // 取值方法
  get(key) {
    const target = this.__target;
    const hadKey = target.has(key);
    
    // 追踪依赖
    track(target, key);
    
    if (hadKey) {
      const value = target.get(key);
      // 嵌套对象响应式
      return isObject(value) ? reactive(value) : value;
    }
  },
  
  // 设值方法
  set(key, value) {
    const target = this.__target;
    const hadKey = target.has(key);
    const oldValue = target.get(key);
    
    // 设置值
    target.set(key, value);
    
    // 触发更新
    if (!hadKey) {
      trigger(target, 'add', key);
    } else if (oldValue !== value) {
      trigger(target, 'set', key);
    }
    
    return this;
  },
  
  // 添加方法(Set专用)
  add(value) {
    const target = this.__target;
    const hadKey = target.has(value);
    
    target.add(value);
    
    if (!hadKey) {
      trigger(target, 'add', value);
    }
    
    return this;
  },
  
  // 删除方法
  delete(key) {
    const target = this.__target;
    const hadKey = target.has(key);
    
    const result = target.delete(key);
    
    if (hadKey) {
      trigger(target, 'delete', key);
    }
    
    return result;
  },
  
  // 清空方法
  clear() {
    const target = this.__target;
    const hadItems = target.size > 0;
    
    const result = target.clear();
    
    if (hadItems) {
      trigger(target, 'clear');
    }
    
    return result;
  }
};

源码对标:Vue3 的 collectionHandlers

Vue3 源码中的 collectionHandlers.ts 实现了完整的集合代理逻辑。其核心思想是:

// 创建集合代理
function createCollectionHandler(isReadonly = false, isShallow = false) {
  return {
    get(target, key, receiver) {
      // 拦截 size 属性
      if (key === 'size') {
        track(target, 'size');
        return Reflect.get(target, key, target);
      }
      
      // 返回重写的方法
      if (key in mutableInstrumentations) {
        return mutableInstrumentations[key];
      }
      
      // 其他方法(如 keys、values 等)
      return Reflect.get(target, key, target);
    }
  };
}

实战:解决数组代理的无限递归

问题复现

让我们重现一个真实的无限递归场景:

// 问题代码
const arr = reactive([1, 2, 3]);

arr.push(4); // 死循环!

// 另一个容易忽略的场景
arr.splice(0, 1); // 也可能死循环

解决方案:标记和缓存

Vue3 的解决方案是结合标记缓存

// 防止重复拦截
function createArrayProxy(arr) {
  // 如果已经是响应式数组,直接返回
  if (arr.__v_isReactive) {
    return arr;
  }
  
  const proxy = new Proxy(arr, {
    get(target, key, receiver) {
      // 标记代理,防止重复代理
      if (key === '__v_isReactive') {
        return true;
      }
      
      // 关键优化:缓存方法调用结果
      if (arrayMethods.includes(key)) {
        // 使用 weakMap 缓存绑定后的方法
        if (!cachedMethods.has(key)) {
          const method = arrayMethodsProto[key];
          cachedMethods.set(key, method.bind(receiver));
        }
        return cachedMethods.get(key);
      }
      
      // ... 其他逻辑
    },
    
    set(target, key, value, receiver) {
      // 添加守卫条件,避免递归
      if (key === '__v_isReactive') {
        return false;
      }
      
      // ... 设置逻辑
    }
  });
  
  return proxy;
}

最终实现:安全的数组代理

结合所有优化,最终的数组代理实现:

class ArrayHandler {
  constructor(isReadonly = false, isShallow = false) {
    this.isReadonly = isReadonly;
    this.isShallow = isShallow;
    // 方法缓存
    this.methodCache = new Map();
  }
  
  get(target, key, receiver) {
    // 跳过内部标记
    if (key === '__v_isReactive' || key === '__v_isReadonly') {
      return this.isReadonly ? false : true;
    }
    
    // 追踪依赖
    if (!this.isReadonly && typeof key !== 'symbol') {
      track(target, key);
    }
    
    // 处理数组方法
    if (arrayMethods.includes(key)) {
      let method = this.methodCache.get(key);
      if (!method) {
        method = arrayMethodsProto[key].bind(receiver);
        this.methodCache.set(key, method);
      }
      return method;
    }
    
    const value = Reflect.get(target, key, receiver);
    
    // 嵌套响应式
    if (!this.isShallow && isObject(value)) {
      return this.isReadonly ? readonly(value) : reactive(value);
    }
    
    return value;
  }
  
  set(target, key, value, receiver) {
    if (this.isReadonly) {
      console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`);
      return true;
    }
    
    const oldLength = target.length;
    const oldValue = target[key];
    const keyIsArrayIndex = isArrayIndex(key);
    
    const result = Reflect.set(target, key, value, receiver);
    
    // 触发更新
    if (target.length !== oldLength) {
      trigger(target, 'length');
    }
    
    if (key !== 'length' && oldValue !== value) {
      trigger(target, key);
    }
    
    return result;
  }
}

// 工厂函数
function reactiveArray(arr) {
  if (!Array.isArray(arr)) {
    return arr;
  }
  
  // 避免重复代理
  if (arr.__v_isReactive) {
    return arr;
  }
  
  return new Proxy(arr, new ArrayHandler());
}

性能优化与最佳实践

避免不必要的数组代理开销

// 不推荐:大数组频繁操作
const bigArray = reactive(new Array(10000).fill(0));
for (let i = 0; i < bigArray.length; i++) {
  bigArray[i] = i; // 触发 10000 次 set
}

// 推荐:批量更新
const bigArray = reactive(new Array(10000).fill(0));
// 使用 splice 一次更新
bigArray.splice(0, bigArray.length, ...new Array(10000).fill(0));

集合类型的使用建议

// Map 的响应式使用
const map = reactive(new Map());

// 正确:使用 set 方法
map.set('key', 'value');

// 错误:直接赋值属性
map.key = 'value'; // 不会触发响应式

// Set 的响应式使用
const set = reactive(new Set());

// 正确:使用 add
set.add('item');

// 错误:不会触发响应式
set[0] = 'item';

结语

数组和集合的响应式实现是 Vue3 中最复杂但也最精巧的部分。通过本文的深入分析,我们不仅理解了 Vue3 如何解决这些技术难题,更重要的是学会了如何避免在实际开发中踩坑。这些知识将帮助你在构建复杂应用时,能够更加得心应手地处理各种数据结构的响应式需求。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

reactive 工具函数集

除了基础的 reactive 函数,Vue3 还提供了一系列工具函数来处理各种边界情况:只读数据、浅层响应式、跳过代理、类型判断等。这些工具函数共同构成了完整的响应式工具箱。

前言:为什么需要这些工具函数?

在实际开发中,我们常常会面临各种需求:

  1. 需要只读数据(如配置对象):
const config = readonly({ api: '/api', timeout: 3000 });
config.api = '/new-api'; // 错误!不能修改只读数据
  1. 性能优化:不需要深层响应式:
const shallow = shallowReactive({ user: { name: '张三' } });
shallow.user.name = '李四'; // 不会触发更新!
  1. 跳过不需要代理的对象(如第三方实例):
const instance = markRaw(new ThirdPartyClass());
const state = reactive({ instance }); // instance 不会被代理
  1. 类型判断:
if (isReactive(state)) {
    console.log(`${state} 是响应式对象`);
}
  1. 获取原始对象:
const raw = toRaw(state); // 获取未被代理的原始对象

这些工具函数让响应式系统更加灵活和强大。

readonly - 只读代理

readonly 的基本概念

readonly 会创建一个只读的响应式代理,任何修改操作都会失败,有以下适用场景:

  • 全局配置对象(不允许修改)
  • 从props传入的不可变数据
  • 状态管理中的常量
  • 对外暴露的只读接口

readonly 的基础实现

const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly'
};

// 工具函数
function isObject(val) {
    return val !== null && typeof val === 'object';
}

function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

// 只读处理器
const readonlyHandlers = {
    get(target, key, receiver) {
        // 处理特殊标记
        if (key === ReactiveFlags.IS_REACTIVE) {
            return false;
        }
        if (key === ReactiveFlags.IS_READONLY) {
            return true;
        }
        
        const result = Reflect.get(target, key, receiver);
        
        // 嵌套对象也变成只读
        if (isObject(result)) {
            return readonly(result);
        }
        
        return result;
    },
    
    set(target, key, value, receiver) {
        return true; // 返回true表示操作"成功",但实际上没有修改
    },
    
    deleteProperty(target, key) {
        return true;  // 返回true表示操作"成功",但实际上没有删除
    }
};

// 只读代理函数
function readonly(target) {
    if (!isObject(target)) {
        return target;
    }
    if (isReadonly(target)) {
        return target;
    }
    return new Proxy(target, readonlyHandlers);
}

shallowReactive - 浅层响应式

shallowReactive 的概念

shallowReactive 会创建一个只对顶层属性进行响应式处理的代理,嵌套对象不会被代理,有以下适用场景:

  • 性能优化:大型嵌套对象,但只需要顶层响应式
  • 与第三方库集成(不希望代理库内部对象)
  • 明确知道嵌套对象不会变化

shallowReactive 的实现

const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly'
};

// 工具函数
function isObject(val) {
    return val !== null && typeof val === 'object';
}

function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

// 只读处理器
const readonlyHandlers = {
    get(target, key, receiver) {
        // 处理特殊标记
        if (key === ReactiveFlags.IS_REACTIVE) {
            return false;
        }
        if (key === ReactiveFlags.IS_READONLY) {
            return true;
        }
        
        const result = Reflect.get(target, key, receiver);
        
        // 浅层:不代理嵌套对象
        return result;
    },
    
    set(target, key, value, receiver) {
        const result = Reflect.set(target, key, value, receiver);
        
        return result;
    }
};

// 浅层响应式函数
function shallowReactive(target) {
    if (!isObject(target)) {
        return target;
    }
    return new Proxy(target, shallowReactiveHandlers);
}

浅层 vs 深层对比

性能考虑

  • 深层响应式:需要递归代理所有嵌套对象,初始化开销大
  • 浅层响应式:只代理顶层,初始化快,但嵌套对象变化不触发更新

选择指南

  • 大型数据对象 → 浅层响应式
  • 嵌套对象需要响应式 → 深层响应式
  • 性能敏感 → 浅层响应式
  • 与第三方库集成 → 浅层响应式
  • 简单数据结构 → 深层响应式

markRaw - 跳过代理

markRaw 的概念

markRaw 会标记一个对象,使其永远不会被响应式代理,有以下适用场景:

  • 第三方库的实例(如Three.js对象、地图实例)
  • 有循环引用的复杂对象
  • 不需要响应式的静态数据
  • 性能优化:跳过大型但不需响应的对象

markRaw 的实现

// 定义原始标记
const RAW_MARK = '__v_skip';

// markRaw 函数
function markRaw(value) {
    if (isObject(value)) {
        Object.defineProperty(value, RAW_MARK, {
            value: true,
            enumerable: false,
            configurable: true,
            writable: false
        });
    }
    return value;
}

// 检查是否应该跳过代理
function shouldSkip(value) {
    return !!(value && value[RAW_MARK]);
}

// 修改reactive函数,支持跳过
function reactiveWithSkip(target) {
    
    if (!isObject(target)) {
        return target;
    }
    
    // 检查是否应该跳过
    if (shouldSkip(target)) {
        return target;  // 直接返回原始对象,不进行代理
    }
    
    if (isReactive(target)) {
        return target;
    }
    
    const proxy = new Proxy(target, {
        get(target, key, receiver) {
            if (key === ReactiveFlags.IS_REACTIVE) return true;
            if (key === ReactiveFlags.RAW) return target;
            
            const result = Reflect.get(target, key, receiver);
            
            // 懒代理时也要检查skip
            if (isObject(result) && !shouldSkip(result)) {
                return reactiveWithSkip(result);
            }
            
            return result;
        },
        
        set(target, key, value, receiver) {
            return Reflect.set(target, key, value, receiver);
        }
    });
    
    return proxy;
}

isReactive 类型判断

isReactive 的实现

// 完善ReactiveFlags
const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly',
    IS_SHALLOW: '__v_isShallow',
    RAW: '__v_raw'
};

// 判断函数
function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

function isShallow(value) {
    return !!(value && value[ReactiveFlags.IS_SHALLOW]);
}

function isProxy(value) {
    return isReactive(value) || isReadonly(value);
}

toRaw - 获取原始对象

toRaw 的概念

toRaw 会获取响应式代理背后的原始对象,有以下适用场景:

  • 需要绕过响应式系统直接操作原始数据
  • 传递给不希望接收代理的第三方库
  • 性能敏感操作(避免代理开销)
  • 调试和测试

toRaw 注意事项

  • 对非代理对象调用toRaw会返回自身
  • 修改原始对象不会触发更新
  • 谨慎使用,避免破坏响应式

toRaw 的实现

function toRaw(observed) {
    const raw = observed && observed[ReactiveFlags.RAW];
    return raw ? toRaw(raw) : observed;
}

完整工具函数集实现

// 定义所有标记
const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly',
    IS_SHALLOW: '__v_isShallow',
    RAW: '__v_raw',
    SKIP: '__v_skip'
};

// 工具函数
function isObject(val) {
    return val !== null && typeof val === 'object';
}

function isFunction(val) {
    return typeof val === 'function';
}

// 类型判断
function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

function isShallow(value) {
    return !!(value && value[ReactiveFlags.IS_SHALLOW]);
}

function isProxy(value) {
    return isReactive(value) || isReadonly(value);
}

function isRef(value) {
    return !!(value && value.__v_isRef === true);
}

// 原始对象获取
function toRaw(observed) {
    const raw = observed && observed[ReactiveFlags.RAW];
    return raw ? toRaw(raw) : observed;
}

// 跳过代理
const markRaw = (value) => {
    if (isObject(value)) {
        Object.defineProperty(value, ReactiveFlags.SKIP, {
            value: true,
            enumerable: false,
            configurable: true,
            writable: false
        });
    }
    return value;
};

const shouldSkip = (value) => {
    return !!(value && value[ReactiveFlags.SKIP]);
};

// 响应式处理器基类
class BaseReactiveHandler {
    constructor(readonly = false, shallow = false) {
        this.readonly = readonly;
        this.shallow = shallow;
    }
    
    get(target, key, receiver) {
        if (key === ReactiveFlags.IS_REACTIVE) {
            return !this.readonly;
        }
        if (key === ReactiveFlags.IS_READONLY) {
            return this.readonly;
        }
        if (key === ReactiveFlags.IS_SHALLOW) {
            return this.shallow;
        }
        if (key === ReactiveFlags.RAW) {
            return target;
        }
        
        const result = Reflect.get(target, key, receiver);
        
        // 跳过代理检查
        if (shouldSkip(result)) {
            return result;
        }
        
        // 根据模式处理嵌套对象
        if (isObject(result)) {
            if (this.shallow) {
                return result;
            }
            return this.readonly ? readonly(result) : reactive(result);
        }
        
        return result;
    }
    
    set(target, key, value, receiver) {
        if (this.readonly) {
            return true;
        }
        return Reflect.set(target, key, value, receiver);
    }
    
    deleteProperty(target, key) {
        if (this.readonly) {
            return true;  
        }
        return Reflect.deleteProperty(target, key);
    }
    
    getType() {
        if (this.readonly && this.shallow) return 'shallowReadonly';
        if (this.readonly) return 'readonly';
        if (this.shallow) return 'shallowReactive';
        return 'reactive';
    }
}

// 缓存Map
const reactiveMap = new WeakMap();
const readonlyMap = new WeakMap();

// 创建代理的通用函数
function createReactiveObject(target, handlers, proxyMap) {
    if (!isObject(target)) {
        return target;
    }
    
    if (target[ReactiveFlags.RAW] && !(proxyMap.has(target))) {
        return target;
    }
    
    const existingProxy = proxyMap.get(target);
    if (existingProxy) {
        return existingProxy;
    }
    
    if (shouldSkip(target)) {
        return target;
    }
    
    const proxy = new Proxy(target, handlers);
    proxyMap.set(target, proxy);
    
    return proxy;
}

// 主函数
function reactive(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(false, false),
        reactiveMap
    );
}
// 只读函数
function readonly(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(true, false),
        readonlyMap
    );
}
// 浅层函数
function shallowReactive(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(false, true),
        reactiveMap
    );
}
// 浅层只读函数
function shallowReadonly(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(true, true),
        readonlyMap
    );
}

使用场景总结

  • reactive: 默认选择,需要完整响应式
  • readonly: 配置对象、常量、对外暴露的只读数据
  • shallowReactive: 大型对象、性能优化、明确不需要深层响应式
  • shallowReadonly: 只读的大型对象
  • markRaw: 第三方实例、不需要响应的对象
  • toRaw: 绕过响应式、传递给第三方库、性能敏感操作
  • isReactive: '类型判断、调试

结语

本篇文章主要介绍了 reactive 的工具函数集,包含只读函数、浅层响应函数、跳过代理、类型判断等,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

effect的调度与清理:深入Vue3响应式系统的进阶特性

上一篇文章中,我们实现了基本的 effect,支持嵌套和 runner。但真实的响应式系统还需要处理更复杂的情况:分支切换导致的无效依赖、调度器控制执行时机、停止 effect 回收资源等,本文将深入这些高级特性。

前言:从分支切换的问题说起

const state = reactive({ ok: true, text: 'hello' });

effect(() => {
    console.log(state.ok ? state.text : 'not ok');
});

上述代码中,当 state.oktrue 时,effect 依赖了 oktext ; 当 state.okfalse 时,effect 只依赖了 ok ,此时 text 的依赖应该被清理掉!

这就是分支切换需要解决的问题。

分支切换与cleanup

什么是分支切换?

前言 中的代码为例,effect 内部存在一个三元表达式:state.ok ? state.text : 'not ok' ,根据字段 state.ok 值的不同,代码执行的分支会随之变化,执行不同的代码分支,这就是所谓的 分支切换

分支切换带来的问题

分支切换会产生遗留的副作用函数,上述例子中,当字段 state.ok 值为 false 时,此时 state.text 并不会被读取使用,但仍然会被依赖收集,这就产生了遗留的副作用,这显然是不合理的。

cleanup的作用

为了解决分支切换的问题,我们需要使用 cleanup 函数进行处理。该函数接收副作用函数作为参数,遍历副作用函数中的依赖集合,然后将该副作用从依赖集合中移除。这样,就可以避免副作用函数产生遗留问题了:

function cleanup(effect) {
    const { deps } = effect;
    if (deps.length) {
        console.log(`   [cleanup] 清除 ${effect.name}${deps.length} 个旧依赖`);
        deps.forEach(dep => dep.delete(effect));
        deps.length = 0;
    }
}

cleanup 的执行时机

每次 effect 执行之前,都会先调用 cleanup 函数,清理所有旧的依赖关系。以此确保依赖关系始终是最新的,避免无效更新:

run() {
    // 先清理所有旧依赖
    cleanup(this);
    
    // 设置为当前effect
    activeEffect = this;
    effectStack.push(this);
    
    console.log(`   [run] ${this.name} 执行`);
    
    try {
        // 执行fn,重新收集依赖
        this.fn();
    } finally {
        // 恢复activeEffect
        effectStack.pop();
        activeEffect = effectStack[effectStack.length - 1];
    }
}

cleanup 工作流程

effect.run()
    ↓
cleanup(effect)  →  移除所有旧依赖
    ↓
执行fn()         →  重新收集依赖
    ↓
完成执行

scheduler调度器

可调度性

可调度性是响应式系统中非常重要的特性,当触发更新重新执行副作用函数时,我们能决定副作用函数执行的时机、次数以及方式等,这就是可调度性

为什么需要调度器?

没有调度器的时候,可能会产生一些新的问题:

  • 频繁修改数据会导致effect执行多次,造成性能浪费
  • 有时需要控制effect的执行时机(如异步更新)
  • 需要批量处理更新,减少重复计算

如以下示例:

function demonstrateNoScheduler() {
    const state = reactive({ count: 0 });
    
    effect(() => {
        console.log(`   effect执行: count = ${state.count}`);
    });
    
    console.log('连续修改3次数据:');
    state.count = 1;
    state.count = 2;
    state.count = 3;
    
    console.log('   effect被执行了3次,可能是不必要的');
}

上述代码中,由于连续更改 state.count 的值,可能会导致 effect 被多次重复执行,这在很大程度上是不必要的。

通过 scheduler 调度器,可以自定义 effect 的执行策略,很好的解决上述问题。

调度器的基本实现

class EffectWithScheduler {
    constructor(fn, scheduler) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.deps = [];
        this.active = true;
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        cleanup(this);
        activeEffect = this;
        effectStack.push(this);
        
        try {
            const result = this.fn();
            return result;
        } finally {
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    }
    
    // 触发更新时调用
    trigger() {
        if (this.scheduler) {
            // 如果有调度器,交给调度器处理
            this.scheduler(this);
        } else {
            // 否则立即执行
            this.run();
        }
    }
}

调度器的应用场景

  1. 异步更新(Vue的nextTick),可以将多次同步更新合并为一次异步更新
  2. 使用 requestAnimationFrame 控制动画帧更新时机
  3. 控制高频更新的执行频率,防抖/节流
  4. 可以限制只有在特定条件下才执行 effect

懒执行effect(lazy)

为什么需要懒执行?

在有些场景下,我们并不希望 effect 副作用函数立即执行,而是希望它在需要的时候才执行,此处我们就可以通过 lazy 属性,即懒执行来实现。

懒执行场景

  1. 计算属性(computed)—— 只在被访问时才计算
  2. 需要手动控制的 effect
  3. 条件性执行的副作用
  4. 性能优化 —— 避免不必要的初始化计算

懒执行的实现

class LazyEffect {
    constructor(fn, options = {}) {
        this.fn = fn;
        this.lazy = options.lazy || false;
        this.scheduler = options.scheduler || null;
        this.deps = [];
        this.active = true;
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        cleanup(this);
        activeEffect = this;
        effectStack.push(this);
        
        try {
            const result = this.fn();
            return result;
        } finally {
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    }
    
    trigger() {
        if (this.scheduler) {
            this.scheduler(this);
        } else {
            this.run();
        }
    }
}

停止effect(stop方法)

stop 的作用

  1. 组件卸载时,停止响应式依赖,避免内存泄漏
  2. 用户可以手动停止不需要的副作用
  3. 临时禁用某个响应式关系

stop 的实现

class StoppableEffect {
    constructor(fn, options = {}) {
        this.fn = fn;
        this.scheduler = options.scheduler || null;
        this.onStop = options.onStop || null; // 停止时的回调
        this.deps = [];
        this.active = true; // 是否活跃
    }
    
    run() {
        if (!this.active) {
            // 如果不活跃,只执行函数,不收集依赖
            return this.fn();
        }
        
        cleanup(this);
        activeEffect = this;
        effectStack.push(this);
        
        try {
            const result = this.fn();
            return result;
        } finally {
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    }
    
    stop() {
        if (this.active) {
            console.log('   [stop] 停止effect');
            cleanup(this); // 清除所有依赖
            this.active = false;
            
            // 调用停止回调
            if (this.onStop) {
                this.onStop();
            }
        }
    }
    
    trigger() {
        if (this.scheduler) {
            this.scheduler(this);
        } else if (this.active) {
            this.run();
        }
    }
}

完整的ReactiveEffect类源码

// 全局变量
const targetMap = new WeakMap();
let activeEffect = null;
const effectStack = [];

// 清理函数
function cleanup(effect) {
    const { deps } = effect;
    if (deps.length) {
        console.log(`   [cleanup] 清除 ${effect.name || 'anonymous'}${deps.length} 个依赖`);
        deps.forEach(dep => dep.delete(effect));
        deps.length = 0;
    }
}

// 完整的ReactiveEffect类
class ReactiveEffect {
    constructor(fn, options = {}) {
        this.fn = fn;
        this.scheduler = options.scheduler || null;
        this.onStop = options.onStop || null;
        this.onTrack = options.onTrack || null;
        this.onTrigger = options.onTrigger || null;
        
        this.deps = [];
        this.active = true;
        this.name = fn.name || 'anonymous';
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        // 清理旧依赖
        cleanup(this);
        
        // 入栈
        effectStack.push(this);
        activeEffect = this;
        
        // 调试钩子
        if (this.onTrack) {
            // 实际会传入更详细的信息
        }
        
        console.log(`   [run] 开始执行 ${this.name}`);
        
        try {
            const result = this.fn();
            console.log(`   [run] ${this.name} 执行完成`);
            return result;
        } finally {
            // 出栈
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    }
    
    stop() {
        if (this.active) {
            console.log(`   [stop] 停止 ${this.name}`);
            cleanup(this);
            this.active = false;
            
            if (this.onStop) {
                this.onStop();
            }
        }
    }
    
    // 触发更新(由响应式系统调用)
    trigger() {
        if (!this.active) return;
        
        if (this.scheduler) {
            console.log(`   [scheduler] ${this.name} 被调度`);
            this.scheduler(this);
        } else {
            this.run();
        }
    }
}

// 依赖收集
function track(target, key) {
    if (!activeEffect) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
        
        if (activeEffect.onTrack) {
            activeEffect.onTrack({
                effect: activeEffect,
                target,
                key,
                type: 'get'
            });
        }
        
        console.log(`   [track] ${activeEffect.name} 依赖了 ${String(key)}`);
    }
}

// 触发更新
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    
    const dep = depsMap.get(key);
    if (!dep) return;
    
    console.log(`   [trigger] ${String(key)} 变化,触发 ${dep.size} 个effect`);
    
    // 复制一份,避免遍历时修改Set
    const effects = new Set(dep);
    effects.forEach(effect => {
        if (effect !== activeEffect) {
            effect.trigger();
        }
    });
}

// 创建响应式对象
function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            track(target, key);
            return target[key];
        },
        set(target, key, value) {
            target[key] = value;
            trigger(target, key);
            return true;
        }
    });
}

// 主effect函数
function effect(fn, options = {}) {
    const _effect = new ReactiveEffect(fn, options);
    
    if (!options.lazy) {
        _effect.run();
    }
    
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    
    return runner;
}

结语

本篇文章主要介绍了 effect 副作用函数的高级特性,掌握这些特性,我们不仅能更好地理解 Vue3 的工作原理,还能在遇到性能问题时,知道如何优化 effect 的执行策略,甚至在需要时实现自己的响应式系统。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

effect函数的完整实现与追踪:深入Vue3响应式核心

在上一篇文章中,我们实现了一个简易版的 effect。但真实的 Vue3 中,effect 要复杂得多:它需要处理嵌套 effect、支持停止追踪、提供 runner 函数、实现调度器等。本文将一步步完善 effect 的实现,最终与Vue3源码对标。

前言:从极简到完善

在上一篇文章 副作用的概念与effect基础:Vue3响应式系统的核心 中,我们实现了一个简易版的 effect ,这个版本有哪些问题呢?

  1. 嵌套 effect 会破坏 activeEffect
  2. 无法停止 effect 的追踪
  3. 没有返回 runner 函数(可以手动执行)
  4. 不支持调度器(scheduler)
  5. 没有 effect 选项(lazy、onTrack等)
  6. 没有处理错误情况

本篇文章,将一步步解决这些问题。

全局 activeEffect 的设计挑战

为什么需要 activeEffect?

当执行 effect 时,我们怎么知道当前正在运行的 effect 到底是哪个呢?这就是 activeEffect 的作用,使用全局变量 activeEffect 可以记录当前正在执行的 effect

activeEffect 工作流程

  1. 使用 effect 注册副作用函数 fn1 ,执行effect(fn1)
  2. 并将副作用函数 fn1 赋值给activeEffect :activeEffect = fn1
  3. fn1 执行,访问响应式数据
  4. track 函数被调用,将 activeEffect(fn1) 添加到依赖集合
  5. fn1 执行完毕,将 activeEffect 置空:activeEffect = null

activeEffect 工作过程示例

function demonstrateActiveEffect() {
    let activeEffect = null;
    
    function track() {
        console.log(`   [track] 收集依赖,当前effect: ${activeEffect?.name || 'null'}`);
    }
    
    function run(effect) {
        console.log(`   [run] 开始执行 ${effect.name}`);
        activeEffect = effect;
        effect.fn();
        activeEffect = null;
        console.log(`   [run] 结束执行 ${effect.name}`);
    }
    
    const effect1 = {
        name: 'effect1',
        fn: () => {
            console.log('     effect1执行中');
            track();
        }
    };
    
    const effect2 = {
        name: 'effect2',
        fn: () => {
            console.log('     effect2执行中');
            track();
        }
    };
    
    console.log('1. 执行effect1:');
    run(effect1);
    
    console.log('\n2. 执行effect2:');
    run(effect2);
}

嵌套effect的处理(effect栈)

effect 是可以发生嵌套的,如以下示例:

effect(fn1() {
    effect(fn2() {
        effect(fn3(){
            /* ... */
        })
    })
})

这段代码中,fn1 内部嵌套了 fn2;fn2 内部又嵌套了 fn3。当 fn1 执行时,会导致 fn2 的执行;当 fn2 执行时,又会导致 fn3 的执行。

为什么要处理 effect 嵌套问题?

以上述嵌套 effect 为例,如果我们用之前的 demonstrateActiveEffect() 函数处理,会发生什么问题呢?

此时,我们期望的结果是:fn1 执行过程中,fn2 开始执行,同时 fn3 也开始执行;fn1 和 fn2 应该被暂停。但实际上,当我们使用全局 activeEffect 来存储副作用函数时,它会被后面的副作用覆盖,即:fn2 会覆盖 fn1,fn3 会覆盖 fn2,导致最后的结果是只有 fn3 被收集了,无法再收集 fn1 和 fn2 。

为了解决这个问题,我们就需要一个副作用栈 effectStack ,在副作用函数执行时,将当前副作用函数压入栈底,待副作用函数执行完成后,再将其弹出,并始终让 activeEffect 指向栈顶的副作用函数。

class EffectStack {
    constructor() {
        this.stack = [];
        this.current = null;
    }
    
    // 入栈
    push(effect) {
        console.log(`   [栈] 入栈: ${effect.name || 'anonymous'}`);
        this.stack.push(effect);
        this.current = effect;
    }
    
    // 出栈
    pop() {
        const popped = this.stack.pop();
        console.log(`   [栈] 出栈: ${popped?.name || 'anonymous'}`);
        this.current = this.stack[this.stack.length - 1] || null;
        return popped;
    }
    
    // 获取当前effect
    getCurrent() {
        return this.current;
    }
}

使用 effect 栈解决嵌套问题

function demonstrateEffectStack() {
    const effectStack = new EffectStack();
    
    function track() {
        const current = effectStack.getCurrent();
        console.log(`   [track] 当前effect: ${current?.name || 'null'}`);
    }
    
    function effect(fn) {
        const effectFn = () => {
            effectStack.push(effectFn);
            fn();
            effectStack.pop();
        };
        effectFn.name = fn.name;
        effectFn();
        return effectFn;
    }
    
    console.log('使用effect栈后:');
    
    effect(function effect1() {
        console.log('  effect1开始');
        track(); // 收集effect1
        
        effect(function effect2() {
            console.log('  effect2开始');
            track(); // 收集effect2
            console.log('  effect2结束');
        });
        
        track(); // 现在能正确收集effect1了!
        console.log('  effect1结束');
    });
}

什么时候会出现嵌套的 effect 呢?

在 Vue 中,当我们使用了嵌套组件时,其实就发生了 effect 嵌套:

<!-- Foo.vue -->
<template>
  <div>
    <Bar />
  </div>
</template>

<script setup lang="ts">
import Bar from './bar.vue'

</script>

上述代码相当于:

effect(() => {
    Foo.render();
    effect(() => {
        Bar.render();
    });
})

effect 返回 runner 函数

runner函数的作用

effect 默认立即执行一次,但有时我们希望手动控制执行时机,因此我们就希望 effect 能返回一个函数,我们可以通过这个函数手动触发 effect 重新执行。

使用场景

  • 懒执行的effect(lazy: true)
  • 需要手动触发的更新
  • 可以随时停止的effect

实现带 runner 的 effect

function effectWithRunner(fn, options = {}) {
    const _effect = new ReactiveEffect(fn);
    
    // runner函数
    const runner = () => {
        return _effect.run();
    };
    
    // 保存effect实例到runner上,方便后续操作
    runner.effect = _effect;
    
    // 如果不是懒执行,立即运行
    if (!options.lazy) {
        runner();
    }
    
    return runner;
}

runner的返回值

方案1:返回fn的执行结果

function effect1(fn) {
    return fn();
}

方案2:返回runner函数

function effect2(fn) {
    const runner = () => fn();
    runner();
    return runner;
}

方案3:返回effect实例(Vue3的做法)

function effect3(fn) {
    const _effect = new ReactiveEffect(fn);
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    runner();
    return runner;
}

完整的effect实现

console.log('\n=== 完整版effect实现 ===\n');

// 依赖存储
const targetMap = new WeakMap();

// effect栈
const effectStack = [];

// 当前激活的effect
function getCurrentEffect() {
    return effectStack[effectStack.length - 1];
}

// ReactiveEffect类
class ReactiveEffect {
    constructor(fn, scheduler = null) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.deps = [];
        this.active = true; // 是否激活
        this.name = fn.name || 'anonymous';
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        try {
            // 入栈
            effectStack.push(this);
            cleanupEffect(this); // 清除旧的依赖
            console.log(`   [run] 开始执行 ${this.name}`);
            
            // 执行fn,期间会触发track
            return this.fn();
        } finally {
            // 出栈
            effectStack.pop();
            console.log(`   [run] 结束执行 ${this.name}`);
        }
    }
    
    stop() {
        if (this.active) {
            cleanupEffect(this);
            this.active = false;
        }
    }
}

// 清除effect的所有依赖
function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        console.log(`   [cleanup] 清除 ${effect.name}${deps.length} 个依赖`);
        deps.forEach(dep => dep.delete(effect));
        deps.length = 0;
    }
}

// 依赖收集
function track(target, key) {
    const activeEffect = getCurrentEffect();
    if (!activeEffect) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
        console.log(`   [track] ${activeEffect.name} 依赖了 ${key}`);
    }
}

// 触发更新
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    
    const dep = depsMap.get(key);
    if (!dep) return;
    
    console.log(`   [trigger] ${key} 变化,触发 ${dep.size} 个effect`);
    
    // 复制一份,防止在遍历过程中修改Set
    const effects = new Set(dep);
    effects.forEach(effect => {
        if (effect !== getCurrentEffect()) { // 避免无限循环
            if (effect.scheduler) {
                effect.scheduler();
            } else {
                effect.run();
            }
        }
    });
}

// 主effect函数
function effect(fn, options = {}) {
    const { lazy = false, scheduler = null } = options;
    
    const _effect = new ReactiveEffect(fn, scheduler);
    
    // 创建runner函数
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    
    // 立即执行(除非是懒执行)
    if (!lazy) {
        runner();
    }
    
    return runner;
}

结语

本篇文章简单介绍了 activeEffect 的设计与挑战,以及嵌套 effect 的处理。下一篇文章中,我们将介绍 effect 的执行调度、懒执行和停止跟踪等内容。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

副作用的概念与effect基础:Vue3响应式系统的核心

当我们修改一个响应式数据时,Vue 是如何知道要更新哪些地方的?这一切都始于 effect 函数。理解副作用和 effect,是掌握 Vue3 响应式系统的钥匙。

前言:一个看似简单的问题

我们先看看这段代码,你认为 Vue 应该如何工作?

const state = reactive({ count: 0 });
// 这里访问了 state.count,Vue 需要记录这个依赖
function render() {
    return `<div>${state.count}</div>`;
}
// 这里修改了 state.count,Vue 需要触发更新
state.count++;

问题来了:Vue 如何知道 render() 依赖于 state.count ?又如何知道当 count 变化时需要重新执行什么?

这就是副作用追踪系统要解决的核心问题。

什么是副作用

副作用的定义

在计算机科学中,副作用是指在函数执行过程中,除了返回值之外,对外部环境产生的任何影响,即产生了副作用,如下面代码所示:

示例1:修改 DOM

function updateDOM() {
    document.body.innerHTML = 'Hello'; // 副作用:修改 DOM
}

当 updateDOM 方法执行时,它会设置 body 的文本内容,但除了 updateDOM 方法外,还有很多方法都可以读取或设置 body 的文本内容。也就是说,updateDOM 方法的执行,会直接或间接影响其他函数的执行,此时 updateDOM 方法产生了副作用。

实际开发中,副作用很容易产生,我们可以看看以下几个示例:

示例2:修改外部变量

let total = 0;
function addToTotal(value) {
    total += value; // 副作用:修改了外部变量 total
}

示例3:控制台输出

function showMessage(msg) {
    console.log(msg); // 副作用:控制台输出
}

示例4:网络请求

function fetchData() {
    fetch('/api/data'); // 副作用:发起网络请求
}

有一个与副作用函数对应的概念:纯函数。纯函数是指,当前函数操作不会产生任何副作用,不会改变外部任何状态。

副作用的特征

  • 修改函数外部的变量或状态
  • 执行输入/输出操作(控制台、网络、文件)
  • 修改文档对象模型
  • 抛出异常(改变了程序的控制流)
  • 产生随机数(结果不可预测)

Vue 的上下文中,最常见的副作用

  • 渲染 DOM
  • 更新响应式数据
  • 执行 watch 回调
  • 计算 computed 属性
  • 调用生命周期钩子

为什么副作用对 Vue 如此重要?

我们以前言中的代码为例,如果没有副作用追踪,Vue 怎么知道需要重新渲染呢?不追踪副作用,我们无法知道 render() 函数依赖于 count

所以,Vue 需要追踪哪些函数(副作用)访问了哪些响应式数据,这样当数据变化时,就能自动重新执行这些函数。

Vue为什么需要追踪副作用

声明式渲染的需求

副作用追踪让声明式成为可能:我们只需要关心要如何显示数据;至于数据是如何更新的,我们其实并不关心:

<template>
    <div>{{ count }}</div>
    <div>{{ doubleCount }}</div>
</template>

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

自动追踪依赖

// 假设我们有这样的响应式对象
const obj = reactive({
    foo: 1,
    bar: 2
});

// 定义一些副作用
function effect1() {
    console.log('effect1:', obj.foo);
}

function effect2() {
    console.log('effect2:', obj.bar);
}

function effect3() {
    console.log('effect3:', obj.foo + obj.bar);
}

// 我们需要建立这样的依赖关系图:
const dependencyGraph = {
    'obj.foo': ['effect1', 'effect3'],
    'obj.bar': ['effect2', 'effect3']
};

// 当 obj.foo 变化时,自动执行 effect1 和 effect3
// 当 obj.bar 变化时,自动执行 effect2 和 effect3

精细化的更新控制

如果没有依赖追踪,只能暴力更新:

function withoutTracking() {
    // 每次数据变化都重新渲染整个应用
    // 性能极差!
    fullRender();
}

有了依赖追踪,可以实现精准更新:

function withTracking() {
    // 只重新执行依赖于变化数据的副作用
    // 性能优秀!
    const deps = getDeps(changeProperty);
    deps.forEach(effect => effect());
}

effect 函数的定位与作用

effect 是什么?

effect 是 Vue3 响应式系统的核心函数,它的作用是自动追踪函数内部对响应式数据的访问,并在这些数据变化时重新执行函数。其概念模型如下:

function effect(fn) {
    // 1. 记录当前正在执行的 effect
    // 2. 执行 fn,期间所有对响应式数据的访问都会被记录
    // 3. 建立依赖关系:响应式数据 → 这个 effect
    // 4. 当数据变化时,重新执行 fn
}

effect 在 Vue3 中的定位

effect 是整个 Vue3 中,响应式系统的基石:

在 Vue3 底层基础中,effect 可以:

  • 创建响应式对象 reactive
  • 创建响应式引用 ref
  • 实现副作用追踪系统 effect

在 Vue3 中层应用中,基于 effect 可以:

  • 实现计算属性 computed
  • 实现监听器 watch
  • 实现渲染函数 render

在 Vue3 上层暴露中,可以

  • 组合式 API:CompositionAPI
  • 选项式 API:OptionsAPI

手写实现简易版 effect

effect 系统的工作流程

  1. 调用 effect(fn)
  2. 设置 activeEffect = fn
  3. 执行 fn,期间访问响应式数据
  4. 在 get 拦截器中调用 track,将 activeEffect 添加到依赖集合
  5. fn 执行完毕,重置 activeEffect = null
  6. 当数据变化时,在 set 拦截器中调用 trigger
  7. 从依赖集合中取出所有 effect,依次执行

基础版本:activeEffect 模式

// 1. 依赖存储:使用 WeakMap 存储对象 → 属性 → effect 的映射
const targetMap = new WeakMap();

// 2. 当前激活的 effect
let activeEffect = null;

// 3. effect 函数实现
function effect(fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn;  // 设置当前激活的 effect
            console.log(`   [effect] 执行 ${fn.name || 'anonymous'}`);
            fn();                     // 执行原始函数,期间会触发 track
        } finally {
            activeEffect = null;       // 重置激活的 effect
        }
    };
    
    effectFn(); // 立即执行一次,完成依赖收集
    return effectFn;
}

思考题:如果没有 effect 系统,响应式如何实现?

方案1:手动依赖管理(Vue 1.x 的做法)

function withoutEffectV1() {
    const watchers = [];
    
    class Watcher {
        constructor(updateFn) {
            this.updateFn = updateFn;
            watchers.push(this);
        }
        
        update() {
            this.updateFn();
        }
    }
    
    // 数据变化时,手动通知所有 watcher
    function notifyAll() {
        watchers.forEach(w => w.update());
    }
    
    // 使用
    const w1 = new Watcher(() => console.log('更新视图1'));
    const w2 = new Watcher(() => console.log('更新视图2'));
    
    console.log('数据变化,手动通知:');
    notifyAll();
}

缺点:无法精确知道哪个 watcher 依赖哪个数据

方案2:发布订阅模式(全局事件总线)

function withoutEffectV2() {
    const events = {};
    
    function on(event, callback) {
        if (!events[event]) events[event] = [];
        events[event].push(callback);
    }
    
    function emit(event, data) {
        events[event]?.forEach(cb => cb(data));
    }
    
    // 使用
    on('countChange', (newVal) => {
        console.log('count 变化了:', newVal);
    });
    
    console.log('数据变化,手动触发:');
    emit('countChange', 1);
}

缺点:需要手动维护事件名,容易出错

方案3:计算属性风格(类似 Vue 2 的 computed)

function withoutEffectV4() {
    let data = { count: 0 };
    let dirty = true;
    let cachedValue = null;
    
    function computed(getter) {
        return {
            get value() {
                if (dirty) {
                    cachedValue = getter();
                    dirty = false;
                }
                return cachedValue;
            },
            setDirty() {
                dirty = true;
            }
        };
    }
    
    const doubleCount = computed(() => data.count * 2);
    
    console.log('第一次访问:', doubleCount.value); // 计算
    console.log('第二次访问:', doubleCount.value); // 缓存
    
    data.count = 2;
    doubleCount.setDirty(); // 手动标记为脏
    console.log('数据变化后:', doubleCount.value); // 重新计算
}

缺点:需要手动标记脏数据

上述方案的共同问题

  1. 无法自动建立依赖关系:需要开发者手动声明依赖,容易遗漏或多余
  2. 更新粒度粗:无法精确知道哪个数据依赖哪个 effect,往往需要全量更新
  3. 内存泄漏风险:手动管理订阅时容易忘记取消,导致内存泄漏
  4. 扩展性差:添加新功能需要修改现有代码,违反开闭原则
  5. 测试困难:依赖关系分散在代码各处,难以单元测试

effect 系统的优势

  1. 自动依赖收集:执行 effect 时自动追踪访问的属性,建立精确依赖关系
  2. 精确更新:数据变化时只重新执行真正依赖它的 effect,避免无效计算
  3. 生命周期管理:effect 可以自动清理,避免内存泄漏
  4. 可组合性:effect 可以嵌套,支持复杂的依赖关系
  5. 测试友好:副作用集中管理,便于测试和调试

结语

本文简单介绍了 Vue3 中的副作用函数 effect,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

前端构建工具:从Rollup到Vite

在 Vue.js 源码中,pnpm run build reactivity 这个命令背后究竟发生了什么?为什么 Vue3 选择 Rollup 作为构建工具?ViteRollup 又是什么关系?本文将深入理解 Rollup 的核心配置,探索 Vue3 的构建体系,并理清 ViteRollup 的渊源。

Rollup 基础配置解析

什么是 Rollup?

Rollup 是一个 JavaScript 模块打包器,它可以将多个模块打包成一个单独的文件。与 Webpack 不同,Rollup 专注于 ES 模块的静态分析,以生成更小、更高效的代码。

Rollup 的核心优势

  • treeShaking:基于 ES 模块的静态分析,自动移除未使用的代码
  • 支持输出多种模块格式(ESM、CJS、UMD、IIFE)
  • 配置文件简洁直观,学习成本低
  • 插件体系完善,可以处理各种场景

核心配置:input 与 output

Rollup 的配置文件通常是 rollup.config.js,它导出一个配置对象或数组:

input:入口文件配置

// rollup.config.js
export default {
    // 单入口(最常见)
    input: 'src/index.js',
    
    // 多入口(对象形式)
    input: {
        main: 'src/main.js',
        admin: 'src/admin.js',
        utils: 'src/utils.js'
    },
    
    // 多入口(数组形式)
    input: ['src/index.js', 'src/cli.js']
};

output:输出配置

output 配置决定了打包产物的形式和位置:

export default {
    input: 'src/index.js',
    
    // 单输出配置
    output: {
        file: 'dist/bundle.js',      // 输出文件
        format: 'esm',                // 输出格式
        name: 'MyLibrary',            // UMD/IIFE 模式下的全局变量名
        sourcemap: true,               // 生成 sourcemap
        banner: '/*! MyLibrary v1.0.0 */' // 文件头注释
    },
    
    // 多输出配置(数组形式,输出多种格式)
    output: [
        {
            file: 'dist/my-lib.cjs.js',
            format: 'cjs'              // CommonJS,适用于 Node.js
        },
        {
            file: 'dist/my-lib.esm.js',
            format: 'es'                // ES Module,适用于现代浏览器/打包工具
        },
        {
            file: 'dist/my-lib.umd.js',
            format: 'umd',              // UMD,适用于所有场景
            name: 'MyLibrary'
        },
        {
            file: 'dist/my-lib.iife.js',
            format: 'iife',              // IIFE,直接用于浏览器 script 标签
            name: 'MyLibrary'
        }
    ]
};
输出格式详解
格式 全称 适用场景 特点
es / esm ES Module 现代浏览器、打包工具 保留 import/export,支持 Tree Shaking
cjs CommonJS Node.js 环境 使用 require/module.exports
umd Universal Module Definition 通用(浏览器、Node.js) 兼容 AMD、CommonJS 和全局变量
iife Immediately Invoked Function Expression 直接在浏览器用 script 脚本引入 自执行函数,避免全局污染
amd Asynchronous Module Definition RequireJS 等 异步模块加载

插件系统:扩展 Rollup 的能力

Rollup 的核心功能很精简,大多数能力需要通过插件来扩展。插件通过 plugins 数组配置,可以是单个插件实例或包含多个插件的数组:

import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import json from '@rollup/plugin-json';
import replace from '@rollup/plugin-replace';
import babel from '@rollup/plugin-babel';

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'umd',
        name: 'MyLibrary'
    },
    plugins: [
        // 解析 node_modules 中的第三方模块[citation:1]
        nodeResolve(),
        
        // 将 CommonJS 模块转换为 ES 模块[citation:10]
        commonjs(),
        
        // 支持导入 JSON 文件
        json(),
        
        // 替换代码中的字符串(常用于环境变量)
        replace({
            'process.env.NODE_ENV': JSON.stringify('production')
        }),
        
        // 使用 Babel 进行代码转换
        babel({
            babelHelpers: 'bundled',
            exclude: 'node_modules/**'
        }),
        
        // 压缩代码(生产环境)
        terser()
    ]
};

external:排除外部依赖

当构建一个库时,我们通常不希望将第三方依赖(如 React、Vue、lodash)打包进最终的产物,而是将其声明为外部依赖:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/my-lib.js',
        format: 'umd',
        name: 'MyLibrary',
        // 为 UMD 模式提供全局变量名映射
        globals: {
            'react': 'React',
            'react-dom': 'ReactDOM',
            'lodash': '_'
        }
    },
    // 排除外部依赖
    external: [
        'react',
        'react-dom',
        'lodash',
        // 也可以使用正则表达式
        /^lodash\//  // 排除 lodash 的所有子模块
    ]
};

Tree Shaking

Rollup 最令人津津乐道的就是其 Tree Shaking 功能,它通过静态分析移除未使用的代码,减小打包体积:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    },
    treeshake: {
        // 模块级别的副作用分析
        moduleSideEffects: false,
        
        // 属性访问分析(更精确的 Tree Shaking)
        propertyReadSideEffects: false,
        
        // 尝试合并模块
        tryCatchDeoptimization: false,
        
        // 未知全局变量分析
        unknownGlobalSideEffects: false
    }
};

// 更简单的用法:直接使用布尔值
treeshake: true // 开启默认的摇树优化[citation:1]

watch:监听模式

在开发过程中,我们可以开启监听模式,当文件变化时自动重新打包:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    },
    watch: {
        include: 'src/**',      // 监听的文件
        exclude: 'node_modules/**', // 排除的文件
        clearScreen: false        // 不清除屏幕
    }
};

// 或者在命令行中开启
// rollup -c --watch
// rollup -c -w (简写)

Vue3 使用的关键 Rollup 插件

Vue3 的源码采用 monorepo 管理,使用 Rollup 进行构建。让我们看看 Vue3 在构建过程中使用了哪些关键插件:

@rollup/plugin-node-resolve

作用:允许 Rollup 从 node_modules 中导入第三方模块。

// 为什么需要这个插件?
import { reactive } from '@vue/reactivity'; // 这个模块在 node_modules 中
// 没有插件时,Rollup 无法解析这个路径

// Vue3 中的使用
import nodeResolve from '@rollup/plugin-node-resolve';

export default {
    plugins: [
        nodeResolve({
            // 指定解析的模块类型
            mainFields: ['module', 'main'], // 优先使用 module 字段[citation:5]
            extensions: ['.js', '.json', '.ts'], // 支持的文件扩展名
            preferBuiltins: false // 不优先使用 Node 内置模块
        })
    ]
};

@rollup/plugin-commonjs

作用:将 CommonJS 模块转换为 ES 模块,使得 Rollup 可以处理那些尚未提供 ES 模块版本的依赖:

import commonjs from '@rollup/plugin-commonjs';

export default {
    plugins: [
        commonjs({
            // 指定哪些文件需要转换
            include: 'node_modules/**',
            
            // 扩展名
            extensions: ['.js', '.cjs'],
            
            // 忽略某些模块的转换
            ignore: ['conditional-runtime-dependency']
        })
    ]
};

@rollup/plugin-replace

作用:在打包时替换代码中的字符串,常用于注入环境变量或特性开关(Feature Flags):

// Vue3 中的特性开关示例[citation:2]
// packages/compiler-core/src/errors.ts
export function createCompilerError(code, loc, messages, additionalMessage) {
    // __DEV__ 在构建时被替换为 true 或 false
    if (__DEV__) {
        // 开发环境才执行的代码
    }
}

// rollup 配置
import replace from '@rollup/plugin-replace';

export default {
    plugins: [
        replace({
            // 防止被 JSON.stringify 转义
            preventAssignment: true,
            
            // 定义环境变量
            __DEV__: process.env.NODE_ENV !== 'production',
            __VERSION__: JSON.stringify('3.2.0'),
            
            // 特性开关
            __FEATURE_OPTIONS_API__: true,
            __FEATURE_PROD_DEVTOOLS__: false
        })
    ]
};

@rollup/plugin-json

作用:支持从 JSON 文件导入数据:

import json from '@rollup/plugin-json';

export default {
    plugins: [
        json({
            // 指定 JSON 文件的大小限制,超过限制则作为单独文件引入
            preferConst: true,
            indent: '  '
        })
    ]
};

// 使用时
import pkg from './package.json';
console.log(pkg.version);

rollup-plugin-terser

作用:压缩代码,减小生产环境的包体积:

import { terser } from 'rollup-plugin-terser';

export default {
    plugins: [
        // 只在生产环境使用
        process.env.NODE_ENV === 'production' && terser({
            compress: {
                drop_console: true,      // 移除 console
                drop_debugger: true,      // 移除 debugger
                pure_funcs: ['console.log'] // 移除特定的函数调用
            },
            output: {
                comments: false           // 移除注释
            }
        })
    ]
};

@rollup/plugin-babel

作用:使用 Babel 进行代码转换,处理语法兼容性问题:

import babel from '@rollup/plugin-babel';

export default {
    plugins: [
        babel({
            // 排除 node_modules
            exclude: 'node_modules/**',
            
            // 包含的文件
            include: ['src/**/*.js', 'src/**/*.ts'],
            
            // Babel helpers 的处理方式
            babelHelpers: 'bundled', // 或 'runtime'
            
            // 扩展名
            extensions: ['.js', '.jsx', '.ts', '.tsx']
        })
    ]
};

@rollup/plugin-typescript

作用:支持 TypeScript 编译:

import typescript from '@rollup/plugin-typescript';

export default {
    plugins: [
        typescript({
            tsconfig: './tsconfig.json',
            declaration: true,      // 生成 .d.ts 文件
            declarationDir: 'dist/types'
        })
    ]
};

如何构建指定包(以 pnpm run build reactivity 为例)

Vue3 采用 monorepo 管理多个包,使用 pnpm 作为包管理器。理解 pnpm run build reactivity 背后的机制,能帮助我们更好地理解现代构建流程:

项目结构

vue-next/
├── packages/               # 所有子包
│   ├── reactivity/         # 响应式系统
│   │   ├── src/
│   │   ├── package.json    # 包级配置
│   │   └── ...
│   ├── runtime-core/       # 运行时核心
│   ├── runtime-dom/        # 浏览器运行时
│   ├── compiler-core/      # 编译器核心
│   ├── vue/                # 完整版本
│   └── ...
├── package.json            # 根配置
├── pnpm-workspace.yaml     # pnpm 工作区配置
└── rollup.config.js        # Rollup 配置文件

pnpm-workspace.yaml 配置

# pnpm-workspace.yaml
packages:
  - 'packages/*'  # 声明 packages 下的所有目录都是工作区的一部分

这个配置告诉 pnpm:packages 目录下的每个子目录都是一个独立的包,它们之间可以互相引用而不需要发布到 npm。

根 package.json 的脚本配置

// 根目录 package.json
{
  "private": true,
  "scripts": {
    "build": "node scripts/build.js",                 // 构建所有包
    "build:reactivity": "pnpm run build reactivity",  // 只构建 reactivity 包
    "dev": "node scripts/dev.js",                      // 开发模式
    "test": "jest"                                      // 运行测试
  }
}

pnpm run 的底层原理

当我们在命令行执行 pnpm run build reactivity 时,背后发生了以下步骤:

  1. 解析命令:pnpm run build reactivity
  2. 读取根目录 package.json 中的 scripts
  3. 找到 "build": node scripts/build.js
  4. 将参数 "reactivity" 传递给脚本
  5. 在 PATH 环境变量中查找 node
  6. 执行 node scripts/build.js reactivity
  7. 脚本根据参数决定构建哪个包

build.js 脚本分析

Vue3 的构建脚本会解析命令行参数,决定构建哪些包:

// scripts/build.js (简化版)
const fs = require('fs');
const path = require('path');
const execa = require('execa');
const { targets: allTargets } = require('./utils');

// 获取命令行参数
const args = require('minimist')(process.argv.slice(2));
const targets = args._; // 获取到的参数数组

async function build() {
    // 如果没有指定目标,构建所有包
    if (!targets.length) {
        await buildAll(allTargets);
    } else {
        // 只构建指定的包
        await buildSelected(targets);
    }
}

async function buildSelected(targets) {
    for (const target of targets) {
        await buildPackage(target);
    }
}

async function buildPackage(packageName) {
    console.log(`开始构建: @vue/${packageName}`);
    
    // 切换到包目录
    const pkgDir = path.resolve(__dirname, '../packages', packageName);
    
    // 使用 rollup 构建该包
    await execa(
        'rollup',
        [
            '-c',                                      // 使用配置文件
            '--environment',                           // 设置环境变量
            `TARGET:${packageName}`,                   // 告诉 rollup 要构建哪个包
            '--watch'                                   // 开发模式时可能开启
        ],
        {
            stdio: 'inherit',                          // 继承输入输出
            cwd: pkgDir                                 // 在包目录执行
        }
    );
}

build();

Rollup 配置如何区分不同的包

// rollup.config.js (简化版)
import { createRequire } from 'module';
import path from 'path';
import fs from 'fs';

// 获取所有包
const packagesDir = path.resolve(__dirname, 'packages');
const packages = fs.readdirSync(packagesDir)
    .filter(f => fs.statSync(path.join(packagesDir, f)).isDirectory());

// 根据环境变量决定构建哪个包
const target = process.env.TARGET;

function createConfig(packageName) {
    const pkgDir = path.resolve(packagesDir, packageName);
    const pkg = require(path.join(pkgDir, 'package.json'));
    
    // 为每个包生成不同的配置
    return {
        input: path.resolve(pkgDir, 'src/index.ts'),
        output: [
            {
                file: path.resolve(pkgDir, pkg.main),
                format: 'cjs',
                sourcemap: true
            },
            {
                file: path.resolve(pkgDir, pkg.module),
                format: 'es',
                sourcemap: true
            }
        ],
        plugins: [
            // 共用插件
        ],
        external: [
            ...Object.keys(pkg.dependencies || {}),
            ...Object.keys(pkg.peerDependencies || {})
        ]
    };
}

// 如果指定了 target,只构建那个包
if (target) {
    module.exports = createConfig(target);
} else {
    // 否则构建所有包
    module.exports = packages.map(createConfig);
}

包级 package.json 的配置

每个包都有自己的 package.json,定义了该包的元信息和构建产物的入口:

// packages/reactivity/package.json
{
  "name": "@vue/reactivity",
  "version": "3.2.0",
  "main": "dist/reactivity.cjs.js",     // CommonJS 入口
  "module": "dist/reactivity.esm.js",    // ES Module 入口
  "unpkg": "dist/reactivity.global.js",  // 直接引入的 UMD 版本
  "types": "dist/reactivity.d.ts",       // TypeScript 类型定义
  "dependencies": {
    "@vue/shared": "3.2.0"
  }
}

Vite 与 Rollup 的关系

为什么需要 Vite?

虽然 Rollup 很优秀,但在开发大型应用时,它和 Webpack 一样面临着性能瓶颈:随着项目变大,启动开发服务器的时间越来越长。

Vite 的双引擎架构

Vite 在开发环境和生产环境使用不同的引擎:

  • 开发环境:利用浏览器原生 ES 模块 + esbuild 预构建
  • 生产环境:使用 Rollup 进行深度优化打包

开发环境:利用原生 ES 模块

<!-- Vite 开发服务器的原理 -->
<script type="module">
    // 浏览器直接请求模块,服务器实时编译返回
    import { createApp } from '/node_modules/.vite/vue.js'
    import App from '/src/App.vue'
    
    createApp(App).mount('#app')
</script>

esbuild 使用 Go 编写,比 JS 编写的打包器快 10-100 倍,可以预构建依赖,并转换 TypeScript/JSX。

生产环境:使用 Rollup 打包

Vite 在生产环境构建时,会使用 Rollup 进行打包。Vite 的插件系统也是与 Rollup 兼容的,这意味着绝大多数 Rollup 插件也可以在 Vite 中使用:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
    plugins: [
        vue()  // 这个插件同时支持开发环境和生产环境
    ],
    
    // 构建配置
    build: {
        // 底层是 Rollup 配置
        rollupOptions: {
            input: {
                main: resolve(__dirname, 'index.html'),
                nested: resolve(__dirname, 'nested/index.html')
            },
            output: {
                // 代码分割配置
                manualChunks: {
                    vendor: ['vue', 'vue-router']
                }
            }
        },
        
        // 输出目录
        outDir: 'dist',
        
        // 生成 sourcemap
        sourcemap: true,
        
        // 压缩配置
        minify: 'terser' // 或 'esbuild'
    }
});

Vite 与 Rollup 的配置对比

配置项 Rollup Vite
入口文件 input build.rollupOptions.input
输出目录 output.file / output.dir build.outDir
输出格式 output.format build.rollupOptions.output.format
外部依赖 external build.rollupOptions.external
插件 plugins plugins (同时支持 Vite 和 Rollup 插件)
开发服务器 无(需配合 rollup -w) 内置,支持 HMR

何时选择 Vite,何时选择 Rollup?

使用 Rollup

  • 开发 JavaScript/TypeScript 库
  • 需要精细控制打包过程
  • 项目不复杂,不需要开发服务器
  • 已有基于 Rollup 的构建流程

使用 Vite

  • 开发应用(Vue/React 项目)
  • 需要快速启动的开发服务器
  • 需要 HMR 热更新
  • 希望简化配置

两者结合

  • 库开发时使用 Rollup
  • 应用开发时使用 Vite
  • Vite 内部使用 Rollup 构建生产环境

总结

Rollup 的核心优势

  • 简洁性: 配置直观,学习成本低
  • TreeShaking: 基于ES模块的静态分析,产出代码极小
  • 多格式输出: 支持输出多种模块格式,适用于不同环境
  • 插件生态: 丰富的插件,可以处理各种场景
  • 源码可读性: 打包后的代码保持较好的可读性

Vite 的创新之处

  • 开发体验: 利用原生ES模块,实现极速启动和热更新
  • 双引擎架构: 开发用 esbuild,生产用 Rollup,各取所长
  • 配置简化: 内置常用配置,开箱即用
  • 插件兼容: 兼容 Rollup 插件生态

构建工具是现代前端开发的基石,深入理解它们不仅能帮助我们写出更高效的代码,还能在遇到问题时快速定位和解决。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

JavaScript 防抖与节流进阶:从原理到实战

当用户疯狂点击按钮、疯狂滚动页面、疯狂输入搜索关键词时,应用还能流畅运行吗?防抖(Debounce)和节流(Throttle)是应对高频事件的终极武器。本文将从源码层面深入理解它们的差异,并实现一个支持立即执行、延迟执行、取消功能、记录参数的完整版本。

前言:高频事件带来的挑战

我们先来看一些简单的场景:

window.addEventListener('resize', () => {
    // 窗口大小改变时重新计算布局
    recalcLayout(); // 一秒可能触发几十次!
});

searchInput.addEventListener('input', () => {
    // 用户每输入一个字符就发起搜索请求
    fetchSearchResults(input.value); // 浪费大量请求!
});

window.addEventListener('scroll', () => {
    // 滚动时加载更多数据
    loadMoreData(); // 滚动一下触发几十次!
});

在这些场景中,当事件触发频率远高于我们需要的处理频率,就会出现卡顿、闪屏等现象,这就是防抖和节流要解决的核心问题。

理解防抖与节流的本质差异

核心概念对比

类型 防抖 节流
概念 将多次高频操作合并为一次,仅在最后一次操作后的延迟时间到达时执行 保证在单位时间内只执行一次,稀释执行频率
场景示例 电梯关门:等最后一个人进来后才关门,中间如果有人进来就重新计时 地铁安检:无论多少人排队,每秒钟只能通过一个人
执行次数 只执行最后一次 定期执行,不保证最后一次
频率 N次高频调用 → 1次执行 N次高频调用 → N/间隔时间次执行

适用场景对比

防抖场景

  • 搜索框输入(用户停止输入后才搜索)
  • 窗口大小调整(窗口调整完成后重新计算)
  • 表单验证(用户输完才验证)
  • 自动保存(停止编辑后保存)
  • 按钮防连点(避免重复提交)

节流场景

  • 滚动加载更多(滚动过程中定期检查)
  • 动画帧(控制动画执行频率)
  • 游戏循环(固定帧率)
  • 鼠标移动事件(实时位置但不过度频繁)
  • DOM元素拖拽(平滑移动)

防抖函数实现

基础防抖实现

function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    // 每次调用都清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置新的定时器
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

支持立即执行的防抖

function debounceEnhanced(fn, delay, immediate = false) {
  let timer = null;
  let lastContext = null;
  let lastArgs = null;
  let lastResult = null;
  let callCount = 0;

  return function (...args) {
    lastContext = this;
    lastArgs = args;

    // 第一次调用且需要立即执行
    if (immediate && !timer) {
      lastResult = fn.apply(lastContext, lastArgs);
      callCount++;
      console.log(`立即执行 (调用 #${callCount})`);
    }

    // 清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置延迟执行
    timer = setTimeout(() => {
      // 如果不是立即执行模式,或者已经执行过立即执行
      if (!immediate) {
        lastResult = fn.apply(lastContext, lastArgs);
        callCount++;
        console.log(`延迟执行 (调用 #${callCount})`);
      }

      // 清理
      timer = null;
      lastContext = null;
      lastArgs = null;
    }, delay);

    return lastResult;
  };
}

完整版防抖(支持取消、取消、参数记录)

class DebouncedFunction {
  constructor(fn, delay, options = {}) {
    this.fn = fn;
    this.delay = delay;
    this.immediate = options.immediate || false;
    this.maxWait = options.maxWait || null;

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;

    // 参数历史记录
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 调用次数统计
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const time = Date.now();
    this.stats.invokedCount++;
    this.lastInvokeTime = time;

    // 记录参数历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: time,
        type: this.timer ? 'delayed' : 'immediate'
      });

      // 限制历史记录数量
      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行原函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);

    // 清理
    this.lastArgs = null;
    this.lastContext = null;

    return this.lastResult;
  }

  /**
   * 调用防抖函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;
    this.lastCallTime = now;

    // 立即执行模式处理
    if (this.immediate && !this.timer) {
      this._invoke();
    }

    // 清除现有定时器
    if (this.timer) {
      clearTimeout(this.timer);
    }

    // 最大等待时间处理
    if (this.maxWait && this.lastInvokeTime) {
      const timeSinceLastInvoke = now - this.lastInvokeTime;
      if (timeSinceLastInvoke >= this.maxWait) {
        this._invoke();
        return this.lastResult;
      }
    }

    // 设置新的定时器
    this.timer = setTimeout(() => {
      // 非立即执行模式,或者已经执行过立即执行
      if (!this.immediate) {
        this._invoke();
      }
      this.timer = null;
    }, this.delay);

    return this.lastResult;
  }

  /**
   * 取消当前待执行的防抖
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
      this.stats.canceledCount++;
    }

    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消后续
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有待执行的任务
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取调用历史
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;
  }
}

节流函数实现

基础节流实现

function throttleTimer(fn, interval) {
  let timer = null;

  return function (...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, interval);
    }
  };
}

完整版节流(支持首尾执行)

class ThrottledFunction {
  constructor(fn, interval, options = {}) {
    this.fn = fn;
    this.interval = interval;
    this.leading = options.leading !== false; // 是否立即执行
    this.trailing = options.trailing !== false; // 是否最后执行

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastInvokeTime = 0;

    // 参数历史
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 统计信息
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const now = Date.now();
    this.lastInvokeTime = now;
    this.stats.invokedCount++;

    // 记录历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: now,
        type: 'executed'
      });

      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 调用节流函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;

    // 检查是否在节流期内
    const timeSinceLastInvoke = now - this.lastInvokeTime;
    const isThrottled = timeSinceLastInvoke < this.interval;

    if (isThrottled) {
      this.stats.throttledCount++;

      // 如果需要尾部执行
      if (this.trailing) {
        // 清除现有的尾部执行定时器
        if (this.timer) {
          clearTimeout(this.timer);
        }

        // 设置尾部执行定时器
        const remainingTime = this.interval - timeSinceLastInvoke;
        this.timer = setTimeout(() => {
          if (this.lastArgs) {
            this._invoke();
          }
          this.timer = null;
        }, remainingTime);
      }

      return this.lastResult;
    }

    // 不在节流期内
    if (this.leading) {
      // 头部执行
      this._invoke();
    } else if (this.trailing) {
      // 延迟执行
      if (this.timer) {
        clearTimeout(this.timer);
      }
      this.timer = setTimeout(() => {
        if (this.lastArgs) {
          this._invoke();
        }
        this.timer = null;
      }, this.interval);
    }

    return this.lastResult;
  }

  /**
   * 取消尾部执行
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消尾部执行
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有尾部待执行
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取历史记录
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
    this.lastInvokeTime = 0;
    this.lastResult = null;
  }
}

进阶实现与组合优化

支持最大等待时间的防抖

支持最大等待时间的防抖,就是确保函数至少每隔 maxWait 时间执行一次:

function debounceMaxWait(fn, delay, maxWait) {
  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let lastInvokeTime = null;
  let maxTimer = null;

  const invoke = () => {
    lastInvokeTime = Date.now();
    fn.apply(lastContext, lastArgs);
    lastArgs = null;
    lastContext = null;
  };

  const startMaxWaitTimer = () => {
    if (maxTimer) clearTimeout(maxTimer);

    maxTimer = setTimeout(() => {
      if (lastArgs) {
        console.log('达到最大等待时间,强制执行');
        invoke();
      }
    }, maxWait);
  };

  return function (...args) {
    lastArgs = args;
    lastContext = this;

    // 清除现有延迟定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置最大等待时间定时器
    if (maxWait && !lastInvokeTime) {
      startMaxWaitTimer();
    }

    // 设置新的延迟定时器
    timer = setTimeout(() => {
      invoke();
      timer = null;

      if (maxTimer) {
        clearTimeout(maxTimer);
        maxTimer = null;
      }
    }, delay);
  };
}

动态调整延迟时间的防抖

根据调用频率动态调整等待时间:

function debounceAdaptive(fn, baseDelay, options = {}) {
  const {
    minDelay = 100,
    maxDelay = 1000,
    factor = 0.8
  } = options;

  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let callTimes = [];
  let currentDelay = baseDelay;

  const calculateDelay = () => {
    // 计算最近1秒内的调用频率
    const now = Date.now();
    callTimes = callTimes.filter(t => now - t < 1000);
    const frequency = callTimes.length;

    // 根据频率调整延迟
    if (frequency > 10) {
      // 高频调用,增加延迟
      currentDelay = Math.min(currentDelay * (1 + frequency / 100), maxDelay);
    } else if (frequency < 2) {
      // 低频调用,减少延迟
      currentDelay = Math.max(currentDelay * factor, minDelay);
    }

    return currentDelay;
  };

  return function (...args) {
    callTimes.push(Date.now());
    lastArgs = args;
    lastContext = this;

    if (timer) {
      clearTimeout(timer);
    }

    const delay = calculateDelay();
    console.log(`  当前延迟: ${Math.round(delay)}ms (调用频率: ${callTimes.length}/秒)`);

    timer = setTimeout(() => {
      fn.apply(lastContext, lastArgs);
      timer = null;
    }, delay);
  };
}

实际应用场景实战

搜索框自动补全

class SearchAutoComplete {
  constructor(options = {}) {
    this.searchAPI = options.searchAPI || this.mockSearchAPI;
    this.minLength = options.minLength || 2;
    this.debounceDelay = options.debounceDelay || 300;
    this.maxResults = options.maxResults || 10;
    this.cacheResults = options.cacheResults !== false;

    // 搜索缓存
    this.cache = new Map();

    // 创建防抖搜索函数
    this.debouncedSearch = this.createDebouncedSearch();

    // 请求计数器
    this.requestCount = 0;
    this.cacheHitCount = 0;
  }

  /**
   * 模拟搜索API
   */
  async mockSearchAPI(query) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 200));

    // 模拟搜索结果
    const results = [];
    const prefixes = ['apple', 'banana', 'orange', 'grape', 'watermelon'];

    for (let i = 1; i <= 5; i++) {
      results.push({
        id: i,
        text: `${query} 结果 ${i}`,
        category: prefixes[i % prefixes.length]
      });
    }

    return results;
  }

  /**
   * 创建防抖搜索函数
   */
  createDebouncedSearch() {
    const searchFn = async (query) => {
      // 检查缓存
      if (this.cacheResults && this.cache.has(query)) {
        this.cacheHitCount++;
        return this.cache.get(query);
      }

      // 执行真实搜索
      this.requestCount++;
      console.log(`  🌐 [请求#${this.requestCount}] "${query}"`);

      try {
        const results = await this.searchAPI(query);

        // 存入缓存
        if (this.cacheResults) {
          this.cache.set(query, results);

          // 限制缓存大小
          if (this.cache.size > 50) {
            const oldestKey = this.cache.keys().next().value;
            this.cache.delete(oldestKey);
          }
        }

        return results;
      } catch (error) {
        console.error(`搜索失败: ${query}`, error);
        return [];
      }
    };

    // 使用完整版防抖
    return debounceComplete(searchFn, this.debounceDelay, {
      immediate: false,
      maxWait: 1000
    });
  }

  /**
   * 用户输入处理
   */
  onInput(query) {

    // 忽略空查询
    if (!query || query.length < this.minLength) {
      console.log('  查询太短,忽略');
      return Promise.resolve([]);
    }

    // 执行防抖搜索
    return this.debouncedSearch(query)
      .then(results => {
        const limited = results.slice(0, this.maxResults);
        console.log(`返回 ${limited.length} 条结果`);
        this.renderResults(limited);
        return limited;
      })
      .catch(error => {
        console.error('搜索失败:', error);
        return [];
      });
  }

  /**
   * 渲染搜索结果
   */
  renderResults(results) {
    // 实际项目中这里会更新DOM
    console.log(' 搜索结果:');
    results.slice(0, 3).forEach((result, i) => {
      console.log(`    ${i + 1}. ${result.text}`);
    });
    if (results.length > 3) {
      console.log(`... 等 ${results.length} 条`);
    }
  }

  /**
   * 清空缓存
   */
  clearCache() {
    this.cache.clear();
    this.cacheHitCount = 0;
    console.log('搜索缓存已清空');
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return {
      requestCount: this.requestCount,
      cacheHitCount: this.cacheHitCount,
      cacheSize: this.cache.size,
      pending: this.debouncedSearch.pending(),
      debounceStats: this.debouncedSearch.getStats?.()
    };
  }
}

无限滚动加载

console.log('\n=== 无限滚动加载 ===\n');

class InfiniteScroll {
  constructor(options = {}) {
    this.loadMoreAPI = options.loadMoreAPI || this.mockLoadMoreAPI;
    this.throttleInterval = options.throttleInterval || 200;
    this.threshold = options.threshold || 200;
    this.pageSize = options.pageSize || 20;

    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];

    // 创建节流滚动处理函数
    this.throttledScroll = this.createThrottledScroll();

    // 记录最后一次滚动位置
    this.lastScrollPosition = 0;
    this.scrollHistory = [];
  }

  /**
   * 模拟加载更多数据
   */
  async mockLoadMoreAPI(page, pageSize) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 300));

    // 模拟数据
    const start = page * pageSize;
    const items = [];

    for (let i = 0; i < pageSize; i++) {
      items.push({
        id: start + i,
        title: `项目 ${start + i}`,
        content: `这是第 ${start + i} 个项目的内容`,
        timestamp: Date.now()
      });
    }

    // 模拟没有更多数据
    const hasMore = page < 10;

    return { items, hasMore };
  }

  /**
   * 创建节流滚动处理函数
   */
  createThrottledScroll() {
    const scrollHandler = async (scrollTop, clientHeight, scrollHeight) => {
      const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

      console.log(`滚动位置: ${scrollTop}, 距离底部: ${distanceFromBottom}px`);

      // 记录滚动位置
      this.lastScrollPosition = scrollTop;
      this.scrollHistory.push({
        position: scrollTop,
        timestamp: Date.now()
      });

      // 限制历史记录大小
      if (this.scrollHistory.length > 20) {
        this.scrollHistory.shift();
      }

      // 检查是否需要加载更多
      if (distanceFromBottom < this.threshold) {
        await this.loadMore();
      }
    };

    return throttleComplete(scrollHandler, this.throttleInterval, {
      leading: true,
      trailing: true
    });
  }

  /**
   * 处理滚动事件
   */
  onScroll(event) {
    const target = event.target;
    const scrollTop = target.scrollTop || target.scrollingElement?.scrollTop || 0;
    const clientHeight = target.clientHeight || window.innerHeight;
    const scrollHeight = target.scrollHeight || document.documentElement.scrollHeight;

    this.throttledScroll(scrollTop, clientHeight, scrollHeight);
  }

  /**
   * 加载更多数据
   */
  async loadMore() {
    if (this.isLoading || !this.hasMore) {
      console.log(`${this.isLoading ? '正在加载中' : '没有更多数据'}`);
      return;
    }

    this.isLoading = true;
    this.currentPage++;

    console.log(`加载第 ${this.currentPage} 页数据...`);

    try {
      const result = await this.loadMoreAPI(this.currentPage, this.pageSize);

      this.hasMore = result.hasMore;
      this.items.push(...result.items);

      console.log(`加载完成,当前总条目: ${this.items.length}`);
      console.log(`还有更多: ${this.hasMore}`);

      this.renderItems(result.items);
    } catch (error) {
      console.error('加载失败:', error);
      this.currentPage--; // 回退页数
    } finally {
      this.isLoading = false;
    }
  }

  /**
   * 渲染新加载的项目
   */
  renderItems(newItems) {
    // 实际项目中这里会更新DOM
    console.log('新增项目:');
    newItems.slice(0, 3).forEach((item, i) => {
      console.log(`${item.id}. ${item.title}`);
    });
    if (newItems.length > 3) {
      console.log(`... 等 ${newItems.length} 条`);
    }
  }

  /**
   * 重置到顶部
   */
  reset() {
    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];
    this.scrollHistory = [];
    console.log('滚动列表已重置');
  }

  /**
   * 获取滚动统计
   */
  getScrollStats() {
    if (this.scrollHistory.length < 2) {
      return { avgSpeed: 0 };
    }

    const recent = this.scrollHistory.slice(-10);
    let totalSpeed = 0;

    for (let i = 1; i < recent.length; i++) {
      const distance = recent[i].position - recent[i - 1].position;
      const timeDiff = recent[i].timestamp - recent[i - 1].timestamp;
      const speed = distance / timeDiff; // px/ms
      totalSpeed += speed;
    }

    return {
      avgSpeed: totalSpeed / (recent.length - 1),
      scrollCount: this.scrollHistory.length,
      lastPosition: this.lastScrollPosition
    };
  }
}

最佳实践指南

防抖最佳实践

  • 默认延迟时间:300-500ms(用户输入)、200-300ms(窗口调整)、1000ms(自动保存)
  • 搜索框建议使用防抖,避免频繁请求
  • 表单验证使用防抖,用户输完再验证
  • 提交按钮使用防抖,防止重复提交
  • 需要立即反馈的操作设置 immediate: true

节流最佳实践

  • 滚动加载:200-300ms(平衡响应性和性能)
  • 拖拽事件:16-33ms(约30-60fps)
  • 窗口大小调整:100-200ms
  • 游戏循环:使用 requestAnimationFrame 替代定时器节流
  • 频繁的状态更新:考虑使用 requestAnimationFrame

内存管理实践

  • 组件卸载时取消未执行的防抖/节流
  • 避免在全局作用域创建过多的防抖/节流函数
  • 使用缓存时注意设置最大缓存大小
  • 定期清理过期的缓存数据

调试技巧

  • 添加日志追踪函数调用
  • 记录调用历史便于回溯问题
  • 使用 Stats 统计调用次数和节流情况
  • 开发环境设置更短的延迟时间便于测试

防抖节流选择决策树

是否需要处理高频事件?
        │
        ├─→ 是
        │   │
        │   ├─→ 是否需要关注最后一次执行?
        │   │   │
        │   │   ├─→ 是 → 使用防抖
        │   │   │   │
        │   │   │   ├─→ 搜索建议、自动保存、表单验证
        │   │   │   └─→ 窗口调整、拖拽结束
        │   │   │
        │   │   └─→ 否 → 使用节流
        │   │       │
        │   │       ├─→ 滚动加载、拖拽中、动画帧
        │   │       └─→ 游戏循环、鼠标移动
        │   │
        │   └─→ 是否需要立即执行?
        │       │
        │       ├─→ 是 → immediate: true
        │       │   │
        │       │   ├─→ 按钮提交(防止双击)
        │       │   └─→ 数据埋点
        │       │
        │       └─→ 否 → immediate: false
        │           │
        │           ├─→ 搜索建议(避免每个字符都请求)
        │           └─→ 自动保存(停止编辑后保存)
        │
        └─→ 否 → 不需要特殊处理

最终建议

  1. 不要盲目使用防抖/节流,先评估是否真的需要
  2. 根据用户体验选择合理的延迟时间
  3. 为防抖/节流函数命名时标明其特性
  4. 在类组件中绑定this时注意上下文
  5. 优先使用成熟的库实现(lodash、underscore)
  6. 理解原理,但不一定需要每次都自己实现
  7. 监控实际效果,根据数据持续优化

结语

防抖和节流是前端性能优化的基本工具,掌握它们不仅能提升应用性能,还能优化用户体验。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

❌