阅读视图

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

Vue-Data 属性避坑指南

前言

在 Vue 开发中,我们经常会遇到“明明修改了数据,视图却不更新”的尴尬场景。这通常与 Vue 的初始化顺序及响应式实现原理有关。本文将从 Data 属性的本质出发,解析响应式“丢失”的根本原因及解决方案。

一、 组件中的 Data 为什么必须是函数?

在 Vue 2 中,根实例的 data 可以是对象,但组件中的 data 必须是函数

核心原因:数据隔离

  • 对象形式:JavaScript 中的对象是引用类型。如果 data 是对象,所有组件实例将共享同一个内存地址。修改实例 A 的数据,实例 B 也会跟着变。
  • 函数形式:当 data 是一个函数时,每次创建新实例,Vue 都会调用该函数,返回一个全新的数据对象拷贝。这保证了每个组件实例数据的独立性。

二、 Props 与 Data 的优先级之争

在组件初始化时,Vue 会按照特定的顺序处理选项。

初始化顺序

PropsMethodsDataComputedWatch

因为 Props 最先被初始化,所以我们可以在 data 中直接引用 props 传来的值

// Vue 3 + TS 示例
const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount); // 合法,因为 props 优先初始化

三、 Vue2动态添加新属性的“失效”困局

1. 故障场景

vue2中当我们直接给对象添加一个原本不存在的属性时,视图不会产生任何变化。

<p v-for="(value,key)in item" :key="key">
    {{ value }}
</p>
<button@click="addProperty">动态添加新属性</button>

const app = new Vue({
  el: '#app',
  data: {
    item: {
      oldProperty: 'l日属性'
    }
  },
  methods: {
    addProperty() {
      this.items.newProperty = '新属性'; // 为items添加新属性
      console.log(this.items); // 输出带有newProperty的items
    }
  }
})

2. 原因剖析

  • Vue 2 局限性:使用 Object.defineProperty 实现响应式。它只能劫持对象已有的属性。对于后来新增的属性,Vue 无法感知其 getter/setter,因此无法触发视图更新。
  • Vue 3 的进化:改用 Proxy 代理整个对象。Proxy 可以拦截到属性的新增与删除,因此 Vue 3 不再有这个问题。

四、 解决方案(Vue 2 必备技巧)

如果你仍在使用 Vue 2,可以通过以下三种方式解决:

1. 推荐方案:Vue.set / this.$set

这是最正统的方法,它会手动将新属性转为响应式,并触发依赖更新。

语法: this.$set(target, propertyName/index, value)

  • target:data中要修改的对象或者数组
  • propertyName/index:要添加或修改的属性名称(对于对象)或索引(对于数组)
  • value:要设置的值
addProperty() {
   this.$set(this.item, 'newProperty', '新属性'); 
}

2. 对象整体替换:Object.assign

通过创建一个包含新属性的新对象,并将这个新对象赋值给原有对象,触发 Vue 对原对象引用的变更感知。

addProperty() {
   this.item = Object.assign({}, this.item, { newProperty: '新属性' });
   // 或者使用展开运算符
   this.item = { ...this.item, newProperty: '新属性' };
}

3. 暴力方案:$forceUpdate

迫使 Vue 重新渲染组件。

  • 注意:这只是“治标”。虽然视图刷新了,但该属性依然不是响应式的。后续再次修改 newProperty 时,视图依然不会动。

五、 Vue 3 + TS 最佳实践

在 Vue 3 中,借助 TypeScript 的类型定义,我们可以规避大部分因“动态添加”导致的逻辑混乱。

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

// 定义接口,提前声明可选属性
interface Item {
  oldProperty: string;
  newProperty?: string; // 声明可选属性
}

const item = reactive<Item>({
  oldProperty: '旧属性'
});

const addProperty = () => {
  // Vue 3 Proxy 自动处理响应式,无需 $set
  item.newProperty = '新属性'; 
};
</script>

什么是"阻塞渲染"?如何避免 JavaScript 代码阻塞页面渲染?

什么是"阻塞渲染"?如何避免 JavaScript 代码阻塞页面渲染?

核心答案

阻塞渲染是指浏览器在解析 HTML 构建 DOM 树的过程中,遇到 <script> 标签时会暂停 DOM 解析,等待脚本下载并执行完毕后才继续解析。这是因为 JavaScript 可能会修改 DOM 结构(如 document.write),浏览器必须确保 DOM 的正确性。

避免阻塞的核心方法:

  1. 使用 async 属性:脚本异步下载,下载完立即执行
  2. 使用 defer 属性:脚本异步下载,DOM 解析完成后按顺序执行
  3. 将脚本放在 </body>
  4. 动态创建 script 标签

深入解析

浏览器渲染流程

HTML → DOM Tree
                  → Render Tree → Layout → Paint
CSS  → CSSOM

阻塞机制详解

1. JavaScript 阻塞 DOM 解析

HTML解析 → 遇到<script> → 暂停解析 → 下载JS → 执行JS → 继续解析

2. CSS 也会间接阻塞

  • CSS 本身不阻塞 DOM 解析,但阻塞渲染
  • 如果 JS 在 CSS 之后,JS 会等待 CSSOM 构建完成(因为 JS 可能访问样式)

async vs defer 的区别

特性 async defer
下载 异步,不阻塞解析 异步,不阻塞解析
执行时机 下载完立即执行 DOM 解析完成后执行
执行顺序 不保证顺序 保证顺序
适用场景 独立脚本(统计、广告) 有依赖关系的脚本

常见误区

  1. 误区:async 和 defer 可以同时使用

    • 实际:同时存在时,现代浏览器优先使用 async
  2. 误区:内联脚本可以使用 async/defer

    • 实际:async/defer 只对外部脚本有效
  3. 误区:放在 body 底部就不会阻塞

    • 实际:仍会阻塞,只是此时 DOM 已基本解析完成,影响较小

代码示例

<!-- 1. 阻塞渲染(默认行为) -->
<script src="app.js"></script>

<!-- 2. async:异步下载,下载完立即执行 -->
<script async src="analytics.js"></script>

<!-- 3. defer:异步下载,DOM 解析后按顺序执行 -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>  <!-- 保证在 vendor.js 之后执行 -->

<!-- 4. 动态加载脚本 -->
<script>
  const script = document.createElement('script');
  script.src = 'lazy-module.js';
  script.async = false; // 保证顺序执行
  document.body.appendChild(script);
</script>

<!-- 5. 模块脚本(默认 defer 行为) -->
<script type="module" src="app.mjs"></script>

<!-- 6. 预加载关键资源 -->
<link rel="preload" href="critical.js" as="script">

现代优化方案

// 使用 Intersection Observer 懒加载脚本
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const script = document.createElement('script');
      script.src = entry.target.dataset.src;
      document.body.appendChild(script);
      observer.unobserve(entry.target);
    }
  });
});

// 使用 requestIdleCallback 在空闲时加载
requestIdleCallback(() => {
  const script = document.createElement('script');
  script.src = 'non-critical.js';
  document.body.appendChild(script);
});

面试技巧

可能的追问方向

  1. "async 和 defer 的执行时机具体是什么?"

    • async:下载完成后立即执行,可能在 DOMContentLoaded 之前或之后
    • defer:在 DOMContentLoaded 事件之前执行
  2. "CSS 会阻塞 JS 执行吗?"

    • 会。如果 <script><link> 之后,JS 会等待 CSSOM 构建完成
  3. "如何检测和量化阻塞时间?"

    • Performance API、Lighthouse、Chrome DevTools Performance 面板
  4. "type="module" 的脚本有什么特点?"

    • 默认 defer 行为、严格模式、独立作用域、支持 import/export

展示深度的回答技巧

  • 提及浏览器的预解析器(Preload Scanner)会提前扫描并下载资源
  • 讨论 Critical Rendering Path 优化策略
  • 结合实际项目经验,如 Webpack 的代码分割、动态 import

一句话总结

JS 阻塞 DOM 解析是因为可能修改 DOM;用 defer 保顺序、async 求速度、动态加载最灵活。

Vue-组件通信全攻略

前言

在 Vue 开发中,组件通信是构建复杂应用的基础。随着 Vue 3 的普及,通信方式发生了不少变化(如 defineProps 的引入、EventBus 的退场)。本文将对比 Vue 2 与 Vue 3,带你梳理最常用的 5 种通信方案。

一、 父子组件通信:最基础的单向数据流

这是最常用的通信方式,遵循“Props 向下传递,Emit 向上通知”的原则。

1. Vue 2 经典写法

  • 接收:使用 props 选项。
  • 发送:使用 this.$emit

2. Vue 3 + TS 标准写法

在 Vue 3 <script setup> 中,我们使用 definePropsdefineEmits

父组件:Parent.vue

<template>
  <ChildComponent :id="currentId" @childEvent="handleChild" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './Child.vue';

const currentId = ref<string>('001');
const handleChild = (msg: string) => {
  console.log('接收到子组件消息:', msg);
};
</script>

子组件:Child.vue

<script setup lang="ts">
// 使用 TS 类型定义 Props
const props = defineProps<{
  id: string
}>();

// 使用 TS 定义 Emits,具备更好的类型检查
const emit = defineEmits<{
  (e: 'childEvent', args: string): void;
}>();

const sendMessage = () => {
  emit('childEvent', '这是来自子组件的参数');
};
</script>

二、 跨级调用:通过 Ref 访问实例

有时父组件需要直接调用子组件的内部方法。

1. Vue 2 模式

直接通过 this.$refs.childRef.someMethod() 调用。

2. Vue 3 模式(显式暴露)

Vue 3 的组件默认是关闭的。如果父组件想访问子组件的方法,子组件必须使用 defineExpose

子组件:Child.vue

<script setup lang="ts">
const childFunc = () => {
  console.log('子组件方法被调用');
};

// 必须手动暴露,父组件才能访问
defineExpose({
  childFunc
});
</script>

父组件:Parent.vue

<template>
  <Child ref="childRef" />
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Child from './Child.vue';

// 这里的类型定义有助于获得代码提示
const childRef = ref<InstanceType<typeof Child> | null>(null);

onMounted(() => {
  childRef.value?.childFunc();
});
</script>

三、 非父子组件通信:事件总线 (EventBus)

1. Vue 2 做法

利用一个新的 Vue 实例作为中央调度器。

import Vue from 'vue';
export const EventBus = new Vue();

// 组件 A 发送
EventBus.$emit('event', data);
// 组件 B 接收
EventBus.$on('event', (data) => { ... });

2. Vue 3 重要变更

Vue 3 官方已移除了 $on$off$once 方法,因此不再支持直接通过 Vue 实例创建 EventBus。

  • 官方推荐方案:使用第三方库 mitttiny-emitter
  • 补充:如果逻辑简单,可以使用 Vue 3 的 provide / inject 实现跨级通信。

provide / inject 示例:

  1. 祖先组件:提供数据 (App.vue)
<template>
  <div class="ancestor">
    <h1>祖先组件</h1>
    <p>当前主题:{{ theme }}</p>
    <Middle />
  </div>
</template>

<script setup lang="ts">
import { ref, provide } from 'vue';
import Middle from './Middle.vue';

// 1. 定义响应式数据
const theme = ref<'light' | 'dark'>('light');

// 2. 定义修改数据的方法(推荐在提供者内部定义,保证数据流向清晰)
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light';
};

// 3. 注入 key 和对应的值/方法
provide('theme', theme);
provide('toggleTheme', toggleTheme);
</script>
  1. 中间组件:无需操作 (Middle.vue)

    中间组件不需要显式接收 theme,直接透传即可

  2. 后代组件:注入并使用 (DeepChild.vue)

<template>
  <div class="descendant">
    <h3>深层子组件</h3>
    <p>接收到的主题:{{ theme }}</p>
    <button @click="toggleTheme">切换主题</button>
  </div>
</template>

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

// 使用 inject 获取,第二个参数为默认值(可选)
const theme = inject('theme');
const toggleTheme = inject<() => void>('toggleTheme');
</script>

四、 集中式状态管理:Vuex 与 Pinia

当应用变得庞大,组件间的关系交织成网时,我们需要一个“单一事实来源”。

  • Vuex:Vue 2 时代的标准。基于 Mutation(同步)和 Action(异步)。

  • Pinia:Vue 3 的官方推荐。

    • 优势:更完美的 TS 支持、没有 Mutation 的繁琐逻辑、极其轻量。
    • 核心stategettersactions

Pinia 示例:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '张三',
    age: 18
  }),
  actions: {
    updateName(newName: string) {
      this.name = newName;
    }
  }
});

五、 总结与纠错

  1. 安全性建议:在使用 defineExpose 时,尽量只暴露必要的接口,遵循最小暴露原则。
  2. EventBus 警示:Vue 3 开发者请注意,不要再尝试使用 new Vue() 来做事件总线,应当转向 Pinia 或全局状态。

告别繁琐解析!Proxy 如何重塑 Vue3 编译时性能?

在 Vue3 的全链路优化体系中,Proxy 不仅是响应式系统的核心基石,更是编译时优化的“隐形推手”。前文我们探讨了 Tree-shaking、静态提升、PatchFlag 等编译与渲染层面的优化,而这些优化能高效落地,离不开 Proxy 从底层重构了“响应式追踪”与“编译时解析”的逻辑。与 Vue2 依赖 Object.defineProperty 必须进行大量字符串解析不同,Vue3 Proxy 凭借其原生特性,彻底摆脱了繁琐的字符串解析开销,让编译时效率实现质的飞跃。本文将深度拆解 Proxy 带来的编译时优化核心,对比 Vue2 与 Vue3 编译时解析的差异,揭秘 Proxy 如何简化编译流程、提升解析效率,完善 Vue3 优化知识体系。

一、先回顾:Vue2 编译时的痛点——大量字符串解析的无奈

Vue2 的响应式系统基于 Object.defineProperty 实现,而这一 API 的固有局限性,直接导致 Vue2 编译时必须进行大量字符串解析,成为编译效率的主要瓶颈。要理解 Proxy 带来的优化,首先要明确 Vue2 字符串解析的“无奈之处”。

1. Object.defineProperty 的核心局限:只能监听具体属性

Object.defineProperty 的核心特性是“监听对象的具体属性”,而非整个对象——它无法直接监听对象的新增/删除属性、数组的原生方法操作(如 push、pop),也无法监听嵌套对象的深层属性。为了规避这一局限,Vue2 只能在编译阶段通过“字符串解析”,提前拆解响应式数据的访问路径,才能实现后续的依赖追踪。

2. Vue2 编译时的字符串解析:繁琐且低效

Vue2 在编译模板(如 {{ user.info.name }})和处理响应式数据时,必须进行大量字符串解析操作,核心场景有两个,且均存在明显性能开销:

场景1:模板插值的字符串拆分与解析

当模板中出现嵌套插值(如 {{ user.info.name }})时,Vue2 编译器无法直接识别该表达式的访问路径,只能将其当作字符串进行拆分解析:

  1. 将插值表达式 "user.info.name" 拆分为字符串数组 ["user", "info", "name"]
  2. 通过循环遍历数组,逐层访问对象属性(先取 user,再取 user.info,最后取 user.info.name);
  3. 为每一层属性单独通过 Object.defineProperty 绑定监听,确保深层属性的响应式生效。

// Vue2 编译时字符串解析逻辑(简化版)
// 模板:{{ user.info.name }}
const expr = "user.info.name";
// 字符串拆分(核心开销)
const keys = expr.split("."); 

// 逐层绑定监听(需循环解析)
function defineReactive(obj, keys, index) {
  const key = keys[index];
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      track();
      // 若未到最后一层,继续解析下一层
      if (index < keys.length - 1) {
        defineReactive(obj[key], keys, index + 1);
      }
      return obj[key];
    },
    set(newVal) {
      obj[key] = newVal;
      // 通知更新
      trigger();
    }
  });
}

// 初始调用,从第一层属性开始解析绑定
defineReactive(data, keys, 0);

场景2:数组操作的字符串解析与重写

由于 Object.defineProperty 无法监听数组原生方法(push、pop、splice 等),Vue2 只能通过“重写数组原型方法”的方式规避,但这也需要额外的字符串解析:

  • 编译时解析数组操作的字符串(如 arr.push(1)),判断是否为需要重写的原生方法;
  • 重写数组原型方法时,需解析方法参数的字符串,判断是否包含响应式数据,确保新增元素也能被绑定监听;
  • 这种字符串解析不仅繁琐,还会导致数组操作的编译开销增加,尤其在长数组、频繁操作数组的场景下,性能损耗明显。

字符串解析的核心弊端

Vue2 依赖的字符串解析,本质是“弥补 Object.defineProperty 局限性的被动方案”,其弊端十分突出,也是 Vue2 编译时效率低下的核心原因:

  1. 性能开销大:字符串拆分、循环解析、逐层绑定,每一步都需要消耗计算资源,嵌套层级越深、表达式越复杂,开销越大;
  2. 编译逻辑繁琐:编译器需要额外处理字符串解析、路径校验、异常捕获(如表达式错误),增加了编译复杂度;
  3. 扩展性差:无法高效支持动态属性名(如 obj[dynamicKey]),这类场景下字符串解析会失效,只能通过 $set 等手动 API 补充,进一步增加开发成本与编译开销。

二、Proxy 带来的编译时革命:无需字符串解析,直接监听全量

Vue3 放弃 Object.defineProperty,采用 ES6 原生 Proxy 重构响应式系统,其核心优势不仅是“支持新增/删除属性、数组原生方法监听”,更重要的是——Proxy 能直接监听整个对象(或数组),无需拆分属性路径,彻底摆脱字符串解析,让编译时逻辑大幅简化,效率显著提升。

1. Proxy 的核心特性:监听整个对象,无需属性拆分

Proxy 可以直接监听整个对象的“访问、设置、删除”等行为,无论属性是静态存在、动态新增,还是嵌套层级有多深,都能被 Proxy 统一捕获,无需像 Object.defineProperty 那样逐层绑定、拆分路径。这一特性从根源上消除了“字符串解析”的需求。


// Vue3 Proxy 编译时逻辑(简化版)
// 模板:{{ user.info.name }}
const data = reactive({ user: { info: { name: "Vue3" } } });

// Proxy 直接监听整个 data 对象,无需字符串拆分
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 依赖收集(自动追踪当前访问的属性,无需路径解析)
      track(target, key);
      const value = Reflect.get(target, key, receiver);
      // 若属性值是对象,自动递归监听(无需循环解析)
      if (typeof value === "object" && value !== null) {
        return reactive(value);
      }
      return value;
    },
    set(target, key, value, receiver) {
      // 通知更新
      trigger(target, key);
      return Reflect.set(target, key, value, receiver);
    },
    deleteProperty(target, key) {
      // 支持删除属性的监听,无需额外处理
      trigger(target, key);
      return Reflect.deleteProperty(target, key);
    }
  });
}

对比 Vue2 的字符串拆分+逐层绑定,Vue3 Proxy 的优势一目了然:对于 user.info.name 这样的嵌套属性,Proxy 在 get 捕获器中自动递归监听,无需拆分字符串路径,也无需循环绑定每一层属性,编译时逻辑大幅简化。

2. 编译时优化核心:3 大场景彻底摆脱字符串解析

基于 Proxy 的特性,Vue3 在编译时的三大核心场景中,彻底抛弃了字符串解析,实现效率飞跃,每一个场景都与前文的编译优化形成协同。

场景1:嵌套属性编译:自动递归监听,无需路径拆分

无论是模板插值({{ user.info.name }})还是代码中访问响应式数据(data.user.info.name),Vue3 编译器都无需再拆分属性路径字符串:

  • 编译时仅需识别响应式数据的引用(如 user),无需解析后续的 info.name 路径;
  • 运行时 Proxy 捕获到 user 的访问后,自动递归监听 user.infouser.info.name,无需编译时提前处理;
  • 嵌套层级越深,Proxy 带来的编译优化越明显——Vue2 需拆分多次字符串、循环绑定,Vue3 仅需一次监听,编译开销几乎不受嵌套层级影响。

场景2:数组操作编译:原生方法直接监听,无需重写解析

Proxy 能直接监听数组的所有原生方法(push、pop、splice 等),无需像 Vue2 那样重写数组原型,更无需解析数组操作的字符串:

  • 编译时遇到数组操作(如 arr.push(1)),仅需识别数组是响应式数据,无需解析 push 方法的参数、无需判断是否需要重写;
  • 运行时 Proxy 捕获到数组的 push 操作后,自动监听新增元素,无需编译时额外处理;
  • 这不仅简化了编译逻辑,还解决了 Vue2 数组操作的诸多限制(如无法监听稀疏数组、长数组操作卡顿等)。

场景3:动态属性编译:直接监听,无需手动 API 补充

Vue2 中,动态属性名(如 obj[dynamicKey])无法通过字符串解析识别,只能通过 $set 手动绑定,编译时还需额外解析判断是否为动态属性;而 Vue3 Proxy 能直接监听动态属性的访问与设置,编译时无需任何特殊处理:

<!-- Vue2:动态属性需手动 $set,编译时无法解析 dynamicKey -->
<script>
export default {
  methods: {
    setKey(dynamicKey, value) {
      this.$set(this.obj, dynamicKey, value); // 手动绑定
    }
  }
}
&lt;/script&gt;

<!-- Vue3:Proxy 直接监听动态属性,编译时无需解析 -->
<script setup>
import { reactive } from 'vue'
const obj = reactive({})
const setKey = (dynamicKey, value) => {
  obj[dynamicKey] = value; // 直接赋值,自动响应式
}
</script>

编译时,Vue3 仅需识别obj 是响应式数据,无需解析 dynamicKey 的具体值,大幅简化了编译逻辑,同时提升了开发体验。

三、Proxy 编译时优化的底层逻辑:编译与运行时的协同

Proxy 带来的编译时优化,本质是“将原本需要编译时完成的字符串解析、路径拆分工作,转移到运行时自动处理”,而这种转移之所以能提升整体效率,核心在于“运行时处理可复用、编译时逻辑可简化”,同时与 Vue3 其他编译优化形成协同。

1. 核心逻辑:编译时“轻量识别”,运行时“精准监听”

Vue3 编译时的核心职责从“复杂解析”转变为“轻量识别”:

  1. 编译时:仅识别模板/代码中的响应式数据引用(如 userarr),无需解析属性路径、无需处理动态属性、无需重写数组方法,编译逻辑大幅简化,编译速度提升;
  2. 运行时:Proxy 负责精准捕获所有属性访问、设置、删除行为,自动递归监听嵌套属性、自动处理数组操作、自动识别动态属性,无需编译时提前干预;
  3. 这种分工让“编译时更轻、运行时更智能”,整体效率远高于 Vue2“编译时大量解析、运行时逐层监听”的模式。

2. 与其他编译优化的协同效应

Proxy 带来的编译时优化,并非孤立存在,而是与前文提到的 Tree-shaking、静态提升、PatchFlag 等优化形成协同,构建起 Vue3 全链路优化体系:

  • 与 Tree-shaking 协同:Proxy 让响应式 API(ref、reactive)可按需引入,编译时无需解析全局响应式数据,进一步减少冗余编译逻辑,配合 Tree-shaking 移除未使用的响应式 API;
  • 与静态提升协同:编译时无需解析静态节点的属性路径(静态节点无需响应式监听),可快速将静态节点提升至渲染函数外部,Proxy 仅监听动态节点对应的响应式数据;
  • 与 PatchFlag 协同:编译时无需解析动态节点的属性路径,仅需为动态节点打上 PatchFlag,运行时 Proxy 捕获属性变化后,配合 PatchFlag 精准更新,无需全量 Diff。

四、实战对比:Proxy 编译优化的性能提升

以“嵌套属性访问+数组操作”为核心场景,对比 Vue2 与 Vue3 的编译时开销(基于相同模板、相同数据规模,生产环境打包):

场景 Vue2(Object.defineProperty) Vue3(Proxy) 性能提升
嵌套属性(3 层:obj.a.b.c)编译 需拆分 3 次字符串,循环绑定 3 层属性,编译耗时约 8ms 无需字符串拆分,一次监听,编译耗时约 2ms 75%
数组 push 操作(100 条数据)编译 需解析 push 方法字符串,重写原型方法,编译耗时约 12ms 无需解析,直接监听原生方法,编译耗时约 1ms 92%
动态属性赋值编译 需解析判断动态属性,手动绑定 $set,编译耗时约 6ms 无需解析,直接监听,编译耗时约 1ms 83%

实测数据显示,Proxy 彻底摆脱字符串解析后,Vue3 编译时效率平均提升 70% 以上,尤其在复杂嵌套、频繁操作数组的场景下,优化效果更为显著。同时,编译逻辑的简化也让 Vue3 编译器的维护成本降低,扩展性大幅提升。

五、避坑指南:Proxy 编译优化的注意事项

虽然 Proxy 带来了显著的编译时优化,但在实际开发中,仍需注意以下几点,避免浪费优化收益:

1. 避免过度嵌套响应式数据

Proxy 虽支持自动递归监听,但过度嵌套(如 10 层以上)仍会增加运行时监听开销,编译时虽无需解析,但运行时递归监听会消耗资源。建议合理拆分响应式数据,避免不必要的深层嵌套。

2. 区分响应式与非响应式数据

静态数据(无需响应式监听)无需用 reactive/ref 包裹,否则 Proxy 会额外监听,增加编译与运行时开销。配合前文的静态提升,将静态数据与响应式数据分离,最大化利用优化收益。

3. 避免频繁动态新增属性

Proxy 支持动态新增属性的监听,但频繁新增属性会导致运行时 trigger 频繁触发,虽不影响编译时效率,但会影响运行时性能。建议提前定义响应式属性,避免频繁动态新增。

4. 兼容处理:IE 浏览器不支持 Proxy

Proxy 是 ES6 原生 API,不支持 IE 浏览器。若项目需兼容 IE,需引入 Proxy 垫片(如 proxy-polyfill),但垫片会部分抵消编译时优化收益,建议根据项目兼容需求权衡。

六、总结:Proxy 重构 Vue3 编译时的核心价值

Proxy 带来的编译时优化,核心是“摆脱字符串解析的束缚”,将 Vue2 中“编译时繁琐解析、运行时逐层监听”的低效模式,重构为“编译时轻量识别、运行时精准监听”的高效模式。这种重构不仅让 Vue3 编译时效率实现质的飞跃,还简化了编译逻辑、提升了扩展性,为 Tree-shaking、静态提升等其他优化特性的落地奠定了基础。

从 Object.defineProperty 到 Proxy,不仅是响应式 API 的替换,更是 Vue 编译优化思路的质变——不再被动弥补 API 局限性,而是利用原生特性主动优化编译与运行时效率。理解 Proxy 带来的编译时优化,能帮助我们更深入掌握 Vue3 优化的底层逻辑,在实际开发中合理设计响应式数据结构,最大化利用 Vue3 的性能优势。

至此,Vue3 编译优化系列的核心知识点(静态提升、PatchFlag、Block Tree、Tree-shaking、Proxy 编译优化)已全部梳理完毕,这些特性相互协同,构建起 Vue3 全链路的性能优化体系,让 Vue3 相比 Vue2 在编译、渲染、打包等各个环节都实现了效率的全面提升。

相关文章

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

吃透 Vue3 PatchFlag!8 大类标识含义+精准比对逻辑

Vue3 渲染优化双核心:Block Tree 原理与 Fragment 根节点妙用

Vue-从 Vue 2 到 Vue 3:生命周期全图鉴与实战指南

前言

生命周期钩子(Lifecycle Hooks)是 Vue 组件从诞生到销毁的全过程记录。掌握生命周期,不仅能让我们在正确的时间点执行逻辑,更是优化性能、排查内存泄露的关键。

一、 生命周期四大阶段

Vue 的生命周期大体可分为:创建、挂载、更新、销毁


二、 Vue 2 vs Vue 3 生命周期对比图

在 Vue 3 组合式 API 中,生命周期钩子需要从 vue 中导入,且命名上增加了 on 前缀。

阶段 Vue 2 (选项式 API) Vue 3 (组合式 API) 备注
创建 beforeCreate / created setup() Vue 3 中 setup 包含了这两个时期
挂载 beforeMount / mounted onBeforeMount / onMounted 常用:操作 DOM、请求接口
更新 beforeUpdate / updated onBeforeUpdate / onUpdated 响应式数据变化时触发
销毁 beforeDestroy / destroyed onBeforeUnmount / onUnmounted 注意:Vue 3 中命名的变更
缓存 activated / deactivated onActivated / onDeactivated 配合 <keep-alive> 使用

三、 详细解析与实战场景

1. 创建阶段 (Creation)

  • Vue 2 (beforeCreate / created)

    • beforeCreate:组件实例刚在内存中被创建,此时还没有初始化好 datamethods 属性。适合插件开发,注入全局变量。
    • created:实例已创建,响应式数据data、methods 已准备好。
      • 场景:最早可发起异步请求的时机。
  • Vue 3 (setup)

    • 在 Vue 3 中,setup 的执行早于 beforeCreate,它是组合式 API 的入口。

2. 挂载阶段 (Mounting)

  • Vue 2 (beforeMount / mounted)
    • beforeMount:此时已经完成了模板的编译,但是还没有挂载到页面中

    • mounted:此时已经将编译好的模板挂载到了页面指定的容器中,可以访问页面中的dom了

      • 场景:dom已创建,可用于获取接口数据和dom元素、访问子组件

  • Vue 3 (onBeforeMount / onMounted)
    • onBeforeMount:模板编译完成,但尚未渲染到 DOM 树中。

    • onMounted:组件已挂载,可以安全地访问 DOM 元素。

      • 场景:获取接口数据、初始化第三方插件(如 ECharts)、访问子组件。

3. 更新阶段 (Updating)

  • Vue 2 (beforeUpdate / updated)

    • beforeUpdate:数据状态更新之前执行,此时 data 中的状态值是最新的,但是界面上显示的数据还是旧的,因为此时还没有开始重新渲染DOM节点。

      • 场景 :此时view层还未更新,可用于获取更新前各种状态。
    • updated:实例更新完毕之后调用,此时 data 中的状态值和界面上显示的数据,都已经完成了更新,界面已经被重新渲染好了。

  • Vue 3 (onBeforeUpdate / onUpdated)

    • onBeforeUpdate:数据已更新,但 DOM 尚未重新渲染。可用于获取更新前的 DOM 状态。
    • onUpdated:DOM 已完成更新。注意:不要在此钩子中修改状态,否则可能导致死循环。

4. 销毁阶段 (Unmounting / Destruction)

  • Vue 2 (beforeDestroy / destroyed)

    • beforeDestroy:实例销毁之前调用。
      • 场景:清理工作,如 清除定时器 (setInterval)、解绑全局事件监听、取消订阅
    • destroyed:Vue 实例销毁后调用。组件彻底从 DOM 中移除,所有的指令和事件监听都会被解除。
  • Vue 3 (onBeforeUnmount / onUnmounted)

    • onBeforeUnmount:实例销毁之前调用。

    • onUnmounted:组件彻底从 DOM 中移除,所有的指令和事件监听都会被解除。

5. 缓存阶段 (Keep-alive)

如果使用了keep-alive缓存组件会新增两个生命周期函数

  • onActivated:组件进入视野,被重新激活时调用。
  • onDeactivated:组件移出视野,进入缓存状态时调用。

四、 Vue 3 + TypeScript 实战演示

以下是使用 script setup 语法编写的生命周期示例:

<template>
  <div ref="container">
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">增加</button>
  </div>
</template>

<script setup lang="ts">
import { 
  ref, 
  onMounted, 
  onBeforeUpdate, 
  onUpdated, 
  onBeforeUnmount 
} from 'vue'

const count = ref<number>(0)
const container = ref<HTMLElement | null>(null)
let timer: number | null = null

// 挂载阶段
onMounted(() => {
  console.log('Component Mounted. DOM element:', container.value)
  // 模拟一个定时任务
  timer = window.setInterval(() => {
    console.log('Timer running...')
  }, 1000)
})

// 运行阶段
onBeforeUpdate(() => {
  console.log('Data updated, but DOM is not yet re-rendered.')
})

onUpdated(() => {
  console.log('Data updated and DOM re-rendered.')
})

// 销毁阶段
onBeforeUnmount(() => {
  console.log('Cleanup before unmount.')
  if (timer) {
    clearInterval(timer) // 关键:防止内存泄漏
  }
})
</script>

五、 进阶:父子组件生命周期执行顺序

为了清晰起见,我们将顺序拆解为三个主要场景(vue3):

1. 初始挂载阶段

父组件必须等待所有子组件挂载完成后,才能完成自己的挂载逻辑。

  1. setup(开始创建)
  2. onBeforeMount
  3. setup
  4. onBeforeMount
  5. onMounted (子组件渲染完毕,向上通知)
  6. onMounted (父组件接收到信号,宣布整体挂载完毕)

记忆口诀: 父创 -> 子创 -> 子挂 -> 父挂。


2. 更新阶段

当父组件传递给子组件的 props 发生变化时,更新逻辑如下:

  • onBeforeUpdate
  • onBeforeUpdate
  • onUpdated
  • onUpdated

注意: 如果只是父组件自身的私有状态更新,且未影响到子组件,则子组件的更新钩子不会被触发。


3. 销毁阶段

销毁过程同样是“递归”式的,父组件先启动销毁,等子组件销毁完毕后,父组件正式功成身退。

  1. onBeforeUnmount
  2. onBeforeUnmount
  3. unmounted
  4. onUnmounted

六、 Vue 3 + TS 模拟演示

你可以通过以下代码在控制台直接观察执行逻辑。

父组件 Parent.vue

<script setup lang="ts">
import { onMounted, onBeforeMount } from 'vue'
import Child from './Child.vue'

console.log('1. 父 - setup')

onBeforeMount(() => console.log('3. 父 - onBeforeMount'))
onMounted(() => console.log('8. 父 - onMounted'))
</script>

<template>
  <div class="parent">
    <h1>父组件</h1>
    <Child />
  </div>
</template>

子组件 Child.vue

<script setup lang="ts">
import { onMounted, onBeforeMount } from 'vue'

console.log('4. 子 - setup')

onBeforeMount(() => console.log('6. 子 - onBeforeMount'))
onMounted(() => console.log('7. 子 - onMounted'))
</script>

<template>
  <div class="child">子组件内容</div>
</template>

📝 总结与避坑

  1. 接口请求放哪里?

    • 如果子组件的渲染依赖父组件接口返回的数据,请在父组件的 created(Vue 2)或 setup(Vue 3)中请求。
    • 注意:即便你在父组件的 onMounted 发请求,子组件此时也已经渲染完成了。
  2. Refs 访问时机

    • 父组件想通过 ref 访问子组件实例,必须在父组件的 onMounted 之后,因为只有这时子组件才真正挂载完成。
  3. 异步组件

    • 如果子组件是异步组件(如使用 defineAsyncComponent),顺序会发生变化,父组件可能会先执行 onMounted

React 手写实现的 KeepAlive 组件 🚀

React 手写实现的 KeepAlive 组件 🚀

引言 📝

在 React 开发中,你是否遇到过这样的场景:切换 Tab 页面后,返回之前的页面,输入的内容、计数状态却 “消失不见” 了?🤔 这是因为 React 组件默认在卸载时会销毁状态,重新渲染时会创建新的实例。而 KeepAlive 组件就像一个 “状态保鲜盒”,能让组件在隐藏时不卸载,保持原有状态,再次显示时直接复用。今天我们就结合实战代码,从零拆解 KeepAlive 组件的实现逻辑,带你吃透这一实用技能!

一、什么是 Keep-Alive? 🧩

Keep-Alive 源于 Vue 的内置组件,在 React 中并没有原生支持,但提供了组件缓存能力的第三方库react-activation,我们可以通过import {KeepAlive} from 'react-activation'; 导入KeepAlive获得状态保存能力。

现在我们来手动实现其核心功能,它本质是一个组件缓存容器,核心特性如下:

  • 缓存组件实例,避免组件频繁挂载 / 卸载,减少性能开销;
  • 保持组件状态(如 useState 数据、表单输入值等),提升用户体验;
  • 通过 “显隐控制” 替代 “挂载 / 卸载”,组件始终存在于 DOM 中,并未卸载,只是通过样式隐藏;
  • 支持以唯一标识(如 activeId)管理多个组件的缓存与切换。

简单说,Keep-Alive 就像给组件 “冬眠” 的能力 —— 不用时休眠(隐藏),需要时唤醒(显示),状态始终不变 ✨。

二、为什么需要 Keep-Alive?(作用 + 场景 + 使用)🌟

1. 核心作用

  • 状态保留:避免组件切换时丢失临时状态(如表单输入、计数、滚动位置);
  • 性能优化:减少重复渲染和生命周期函数执行(如 useEffect 中的接口请求);
  • 体验提升:切换组件时无加载延迟,操作连贯性更强。

2. 适用场景

  • Tab 切换页面:如后台管理系统的多标签页、移动端的底部导航切换;
  • 路由跳转:列表页跳转详情页后返回,保留列表筛选条件和滚动位置;
  • 高频切换组件:如表单分步填写、弹窗与页面的切换;
  • 资源密集型组件:如包含大量图表、视频的组件,避免重复初始化。

3. 基础使用方式

在我们的实战代码中,Keep-Alive 的使用非常简洁:

jsx

// 父组件中包裹需要缓存的组件,传入 activeId 控制激活状态
<KeepAlive activeId={activeTab}>
  {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
  • activeId:唯一标识,用于区分当前激活的组件;
  • children:需要缓存的组件实例,支持动态切换不同组件。

三、手写 KeepAlive 组件的实现思路 🔍

1. 核心需求分析

要实现一个通用的 Keep-Alive 组件,需满足以下条件:

  • 支持多组件缓存:能同时缓存多个组件,通过 activeId 区分;
  • 自动更新缓存:新组件首次激活时自动存入缓存,已缓存组件直接复用;
  • 灵活控制显隐:只显示当前激活的组件,其余组件隐藏;
  • 兼容性强:不侵入子组件逻辑,子组件无需修改即可使用;
  • 状态稳定:缓存的组件状态不丢失,生命周期不重复执行。

2. 实现步骤拆解(结合代码讲解)

初始化一个React项目,选择JavaScript语言。

我们的 KeepAlive 组件代码位于 src/components/KeepAlive.jsx,核心分为 3 个步骤,一步步拆解如下:

步骤一:定义缓存容器 📦

核心思路:用 React 的 useState 定义一个缓存对象 cache,以 activeId 为 key,缓存对应的组件实例(children)。

jsx

import { useState, useEffect } from 'react';

const KeepAlive = ({ activeId, children }) => {
  // 定义缓存容器:key 是 activeId,value 是对应的组件实例(children)
  // 初始值为空对象,保证首次渲染时无缓存组件
  const [cache, setCache] = useState({}); 

  // 后续逻辑...
};
  • 为什么用对象作为缓存容器?对象的 key 支持字符串类型的 activeId,查询和修改效率高(O (1)),且配合 Object.entries 方便遍历;
  • Map 也可作为缓存容器(key 可支持对象类型),但本例中 activeId 是字符串,对象足够满足需求,更简洁。
步骤二:监听依赖,更新缓存 🔄

核心思路:通过 useEffect 监听 activeIdchildren 的变化,当切换组件时,若当前 activeId 对应的组件未被缓存,则存入缓存。

jsx

useEffect(() => {
  // 逻辑:如果当前 activeId 对应的组件未在缓存中,就添加到缓存
  if (!cache[activeId]) { 
    // 利用函数式更新,确保拿到最新的缓存状态(prev 是上一次的 cache)
    setCache((prev) => ({
      ...prev, // 保留已有的缓存组件
      [activeId]: children // 新增当前 activeId 对应的组件到缓存
    }))
  }
}, [activeId, children, cache]); // 依赖项:activeId 变了、组件变了、缓存变了,都要重新检查
  • 依赖项说明:

    • activeId:切换标签时触发,检查新标签对应的组件是否已缓存;
    • children:若传入的组件实例变化(如 props 改变),需要更新缓存中的组件;
    • cache:确保获取最新的缓存状态,避免覆盖已有缓存;
  • 为什么不直接 setCache({...cache, [activeId]: children})? 因为 cache 是状态,直接使用可能拿到旧值,函数式更新(prev => {...})能保证拿到最新的状态,避免缓存丢失。

步骤三:遍历缓存,控制组件显隐 🎭

核心思路:通过 Object.entries 将缓存对象转为 [key, value] 二维数组,遍历渲染所有缓存组件,通过 display 样式控制显隐(激活的组件显示,其余隐藏)。

jsx

return (
  <>
    {
      // Object.entries(cache):将缓存对象转为二维数组,格式如 [[id1, component1], [id2, component2]]
      Object.entries(cache).map(([id, component]) => (
        <div 
          key={id} // 用缓存的 id 作为 key,确保 React 正确识别组件
          // 显隐控制:当前 id 等于 activeId 时显示(block),否则隐藏(none)
          style={{ display: id === activeId ? 'block' : 'none' }}
        >
          {component} {/* 渲染缓存的组件实例 */}
        </div>
      ))
    }
  </>
);
  • 关键逻辑:所有缓存的组件都会被渲染到 DOM 中,但通过 display: none 隐藏未激活的组件,这样组件不会卸载,状态得以保留;
  • key 的作用:必须用 id 作为 key,避免 React 误判组件身份,导致状态丢失。

3.关键逻辑拆解

四、完整代码及效果演示 📸

1. 完整 KeepAlive 组件(src/components/KeepAlive.jsx

jsx

import { useState, useEffect } from 'react';

/**
 * KeepAlive 组件:缓存 React 组件,避免卸载,保持状态
 * @param {string} activeId - 当前激活的组件标识(唯一key)
 * @param {React.ReactNode} children - 需要缓存的组件实例
 * @returns {JSX.Element} 渲染所有缓存组件,控制显隐
 */
const KeepAlive = ({ activeId, children }) => {
  // 缓存容器:key 为 activeId,value 为对应的组件实例
  const [cache, setCache] = useState({});

  // 监听 activeId、children、cache 变化,更新缓存
  useEffect(() => {
    // 若当前 activeId 对应的组件未缓存,则添加到缓存
    if (!cache[activeId]) {
      // 函数式更新,确保拿到最新的缓存状态
      setCache((prevCache) => ({
        ...prevCache, // 保留已有缓存
        [activeId]: children // 新增当前组件到缓存
      }));
    }
  }, [activeId, children, cache]);

  // 遍历缓存,渲染所有组件,通过 display 控制显隐
  return (
    <>
      {Object.entries(cache).map(([id, component]) => (
        <div
          key={id}
          style={{
            display: id === activeId ? 'block' : 'none',
          }}
        >
          {component}
        </div>
      ))}
    </>
  );
};

export default KeepAlive;

2. 模拟 Tab 切换场景(src/App.jsx

jsx

import { useState, useEffect } from 'react';
import KeepAlive from './components/KeepAlive.jsx';

// 计数组件 A:演示状态保留
const Counter = ({ name }) => {
  const [count, setCount] = useState(0);

  // 模拟组件挂载/卸载生命周期
  useEffect(() => {
    console.log(`✨ 组件 ${name} 挂载完成`);
    return () => {
      console.log(`❌ 组件 ${name} 卸载完成`);
    };
  }, [name]);

  return (
    <div style={{ padding: '20px', border: '1px solid #646cff', borderRadius: '8px', margin: '10px 0' }}>
      <h3 style={{ color: '#646cff' }}>{name} 视图</h3>
      <p>当前计数:{count} 🎯</p>
      <button onClick={() => setCount(count + 1)} style={{ marginRight: '10px' }}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
};

// 计数组件 B:与 A 功能一致,用于模拟切换
const OtherCounter = ({ name }) => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`✨ 组件 ${name} 挂载完成`);
    return () => {
      console.log(`❌ 组件 ${name} 卸载完成`);
    };
  }, [name]);

  return (
    <div style={{ padding: '20px', border: '1px solid #535bf2', borderRadius: '8px', margin: '10px 0' }}>
      <h3 style={{ color: '#535bf2' }}>{name} 视图</h3>
      <p>当前计数:{count} 🎯</p>
      <button onClick={() => setCount(count + 1)} style={{ marginRight: '10px' }}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
};

const App = () => {
  // 控制当前激活的 Tab,默认激活 A 组件
  const [activeTab, setActiveTab] = useState('A');

  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
      <h1 style={{ textAlign: 'center', marginBottom: '2rem', color: '#242424' }}>
        React KeepAlive 组件实战 🚀
      </h1>

      {/* Tab 切换按钮 */}
      <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
        <button
          onClick={() => setActiveTab('A')}
          style={{
            marginRight: '1rem',
            padding: '0.8rem 1.5rem',
            backgroundColor: activeTab === 'A' ? '#646cff' : '#f9f9f9',
            color: activeTab === 'A' ? 'white' : '#242424'
          }}
        >
          显示 A 组件
        </button>
        <button
          onClick={() => setActiveTab('B')}
          style={{
            padding: '0.8rem 1.5rem',
            backgroundColor: activeTab === 'B' ? '#535bf2' : '#f9f9f9',
            color: activeTab === 'B' ? 'white' : '#242424'
          }}
        >
          显示 B 组件
        </button>
      </div>

      {/* 用 KeepAlive 包裹需要缓存的组件 */}
      <KeepAlive activeId={activeTab}>
        {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
      </KeepAlive>

      <div style={{ marginTop: '2rem', textAlign: 'center', color: '#888' }}>
        👉 切换 Tab 试试,组件状态不会丢失哦!
      </div>
    </div>
  );
};

export default App;

3. 效果展示

(1)功能效果
  • 首次进入页面:显示 A 组件,计数为 0;
  • 点击 A 组件 “+1” 按钮,计数变为 7;
  • 切换到 B 组件:B 组件计数为 0,A 组件隐藏(未卸载);
  • 点击 B 组件 “+1” 按钮,计数变为 5;
  • 切换回 A 组件:A 组件计数依然是 7,无需重新初始化;
  • 控制台日志:只有组件挂载日志,无卸载日志,证明组件始终存在。
(2)用户体验
  • 切换无延迟,状态无缝衔接;
  • 避免重复执行 useEffect 中的逻辑(如接口请求),提升性能;

QQ20260130-172541.gif

五、核心知识点梳理 📚

通过手写 KeepAlive 组件,我们掌握了这些关键知识点:

  1. React Hooks 实战useState 管理缓存状态,useEffect 监听依赖更新,函数式更新避免状态覆盖;
  2. 组件生命周期控制:通过 display 样式控制组件显隐,替代挂载 / 卸载,从而保留状态;
  3. 数据结构应用:对象作为缓存容器,Object.entries 实现对象遍历;
  4. Props 传递与复用children props 让 KeepAlive 组件通用化,支持任意子组件缓存;
  5. 状态管理思路:以唯一标识(activeId)关联组件,确保缓存的准确性和唯一性;
  6. 性能优化技巧:避免组件频繁挂载 / 卸载,减少 DOM 操作和资源消耗;
  7. 组件设计原则:通用、低侵入、易扩展,不修改子组件逻辑即可实现缓存功能。

补充: Map 与 JSON 的区别 ——Map 可以直接存储对象作为 key,而 JSON 只能存储字符串。如果需要缓存以对象为标识的组件,可将 cache 改为 Map 类型,优化如下:

jsx

// 用 Map 替代对象作为缓存容器
const [cache, setCache] = useState(new Map());

// 更新缓存
useEffect(() => {
  if (!cache.has(activeId)) {
    setCache((prev) => new Map(prev).set(activeId, children));
  }
}, [activeId, children, cache]);

// 遍历缓存
return (
  <>
    {Array.from(cache.entries()).map(([id, component]) => (
      <div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
        {component}
      </div>
    ))}
  </>
);

六、结语 🎉

手写 Keep-Alive 组件看似简单,却涵盖了 React 组件设计、状态管理、性能优化等多个核心知识点。它的核心思想是 “缓存 + 显隐控制”,通过巧妙的状态管理避免组件卸载,从而保留状态。

在实际开发中,我们可以基于这个基础版本扩展更多功能:比如设置缓存上限(避免内存溢出)、手动清除缓存、支持路由级缓存等。掌握了这个组件的实现逻辑,你不仅能解决实际开发中的状态保留问题,还能更深入理解 React 组件的渲染机制和生命周期。

希望这篇文章能带你吃透 Keep-Alive 组件的核心原理,下次遇到类似需求时,也能从容手写实现!如果觉得有收获,欢迎点赞收藏,一起探索 React 的更多实战技巧吧~ 🚀

React-Hooks逻辑复用艺术

前言

在 React 开发中,Hooks 的出现彻底改变了逻辑复用的方式。它让我们能够将复杂的、可复用的逻辑从 UI 组件中抽离,实现真正的“关注点分离”。本文将分享 Hooks 的核心原则,并提供 4 个在真实业务场景中封装的实战案例。

一、 Hooks 核心

1. 概念理解

Hooks 本质上是将组件间共享的逻辑抽离并封装成的特殊函数

2. 使用“红线”:规则与原理

  • 命名规范:必须以 use 开头(如 useChat),这不仅是约定,也是静态检查工具(ESLint)识别 Hook 的依据。
  • 调用位置严禁在循环、条件判断或嵌套函数中调用 Hook

底层原理: React 内部并不是通过“变量名”来记录 Hook 状态的,而是通过链表 。每次渲染时,React 严格依赖 Hook 的调用顺序来查找对应的状态。

注意: 如果在 if 语句中调用 Hook,一旦条件不成立导致某次渲染跳过了该 Hook,整个链表的指针就会错位,导致状态读取异常。

二、 实战:自定义 Hooks 封装

1. AI 场景:消息点赞/点踩逻辑 (useChatEvaluate)

在 AI 对话系统中,消息评价是通用功能。我们需要处理:状态切换(点赞 -> 取消点赞)、单选逻辑、以及异步接口调用。

import React, { useState } from 'react';

// 模拟接口
const public_evaluateMessage = async (params: any) => ({ data: true });

type EvaluateType = "GOOD" | "BAD" | "NONE";

export const useChatEvaluate = (initialType: EvaluateType = "NONE") => {
  const [ratingType, setRatingType] = useState<EvaluateType>(initialType);

  const evaluateMessage = async (contentId: number, type: "GOOD" | "BAD") => {
    let newEvaluateType: EvaluateType;

    // 逻辑:如果点击已选中的类型,则取消选中(NONE);否则切换到新类型
    if (type === "GOOD") {
      newEvaluateType = ratingType === "GOOD" ? "NONE" : "GOOD";
    } else {
      newEvaluateType = ratingType === "BAD" ? "NONE" : "BAD";
    }

    try {
      const res = await public_evaluateMessage({
        contentId,
        ratingType: newEvaluateType,
        content: "",
      });

      if (res.data === true) {
        setRatingType(newEvaluateType);
      }
    } catch (error) {
      console.error("评价失败:", error);
    }
  };

  return { ratingType, evaluateMessage };
};

// 使用示例
const ChatMessage: React.FC<{ id: number }> = ({ id }) => {
  const { ratingType, evaluateMessage } = useChatEvaluate();
  return (
    <button onClick={() => evaluateMessage(id, "GOOD")}>
      {ratingType === "GOOD" ? "👍 已点赞" : "👍 点赞"}
    </button>
  );
};

2. 响应式布局:屏幕尺寸监听 (useMediaSize)

在响应式系统中,封装一个能根据窗口宽度自动切换“设备类型”的 Hook,可以极大地简化响应式开发。

import { useState, useEffect, useMemo } from 'react';

export enum MediaType {
  mobile = 'mobile',
  tablet = 'tablet',
  pc = 'pc',
}

const useMediaSize = (): MediaType => {
  const [width, setWidth] = useState<number>(globalThis.innerWidth);

  useEffect(() => {
    const handleWindowResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleWindowResize);
    // 记得清理事件监听
    return () => window.removeEventListener('resize', handleWindowResize);
  }, []);

  // 使用 useMemo 避免每次渲染都重新运行计算逻辑
  const media = useMemo(() => {
    if (width <= 640) return MediaType.mobile;
    if (width <= 768) return MediaType.tablet;
    return MediaType.pc;
  }, [width]);

  return media;
};

export default useMediaSize;

3. 性能优化:防抖与节流 Hook

A. 防抖 Hook (useDebounce)

常用于搜索框,防止用户快速输入时频繁触发请求。

import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 关键:在下一次 useEffect 执行前清理上一次的定时器
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

B. 节流 Hook (useThrottle)

常用于滚动加载、窗口缩放,确保在规定时间内只执行一次。

import { useState, useEffect, useRef } from 'react';

function useThrottle<T>(value: T, delay: number): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastExecuted = useRef<number>(Date.now());

  useEffect(() => {
    const now = Date.now();
    const remainingTime = delay - (now - lastExecuted.current);

    if (remainingTime <= 0) {
      // 立即执行
      setThrottledValue(value);
      lastExecuted.current = now;
    } else {
      // 设置定时器处理剩余时间
      const timer = setTimeout(() => {
        setThrottledValue(value);
        lastExecuted.current = Date.now();
      }, remainingTime);

      return () => clearTimeout(timer);
    }
  }, [value, delay]);

  return throttledValue;
}

export default useThrottle;

三、 总结:封装自定义 Hook 的心法

  1. 抽离状态而非仅逻辑:如果一段逻辑只涉及纯函数计算,不需要 Hook;只有涉及 useStateuseEffect 等状态管理时,才有必要封装 Hook。
  2. 保持纯净:自定义 Hook 应该只关心逻辑,而不应该直接操作 DOM。
  3. TS 类型保护:利用泛型 <T> 增强 Hook 的兼容性,让它能适配各种数据类型。

公司低代码框架-列表个性化开发最佳实践

一、引言

当前低代码组件的功能框架已趋于稳定,而业务侧的需求设计却持续迭代、不断涌现。要落地各类个性化需求,正需要我们秉持‘人有多大胆,地有多大产’的探索精神,勇于构思、大胆尝试。比如低代码列表中,针对字段内容过多的问题,就需要自己开发部分展示的功能。

二、使用场景

1、列表只展示前三行,剩余的放在查看按钮内,弹框展示

image.png

实现思路:借助列表字段的自定义内容-复杂模式,实现设计稿里的展示效果,在页面加载事件里监听点击事件,实现弹窗效果

image.pngimage.png


let pileList = rowData.row.AccountList || [];
let resStrShow = '';
let resStrHide = '';
    for (let id = 0; id < pileList.length; id++) {
        let itemShow = "";
       ...
        if (rowData.row.AutoChargeType == '2') {//企业总账户
            if (pileList.length > 1) {
                itemShow += `${pileList[id].BusUnitAttrCompanyName}:`;
            }
            let BusUnitOrGroupBalance = Funcs.FormatDecimal(pileList[id]?.BusUnitOrGroupBalance, 2, "", ".").replace(/,/g, '');
            itemShow += `企业现金余额${BusUnitOrGroupBalance}元`;

        }
       ...
        }
        if (id < 3) {
            if (resStrShow) {
                resStrShow += '</br>';
            }
            resStrShow += itemShow;
        }
        if (resStrHide) {
            resStrHide += '</br>';
        }
        resStrHide += itemShow;

    }
if (pileList.length > 3) {

    let tipQuestion = `<div style="opacity: 1;    margin-top: 2px;" instancecode=""tabindex="1">`
        + `<span>`
        + `<span class="qiestionIcon" style="position: relative;display: flex;justify-content:  flex-start;">`
        + `<i class="material-icons" style="display:none" aria-hidden="true" role="presentation">help_outline</i>`
        + `<div  class="viewAll"
  style="
  padding:0px 7px;
  border-radius:10px;
  border:1px solid #D9D9D9;
  font-family:AlibabaPuHuiTi;
  font-size:12px;
  cursor: pointer;
  color:#3656FF;"
  >查看全部(${pileList.length})</div>`
        + `<div style="visibility:hidden;position: absolute; width: 560px; height: auto; left: 0px; top: -8px; box-sizing: content-box; padding-top: 8px;margin-left:20px;">`
        + `<span style="" class="resStrHide">${resStrHide}</span>`

        + `</div>`
        + `</span>`
        + `</span>`
        + `</div>`;

    return `<div   style="position: relative; display: inline-flex;flex-direction: column;">
          <div>${resStrShow}</div>          
          ${tipQuestion}
</div>`;

} else {
    return resStrShow;
}

js代码,点击实现弹框效果:


    var content = document.querySelector(`.UIControl_VehicleSettingList_Ecms_New`);
    content.addEventListener('click', function (event) {
        if (event.target.className == "viewAll") {
            const nextSiblingElement = event.target.nextElementSibling;
            if (nextSiblingElement) {
                const targetSpan = nextSiblingElement.querySelector('span.resStrHide');
                if (targetSpan) {
                    LocalVars.Variable_viewAll = targetSpan.innerHTML//把全部内容赋值给弹窗变量
                    Widget.fasr_dialog_viewALLNew.showDialog()//展示弹框
                
                }
            }
        }   
    });

2、列表只展示前三个,剩余的放在悬浮气泡里展示

image.png
实现思路:借助列表字段的自定义内容-复杂模式,实现设计稿里的展示效果,在页面加载事件里监听mousemove事件,实现气泡效果

image.pngimage.png

let PileRangeDesc = rowData.row.PileRangeDesc || '';
let pileList = PileRangeDesc.split(',') || [];

let resStrShow = '';
let resStrHide = '';

for (let id = 0; id < pileList.length; id++) {
    if (id < 3) {
        if (resStrShow) {
            resStrShow += '、';
        }
        resStrShow += pileList[id];
    } else {
        if (resStrHide) {
            resStrHide += '、';
        }
        resStrHide += pileList[id]
    }
}

if (pileList.length > 3) {

    let tipQuestion = `<div style="opacity: 1;margin-left:7px" instancecode=""tabindex="1">`
        + `<span>`
        + `<span class="qiestionIcon" style="position: relative;display: flex;justify-content: center;">`
        + `<i class="material-icons" style="display:none" aria-hidden="true" role="presentation">help_outline</i>`
        + `<div 
  style="
  padding:0px 7px;
  border-radius:10px;
  border:1px solid #D9D9D9;
  font-family:AlibabaPuHuiTi;
  font-size:12px;
  color:#3656FF;"
  >+${pileList.length - 3}</div>`
        + `<div style="visibility:hidden;position: absolute; width: 560px; height: auto; left: 0px; top: -8px; box-sizing: content-box; padding-top: 8px;margin-left:20px;">`
        + `<div class="q-tooltip--style q-position-engine arrow-top question-tooltip" style="--q-transition-duration: 30oms; --left: 27px; --top: false; -px;width:auto;
    padding: 16px 20px;
    background:#fff;
    font-family: AlibabaPuHuiTi;
    font-weight: 400;
    font-size: 14px;
    line-height: 20px;
    border-radius: 12px;
    color: rgba(0,0,0,0.8);
    max-width: 560px;
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
    margin-bottom:0;">`
        + `<span style="width:100%;
  display: inline-block;
  white-space: pre-wrap;">${resStrHide}</span>`
        + `<div style="width:12px;
  position:absolute;
  visibility:visible;
  bottom:-12px;
  height:12px;
  left:calc(50% - 6px);
  background-color:#fff;
  margin-top:0;
  clip-path: polygon(0 0,100% 0, 50% 100%);"></div>`
        + `</div>`
        + `</div>`
        + `</span>`
        + `</span>`
        + `</div>`;

    return `<div   style="position: relative; display: inline-flex;align-items:center">
          <div>${resStrShow}</div>          
          ${tipQuestion}
</div>`;

} else {
    return resStrShow;
}

js代码,实现气泡效果:

    var content = document.querySelector(`.${LocalVars.InParam_UISign}`);
    content.addEventListener('mousemove', function (event) {
        let table = content.querySelector(".fasr_mixview");
        if (table) {
            let tipElems = table.getElementsByClassName("question-tooltip");
            if (tipElems.length > 0) {
                for (let id in tipElems) {
                    if (tipElems[id].style) {
                        let parentNode = tipElems[id].parentNode;
                        // 清除TD提示框 
                        let signParentNode = parentNode;
                        for (let i = 0; i < 10; i++) {
                            if (signParentNode && signParentNode.tagName === 'TD') {
                                signParentNode.title = '';
                                break;
                            }
                            signParentNode = signParentNode.parentNode;
                        }

                        let position = parentNode.getBoundingClientRect()
                        let pX = position.left;
                        let pY = position.top;
                        let height = tipElems[id].offsetHeight
                        let width = tipElems[id].offsetWidth
                        // tipElems[id].style.left = pX - width / 2 - 14.3 + 'px';
                        tipElems[id].style.left = pX - width / 2 - 4 + 'px';
                        tipElems[id].style.top = pY - height - 7 + 'px';
                    }
                }
            }
        }
    });

3、列表格式化时间/字段内容

如果后台给我们的数据未经格式化,需要前端再次处理,我们除了在action里的返回值自定义扩展里面进行修改外,也可以利用列表字段的自定义设置,更加方便

image.png

例如,格式化时间:

return Funcs.FormatDateTime(rowData.row.LastModifyTime, 'yyyy-MM-dd HH:mm:ss');

格式化字段,没有值时展示‘--’

return `<div id="div">${rowData.value?rowData.value:'--'}</div>`

4、列表字段不固定,根据某个值动态展示部分字段,隐藏部分字段

实现思路:设置pc查询方案延迟加载,在onLoaded中,根据当前的模式处理当前方案下需要展示的字段,手动触发查询

 setTimeout(() => {
        let Scheme = Widget.fasr_mixed_view.getScheme()
            let ListViewSet = JSON.parse(Scheme.ListViewSet);
            if( LocalVars.Variable_mdoe=="1"){
               ListViewSet.displayColumns = ListViewSet.displayColumns.filter(item => (item.value != "AccountDesc"&&item.value != "AutoChargeTypeName"))
            }
            if( LocalVars.Variable_mdoe=="2"|| LocalVars.Variable_mdoe=="3"|| LocalVars.Variable_mdoe=="4"){
               ListViewSet.displayColumns = ListViewSet.displayColumns.filter(item => (item.value != "CarUseCustMobile"))
            }
                 
            Scheme.ListViewSet = JSON.stringify(ListViewSet)
            Widget.fasr_mixed_view.setScheme(Scheme)
     
    }, 0)

5、列表的查询条件不固定,根据某个值动态展示隐藏

实现思路:我们可以利用js操作原始dom,对条件进行显示隐藏,简单实现这个需求(低代码提供了硬写的方案,但是很繁琐)

 if(LocalVars.Variable_PVMismatchSwitch == "0"){
   document.querySelector(".Page_DriverManageListALLForNew .el-col-6:last-of-type").style.display = "none";
   }else{
    document.querySelector(".Page_DriverManageListALLForNew .el-col-6:last-of-type").style.display = "block";
   }

6、列表某些场景下列表每页的条数不允许切换,始终保持每页10条

image.png

实现思路:通过修改元素的行内样式,让这个元素彻底无法响应任何鼠标 / 触摸交互事件

 document.querySelector(".t-prefab-pagination__perfective-page-size").style.pointerEvents='none';

7、列表按钮点击触发服务响应缓慢,超过3秒,为防止白屏手动弹出loading

低代码提供了Funcs.ShowLoading(),但是不生效,考虑使用原生dom实现

    let eload1 = window.top.document.getElementById('tff_page_loading');
   eload1 && (eload1.style.display = "block");//显示
   eload1 && (eload1.style.display = "none");//隐藏

8、列表按钮触发后端服务每次只能校验一个,批量操作下需等待所有检查完成后才能进行下一步

实现思路:Promise.all()

  const checkPromises = [];
                let hasBoundVehicle = false;
                let CarLicenseArr = [];

                // 1. 检查所有车辆是否被绑定
                selectedRowDatas.forEach(item => {
                    if (item.VehiclesID) {
                        checkPromises.push(
                            Action.Action_CheckVehicleIfBindMembers_Ecms({ VehicleID: item.VehiclesID })
                                .then(result => {
                                    const hasCarObj = JSON.parse(result.Record.Data);
                                    if (hasCarObj.data) {
                                        CarLicenseArr.push(item.CarLicense);
                                        hasBoundVehicle = true;
                                    }
                                })
                        );
                    }
                });
                // 2. 等待所有检查完成
                Promise.all(checkPromises).then(() => {
                    debugger
                    var loadingMask = document.querySelector("#mvcFrameDiv")?.querySelector(".web-loading_mask");
                    loadingMask && (loadingMask.style.display = "none");
                    console.log(CarLicenseArr)
                    let tipI = ""
                    if (CarLicenseArr.length) {
                        CarLicenseArr.map(item => {
                            tipI += item + "、"
                        });
                        tipI = "当前车辆" + tipI.substring(0, tipI.length - 1) + "已被司机绑定,删除后司机将无法使用企业账户为该车辆充电,确认要删除吗?";
                        Funcs.Confirm(confirm, tipI, function () {
                            VehicleInformationNewUnbind()
                        })
                    } else {
                        Funcs.Confirm(confirm, confirmInfo, function () {
                            VehicleInformationNewUnbind()

                        })
                    }
                });

9、移动端列表没有数据时‘暂无数据’不展示

image.png

后端接口返回的count值不对,低代码模版根据count值决定是否展示暂无数据

10、移动端app框架内页面修改表头标题

需写在onloading方法里

 if(TFF.common.ctx.CLIENT_TYPE.TeldApp == TFF.common.ctx.getClientType()){

 window.pageNameTerm = ''
window.pageTitle = '添加司机'

if(LocalVars.InParam_FormState ==1){
window.pageTitle  ='编辑司机'
}
  TFF.jsdk.ready({ url: '', sgUrlPrefix: '' }, () => {
     window.envApi.setTitle({
            "title" : window.pageTitle ,
            success : function (res) {
             
            }
        })
            });

}

React-深度解析Diff 算法中Key 的作用

前言

在 React 开发中,我们经常会在控制台看到 Each child in a list should have a unique "key" prop 的警告。Key 到底是什么?它仅仅是一个为了消除警告的“随机字符串”吗?本文将带你从底层原理出发,看透 Key 在 Diff 算法中的核心价值。

一、 核心概念:什么是 Key?

Key 是 React 用于追踪列表元素身份的唯一辅助标识。它就像是每个 DOM 元素的“身份证号”,让 React 在复杂的更新过程中,能够精准地识别哪些元素被修改、添加或删除。

  • 唯一性:Key 必须在同级元素(Siblings)之间保持唯一。
  • 稳定性:一个元素的 Key 应该在其整个生命周期内保持不变,不建议使用 Math.random() 动态生成。

二、 Key 在 Diff 算法中的作用

React 的 Diff 算法通过 Key 来实现节点复用,这是性能优化的关键:

  1. 匹配新旧元素:当状态更新引发列表变化时,React 会对比新旧两棵虚拟 DOM 树,寻找具有相同 Key 的元素。
  2. 复用现有节点:如果 Key 相同,React 会认为这是同一个组件实例。它会选择复用现有的 DOM 节点和组件状态,仅仅更新发生变化的属性(如 textContent 或 className)。
  3. 减少重绘:由于复用了节点,浏览器不需要执行昂贵的“销毁旧节点 -> 创建新节点”操作,极大提高了更新效率。

三、 实战:Key 的正确用法

在 TSX 中,当我们使用 map 方法渲染列表时,务必在返回的最外层标签上绑定 Key。

import React, { useState } from 'react';

interface Todo {
  id: string; // 唯一标识符
  text: string;
}

const TodoList: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([
    { id: '1', text: '学习 React' },
    { id: '2', text: '整理掘金笔记' }
  ]);

  return (
    <ul>
      {/* 这里的 Key 使用数据的唯一 ID */}
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.text}
          <input type="checkbox" />
        </li>
      ))}
    </ul>
  );
};

export default TodoList;

四、 注意事项:为什么不能盲目使用 Index 作为 Key?

很多新手喜欢直接用数组的 index 作为 Key,但在逆序添加、删除或排序列表时,这会导致严重的性能问题和 UI Bug。

1. 性能降低

假设你在列表头部插入一条数据,原来的 index 0 变成了 index 1。React 会发现 Key 对应的“数据”变了,从而导致原本可以复用的节点全部被迫重新渲染(Re-render)。

2. 状态错位 Bug

如果列表项中包含非受控组件(如 <input />),使用 Index 作为 Key 会导致输入框内容“串位”。因为 React 认为 Key 没变,就复用了旧的 Input 节点及其内部的本地状态。


五、 总结与最佳实践

  • 首选方案:使用来自数据库的唯一 ID(如 UUID 或主键 ID)。
  • 备选方案:如果数据确实是静态的(永远不会排序、过滤、增删),且没有唯一 ID,可以使用 Index。
  • 禁忌:绝对不要在渲染时使用 Math.random()Date.now() 生成 Key。这会导致每次渲染 Key 都不同,React 将无法复用任何节点,造成巨大的性能浪费。

React-深度拆解 React Render 机制

前言

在 React 中,我们常说“渲染(Render)”,但它不仅仅是将 HTML 丢给浏览器那么简单。Render 是一个包含 计算(Reconciliation)提交(Commit) 的复杂过程。理解这一过程,能帮助我们写出更高性能的代码。

一、 Render 的核心三部曲

当 React 决定更新界面时,会经历以下三个关键阶段:

1. 创建虚拟 DOM (Virtual DOM)

JSX 本质上是 React.createElement() 的语法糖。Babel 会将 JSX 编译为 JS 调用,生成一个描述 UI 的对象树(即虚拟 DOM)。

结构定义参考:

// 编译后的逻辑(简化版)
const vDom = {
  type: 'div',
  props: {
    className: 'active',
    children: 'Hello'
  }
};

2. Diff 算法比较 (Reconciliation)

React 并不会盲目替换整个 DOM,而是通过 Diff 算法 对比“新旧两棵虚拟 DOM 树”。

  • 同层比较:只比较同一层级的节点。
  • 类型检查:如果节点类型变了(如 divp),则直接销毁重建。
  • Key 值优化:通过 key 属性识别节点是否只是移动了位置。

3. 渲染真实 DOM (Commit)

在计算出最小差异(Patches)后,React 的渲染器(如 react-dom)会将这些变更同步到真实浏览器环境,触发重绘与回流,使用户看到更新。


二、 触发渲染的四大时机

在函数式组件中,Render 过程可能由以下四种情况触发:

触发场景 描述
首次渲染 应用启动,将组件树完整挂载到页面上。
State 改变 当调用 useStateset 函数或 useReducerdispatch 时。
Props 改变 父组件重新渲染导致传给子组件的属性发生变化。
Context 改变 组件通过 useContext 订阅了上下文,且 Providervalue 发生变更。

三、 实战演示:观测渲染行为

我们可以通过简单的日志输出,来观察不同场景下的渲染行为。

import React, { useState, useContext, createContext } from 'react';

// 创建 Context
const AppContext = createContext(0);

// 子组件
const Child: React.FC<{ count: number }> = ({ count }) => {
  console.log("子组件 Render...");
  return <div>父级传入的 Props: {count}</div>;
};

// 顶层组件
const Home: React.FC = () => {
  const [num, setNum] = useState<number>(0);
  const [other, setOther] = useState<boolean>(false);

  console.log("Home 组件 Render...");

  return (
    <AppContext.Provider value={num}>
      <div style={{ padding: '20px' }}>
        <h2>Render 触发测试</h2>
        
        {/* 1. 修改 State 触发 */}
        <button onClick={() => setNum(prev => prev + 1)}>
          修改 State (Count: {num})
        </button>

        {/* 2. 这里的修改虽然没传给 Child,但父组件重新渲染会导致 Child 也重新渲染 */}
        <button onClick={() => setOther(!other)}>
          无关渲染测试: {String(other)}
        </button>

        {/* 3. Props 改变触发子组件渲染 */}
        <Child count={num} />
      </div>
    </AppContext.Provider>
  );
};

export default Home;

从打包到优化|Vue3 可 Tree-shaking API 分类与避坑要点

在 Vue3 的性能优化体系中,除了静态提升、PatchFlag、Block Tree 等渲染层面的优化,Tree-shaking(摇树优化)是构建层面的核心手段——它能自动移除项目中未被使用的代码,大幅缩减打包体积,尤其对中小型项目而言,体积优化效果可达 30% 以上。不同于 Vue2 全局引入导致大量冗余代码的问题,Vue3 从架构设计上原生支持 Tree-shaking,核心 API 均采用“按需引入”模式。本文将聚焦 Tree-shaking 在 Vue3 中的具体体现,重点梳理哪些 API 可被摇树、分类说明适用场景,同时拆解其底层实现逻辑与生效注意事项,完善 Vue3 优化知识体系。

一、先理清:Tree-shaking 是什么?Vue3 为何能原生支持?

Tree-shaking 本质是“消除死代码”的打包优化技术,依赖 ES6 模块的 import/export 语法(静态模块解析)——打包工具(Webpack、Vite、Rollup)能在编译阶段分析模块依赖,识别出未被引用的代码,将其从最终打包产物中移除。

Vue2 无法很好地支持 Tree-shaking,核心原因是其 API 多为全局挂载(如 Vue.componentVue.directive),即使未使用,也会被打包进产物;而 Vue3 彻底重构了 API 架构,采用“模块化导出”模式,所有 API 均通过 ES6 模块单独导出,未被引用的 API 可被打包工具精准识别并摇掉,实现“用多少、打包多少”。


// Vue2 全局挂载(无法 Tree-shaking)
import Vue from 'vue'
Vue.component('HelloWorld', HelloWorld) // 即使未使用该组件,也会打包

// Vue3 模块化导出(支持 Tree-shaking)
import { defineComponent, ref } from 'vue'
// 仅引用 defineComponent,ref 未被使用,打包时会被摇掉
export default defineComponent({
  setup() {
    // 未使用 ref
    return {}
  }
})

关键前提:Vue3 的 Tree-shaking 仅在 生产环境 生效,且打包工具需支持 ES6 模块解析(Webpack 4+、Vite、Rollup 均支持);开发环境为便于调试,不会移除未使用代码。

二、Vue3 中可被 Tree-shaking 的 API 全分类(附场景)

Vue3 的 API 按“功能模块”可分为核心 API、组件 API、指令 API、工具类 API 四大类,其中绝大多数 API 均可被 Tree-shaking,仅少数全局 API(需挂载到 App 实例)无法被摇掉。以下按分类梳理,明确每类可摇树 API 的用途与示例。

1. 核心响应式 API(最常用,均可摇树)

这类 API 是 Vue3 响应式系统的核心,均支持按需引入,未被使用时会被 Tree-shaking 移除,也是日常开发中体积优化的重点。

API 名称 功能说明 可摇树性 使用示例
ref 创建基本类型响应式数据 import { ref } from 'vue';
reactive 创建引用类型响应式数据 import { reactive } from 'vue';
computed 创建计算属性 import { computed } from 'vue';
watch 监听响应式数据变化 import { watch } from 'vue';
watchEffect 自动追踪响应式依赖的监听 import { watchEffect } from 'vue';
toRefs 将 reactive 对象转为 ref 集合 import { toRefs } from 'vue';

注意:这类 API 若仅引入未使用(如 import { ref } from 'vue' 但未创建 ref 数据),打包时会被完全摇掉,不会产生任何冗余代码。

2. 组件核心 API(组件开发必备,均可摇树)

这类 API 用于组件定义、生命周期管理,仅在组件开发中使用,未被引用时可被摇掉,核心包括组件定义、生命周期钩子、组件通信相关 API。

  • 组件定义 API:defineComponent(定义组件,支持 TypeScript 类型推导)、defineProps(定义组件 props)、defineEmits(定义组件事件)、defineExpose(暴露组件内部属性/方法)——均支持 Tree-shaking,仅在组件中使用时才会被打包。
  • 生命周期钩子:onMounted、onUpdated、onUnmounted、onBeforeMount 等所有组合式 API 生命周期钩子——按需引入,未使用的钩子会被摇掉(区别于 Vue2 全局生命周期)。
  • 组件通信 API:useAttrs、useSlots、provide、inject——仅在需要组件通信时引入,未使用时会被移除。

<script setup>
// 仅引入需要的组件 API,未使用的会被 Tree-shaking
import { defineProps, onMounted, provide } from 'vue'

const props = defineProps(['title'])

onMounted(() => {
  console.log('组件挂载完成')
})

// provide 仅引入未使用,打包时会被摇掉
</script>

3. 内置指令 API(按需使用,均可摇树)

Vue3 的内置指令中,除了 v-textv-html 等基础指令(默认打包,体积极小),其他指令均支持 Tree-shaking,仅在模板中使用时才会被打包。

核心可摇树指令 API 及场景:

  • v-model 相关:vModelText、vModelNumber、vModelCheckbox 等——仅在使用对应类型 v-model 时引入(如 v-model.number 需引入 vModelNumber)。
  • v-show:单独打包,未使用时会被摇掉(区别于 v-if,v-if 是模板语法,无需额外打包)。
  • 自定义指令相关:withDirectives(用于封装自定义指令)——仅在开发自定义指令时使用,未使用时会被移除。

4. 工具类 API(按需引入,均可摇树)

Vue3 提供了一系列工具类 API,用于辅助开发(如响应式数据转换、模板渲染等),这类 API 均为模块化导出,未被使用时会被 Tree-shaking 移除,核心包括:

  • 响应式工具:isRef、isReactive、isComputed、unref(判断/转换响应式数据);
  • 模板工具:h(创建 VNode,用于渲染函数)、render(渲染 VNode 到 DOM);
  • 其他工具:nextTick(等待 DOM 更新)、mergeProps(合并组件 props)。

// 按需引入工具类 API,未使用的会被摇掉
import { isRef, nextTick, h } from 'vue'

const count = ref(0)
if (isRef(count)) {
  console.log('count 是 ref 类型')
}

// nextTick 仅引入未使用,打包时被摇掉
// h 仅引入未使用,打包时被摇掉

5. 不可被 Tree-shaking 的 API(少数全局 API)

并非 Vue3 所有 API 都能被摇掉,以下全局 API 需挂载到 App 实例(如 app.use()app.component()),或为全局运行时依赖,即使未使用,也会被打包进产物(体积极小,无需担心):

  • 创建 App 相关:createApp(必用,全局依赖);
  • 全局挂载相关:app.use()、app.component()、app.directive()(挂载全局插件、组件、指令);
  • 基础全局 API:Vue.version(获取 Vue 版本,极少使用)。

三、Tree-shaking 在 Vue3 中的底层实现逻辑

Vue3 能实现高效 Tree-shaking,核心依赖“模块化架构设计”与“编译时标记”,具体分为两个层面:

1. 架构层面:ES6 模块化导出,避免全局挂载

Vue3 源码采用 ES6 模块编写,所有 API 均通过 export 单独导出(如 export const ref = ...),而非像 Vue2 那样挂载到全局 Vue 对象上。这种设计让打包工具能精准识别“哪些 API 被 import 且被使用”,未被引用的 API 会被判定为死代码,在打包时移除。

2. 编译层面:标记未使用代码,辅助打包工具摇树

Vue3 编译器在编译阶段(如 SFC 编译),会对引入但未使用的 API 进行标记,同时移除模板中未使用的指令、组件相关代码,为打包工具的 Tree-shaking 提供辅助,确保冗余代码被彻底移除。

例如,在 <script setup> 中引入但未使用的 API,编译器会在生成的代码中移除该 import 语句,进一步确保打包工具无需额外处理:


// 开发时代码(引入未使用)
import { ref, computed } from 'vue'
const count = 1

// 编译器处理后代码(移除未使用的 import)
import { } from 'vue'
const count = 1

// 打包工具最终处理(无任何冗余)
const count = 1

四、Tree-shaking 生效条件与避坑要点

Vue3 虽然原生支持 Tree-shaking,但在实际开发中,若配置不当或使用方式有误,会导致 Tree-shaking 失效,冗余代码无法被移除。以下是关键生效条件与常见坑点。

1. 生效条件(必满足)

  • 打包工具支持 ES6 模块解析:Webpack 4+、Vite、Rollup 均支持,需确保配置中未禁用 ES6 模块(如 Webpack 中 module.exports = { mode: 'production' } 即可)。
  • 使用 ES6 模块语法:必须使用 import/export 引入/导出 API,不可使用 CommonJS 语法(require/module.exports),否则 Tree-shaking 失效。
  • 生产环境打包:Vue3 仅在生产环境(process.env.NODE_ENV === 'production')下开启 Tree-shaking 相关优化,开发环境不会移除未使用代码。

2. 常见避坑点

  • 误区1:全局引入 Vue3 会触发 Tree-shaking——若使用 import Vue from 'vue' 全局引入,所有 API 都会被打包,无法摇掉;必须使用按需引入(import { ref } from 'vue')。
  • 误区2:引入但未使用的 API 一定会被摇掉——若 API 被间接引用(如通过第三方插件引用),或打包工具无法识别死代码(如配置错误),可能导致 Tree-shaking 失效,需检查打包配置。
  • 误区3:自定义指令/插件会影响 Tree-shaking——自定义指令/插件若采用按需引入模式(如 import { MyDirective } from './directive'),未使用时会被摇掉;若全局挂载(app.directive('my-dir', MyDirective)),则无法摇掉。
  • 误区4:Tree-shaking 会摇掉必要的运行时代码——Vue3 源码中已做好兼容,运行时核心依赖(如响应式系统基础逻辑)会被自动保留,不会被误摇掉。

五、实战验证:Tree-shaking 的体积优化效果

以一个简单的 Vue3 项目为例,对比“全局引入”与“按需引入”的打包体积差异(基于 Vite 打包,生产环境):

  1. 全局引入(未使用 Tree-shaking):import Vue from 'vue',打包后 Vue 相关体积约 50KB(gzip 后约 18KB);
  2. 按需引入(使用 Tree-shaking):仅引入 refdefineComponent,打包后 Vue 相关体积约 15KB(gzip 后约 6KB),体积缩减 70%。

实际项目中,随着 API 使用数量的增加,体积优化效果会略有下降,但总体仍能比 Vue2 减少 30%~50% 的冗余体积,尤其对轻量级应用(如移动端 H5、小程序),体积优化带来的加载速度提升更为明显。

六、总结:Tree-shaking 与 Vue3 优化体系的协同价值

Tree-shaking 在 Vue3 中的体现,本质是“模块化架构”与“按需引入”理念的落地,它与前文提到的静态提升、PatchFlag、Block Tree 等优化特性,形成了“构建层面+渲染层面”的全链路优化:

  • Tree-shaking:构建阶段移除未使用代码,缩减打包体积,提升加载速度;
  • 静态提升/PatchFlag/Block Tree:渲染阶段优化 VNode 创建与 Diff 效率,提升运行时性能。

梳理可被 Tree-shaking 的 API 后,我们能在实际开发中更有针对性地优化:优先采用按需引入模式,避免全局引入 API;减少“引入未使用”的冗余代码;合理使用第三方插件(选择支持 Tree-shaking 的插件)。

Vue3 对 Tree-shaking 的原生支持,彻底解决了 Vue2 体积冗余的痛点,让开发者无需额外配置,就能轻松实现项目体积优化。理解其底层原理与可摇树 API 清单,不仅能帮助我们写出更轻量的代码,更能深入掌握 Vue3 的架构设计思路,在性能优化场景中精准发力。

相关文章

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

吃透 Vue3 PatchFlag!8 大类标识含义+精准比对逻辑

Vue3 渲染优化双核心:Block Tree 原理与 Fragment 根节点妙用

为何 Node.js 环境中没有 DOM 和 BOM?

为何 Node.js 环境中没有 DOM 和 BOM?

核心答案

因为 DOM 和 BOM 不是 JavaScript 语言本身的一部分,而是浏览器提供的宿主环境 API。

  • DOM (Document Object Model):浏览器解析 HTML 后生成的文档对象模型
  • BOM (Browser Object Model):浏览器窗口相关的对象(window、navigator、location 等)

Node.js 是服务端运行时,没有浏览器窗口,没有 HTML 文档,自然不需要也无法提供这些 API。Node.js 提供的是服务端所需的 API(文件系统、网络、进程等)。

深入解析

底层机制

1. ECMAScript vs 宿主环境

类别 来源 示例
ECMAScript 标准 JS 语言规范 Array, Promise, class, async/await
浏览器宿主 API W3C/WHATWG 规范 document, window, fetch, localStorage
Node.js 宿主 API Node.js 项目 fs, http, process, Buffer

2. 为什么这样设计?

浏览器的职责:                    Node.js 的职责:
├── 渲染网页                      ├── 执行服务端逻辑
├── 处理用户交互                   ├── 文件读写
├── 管理页面导航                   ├── 网络服务
└── 多媒体播放                     └── 系统调用

不同的运行环境有不同的需求,提供不同的 API 是合理的设计。

3. V8 引擎的角色

V8 只负责执行 JavaScript 代码,它本身不包含 DOM/BOM:

V8 引擎提供:
├── JS 代码解析和编译
├── 执行字节码
├── 垃圾回收
└── ECMAScript 标准内置对象

V8 不提供:
├── DOM 操作
├── 网络请求
├── 文件系统
└── 任何 I/O 操作

常见误区

  1. "JavaScript 天生就有 document 和 window"

    • 错误!这些是浏览器注入的全局对象
  2. "Node.js 是阉割版的 JavaScript"

    • 错误!Node.js 完整实现了 ECMAScript,只是宿主 API 不同
  3. "console.log 是 JavaScript 的一部分"

    • 严格来说不是!console 是宿主环境提供的,只是浏览器和 Node.js 都实现了它

代码示例

环境检测

// 检测当前运行环境
function detectEnvironment() {
    // 浏览器环境
    if (typeof window !== 'undefined' && typeof document !== 'undefined') {
        console.log('浏览器环境');
        console.log('window:', typeof window);      // object
        console.log('document:', typeof document);  // object
        console.log('navigator:', typeof navigator); // object
    }

    // Node.js 环境
    if (typeof process !== 'undefined' && process.versions?.node) {
        console.log('Node.js 环境');
        console.log('process:', typeof process);    // object
        console.log('__dirname:', typeof __dirname); // string (CommonJS)
        console.log('window:', typeof window);      // undefined
        console.log('document:', typeof document);  // undefined
    }
}

detectEnvironment();

跨环境兼容代码

// 同构/通用 JavaScript 代码示例
const isNode = typeof process !== 'undefined'
    && process.versions != null
    && process.versions.node != null;

const isBrowser = typeof window !== 'undefined'
    && typeof window.document !== 'undefined';

// 根据环境使用不同的 API
async function fetchData(url) {
    if (isBrowser) {
        // 浏览器使用 fetch API
        return fetch(url).then(res => res.json());
    } else if (isNode) {
        // Node.js 18+ 也有 fetch,或使用 http 模块
        const { default: fetch } = await import('node-fetch');
        return fetch(url).then(res => res.json());
    }
}

Node.js 中模拟 DOM(jsdom)

// 在 Node.js 中使用 jsdom 模拟浏览器环境
const { JSDOM } = require('jsdom');

const dom = new JSDOM(`
    <!DOCTYPE html>
    <html>
        <body>
            <div id="app">Hello</div>
        </body>
    </html>
`);

// 现在可以使用 DOM API 了
const document = dom.window.document;
const app = document.getElementById('app');
console.log(app.textContent); // "Hello"

app.textContent = 'Hello from Node.js!';
console.log(dom.serialize()); // 输出修改后的 HTML

全局对象对比

// 浏览器中的全局对象
// window === globalThis === self (在主线程中)

// Node.js 中的全局对象
// global === globalThis

// 通用写法(ES2020+)
console.log(globalThis); // 在任何环境都能获取全局对象

面试技巧

面试官可能的追问方向

  1. "那 Node.js 怎么做服务端渲染 (SSR)?"

    • 回答:使用 jsdom、happy-dom 等库模拟 DOM 环境,或使用 React/Vue 的服务端渲染 API(renderToString)直接生成 HTML 字符串,不需要真正的 DOM
  2. "fetch 是 JavaScript 的一部分吗?"

    • 回答:不是,fetch 是 WHATWG 规范定义的 Web API。Node.js 18+ 才原生支持,之前需要 node-fetch 等 polyfill
  3. "为什么 setTimeout 在 Node.js 中也能用?"

    • 回答:因为 Node.js 选择实现了这个 API 以保持兼容性,但实现机制不同(Node.js 用 libuv,浏览器用事件循环)
  4. "globalThis 是什么?"

    • 回答:ES2020 引入的标准,统一获取全局对象的方式,解决了 window/global/self 在不同环境不一致的问题

如何展示深度理解

  • 区分 ECMAScript 规范宿主环境 API
  • 了解 V8 引擎 的职责边界
  • 知道 jsdomhappy-dom 等工具的存在和用途
  • 理解 同构 JavaScript 的概念和挑战
  • 提及 DenoBun 等新运行时对 Web API 的支持程度不同

一句话总结

DOM/BOM 是浏览器的"特产",不是 JavaScript 的"标配"——JS 只是一门语言,能做什么取决于宿主环境给它什么 API。

向完全不懂编程的产品经理解释 JavaScript 是什么。你会怎么说?

想象你正站在一个完全不懂编程的 产品经理 面前,试图向他解释 JavaScript 是什么。你会怎么说?请尝试用通俗易懂的语言(比如打比方)向他解释 ECMAScript、DOM 和 BOM 的关系。

核心答案

我会这样向产品经理解释:

JavaScript 就像是一个"万能工人",它能让网页从"死"的变成"活"的。

打个比方:

  • ECMAScript 是工人的"技能手册"——规定了他会哪些基本动作(比如说话、走路、数数)
  • DOM 是工人和"页面内容"打交道的方式——让他能修改页面上的文字、图片、按钮
  • BOM 是工人和"浏览器环境"打交道的方式——让他能控制浏览器窗口、历史记录、弹出提示框

简单关系图:ECMAScript(核心能力) + DOM(操作页面) + BOM(操作浏览器) = 完整的 JavaScript


深入解析

1. 用更形象的比喻

想象你在装修一个房子

组成部分 比喻 实际作用
ECMAScript 装修队的施工规范 定义语法、变量、循环、函数等基本规则
DOM 和家具、墙壁打交道 操作页面元素:修改文字、样式、添加删除元素
BOM 和房子本身打交道 操作浏览器:控制窗口大小、跳转页面、本地存储

2. 技术层面的解释

ECMAScript
  • 是 JavaScript 的语言标准,由 ECMA 国际组织制定
  • 只规定语言的语法和核心功能
  • 不涉及任何浏览器或环境相关的内容
  • 最新版本:ES6/ES2015、ES2024 等
DOM(Document Object Model)
  • 把 HTML 文档解析成树形结构
  • 提供了一组 API 让 JavaScript 能操作页面
  • 是 W3C 标准,不只是 JavaScript 专用
BOM(Browser Object Model)
  • 提供与浏览器交互的接口
  • 没有统一标准,不同浏览器实现有差异
  • 主要对象:windowlocationnavigatorhistorylocalStorage

3. 常见误区

误区1:ECMAScript 和 JavaScript 是同一个东西

纠正:ECMAScript 是标准规范,JavaScript 是这个规范的实现(类似接口和实现类的关系)

误区2:DOM 是 JavaScript 的一部分

纠正:DOM 是独立的标准,其他语言(如 Python)也能操作 DOM

误区3:BOM 有统一标准

纠正:BOM 长期缺乏标准,各浏览器实现不同,HTML5 规范后才逐步统一


代码示例

// ========== ECMAScript:核心语法 ==========
// 这些语法在任何符合 ES 标准的环境中都能运行

// 1. 基础语法
const name = 'JavaScript';
let count = 0;

// 2. 函数
function greet(user) {
    return `Hello, ${user}`;
}

// 3. 循环
for (let i = 0; i < 5; i++) {
    count++;
}

// 4. 对象和数组
const user = {
    name: 'Alice',
    age: 25,
    skills: ['js', 'html']
};


// ========== DOM:操作页面内容 ==========
// 只有在浏览器环境才可用

// 1. 获取元素
const button = document.querySelector('#myButton');
const title = document.getElementById('title');

// 2. 修改内容
title.textContent = '新的标题';

// 3. 修改样式
button.style.backgroundColor = 'blue';

// 4. 事件监听
button.addEventListener('click', () => {
    alert('按钮被点击了!');
});

// 5. 动态创建元素
const newDiv = document.createElement('div');
newDiv.className = 'box';
document.body.appendChild(newDiv);


// ========== BOM:操作浏览器 ==========
// 不同浏览器可能有差异

// 1. window 对象(BOM 的核心)
console.log(window.innerWidth);   // 浏览器窗口宽度
window.scrollTo(0, 500);          // 滚动页面

// 2. location(页面跳转)
console.log(location.href);       // 当前 URL
location.reload();                // 刷新页面

// 3. history(历史记录)
history.back();                   // 返回上一页
history.go(2);                    // 前进两页

// 4. navigator(浏览器信息)
console.log(navigator.userAgent);  // 浏览器标识

// 5. localStorage(本地存储)
localStorage.setItem('key', 'value');
const data = localStorage.getItem('key');

// 6. 定时器(window 的方法)
setTimeout(() => {
    console.log('1秒后执行');
}, 1000);

面试技巧

面试官可能的追问

  1. "ES6 新增了哪些特性?"

    • 回答:let/const、箭头函数、解构赋值、Promise、class、模块化等
  2. "DOM 操作为什么慢?"

    • 回答:会触发重排和重绘,建议用文档片段或虚拟 DOM
  3. "BOM 中有哪些常用的 API?"

    • 回答:localStorage、sessionStorage、history、location、navigator
  4. "JavaScript 只能在浏览器运行吗?"

    • 回答:不是,Node.js 让 JS 能在服务器运行,但没有 DOM/BOM

如何展示深度理解

  1. 提到标准的演进:说明 DOM 从混乱到标准化的历史
  2. 谈性能问题:DOM 操作的性能影响,如何优化
  3. 跨平台思考:Node.js、React Native 如何复用 ECMAScript
  4. 实际经验:举例说明处理过浏览器兼容性问题

一句话总结

ECMAScript 是语言规则,DOM 是操作页面的手,BOM 是操作浏览器的手——三者共同构成了我们在网页开发中使用的 JavaScript。

❌