普通视图

发现新文章,点击刷新页面。
今天 — 2025年8月20日首页
昨天 — 2025年8月19日首页

前端无接口实现Table导出Excel的两种方案(附完整代码)

作者 snow来了
2025年8月19日 17:39
在日常开发中,表格数据导出Excel是高频需求,多数场景下依赖后端接口返回二进制文件实现下载。但当无后端接口支持时,前端也可通过纯前端方案完成导出,以下是两种实用方案的详细实现与对比。 方案1:纯前端

高德地图绘制工具全解析:线路、矩形、圆形、多边形绘制与编辑指南 🗺️✏️

作者 借月
2025年8月19日 15:39
引言 🌟 在现代WebGIS开发中,地图绘制功能是许多应用的核心需求。本文将基于Vue和高德地图(AMap)API,详细解析四种常见图形(线路、矩形、圆形、多边形)的绘制与编辑实现方案,并加入丰富的可
昨天以前首页

vue3入门-v-model、ref和reactive讲解

作者 定栓
2025年8月18日 19:04

组件上v-model用法

在 Vue 2.0 发布后,开发者使用 v-model 指令必须使用为 valueprop。如果开发者出于不同的目的需要使用其他的 prop,他们就不得不使用 v-bind:propName.sync。此外,由于 v-modelvalue 之间的这种硬编码关系的原因,产生了如何处理原生元素和自定义元素的问题。

在 Vue 2.2 中,我们引入了 model 组件选项,允许组件自定义用于 v-modelprop事件。但是,这仍然只允许在组件上使用一个 model

在 Vue 3 中,双向数据绑定的 API 已经标准化,减少了开发者在使用 v-model 指令时的混淆并且在使用 v-model 指令时可以更加灵活。

首先让我们回忆一下 v-model原生元素上的用法:

<input v-model="text" />

在代码背后,模板编译器会对 v-model 进行更冗长的等价展开。因此上面的代码其实等价于下面这段:

<input :value="text" @input="text = $event.target.value" />

接下来我们看下在自定义组件上的用法。

2.x语法

<ChildComponent v-model="text" />
<!-- 去除 v-model 语法糖后的写法 -->
<ChildComponent :value="text" @input="text = $event" />

ChildComponent.vue

<template>
  <input :value="value" @input="($event) => $emit('input', $event.target.value)" />
</template>
<script>
  export default {
    props: ['value'],
  }
</script>

如果要将属性或事件名称更改为其他名称,则需要在 ChildComponent 组件中添加 model 选项:

ParentComponent.vue

<myComponent v-model="isChecked" />

ChildComponent.vue

<template>
  <input type="checkbox" :checked="checked" @change="($event) => $emit('change', $event.target.checked)" />
</template>
<script>
  export default {
    model: {
      // 使用 `checked` 代替 `value` 作为 model 的 prop
      prop: 'checked',
      // 使用 `change` 代替 `input` 作为 model 的 event
      event: 'change'
    },
    props: {
      checked: Boolean
    }
  }
</script>

在这个例子中,父组件 v-model 的实际内部处理如下:

<ChildComponent :value="text" @change="text = $event" />

使用 v-bind.sync

在某些情况下,我们可能需要对某一个 prop 进行“双向绑定”(除了前面用 v-model 绑定 prop 的情况)。为此,我们建议使用 update:myPropName 抛出事件。

假设 ChildComponent 带有 title prop ,我们可以通过下面的方式将分配新 value 的意图传达给父级:

this.$emit('update:title', newValue)

如果需要的话,父级可以监听该事件并更新本地 data property。例如:

<ChildComponent :title="text" @update:title="text = $event" />

为了方便起见,我们可以使用 .sync 修饰符来缩写,如下所示:

<ChildComponent :title.sync="text" />

3.x语法

当使用在一个组件上时,v-model 会被展开为如下的形式:

<template> 
  <myComponent model-value="text" @update:model-value="($event) => text = $event" />
  <div>{{ text }}</div>
</template>
<script setup>
import { ref } from 'vue'
import myComponent from './components/myComponent.vue'
const text = ref('')
</script>

要让这个例子实际工作起来,<myComponent> 组件内部需要做两件事:

  • 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  • 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件

这里是相应的代码:

myComponent.vue

<template>
  <input :value="modelValue" @input="(e) => $emit('update:modelValue', e.target.value)" />
</template>
<script setup>
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
</script>

现在 v-model 可以在这个组件上正常工作了:

<myComponent v-model="text" />

另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 gettersettercomputed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:

myComponent.vue

<template>
  <input v-model="value" />
</template>
<script setup>
import { computed } from 'vue'
const { modelValue } = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
const value = computed({
  get() {
    return modelValue
  },
  set(newVal) {
    return emits('update:modelValue', newVal)
  }
})
</script>

v-model 的参数

组件上的 v-model 也可以接受一个参数:

<MyComponent v-model:title="bookTitle" />

在这种情况下,子组件应该使用 title prop 和 update:title 事件来更新父组件的值,而非默认的 modelValue prop 和 update:modelValue 事件:

myComponent.vue

<template>
  <input :value="title" @input="(e) => $emit('update:title', e.target.value)" />
</template>
<script setup>
const { title } = defineProps(['title'])
const emits = defineEmits(['update:title'])
</script>

多个 v-model 绑定

利用刚才在 v-model 的参数小节中学到的指定参数与事件名的技巧,我们可以在单个组件实例上创建多个 v-model 双向绑定。

组件上的每一个 v-model 都会同步不同的 prop,而无需额外的选项:

<template> 
  <myComponent v-model:book-name="bookName" v-model:book-auther="bookAuther" />
  <div>bookName:{{ bookName }}、bookAuther:{{ bookAuther }}</div>
</template>
<script setup>
import { ref } from 'vue'
import myComponent from './components/myComponent.vue'
const bookName = ref('')
const bookAuther = ref('')
</script>

在这种情况下,子组件应该使用 bookNamebookAuther prop,以及 update:bookNameupdate:bookAuther 事件来更新父组件的值:

myComponent.vue

<template>
  <input :value="bookName" @input="(e) => $emit('update:bookName', e.target.value)" />
  <input :value="bookAuther" @input="(e) => $emit('update:bookAuther', e.target.value)" />
</template>
<script setup>
const { bookName, bookAuther } = defineProps(['bookName', 'bookAuther'])
const emits = defineEmits(['update:bookName', 'update:bookAuther'])
</script>

ref与reactive

在 Vue 3 中,响应式数据的创建主要依赖于 refreactive 两个 API。它们各自有不同的用途和适用场景。

ref 用于创建基本数据类型的响应式数据,而 reactive 用于创建复杂数据结构(如对象和数组)的响应式数据。

ref

这个方法需要在顶部引入:import { ref } from 'vue'。通常使用它定义响应式数据,不限数据类型。

let xxx = ref(初始值)

返回值: 传入的是基本数据类型,则返回 RefImpl 实例对象(简称ref)。如果传的是引用数据类型,则内部是通过 reactive 方法处理,最后形成了一个 Proxy 类型的对象。ref 对象的 value 属性是响应式的。

ref 创建的数据,js 中需要 .valuetemplate 中可省略(自动解包)。

<script setup>
import { ref, reactive } from 'vue';

  const text = ref('')
  console.log('ref text', text) // ref text RefImpl {dep: Dep, __v_isRef: true, __v_isShallow: false, _rawValue: '', _value: ''}

  const obj = reactive({
    name: 'caoyuan'
  })
  console.log('reactive obj', obj) // reactive obj Proxy(Object) {name: 'caoyuan'}

</script>

我们打印 obj,你会发现,它不再是 RefImpl 实例对象,变成了 Proxy 实例对象,vue3 底层把对象都变成了 Proxy 实例对象,对于基本数据类型就是按照 Object.defineProperty 里面的 getset 进行数据劫持然后进行响应式,但是如果是对象类型的话,是用到的 Proxy。vue3把它封装在新函数 reactive 里,就相当于,ref 中是对象,自动会调用 reactive

那为什么定义一个响应式数据,偏偏要用 .value 去操作呢,满篇的 .value 有什么必要吗,像 Vue2 里直接拿着变量名称处理不是很好?

在官网中得到了解答:

将值封装在一个对象中,看似没有必要,但为了保持 JavaScript 中不同数据类型的行为统一,这是必须的。这是因为在 JavaScript 中,NumberString 等基本类型是通过值而非引用传递的。

按引用传递与按值传递

在任何值周围都有一个封装对象,这样我们就可以在整个应用中安全地传递它,而不必担心在某个地方失去它的响应性。

提示: 换句话说,ref 为我们的值创建了一个响应式引用。在整个组合式 API 中会经常使用引用的概念。

注:安装 Vue - Official 插件后,搜索 Dot Value,勾选对应选项,插件会在使用 ref 创建的变量时自动添加 .value

reactive

前边提到,ref 可以返回任意类型的变量的响应式副本,那 reactive 还有什么必要吗?

当然是有必要的。上一段对 ref 了解,他在操作该响应式变量的时候,需要 .value 去取值,那有没有一个方法,可以避开 NumberString 等基本类型,操作时候无需 .value 呢?答案是有的。也就是 reactive 函数。

作用:定义一个对象类型的响应式数据,不能定义基本数据类型。

语法:const 代理对象 = reactive(源对象)

  • 接收一个对象(或数组),返回一个代理对象(Proxy 的实例对象,简称 proxy 对象)
  • reactive 定义的响应式数据是深层次的,意思是不管对象嵌套多少层,整个对象都是响应式的
  • 内部基于 ES6 的 Proxy 实现,通过代理对象操作源对象内部数据进行操作
<template>
  <div>{{  arr.toString()  }}</div>
  <div>{{ obj.info.schoolInfo.location }}</div>
</template>
<script setup>
import { reactive } from 'vue';

  const arr = reactive([1,2,3])
  // 3秒后值变化
  setTimeout(() => {
    arr.push(4)
  }, 3000);

  const obj = reactive({
    info: {
      name: 'caoyuan',
      schoolInfo: {
        location: 'henan'
      }
    }
  })
  // 6秒后值变化
  setTimeout(() => {
    obj.info.schoolInfo.location = 'shanghai'
  }, 6000);
</script>

ref与reactive的区别

从定义数据角度:

  • ref 用来定义基本类型数据、引用类型数据。定义引用数据类型时,内部会调用 reactive 转为代理对象
  • reactive 用来定义引用类型数据,不支持基本数据类型

从原理角度:

  • ref 通过 Object.defineProperty()getset 来实现响应式(数据劫持)
  • reactive 通过使用 Proxy 来实现响应式(数据劫持), 并通过 Reflect 操作源对象内部的数据
  • ref 遇到引用数据类型时,它的内部会自动通过 reactive 转为代理对象

从使用角度:

  • ref 定义的数据:操作数据需要 .value,读取数据时模板中直接读取不需要 .value
  • reactive 定义的数据:操作数据与读取数据:均不需要 .value

使用原则:

  • 若需要一个基本类型的响应式数据,必须使用 ref
  • 若需要一个响应式对象,层级不深,refreactive都可以
  • 若需要一个响应式对象,且层级较深,推荐使用 reactive

ref模板引用

在某些情况下,我们仍然需要直接访问底层 DOM 元素。要实现这一点,我们可以使用特殊的 ref attribute:

<input ref="input">

ref 是一个特殊的 attribute,它允许我们在一个特定的 DOM 元素子组件实例被挂载后,获得对它的直接引用。这可能很有用,比如说在组件挂载时将焦点设置到一个 input 元素上,或在一个元素上初始化一个第三方库。

访问模板引用

要在组合式 API 中获取引用,我们可以使用辅助函数 useTemplateRef()

<template>
  <input ref="my-input" />
</template>
<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 第一个参数必须与模板中的 ref 值匹配
const inputRef = useTemplateRef('my-input')

onMounted(() => {
  inputRef.value.focus()
})
</script>

注意,你只可以在组件挂载后才能访问模板引用。如果你想在模板中的表达式上访问 input,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!

如果你需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

watchEffect(() => {
  if (inputRef.value) {
    inputRef.value.focus()
  } else {
    // 此时还未挂载,或此元素已经被卸载(例如通过 v-if 控制)
  }
})

组件上的 ref

模板引用也可以被用在一个子组件上。这种情况下引用中获得的值是组件实例:

<template>
  <Child ref="child" />
</template>
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Child from './Child.vue'

const childRef = useTemplateRef('child')

onMounted(() => {
  // childRef.value 将持有 <Child /> 的实例
})
</script>

如果一个子组件使用的是选项式 API 或没有使用 <script setup>,被引用的组件实例和该子组件的 this 完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,你应该首先使用标准的 propsemit 接口来实现父子组件交互。

当使用了 <script setup> 的组件是默认私有的:一个父组件无法访问到一个使用了 <script setup> 的子组件中的任何东西,除非子组件在其中通过 defineExpose 宏显式暴露:

<script setup>
import { ref } from 'vue'

const a = 1
const b = ref(2)

// 像 defineExpose 这样的编译器宏不需要导入
defineExpose({
  a,
  b
})
</script>

当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样)。

请注意,defineExpose 必须在任何顶层 await 操作之前调用。否则,在 await 操作后暴露的属性和方法将无法访问。

v-for 中的模板引用

当在 v-for 中使用模板引用时,对应的 ref 中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素:

<template>
  <ul>
    <li v-for="item in list" ref="items">
      {{ item }}
    </li>
  </ul>
</template>
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'

const list = ref([
  /* ... */
])

const itemRefs = useTemplateRef('items')

onMounted(() => console.log(itemRefs.value))
</script>

应该注意的是,ref 数组并不保证与源数组相同的顺序。

函数模板引用

除了使用字符串值作名字,ref attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数:

<input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }">

注意我们这里需要使用动态的 :ref 绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时的 el 参数会是 null。你当然也可以绑定一个组件方法而不是内联函数。

Vue3 响应式原理

作者 LIUENG
2025年8月18日 17:44

原理图

classDiagram
  note for ReactiveEffect "activeEffect 记录当前实例变量"
  note for ReactiveEffect "createDep<br />cleanupDepEffect<br />preCleanupEffect<br />postCleanupEffect<br />triggerComputed<br />pauseTrack<br />resetTrack<br />pauseScheduling<br />resetScheduling<br />"
  ReactiveEffect <|-- ComputedRefImpl : this.effect 实例化,等待响应式数据更新执行
  note for RefImpl "trackRefValue<br />triggerRefValue<br />track<br />trigger<br />trackEffect<br />triggerEffects"
  ReactiveEffect <|-- VueCreateApp : 初始实例化执行函数 render
  class ReactiveEffect {
    fn: Function 初始化函数
    trigger: Function 非渲染函数
    scheduler: Function 微任务函数
    active: true
    deps: [] 记录发布者消息
    _trackId: 0
    _dirtyLevel: 0 用于控制执行
    _runnings: 0
    _shouldSchedule: false
    _depsLength: 0
    ...
    get dirty()
    set dirty()
    run() 执行函数
    stop()
  }
  class RefImpl {
    dep: void 0
    _value: any
    ...
    get value()
    set value()
  }
  class ComputedRefImpl {
    getter
    _setter
    dep: void 0
    effect
      effect.computed
      effect.active: true
    ...
    get value()
    set value()
    get _dirty()
    set _dirty()
  }
  class VueCreateApp {
    ...
    mount()
  }

思考

以 Vue 组合式 API(ref computed) 解析

import { ref, computed } from 'vue';
// 响应式数据声明
const msg = ref('');
// 计算属性
const c_msg = computed(() => {
  return msg.value + ' world';
});
// 初始化
const app = Vue.createApp({
  setup() {
    return {
      msg,
      c_msg,
    };
  },
  render() {
    msg.value = 'hello';
    return c_msg.value;
  },
});
// 渲染
app.mount(/* '#root' */);

当响应式数据更新时,computed getter 函数如何执行,并且又是如何触发更新

ref

import { ref } from 'vue';
// 普通值
const ref1 = ref(1);
const ref2 = ref('1');
// 对象或者数组
const ref3 = ref({ a: 1 });
const ref4 = ref([1]);
flowchart TB
  A["ref(value)<br />createRef<br />new RefImpl()"] --> E{"value 基本类型"}
  E -- "是" --> F["直接通过.value方式监听响应值"]
  E -- "否" --> G["通过 toReactive/reactive 函数使用 proxy 监听响应值"]
  F --> H["get: trackRefValue<br/>set: triggerRefValue"]
  G --> I["proxy get: track<br />proxy set: trigger"]
  I --> J["triggerEffects 触发更新"]
  H --> J

描述

ref API 每一个响应数据监听都有一个 dep 发布者,等待订阅

  • 接收一个参数
  • 创建 .value 获取值 ref2 = new RefImpl
    • 基本类型
      • get 获取值 trackRefValue 添加发布者,等待订阅 ref2.dep = createDep
      • set 更新值 triggerValue
    • 引用类型
      • 使用 targetMap = new WeakMap 缓存
        • value 为 key, dep = createDep 为值
      • proxy get 获取值 track 添加发布者,等待订阅
      • proxy set 更新值 trigger
  • 触发的条件
    • activeEffect 变量, 当前初始化 ReactiveEffect 实例的渲染函数
  • 渲染函数
    • trackEffect 订阅触发
    • triggerEffects
      • _dirtyLevel 函数参数
        • 0 初始状态
        • 4 ref 值更新

computed

当定义的响应值触发变化时,触发更新

import { ref, computed } from 'vue';
const count = ref(1);
const plusOne = computed(() => {
  return count.value + 1;
});
// 更新值,触发变化
count.value = 2;
// const plusOne = computed({
//   get: () => count.value + 1,
//   set: (val) => {
//     count.value = val - 1
//   }
// })
// plusOne.value = 1
console.log(count.value); // 0
flowchart
  direction TB
    A["computed<br />computed$1<br />可自定义 get/set"] --> B["new ComputedRefImpl"]
    B -- "computed getter 是一个函数,因此需要实例化 ReactiveEffect 等待更新如何执行" --> C["监听响应值 .value"]
    C --> D["get value: trackRefValue/triggerRefValue"]

描述

computed API 同样也为响应式数据,为每一个 computed 数据实例化添加发布者,等待订阅更新

  • 接收 getter/setter 函数参数
    • 实例化 new ComputedRefImpl,创建 .value 值
      • 初始化 effect = ReactiveEffect getter 函数,等待执行
      • 添加发布者 dep
      • ...
    • 更新过程
      • set value 记录发布者列表,等待更新
        • triggerRefValue/triggerEffects
      • get value 获取响应式数据发布者
        • trackRefValue/trackEffect
        • 触发 getter 函数执行,获取新值,更新渲染

相关函数说明

trackRefValue 函数

当获取响应式数据 .value 时,会创建发布者并被缓存到当前响应式数据 dep = createDep,因此每个响应式数据都含有 dep 属性

triggerRefValue 函数

当响应式数据更新值 .value = ? 时,会通知当前的发布者更新消息,执行更新

track 函数

当响应式数据是非基本类型值时,通过变量 targetMap 创建当前的 dep = createDep

trackEffect

记录当前更新的响应式数据 dep 并且添加到 ReactiveEffect 实例化 deps 列表中

trigger 函数

当响应式数据更新时,获取当前的发布者列表,等待执行

ReactiveEffect _dirtyLevel 属性

_dirtyLevel(0 1 2 3 4)属性的作用,通过不同的状态来控制更新

测试

数据更新

msg.value = 'hello1';
// 触发一次更新
// 渲染结果 hello1 world

批量更新

可以看到ReactiveEffect类中scheduler参数,记录当前需要更新函数的队列,采用Promise then微任务方式

// 初始组件渲染实例
const effect = new ReactiveEffect(/* ...,  */ trigger, () => queueJob(update));
const update = () => {
  if (effect.dirty) {
    effect.run();
  }
};
msg.value = 'hello1';
msg.value = 'hello2';
// 多次更新值,只触发一次渲染函数
// 渲染结果 hello2 world

附源码实现

从 Vue3 源码中实现部分

(function (exports, factory) {
  exports.Vue = factory();
})(self, function () {
  let shouldTrack = true;
  let activeEffect;
  const NOOP = () => {};
  const trackStack = [];
  let pauseScheduleStack = 0;
  const queueEffectSchedulers = [];
  const isObject = (v) => v != null && typeof v === 'object';

  function queueJob(job) {
    console.log('nextTick wait update');
    Promise.resolve().then(() => {
      job();
    });
  }

  function pauseScheduling() {
    pauseScheduleStack++;
  }
  function resetScheduling() {
    pauseScheduleStack--;
    while (!pauseScheduleStack && queueEffectSchedulers.length) {
      queueEffectSchedulers.shift()();
    }
  }

  function pauseTracking() {
    trackStack.push(shouldTrack);
    shouldTrack = false;
  }
  function resetTracking() {
    const last = trackStack.pop();
    shouldTrack = last === void 0 ? true : last;
  }
  function triggerComputed(computed) {
    return computed.value;
  }
  function preCleanupEffect(effect2) {
    effect2._trackId++;
    effect2._depsLength = 0;
  }
  function postCleanupEffect(effect2) {
    if (effect2.deps.length > effect2._depsLength) {
      for (let i = effect2._depsLength; i < effect2.dep.length; i++) {
        cleanupDepEffect(effect2.deps[i], effect2);
      }
      effect2.deps.length = effect2._depsLength;
    }
  }
  class ReactiveEffect {
    constructor(fn, trigger, scheduler, scope) {
      this.fn = fn;
      this.trigger = trigger;
      this.scheduler = scheduler;
      this.active = true;
      this.deps = [];

      this._trackId = 0;
      this._dirtyLevel = 4;
      this._runnings = 0;
      this._depsLength = 0;
      this._shouldSchedule = false;
    }
    get dirty() {
      if (this._dirtyLevel === 2 || this._dirtyLevel === 3) {
        this._dirtyLevel = 1;
        pauseTracking();
        for (let i = 0; i < this._depsLength; i++) {
          const dep = this.deps[i];
          if (dep.computed) {
            triggerComputed(dep.computed);
            if (this._dirtyLevel >= 4) {
              break;
            }
          }
        }
        if (this._dirtyLevel === 1) {
          this._dirtyLevel = 0;
        }
        resetTracking();
      }
      return this._dirtyLevel >= 4;
    }
    set dirty(v) {
      this._dirtyLevel = v ? 4 : 0;
    }
    run() {
      this._dirtyLevel = 0;
      if (!this.active) {
        return this.fn();
      }
      let lastTrack = shouldTrack;
      let lastEffect = activeEffect;
      try {
        shouldTrack = true;
        activeEffect = this;
        this._runnings++;
        preCleanupEffect(this);
        return this.fn();
      } finally {
        postCleanupEffect(this);
        this._runnings--;
        activeEffect = lastEffect;
        shouldTrack = lastTrack;
      }
    }
    stop() {
      if (this.active) {
        preCleanupEffect(this);
        postCleanupEffect(this);
        this.active = false;
      }
    }
  }

  function createDep(cleanup, computed) {
    const dep = new Map();
    dep.cleanup = cleanup;
    dep.computed = computed;
    return dep;
  }
  function cleanupDepEffect(dep, effect2) {
    const trackId = dep.get(effect2);
    if (trackId !== void 0 && trackId !== effect2.trackId) {
      dep.delete(effect2);
      if (dep.size === 0) {
        dep.cleanup();
      }
    }
  }
  function trackEffect(effect2, dep) {
    if (dep.get(effect2) !== effect2._trackId) {
      dep.set(effect2, effect2._trackId);
      const oldDep = effect2.deps[effect2._depsLength];
      if (oldDep !== dep) {
        // console.log('old dep', oldDep);
        if (oldDep) {
          cleanupDepEffect(oldDep, effect2);
        }
        effect2.deps[effect2._depsLength++] = dep;
      } else {
        effect2._depsLength++;
      }
    }
  }
  function triggerEffects(dep, dirtyLevel) {
    pauseScheduling();
    for (const effect2 of dep.keys()) {
      let tracking;
      if (effect2._dirtyLevel < dirtyLevel && (tracking != null ? tracking : (tracking = dep.get(effect2) === effect2._trackId))) {
        effect2._shouldSchedule || (effect2._shouldSchedule = effect2._dirtyLevel === 0);
        effect2._dirtyLevel = dirtyLevel;
      }
      if (effect2._shouldSchedule && (tracking != null ? tracking : (tracking = dep.get(effect2) === effect2._trackId))) {
        effect2.trigger();
        if (!effect2._runnings && effect2._dirtyLevel !== 2) {
          effect2._shouldSchedule = false;
          if (effect2.scheduler) {
            queueEffectSchedulers.push(effect2.scheduler);
          }
        }
      }
    }
    resetScheduling();
  }

  function trackRefValue(ref2) {
    let _a;
    if (shouldTrack && activeEffect) {
      _a = ref2.dep;
      if (_a != null) {
        _a = _a;
      } else {
        _a = ref2.dep = createDep(() => (ref2.dep = void 0), ref2 instanceof ComputedRefImpl ? ref2 : void 0);
      }
      trackEffect(activeEffect, _a);
    }
  }
  function triggerRefValue(ref2, dirtyLevel, newVal) {
    const dep = ref2.dep;
    if (dep) {
      triggerEffects(dep, dirtyLevel);
    }
  }

  const reactiveMap = new WeakMap();
  const targetMap = new WeakMap();

  function track(target, type, key) {
    if (shouldTrack && activeEffect) {
      let depsMap = targetMap.get(target);
      if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
      }
      let dep = depsMap.get(key);
      if (!dep) {
        depsMap.set(key, (dep = createDep(() => depsMap.delete(key))));
      }
      trackEffect(activeEffect, dep);
    }
  }
  function trigger(target, type, key, newValue, oldValue) {
    const depsMap = targetMap.get(target);
    if (!depsMap) {
      return;
    }
    let deps = [];
    if (key !== void 0) {
      deps.push(depsMap.get(key));
    }
    switch (type) {
      case 'set':
        // nothing
        break;
    }
    pauseScheduling();
    for (let dep of deps) {
      if (dep) {
        triggerEffects(dep, 4);
      }
    }
    resetScheduling();
  }

  class BaseReactiveHandler {
    constructor() {}
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      // track
      // console.log('proxy get');
      track(target, 'get', key);
      return result;
    }
  }
  class MutableReactiveHandler extends BaseReactiveHandler {
    constructor() {
      super();
    }
    set(target, key, value, receiver) {
      // console.log('proxy set');
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      // add set 这里考虑 set
      if (!Object.is(oldValue, value)) {
        trigger(target, 'set', key, value, oldValue);
      }
      return result;
    }
  }

  function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) {
    const existingProxy = proxyMap.get(target);
    if (existingProxy) {
      return existingProxy;
    }
    const proxy = new Proxy(target, baseHandlers);
    proxyMap.set(target, proxy);
    return proxy;
  }
  function reactive(target) {
    return createReactiveObject(target, false, new MutableReactiveHandler(), {}, reactiveMap);
  }
  function toReactive(value) {
    return isObject(value) ? reactive(value) : value;
  }
  class RefImpl {
    constructor(value, shallow) {
      this.dep = void 0;
      this._rawValue = value;
      this._value = toReactive(value);
      // shallow 不考虑
      // this.shallow = shallow;
    }
    get value() {
      // console.log('get');
      trackRefValue(this);
      return this._value;
    }
    set value(newVal) {
      // console.log('set', newVal, this._rawValue);
      if (Object.is(newVal, this._rawValue)) {
        return;
      }
      this._rawValue = newVal;
      this._value = toReactive(newVal);
      triggerRefValue(this, 4, newVal);
    }
  }
  function createRef(rawValue, shallow) {
    return new RefImpl(rawValue, shallow);
  }
  function ref(value) {
    return createRef(value, false);
  }

  // computed
  class ComputedRefImpl {
    constructor(getter, _setter, isReadonly) {
      this.getter = getter;
      this._setter = _setter;
      this.dep = void 0;
      this.effect = new ReactiveEffect(
        () => getter(this._value),
        () => {
          triggerRefValue(this, this.effect._dirtyLevel === 2 ? 2 : 3);
        }
      );
      this.effect.computed = this;
      this.effect.active = true;
    }
    get value() {
      if (this.effect.dirty && !Object.is(this._value, (this._value = this.effect.run()))) {
        triggerRefValue(this, 4);
      }
      trackRefValue(this);
      if (this.effect._dirtyLevel >= 2) {
        triggerRefValue(this, 2);
      }
      return this._value;
    }
    set value(newVal) {
      this._setter(newVal);
    }
    get _dirty() {
      return this.effect.dirty;
    }
    set _dirty(v) {
      this.effect.dirty = v;
    }
  }
  function computed$1(getterOrOptions) {
    // only getter
    let getter = getterOrOptions;
    const cRef = new ComputedRefImpl(getter, NOOP, true);
    return cRef;
  }
  function computed(getterOrOptions) {
    const c = computed$1(getterOrOptions);
    return c;
  }

  // init
  const createApp = (options) => {
    return {
      mount() {
        if (options.setup) {
          options.setup();
        }
        const effect = new ReactiveEffect(options.render, NOOP, () => queueJob(update));
        const update = () => {
          if (effect.dirty) {
            effect.run();
          }
        };
        update();
      },
    };
  };

  return {
    ref,
    computed,
    createApp,
  };
});

相关链接

代码实现

END

Vue2实践(3)之用component做一个动态表单(二)

作者 wycode
2025年8月18日 16:56

前言

在上一篇中,我们已经通过<component>实现了组件的动态渲染,为这个动态表单功能定下框架。

在这篇中,我们将着重实现功能。

功能实现

在上一篇中,我们定下了由设置组件来制作具体组件的方案,我们先来完善这一功能——从设置组件中获取完整的具体组件信息。

在这里我选用SelectInputSetting来做例子

<template>
    <div>
        <TextInput :field="labelField" v-model="setting.name" />
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput class="option-content" :field="item" v-model="setting.options[index].value" />
            <button @click="deleteOption(index)">删除</button>
        </div>
        <button @click="addOption">添加选项</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '', // 通过之前的源码文档,我们得知初始的object其中的属性是响应式的
        },
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0, // 内部自增标识
        },
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1); // 通过之前的源码文档,我们得知vue通过劫持数组原型方法实现数组响应式,splice就是其中之一
        },
    }
}
</script>

设置组件与预览表单的数据交互

分析

目前在设置组件SelectInputSetting中,通过setting收集用户输入,已经能够得到一份“下拉框组件定义”数据;

接下来,只要把这份数据传递到“表单预览”中,即可。所以我们需要实现它们之间的数据交互,通常来说有许多方案,但是考虑到用户操作性,数据交互可以通过:点击、拖拽等交互实现。在这里我们选用“拖拽”交互。

实现拖拽交互

实现拖拽交互,需要使用浏览器提供的一些API。

SelectInputSetting.vue
<template>
    <div>
        <TextInput draggable="true" :field="labelField" v-model="setting.name" @dragstart="dragstart" />
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput class="option-content" :field="item" v-model="setting.options[index].value" />
            <button @click="deleteOption(index)">删除</button>
        </div>
        <button @click="addOption">添加选项</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '',
        },
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0,
        },
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1);
        },
        // 开始拖动事件
        dragstart(e) {
            const dataStr = JSON.stringify(this.setting);
            e.dataTransfer.setData('application/json', dataStr);
        }
    }
}
</script>

draggable标识: 应用于HTML元素,用于标识元素是否允许使用浏览器原生行为或HTML 拖放操作 API拖动。true时元素可以被拖动。

dragstart事件: dragstart 事件在用户开始拖动元素或被选择的文本时调用。

通过HTML的拖放API,我们将数据传递通过event进行传递。

DynamicForm.vue
<template>
    <div class="container">
        <div class="main-area" @drop="addComponent" @dragover.prevent>
            <!-- 表单预览域 -->
            <div class="form-title">
                <TextInput :field="titleField" />
            </div>
            <div class="form-content" v-for="(item) in fields" :key="item.id">
                <component class="form-component" :is="item.type" :field="item" />
            </div>
        </div>
        <div class="sidebar">
            <!-- 表单组件域 -->
            <SelectInput v-model="componentValue" :field="createField" />
            <div>
                <component class="form-component" :is="componentValue" />
            </div>
        </div>
    </div>
</template>

<script>
import TextInput from './FieldTypes/TextInput.vue';
import TextInputSetting from './FieldTypes/TextInputSetting.vue';
import SelectInput from './FieldTypes/SelectInput.vue';
import SelectInputSetting from './FieldTypes/SelectInputSetting.vue';
export default {
    components: {
        TextInput,
        TextInputSetting,
        SelectInput,
        SelectInputSetting
    },
    data: () => ({
        titleField: {
            name: '表单名称',
            placeholder: '请输入表单名称',
            value: ''
        },
        componentValue: '',
        createField: {
            name: '选择要创建的组件',
            placeholder: '',
            value: '',
            options: [
                { 'value': 'TextInputSetting', 'name': '文本框' },
                { 'value': 'SelectInputSetting', 'name': '下拉单选框' },
            ]
        },
        fields: [],
    }),
    methods: {
        addComponent(e) {
            e.preventDefault(); // drop事件必须阻止默认行为

            const dataStr = e.dataTransfer.getData('application/json');
            const data = JSON.parse(dataStr);
            data.id = Date.now().toString(); // 添加一个唯一标识用于diff
            this.fields.push(data);
        }
    }
}
</script>

<style lang="scss" scoped>
.container {
    display: flex;
    border: 2px solid #000;
    padding: 10px;
}

.main-area {
    flex-grow: 4;
    margin-right: 10px;
    padding: 0 10px;
    border: 2px solid #000;
    border-radius: 10px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: flex-start;

    .form-title {
        width: auto;
        text-align: center;
        margin-bottom: 8px;
    }

    .form-content {
        border-radius: 10px;
        padding: 8px;
        width: 90%;
        border: 1px solid #ccc; // 默认边框

        .form-component {
            width: 300px;
        }

        &:hover {
            border: 1px solid #ccc;
            cursor: all-scroll;
        }
    }
}

.sidebar {
    display: flex;
    flex-direction: column;
    border: 2px solid #000;
    border-radius: 10px;
    padding: 10px;
    flex-grow: 1;

    .form-component {
        border: 1px solid #555;
        border-radius: 10px;
        padding: 8px;
        margin-bottom: 10px;
    }

    label {
        margin-bottom: 5px;
    }

    input {
        margin-top: 10px;
        padding: 5px;
        border: 1px solid #000;
        border-radius: 5px;
    }
}
</style>

drop事件: 事件在元素或文本选择被放置到有效的放置目标上时触发。为确保 drop 事件始终按预期触发,应当在处理 dragover 事件的代码部分始终包含 preventDefault() 调用。

dragover.prevent: 只有在 dragenterdragover 事件中调用了 event.preventDefault(),浏览器才会允许元素的拖放操作。

通过HTML的拖放API,我们将数据传递通过event进行接接收。

功能完善

  • SelectInputSetting中,选项删除按钮在当前选项hover时才出现
<style lang="scss" scoped>
    .option {
        button {
            visibility: hidden;
        }
        &:hover {
            button {
                visibility: visible;
            }
        }
    }
</style>

使用visibility属性的修改只会触发重绘,使用display实现的话会触发重排。这个跟使用v-show还是v-if的问题相似;

使用css元素实现而不是vue指令,在于css更好控制:hover

  • 组件设置完成后应该有保存选项来进行锁定,避免误操作
<!-- TextInput -->
<template>
  <div class="text-input-container">
    <label :for="field.name" class="text-input-label">{{ field.name }}:</label>
    <input class="text-input" type="text" :placeholder="field.placeholder" :value="value"
      @input="$emit('input', $event.target.value)" :required="field.required" :readonly="disabled" />
  </div>
</template>

<script>
export default {
  props: ['field', 'disabled', 'value'],
}
</script>
<!-- SelectInputSetting -->
<template>
    <div :class="{'drag':isFinished}" :draggable="isFinished" @dragstart="dragstart">
        <TextInput :disabled="isFinished" :field="labelField" v-model="setting.name" />
        <!-- 这里如果不使用item.key而是使用index,会因为节点复用导致显示错误 -->
        <div class="option" v-for="(item, index) in setting.options" :key="item.key"> 
            <TextInput :disabled="isFinished" class="option-content" :field="item" v-model="setting.options[index].value" />
            <button v-show="!isFinished" @click="deleteOption(index)">删除</button>
        </div>
        <button v-show="!isFinished" @click="addOption">添加选项</button>
        <button v-show="!isFinished" @click="finish">完成</button>
    </div>
</template>
<script>
import TextInput from './TextInput.vue';
export default {
    components: {
        TextInput
    },
    data: () => ({
        labelField: {
            name: '选项名称',
            placeholder: '请输入选项名称',
            value: '',
        },
        
        setting: {
            id: '',
            editor: 'SelectInputSetting',
            type: 'SelectInput',
            name: '',
            value: '',
            options:[],
            optionCount: 0
        },
        isFinished: false,
    }),
    methods: {
        addOption() {
            this.setting.options.push({
                name: '选项内容',
                placeholder: '请输入选项名称',
                value: '',
                key: this.setting.optionCount++
            })
        },
        deleteOption(index) {            
            this.setting.options.splice(index, 1);
        },
        dragstart(e) {
            const dataStr = JSON.stringify(this.setting);
            e.dataTransfer.setData('application/json', dataStr);
        },
        finish() {
            this.isFinished = true;
        },
    }
}
</script>
<style lang="scss" scoped>
.drag {
    &:hover {
        cursor: all-scroll; // 修改鼠标样式,更符合移动组件的暗示
    }
}
</style>

小结

至此我们已经完成了一个相对简单的动态表单组件。能从中体会组件的设计思想、代码组织,并且了解到一些具体实现需要调用的API。

接下来我们还将继续实现类似的有趣实践——导航栏

Oxc 最新 Transformer Alpha 功能速览! 🚀🚀🚀

2025年8月17日 20:16

前言

刚刚看到尤雨溪推特转发了 OXC 团队的最新成果,并介绍了该成果背后的一些故事!

尤雨溪推特

今天介绍下这些详细成果!

往期精彩推荐

正文

Oxc Transformer Alpha 内置 React Refresh,以及无需 TypeScript 编译器的独立 .d.ts 文件生成。相较于 SWC 和 Babel,Oxc 在性能、内存占用和包体积上表现出色,堪称前端构建的实用利器。

以下是其核心特性的详细解析。

1. TypeScript 和 JSX 到 ESNext 转换

Oxc 支持将 TypeScript 和 React JSX 代码转换为 ESNext,性能显著优于传统工具:

  • 3-5 倍于 SWC:处理 100 到 10,000 行代码,Oxc 耗时仅 0.14ms 至 14.9ms,而 SWC 为 0.7ms 至 35.9ms。
  • 20-50 倍于 Babel:Babel 处理同样代码耗时 11.5ms 至 492ms,Oxc 效率遥遥领先。

2. 内置 React Refresh

Oxc 集成了 React Refresh,支持开发中的热重载,速度比 SWC 快 5 倍,比 Babel 快 50 倍。这让 React 开发更流畅,减少等待时间。

3. TypeScript 独立声明生成

Oxc 提供无需 TypeScript 编译器的 .d.ts 文件生成,性能惊人:

  • 40 倍于 TSC:处理 100 行代码仅需 0.1ms(TSC 为 23.1ms)。
  • 20 倍于大文件:10,000 行代码耗时 3.5ms(TSC 为 115.2ms)。

示例

import { transform } from 'oxc-transform';
const transformed = transform('file.ts', sourceCode, {
  typescript: {
    onlyRemoveTypeImports: true,
    declaration: { stripInternal: true },
  },
});
await fs.writeFile('out.js', transformed.code);
await fs.writeFile('out.d.ts', transformed.declaration);

4. 轻量级与低内存占用

Oxc 仅需 2 个 npm 包(总计 2MB),对比 SWC 的 37.5MB 和 Babel 的 21MB(170 个包)。内存占用上,Oxc 处理 10,777 行代码仅用 51MB 内存,SWC 用 67MB,Babel 高达 172MB。

5. 实际应用案例

  • Vue.js:实验性使用 oxc-transform 优化构建流程。
  • vue-macros:通过 unplugin-isolated-decl.d.ts 生成时间从 76s 降至 16s。
  • Airtable:在 Bazel 构建中集成 Oxc 的 .d.ts 生成。
  • Rolldown:直接使用 Rust oxc_transformer crate。

最后

Oxc Transformer Alpha 以 Rust 的高性能和轻量级设计,为 JavaScript 编译带来新可能。无论是加速 TypeScript 转换还是优化 React 开发体验,它都展现了朴实无华的实用力量!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

Vue3之计算属性

作者 littleding
2025年8月17日 18:33

Vue3的计算属性为了解决依赖响应式状态的复杂逻辑

基本用法

<script setup> import { reactive, computed } from 'vue' 

const author = reactive({ 
    name: 'John Doe', 
    books: [ 
        'Vue 2 - Advanced Guide', 
        'Vue 3 - Basic Guide', 
        'Vue 4 - The Mystery' 
    ] 
}) 

// 一个计算属性 ref 
const publishedBooksMessage = computed(() => {
    return author.books.length > 0 ? 'Yes' : 'No' 
}) 
    
</script> 

<template> 
    <p>Has published books:</p> <span>{{ publishedBooksMessage }}</span>     
</template>

这里定义了一个计算属性 publishedBooksMessagecomputed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

Vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。

不同写法

要绑定一个动态类

写法一

const isActive = ref(true) 
const error = ref(null) 
const classObject = computed(() => ({ 
    active: isActive.value && !error.value, 
    'text-danger': error.value && error.value.type === 'fatal' 
}))

<div :class="classObject"></div>

写法二

const isActive = ref(true) 
const error = ref(null) 
const classObject = computed(() => { 
    return {
        active: isActive.value && !error.value, 
        'text-danger': error.value && error.value.type === 'fatal' 
    }
})

<div :class="classObject"></div>

两种计算属性写法

写法一 用了对象字面量的简写形式(省略了return和大括号),直接返回一个对象。这是 ES6 的箭头函数语法:当函数体只有一句返回语句且为对象时,可以省略return和外层大括号,用小括号包裹对象。

写法二 用了完整的函数体语法,显式写出return语句。这种写法更适合函数体可能扩展的场景(比如未来需要添加更多逻辑,如条件判断、变量计算等)

Vue 事件绑定机制

2025年8月17日 14:40

Vue 将事件系统拆分为原生 DOM 事件与自定义组件事件两套正交实现,前者对接浏览器事件循环,后者基于发布–订阅模型。本文以 v-on(缩写 @)为线索,结合运行时源码路径,给出端到端的实现剖析。

一、架构概览

Vue 的事件绑定分为两条主线:

  • 原生事件绑定

    通过 @clickv-on:click 直接作用于普通 DOM 元素,最终调用浏览器的 addEventListener

  • 组件事件绑定

    通过 @click 作用于子组件标签时,实际上是父组件监听子组件的自定义事件,由子组件通过 $emit 触发,不经过 DOM。

二、原生事件绑定:从 AST 到 addEventListener

1.编译阶段

模板中的 @click="handler" 经模板编译器解析后,生成 AST,最终转化为 VNode 的 data.on = { click: handler }

2.运行时挂载

首次渲染时,patch 过程会调用 createElm,为真实 DOM 节点执行 invokeCreateHooks,其中 cbs.create 包含 updateDOMListeners(位于 src/platforms/web/runtime/modules/events.js)。

updateDOMListeners 的职责:

  • 归一化事件名,处理 IE 兼容性差异。
  • 生成包裹函数,处理 .once.passive.capture 等修饰符。
  • 调用 updateListenersaddtarget.addEventListener(type, wrappedHandler, useCapture)

3.更新阶段

当组件更新时,patch 再次调用 updateDOMListeners,通过 sameVnode 判断事件差异,按需移除旧事件并重新绑定新事件。

三、组件事件绑定:on + events + emit

1.父组件编译

<Child @click="handleClick" /> 编译后,VNode 的 componentOptions.listeners = { click: handleClick },不会出现在 DOM 属性上。

2.子组件初始化

子组件实例化时:

  • initInternalComponent 将父级 listeners 注入到 vm.$options._parentListeners
  • initEvents 创建 _events = Object.create(null) 作为事件中心。
  • _parentListeners 非空,执行 updateComponentListeners(vm, _parentListeners),内部通过 $on 注册事件:
function add(event, fn) {
  vm.$on(event, fn)
}

3.手动触发

子组件内部调用 this.$emit('click', payload) 时,执行:

const cbs = vm._events[event]
if (cbs) {
  cbs.forEach(cb => cb(payload))
}

整个过程与浏览器事件体系完全隔离,因此可跨层级通信,且参数可控。

四、.native:在组件根节点强制使用原生事件

<Child @click.native="handler" /> 编译为 nativeOn 而非 on,运行时由 updateDOMListeners 读取 nativeOn,流程与原生事件一致,绑定在组件根 DOM 上。

五、事件修饰符实现细节

  • .stop:包裹函数内调用 e.stopPropagation()
  • .prevent:包裹函数内调用 e.preventDefault()
  • .once:绑定后立即移除监听器,并标记 _withOnce
  • .passive:调用 addEventListener(type, fn, { passive: true })
  • .capture:第三个参数传入 useCapture: true

六、性能与内存考量

  • 原生事件由浏览器托管,Vue 仅在 VNode 销毁时执行 removeEventListener,无额外开销。
  • 组件事件存储在 JS 对象,组件销毁时统一 $off,防止内存泄漏。

结论

Vue 事件系统通过“编译期转换 + 运行时调度”实现高度抽象:

  • 原生事件:AST → VNode → patch → addEventListener,完全对齐浏览器。
  • 组件事件:父子间通过 VNode.listeners → vm.events → emit,脱离 DOM,实现跨组件通信。

理解这一分层设计,有助于在复杂场景(服务端渲染、微前端、自定义渲染器)中精准定位事件相关问题。

HiCharts??与Echarts一样的图表库??

作者 菜牙买菜
2025年8月17日 13:07

HiCharts也是一款可视化图表库,适用于Javascript, Angular, React, VueJS, ios, R, NET,但是如果需要商用需要额外付费

对比点 Echarts Hicharts
性能 大数据量性能较好 中等数据量性能优秀
文档支持 中文文档完善 英文文档为主
特点 功能丰富,适合复杂可视化需求 功能完善,偏向传统商业图表

中文文档:highcharts.com.cn/

英文文档:www.highcharts.com/

一、入门学习

是好是坏,接下来代码里见真章,开码!!!

安装HiCharts依赖

pnpm install highcharts --save

HiCharts的使用起来与Echarts差不多,也是定义元素、配置项,然后去渲染图表,只是API使用不同,接下来我们定义简单的折线图渲染看看效果。

  1. 定义渲染元素:注意设置宽高
<div ref="chartRef" class="chart"></div>

2. 定义图表配置项

  // 初始化图表配置
  const options = {
    title: {
      text: '手机品牌月度销量',
      align: 'left'
    },
    subtitle: {
      text: '市场调研',
      align: 'left'
    },
    yAxis: {
      title: {
        text: '销量 (百万台)'
      }
    },
    xAxis: {
      categories: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
    },
    legend: {
      enabled: true, // 显示图例
      align: 'right',
      verticalAlign: 'top'
    },
    tooltip: {
      valueSuffix: ' 百万台'
    },
    plotOptions: {
      series: {
        marker: {
          enabled: true // 显示数据点
        }
      }
    },
    series: [
      {
        name: '小米',
        data: [4.2, 3.8, 4.5, 5.1, 5.7, 6.3, 6.8, 7.2, 7.5, 8.1, 8.6, 9.2]
      },
      {
        name: '华为',
        data: [8.5, 7.8, 9.1, 6.2, 5.4, 10.9, 12.2, 11.8, 10.5, 8.3, 9.2, 11.8]
      }
    ]
  }
  // 初始化图表
  Highcharts.chart(chartRef.value, options);
}

3. 页面初始化完成后渲染图表

onMounted(() => {
    initCharts();
})

HiCharts渲染效果如下图,为了方便作对比,笔者使用Echarts实现了同款图表,大家觉得哪个更好?

Echarts实现同款图表效果

在上述的例子中,在图表的右下角有着HiCharts的Logo,只需要加入如下配置即可消除

credits: {
  enabled: false
},

二、常见图表

(一) 柱状图(column)

基本结构与折线图类似,需要修改图表类型为column

{
  chart: {
    // 定义图表类型
    type: 'column'
  },
  title: {
    text: '手机品牌月度销量',
        align: 'center'
  },
  subtitle: {
    text: '市场调研',
        align: 'center'
  },
  xAxis: {
    categories: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
  },
  yAxis: {
    min: 0,
        title: {
      text: '销量 (百万台)'
    }
  },
  tooltip: {
    valueSuffix: ' 百万台'
  },
  plotOptions: {
    column: {
      pointPadding: 0.2,
          borderWidth: 0
    }
  },
  series: [
    {
      name: '小米',
      data: [4.2, 3.8, 4.5, 5.1, 5.7, 6.3, 6.8, 7.2, 7.5, 8.1, 8.6, 9.2]
    },
    {
      name: '华为',
      data: [8.5, 7.8, 9.1, 6.2, 5.4, 10.9, 12.2, 11.8, 10.5, 8.3, 9.2, 11.8]
    }
  ]
}

(二) 饼图(Pie)

const options = {
  chart: {
    type: 'pie',
    zooming: {
      type: 'xy'
    },
    panning: {
      enabled: true,
      type: 'xy'
    },
    panKey: 'shift'
  },
  title: {
    text: '手机品牌月度销量占比',
    align: 'center'
  },
  subtitle: {
    text: '市场调研',
    align: 'center'
  },
  tooltip: {
    valueSuffix: '%'
  },
  series: [
    {
      name: '手机品牌',
      colorByPoint: true,
      data: [
        {
          name: '小米',
          y: 55.02
        },
        {
          name: '华为',
          y: 26.71
        },
        {
          name: 'OPPO',
          y: 16.71
        },
        {
          name: '魅族',
          y: 16.71
        },
      ]
    }
  ]
}

(三) 面积图(area)

const options = {
  chart: {
    type: 'area'
  },
  title: {
    text: '手机品牌月度销量'
  },
  subtitle: {
    text: '市场调研'
  },
  xAxis: {
    categories: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
  },
  yAxis: {
    title: {
      text: '销量 (百万台)'
    }
  },
  credits: {
    enabled: false
  },
  legend: {
    enabled: true, // 显示图例
    align: 'right',
    verticalAlign: 'top'
  },
  tooltip: {
    valueSuffix: ' 百万台'
  },
  plotOptions: {
    area: {
      pointStart: 1940,
      marker: {
        enabled: false,
        symbol: 'circle',
        radius: 2,
        states: {
          hover: {
            enabled: true
          }
        }
      }
    }
  },
  series: [
    {
      name: '小米',
      data: [4.2, 3.8, 4.5, 5.1, 5.7, 6.3, 6.8, 7.2, 7.5, 8.1, 8.6, 9.2]
    },
    {
      name: '华为',
      data: [8.5, 7.8, 9.1, 6.2, 5.4, 10.9, 12.2, 11.8, 10.5, 8.3, 9.2, 11.8]
    }
  ]
};

(四) 组合图

const options = {
  chart: {
    zooming: {
      type: 'x'
    }
  },
  credits: {
    enabled: false
  },
  title: {
    text: '手机品牌月度销量与市场份额'
  },
  xAxis: {
    categories: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
  },
  yAxis: [
    {
      labels: {
        format: '{value}%'  // 左侧Y轴:市场份额百分比
      },
      title: {
        text: '市场份额'
      },
      lineColor: Highcharts.getOptions().colors[1],
      lineWidth: 2
    },
    {
      labels: {
        format: '{value} 百万台'  // 右侧Y轴:销量
      },
      title: {
        text: '销量'
      },
      lineColor: Highcharts.getOptions().colors[0],
      lineWidth: 2,
      opposite: true
    }
  ],
  tooltip: {
    shared: true
  },
  legend: {
    align: 'left',
    verticalAlign: 'top'
  },
  series: [
    {
      name: '华为',
      type: 'column',
      yAxis: 1,
      data: [12.5, 15.2, 18.7, 22.3, 25.1, 28.6, 30.2, 32.8, 29.4, 26.7, 20.5, 17.3],
      tooltip: {
        valueSuffix: ' 百万台'
      }
    },
    {
      name: '市场份额',
      type: 'spline',
      data: [18, 20, 22, 25, 27, 30, 32, 35, 33, 30, 25, 22],
      tooltip: {
        valueSuffix: '%'
      }
    }
  ]
};

三、中文配置

HiCharts默认使用的英文,配置中文的话,需要对应js文件

const protocol = window.location.protocol;
const defaultOptionsZhCn = {
  lang: {
    contextButtonTitle: "图表导出菜单",
    decimalPoint: ".",
    downloadJPEG: "下载JPEG图片",
    downloadPDF: "下载PDF文件",
    downloadPNG: "下载PNG文件",
    downloadSVG: "下载SVG文件",
    drillUpText: "返回 {series.name}",
    invalidDate: "无效的时间",
    loading: "加载中...",
    months: [
      "一月",
      "二月",
      "三月",
      "四月",
      "五月",
      "六月",
      "七月",
      "八月",
      "九月",
      "十月",
      "十一月",
      "十二月"
    ],
    noData: "没有数据",
    numericSymbols: null,
    printChart: "打印图表",
    resetZoom: "重置缩放比例",
    resetZoomTitle: "重置为原始大小",
    shortMonths: [
      "一月",
      "二月",
      "三月",
      "四月",
      "五月",
      "六月",
      "七月",
      "八月",
      "九月",
      "十月",
      "十一月",
      "十二月"
    ],
    thousandsSep: ",",
    weekdays: [
      "星期天",
      "星期一",
      "星期二",
      "星期三",
      "星期四",
      "星期五",
      "星期六"
    ],
    rangeSelectorFrom: "开始时间",
    rangeSelectorTo: "结束时间",
    rangeSelectorZoom: "范围",
    zoomIn: "缩小",
    zoomOut: "放大"
  },
  global: {
    canvasToolsURL:
      protocol + "//cdn.hcharts.cn/highcharts/modules/canvas-tools.js",
    VMLRadialGradientURL:
      protocol + +"//cdn.hcharts.cn/highcharts/gfx/vml-radial-gradient.png"
  },
  title: { text: "图表标题" },
  tooltip: {
    dateTimeLabelFormats: {
      millisecond: "%H:%M:%S.%L",
      second: "%H:%M:%S",
      minute: "%H:%M",
      hour: "%H:%M",
      day: "%Y-%m-%d",
      week: "%Y-%m-%d",
      month: "%Y-%m",
      year: "%Y"
    },
    split: false
  },
  exporting: { url: protocol + "//export.highcharts.com.cn" },
  credits: {
    text: "Highcharts.com.cn",
    href: "https://www.highcharts.com.cn"
  },
  xAxis: {
    dateTimeLabelFormats: {
      millisecond: "%H:%M:%S.%L",
      second: "%H:%M:%S",
      minute: "%H:%M",
      hour: "%H:%M",
      day: "%Y-%m-%d",
      week: "%Y-%m",
      month: "%Y-%m",
      year: "%Y"
    }
  },
  rangeSelector: {
    inputDateFormat: "%Y-%m-%d",
    buttonTheme: {
      width: "auto",
      style: { fontSize: "12px", padding: "4px" }
    },
    buttons: [
      { type: "month", count: 1, text: "月" },
      { type: "month", count: 3, text: "季度" },
      { type: "month", count: 6, text: "半年" },
      { type: "ytd", text: "YTD" },
      { type: "year", count: 1, text: "年" },
      { type: "all", text: "所有" }
    ]
  },
  plotOptions: {
    series: {
      dataGrouping: {
        dateTimeLabelFormats: {
          millisecond: [
            "%Y-%m-%d %H:%M:%S.%L",
            "%Y-%m-%d %H:%M:%S.%L",
            " ~ %H:%M:%S.%L"
          ],
          second: ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", " ~ %H:%M:%S"],
          minute: ["%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M", " ~ %H:%M"],
          hour: ["%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M", " ~ %H:%M"],
          day: ["%Y-%m-%d", "%Y-%m-%d", " ~ %Y-%m-%d"],
          week: ["%Y-%m-%d", "%Y-%m-%d", " ~ %Y-%m-%d"],
          month: ["%Y-%m", "%Y-%m", " ~ %Y-%m"],
          year: ["%Y", "%Y", " ~ %Y"]
        }
      }
    },
    ohlc: {
      tooltip: {
        split: false,
        pointFormat:
          '<span style="color:{point.color}">●</span> <b> {series.name}</b><br/>' +
          "开盘:{point.open}<br/>" +
          "最高:{point.high}<br/>" +
          "最低:{point.low}<br/>" +
          "收盘:{point.close}<br/>"
      }
    },
    candlestick: {
      tooltip: {
        split: false,
        pointFormat:
          '<span style="color:{point.color}">●</span> <b> {series.name}</b><br/>' +
          "开盘:{point.open}<br/>" +
          "最高:{point.high}<br/>" +
          "最低:{point.low}<br/>" +
          "收盘:{point.close}<br/>"
      }
    }
  }
};

export default defaultOptionsZhCn;
import defaultOptionsZhCn from "../../assets/highcharts-zh_CN.js"
    
// 应用中文配置
Highcharts.setOptions(defaultOptionsZhCn);

四、结语

HiCharts的用法与Echarts使用差不多,只是些许区别,不过商用需要付费,而Echarts不需要,那这个HiCharts,但是存在即合理,下一章让我们一起学习一下它的进阶用法!!

设计师到前端不再有墙:Figma + VS Code 自动出码实践

作者 光头老石
2025年8月17日 11:55

过去我们总说“设计与实现之间有鸿沟”。设计师在 Figma 中交付精美的 UI,前端开发却要手动把它们翻译成 HTML/CSS,费时又容易跑偏。

但今天,借助 Figma Dev Mode + VS Code MCP(Model Context Protocol)+ GitHub Copilot agent mode,我们可以直接在编辑器里还原设计稿为 HTML 页面,几乎零差别。本文带你走通这条“自动化落地”的实践路线。

工程结构 与 VS Code 配置

首先,我们要让 VS Code 具备“理解 Figma”的能力。这里用到的是 MCP 插件机制

新建项目目录

**mkdir figma-html-demo && cd figma-html-demo**

目录结构

figma-html-demo/
  ├── .vscode/
  │    ├── settings.json     # 用户层配置
  ├── index.html             # 输出的 HTML 文件
  └── README.md

增加 mcp server

在vs code 编辑器内,使用快捷键 cmd + shift + p , 输入 mcp 然后选择 MCP:Add Server 打开如图二的界面。 选择 HTTP 选项。 输入 http:127.0.0.1:3845/mcp 。回车。

image.png

image 1.png

选择作用域。我倾向于对项目进行配置。 所以选 workspace.

image 2.png

做完这一步。 我们就能看到,工程目录下新增的mcp.json 文件。 大概如下图。

image 3.png

Figma Dev Mode MCP 配置

目前 Figma Dev Mode MCP 处于Beta 阶段。 并且,只有桌面客户端和付费用户才能使用。 如果,你没有付费或者是在网页端是看不到,MCP server 这个选项的。

打开我们的figma 桌面端软件。 在左上角的图标处开启 MCP Server 服务。 如下图。

image 4.png

第二 ,确认好当前需要的设计稿处于 Dev mode 模式。

Dev Mode 会暴露清晰的层级、属性和标注,MCP 插件依赖这个结构来生成 HTML。

image 5.png

Github copilot 生成页面

前边基础配置,我们都做完以后。 现在进入 主要的生成阶段。 首先,我们要在 Figma 中,选择要生成的页面 或者组件,右键选择 复制链接。

image 6.png

打开 github Copilot 将模式从 ask (问答模式) 切换到 Agent 模式。

image 7.png

提示词 示例:

{figma Url}
帮我 实现如上的 Figma 页面。 命名为 index.html 要保留全部细节。采用 tailwind css。 遇到图片素材存储为png 格式,并存放到根目录的assets/imgs 目录下。
这是一个响应式的html5 页面。

输入提示词后, 大概会走这么一个流程 。

image 8.png

结果对比示例

image 9.png

总结

用 VS Code + Figma MCP + GitHub Copilot,我们把过去设计到实现之间的鸿沟,压缩到一条命令。

通过我这个测试项目,目前的还原度已经达到了 一个三四年经验的 前端工程师能做的样子了。

虽然目前无法做到只用一条提示词。 就做到完全的一模一样。 但距离这个目标越来越近了 。程序员以后如果不会 vibe coding 可能会越来越难混了。

🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用

2025年8月16日 16:36

🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用

📚 学习目标

通过本文,你将深入理解:

  • 🎯 Vue3 Diff算法的完整五步策略,而非仅仅是最长递增子序列
  • 🔧 双端比较算法如何最大化节点复用,减少DOM操作
  • ⚡ 最长递增子序列在乱序场景下的核心作用与实现原理
  • 🎨 Key值在Diff算法中的关键作用与性能影响
  • 💡 从算法设计角度理解Vue3相比Vue2的性能提升

🌟 引言

在前端面试中,"Vue3的Diff算法"是一个高频考点。许多候选人的第一反应是"最长递增子序列",但这个回答并不完整。

真相是:Vue3的Diff算法是一个精心设计的五步优化策略,最长递增子序列只是其中一个环节。它通过双端比较、增删处理、乱序优化等多个步骤,实现了对DOM操作的最大化优化。

让我们深入源码,揭开Vue3 Diff算法的神秘面纱。

🔬 核心函数:patchKeyedChildren

patchKeyedChildren 是Vue3 Diff算法的核心实现,负责处理带有key的子节点列表的更新。这个函数体现了Vue3团队在性能优化方面的深度思考。

🎯 算法概览

Vue3的Diff算法采用分治策略,将复杂的列表比较问题分解为五个相对简单的子问题:

  1. 前序比较:处理列表开头的相同节点
  2. 后序比较:处理列表结尾的相同节点
  3. 新增处理:挂载新出现的节点
  4. 删除处理:卸载不再需要的节点
  5. 乱序处理:使用最长递增子序列优化节点移动

这种设计的巧妙之处在于:大多数实际场景下,列表的变化都集中在前四步,只有少数复杂场景才需要进入第五步

  const patchKeyedChildren = (
    c1: VNode[],
    c2: VNode[],
    container: Element,
    parentAnchor: any
  ) => {
    // 📏 初始化指针和长度变量
    let newLen = c2.length // 新子节点数组的长度
    let oldLen = c1.length - 1 // 旧子节点数组的最后一个索引
    let e1 = oldLen // 旧数组的结束指针(从后往前移动)
    let e2 = newLen - 1 // 新数组的结束指针(从后往前移动)
    let i = 0 // 开始指针(从前往后移动)
    
    // 🔍 第一步:从前往后比较,找出开头相同的节点
    // 目的:跳过开头相同的节点,减少后续比较的工作量
    // 例如:[A,B,C,D] vs [A,B,X,Y] → 跳过A,B,从C,D vs X,Y开始处理
    while (i <= e1 && i <= e2) {
      const n1 = c1[i] // 当前旧节点
      const n2 = c2[i] // 当前新节点
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点(可能属性或子节点有变化)
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止前向比较
        break
      }
      i++ // 指针前移
    }
    
    // 🔍 第二步:从后往前比较,找出结尾相同的节点
    // 目的:跳过结尾相同的节点,进一步缩小需要处理的范围
    // 例如:[A,B,C,D] vs [X,Y,C,D] → 跳过C,D,只需处理A,B vs X,Y
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] // 当前旧节点(从后往前)
      const n2 = c2[e2] // 当前新节点(从后往前)
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止后向比较
        break
      }
      e1-- // 旧数组指针前移
      e2-- // 新数组指针前移
    }
    
    // 📊 经过前后两轮比较后的状态分析:
    // - i: 第一个不同节点的位置
    // - e1: 旧数组中最后一个需要处理的节点位置
    // - e2: 新数组中最后一个需要处理的节点位置
    
    // ✅ 第三步:处理新增节点的情况
    // 条件:i > e1 说明旧节点已经处理完,但新节点还有剩余
    // 例如:旧[A,B] 新[A,B,C,D] → 需要新增C,D
    if (i > e1) {
      if (i <= e2) {
        // 确定插入位置的锚点
        const nextPos = e2 + 1
        // 如果下一个位置存在节点,就插入到它前面;否则插入到容器末尾
        const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor
        
        // 挂载所有新增的节点
        while (i <= e2) {
          // patch(null, newNode) 表示挂载新节点
          patch(null, c2[i], container, anchor)
          i++
        }

### 🎯 第三部分:最优移动策略与最长递增子序列

这是Vue3 Diff算法最精彩的部分,也是**最长递增子序列**真正发挥作用的地方。当前四步都无法处理时,说明遇到了复杂的乱序场景。

#### 🎯 核心挑战

乱序场景的核心挑战是:**如何用最少的DOM移动操作,将旧列表转换为新列表?**

```typescript
// 典型乱序场景
// 旧列表:[A, B, C, D, E]
// 新列表:[A, C, E, B, D, F]
// 挑战:B和D需要移动,F需要新增,同时要保持C和E的相对位置不变
🧩 三步解决策略

Vue3将这个复杂问题分解为三个子问题:

  1. 🗺️ 构建映射表:建立新节点key到索引的快速查找表
  2. 🔍 标记可复用节点:找出哪些旧节点可以复用,哪些需要删除
  3. 🎯 最优移动策略:使用最长递增子序列计算最少移动方案
🔧 关键数据结构
// newIndexToOldIndexMap: 核心数据结构
// 索引:新列表中的位置
// 值:对应旧列表中的位置 + 1(+1是为了区分0和未找到)
// 例:[3, 1, 4, 0] 表示:
// - 新列表[0] 对应 旧列表[2]
// - 新列表[1] 对应 旧列表[0] 
// - 新列表[2] 对应 旧列表[3]
// - 新列表[3] 是新增节点
⚡ 移动检测算法
// 移动检测的巧妙之处
let maxNewIndexSoFar = 0
for (let i = s1; i <= e1; i++) {
  const newIndex = keyToNewIndexMap.get(prevChild.key)
  if (newIndex >= maxNewIndexSoFar) {
    maxNewIndexSoFar = newIndex  // 节点位置递增,无需移动
  } else {
    moved = true  // 发现逆序,需要移动
  }
}

这个算法的精髓在于:如果新位置总是递增的,说明相对顺序没变,无需移动

📊 性能优化细节
// 早期退出优化
if (patched >= toBePatched) {
  unmount(prevChild)
  continue
}

// 这个优化的作用:
// 如果已经处理的节点数量达到新节点总数
// 说明剩余的旧节点都是多余的,直接删除
// 避免不必要的查找和比较操作
🔑 Key值的重要性
// 有key的情况:O(1)查找
if (prevChild.key != null) {
  newIndex = keyToNewIndexMap.get(prevChild.key)
}
// 无key的情况:O(n)查找
else {
  for (j = s2; j <= e2; j++) {
    if (newIndexToOldIndexMap[j - s2] === 0 && 
        isSameVNodeType(prevChild, c2[j])) {
      newIndex = j
      break
    }
  }
}

这就是为什么Vue强烈建议在v-for中使用key的原因

  • ✅ 有key:时间复杂度O(1)
  • ❌ 无key:时间复杂度O(n²)
🎯 最长递增子序列的核心作用

在第三步的移动处理中,最长递增子序列发挥了关键作用:

// 核心执行逻辑
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : []

// 示例场景
// 旧列表:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
// 新列表:[A, C, E, B, D]  索引:[0, 1, 2, 3, 4]
// newIndexToOldIndexMap: [1, 3, 5, 2, 4]  // +1偏移后的值

// 最长递增子序列:[1, 3, 5] 对应节点 [A, C, E]
// 结论:A, C, E 不需要移动,只需移动 B, D
⚡ 移动策略优化
// 逆向遍历的巧妙之处
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
  const nextChild = c2[nextIndex]
  const anchor = nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor
  
  if (newIndexToOldIndexMap[i] === 0) {
    // 新增节点
    patch(null, nextChild, container, anchor)
  } else if (moved) {
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 需要移动的节点
      container.insertBefore(nextChild.el, anchor)
    } else {
      j--  // 在最长递增子序列中,不需要移动
    }
  }
}

为什么要逆向遍历?

  • 🎯 保证锚点的正确性:后面的节点位置确定后,前面的节点才能找到正确的插入位置
  • ⚡ 减少DOM操作:避免重复的位置计算
🧮 算法复杂度分析
  • 时间复杂度:O(n log n) - 最长递增子序列算法

  • 空间复杂度:O(n) - 存储序列信息

  • 实际效果:将移动操作从O(n²)优化到接近O(n) } } // 🗑️ 第四步:处理删除节点的情况 // 条件:i > e2 说明新节点已经处理完,但旧节点还有剩余 // 例如:旧[A,B,C,D] 新[A,B] → 需要删除C,D else if (i > e2) { while (i <= e1) { // 卸载多余的旧节点 unmount(c1[i]) i++ } } // 乱序情况:需要进行复杂的diff算法 // 使用最长递增子序列算法来最小化DOM移动操作 else { // 🎯 乱序情况的处理:这是Vue3 diff算法最复杂的部分 // 目标:用最少的DOM操作,将旧子节点列表转换为新子节点列表

    const s1 = i // 旧子节点数组中需要处理的起始位置
    const s2 = i // 新子节点数组中需要处理的起始位置
    
    // 📋 第一步:建立"新节点key → 新节点索引"的快速查找表
    // 作用:后面遍历旧节点时,可以快速找到对应的新节点位置
    // 例如:新节点 [A, B, C] → Map { 'A': 0, 'B': 1, 'C': 2 }
    const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = c2[i]
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
    
    // 🔄 第二步:遍历旧子节点,找出可以复用的节点并记录移动信息
    let j
    let patched = 0 // 已经处理(patch)的节点数量
    const toBePatched = e2 - s2 + 1 // 新子节点中需要处理的总数量
    let moved = false // 标记是否有节点需要移动位置
    let maxNewIndexSoFar = 0 // 记录到目前为止遇到的最大新索引
    
    // 📊 创建"新节点索引 → 旧节点索引"的映射数组
    // 作用:记录每个新节点对应的旧节点位置,用于后续的移动优化
    // 值的含义:0 = 全新节点,>0 = 可复用的旧节点索引+1
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
    
    // 🔍 遍历所有旧子节点,决定每个节点的命运
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i] // 当前处理的旧节点
    
      // ⚡ 性能优化:如果已处理的节点数量达到新节点总数,剩余旧节点直接删除
      // 例如:新节点只有3个,但已经处理了3个,那么剩下的旧节点都是多余的
      if (patched >= toBePatched) {
        unmount(prevChild) // 卸载多余的旧节点
        continue
      }
    
      let newIndex // 旧节点在新节点数组中对应的位置
    
      // 🔑 如果旧节点有key,通过key快速查找对应的新节点位置
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // 🔍 如果旧节点没有key,只能线性搜索找到相同类型的新节点
        // 注意:这种情况性能较差,建议给列表项添加key
        for (j = s2; j <= e2; j++) {
          // 检查:1) 新节点还没有被匹配 2) 新旧节点类型相同
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j])
          ) {
            newIndex = j
            break
          }
        }
      }
    
      // 🗑️ 如果旧节点在新节点中找不到对应项,说明被删除了
      if (newIndex === undefined) {
        unmount(prevChild) // 从DOM中移除
      } else {
        // ✅ 找到了对应的新节点,记录映射关系
        // +1是因为0被用来表示"新节点",所以旧索引要+1存储
        newIndexToOldIndexMap[newIndex - s2] = i + 1
    
        // 🚀 移动检测的巧妙算法:
        // 如果新索引是递增的,说明节点顺序没变,不需要移动
        // 如果新索引比之前的小,说明节点顺序乱了,需要移动
        // 例如:旧节点A在位置0,B在位置1,如果新顺序是B(1)→A(0),
        //      那么处理A时,newIndex=0 < maxNewIndexSoFar=1,需要移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex // 更新最大索引
        } else {
          moved = true // 标记需要移动
        }
    
        // 🔧 对找到的节点进行patch(更新属性、子节点等)
        patch(prevChild, c2[newIndex], container, null)
        patched++ // 已处理数量+1
      }
    }
    
    // 🎯 第三步:处理节点的移动和新节点的挂载
    // 核心思想:只移动必要的节点,最大化复用现有DOM
    
    // 🧮 如果需要移动,计算最长递增子序列(LIS)
    // LIS的作用:找出哪些节点已经在正确位置,不需要移动
    // 例如:[4,2,3,1,5] 的LIS是 [2,3,5],这些位置的节点不用动
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : []
    
    j = increasingNewIndexSequence.length - 1 // LIS的指针,从后往前
    
    // 🔄 从后往前遍历新子节点,确保插入位置正确
    // 为什么从后往前?因为插入时需要知道"锚点"(插入位置的参考节点)
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i // 当前新节点在整个新数组中的真实索引
      const nextChild = c2[nextIndex] // 当前要处理的新节点
    
      // 🎯 确定插入的锚点:下一个节点的DOM元素
      // 如果没有下一个节点,就插入到容器末尾
      const anchor =
        nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor
    
      // 🆕 如果是全新节点(映射值为0),直接挂载到DOM
      if (newIndexToOldIndexMap[i] === 0) {
        patch(null, nextChild, container, anchor)
      }
      // 🚚 如果需要移动节点
      else if (moved) {
        // 🎯 移动策略:只移动不在最长递增子序列中的节点
        // 如果当前节点在LIS中,说明它已经在正确位置,不用移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 移动节点到正确位置(插入到anchor之前)
          container.insertBefore(nextChild.el, anchor)
        } else {
          // 当前节点在LIS中,位置正确,不需要移动
          j-- // LIS指针前移
        }
      }
    }
    

🎯 五步优化策略详解


通过上面的核心代码,我们可以清晰地看到Vue3 Diff算法的五步处理逻辑。让我们逐一深入分析:

## 🔍 第一步:前序比较优化

```ts
   // 📏 初始化指针和长度变量
    let newLen = c2.length // 新子节点数组的长度
    let oldLen = c1.length - 1 // 旧子节点数组的最后一个索引
    let e1 = oldLen // 旧数组的结束指针(从后往前移动)
    let e2 = newLen - 1 // 新数组的结束指针(从后往前移动)
    let i = 0 // 开始指针(从前往后移动)
    
    // 🔍 第一步:从前往后比较,找出开头相同的节点
    // 目的:跳过开头相同的节点,减少后续比较的工作量
    // 例如:[A,B,C,D] vs [A,B,X,Y] → 跳过A,B,从C,D vs X,Y开始处理
    while (i <= e1 && i <= e2) {
      const n1 = c1[i] // 当前旧节点
      const n2 = c2[i] // 当前新节点
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点(可能属性或子节点有变化)
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止前向比较
        break
      }
      i++ // 指针前移
    }

🎯 核心思想

前序比较的核心思想是跳过开头相同的节点,这是一个非常实用的优化策略:

  • 时间复杂度:O(n),其中n是相同前缀的长度
  • 空间复杂度:O(1),只使用常数级别的额外空间
  • 实际效果:在列表末尾添加/删除元素的场景下,这一步就能处理大部分工作

📊 性能优势

// 场景示例:在列表末尾添加元素
// 旧列表:[A, B, C]
// 新列表:[A, B, C, D, E]
// 前序比较后:只需处理 [D, E] 的新增,跳过了 A, B, C 的比较

这种设计让Vue3在处理追加型更新(如聊天记录、商品列表加载更多)时性能极佳。

🔧 实现细节

// isSameVNodeType 的判断逻辑
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

// 为什么要调用 patch?
// 即使节点类型和key相同,节点的props或children可能发生变化
// patch函数会递归处理这些细节更新

🔄 第二步:后序比较优化

   // 🔍 第二步:从后往前比较,找出结尾相同的节点
    // 目的:跳过结尾相同的节点,进一步缩小需要处理的范围
    // 例如:[A,B,C,D] vs [X,Y,C,D] → 跳过C,D,只需处理A,B vs X,Y
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] // 当前旧节点(从后往前)
      const n2 = c2[e2] // 当前新节点(从后往前)
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止后向比较
        break
      }
      e1-- // 旧数组指针前移
      e2-- // 新数组指针前移
    }

🎯 核心思想

后序比较是前序比较的镜像操作,专门处理列表尾部的相同节点

  • 双指针技术e1e2分别指向旧列表和新列表的末尾
  • 逆向遍历:从后往前比较,跳过尾部相同的节点
  • 互补优化:与前序比较形成完美互补,覆盖更多优化场景

📊 典型应用场景

// 场景示例:在列表开头插入元素
// 旧列表:[A, B, C]
// 新列表:[X, Y, A, B, C]
// 后序比较后:跳过 A, B, C,只需处理 X, Y 的新增

🚀 双端优化的威力

前序 + 后序比较的组合,能够高效处理:

  • ✅ 列表头部插入/删除
  • ✅ 列表尾部插入/删除
  • ✅ 列表两端同时变化
  • ✅ 简单的元素替换

➕ 第三步:新增节点处理

    // 📊 经过前后两轮比较后的状态分析:
    // - i: 第一个不同节点的位置
    // - e1: 旧数组中最后一个需要处理的节点位置
    // - e2: 新数组中最后一个需要处理的节点位置
    
    // ✅ 第三步:处理新增节点的情况
    // 条件:i > e1 说明旧节点已经处理完,但新节点还有剩余
    // 例如:旧[A,B] 新[A,B,C,D] → 需要新增C,D
    if (i > e1) {
      if (i <= e2) {
        // 确定插入位置的锚点
        const nextPos = e2 + 1
        // 如果下一个位置存在节点,就插入到它前面;否则插入到容器末尾
        const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor
        
        // 挂载所有新增的节点
        while (i <= e2) {
          // patch(null, newNode) 表示挂载新节点
          patch(null, c2[i], container, anchor)
          i++
        }
      }
    }

🎯 判断逻辑

经过前两步的双端比较后,如果满足 i > e1 && i <= e2,说明存在需要新增的节点:

  • i > e1:旧列表已经遍历完毕
  • i <= e2:新列表还有未处理的节点
  • 结论:这些未处理的节点就是需要新增的节点

🔧 实现细节

// 锚点计算的巧妙之处
const nextPos = e2 + 1
const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor

// 为什么需要锚点?
// DOM的insertBefore需要一个参考节点
// 如果没有参考节点,就插入到容器末尾

📊 性能特点

  • 时间复杂度:O(m),其中m是新增节点的数量
  • 空间复杂度:O(1)
  • DOM操作:只进行必要的插入操作,无多余的移动

当旧节点的数量少于新节点的数量时,那么此时就需要创建新节点来插入到对应的位置

🗑️ 第四步:删除节点处理

   // 🗑️ 第四步:处理删除节点的情况
    // 条件:i > e2 说明新节点已经处理完,但旧节点还有剩余
    // 例如:旧[A,B,C,D] 新[A,B] → 需要删除C,D
    else if (i > e2) {
      while (i <= e1) {
        // 卸载多余的旧节点
        unmount(c1[i])
        i++
      }
    }

🎯 判断逻辑

当满足 i > e2 && i <= e1 时,说明存在需要删除的节点:

  • i > e2:新列表已经遍历完毕
  • i <= e1:旧列表还有未处理的节点
  • 结论:这些未处理的旧节点需要被删除

🔧 实现细节

// 删除操作的实现
if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

⚡ 性能优势

  • 批量删除:一次性处理所有需要删除的节点
  • 内存释放:及时释放不再需要的DOM节点和组件实例
  • 事件清理:自动清理相关的事件监听器和响应式依赖
  • 时间复杂度:O(k),其中k是需要删除的节点数量

📊 典型应用场景

// 场景示例:删除列表中的部分元素
// 旧列表:[A, B, C, D, E]
// 新列表:[A, B]
// 删除处理:自动卸载 C, D, E

🌪️ 第五步:乱序情况下的终极优化

这是Vue3 Diff算法最精彩的部分,也是最长递增子序列真正发挥作用的地方。当前四步都无法处理时,说明遇到了复杂的乱序场景。

🎯 核心挑战

乱序场景的核心挑战是:如何用最少的DOM移动操作,将旧列表转换为新列表?

// 典型乱序场景
// 旧列表:[A, B, C, D, E]
// 新列表:[A, C, E, B, D, F]
// 挑战:B和D需要移动,F需要新增,同时要保持C和E的相对位置不变

🧩 三步解决策略

Vue3将这个复杂问题分解为三个子问题:

  1. 🗺️ 构建映射表:建立新节点key到索引的快速查找表
  2. 🔍 标记可复用节点:找出哪些旧节点可以复用,哪些需要删除
  3. 🎯 最优移动策略:使用最长递增子序列计算最少移动方案

🗺️ 第一部分:构建映射表

   // 🎯 乱序情况的处理:这是Vue3 diff算法最复杂的部分
      // 目标:用最少的DOM操作,将旧子节点列表转换为新子节点列表

      const s1 = i // 旧子节点数组中需要处理的起始位置
      const s2 = i // 新子节点数组中需要处理的起始位置

      // 📋 第一步:建立"新节点key → 新节点索引"的快速查找表
      // 作用:后面遍历旧节点时,可以快速找到对应的新节点位置
      // 例如:新节点 [A, B, C] → Map { 'A': 0, 'B': 1, 'C': 2 }
      const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
      for (i = s2; i <= e2; i++) {
        const nextChild = c2[i]
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }

🎯 设计目的

构建映射表是一个经典的空间换时间优化策略:

  • 时间复杂度:从O(n²)降低到O(n)
  • 空间复杂度:O(n),用于存储映射关系
  • 查找效率:从线性查找提升到常数时间查找
📊 性能对比
// 没有映射表的查找(O(n²))
for (let i = 0; i < oldChildren.length; i++) {
  for (let j = 0; j < newChildren.length; j++) {
    if (oldChildren[i].key === newChildren[j].key) {
      // 找到匹配节点
    }
  }
}

// 使用映射表的查找(O(n))
const keyToNewIndexMap = new Map()
for (let i = 0; i < newChildren.length; i++) {
  keyToNewIndexMap.set(newChildren[i].key, i)
}

for (let i = 0; i < oldChildren.length; i++) {
  const newIndex = keyToNewIndexMap.get(oldChildren[i].key)
  // 常数时间找到匹配节点
}
🔧 实现细节
// 为什么使用 Map 而不是普通对象?
// 1. Map 支持任意类型的 key(string | number | symbol)
// 2. Map 的查找性能更稳定
// 3. Map 避免了原型链污染问题

// key 的类型检查
if (nextChild.key != null) {
  // 只有明确设置了 key 的节点才参与映射
  // undefined 和 null 都会被跳过
  keyToNewIndexMap.set(nextChild.key, i)
}

🔍 第二部分:标记可复用节点与移动检测

 // 🔄 第二步:遍历旧子节点,找出可以复用的节点并记录移动信息
      let j
      let patched = 0 // 已经处理(patch)的节点数量
      const toBePatched = e2 - s2 + 1 // 新子节点中需要处理的总数量
      let moved = false // 标记是否有节点需要移动位置
      let maxNewIndexSoFar = 0 // 记录到目前为止遇到的最大新索引

      // 📊 创建"新节点索引 → 旧节点索引"的映射数组
      // 作用:记录每个新节点对应的旧节点位置,用于后续的移动优化
      // 值的含义:0 = 全新节点,>0 = 可复用的旧节点索引+1
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

      // 🔍 遍历所有旧子节点,决定每个节点的命运
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i] // 当前处理的旧节点

        // ⚡ 性能优化:如果已处理的节点数量达到新节点总数,剩余旧节点直接删除
        // 例如:新节点只有3个,但已经处理了3个,那么剩下的旧节点都是多余的
        if (patched >= toBePatched) {
          unmount(prevChild) // 卸载多余的旧节点
          continue
        }

        let newIndex // 旧节点在新节点数组中对应的位置

        // 🔑 如果旧节点有key,通过key快速查找对应的新节点位置
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 🔍 如果旧节点没有key,只能线性搜索找到相同类型的新节点
          // 注意:这种情况性能较差,建议给列表项添加key
          for (j = s2; j <= e2; j++) {
            // 检查:1) 新节点还没有被匹配 2) 新旧节点类型相同
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j])
            ) {
              newIndex = j
              break
            }
          }
        }

        // 🗑️ 如果旧节点在新节点中找不到对应项,说明被删除了
        if (newIndex === undefined) {
          unmount(prevChild) // 从DOM中移除
        } else {
          // ✅ 找到了对应的新节点,记录映射关系
          // +1是因为0被用来表示"新节点",所以旧索引要+1存储
          newIndexToOldIndexMap[newIndex - s2] = i + 1

          // 🚀 移动检测的巧妙算法:
          // 如果新索引是递增的,说明节点顺序没变,不需要移动
          // 如果新索引比之前的小,说明节点顺序乱了,需要移动
          // 例如:旧节点A在位置0,B在位置1,如果新顺序是B(1)→A(0),
          //      那么处理A时,newIndex=0 < maxNewIndexSoFar=1,需要移动
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex // 更新最大索引
          } else {
            moved = true // 标记需要移动
          }

          // 🔧 对找到的节点进行patch(更新属性、子节点等)
          patch(prevChild, c2[newIndex], container, null)
          patched++ // 已处理数量+1
        }
      }

第三步:处理节点的移动和新节点的挂载

 // 🎯 第三步:处理节点的移动和新节点的挂载
      // 核心思想:只移动必要的节点,最大化复用现有DOM

      // 🧮 如果需要移动,计算最长递增子序列(LIS)
      // LIS的作用:找出哪些节点已经在正确位置,不需要移动
      // 例如:[4,2,3,1,5] 的LIS是 [2,3,5],这些位置的节点不用动
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : []

      j = increasingNewIndexSequence.length - 1 // LIS的指针,从后往前

      // 🔄 从后往前遍历新子节点,确保插入位置正确
      // 为什么从后往前?因为插入时需要知道"锚点"(插入位置的参考节点)
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i // 当前新节点在整个新数组中的真实索引
        const nextChild = c2[nextIndex] // 当前要处理的新节点

        // 🎯 确定插入的锚点:下一个节点的DOM元素
        // 如果没有下一个节点,就插入到容器末尾
        const anchor =
          nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor

        // 🆕 如果是全新节点(映射值为0),直接挂载到DOM
        if (newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild, container, anchor)
        }
        // 🚚 如果需要移动节点
        else if (moved) {
          // 🎯 移动策略:只移动不在最长递增子序列中的节点
          // 如果当前节点在LIS中,说明它已经在正确位置,不用移动
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            // 移动节点到正确位置(插入到anchor之前)
            container.insertBefore(nextChild.el, anchor)
          } else {
            // 当前节点在LIS中,位置正确,不需要移动
            j-- // LIS指针前移
          }
        }
      }

🧮 最长递增子序列算法深度解析

在第3部分中,涉及到了最长递增子序列:getSequence(newIndexToOldIndexMap),这个函数是Vue3 Diff算法的核心优化,用于计算出最少的DOM移动次数。

🎯 算法核心思想

最长递增子序列(Longest Increasing Subsequence, LIS)在Vue3中的作用是:找出哪些节点已经处于正确的相对位置,无需移动

// 示例场景
// newIndexToOldIndexMap: [4, 2, 3, 1, 5]
// 表示:新位置0对应旧位置3,新位置1对应旧位置1,以此类推
// 
// LIS算法会找出:[2, 3, 5] (索引为1, 2, 4的元素)
// 含义:这些位置的节点相对顺序正确,不需要移动
// 只需要移动其他节点:索引0和3的节点

⚡ 算法实现与优化

/**
 * 计算最长递增子序列的函数
 * 这是Vue3 diff算法的核心优化,用于最小化DOM移动操作
 *
 * 🎯 算法原理:
 * 1. 使用动态规划 + 二分查找,时间复杂度O(n log n)
 * 2. 维护一个递增序列,对每个元素二分查找插入位置
 * 3. 通过前驱数组记录路径,最后回溯得到完整序列
 * 4. 贪心策略:总是保持当前长度下的最小尾元素
 *
 * @param arr 输入数组,通常是newIndexToOldIndexMap
 * @returns 最长递增子序列的索引数组
 */
function getSequence(arr: number[]): number[] {
  const p = arr.slice() // 🔗 前驱数组,记录每个位置的前一个元素索引
  const result = [0] // 📊 结果数组,存储最长递增子序列的索引
  let i, j, u, v, c
  const len = arr.length

  // 🔄 主循环:处理每个元素
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    
    // ⚡ 关键优化:跳过值为0的元素
    // 0表示新节点,不参与LIS计算,因为新节点没有"原始位置"
    if (arrI !== 0) {
      j = result[result.length - 1] // 当前序列的最后一个索引

      // 🚀 快速路径:如果当前元素大于序列最后元素,直接追加
      // 这是最常见的情况,避免了二分查找的开销
      if (arr[j] < arrI) {
        p[i] = j // 记录前驱关系
        result.push(i) // 扩展序列
        continue
      }

      // 🔍 二分查找:找到第一个大于等于arrI的位置
      // 目标:在保持序列递增的前提下,找到最佳插入位置
      u = 0 // 左边界
      v = result.length - 1 // 右边界
      
      while (u < v) {
        c = (u + v) >> 1 // 🎯 位运算取中点,比Math.floor((u + v) / 2)更快
        
        if (arr[result[c]] < arrI) {
          u = c + 1 // 在右半部分继续查找
        } else {
          v = c // 在左半部分继续查找
        }
      }

      // 🔄 贪心替换:如果找到更优的元素,进行替换
      // 贪心策略:相同长度的递增序列中,尾元素越小越好
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1] // 记录前驱关系
        }
        result[u] = i // 替换为更优的元素
      }
    }
  }

  // 🔙 回溯构建最长递增子序列
  // 由于替换操作,result数组存储的不是最终序列
  // 需要通过前驱数组p来重建真正的LIS
  u = result.length
  v = result[u - 1] // 从最后一个元素开始回溯
  
  while (u-- > 0) {
    result[u] = v // 重建序列
    v = p[v] // 跳转到前驱元素
  }

  return result
}

📊 算法复杂度分析

操作 时间复杂度 空间复杂度 说明
构建LIS O(n log n) O(n) 二分查找优化的动态规划
回溯重建 O(k) O(1) k为LIS长度
总体 O(n log n) O(n) 相比暴力O(n²)有显著提升

🎨 实际应用示例

// 🎯 实际场景演示
// 旧列表:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
// 新列表:[B, A, D, C, E]  索引:[0, 1, 2, 3, 4]

// Step 1: 构建 newIndexToOldIndexMap
// B(新0) -> 旧1: map[0] = 2  (+1偏移)
// A(新1) -> 旧0: map[1] = 1  (+1偏移)
// D(新2) -> 旧3: map[2] = 4  (+1偏移)
// C(新3) -> 旧2: map[3] = 3  (+1偏移)
// E(新4) -> 旧4: map[4] = 5  (+1偏移)
// 结果:[2, 1, 4, 3, 5]

// Step 2: 计算LIS
const lis = getSequence([2, 1, 4, 3, 5])
// 返回:[1, 3, 4] (对应新列表中A, C, E的位置)

// Step 3: 移动策略
// 不移动:A(位置1), C(位置3), E(位置4) - 在LIS中
// 需移动:B(位置0), D(位置2) - 不在LIS中
// 结果:只需要2次DOM移动操作,而不是4次

🚀 性能优化细节

1. 位运算优化
// 使用位运算代替除法,提升性能
c = (u + v) >> 1  // 比 Math.floor((u + v) / 2) 快约20%
2. 早期退出策略
// 快速路径:避免不必要的二分查找
if (arr[j] < arrI) {
  result.push(i)
  continue  // 直接跳过二分查找
}
3. 贪心策略
// 相同长度的序列中,选择尾元素最小的
// 这样为后续元素提供更多的扩展可能性
if (arrI < arr[result[u]]) {
  result[u] = i  // 贪心替换
}

🎯 为什么选择LIS?

  1. 最优性保证:LIS确保找到需要移动的最少节点数
  2. 稳定性:相对位置正确的节点不会被移动
  3. 高效性:O(n log n)的时间复杂度,适合大列表
  4. 实用性:大多数实际场景下,列表变化都有一定的局部性

这就是Vue3 Diff算法中最长递增子序列的完整实现和优化策略。它不仅仅是一个算法,更是Vue3性能优化的核心体现。

🎯 核心原理总结

🔍 关键技术洞察

1. 五步优化策略的设计哲学

Vue3的Diff算法并非单纯依赖最长递增子序列,而是采用分层优化的设计思想:

  • 前四步:处理90%的常见场景(前后端比较、增删操作)
  • 第五步:处理10%的复杂场景(乱序移动)
  • 核心理念:用简单算法处理简单问题,用复杂算法处理复杂问题
2. Key值的核心作用机制
// Key值的三重作用
1. 🔍 节点识别:快速判断节点是否可复用
2. ⚡ 性能优化:从O(n²)降低到O(n)
3. 🎯 移动计算:为LIS算法提供准确的位置映射

为什么v-for需要手动添加key?

  • ✅ 其他节点:Vue3自动生成key(基于节点类型和位置)
  • ❌ v-for节点:动态生成,无法自动推断稳定的key
  • 🎯 解决方案:开发者提供业务相关的唯一标识
3. 算法复杂度的渐进优化
场景 传统算法 Vue3算法 优化效果
前后端添加 O(n²) O(n) 🚀 线性优化
简单移动 O(n²) O(n) 🚀 线性优化
复杂乱序 O(n²) O(n log n) ⚡ 对数优化
无key场景 O(n³) O(n²) 📈 仍需优化

🎨 设计模式分析

1. 分治策略(Divide and Conquer)
// 将复杂的列表比较问题分解为5个子问题
// 每个子问题都有针对性的优化策略
function patchKeyedChildren() {
  // 分治:前序比较
  syncFromStart()
  // 分治:后序比较  
  syncFromEnd()
  // 分治:新增处理
  mountNewNodes()
  // 分治:删除处理
  unmountOldNodes()
  // 分治:乱序处理
  handleComplexCase()
}
2. 贪心算法(Greedy Algorithm)
// 在LIS算法中的应用
// 总是选择当前长度下的最小尾元素
// 为后续扩展提供最大可能性
if (arrI < arr[result[u]]) {
  result[u] = i  // 贪心选择
}
3. 动态规划(Dynamic Programming)
// LIS算法的DP思想
// 状态:dp[i] = 以i结尾的最长递增子序列长度
// 转移:通过二分查找优化状态转移

🚀 性能优化要点

1. 空间换时间
  • 映射表:O(n)空间换取O(1)查找时间
  • 前驱数组:O(n)空间支持LIS回溯
  • 索引映射:避免重复的DOM查询
2. 算法层面优化
  • 二分查找:将LIS从O(n²)优化到O(n log n)
  • 位运算:使用>>代替除法运算
  • 早期退出:避免不必要的计算
3. 工程层面优化
  • 批量操作:减少DOM操作次数
  • 锚点策略:精确控制插入位置
  • 内存管理:及时释放不再需要的引用

🔮 与Vue2的对比

特性 Vue2 Vue3 改进
算法策略 双端比较 五步优化 🎯 更全面
复杂度 O(n²) O(n log n) ⚡ 更高效
移动优化 启发式 LIS算法 🧮 更精确
内存使用 较高 优化 💾 更节省

💡 最佳实践建议

1. Key值设计原则
// ✅ 推荐:使用稳定的业务ID
<li v-for="user in users" :key="user.id">

// ❌ 避免:使用数组索引
<li v-for="(user, index) in users" :key="index">

// ❌ 避免:使用随机值
<li v-for="user in users" :key="Math.random()">
2. 列表更新策略
// 🚀 高效:批量更新
const newUsers = [...users, ...newData]
users.value = newUsers

// 🐌 低效:逐个更新
newData.forEach(user => users.value.push(user))
3. 性能监控
// 开发环境下监控Diff性能
if (__DEV__) {
  console.time('diff-performance')
  patchKeyedChildren()
  console.timeEnd('diff-performance')
}

🎓 进阶学习建议

  1. 算法基础:深入学习动态规划、贪心算法、二分查找
  2. 数据结构:理解Map、数组操作的性能特点
  3. 浏览器原理:了解DOM操作的性能成本
  4. Vue源码:阅读完整的patch函数实现
  5. 性能调优:使用Vue DevTools分析实际项目的Diff性能

🌟 结语

Vue3的Diff算法是前端框架设计的典型代表,它完美诠释了工程化思维

  • 🎯 问题分解:将复杂问题分解为可管理的子问题
  • 性能优先:在保证正确性的前提下追求极致性能
  • 🔧 工程实用:算法设计贴近实际应用场景
  • 📈 持续优化:从Vue2到Vue3的不断改进

掌握Vue3 Diff算法,不仅能帮助我们写出更高性能的Vue应用,更能提升我们的算法思维和工程能力。这正是优秀前端工程师必备的核心素养。

Vue3第十八天,Vue3中的组件通信

2025年8月16日 11:44

Vue3组件通信和Vue2的区别:

  • 移出事件总线,使用mitt代替。
  • vuex换成了pinia
  • .sync优化到了v-model里面了。
  • $listeners所有的东西,合并到$attrs中了。
  • $children被砍掉了。

常见搭配形式:

image-20231119185900990.png

1. 【props】

概述:props是使用频率最高的一种通信方式,常用与 :父 ↔ 子

  • 父传子:属性值是非函数
  • 子传父:属性值是函数

父组件:

<template>
  <div class="father">
    <h3>父组件,</h3>
<h4>我的车:{{ car }}</h4>
<h4>儿子给的玩具:{{ toy }}</h4>
<Child :car="car" :getToy="getToy"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
// 数据
const car = ref('奔驰')
const toy = ref()
// 方法
function getToy(value:string){
toy.value = value
}
</script>

子组件

<template>
  <div class="child">
    <h3>子组件</h3>
<h4>我的玩具:{{ toy }}</h4>
<h4>父给我的车:{{ car }}</h4>
<button @click="getToy(toy)">玩具给父亲</button>
  </div>
</template>

<script setup lang="ts" name="Child">
import { ref } from "vue";
const toy = ref('奥特曼')

defineProps(['car','getToy'])
</script>

2. 【自定义事件】

  1. 概述:自定义事件常用于:子 => 父。
  2. 注意区分好:原生事件、自定义事件。
  • 原生事件:

    • 事件名是特定的(clickmosueenter等等)
    • 事件对象$event: 是包含事件相关信息的对象(pageXpageYtargetkeyCode
  • 自定义事件:

    • 事件名是任意名称
    • 事件对象$event: 是调用emit时所提供的数据,可以是任意类型!!!
  1. 示例:
<!--在父组件中,给子组件绑定自定义事件:-->
<Child @send-toy="toy = $event"/>

<!--注意区分原生事件与自定义事件中的$event-->
<button @click="toy = $event">测试</button>
//子组件中,触发事件:
this.$emit('send-toy', 具体数据)

3. 【mitt】

概述:与消息订阅与发布(pubsub)功能类似,可以实现任意组件间通信。

安装mitt

npm i mitt

新建文件:src\utils\emitter.ts

// 引入mitt 
import mitt from "mitt";

// 创建emitter
const emitter = mitt()

/*
  // 绑定事件
  emitter.on('abc',(value)=>{
    console.log('abc事件被触发',value)
  })
  emitter.on('xyz',(value)=>{
    console.log('xyz事件被触发',value)
  })

  setInterval(() => {
    // 触发事件
    emitter.emit('abc',666)
    emitter.emit('xyz',777)
  }, 1000);

  setTimeout(() => {
    // 清理事件
    emitter.all.clear()
  }, 3000); 
*/

// 创建并暴露mitt
export default emitter

接收数据的组件中:绑定事件、同时在销毁前解绑事件:

import emitter from "@/utils/emitter";
import { onUnmounted } from "vue";

// 绑定事件
emitter.on('send-toy',(value)=>{
  console.log('send-toy事件被触发',value)
})

onUnmounted(()=>{
  // 解绑事件
  emitter.off('send-toy')
})

【第三步】:提供数据的组件,在合适的时候触发事件

import emitter from "@/utils/emitter";

function sendToy(){
  // 触发事件
  emitter.emit('send-toy',toy.value)
}

注意这个重要的内置关系,总线依赖着这个内置关系

4.【v-model】

  1. 概述:实现 父↔子 之间相互通信。

  2. 前序知识 —— v-model的本质

    <!-- 使用v-model指令 -->
    <input type="text" v-model="userName">
    
    <!-- v-model的本质是下面这行代码 -->
    <input 
      type="text" 
      :value="userName" 
      @input="userName =(<HTMLInputElement>$event.target).value"
    >
    
  3. 组件标签上的v-model的本质::moldeValueupdate:modelValue事件。

<!-- 组件标签上使用v-model指令 -->
<AtguiguInput v-model="userName"/>

<!-- 组件标签上v-model的本质 -->
<AtguiguInput :modelValue="userName" @update:model-value="userName = $event"/>

AtguiguInput组件中:

<template>
  <div class="box">
    <!--将接收的value值赋给input元素的value属性,目的是:为了呈现数据 -->
<!--给input元素绑定原生input事件,触发input事件时,进而触发update:model-value事件-->
    <input 
       type="text" 
       :value="modelValue" 
       @input="emit('update:model-value',$event.target.value)"
    >
  </div>
</template>

<script setup lang="ts" name="AtguiguInput">
  // 接收props
  defineProps(['modelValue'])
  // 声明事件
  const emit = defineEmits(['update:model-value'])
</script>

4. 也可以更换value,例如改成abc

<!-- 也可以更换value,例如改成abc-->
<AtguiguInput v-model:abc="userName"/>

<!-- 上面代码的本质如下 -->
<AtguiguInput :abc="userName" @update:abc="userName = $event"/>

AtguiguInput组件中:

<template>
  <div class="box">
    <input 
       type="text" 
       :value="abc" 
       @input="emit('update:abc',$event.target.value)"
    >
  </div>
</template>

<script setup lang="ts" name="AtguiguInput">
  // 接收props
  defineProps(['abc'])
  // 声明事件
  const emit = defineEmits(['update:abc'])
</script>

5. 如果value可以更换,那么就可以在组件标签上多次使用v-model

<AtguiguInput v-model:abc="userName" v-model:xyz="password"/>

5.【$attrs 】

  1. 概述:$attrs用于实现当前组件的父组件,向当前组件的子组件通信(祖→孙)。

  2. 具体说明:$attrs是一个对象,包含所有父组件传入的标签属性。

    注意:$attrs会自动排除props中声明的属性(可以认为声明过的 props 被子组件自己“消费”了)

父组件:

<template>
  <div class="father">
    <h3>父组件</h3>
<Child :a="a" :b="b" :c="c" :d="d" v-bind="{x:100,y:200}" :updateA="updateA"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
let a = ref(1)
let b = ref(2)
let c = ref(3)
let d = ref(4)

function updateA(value){
a.value = value
}
</script>

子组件:

<template>
<div class="child">
<h3>子组件</h3>
<GrandChild v-bind="$attrs"/>
</div>
</template>

<script setup lang="ts" name="Child">
import GrandChild from './GrandChild.vue'
</script>

孙组件:

<template>
<div class="grand-child">
<h3>孙组件</h3>
<h4>a:{{ a }}</h4>
<h4>b:{{ b }}</h4>
<h4>c:{{ c }}</h4>
<h4>d:{{ d }}</h4>
<h4>x:{{ x }}</h4>
<h4>y:{{ y }}</h4>
<button @click="updateA(666)">点我更新A</button>
</div>
</template>

<script setup lang="ts" name="GrandChild">
defineProps(['a','b','c','d','x','y','updateA'])
</script>

6. 【refsrefs、parent】

  1. 概述:

    • $refs用于 :父→子。
    • $parent用于:子→父。
  2. 原理如下:

    属性 说明
    $refs 值为对象,包含所有被ref属性标识的DOM元素或组件实例。
    $parent 值为对象,当前组件的父组件实例对象。

7. 【provide、inject】

  1. 概述:实现祖孙组件直接通信

  2. 具体使用:

    • 在祖先组件中通过provide配置向后代组件提供数据
    • 在后代组件中通过inject配置来声明接收数据
  3. 具体编码:

    【第一步】父组件中,使用provide提供数据

<template>
  <div class="father">
    <h3>父组件</h3>
    <h4>资产:{{ money }}</h4>
    <h4>汽车:{{ car }}</h4>
    <button @click="money += 1">资产+1</button>
    <button @click="car.price += 1">汽车价格+1</button>
    <Child/>
  </div>
</template>

<script setup lang="ts" name="Father">
  import Child from './Child.vue'
  import { ref,reactive,provide } from "vue";
  // 数据
  let money = ref(100)
  let car = reactive({
    brand:'奔驰',
    price:100
  })
  // 用于更新money的方法
  function updateMoney(value:number){
    money.value += value
  }
  // 提供数据
  provide('moneyContext',{money,updateMoney})
  provide('car',car)
</script>

注意:子组件中不用编写任何东西,是不受到任何打扰的

【第二步】孙组件中使用inject配置项接受数据。

<template>
  <div class="grand-child">
    <h3>我是孙组件</h3>
    <h4>资产:{{ money }}</h4>
    <h4>汽车:{{ car }}</h4>
    <button @click="updateMoney(6)">点我</button>
  </div>
</template>

<script setup lang="ts" name="GrandChild">
  import { inject } from 'vue';
  // 注入数据
 let {money,updateMoney} = inject('moneyContext',{money:0,updateMoney:(x:number)=>{}})
  let car = inject('car')
</script>

8. 【slot】

1. 默认插槽

default_slot.png

父组件中:
        <Category title="今日热门游戏">
          <ul>
            <li v-for="g in games" :key="g.id">{{ g.name }}</li>
          </ul>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <!-- 默认插槽 -->
            <slot></slot>
          </div>
        </template>

2. 具名插槽

父组件中:
        <Category title="今日热门游戏">
          <template v-slot:s1>
            <ul>
              <li v-for="g in games" :key="g.id">{{ g.name }}</li>
            </ul>
          </template>
          <template #s2>
            <a href="">更多</a>
          </template>
        </Category>
子组件中:
        <template>
          <div class="item">
            <h3>{{ title }}</h3>
            <slot name="s1"></slot>
            <slot name="s2"></slot>
          </div>
        </template>

3. 作用域插槽

  1. 理解:数据在组件的自身,但根据数据生成的结构需要组件的使用者来决定。(新闻数据在News组件中,但使用数据所遍历出来的结构由App组件决定)
  2. 具体编码:
父组件中:
      <Game v-slot="params">
      <!-- <Game v-slot:default="params"> -->
      <!-- <Game #default="params"> -->
        <ul>
          <li v-for="g in params.games" :key="g.id">{{ g.name }}</li>
        </ul>
      </Game>

子组件中:
      <template>
        <div class="category">
          <h2>今日游戏榜单</h2>
          <slot :games="games" a="哈哈"></slot>
        </div>
      </template>

      <script setup lang="ts" name="Category">
        import {reactive} from 'vue'
        let games = reactive([
          {id:'asgdytsa01',name:'英雄联盟'},
          {id:'asgdytsa02',name:'王者荣耀'},
          {id:'asgdytsa03',name:'红色警戒'},
          {id:'asgdytsa04',name:'斗罗大陆'}
        ])
      </script>

我们下次有缘再见!

❌
❌