阅读视图

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

3.响应式系统基础:从发布订阅模式的角度理解 Vue2 的数据响应式原理

前言

关于 Vue2 和 Vue3 的数据响应式原理,相信 Vue 技术栈的同学或多或少都了解过,甚至在简历上写很熟悉,而且我们在第一篇中也已经基本实现过了,但大家是否真的彻底掌握了呢?

正如我们前面所说的那样,Vue2、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,也就是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。

同样地基于依赖追踪和触发的响应式系统都是通过发布订阅模式进行实现的,那么你知道 Vue 的数据响应式原理中是如何运用发布订阅模式的吗?

所以我们在本篇当中将从发布订阅模式的角度来理解 Vue 的数据响应式原理,彻底掌握数据响应式的基本原理,同时也巩固我们在上一篇中所说的发布订阅模式。我们在上一篇中学习了发布订阅模式,我们都是基于一些 demo 的例子去理解,本篇则真正的把发布订阅模式在实际项目的进行运用。

温馨提示,阅读本本之前最好先阅读前一篇文章,对发布订阅模式有一定的理解

发布订阅模式原理回顾

我们在上一篇中最后是通过 Object.defineProperty 方法对公众号对象 weChatOfficialAccountarticle 属性进行劫持监听,然后在 getter 的时候进行订阅,在 setter 的时候进行发布。

代码如下:

// 定义公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(this.article))
    },
    // 取消订阅
    remove(fn) { 
        // 找到需要删除的订阅者
        const index = this.subscribers.indexOf(fn)
        // 删除订阅者
        this.subscribers.splice(index, 1 )
    }
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) weChatOfficialAccount.addDep(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            weChatOfficialAccount.notify()
        }
    })
}

我们可以看到 weChatOfficialAccount 对象上有很多属于发布订阅模式中的功能,如果说还有其他对象也需要实现这样的功能,那么也要实现一遍这些功能,很明显这样是不可接受的,我们可以通过上一篇中实现的消息代理来代替 weChatOfficialAccount 对象中的发布订阅模式的功能。

首先我们对消息代理的实现做如下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}

如果熟悉 Vue2 数据响应式原理的同学对上面的代码肯定很熟悉,这个就是 Vue2 源码中的 Dep 类的简易实现,所以 Vue2 源码中的 Dep 其实就是一个事件总线或者叫消息代理,但它又不仅仅是消息代理,在某些时刻它同时又是一个订阅者,这个情况就是我们在上篇当中所说的一个对象既可以是发布者也可以是订阅者,而在 Vue2 源码中 Dep 类既是消息代理中心又是订阅者,具体情况我们将下文中进行详细讲解。

接下来我们继续做如下修改:

const weChatOfficialAccount = {
    // 消息代理对象
    __ob__: new Dep(),
    // 文章内容
    article: '', 
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) dep.addSub(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们在公众号对象中通过 __ob__ 属性来存储消息代理对象,这样公众号对象原本属于发布订阅的功能就通过 Dep 类来实现了,这样代码的功能职责就梳理得十分清晰了,也符合代码整洁之道。

我们对修改后的代码进行测试:

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.article}`)
}
// 读取一次,触发 getter 进行订阅
weChatOfficialAccount.article
// 设置为 null 防止重复订阅
subscriber = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

上述测试代码的测试结果将会打印:

收到的公众号文章:通过 Object.defineProperty 方法实现订阅发布模式

我们通过 Object.defineProperty 方法实现对 weChatOfficialAccount 对象的属性 article 进行监听实现发布订阅功能,然后订阅的时候需要读取一下,触发 getter 进行订阅,这个行为在上述例子中比较奇怪,我们把它改成我们容易理解的例子。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title></title>
  </head>
  <body>
    <div id='content'></div>
    <script>
        const weChatOfficialAccount = {
            // 消息代理对象
            __ob__: new Dep(),
            // 文章内容
            article: '文章内容', 
        }
        // 省略 ...
        // 订阅者小明
        let subscriber = () => {
            const el = document.getElementById('content')
            el.textContent = `小明收到的公众号文章:${weChatOfficialAccount.article}`
        }
        // 初始化
        subscriber()
        subscriber = null
    </script>
  <body>
</html>

我们改成我们 web 应用程序中的例子就比较容易理解了。所谓订阅者小明,就是一个 HTML 更新函数,在初始化执行 subscriber() 函数的时候,会读取公众号对象 weChatOfficialAccountarticle 属性的值,这样就会触发 getter 函数进行订阅,在后续当公众号对象 weChatOfficialAccountarticle 属性值发生变化的时候,就会触发 setter 进行发布,也就是重新执行订阅者函数,然后网页内容发生变化。这个也是 Vue2 中的数据响应式的基本原理。

在上述例子中我们只对其中一个属性进行监听,但实际情况很有可能有其他订阅者对其他属性的进行引用。

// 订阅者郭靖
let guojingSubscriber = () => {
    console.log(`郭靖收到的公众号文章作者:${weChatOfficialAccount.author}`)
} 
// 订阅者杨过
let yangguoSubscriber = () => {
    console.log(`杨过收到的公众号发布时间:${weChatOfficialAccount.date}`)
}

然后我们需要对公众号对象进行修改:

const weChatOfficialAccount = {
    // 事件总线对象
    __ob__: new Dep(),
    // 文章内容
    article: '文章内容',
+    author: '作者',
+    date: '日期'
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
+ defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
+ defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)

同时需要对 getter 中的添加订阅者部分进行修改,为了精准添加对应的订阅者,我们需要判断对应的属性:

function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 为了精准添加对应属性的订阅者,我们需要判断对应的属性
+            if (subscriber && key === 'article') dep.addSub(subscriber)
+            if (guojingSubscriber && key === 'author') dep.addSub(guojingSubscriber)
+            if (yangguoSubscriber && key === 'date') dep.addSub(yangguoSubscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们上述代码中为了精准添加对应属性的订阅者,我们需要在 getter 中判断对应的属性,在功能简单的情况下可以,如果功能复杂,对象的属性庞大的情况下,这样肯定是不能接受的。

又因为在触发 getter 的时候,只会是在某个订阅者函数在执行的时候,也就是说在 getter 被触发的时候,这个时候的订阅者是确定的,所以我们可以采用 中间变量 形式来解决这个问题。我们设置一个全局变量 activeEffect,也就是所谓中间变量,然后在初始化执行订阅者函数之前把需要执行的订阅者函数赋值给 activeEffect,然后在 getter 里面就可以把中间变量 activeEffect 通过消息代理对象添加到订阅者记录里面了,然后在执行完该订阅者函数之后则需要把中间变量 activeEffect 设置为 null,防止重复添加。

代码修改如下:

+ // 订阅者中间变量
+ let activeEffect
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 存在订阅者中间变量就进行订阅者添加
+            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}
+ // 初始化订阅者
+ activeEffect = subscriber
+ subscriber()
+ activeEffect = null
+ activeEffect = guojingSubscriber
+ guojingSubscriber()
+ activeEffect = null
+ activeEffect = yangguoSubscriber
+ yangguoSubscriber()
+ activeEffect = null

接着我们进行测试:

// 公众号发布文章
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

打印结果如下:

C01.png

我们就发现我们虽然只对 article 属性进行赋值,但也触发了其他属性的订阅者的执行。那么我们就需要对属性与订阅者之间进行准确关联。那么如何进行准确关联呢?我们通过第一篇文章可以知道,在通过 Object.defineProperty 对每一个属性进行劫持监听的时候,通过闭包的形式把属性值缓存下来的,所以每一个属性的消息代理也放在闭包函数 defineReactive 中。

// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
+    // 通过闭包把每一个属性的消息代理进行缓存
+    const dep = new Dep()
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

这时我们再进行测试的时候,就可以精准触发订阅者了。

我们上面是通过手动调用 defineReactive 函数进行对象的属性劫持的,我们可以通过获取所有对象的属性然后遍历调用 defineReactive 函数进行对象的属性劫持,同时把这个功能封装成一个工具函数 observe。我们在第一篇中也实现过的了,下面我们来重新实现一下:

function observe (data) {
    Object.keys(data).forEach(key => {
        defineReactive(data, key, data[key]) 
    })
}

这样我们就可以通过 observe 函数来定义一个响应式对象了。

const weChatOfficialAccount = {
    // 文章内容
    article: '文章内容',
    author: '作者',
    date: '日期'
}
- defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
- defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
- defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)
+ observe(weChatOfficialAccount)

至此我们便通过发布订阅模式初步实现了 Vue2 的响应式原理。创建一个对象,通过 observe 工具函数遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter,然后在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。

从发布订阅模式的角度来说就是每一个对象的 property 都是发布者,然后它的消息代理则通过闭包的形式跟每一个 property 的值一起缓存在 defineReactive 闭包函数创建独立空间中,它们是多对多的关系。

小结

在我们一般的发布订阅模式(也叫观察者模式)中,发布者或者被观察者是很明确的,是一个具体的对象,但正如我们前一篇文章所说的那样,发布订阅模式是没有标准范式的,设计模式也是,辨别一种模式不能通过代码结构,而是代码意图。而在我们上述实现的 Vue2 响应式原理的过程中,我们发现其实每一个对象属性(property)都是一个发布者或者叫被观察者,它的发布者功能则通过消息代理中介进行实现,而这个消息代理对象则通过闭包的形式跟每个属性值一起缓存在闭包当中。我们又可以发现所谓发布订阅模式的触发条件也不是唯一的,我们一般的描述定义是,当一个对象发现变化的时候才去触发所有依赖它的订阅者,其实不然,发布订阅模式的触发条件可以是状态的变化、某个操作的变化、甚至是发布订阅者的通知也可以触发另外一个发布者进行发布操作。如果有在 Vue 中使用过事件总线的同学会很清楚,我们在组件中触发通知(emit)订阅者操作的时候并不一定是组件属性发生了变化,而有可能是某个方法触发了通知(emit)订阅者操作。

对数组进行响应式的处理

可以通过 Object.defineProperty 对数组进行监听,但监听不了数组自身的原型链方法,而 pushpopshiftunshiftsplicesortreverse 对数组进行操作是会改变数组的数据结构的,从发布订阅模式的角度来说数据发生变化后我们需要通知该数组对象的所有订阅者。为了实现这需求我们需要劫持数组的操作方法,即在对数组进行 push 等操作的时候我们能监听到。实现方案就是对数组的原型进行重写,重写的方法就是覆盖数组数组对象上的原型对象 __proto__。我们在第一篇当中是通过粗暴的直接覆盖的方式,但那样会把原来的一些数组方法也覆盖掉了,那样是不可取。

我们可以通过获取数组原型上的对象,然后只修改需要修改的方法即可。我们对 observe 方法修改如下:

function observe (data) {
+    // 如果是数组则重写数组上的原型
+    if (Array.isArray(data)) {
+        // 获取数组原型
+        const arrayProto = Array.prototype
+        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+        const arrayMethods = Object.create(arrayProto)
+        // 修改 push 方法
+        arrayMethods['push'] = function (...args) {
+            // 获取原始方法
+            const original = arrayProto['push']
+            // 执行原始方法
+            const result = original.apply(this, args)
+            return result
+        }
+        // 覆盖原型对象
+        data.__proto__ = arrayMethods
+    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
+    }
}

上述代码我们通过 Object.create 创建一个原型为 arrayProto 的空对象:arrayMethods。然后给空对象设置 push 属性值为一个函数,最终把 arrayMethods 赋值给 data__proto__。这里就涉及到了一个 JavaScript 原型链的基础知识,当我们获取一个对象的属性值的时候,我们优先从该对象的自身属性上去获取,如果找不到则沿着该对象的 __proto__ 属性上的对象上的属性去查找,如果还找不到,则继续沿着 __proto__ 上的对象去查找。

我们经过上面的代码设置之后,我们通过 observe 设置一个数组,那么这个数组的原型对象则变为了arrayMethods,当执行该数组的 push 方法,根据原型链的规则,它会先执行 arrayMethods 对象上的 push 方法,这样我们就可以对该数组的 push 方法进行了监听,我们最终还是通过原本数组上 push 方法进行操作,但我们可以捕捉到了 push 的动作,这样我们就可以在 push 操作之后,进行通知所有该数组上的订阅者了。

我们从前面的发布订阅模式的知识可以知道,一个发布者对象上需要有一个消息代理对象,所以我们需要继续迭代我们的代码:

function observe (data) {
+    // 不存在消息代理则设置消息代理对象
+    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
+            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+            data.__ob__.notify()
             // 同时对新添加的数据也进行响应式化
+             for (let i = 0, l = args.length; i < l; i++) {
+              observe(args[i])
+             }
        }
        // 覆盖原型对象
        data.__proto__ = arrayMethods
    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
    }
}

那么我们这个发布者对象的订阅者在哪里进行添加呢,从数据响应式的角度就是这个响应式对象的依赖在哪里收集呢?

其实不管是对象还是数组的订阅者都是在 getter 中进行添加的。
例如:{ list: [1,2,3,4] }
你要获取到 list 数组的内容,首先是通过 list 这个 property 进行获取的,所以当通过 list 这个 property 进行获取数组内容的时候,就触发了 list 这个 property 的 getter。

所以我们需要对 defineReactive 函数进行修改:

function defineReactive(data, key) {
    let val = data[key]
    // 获取消息代理对象
    const dep = new Dep()
+    // 对获取到的属性值进行递归 observe 监听
+    const childOb = observe(val)
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
+                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
+                if (childOb) childOb.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

在 getter 中会进行 property 的订阅者添加,收集到的订阅者保存在对应 property 的消息代理对象中,同时也会判断,property 的值如果是一个对象,还会对这个对象进行订阅者添加,收集到的订阅者还会保存到这个对象的消息代理对象上。

所以我们还需要对 observe 函数进行修改:

+ // 判断是否是对象
+ function isObject(obj) {
+     return obj !== null && typeof obj === 'object'
+ }
function observe (data) {
+    // 不是对象则直接返回
+    if (!isObject(data)) return 
    
    // 省略 ...
    
+    // 返回消息代理对象
+    return data.__ob__
}

至此我们对数组的响应式也实现了,接下来就是进行测试:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}

observe(weChatOfficialAccount)

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
}

// 初始化订阅者
activeEffect = subscriber
subscriber()
activeEffect = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

我们可以看到正确打印了结果,也就是我们也实现了对数组的响应式。

通过重构实现 Observer 类

我们上述函数 observe 的实现其实职责是很不清晰的,也不利于后续的维护,所以我们需要对它进行重构。软件应该是“自描述”的,代码除了给机器看之外,也要给人看。我们希望写的代码更易读,让代码可以更好地表达自己的意图。

这里我们涉及到一些开发技巧,我们可以先实现具体的功能,然后再重构,在重构的时候通过封装成抽象的类或者其他函数,让代码可以更好地表达自己的意图。那么 observe 函数中可以将对数组响应式的处理,还有对对象属性循环劫持分别封装成不同的函数,然后通过函数名称可以让我们的代码意图更明显。

我们对 observe 函数进行重构,代码如下:

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return 
    // 不存在消息代理则设置消息代理对象
    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
+        protoAugment(data)
    } else {
+        walk(data)
    }
    // 返回消息代理对象
    return data.__ob__
}
+ // 对数组进行响应式处理
+ function protoAugment(target) {
+    // 获取数组原型
+    const arrayProto = Array.prototype
+    // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+    const arrayMethods = Object.create(arrayProto)
+    // 修改 push 方法
+    arrayMethods['push'] = function (...args) {
+        // 获取原始方法
+        const original = arrayProto['push']
+        // 执行原始方法
+        const result = original.apply(this, args)
+        // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+        target.__ob__.notify()
        // 同时对新添加的数据也进行响应式化
        observeArray(args)
+    }
+    // 覆盖原型对象
+    target.__proto__ = arrayMethods
+ }
+ // 对对象进行响应式处理
+ function walk(obj) {
+    const keys = Object.keys(obj)
+    keys.forEach(key => {
+        // 如果属性不是 __ob__ 则进行监听
+        if (key !== '__ob__')  defineReactive(obj, key) 
+    })
+ }
 // 对数组的每一项元素都进行响应式处理
 function observeArray (items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
 }

经过我们上面对不同的功能代码进行重构后,我们就可以通过函数名称很容易理解代码的意图了。但我们上面的功能函数还是十分的分散,而它们都是同一种功能类型的函数,都是实现对象响应式的功能函数,所以我们可以通过 OOP 的思想把响应式数据和操作封装到一个类里面,这个类我们把它命名为 Observer

Observer 类的代码实现如下:

class Observer {
    constructor(value) {
        this.value = value
        this.dep = new Dep()
        this.value.__ob__ = this
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
            this.protoAugment(value)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {
            // 对对象进行响应式处理
            this.walk(value)
        }
    }
    // 进行原型重写
    protoAugment(target) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            const ob = this.__ob__
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
            ob.dep.notify()
            // 同时对新添加的数据也进行响应式化
            ob.observeArray(args)
        }
        // 覆盖原型对象
        target.__proto__ = arrayMethods
    }

    // 对对象进行响应式处理
    walk(obj) {
        const keys = Object.keys(obj)
        keys.forEach(key => {
            // 如果属性不是 __ob__ 则进行监听
            if (key !== '__ob__')  defineReactive(obj, key) 
        })
    }
    // 对数组的每一项元素都进行响应式处理
    observeArray (items) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
}

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return
    const ob = new Observer(data)
    // 返回 Observer 实例对象
    return ob
}

defineReactive 函数也需要进行以下修改:

function defineReactive(data, key) {
// ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
// ...
        }
    })
}

我们修改后进行重新测试也正常打印了结果:

C03.png

我们还可以进行一下性能优化,我们上述代码 protoAugment 函数部分,我们创建了数组原型对象的变量,而这些变量其实是不会变化,我们可以把它们的声明移到 protoAugment 函数外面,这样每一次调用 protoAugment 函数就不会重复重新创建这些变量了。

+ // 获取数组原型
+ const arrayProto = Array.prototype
+ // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+ const arrayMethods = Object.create(arrayProto)
+ // 修改 push 方法
+ arrayMethods['push'] = function (...args) {
+    const ob = this.__ob__
+    // 获取原始方法
+    const original = arrayProto['push']
+    // 执行原始方法
+    const result = original.apply(this, args)
+    // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+    ob.dep.notify()
+    // 同时对新添加的数据也进行响应式化
+    ob.observeArray(args)
+ }

class Observer {
    constructor(value) {
        // 省略 ...
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
+            this.protoAugment(value, arrayMethods)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {

        }
    }

+    protoAugment(target, src) {
+        // 覆盖原型对象
+        target.__proto__ = src
+    }
    // 省略 ...
}

至此我们通过重构就实现了 Observer 类,这一节没有涉及到发布订阅模式和数据响应式相关的内容,只是一下编程技巧的内容,而之所以有这一节是为了我们的代码结构更贴近 Vue2 源码的实现。通过这一节的实现,我们也可以知道发布订阅模式是如何在 Vue2 数据响应式中实现的。

那么从发布订阅模式的角度来看所谓 Observer 类,其实是一个发布者或者叫被观察者,虽然它的类命叫 Observer 翻译过叫观察者,但从观察者模式的角度来看,它不能叫观察者,因为它并没有向哪个被观察者进行订阅操作。但它又不是一个纯粹的发布者,它主要作用是将数据对象转换为响应式对象,使得当数据发生变化时能够触发相应的更新操作,它同时通过递归遍历数据对象中的所有属性,为每个属性设置 gettersetter 来实现数据的劫持和监听,从功能上来看它是在观察自己的属性。

从代码结构上来看,它的发布订阅模式的实现跟传统标准的发布订阅模式的结构还是存在很大差别的,但正如我们上篇文章中所说的那样,我们并不能从代码结构上去判断是否属于什么模式,而是从代码意图去判断。

订阅者中介实现

我们知道 Vue2 中的订阅者是通过 Watcher 类来实现的,也就是我们上一篇文章中所讲的订阅者中介

我们先实现一个订阅者中介:

class Watcher {
    constructor(fn) {
        // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
        this.getter = fn
        // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
        this.get()
    }
    get() {
        // 通过 Dep.target 来设置当前的订阅者是谁
        Dep.target = this
        this.getter()
        Dep.target = null
    }
    // 接受发布者通知的更新方法
    update() {
        this.getter()
    }
}

我们这里设计的订阅者中介类的实现跟我们上一篇中的订阅者中介类的实现,最大的不同就是,这里的设计需要在初始化的时候就要去执行一次订阅者所传的参数函数,因为在 web 应用应用中,应用需要初始化。

我们在实例化订阅者的时候,就把该订阅者需要做的事情当成参数传进去:

new Subscriber(() => {
    console.log(`郭靖收到的公众号文章:${weChatOfficialAccount.article}`)
}) 

同时 Dep 类的 notify 方法也需要修改一下:

class Dep {
  // 通知订阅者
  notify() {
-    this.subs.forEach(sub => sub())
+    this.subs.forEach(sub => sub.update())
  }
}

defineReactive 函数也需要修改:

- // 订阅者中间变量
- let activeEffect
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 省略 ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
-            if (activeEffect){
+            if (Dep.target){
-                dep.addSub(activeEffect)
+                dep.addSub(Dep.target)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.dep.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(Dep.target) 
            }
            return val
        },
        set(newVal) {
            // 省略 ...
        }
    })
}

这样我们就可以很方便通过一下进行测试了:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}
observe(weChatOfficialAccount)
// 初始化订阅者
new Watcher(() => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
})

// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

小结

Dep 和 Watcher 互为订阅者

我们通过上文知道 Dep 类其实是一个消息代理或者叫事件总线,而 Watcher 则是一个订阅者,但我们前面也留了一个引子,说它们还有一层关系,就是互为订阅者。那么既然 DepWatcher 互为订阅者,也就是说它们其实也是一个发布者的角色。所以现实系统中的应用远远要比我们所学的所谓标准模式要复杂得多。

我们知道在 Vue2 中可以通过 Options 选项设置 watcher 来实现对响应式数据的监听,其实还可以通过 this.$watcher() 来实现对响应式数据的监听,使用方法都是一样的,唯一的不同就是 this.$watcher() 会返回一个函数,这个函数的作用就是停止对响应式数据的监听。

那么要实现停止对响应式数据的监听则需要知道那些 Dep 记录了当前的 Watcher,我们就需要通知那些 Dep 取消订阅当前的 Watcher。那么要实现这个功能,就需要 Watcher 也进行记录自己订阅了哪些 Dep,当取消对响应式数据的监听的时候,就从当前 Watcher 的订阅记录里去通知那些 Dep 取消自己的订阅。

比如说我们在 Vue2 当中有这么一个功能:

const unwatch = this.$watch(function(){
      return this.name + this.age + this.sex
    }, function(newValue, oldValue){
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

我们接下来去实现这个功能,也就是实现 Vue2 的 $watcher() 功能。以下是 Vue2 官网对 $watcher API 的一些参数和功能的介绍。

  • [vm.$watch( expOrFn, callback, options )]

  • 参数

    • {string | Function} expOrFn

    • {Function | Object} callback

    • {Object} [options]
      
      • {boolean} deep
      • {boolean} immediate
  • 返回值{Function} unwatch

  • 用法

    观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。

这个 $watcher 的功能从发布订阅模式的角度可以看成是,第一个参数是订阅者要做的事情,第二个参数是在做完事情后拿到结果再通过第二参数输出结果,而且是每次所依赖的响应式数据发生变化后都需要执行第二个参数函数,输出新的结果。

那么我们先实现下面的功能:

new Watcher(function() {
   return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
   console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
})

我们对 Watcher 类做以下修改:

class Watcher {
-    constructor(fn) {
+    constructor(fn, cb) {
+        this.cb = cb
       // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
       this.getter = fn
       // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
-        this.get()
+        this.value = this.get()
   }
   get() {
+        let value
       // 通过 Dep.target 来设置当前的订阅者是谁
       Dep.target = this
+        value = this.getter()
       Dep.target = null
+        return value
   }
   // 接受发布者通知的更新方法
   update() {
     // 获取新值
-      this.getter()
+      const value = this.getter()
+      // 设置旧值
+      const oldValue = this.value
+      // 更新值
+      this.value = value
+      if (this.cb) {
+        // 因为是用户写的,有可能存在错误
+        try {
+          this.cb(value, oldValue)
+        } catch(err) {
+          throw err
+        }
+      }
   }
}

我们进行测试:

weChatOfficialAccount.article = '第一次更新'
weChatOfficialAccount.article = '第二次更新'

测试结果如下:

C04.png

我们看到正确打印了结果。

有了以上的基础功能,接下来我们就很容易实现 $watcher API,代码如下:

function $watcher(expOrFn, cb) {
    const watcher = new Watcher(expOrFn, cb)
}

我们知道 $watcher API 是有很多配置选项的,也就是第三个参数,比如立即执行回调就是通过第三个参数配置 immediatetrue 来实现的,下面我们也来实现它:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

在 Vue2 中 Watcher 实例是分为系统的 Watcher 和用户的 Watcher 的,像我们在组件里面通过配置 watcher,就是用户的 Watcher,怎么体现区分呢?我们下面来设置,其实也很简单:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
+    // 设置用户级的 Watcher
+    options.user = true
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

接着修改 Watcher 类:

class Watcher {
-    constructor(fn, cb){
+    constructor(fn, cb, options) {
+      if (options) {
+          this.user = !!options.user
+      } else {
+          this.user = false
+      }
      // 省略...
    }
    update() {
      // 省略...
-      if (this.cb) {
+      if (this.user) {
        // 省略...
      }
    }
}

修改也很简单,从上面的修改可以看得出,只有用户级的 Watcher 才会在更新的时候执行回调函数。

接下来我们测试立即回调功能:

$watcher(function() {
    return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
    console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
}, { immediate: true })

我们可以看到初始化的时候,就立即执行回调函数了。

C05.png

立即执行,旧值为 undefined,符合如期。

实现了上面的基础部分的功能,我们就可以实现重要的功能了,取消订阅。

function $watcher(expOrFn, cb, options) {
     // 省略...
+    // 返回一个可以取消订阅的函数
+    return function unwatchFn () {
+      watcher.teardown()
+    }
}

我们这里通过 Watcher 实例对象的 treardown 方法去取消订阅,其实是要去通知那些记录了该 Watcher 的 Dep 去删除其记录中的该 Watcher。那么我们怎么知道哪些 Dep 记录该 Watcher 呢?所以我们就需要在 Watcher 中记录其订阅了的 Dep。从发布订阅模式的角度来说就是 Dep 要对 Watcher 进行订阅,Dep 是订阅者,Watcher 是发布者,而我们之前是 Watcher 对相关的属性的 Dep 进行订阅,Watcher 是订阅者,相关属性的 Dep 是发布者。

我们首先对 Watcher 实现发布订阅的功能,代码迭代如下:

class Watcher {
    constructor(fn, cb, options) {
        // 省略...
+        this.deps = []
        // 省略...
    }
+    addDep(dep) {
+        this.deps.push(dep)
+    }
+    // 取消订阅
+    teardown() {
+        let i = this.deps.length
+        while (i--) {
+            this.deps[i].removeSub(this)
+        }
+    }
}

我们这里的取消订阅是通过 Watcher 所记录的 Dep 实例对象去执行 Dep 上的 removeSub 方法去把自己删除,这样将来 Dep 触发更新的时候,就通知不了自己了,也就执行不了 update 方法了。

接下来我们实现 Dep 类上的 removeSub 方法,迭代代码如下:

class Dep {
  // 省略..

+  // 取消订阅
+  removeSub (sub) {
+    // 找到需要取消的订阅者
+    const index = this.subs.indexOf(sub)
+    if (index > -1) {
+        // 删除订阅者
+        this.subs.splice(index, 1)
+    }
+  }
  // 省略...
}

我们可以看到 Dep 类中的取消订阅功能,跟普通发布订阅中的取消订阅功能是一样的。

我们前面已经实现了 DepWatcher 的订阅,那么接下来就是 Watcher 怎么对 Dep 进行订阅了。我们知道不管是什么数据类型都是在 getter 中进行依赖收集的,所以要实现 WatcherDep 的订阅,也要从 getter 开始。我们在 getter 里面可以通过 Dep.target 获取到当前的 Watcher,也可以获取到当前属性对应的 Dep 实例对象,那么就可以互相添加订阅者了。

代码迭代如下:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
                dep.addSub(Dep.target)
+                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                Dep.target.addDep(dep)
                if (childOb){
                    childOb.dep.addSub(Dep.target)
+                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                    Dep.target.addDep(childOb.dep)
                }
            }
            return val
        },
        // 省略...
    })
}

接下来我们就可以进行测试了:

const unwatch = $watcher(function(){
    return weChatOfficialAccount.article + weChatOfficialAccount.author
}, function(newValue, oldValue) {
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

console.log('会打印新值旧值')
weChatOfficialAccount.article = 'co'
console.log('会打印新值旧值')
weChatOfficialAccount.author = 'byte'
console.log('不会打印新值旧值')
weChatOfficialAccount.article = 'cobyte'

我们发现如期打印了我们期待的结果:

C06.png

我们上面在 getter 中对 Dep 和 Watcher 进行相互订阅的操作,还可以进行优化一下,让代码更优雅。

class Dep {
    // 省略...
+  // 通过 depend 方法进行依赖收集
+  depend() {
+    if (Dep.target) {
+      // 在 Dep 中进行 Watcher 
+      Dep.target.addDep(this)
+    }
+  }
  // 省略...
}

接着在 Watcher 中调用 Dep 的方法添加自己

class Watcher {
    // 省略...
    addDep(dep) {
        this.deps.push(dep)
+        // 调用 Dep 实例的添加订阅方法添加自己
+        dep.addSub(this)
    }
    // 省略...
}

接着我们修改 getter 中代码:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
-                dep.addSub(Dep.target)
-                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                Dep.target.addDep(dep)
+                dep.depend()
                if (childOb){
-                    childOb.dep.addSub(Dep.target)
-                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                    Dep.target.addDep(childOb.dep)
+                    childOb.dep.depend()
                }
            }
            return val
        },
        // 省略...
    })
}

经过我们的重构,getter 中的依赖收集相关代码变得清晰多了。

总结

从发布订阅模式的角度来理解 Vue 的数据响应式原理,就是发布订阅模式的具体运用的过程。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

前端架构演进:基于AST的常量模块自动化迁移实践

前端架构演进:基于AST的常量模块自动化迁移实践

从“硬编码”到“全自动”:一次常量模块重构的工程化探索

在这里插入图片描述

一、背景与痛点

在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。

在我们的项目中,常量定义最初采用了一种集中式导出方式:

// src/constants/Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
  // ... 数十个常量
}

而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:

// 旧代码片段
import { Constants_expert } from '@/locales';

if (status === Constants_expert.default.STATUS_APPROVED) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/locales 的导出,极易遗漏。

为了彻底解决这些问题,我们决定进行两项重构:

  • 常量文件:将 export default { ... } 拆解为多个 export const,实现具名导出。
  • 业务代码:将所有 Constants_xxx.default.PROP 替换为直接使用 PROP,并添加对应的具名导入。

项目涉及 30+ 个常量文件200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。

本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。


二、整体方案设计

整个迁移流程分为两个独立的阶段,必须严格按顺序执行

graph LR
    A[常量文件] -->|transform-const.js| B[具名导出常量]
    C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
    B -.->|提供导出变量列表| D
  • 第一阶段:扫描 src/constants/*.ts,将每个文件中的 export default 对象转换为多个 export const 语句。
  • 第二阶段:扫描 src/views 下的所有 .vue.ts.js 文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。

两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。


三、第一阶段:常量文件格式转换(transform-const.js)

3.1 核心目标

将这样的代码:

// Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
}

转换为:

export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;

同时保留所有注释(文件头注释、属性上方注释等)。

3.2 AST 操作流程

我们使用 Babel 全家桶完成这次转换:

  • @babel/parser:将源码解析为 AST
  • @babel/traverse:遍历和修改 AST 节点
  • @babel/types:构建新的 AST 节点
  • @babel/generator:将 AST 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

traverse(ast, {
  ExportDefaultDeclaration(path) {
    if (t.isObjectExpression(path.node.declaration)) {
      defaultExportObject = path.node.declaration;
      path.remove(); // 移除整个 export default
    }
  },
});

defaultExportObject.properties.forEach((prop) => {
  const propName = prop.key.name;
  const propValue = prop.value;
  const exportDecl = t.exportNamedDeclaration(
    t.variableDeclaration('const', [
      t.variableDeclarator(t.identifier(propName), propValue),
    ])
  );
  // 保留注释
  if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
  exportConstNodes.push(exportDecl);
});

3.3 易错点与防御

  • 非对象默认导出:某些常量文件可能已经是 export const 格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。
  • 属性名非标识符:如果对象的键是字符串字面量(如 "my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。
  • 文件备份:转换前自动创建 .bak 文件,防止误操作导致代码丢失。

四、第二阶段:业务代码引用迁移(transform-project.js)

这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScriptVue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。

4.1 动态发现常量文件

第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。

function loadAllConstantFiles() {
  const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
  const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }

  for (const filePath of constantFiles) {
    const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
    const exportedNames = new Set();
    traverse(ast, {
      ExportNamedDeclaration(path) {
        if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
          path.node.declaration.declarations.forEach(d => {
            if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
          });
        }
      },
    });
    constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
  }
  return constantMap;
}

这样我们就获得了所有常量文件的“导出变量白名单”。

4.2 识别旧的导入模式

在业务代码中,旧的导入语句通常长这样:

import { Constants_expert, Constants_supplier_portrait } from '@/locales';

我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。

使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:

traverse(ast, {
  ImportDeclaration(path) {
    if (path.node.source.value === OLD_IMPORT_SOURCE) {
      path.node.specifiers.forEach(spec => {
        if (t.isImportSpecifier(spec)) {
          const importedName = spec.imported.name;
          const localName = spec.local.name;
          if (constantMap.has(importedName)) {
            oldLocalToConstantMap.set(localName, importedName);
            shouldRemove = true;
          }
        }
      });
      if (shouldRemove) path.remove(); // 删除整条导入语句
    }
  },
});

4.3 替换成员访问表达式

旧的引用方式有两种常见形态:

  • Constants_expert.default.STATUS_PENDING
  • Constants_expert.STATUS_PENDING(某些早期代码省略了 .default

我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。

通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:

traverse(ast, {
  MemberExpression(path) {
    const root = findRootIdentifier(path.node);
    if (!root) return;
    const localName = root.name;
    if (!oldLocalToConstantMap.has(localName)) return;

    const constantSetName = oldLocalToConstantMap.get(localName);
    const chain = getPropertyChain(path.node);
    let propName = null;
    if (chain.length >= 3 && chain[1] === 'default') {
      propName = chain[2];
    } else if (chain.length >= 2) {
      propName = chain[1];
    }

    if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
      // 记录需要导入的变量
      neededImports.get(constantSetName).add(propName);
      // 替换整个节点为一个简单的标识符
      path.replaceWith(t.identifier(propName));
    }
  },
});

4.4 Vue SFC 的特殊处理

Vue 单文件组件包含 <template><script><script setup> 等多个块,需要分别处理。

Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script><script setup>,需要分别处理并合并导入变量。

Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。

但正则替换有几个坑:

  • 常量名可能包含正则元字符(如 +.),需要转义。
  • 需要同时匹配 .default 和没有 .default 的情况。
  • 替换后要记录使用了哪些变量,以便生成导入。

我们构建动态正则:

const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');

匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。

4.5 生成新的导入语句

经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expertConstants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。

因此,我们必须先检测冲突:

const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
  for (const v of vars) {
    if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
      throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
    }
    varToConstMap.set(v, constName);
  }
}

如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:

const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');

最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。


五、技术难点与解决方案

5.1 路径别名动态转换

最初我们使用 path.relative 然后替换 ../@/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。

5.2 多个 <script> 块的替换位置

Vue SFC 可能同时存在 <script><script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offsetloc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。

5.3 模板正则的精确匹配

模板中可能包含字符串字面量,例如:

<div :title="'Constants_expert.default.STATUS'"></div>

我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。

5.4 保留代码格式与注释

AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate{ retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。


六、工程化落地与自动化流程

为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:

# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants

# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js

# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js

# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test

# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"

两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:

# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done

七、成果与思考

通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:

  • Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
  • 可读性提升:代码中直接使用 STATUS_PENDING 而非冗长的 Constants_expert.default.STATUS_PENDING
  • 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。

更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。

未来拓展方向

  • 支持更复杂的引用模式:如 Constants_expert['default'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章

Vue 中实现文字滚动(跑马灯)的多种方式

在 Vue 里实现文字滚动(跑马灯) ,最常用、最稳的就两种:

  1. CSS 动画纯实现(简单、性能好)
  2. JS 控制滚动(可暂停、可控制速度)

下面直接给你可复制粘贴的 Vue 组件代码


方式1:纯 CSS 跑马灯(推荐)

Marquee.vue

<template>
  <div class="marquee-wrap">
    <div class="marquee-content">
      {{ text }}
    </div>
  </div>
</template>

<script setup>
const text = '这里是需要滚动的文字,Vue 跑马灯效果,从右向左无限滚动~';
</script>

<style scoped>
.marquee-wrap {
  width: 100%;
  overflow: hidden;
  white-space: nowrap;
  background: #f5f5f5;
  padding: 8px 16px;
  border-radius: 8px;
}

.marquee-content {
  display: inline-block;
  animation: marquee 15s linear infinite;
}

@keyframes marquee {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(-100%);
  }
}
</style>

特点:

  • 一行无限滚动
  • 无 JS,性能最好
  • 鼠标悬浮暂停版往下看

方式2:hover 暂停 + 无缝滚动(更常用)

<template>
  <div class="box">
    <div class="marquee" @mouseenter="pause" @mouseleave="play">
      <div class="text" :style="{ animationPlayState }">
        {{ content }}
      </div>
    </div>
  </div>
</template>

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

const content = 'Vue3 无缝跑马灯,鼠标移入暂停,移出继续滚动~';
const animationPlayState = ref('running');

const pause = () => {
  animationPlayState.value = 'paused';
};
const play = () => {
  animationPlayState.value = 'running';
};
</script>

<style scoped>
.box {
  width: 100%;
  overflow: hidden;
  background: #f9f9f9;
  padding: 10px;
  border-radius: 6px;
}
.marquee {
  white-space: nowrap;
}
.text {
  display: inline-block;
  animation: move 12s linear infinite;
}
@keyframes move {
  0% { transform: translateX(100%); }
  100% { transform: translateX(-100%); }
}
</style>

方式3:真正无缝(无空白,首尾衔接)

适合公告、长文本:

<template>
  <div class="wrap">
    <div class="box">
      <span class="txt1">{{ text }}</span>
      <span class="txt2">{{ text }}</span>
    </div>
  </div>
</template>

<script setup>
const text = '这里是真正无缝跑马灯,没有空白间隔,一直循环滚动';
</script>

<style scoped>
.wrap {
  width: 100%;
  overflow: hidden;
  background: #fff8e1;
  padding: 8px 0;
}
.box {
  display: flex;
  width: max-content;
  animation: scroll 10s linear infinite;
}
.txt1, .txt2 {
  padding: 0 20px;
}
@keyframes scroll {
  0% { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}
</style>

方式4:JS 控制滚动(可变速、可停止)

<template>
  <div class="box" style="overflow: hidden">
    <div class="text" :style="{ marginLeft: `${left}px` }">
      JS 控制跑马灯,可随时停止、加速、减速
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const left = ref(300);
let timer = null;

onMounted(() => {
  timer = setInterval(() => {
    left.value -= 1;
    if (left.value < -300) left.value = 300;
  }, 20);
});
onUnmounted(() => clearInterval(timer));
</script>

Vue 项目必备:10 个高频实用自定义指令,直接复制即用(Vue2 / Vue3 通用)

在实际开发中,很多重复逻辑(权限控制、防抖点击、图片懒加载、文本复制等)用自定义指令来做最优雅,不污染组件、不写冗余代码、复用性极强。

今天整理了 10 个企业级最常用的 Vue 自定义指令,Vue2 / Vue3 都能跑,复制到项目里直接用,建议收藏进你的工具库。


1. v-permission 按钮权限控制(后台系统必用)

根据权限码控制按钮显隐,后端返回权限列表直接用。

// directives/permission.js
import { useUserStore } from '@/stores/user'

export default {
  mounted(el, binding) {
    const { permissions } = useUserStore()
    const value = binding.value
    if (!value) return
    // 无权限则移除元素
    if (!permissions.includes(value)) {
      el.parentNode?.removeChild(el)
    }
  }
}

使用:

<button v-permission="'user:add'">添加用户</button>

2. v-debounce 防抖点击(搜索/提交防重复)

// directives/debounce.js
export default {
  mounted(el, binding) {
    const { func, delay = 300 } = binding.value
    let timer = null
    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => func(), delay)
    })
  }
}

使用:

<button v-debounce="{ func: handleSearch, delay: 500 }">搜索</button>

3. v-throttle 节流指令(滚动/防狂点)

// directives/throttle.js
export default {
  mounted(el, binding) {
    const { func, delay = 500 } = binding.value
    let lastTime = 0
    el.addEventListener('click', () => {
      const now = Date.now()
      if (now - lastTime >= delay) {
        func()
        lastTime = now
      }
    })
  }
}

4. v-copy 一键复制文本

// directives/copy.js
export default {
  mounted(el, binding) {
    el.addEventListener('click', () => {
      const text = binding.value
      navigator.clipboard.writeText(text).then(() => {
        ElMessage.success('复制成功')
      })
    })
  }
}

使用:

<span v-copy="orderNo">复制订单号</span>

5. v-longpress 长按指令

// directives/longpress.js
export default {
  mounted(el, binding) {
    const { func, time = 1000 } = binding.value
    let timer = null
    el.addEventListener('mousedown', () => {
      timer = setTimeout(() => func(), time)
    })
    el.addEventListener('mouseup mouseleave', () => clearTimeout(timer))
  }
}

6. v-input-number 仅允许输入数字(支持小数)

// directives/number.js
export default {
  mounted(el) {
    const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
    input.addEventListener('input', () => {
      input.value = input.value.replace(/[^\d.]/g, '')
      const arr = input.value.split('.')
      if (arr.length > 2) input.value = arr[0] + '.' + arr[1]
    })
  }
}

使用:

<el-input v-input-number v-model="num" />

7. v-lazy 图片懒加载(性能优化)

// directives/lazy.js
export default {
  mounted(el, binding) {
    const observer = new IntersectionObserver(([{ isIntersecting }]) => {
      if (isIntersecting) {
        el.src = binding.value
        observer.unobserve(el)
      }
    })
    observer.observe(el)
  }
}

使用:

<img v-lazy="imgUrl" alt="" />

8. v-draggable 元素拖拽

// directives/drag.js
export default {
  mounted(el) {
    el.style.cssText += ';position:fixed;cursor:move;'
    el.addEventListener('mousedown', (e) => {
      const x = e.clientX - el.offsetLeft
      const y = e.clientY - el.offsetTop
      const move = (e) => {
        el.style.left = e.clientX - x + 'px'
        el.style.top = e.clientY - y + 'px'
      }
      document.addEventListener('mousemove', move)
      document.addEventListener('mouseup', () => {
        document.removeEventListener('mousemove', move)
      }, { once: true })
    })
  }
}

9. v-watermark 页面水印(防截图)

// directives/watermark.js
export default {
  mounted(el, binding) {
    const text = binding.value || '内部资料'
    const canvas = document.createElement('canvas')
    canvas.width = 200
    canvas.height = 150
    const ctx = canvas.getContext('2d')
    ctx.font = '14px Arial'
    ctx.fillStyle = 'rgba(0,0,0,0.1)'
    ctx.rotate(-0.2)
    ctx.fillText(text, 20, 50)
    el.style.background = `url(${canvas.toDataURL()}) repeat`
  }
}

10. v-auto-height 自适应高度(表格/弹窗常用)

自动计算高度,避免滚动条错乱

// directives/autoHeight.js
export default {
  mounted(el) {
    const resize = () => {
      const top = el.getBoundingClientRect().top
      el.style.height = window.innerHeight - top - 20 + 'px'
    }
    resize()
    window.addEventListener('resize', resize)
    el._resize = resize
  },
  unmounted(el) {
    window.removeEventListener('resize', el._resize)
  }
}

统一注册(Vue3)

directives/index.js 统一导出:

import permission from './permission'
import debounce from './debounce'
// ...其他

export default {
  install(app) {
    app.directive('permission', permission)
    app.directive('debounce', debounce)
  }
}

main.js 引入:

import directives from '@/directives'
app.use(directives)

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

同样做缩略图,为什么别人又快又稳?踩过无数坑后,我总结出前端缩略图实战指南

最近有个项目需要用到上传图片,然后在列表页回显一下图片。

需求这边还想着要不要做一个瀑布图,但是做好以后图片量太多而且图片太大,导致展示效果并不好。

image.png

尤其是放在首屏上,长时间的白屏。

核心问题是:用户上传的图片过大,不仅导致页面加载缓慢、消耗过多带宽,而且还影响了服务器存储。

所以缩略图就成了最优解。

既不影响视觉展示,又能大幅降低资源消耗。

先明确结论:业界主流做法是什么?

很多人会陷入“非此即彼”的误区,纠结到底该前端还是后端生成缩略图。

但实际上,生产环境中最主流、最稳妥的架构是:前端做预览缩略图 + 后端/云存储做正式缩略图

两者分工配合,兼顾用户体验、性能和安全性。这也是目前大厂主流的实现方案。

简单来说:

  • 前端负责"":用户上传图片后,立即生成缩略图用于页面预览,提升交互体验;

  • 后端/云存储负责""和"生成":存储用户上传的原图,同时生成多尺寸正式缩略图,供页面正式展示。

前端缩略图实现(4种方案附代码)

前端生成缩略图的核心目的是"预览"和"减少上传流量",核心技术依赖 Canvas 绘图缩放createImageBitmap API

方案1:Canvas

这是前端生成缩略图的"标准方案",兼容所有浏览器(包括IE10+),零依赖,无需引入任何第三方库,是生产环境中最常用的方案。

核心原理

读取用户上传的图片文件 → 用Image对象加载图片 → 绘制到Canvas并按比例缩小 → 导出为缩略图Blob/Base64。

完整代码

/**
 * 生成图片缩略图
 * @param {File} file - 用户上传的图片文件(input[type="file"]获取)
 * @param {Number} maxWidth - 缩略图最大宽度(默认300px)
 * @param {Number} maxHeight - 缩略图最大高度(默认300px)
 * @param {Number} quality - 图片质量(0~1,1为最高质量,默认0.8)
 * @returns {Promise<Blob>} 缩略图文件(可直接上传或预览)
 */
async function createThumbnail(file, maxWidth = 300, maxHeight = 300, quality = 0.8) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.onload = () => {
      URL.revokeObjectURL(img.src);
      let { width, height } = img;
      // 如果原图尺寸超过设定的最大尺寸,进行等比缩小
      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height);
        width *= ratio;
        height *= ratio;
      }
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0, width, height);
      canvas.toBlob(
        (blob) => resolve(blob), // 成功回调,返回缩略图Blob
        file.type || 'image/jpeg', // 保持原图格式,无格式则默认jpeg
        quality // 图片质量
      );
    };
    img.onerror = () => reject(new Error('图片加载失败,请检查文件格式'));
  });
}

// 使用
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return; // 未选择文件,直接返回

  try {
    // 生成300x300的缩略图(可根据需求调整尺寸和质量)
    const thumbBlob = await createThumbnail(file, 300, 300, 0.7);
    
    // 场景1:预览缩略图(页面展示)
    const thumbUrl = URL.createObjectURL(thumbBlob);
    document.querySelector('#preview').src = thumbUrl;

    // 场景2:将缩略图上传到服务器(搭配FormData)
    const formData = new FormData();
    // 第三个参数是缩略图文件名,可自定义
    formData.append('thumbnail', thumbBlob, `thumbnail_${Date.now()}.jpg`);
    // 发起上传请求(实际项目中替换为自己的接口地址)
    const response = await fetch('/api/upload/thumbnail', {
      method: 'POST',
      body: formData
    });
    const result = await response.json();
    console.log('缩略图上传成功:', result);
  } catch (error) {
    console.error('缩略图生成/上传失败:', error);
  }
});

方案2:createImageBitmap

如果项目不考虑兼容性问题,那么这个方案比Canvas原生方案更高效。

它支持直接解析File/Blob对象,无需创建Image对象,加载速度更快。

而且还能在Web Worker中使用(避免阻塞主线程),适合处理大尺寸图片。

完整代码

/**
 * 高性能缩略图生成(createImageBitmap方案)
 * @param {File} file - 用户上传的图片文件
 * @param {Number} maxWidth - 缩略图最大宽度(默认300px)
 * @param {Number} maxHeight - 缩略图最大高度(默认300px)
 * @returns {Promise<Blob>} 缩略图Blob文件
 */
async function createThumbnailFast(file, maxWidth = 300, maxHeight = 300) {
  try {
    // 直接解析File对象,生成ImageBitmap(比Image对象更快)
    const bitmap = await createImageBitmap(file);
    
    // 计算等比缩放尺寸(和Canvas方案逻辑一致)
    let { width, height } = bitmap;
    if (width > maxWidth || height > maxHeight) {
      const ratio = Math.min(maxWidth / width, maxHeight / height);
      width *= ratio;
      height *= ratio;
    }

    // 创建Canvas并绘制
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(bitmap, 0, 0, width, height);

    // 释放ImageBitmap内存(优化性能)
    bitmap.close();

    // 导出为Blob
    return new Promise((resolve) => {
      canvas.toBlob(resolve, file.type || 'image/jpeg', 0.8);
    });
  } catch (error) {
    console.error('高性能缩略图生成失败:', error);
    throw error;
  }
}

// 使用方式(和Canvas方案一致,直接替换函数名即可)
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const thumbBlob = await createThumbnailFast(file, 300, 300);
  // 预览/上传逻辑和上面一致,此处省略
});

方案3:browser-image-compression插件

如果懒得手写,可以使用browser-image-compression插件。Github地址: github.com/vitaly-z/br…

这是一个轻量级前端图片压缩库,自动处理图片缩放、压缩、格式转换,零配置即可使用,还能解决图片旋转(Exif orientation)等常见问题。

完整代码

// 安装依赖
// npm install browser-image-compression --save

// 导入
import imageCompression from 'browser-image-compression';

/**
 * 基于第三方库的缩略图生成
 * @param {File} file - 用户上传的图片文件
 * @returns {Promise<Blob>} 缩略图文件
 */
async function createThumbnailWithLib(file) {
  // 配置选项(灵活调整,无需手写逻辑)
  const options = {
    maxSizeMB: 0.1, // 缩略图最大体积(100KB,超过会自动压缩)
    maxWidthOrHeight: 300, // 缩略图最大尺寸(宽/高不超过300px)
    useWebWorker: true, // 使用Web Worker,避免阻塞主线程
    useWebp: true, // 导出为WebP格式(比JPG小30%+,质量无损失)
    initialQuality: 0.8 // 初始压缩质量
  };

  try {
    // 直接调用库方法,自动生成缩略图
    const thumbBlob = await imageCompression(file, options);
    console.log('库生成缩略图成功,大小:', thumbBlob.size);
    return thumbBlob;
  } catch (error) {
    console.error('库生成缩略图失败:', error);
    throw error;
  }
}

// 使用方式(和前面一致)
document.querySelector('input[type="file"]').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const thumbBlob = await createThumbnailWithLib(file);
  // 预览/上传逻辑省略
});

当然,还有个偷懒的方法,直接给图片一个最大宽高,让他看起来像缩略图,不过仍然无法解决加载速度的问题。

后端/云存储方案

前面说过,前端生成的缩略图主要用于"预览",而"正式缩略图"(用于页面正式展示、多端适配),必须由后端或云存储生成——这是生产环境的标准做法,也是大厂通用架构。

为什么不能全靠前端生成正式缩略图?

很多兄弟会疑惑,既然前端能生成缩略图,为什么还要麻烦后端?

核心原因主要有4点:

  1. 可靠性不足:不同浏览器、不同设备(手机/PC)的Canvas渲染效果存在差异,可能导致缩略图模糊、变形,甚至生成失败。

  2. 安全性风险:前端传什么后端存什么,无法验证缩略图的真实性和合法性,可能存在恶意文件上传风险,甚至出现脚本。

  3. 多尺寸需求:一个项目通常需要多种尺寸的缩略图(如列表图300x300、头像图100x100、详情图1080x720),前端不可能生成所有尺寸,且维护成本极高。

  4. 性能与成本:云存储(如阿里云OSS、腾讯云COS、七牛云)的图片处理功能几乎免费,且速度极快,比自己写后端压缩代码更省资源、更稳定。

主流实现方式:云存储自动生成

目前大厂最常用的方式,是将用户上传的原图存储到云存储(如阿里云OSS)。

云存储会自动生成多种尺寸的缩略图,前端只需通过URL参数即可获取对应尺寸的缩略图,无需后端额外开发。

以阿里云OSS为例(实战示例)

  1. 用户上传原图到OSS,获得原图URL:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg

  2. 前端直接通过URL参数,获取不同尺寸的缩略图(无需后端干预):

  • 300x300缩略图:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_300,h_300

  • 100x100头像图:https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_100,h_100,m_fixed

  • WebP格式缩略图(更小):https://xxx.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,w_300/format,wewebp

其中,x-oss-process=image/resize是OSS的图片缩放参数,还支持裁剪、旋转、加水印等功能,详细参数可参考阿里云OSS官方文档。

如果没有使用云存储,也可以通过后端代码生成缩略图(如Node.js、Java),核心逻辑和前端Canvas类似,都是"读取原图→缩放→保存"。

如果是Node的后端推荐尝试一下sharp库:sharp(originalPath).resize(width, height, {fit: 'cover',position: 'center'}).toFile(thumbnailPath);

总结

个人推荐:

前端负责“预览”,后端/云存储负责“正式生成”,用云存储自动生成多尺寸缩略图(生产首选)。

不过生产状况下有些事需要注意(个人踩过坑的):

  • 大图片上传建议提示用户压缩,同时建议仅支持上传jpg、png、webp等常见格式。
  • 推荐用户优先使用webp格式文件(图片小)。
  • 图片上传过程中的异常处理记得给足提示。
  • 多端适配记得覆盖全,尤其是支持移动端的项目。

通过 npm 下载node_modules 某个依赖 ;例如 下载 @rollup/rollup-linux-arm64-gnu

方法1:通过 npm 下载

从官方 npm registry 下载

# 1. 下载 .tgz 包
npm pack @rollup/rollup-linux-arm64-gnu

# 2. 或者直接安装
npm install @rollup/rollup-linux-arm64-gnu

# 3. 指定版本
npm pack @rollup/rollup-linux-arm64-gnu@latest
npm pack @rollup/rollup-linux-arm64-gnu@4.9.5

从淘宝镜像下载(更快)

# 设置淘宝镜像
npm config set registry https://registry.npmmirror.com

# 下载
npm pack @rollup/rollup-linux-arm64-gnu
# 或
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

方法2:从 GitHub Releases 下载

# Rollup 官方 GitHub Releases
# https://github.com/rollup/rollup/releases

# 下载特定版本
curl -L -O https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

# 或者查看所有 Assets
# 在 Releases 页面找: rollup-linux-arm64-gnu.tgz

方法3:通过 npm 查看可用版本

# 查看所有版本
npm view @rollup/rollup-linux-arm64-gnu versions

# 查看最新版本
npm view @rollup/rollup-linux-arm64-gnu version

# 查看包信息
npm view @rollup/rollup-linux-arm64-gnu

方法4:下载脚本

#!/bin/bash
# download-rollup-arm64.sh

echo "下载 Rollup ARM64 二进制包..."

# 方法1: 从 npm 下载
echo "方法1: 从 npm 下载..."
npm pack @rollup/rollup-linux-arm64-gnu@latest
ls -lh *.tgz

# 方法2: 从淘宝镜像
echo "方法2: 从淘宝镜像下载..."
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

# 验证下载
echo "验证文件..."
tar -tzf rollup-linux-arm64-gnu-*.tgz 2>/dev/null | head -10

方法5:如果无法下载,安装可选依赖

在你的 package.json中添加:

{
  "optionalDependencies": {
    "@rollup/rollup-linux-arm64-gnu": "^4.9.5"
  }
}

然后运行:

npm install
# 这会尝试安装 ARM64 的二进制包

下载链接(直接访问)

在浏览器中直接访问:

npm 官方

https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

淘宝镜像

https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

GitHub

https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

一个让产品经理和设计师都满意的卡片边框方案

📖 前言

上周设计突然甩过来一张图,问我能不能不切图做出这种效果?

image.png

我蒙了一下第一反应感觉可以,无非就是常规的伪类+渐变。但尝试了一下发现两个致命问题:

1、border-image支持渐变但不支持每条边自定义设置;

2、使用伪类可以解决线的问题但是不能解决圆角问题;

忙乎半天又问了问ai感觉还是实现起来不容易,但随后产品过来又是那老一套。拿着别人家的产品看人家这个如何好看,如何优雅,巴拉巴拉。大有一种:

别人能做,你做不了。

这是不能接受的,于是又潜心研究了下,有了最后的效果。

🎯 需求拆解

先梳理一下具体需求:

需求 描述
位置 卡片左下角 L 形(左边 + 底边)
渐变 左下角颜色最深,向两端渐淡
粗细 视觉上 1px
长度 左边和底边长度大致相等
圆角 适配卡片 20px 圆角
性能 纯 CSS,无图片,无 SVG

看起来简单,做起来全是坑。

🧪 方案探索

方案一:两个伪元素分别画线

最直观的想法:用 ::before 画底部线,::after 画左边线。

scss

.card {
  &::before {
    // 底部线
    background: linear-gradient(90deg, gold, transparent);
  }
  &::after {
    // 左边线
    background: linear-gradient(0deg, gold, transparent);
  }
}

问题:两条线在圆角处有接缝,怎么都对不齐。调整了半天,还是能看到明显的拼接痕迹。

结论:放弃,圆角处无法完美衔接。


方案二:SVG 路径描边

SVG 可以精确控制路径和圆角,效果确实完美。

问题

  • 需要额外 HTML 结构
  • 移动端多一个网络请求或内联代码
  • 响应式适配需要额外处理

结论:能用,但不够优雅,性能也不够极致。


方案三:border-image + 渐变

scss

border-image: radial-gradient(circle at bottom left, gold, transparent) 1;

问题border-image 会覆盖四边,无法只控制左下角。

结论:放弃。


方案四:radial-gradient + mask(最终方案)

经过多次尝试,我发现径向渐变的圆心在左下角时,渐变会自然地向左和向上扩散,形成完美的 L 形。

配合 mask 组合,可以精确控制只显示边框区域,而不是整个渐变圆。

完美解决所有问题!

💻 最终代码

以下是基于vue2的一个组件CornerGradientCard,开箱即用。

但注意基于他的点击事件要使用click.native!!!

<template>
   <div
        :class="`gradient-wrapper ${type} `"
        :style="wrapperStyle"
   >
        <div
            class="gradient-wrapper__content"
            :style="{ borderRadius: radiusRem }"
        >
            <slot></slot>
        </div>
    </div>
  </template>

  <script>
  /** 与 postcss.config.js 中非 vant 资源的 rootValue(75) 一致,设计稿 px → rem */
  const POSTCSS_ROOT_VALUE = 75
  function pxToRem(px) {
    const n = Number(px)
    if (Number.isNaN(n)) return '0rem'
    return `${parseFloat((n / POSTCSS_ROOT_VALUE).toFixed(10))}rem`
  }

  export default {
    props: {
      type: {
        default: '',
        type: String
      },
      radius: {
        default: 12,
        type: Number
      },
      marginBottom: {
        default: 14,
        type: Number
      }
    },
    computed: {
      radiusRem() {
        return pxToRem(this.radius)
      },
      wrapperStyle() {
        const r = this.radiusRem
        return {
          borderRadius: r,
          marginBottom: pxToRem(this.marginBottom),
          '--corner-radius': r
        }
      }
    }
  }
  </script>

  <style lang="scss" scoped>
    $gradient-first-percent: 4%;    // 第一个实色节点百分比
    $gradient-second-percent: 10%;  // 第二个半透明节点百分比
    $gradient-transparent-percent: 30%; // 透明节点百分比

    .gradient-wrapper {
        width: 100%;
        position: relative;
        padding: 0 0 1px 1px;
        box-sizing: border-box;
        background-color: #fff;
        overflow: hidden;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;

        &__content {
            width: 100%;
            position: relative;
            z-index: 3;
            margin: 0 0 1px 1px;
            box-sizing: border-box;
            overflow: hidden;
            background-color: transparent;
        }

        // 渐变边框线(核心)
        &::after {
            content: '';
            position: absolute;
            z-index: 2;
            bottom: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: var(--corner-radius);
            pointer-events: none;
            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            mask-composite: exclude;
            -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            -webkit-mask-composite: xor;
            padding: 1px;
        }

        // 渐变底色(光晕效果)
        &::before {
            content: '';
            position: absolute;
            z-index: 1;
            bottom: 1px;
            left: 1px;
            width: 143px;
            height: 73px;
            border-radius: var(--corner-radius);
            filter: blur(10px);
            pointer-events: none;
        }

        &.WX {
            &::after {
                background: radial-gradient(
                circle at bottom left,
                #B6E2C8  $gradient-first-percent,
                #DFF7EA $gradient-second-percent,
                transparent $gradient-transparent-percent
                );
            }
            &::before {
                background: radial-gradient( 83% 83% at 31% 52%, #F0FBF5 0%, rgba(239,255,246,0) 100%);
            }
        }
}
  </style>

 <CornerGradientCard
    v-for="(item, index) in infoData"
    :key="item.id"
    :id="item.id"
    :type="item.type"
    @click.native="clickItem(item)"
>
    <!-- 卡片内容 -->
</CornerGradientCard>
    

🎨 参数调节指南

参数 位置 作用 移动端建议
padding: 1px .wrapper 边框粗细 保持 1px
4% / 10% / 30% 径向渐变 边框长度 根据卡片大小调整
blur(10px) 光晕 柔和度 移动端 8-12px 较佳
border-radius 全局 圆角 与设计稿保持一致

当然,基于此样式还可以可发出各种变种,例如将渐变等放到常规的右上角,替代常规的卡片标签展示样式。

如果这篇文章对你有帮助,烦请动动发财的小手点个赞~

Vue3 插件开发实战 | 从 0 开发一个全局通知组件(Toast/Message)并发布到 npm

一、为什么要自己写插件?

在日常 Vue3 开发中,我们经常使用 Element Plus 或 Ant Design Vue 的 Message/Toast 组件。但你有没有想过:

  • 这些组件是怎么实现 this.$message.success('操作成功') 这种调用的?
  • 为什么它们不需要在模板里写 <message /> 就能显示?
  • 如何把自己写的组件发布到 npm 供别人使用?

今天,我们就从 0 到 1,手写一个全局通知插件,并发布到 npm,成为真正的“开源贡献者”!

二、插件基础结构

Vue3 插件本质上是一个对象或函数,它暴露一个 install 方法。当使用 app.use(plugin) 时,install 方法会被调用,并接收 app 实例和可选的 options

// 插件基础结构
const MyPlugin = {
  install(app: App, options?: any) {
    // 在这里添加全局功能
    // 1. 注册全局组件
    // 2. 添加全局属性/方法
    // 3. 提供全局指令
    // 4. 注入依赖
  }
}

三、项目初始化

我们使用 Vite 创建一个专门用于插件开发的项目:

npm create vite@latest vue3-toast-plugin -- --template vue-ts
cd vue3-toast-plugin
npm install

为了打包到 npm,我们需要的目录结构如下:

vue3-toast-plugin/
├── src/
│   ├── components/
│   │   └── Toast.vue          # 通知组件本体
│   ├── types/
│   │   └── index.ts           # 类型定义
│   ├── index.ts               # 插件入口
│   └── style.css              # 样式(可选)
├── dist/                      # 打包输出
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md

四、开发 Toast 组件

4.1 组件功能设计

一个成熟的 Toast/Message 组件需要支持:

  • 四种类型:successerrorwarninginfo
  • 可配置:显示时长、是否可关闭、位置、自定义内容
  • 支持链式调用:Toast.success('成功').then(...)
  • 支持手动关闭
  • 多个 Toast 自动堆叠

4.2 组件实现

<!-- src/components/Toast.vue -->
<template>
  <Transition name="toast-fade" @after-leave="handleAfterLeave">
    <div
      v-if="visible"
      class="toast"
      :class="[`toast--${type}`, positionClass]"
      :style="customStyle"
      @mouseenter="pauseTimer"
      @mouseleave="resumeTimer"
    >
      <div class="toast__icon">
        <span v-html="iconMap[type]"></span>
      </div>
      <div class="toast__content">
        <slot>{{ message }}</slot>
      </div>
      <button v-if="closable" class="toast__close" @click="close">×</button>
    </div>
  </Transition>
</template>

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

export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'

const props = withDefaults(defineProps<{
  message: string
  type?: ToastType
  duration?: number
  closable?: boolean
  position?: ToastPosition
  onClose?: () => void
}>(), {
  type: 'info',
  duration: 3000,
  closable: false,
  position: 'top'
})

const visible = ref(true)
let timer: ReturnType<typeof setTimeout> | null = null

const iconMap = {
  success: '✓',
  error: '✕',
  warning: '⚠',
  info: 'ℹ'
}

const positionClass = computed(() => `toast--${props.position}`)
const customStyle = computed(() => ({})) // 可扩展自定义样式

const startTimer = () => {
  if (props.duration > 0) {
    timer = setTimeout(() => {
      close()
    }, props.duration)
  }
}

const clearTimer = () => {
  if (timer) {
    clearTimeout(timer)
    timer = null
  }
}

const pauseTimer = () => clearTimer()
const resumeTimer = () => startTimer()

const close = () => {
  visible.value = false
}

const handleAfterLeave = () => {
  props.onClose?.()
}

onMounted(() => {
  startTimer()
})
</script>

<style scoped>
/* 样式在下一节给出 */
</style>

4.3 样式设计

为了让通知美观且不影响页面布局,我们使用固定定位(fixed)。

/* src/style.css */
.toast {
  position: fixed;
  z-index: 9999;
  min-width: 200px;
  max-width: 300px;
  padding: 12px 16px;
  border-radius: 8px;
  background: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 14px;
  transition: all 0.3s ease;
}

/* 位置 */
.toast--top {
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
}
.toast--top-right {
  top: 20px;
  right: 20px;
}
.toast--top-left {
  top: 20px;
  left: 20px;
}
.toast--bottom {
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
}
.toast--bottom-right {
  bottom: 20px;
  right: 20px;
}
.toast--bottom-left {
  bottom: 20px;
  left: 20px;
}

/* 类型颜色 */
.toast--success {
  border-left: 4px solid #67c23a;
}
.toast--success .toast__icon {
  color: #67c23a;
}
.toast--error {
  border-left: 4px solid #f56c6c;
}
.toast--error .toast__icon {
  color: #f56c6c;
}
.toast--warning {
  border-left: 4px solid #e6a23c;
}
.toast--warning .toast__icon {
  color: #e6a23c;
}
.toast--info {
  border-left: 4px solid #409eff;
}
.toast--info .toast__icon {
  color: #409eff;
}

.toast__icon {
  font-size: 18px;
  font-weight: bold;
}
.toast__content {
  flex: 1;
  word-break: break-word;
}
.toast__close {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #999;
  padding: 0 4px;
}
.toast__close:hover {
  color: #333;
}

/* 过渡动画 */
.toast-fade-enter-active,
.toast-fade-leave-active {
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
  opacity: 0;
  transform: translateY(-20px) scale(0.9);
}
.toast-fade-leave-to {
  transform: translateY(-20px) scale(0.9);
}

五、插件核心逻辑:管理多个 Toast 实例

为了实现链式调用和多个 Toast 同时存在,我们需要一个管理器(Manager),负责创建、销毁 Toast 实例。

5.1 创建 Toast 管理器

// src/index.ts
import type { App, ComponentPublicInstance } from 'vue'
import { createVNode, render } from 'vue'
import ToastComponent from './components/Toast.vue'
import './style.css'

export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ToastPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'

export interface ToastOptions {
  message: string
  type?: ToastType
  duration?: number
  closable?: boolean
  position?: ToastPosition
  onClose?: () => void
}

// 存储所有活跃的 Toast 实例
let toastInstances: ComponentPublicInstance[] = []

// 生成唯一 ID(用于区分实例)
let seed = 0

function createToast(options: ToastOptions) {
  const container = document.createElement('div')
  document.body.appendChild(container)
  
  // 创建虚拟节点
  const vnode = createVNode(ToastComponent, {
    ...options,
    onClose: () => {
      // 卸载组件并移除容器
      render(null, container)
      container.remove()
      toastInstances = toastInstances.filter(ins => ins !== vnode.component?.proxy)
      options.onClose?.()
    }
  })
  
  // 渲染组件
  render(vnode, container)
  
  const instance = vnode.component?.proxy
  if (instance) {
    toastInstances.push(instance)
  }
  
  return instance
}

// 核心 API
function show(message: string, options?: Partial<ToastOptions>): Promise<void> {
  return new Promise((resolve) => {
    createToast({
      message,
      type: 'info',
      duration: 3000,
      ...options,
      onClose: () => {
        options?.onClose?.()
        resolve()
      }
    })
  })
}

// 快捷方法
function success(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'success' })
}

function error(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'error' })
}

function warning(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'warning' })
}

function info(message: string, options?: Partial<ToastOptions>) {
  return show(message, { ...options, type: 'info' })
}

// 关闭所有 Toast
function closeAll() {
  toastInstances.forEach(instance => {
    if (instance && instance.close) {
      (instance as any).close()
    }
  })
  toastInstances = []
}

// 导出插件对象
export default {
  install(app: App) {
    // 添加全局属性 $toast
    app.config.globalProperties.$toast = {
      show,
      success,
      error,
      warning,
      info,
      closeAll
    }
  }
}

// 单独导出 API(用于按需引入)
export { show, success, error, warning, info, closeAll }

六、Vite 打包配置

为了发布到 npm,我们需要将组件打包成 UMD、ES 模块等多种格式。

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

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'Vue3ToastPlugin',
      fileName: (format) => `vue3-toast-plugin.${format}.js`,
      formats: ['es', 'umd']
    },
    rollupOptions: {
      // 确保外部化处理那些你不希望打包进库的依赖
      external: ['vue'],
      output: {
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: 'Vue'
        },
        assetFileNames: (assetInfo) => {
          if (assetInfo.name === 'style.css') return 'style.css'
          return assetInfo.name || 'assets/[name]-[hash][extname]'
        }
      }
    },
    cssCodeSplit: false, // 将所有 CSS 打包成一个文件
    sourcemap: true,
    emptyOutDir: true
  }
})
// package.json 关键字段配置
{
  "name": "vue3-toast-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/vue3-toast-plugin.umd.js",
  "module": "./dist/vue3-toast-plugin.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/vue3-toast-plugin.es.js",
      "require": "./dist/vue3-toast-plugin.umd.js",
      "types": "./dist/index.d.ts"
    },
    "./style.css": "./dist/style.css"
  },
  "files": ["dist"],
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build && npm run build:types",
    "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist"
  },
  "peerDependencies": {
    "vue": "^3.2.0"
  }
}

七、生成类型声明文件

为了让 TypeScript 用户有良好的体验,我们需要生成 .d.ts 文件。

// tsconfig.json 中开启声明
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist",
    "emitDeclarationOnly": true
  }
}

也可以在 src/index.ts 中导出类型:

// src/index.ts
export type { ToastOptions, ToastType, ToastPosition } from './components/Toast.vue'

八、本地测试

在发布之前,本地测试非常重要。我们可以使用 npm link 或者在项目的 example 目录下测试。

8.1 创建测试项目

# 在插件项目根目录执行
npm link

# 进入测试项目(比如一个新建的 Vue3 项目)
cd ../vue3-test-project
npm link vue3-toast-plugin

8.2 在测试项目中使用

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'

const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<!-- App.vue -->
<template>
  <div>
    <button @click="$toast.success('操作成功!')">成功提示</button>
    <button @click="$toast.error('出错了!')">错误提示</button>
    <button @click="$toast.warning('警告信息')">警告提示</button>
    <button @click="$toast.info('普通消息')">普通提示</button>
  </div>
</template>

九、发布到 npm

9.1 准备工作

  • 注册 npm 账号:www.npmjs.com/
  • 在终端登录:npm login
  • 确保 package.json 中的 name 未被占用

9.2 打包

npm run build

9.3 发布

npm publish --access public

如果版本更新,需要修改 version 后再次发布:

npm version patch  # 1.0.0 -> 1.0.1
npm publish

十、编写 README 文档

一个好的开源项目必须有清晰的文档。README.md 应该包含:

  • 安装方法
  • 基本使用
  • API 文档
  • 示例代码
  • 贡献指南
# vue3-toast-plugin

一个轻量级、高度可定制的 Vue3 全局通知插件。

安装

npm install vue3-toast-plugin

使用

import { createApp } from 'vue'
import App from './App.vue'
import ToastPlugin from 'vue3-toast-plugin'
import 'vue3-toast-plugin/style.css'

const app = createApp(App)
app.use(ToastPlugin)
app.mount('#app')
<template>
  <button @click="$toast.success('Hello World!')">Show Toast</button>
</template>

API

$toast.success(message, options)

显示成功提示。

参数 类型 默认值 描述
message string - 提示内容
options object {} 可选配置

Options

属性 类型 默认值 描述
duration number 3000 显示时长(ms),设为0则不自动关闭
closable boolean false 是否显示关闭按钮
position string 'top' 位置,可选值见下方

位置选项toptop-righttop-leftbottombottom-rightbottom-left

License

MIT


## 十一、进阶:支持 Vue3 和 Nuxt3

如果你想让插件同时支持 Vue3 和 Nuxt3,可以增加判断环境自动适配的逻辑。Nuxt3 中插件需要写在 `plugins` 目录下,并提供 `ssr: false` 选项。

```typescript
// nuxt 插件适配示例
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(ToastPlugin)
})

十二、总结

通过本篇文章,我们完成了一个完整的 Vue3 插件从开发、打包、测试到发布的全流程。你不仅掌握了插件的核心机制(installcreateVNoderender),还学会了如何管理多个动态组件实例,以及如何让插件具有良好的 TypeScript 支持。

核心收获

  • Vue3 插件本质:{ install(app) {} }
  • 动态渲染组件:createVNode + render
  • 多个实例管理:维护实例数组,提供关闭/销毁逻辑
  • 打包配置:vite.config.ts 的 build.lib 配置
  • 发布流程:npm login → npm run build → npm publish

现在,你可以骄傲地告诉别人:“我发布过一个 npm 包!” 下次遇到重复的组件需求,不妨考虑封装成插件,提升团队复用效率。🚀


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、分享,让更多人学会 Vue3 插件开发!

我用 AI 撸了个开源"万能预览器":浏览器直接打开 Office、CAD 和 3D 模型

最近一直在深耕 AI Agent 与大模型应用,比如 JitKnow AI 知识库、JitWord协同AI文档、Pxcharts 超级表格,同时也持续在给大家分享 GitHub 上真正能落地、能解决实际问题的优质AI开源项目。

两周前发布了我们开源的文档预览SDK——jit-viewer。

图片

目前在npm上已有 2.1k 的下载量,我们也在持续更新迭代,满足更多开发者的需求。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

在 AI Coding的帮助下,我加速了迭代频率,今天很高兴和大家分享Jit-Viewer最新版本 V1.3.0.

什么是 Jit-Viewer

图片

简单来说,它是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。

图片

过去我们 preview 文件,要么调用微软/Google 的在线接口(有隐私风险),要么自建转换服务(服务器成本高)。

jit-viewer 的思路很直接:把解析能力搬到浏览器端。下面我就和大家分享一下最新版本的更新内容。

1. 支持CAD文件预览功能

图片

事情的起因很简单:工程团队在处理设计稿交付时,总是要在微信里发"麻烦安装个 CAD 看图软件"或者"这个 3D 模型我截图给你"。

作为一位写过无数款文档编辑器、多维表格的开发者,我突然意识到——为什么我们不能在浏览器里直接预览这些文件?

没有安装包,没有兼容性问题,打开链接就能看。这不应该是 2026 年的标配吗?

于是借助 AI, 我在 Jit-viewer sdk中支持了CAD文件的预览。

目前线上已提供demo测试,大家也可以体验测试一下。

2. 支持3D文件预览功能

图片

3D模型预览我们开放了很多能力,比如自动旋转3D模型,对3D模型进行旋转,截图,环境渲染器配置等,基本上开箱即用,开发者不需要关注复杂的3D空间知识,只需要按照我们api文档提供的信息配置,即可实现专业的3D模型预览功能。比如你想在web系统中预览3D商品图,手动调整模型渲染方式,都是用轻松用Jit-Viewer 来实现。大家另一个比较关注的问题可能是性能问题,这里我也做了性能优化的方案:

  1. WebAssembly 承担重计算:CAD 的几何解析、3D 模型的三角化都在 WASM 中完成,避免阻塞主线程
  2. 流式加载:大模型支持 LOD(细节层次)加载,先展示低精度轮廓,再逐步细化
  3. Worker 多线程:解析和渲染分离,UI 永不卡顿

3. 视频预览支持完全可控的视频播放控件

图片

我基本上重写了视频播放器,隐藏了video原生的视频播放控件,利用js api,重写了一个完全可控的视频播放 API 接口。

大家可以通过编程式来控制视频的播放,同时还能配置式控制播放控件的显示逻辑:

图片

那么最近的迭代,有哪些应用场景呢?

jit-viewer 不只是用来"打开文件",在我们实际业务中,它解决了几个实际的痛点:

场景 1:设计评审系统

  • 设计师上传 CAD 图纸,产品经理和开发直接在浏览器标注尺寸,无需安装 AutoCAD
  • 支持测量工具(距离、角度、面积),数据实时同步到多维表格

场景 2:3D 电商展示

  • 用户上传 3D 模型,自动生成 360° 预览,替代传统的图片轮播
  • 支持爆炸图动画,展示产品内部结构

场景 3:BIM 轻量化查看

  • 建筑信息模型在浏览器端轻量化展示,现场工程师用手机就能查看管线碰撞

场景 4:制造业协同

  • 供应商和客户之间传递 3D 模型,不再担心"你用的 SolidWorks 版本和我不兼容"

优缺点分析(客观总结,方便大家参考评估)

✅ 优势:

  • 零服务端成本:纯前端方案,不需要维护昂贵的文件转换服务器
  • 隐私安全:文件不上云,本地解析,适合涉密图纸
  • 极致体验:打开即看,无需等待"转换中"的 loading
  • 插件化架构:按需加载,不用 CAD 功能就不加载 2MB 的 WASM 文件

❌ 局限:

  • 超大文件限制:超过 500MB 的 CAD 文件还是建议用桌面软件,浏览器内存有限
  • 复杂特性缺失:CAD 的图层编辑、3D 的复杂材质节点暂时不支持(仅预览)
  • 移动端性能:3D 模型在低端手机上帧率可能下降,建议开启简化模式

写在最后:独立开发者的 vibe coding 感悟

作为一个连续创业者,我越来越确信:AI 不是替代开发者,而是让独立开发者有了对抗大厂的武器。

jit-viewer 的 CAD 解析模块,如果让我手写 C++ 几何算法,可能需要半年。但在 AI 辅助下,我花了两周就把 OpenCascade 移植到了 WebAssembly。剩下的时间,我可以专注在产品设计和开发者体验上。

这也是我开源这个项目的初衷——降低技术门槛,让更多人能做出专业的工具

如果你在做 PLG(产品驱动增长)的 SaaS 工具,或者有文件预览的需求,欢迎试试 jit-viewer。

遇到问题直接提 Issue,我会亲自回复(没错,目前 issue 响应速度还在 2 小时内 ~)。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

字节/腾讯内部流出!Claude Code 2026王炸玩法!效率暴涨10倍

还在把 Claude 当“高级代码抄写员”?
让它写个函数、改个 bug,一问一答像聊天?

大错特错! 2026 年的 Claude Code 早已进化成自主 AI 开发智能体——
它能自己读项目、自己规划、自己写代码、自己跑测试、自己修 bug,甚至直接操控你的电脑完成全流程开发!

真实案例
字节某团队用 Claude Code 的 Subagents(多智能体) 功能,30 分钟交付一个带用户认证的完整博客系统;
腾讯某工程师靠 Computer Use(电脑直控),让 AI 自动部署项目、复现并修复 UI Bug,全程无需动手。

今天这篇,把 Claude Code 2026 最强玩法、最新功能、隐藏技巧、实战避坑 一次性讲透,看完直接从新手变大神!


一、先看效果:以前累死,现在躺赢

场景 旧方式 Claude Code 新方式
开新对话 重复解释项目架构、规范 Kairos 长期记忆自动加载上下文
部署项目 手动敲命令、点 Vercel Computer Use 自动操作 GUI 完成
复杂开发 一人单干,耗时一天 Subagents 派出 AI 团队并行开发
关机后 任务中断 /schedule 云端继续跑

核心价值
从“人写代码” → “人定目标,AI 自主完成全流程”


二、2026 三大王炸功能(官方 3 月刚上线)

1. Computer Use:AI 直接操控你的 macOS 电脑

这是 AI 编程的革命性突破!

Claude 不再局限于代码文本,而是像人一样操作系统

  • ✅ 自动打开终端、执行 npm install
  • ✅ 截图识别报错弹窗、日志
  • ✅ 点击按钮、填写表单、操作 GUI 工具
  • ✅ 完整 Debug 循环:运行→报错→修改→再运行

实战场景
“帮我部署这个 React 项目到 Vercel”
→ Claude 自动登录 Vercel → 构建 → 部署 → 返回结果
全程你只需要看着!

注意:目前仅支持 macOS + Pro/Max 订阅,需授权安全目录。


2. Subagents:召唤你的 AI 开发团队

一个 Claude 不够用?直接派多个分身并行工作!

  • 前端组:开发页面、写样式
  • 后端组:设计 API、写逻辑
  • 测试组:编写用例、跑测试
  • 安全组:审查漏洞、提建议

效率提升:日常开发 3-5 倍,复杂项目 10 倍+


3. Kairos 长期记忆:AI 永远记住你的项目

解决“金鱼记忆”痛点——跨会话永久记忆 + 自动整理

启用方式
在项目根目录创建 CLAUDE.md,Claude 自动读取并永久记忆。

# 项目规范(示例)
- 技术栈:React 18 + TypeScript
- 代码规范:ESLint + Prettier
- 命名:小驼峰,组件名大写开头
- 禁止:直接修改 src/legacy 目录

下次打开,无需重复解释任何信息


三、硬核对比:为什么 Claude Code 是 2026 最强?

SWE-bench 权威数据(复杂任务通过率)

  • Claude Opus 4.680.8%(行业第一)
  • GPT-5.2:80.0%
  • Cursor(GPT-5 后端):61.3%

Token 效率:省 5.5 倍成本

同样复杂任务:

  • Claude Code:33,000 tokens,零错误
  • Cursor:188,000 tokens,多次报错

适用场景对比

工具 最佳场景 劣势
Claude Code 大型项目、全流程开发、跨文件重构 界面极简,学习曲线略陡
Cursor 前端快速开发、实时补全 复杂项目理解弱
Copilot 单行补全、IDE 集成 自主能力差

结论
做正经开发,选 Claude Code;简单业务,选 Cursor。


四、90% 人不知道的隐藏技巧

1. 7 个必学斜杠命令

/auto          # 全自动模式,AI 自主决策
/debug         # 查看会话状态、工具调用
/skill list    # 查看所有可用技能
/schedule      # 云端定时任务(关机后继续跑)
/context clear # 清理上下文,防“变笨”
/llm           # 切换模型(Sonnet/Opus)

2. 提示词黄金公式

角色+目标+规范+示例+约束

【角色】资深全栈,精通 React+TS
【目标】开发登录页,含表单验证
【规范】Tailwind CSS,小驼峰命名
【示例】参考注册页风格
【约束】响应式,支持移动端

3. Computer Use 安全玩法

  • 开启 Safe Mode:敏感操作需二次确认
  • /allow dir ./my-project 限定工作目录
  • 重要操作前手动审查 AI 计划

五、新手 3 步速成指南(闭眼操作)

第 1 步:安装+配置

npm install -g @anthropic/claude-code
claude login
claude config set model opus-4.6
claude config set computer_use true

第 2 步:必装 Skills 包

# 添加市场
claude market add official
claude market add https://github.com/affaan-m/everything-claude

# 安装神级包
claude install everything-claude   # 60+ 全能技能
claude install kairos-mem          # 长期记忆
claude install computer-use-pro    # 电脑直控增强

第 3 步:最佳工作流

  1. 项目根目录创建 CLAUDE.md
  2. 启动 Claude:claude
  3. 启用技能:/skill use everything-claude
  4. 下达目标:“帮我分析项目,规划开发计划”
  5. 确认后执行:/auto

六、避坑指南:7 个常见错误

错误 正确做法
把 Claude 当聊天机器人 给完整角色、目标、规范
不设权限,放任 AI 操作 严格限定目录,开安全模式
上下文爆炸不清理 定期 /context clear
所有任务用最贵模型 简单用 Sonnet,复杂用 Opus
忽略 Computer Use 安全 仅授权工作目录,手动审查

七、2026 选型指南:谁最适合用?

必选 Claude Code,如果你是:

  • 后端/全栈,做复杂业务系统
  • 架构师,负责大型项目重构
  • 技术团队,追求效率最大化
  • 独立开发者,想一个人顶一个团队

考虑其他工具,如果你是:

  • 纯前端,只做快速页面(选 Cursor)
  • 学生/新手,追求简单易用(选 Cursor)
  • 仅需单行补全(选 Copilot)

结语:AI 编程已进入 2.0 时代

2026 年,AI 编程不再是“辅助”,而是“主力”
Claude Code 代表的自主智能体开发模式,正在彻底重构软件开发流程。

今天学会 Claude Code,不是掌握一个工具,而是抢占 AI 时代的开发效率制高点

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

AI 时代的管理后台框架,应该是什么样子?

这些年我一直在做 Fantastic-admin 这套管理后台框架。也一直在关注这个圈子的发展,虽然“技术栈在升级”、“UI 风格也在变化”,但管理后台框架核心一直在不断解决同一个问题:

如何把那些反复出现、又特别容易失控的工程问题,提前收敛成一套系统能力。

早期,这个问题的答案是“给我一个能跑起来的脚手架”;后来变成“帮我把常见页面骨架搭好”;再后来,变成“不要让我被框架反过来绑架”;而到了今天,在 AI 和 Agent 已经真的进入开发现场之后,我觉得问题已经变成了:

一个管理后台框架,能不能同时服务开发者和 Agent ?

这也是我写这篇文章的原因。在我看来,AI 当下的管理后台,已经不能只是一个后台模板,它必须是一套面向长期协作的工程系统。

再聊之前,不妨先回顾下管理后台框架的发展史。这里以 Vue 生态下的管理后台为主。

第一阶段:脚手架时代,解决了“从 0 到 1”

这个阶段最核心的诉求非常朴素:

  • 不要让我从空目录开始搭项目
  • 不要让我自己接 Vue、路由、状态管理、权限、登录、Mock、构建配置,哪怕其中有些我不用,但也最好有

在这个阶段,vue-element-admin 是绕不过去的一款产品,它除了解决了开发者的基本诉求外,还提供了一套非常前卫的设计:用路由驱动导航菜单

今天看这件事很自然,但在当时,这其实是很关键的一步:

  • 导航菜单不再需要额外维护一份数据
  • 路由结构和导航菜单结构天然一致
  • 标题、图标、权限这类信息可以集中管理

为什么这一步重要?因为后台和普通内容网站不一样,导航本身就是产品的信息架构。导航一乱,整个后台的认知成本就会上去。

所以在我看来,第一个阶段最重要的历史贡献就是这个路由即导航的设计,影响了几乎所有后来诞生的后台框架。

第二阶段:模板繁荣时代,开始出现“虚假的强大”

随着 Vue 3 发布,以及 vue-element-admin 作者的停更,大量新的管理后台框架开始出现。

这一阶段有一个非常明显的现象:与其说是框架,更像是“模板展厅”。因为你会看到:

  • 第三方插件集成示例越来越多
  • 图表、地图、编辑器、拖拽控件、可视化页面一应俱全

很容易让人觉得“这个框架很强”,但真的是这样么?

我们不可能在一个项目中把这些所有插件都用上,即便会用到其中几个,提供的这些示例页面也未必能满足实际的需求。而绝大多数真实业务团队,日常最高频的需求反而是:

  • 列表页怎么高效搭建
  • 搜索区、分页区、操作区怎么统一
  • 新增、编辑、详情页怎么组织
  • 菜单、路由、权限、缓存怎么协同

也就是说,这个阶段很多后台框架在解决的是“看起来像个成熟后台”的问题,而不是“怎样真正高效地服务开发者”的问题。

这是我做 Fantastic-admin 时非常警惕的一件事:

不要把框架做成一个演示效果很强、真正落地时却帮不上太多忙的样子货。

第三阶段:后台框架开始回到“系统能力”本身

如果说第二阶段有不少东西是在做“展示能力”,那么从第三阶段开始,我觉得后台框架终于慢慢回到了更本质的问题上:

它到底能不能成为一套真正服务业务的系统。

在我看来,这一阶段出现了两条很清晰的路线。

一条路线是向内走:把框架本身做得更完整

这条路线的核心是尽量扩充框架自身的系统能力,也是我开发 Fantastic-admin 时侧重的一条路。因为我发现,真正影响一个后台项目长期体验的,往往不是那些最显眼的东西,而是:

  • 导航布局够不够灵活
  • 页面布局能不能适配不同产品形态
  • 路由元信息够不够细
  • 标签栏、工具栏、偏好设置是不是成体系
  • 页面保活是不是只停留在“开/关”两档
  • 有没有合理的扩展位,而不是逼着开发者去改框架源码
  • 等等

这些能力平时开发使用未必会注意到,但它们决定了一个项目在需求扩张的时候,能否让开发者放心,不用担心框架没有提供这个能力的问题。

比如页面保活这件事,我一直觉得很多框架做得太粗了,通常都只是提供一个 keepAlive: true 的开关,虽然能解决一部分问题,但真实后台项目的诉求往往更复杂:

  • 从列表进详情,希望列表保活
  • 从列表跳其他模块,希望列表不保活
  • 标签页合并(Fantastic-admin专有功能)后,有些页面要保活,有些页面返回时必须释放保活

基于这些场景,我更想做的是一套可控的保活策略,而不是一个粗糙的开关,因为这才是业务开发者真正会长期依赖的能力。

另一条路线是向外走:继续靠近业务开发本身

另一条路线也很重要,因为一个事实是:后台大量业务页面,本质上高度重复。

  • 结构重复
  • 交互重复
  • 列表重复
  • 表单重复
  • 弹窗抽屉重复

总的来说就是大量 CRUD 模块高度重复,既然重复,那就不应该每次都从基础组件重新拼。

所以有框架开始探索更高层的业务抽象,比如 vben 就提供了更成熟的 CRUD 能力、更高集成度的表格表单组件,这些方向我都认为目前还是对的。

岔开聊一句,为什么说目前还是对的,因为高集成度的封装和抽象,本质上是减轻人类开发者的工作,假设我们面对一个5000-6000行的代码文件,想要理解它是很痛苦的,所以工程化、组件化的理念才如此重要。但这种大文件却刚好很契合 AI ,毕竟如果文件拆分太多,AI 频繁需要跨文件引入,上下文变得碎片化,必然会出现链路过长,信息丢失的情况,反而不适合 AI 优先的开发模式。

但不管怎么说,从这一步开始,后台框架的竞争终于不再停留在“模板多不多”,而是进入了更实在的层面:

谁能真正把业务开发里的重复劳动继续向上抽象。

补充一点:框架开始和 UI 组件库解耦

第三阶段继续往前走,我自己又越来越强烈地感受到另一个问题:

几乎所有后台框架和某个 UI 组件库绑定死了。

这会直接带来几个问题:

  • 开发者认同你的工程设计,但不认同你的 UI 风格
  • 框架代码和某个 UI 库深度绑定,更换 UI 库成本巨大
  • 一旦 UI 组件库停止维护或维护不积极时,整套系统都会受到牵连

发现这个问题后,我就知道不能把 Fantastic-admin 绑死在某个 UI 库上。

shadcn/ui 以及后来社区出现的 shadcn-vue ,对我来说是一个非常关键的信号。

它带来的最重要启发,不是某个按钮或者弹窗组件本身,而是它在强调一件事:

  • 组件代码应该是开放的
  • 组件应该是可读、可改、可延展的
  • 设计系统应该掌握在项目自己手里
  • 组件不是黑盒消费品,而是工程资产

shadcn/ui 官方甚至直接强调自己 不是传统组件库,而是一种构建组件的方式

当侧边导航、弹窗、抽屉、消息通知等等这些基础组件和 UI 组件库解耦后,Fantastic-admin 彻底变成了一套独立的,不再是某个 UI 组件库生态下的管理后台框架。

第四阶段:Agent 爆发之后,后台框架应该被重新定义

到了今天,AI 和 Agent 的爆发,不是在给后台框架“增加一个新卖点”,而是在逼着整个领域重新回答一个问题:

如果 AI 已经能读代码、改代码、理解目录、执行任务,那么管理后台框架应该如何被重新设计?

我自己简单分析了一下,在 AI 时代,一个管理后台框架至少应该具备下面 5 个特征:

1. 必须能让 AI 看懂项目全貌

这里就绕不开 monorepo 的架构了,过去我们说 monorepo 很多时候是在说工程治理、依赖复用、多应用扩展。

但今天我越来越觉得 monorepo 还有一个非常现实、而且会越来越重要的价值:

它天然更适合让 AI 建立完整上下文,能让 AI 拥有完整信息版图。

当应用代码、公共组件、主题、框架设置、文档、各种CI/CD脚本、技能定义都放在同一个结构清晰的仓库里时,AI 更容易快速理解:

  • 哪些是业务层
  • 哪些是公共能力
  • 哪些是配置边界
  • 哪些是复用资产
  • 哪些是项目约定,哪些只是偶然写法

Google 在那篇著名的 monorepo 文章里,把 monorepo 的价值概括为“common source of truth”。

我不想机械照搬这句话,但在 AI 协作语境下,它确实给了我很强的启发:

统一的代码真相源,也意味着统一的 Agent 理解入口。

这当然不是说用了 monorepo 架构,AI 就自动变聪明了。但至少它更容易看到全貌,减少 AI 幻觉的产生。

2. 必须有一套 AI 能稳定读取的项目协议

只有代码结构还不够,要想让 AI 想稳定工作,还必须有一层项目级协议,也就是 AGENTS.md ,或者 CLAUDE.md

它们本质上都在解决同一件事:

AI 协作不能只靠一次次聊天,而是需要项目内置的长期说明。

这意味着一个现代项目,未来不只是有给人看的 README,也应该有给 Agent 看的 README。

3. 应该把高频任务产品化为 Skills

Prompt 适合解决临时问题,但不适合承载高频、稳定、可复用的项目流程。

后台项目最常见的动作其实非常固定:

  • 生成 CRUD 模块
  • 新增表单页
  • 增加路由
  • 配置国际化
  • 修改框架设置
  • 生成 store
  • 定制主题
  • 优化/美化页面

如果这些事情每次都靠人重新组织一段 Prompt,AI 的表现一定会飘忽不定。这也让我决定要把这些高频动作沉淀成 Skills,把目录约定、实现策略、文件位置、限制条件、注意事项全部前置进去。这样做的好处非常直接:

  • AI 不再靠猜
  • 生成结果更接近项目现有风格
  • 不同 Agent 工具之间更容易复用同一套知识
  • 项目经验不再只存在聊天记录里,而会沉淀成长期资产

在我看来,这一步很重要,因为它意味着我们开始从“会用 AI”走向“把 AI 纳入工程系统”。

4. 必须把“可修改”放在“可调用”前面

在 AI 时代,我越来越觉得一个被黑盒包裹得太深的组件体系,长期价值其实会下降。

因为 Agent 最擅长的,不只是调用 API,而是:

  • 阅读现有代码
  • 理解现有代码
  • 修改现有代码
  • 基于现有代码继续延展

如果组件只是一个外部依赖包里的抽象壳,AI 的可操作空间是受限的;但如果组件体系是开放的、分层清晰的、仓库内可读的,AI 的工作质量通常会高很多。

相信这也是 shadcn/ui 爆火的原因之一。

这里说一个暴论,目前国内比较火的 UI 库,我一直都没有看到官方有提供 skills ,在一个既没有 skill ,AI 又无法直接阅读 UI 库的源码,这在当前环境下,很有可能会被逐渐弃用。

未来的软件系统,不只是给人维护的,也会越来越多地交给 AI 一起维护。

所以我理解的现代管理后台,不是“我有一堆组件”就够了,而应该是:

  • 有可读的组件实现
  • 有统一的组件约定
  • 有能沉淀后台业务场景的内建组件层
  • 有可替换的底层 UI 能力

5. 最终服务的是“长期协作”,而不只是“快速生成”

很多人一谈 AI,就会把重点放在“生成更快”上。但我做后台项目这些年越来越觉得:快,从来不是唯一问题,甚至很多时候都不是核心问题。

真正重要的是:

  • 生成出来以后,能不能做 code review
  • 多个页面之间风格能不能保持一致(UI风格、代码风格)
  • 多应用、多主题、多品牌场景下会不会慢慢失控
  • 人和 Agent 或多 Agents 混合协作时,项目是否仍然稳定

所以在我看来,AI 时代最好的后台框架,不一定是第一次生成最惊艳的那个,而应该是:

最适合持续迭代、持续扩展、持续被 AI 正确理解的那个。

最后聊一聊 Fantastic-admin 即将发布的 6.0 版本

v6-is-coming.png

如果把前面这几个阶段串起来看,其实就很容易理解,为什么 Fantastic-admin 要在这个阶段发布一个大版本更新。

因为对我来说,它已经不只是“一个 Vue 3 管理后台框架”,而是在尝试回答一个更具体的问题:

如果管理后台框架要面向下一个阶段,它应该提前长成什么样子?

1. 一套可长期演进的工程底座

Fantastic-admin v6 采用了 pnpm monorepo 架构,仓库里把应用、公共包、文档、脚本、技能清晰拆开:

fantastic-admin/
├── apps/              # 应用目录
│   ├── core           # 应用源码
│   └── example        # 示例应用
├── packages/          # 公共包目录
├── docs/              # 文档站点
├── scripts/           # 脚本工具
├── skills/            # AI 技能
└── package.json       # 根目录 package.json

这么做当然也有工程治理层面的考虑,但更重要的是,我希望“代码、文档、约定、技能”能够在同一个仓库里形成闭环。对于人来说,这是更清楚的工程边界;对于 Agent 来说,这是一张更完整的信息地图。

2. 把项目协议写进了仓库

仓库根目录有 AGENTS.md 文件,里面明确说明了:

  • 项目技术栈
  • 目录结构
  • 开发命令
  • 开发规范
  • 注意事项
  • 对技能使用的补充约束

这么做的原因很简单:我不希望 AI 每次都靠对话去猜这个项目是什么样子。

3. 把高频动作沉淀成了一套 Skills

目前已经有的 Skills ,包括但不限于:

  • CRUD 模块生成
  • 表单页生成
  • 路由生成
  • 国际化管理
  • 框架设置管理
  • 页面优化
  • 预留插槽创建
  • Store 生成
  • 主题定制

Skill 一方面是可以节省 token ,另一方面是将我的能力和我对框架的理解,形成了一套任何人都可以直接复用的标准,这是一份给 AI 的指导方针,让 AI 不再是猜测你的需求,或者可以说相当于你“雇用”了作者本人帮你完成需求😁。

4. 一套更加完善的系统设计

Fantastic-admin 一直以来的重点,都不是去堆砌多少示例页面,而是把后台真正核心的问题做成一套可配置化的系统:

这些能力拆开看都不算噱头,但组合在一起,我认为它们构成的不是一个“模板展示项目”,而是一套真正的后台基础设施。


image.png

至此,Fantastic-admin 即将发布的 6.0 全新版本就是我对 AI 时代管理后台框架的全部理解

如果你对 Fantastic-admin 开始感兴趣了,现在已经发布了 6.0 beta 版,欢迎来尝试体验,我将在4月中旬左右发布正式版本。

告别 Vuex 的繁琐!Pinia 如何以更优雅的方式重塑 Vue 状态管理

引言:Vue状态管理的演进之路

在Vue生态系统中,状态管理一直是构建复杂应用的核心挑战。从早期的Vuex到如今的Pinia,Vue状态管理方案经历了显著的演进。随着Vue 3的发布和组合式API的普及,Pinia凭借其简洁性、类型安全性和卓越的开发体验,迅速成为Vue 3项目的首选状态管理库。

本文将深入探讨Pinia为何成为Vue 3状态管理的最佳实践,通过对比Vuex揭示其设计哲学的优势,并深入挖掘其底层实现机制。

一、Pinia的核心优势:为什么是Vue 3的最佳选择?

1.1 极简的API设计

Pinia摒弃了Vuex中复杂的mutations概念,允许开发者直接修改状态或通过actions进行修改。这种设计大幅减少了样板代码,使状态管理更加直观。

// Pinia的简洁写法
const store = useCounterStore()
store.count++ // 直接修改
// 或
store.increment() // 通过action修改

// 对比Vuex的繁琐流程
store.commit('INCREMENT') // 必须通过mutation

1.2 一流的TypeScript支持

Pinia在设计之初就充分考虑了TypeScript的支持,提供了完整的类型推断,无需额外的类型定义文件。

// 自动类型推断
const userStore = useUserStore()
userStore.name // string类型自动推断
userStore.login() // 参数和返回值类型自动推断

1.3 模块化的扁平结构

Pinia采用扁平化的store结构,每个store都是独立的,避免了Vuex中复杂的模块嵌套和命名空间问题。

// Vuex的嵌套模块结构
store/
├── index.js
├── modules/
│   ├── user.js
│   ├── cart.js
│   └── product.js

// Pinia的扁平结构
stores/
├── useUserStore.js
├── useCartStore.js
└── useProductStore.js

1.4 与组合式API的深度集成

Pinia完美契合Vue 3的组合式API哲学,提供了与refcomputed一致的使用体验。

二、Pinia vs Vuex:架构与设计哲学对比

2.1 架构对比图

传统Vuex架构 vs 现代Pinia架构
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│           Vuex Store                │ │            Pinia Ecosystem          │
│  ┌─────────────────────────────┐  │ │  ┌─────────────────────────────┐  │
│  │         Root State          │  │ │  │      Independent Store 1    │  │
│  └─────────────────────────────┘  │ │  │  ┌─────────────────────┐  │  │
│  ┌─────────────────────────────┐  │ │  │  │   Reactive State    │  │  │
│  │         Getters             │  │ │  │  └─────────────────────┘  │  │
│  └─────────────────────────────┘  │ │  │  ┌─────────────────────┐  │  │
│  ┌─────────────────────────────┐  │ │  │  │    Computed Getters │  │  │
│  │        Mutations            │  │ │  │  └─────────────────────┘  │  │
│  └─────────────────────────────┘  │ │  │  ┌─────────────────────┐  │  │
│  ┌─────────────────────────────┐  │ │  │  │      Actions        │  │  │
│  │         Actions             │  │ │  │  └─────────────────────┘  │  │
│  └─────────────────────────────┘  │ │  └─────────────────────────────┘  │
│  ┌─────────────────────────────┐  │ │  ┌─────────────────────────────┐  │
│  │         Modules             │  │ │  │      Independent Store 2    │  │
│  │  ┌─────────────────────┐  │  │ │  │  ┌─────────────────────┐  │  │
│  │  │      Module A       │  │  │ │  │  │   Reactive State    │  │  │
│  │  └─────────────────────┘  │  │ │  │  └─────────────────────┘  │  │
│  │  ┌─────────────────────┐  │  │ │  │           ...             │  │
│  │  │      Module B       │  │  │ │  └─────────────────────────────┘  │
│  │  └─────────────────────┘  │  │ │  ┌─────────────────────────────┐  │
│  └─────────────────────────────┘  │ │  │      Independent Store N    │  │
└─────────────────────────────────────┘ └─────────────────────────────────────┘

2.2 设计哲学差异

Vuex的设计哲学:

  • 严格的状态变更流程(必须通过mutations)
  • 中心化的store管理
  • 强调可预测性和调试能力
  • 适合大型企业级应用

Pinia的设计哲学:

  • 灵活的状态管理(可直接修改)
  • 去中心化的store组织
  • 强调开发体验和简洁性
  • 适合现代Vue 3应用开发

2.3 详细特性对比表

特性维度 Vuex 4 Pinia 影响分析
学习曲线 陡峭(4个核心概念) 平缓(3个核心概念) Pinia上手更快
TypeScript支持 需要类型辅助 一流的自动推断 Pinia开发效率更高
包体积 ~10KB (gzipped) ~5KB (gzipped) Pinia更轻量
性能表现 良好 优秀(更少的包装层) Pinia略有优势
代码组织 模块嵌套,需要命名空间 扁平化store,天然隔离 Pinia更清晰
状态修改 必须通过mutations 可直接修改或通过actions Pinia更灵活
组合式API支持 兼容但不够自然 深度集成,体验一致 Pinia更现代
DevTools支持 完善 同等完善 两者都优秀
插件生态 丰富但复杂 简洁且易扩展 各有优势

三、Pinia底层实现深度解析

3.1 核心架构实现

Pinia的核心架构基于Vue 3的响应式系统和依赖注入机制,以下是其简化实现:

// 简化的Pinia核心实现
class Pinia {
  constructor() {
    this._s = new Map() // store注册表
    this._a = null // 当前活跃的pinia实例
    this._e = new Map() // 扩展插件
  }
  
  // store工厂函数
  defineStore(idOrOptions, setup) {
    return function useStore(pinia) {
      // 获取或创建store实例
      pinia = pinia || currentPinia
      
      if (!pinia._s.has(id)) {
        // 创建响应式store
        const store = createSetupStore(id, setup, pinia)
        pinia._s.set(id, store)
      }
      
      return pinia._s.get(id)
    }
  }
}

// store创建过程
function createSetupStore($id, setup, pinia) {
  let scope
  
  // 创建响应式上下文
  const partialStore = {
    _p: pinia,
    $id,
    // ... 其他属性和方法
  }
  
  // 使用effectScope管理响应式依赖
  const setupStore = pinia._e.run(() => {
    scope = effectScope()
    return scope.run(() => setup())
  })
  
  // 合并store
  const store = reactive(
    Object.assign(partialStore, setupStore)
  )
  
  // 添加store方法
  store.$patch = function $patch(partialStateOrMutator) {
    // 实现状态批量更新
  }
  
  store.$subscribe = function $subscribe(callback, options = {}) {
    // 实现状态订阅
  }
  
  return store
}

3.2 响应式系统集成

Pinia深度集成Vue 3的响应式系统,其数据流如下图所示:

Pinia响应式数据流
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Component A   │    │   Pinia Store   │    │   Component B   │
│                 │    │                 │    │                 │
│  ┌───────────┐  │    │  ┌───────────┐  │    │  ┌───────────┐  │
│  │   State   │◄─┼────┼──│   State   │──┼────┼──│   State   │  │
│  │  (ref)    │  │    │  │  (ref)    │  │    │  │  (ref)    │  │
│  └───────────┘  │    │  └───────────┘  │    │  └───────────┘  │
│                 │    │                 │    │                 │
│  ┌───────────┐  │    │  ┌───────────┐  │    │  ┌───────────┐  │
│  │  Getter   │◄─┼────┼──│  Getter   │──┼────┼──│  Getter   │  │
│  │(computed) │  │    │  │(computed) │  │    │  │(computed) │  │
│  └───────────┘  │    │  └───────────┘  │    │  └───────────┘  │
│                 │    │                 │    │                 │
│  ┌───────────┐  │    │  ┌───────────┐  │    │  ┌───────────┐  │
│  │  Action   │──┼────┼─►│  Action   │◄─┼────┼──│  Action   │  │
│  │ (method)  │  │    │  │ (method)  │  │    │  │ (method)  │  │
│  └───────────┘  │    │  └───────────┘  │    │  └───────────┘  │
└─────────────────┘    └─────────────────┘    └─────────────────┘
        │                        │                        │
        │                        │                        │
        ▼                        ▼                        ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Vue Reactivity│    │  Effect Scope   │    │  DevTools Hook  │
│     System      │    │   Management    │    │    Integration  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

3.3 依赖注入机制

Pinia利用Vue 3的provide/inject API实现store的依赖注入:

// Pinia的依赖注入实现
const piniaSymbol = Symbol('pinia')

// 安装Pinia插件
function install(app, pinia) {
  // 提供pinia实例到整个应用
  app.provide(piniaSymbol, pinia)
  
  // 全局混入,方便Options API使用
  app.mixin({
    beforeCreate() {
      const options = this.$options
      if (options.pinia) {
        // 根组件设置pinia
        this._provided = {
          ...this._provided,
          [piniaSymbol]: options.pinia
        }
      }
    }
  })
}

// 获取store实例
function useStore(pinia) {
  // 从当前组件实例获取pinia
  const instance = getCurrentInstance()
  pinia = pinia || instance && inject(piniaSymbol)
  
  if (!pinia) {
    throw new Error('Pinia实例未找到')
  }
  
  return pinia._s.get(storeId)
}

3.4 插件系统架构

Pinia的插件系统基于中间件模式,允许在store生命周期中注入逻辑:

// 插件系统实现
function createPinia() {
  const pinia = {
    _s: new Map(),
    _p: [], // 插件列表
    use(plugin) {
      this._p.push(plugin)
      return this
    },
    _e: {
      // 插件执行上下文
      run(fn) {
        const runners = this._p.map(plugin => plugin({ pinia }))
        try {
          return fn()
        } finally {
          runners.forEach(cleanup => cleanup && cleanup())
        }
      }
    }
  }
  return pinia
}

// 持久化插件示例
const persistencePlugin = ({ store }) => {
  // 从localStorage恢复状态
  const stored = localStorage.getItem(store.$id)
  if (stored) {
    store.$patch(JSON.parse(stored))
  }
  
  // 订阅状态变化
  return store.$subscribe((mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

四、性能优化与最佳实践

4.1 性能优化策略

1. 响应式优化

// ❌ 避免:频繁解构store
computed(() => {
  const { items, filters } = useProductStore()
  return items.filter(/* ... */)
})

// ✅ 推荐:一次性解构
const productStore = useProductStore()
const { items, filters } = storeToRefs(productStore)

const filteredItems = computed(() => 
  items.value.filter(item => 
    filters.value.some(filter => item.tags.includes(filter))
  )
)

2. 计算属性缓存

// 使用computed进行缓存
const expensiveComputation = computed(() => {
  // 复杂计算逻辑
  return heavyCalculation(store.data)
})

// 避免在模板中直接计算
// ❌ <div>{{ heavyCalculation(store.data) }}</div>
// ✅ <div>{{ expensiveComputation }}</div>

4.2 代码组织最佳实践

src/
├── stores/
│   ├── index.ts              # 统一导出
│   ├── useUserStore.ts       # 用户相关状态
│   ├── useCartStore.ts       # 购物车状态
│   ├── useProductStore.ts    # 商品状态
│   ├── useUIStore.ts         # UI状态
│   └── types/               # 类型定义
│       ├── user.ts
│       ├── product.ts
│       └── index.ts
├── composables/             # 组合式函数
│   ├── useCartLogic.ts
│   └── useProductFilter.ts
└── plugins/                 # Pinia插件
    └── persistence.ts

五、迁移策略与升级指南

5.1 从Vuex迁移到Pinia

逐步迁移策略:

  1. 并行运行阶段:Vuex和Pinia共存
  2. 模块迁移:按功能模块逐个迁移
  3. 清理阶段:移除Vuex依赖

代码迁移示例:

// Vuex模块
// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({
    name: '',
    token: null
  }),
  mutations: {
    SET_USER(state, user) {
      state.name = user.name
      state.token = user.token
    }
  },
  actions: {
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)
    }
  }
}

// 对应的Pinia Store
// stores/useUserStore.js
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    token: null
  }),
  actions: {
    async login(credentials) {
      const user = await api.login(credentials)
      // 直接修改状态,无需mutation
      this.name = user.name
      this.token = user.token
    }
  }
})

5.2 兼容性处理

// 适配层:让Pinia兼容Vuex风格的代码
const createVuexCompatLayer = (piniaStore) => {
  return {
    state: piniaStore.$state,
    getters: new Proxy({}, {
      get(target, key) {
        return piniaStore[key]
      }
    }),
    commit: (mutation, payload) => {
      // 将mutation映射到action或直接修改
    },
    dispatch: (action, payload) => {
      return piniaStore[action](payload)
    }
  }
}

六、未来展望与社区生态

6.1 Pinia 2.0路线图

  • 更好的SSR支持
  • 性能优化(更小的包体积)
  • 增强的DevTools集成
  • 更多的官方插件

6.2 生态系统

  • pinia-plugin-persistedstate:状态持久化
  • pinia-plugin-debounce:action防抖
  • pinia-plugin-undo:状态撤销/重做
  • @pinia/testing:测试工具

结论

Pinia作为Vue 3的官方状态管理库,代表了Vue状态管理的新方向。它通过简洁的API设计、一流的TypeScript支持和现代化的架构,解决了Vuex在开发体验和类型安全方面的痛点。

核心价值总结:

  1. 开发效率:减少50%以上的样板代码
  2. 类型安全:完整的TypeScript支持,减少运行时错误
  3. 架构清晰:扁平化的store组织,易于维护和扩展
  4. 性能优异:更轻量的实现,更好的Tree-shaking支持
  5. 未来友好:深度集成Vue 3生态,持续活跃的社区

对于新项目,特别是基于Vue 3的项目,Pinia无疑是最佳选择。对于现有Vuex项目,建议制定渐进式迁移计划,逐步享受Pinia带来的开发体验提升。

在Vue状态管理的演进道路上,Pinia不仅是一个工具升级,更是开发理念的进步——它证明了简洁性、类型安全和开发体验可以完美共存,为Vue生态的未来发展奠定了坚实基础。

Vue3 虚拟列表实战 | 解决长列表性能问题(十万条数据流畅渲染,附原理)

一、长列表的性能困境

在企业级前端项目中,我们经常遇到这样的场景:

  • 后台管理系统:操作日志列表,一次加载几万条
  • 数据监控看板:实时数据流,持续追加
  • 聊天记录:几千条消息渲染
  • 商品评论:滚动加载无限列表

如果用传统的 v-for 直接渲染,浏览器会创建海量 DOM 节点。假设列表有 10 万条数据,每个 li 平均占用 300 字节(实际加上事件监听、样式计算等远不止),光是 DOM 节点就占用 30MB+  内存,滚动时浏览器需要重新计算布局和绘制,直接导致 掉帧、卡顿、甚至页面崩溃

<!-- ❌ 反面教材:直接渲染 10 万条数据 -->
<template>
  <div class="list">
    <div v-for="item in hugeList" :key="item.id">
      {{ item.text }}
    </div>
  </div>
</template>

打开 Chrome DevTools 的 Performance 面板,你会看到:

  • 首次渲染耗时 数秒
  • 滚动时帧率掉到 10fps 以下
  • 内存占用飙升,移动端直接闪退

二、虚拟列表原理:只渲染看得见的

核心思想:无论数据有多少,只渲染当前可视区域内的元素,其他元素用空白占位替代。当用户滚动时,动态计算需要显示的数据范围,替换掉离开可视区的 DOM 节点。

2.1 核心概念

┌─────────────────────────────┐
│       可视区域               │  ← 用户能看到的区域(固定高度)
│  ┌─────────────────────┐     │
│  │   item 10           │     │
│  │   item 11           │     │
│  │   item 12           │     │  ← 实际渲染的节点(只占3个)
│  │   item 13           │     │
│  └─────────────────────┘     │
├─────────────────────────────┤
│       缓冲区域               │  ← 上下额外多渲染几行,防止滚动白屏
└─────────────────────────────┘
         ↑
    占位元素(总高度 = 总行数 × 行高)

关键参数

  • total:总数据条数
  • itemHeight:每项的高度(固定高度场景)
  • containerHeight:可视区域高度
  • startIndex / endIndex:当前应该渲染的数据起始和结束索引
  • buffer:缓冲区大小(比如上下各多渲染 5 条)

2.2 计算公式

// 可视区域内最多能显示多少项
visibleCount = Math.ceil(containerHeight / itemHeight)

// 起始索引(根据滚动偏移量计算)
startIndex = Math.floor(scrollTop / itemHeight)

// 结束索引(加上缓冲区)
endIndex = Math.min(total - 1, startIndex + visibleCount + buffer)

// 实际需要渲染的数据
visibleData = data.slice(startIndex, endIndex + 1)

// 占位元素的总高度(用于撑开滚动条)
totalHeight = total * itemHeight

滚动时,只需要更新 startIndex 和 endIndex,Vue 会复用已有 DOM 节点,只更新数据内容,因此性能极高。

三、从 0 封装一个高性能虚拟列表组件

我们使用 Vue3 组合式 API + TypeScript 来实现一个通用的虚拟列表组件。

3.1 组件设计

<!-- components/VirtualList.vue -->
<template>
  <div
    ref="containerRef"
    class="virtual-list-container"
    :style="{ height: containerHeight + 'px' }"
    @scroll="handleScroll"
  >
    <!-- 占位元素:撑开滚动条高度 -->
    <div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
    
    <!-- 实际渲染的列表项,通过 transform 偏移到正确位置 -->
    <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleData"
        :key="getKey(item)"
        class="virtual-list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        <slot :item="item" :index="item.index"></slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'

// Props 定义
interface Props<T = any> {
  // 数据源
  items: T[]
  // 每项高度(固定高度场景)
  itemHeight: number
  // 可视区域高度
  containerHeight?: number
  // 缓冲区大小(上下各多渲染多少条)
  buffer?: number
  // 唯一标识字段名或函数
  keyField?: string | ((item: T) => string | number)
}

const props = withDefaults(defineProps<Props>(), {
  containerHeight: 400,
  buffer: 5,
  keyField: 'id'
})

// 获取唯一 key
const getKey = (item: any): string | number => {
  if (typeof props.keyField === 'function') {
    return props.keyField(item)
  }
  return item[props.keyField] ?? item.id ?? Math.random()
}

// 滚动容器 DOM 引用
const containerRef = ref<HTMLDivElement | null>(null)
const scrollTop = ref(0)

// 计算总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 可视区域最多显示多少项
const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight))

// 起始索引
const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
})

// 结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value + props.buffer * 2
  return Math.min(props.items.length - 1, end)
})

// 可见数据(带上原始索引)
const visibleData = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1).map((item, idx) => ({
    ...item,
    index: startIndex.value + idx
  }))
})

// 偏移量(让实际内容滚动到正确位置)
const offsetY = computed(() => startIndex.value * props.itemHeight)

// 滚动事件处理(节流优化)
let ticking = false
const handleScroll = (e: Event) => {
  const target = e.target as HTMLDivElement
  if (!ticking) {
    requestAnimationFrame(() => {
      scrollTop.value = target.scrollTop
      ticking = false
    })
    ticking = true
  }
}

// 监听 items 变化,如果数据变化导致总高度变化,可能需要重置滚动位置(可选)
watch(() => props.items.length, () => {
  // 可以增加重置逻辑,比如如果新数据为空,重置 scrollTop
})

// 暴露方法,供父组件调用
defineExpose({
  // 滚动到指定索引
  scrollToIndex(index: number) {
    if (containerRef.value) {
      containerRef.value.scrollTop = index * props.itemHeight
    }
  }
})
</script>

<style scoped>
.virtual-list-container {
  overflow-y: auto;
  position: relative;
  scroll-behavior: smooth; /* 平滑滚动,可选 */
}
.virtual-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}
.virtual-list-content {
  position: relative;
  z-index: 1;
}
.virtual-list-item {
  box-sizing: border-box;
  /* 可根据需要添加边框、内边距等,但注意要计入 itemHeight */
}
</style>

3.2 动态高度支持(进阶)

实际业务中,列表项高度往往不固定(例如评论区、富文本内容)。动态高度的实现更复杂,但原理相同:需要维护每项的高度缓存,动态计算总高度和偏移量。

// 动态高度版本的核心思路
const itemHeights = ref<number[]>([])          // 存储每一项的实际高度
const totalHeight = computed(() => itemHeights.value.reduce((a,b)=>a+b,0))

// 当某项渲染后,通过 ResizeObserver 或回调获取实际高度,更新缓存
function updateItemHeight(index: number, height: number) {
  if (itemHeights.value[index] !== height) {
    itemHeights.value[index] = height
    // 重新计算偏移量
  }
}

由于篇幅限制,这里不展开动态高度的完整代码,但原理与固定高度类似,只是需要额外维护高度数组。

四、性能对比:普通列表 vs 虚拟列表

我们模拟一个场景:渲染 10 万条 简单数据,每项高度 40px,可视区域高度 600px。

4.1 测试代码

<!-- 普通列表 -->
<template>
  <div class="normal-list" style="height:600px; overflow-y:auto">
    <div v-for="item in items" :key="item.id" style="height:40px; border-bottom:1px solid #eee">
      {{ item.text }}
    </div>
  </div>
</template>

<script setup>
const items = Array.from({ length: 100000 }, (_, i) => ({ id: i, text: `第 ${i} 条数据` }))
</script>
<!-- 虚拟列表 -->
<template>
  <VirtualList :items="items" :item-height="40" :container-height="600">
    <template #default="{ item }">
      <div style="height:40px; border-bottom:1px solid #eee">
        {{ item.text }}
      </div>
    </template>
  </VirtualList>
</template>

4.2 性能测试结果(使用 Chrome Performance + 内存快照)

指标 普通列表 虚拟列表
初始渲染时间 约 2800ms 约 45ms
DOM 节点数量 100,001 个 约 25 个(可视区+缓冲区)
内存占用 约 85 MB 约 8 MB
滚动帧率(fps) 平均 15-25 fps(卡顿明显) 稳定 60 fps
滚动时重排/重绘 每次滚动都大量触发 仅更新极少量节点

数据来源:Chrome 120,MacBook Pro 2021 实测。

4.3 为什么虚拟列表如此高效?

  • DOM 节点数量极少:只渲染可见区域内的 20-30 个节点,页面布局计算量极小。
  • 滚动时只修改 transform 偏移:不触发重排,只触发合成,GPU 加速。
  • 数据更新高效visibleData 变化时,Vue 仅更新现有节点的内容,不会创建/销毁大量 DOM。

五、项目中使用技巧与最佳实践

5.1 配合异步加载数据(无限滚动)

虚拟列表可以轻松与滚动触底加载结合:

<template>
  <VirtualList
    ref="virtualListRef"
    :items="displayItems"
    :item-height="50"
    @scroll-bottom="loadMore"
  />
</template>

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

const allItems = ref([])
const page = ref(1)

const displayItems = computed(() => allItems.value)

const loadMore = async () => {
  const newData = await fetchData(page.value)
  allItems.value.push(...newData)
  page.value++
}
</script>

在 VirtualList 组件内增加 @scroll 监听,判断 scrollTop + clientHeight >= scrollHeight - threshold 时触发 scroll-bottom 事件即可。

5.2 与 Vue Router 缓存结合

如果列表页使用了 <keep-alive>,虚拟列表的状态(滚动位置)会被保留,需要手动恢复:

// 在组件内
import { onActivated } from 'vue'
const virtualListRef = ref()

onActivated(() => {
  // 恢复上次滚动位置
  const savedScrollTop = sessionStorage.getItem('listScrollTop')
  if (savedScrollTop) {
    virtualListRef.value?.$el.scrollTo(0, parseInt(savedScrollTop))
  }
})

5.3 处理不定高数据

对于评论区、动态内容等高度不固定的场景,推荐使用成熟库如 vue-virtual-scroller,或自行实现动态高度虚拟列表。核心步骤:

  1. 初始化时给每项一个预估高度,用于计算占位总高度
  2. 渲染后通过 ResizeObserver 获取真实高度
  3. 更新高度缓存,重新计算偏移量
  4. 使用二分查找快速定位滚动位置

5.4 性能监控与调优

  • 避免在 item 插槽内使用复杂计算属性或大型组件,保持列表项简单。
  • 如果列表项内有图片,使用懒加载(loading="lazy")或 IntersectionObserver
  • 使用 shallowRef 包裹大数据集,减少深度响应式开销。

六、总结与扩展

虚拟列表解决了什么:通过牺牲“全量渲染”来换取极致的滚动性能和低内存占用,是处理长列表的标准方案。

适用范围

  • ✅ 数据量极大(> 1000 条)
  • ✅ 列表项高度固定或可预估
  • ✅ 需要流畅滚动体验

不适用场景

  • ❌ 列表项高度频繁变化且不可预测(可改用动态高度虚拟列表)
  • ❌ 列表项需要复杂动画过渡
  • ❌ 数据量很小(< 200 条),直接用普通列表更简单

扩展阅读

  • 表格虚拟滚动(<el-table> 开启 virtual-scroll
  • 树形控件虚拟滚动
  • 基于 IntersectionObserver 的无限滚动懒加载

通过本篇文章,你不仅理解了虚拟列表的核心原理,还能亲手实现一个企业级可复用的组件。下次面试官问“如何渲染 10 万条数据”,你就可以自信地亮出代码,并解释背后的性能优化哲学。🚀

附:完整组件源码仓库(示例链接,可根据实际提供)


如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发,让更多人告别长列表性能焦虑!

🚀 Vue 一键转 React!企业后台 VuReact 混写迁移实战

在前端工程化落地过程中,Vue 与 React 生态的混合开发、存量项目迁移是很多团队都会遇到的痛点——既要复用历史业务代码,又想接入 React 生态的新能力,常规的“重写”方案成本高、风险大。

本文以客户支持协同后台为真实案例,基于 Vue 3 + Vue Router + Ant Design (React) + Zustand (React) 技术栈,手把手拆解 VuReact 实现 Vue 到 React “可控混写迁移”的全流程,从环境配置到业务验收一步到位,帮你低成本完成跨框架迁移。

核心差异:VuReact 并非 Veaury/Vuera 这类运行时“套壳”方案,而是通过语义级编译将 Vue DSL 转化为纯净的 React 代码,无运行时冗余、无框架耦合,最终产出可独立维护的 React 工程。

🎥 效果预览

  • 在线体验:skx7pn-5173.csb.app/
  • 源码仓库:github.com/vureact-js/…
  • 核心能力:编译后保留所有业务逻辑、路由守卫、状态联动,支持热更新,Vue 源码修改可同步更新 React 产物。

vureact_hero_demo.gif

📋 前置准备

环境要求

  • Node.js ≥ 19(版本过低易导致依赖安装/编译失败)
  • 克隆示例仓库:
git clone https://github.com/vureact-js/example-customer-support-hub.git

初始化项目

cd customer-support-hub
npm install

安装完成后检查 package.json,确认包含 VuReact 核心编译脚本:

"scripts": {
  "vr:watch": "vureact watch",  // 增量迁移-监听模式
  "vr:build": "vureact build"   // 全量编译
}

同时确认项目根目录存在 vureact.config.ts(核心配置文件),路由入口需指向 Vue 路由文件:

router: {
  configFile: 'src/router/index.ts', // 声明Vue路由入口
},

🔧 核心步骤:VuReact 编译与产物解析

Step 1:执行编译(关键操作)

# 全量编译(首次迁移推荐)
npm run vr:build

# 增量迁移可选监听模式(开发阶段)
# npm run vr:watch

编译成功的核心特征

  1. 控制台输出编译统计(SFC/script/style 处理数量);

在这里插入图片描述

  1. 生成 .vureact/react-app 目录,目录结构与 Vue 源码完全一致;

在这里插入图片描述

  1. 样式自动注入(通过配置钩子修复路径):
// vureact.config.ts
onSuccess: async () => {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const entryFile = path.resolve(__dirname, './.vureact/react-app/src/main.tsx');
  const data = fs.readFileSync(entryFile, 'utf-8');
  // 修复React入口样式导入路径
  const newData = data.replace('index.css', 'styles/app.css');
  fs.writeFileSync(entryFile, newData, 'utf-8');
};

常见编译失败排查

报错类型 快速排查方向
Network/NPM 错误 切换 npm 淘宝源,检查网络连通性
SFC 语法错误 先修复 Vue 源文件的模板插值、指令格式问题
产物目录缺失 确认在项目根目录执行命令,且 vureact.config.ts 配置正常

Step 2:React 产物核心逻辑解析

编译后的 React 工程完全复用 Vue 原有逻辑,核心适配层由 @vureact/runtime@vureact/router 支撑。

1. 路由系统适配

React 入口文件 main.tsx 通过 RouterProvider 挂载路由:

import RouterInstance from './router/index';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterInstance.RouterProvider />
  </StrictMode>,
);

Vue 路由守卫逻辑自动继承(比如未登录跳转登录):

// react-app/src/router/index.ts
import { createRouter, createWebHashHistory } from "@vureact/router";
import routes from './routes';
import { appStore } from '../store/useAppStore';

const router = createRouter({
  history: createWebHashHistory(),
  routes
});

router.beforeEach((to, _from, next) => {
  // 放行公开页面(如登录页)
  if (to.meta.public) {
    next();
    return;
  }
  // 未登录跳转登录页
  const session = appStore.getState().session;
  if (!session.user) {
    next({
      name: 'login',
      query: { redirect: to.fullPath }
    });
    return;
  }
  next();
});

export default router;

2. 状态管理适配(Vue + Zustand)

Vue 源码中直接使用的 Zustand(React 状态库),编译后自动适配 React 语法:

// src/store/useAppStore.ts
import { createStore } from 'zustand/vanilla';

// 核心状态:会话、工单筛选、SLA配置等
export const appStore = createStore<AppState>((set) => ({
  session: { user: null },
  ticketFilters: { status: 'all' },
  activities: [],
  // 核心动作
  login: (user) => set((state) => ({ session: { ...state.session, user } })),
  appendActivity: (text) => set((state) => ({
    activities: [...state.activities, { id: Date.now(), text }]
  }))
}));

Vue 组件中订阅状态的逻辑,编译后也能正常工作:

// 原Vue代码
appStore.subscribe((state) => {
  userName.value = state.session.user?.name || '访客';
});

3. UI 组件适配(Vue 中用 Ant Design React)

Vue 源码中直接使用的 Ant Design React 组件,编译后自动转化为 React 语法:

<!-- 原Vue文件 src/pages/TicketsList.vue -->
<AntTable
  :columns="columns"
  :data-source="rows"
  :pagination="pagination"
  row-key="id"
  :loading="loading"
  @change="onTableChange"
/>

<AntDrawer 
  :open="drawerOpen" 
  width="560" 
  title="客户详情" 
  @close="onCloseDrawer"
>
  <!-- 客户信息展示 -->
</AntDrawer>

Step 3:启动 React 产物工程

# 进入编译后的React工程目录
cd .vureact/react-app

# 安装依赖(首次启动必做)
npm install

# 启动开发服务
npm run dev

启动成功特征

  • Vite 开发服务正常启动,浏览器打开默认进入登录页;

在这里插入图片描述

  • 登录后客服协同主界面样式、交互与原 Vue 项目完全一致;

在这里插入图片描述

  • 热更新生效:修改 Vue 源文件,npm run vr:watch 会同步更新 React 页面。

启动失败排查

  • 依赖缺失:根据日志补充 antdzustand 等包;
  • TS 报错:检查路由入口、运行时包的导入路径;
  • Vite 报错:确保 Node.js ≥ 19,或适当降级 Vite 版本。

✅ 业务闭环验收(核心验证)

迁移后需手动验证核心业务链路,确保功能完整:

  1. 登录与路由守卫:未登录访问业务页自动跳转登录,登录后可回跳目标页面;
  2. 工单全流程:筛选/接单/升级/状态更新正常,活动流、SLA 看板实时同步;
  3. 客户与建单联动:查看客户风险评分,快捷建单后工单列表可正常检索;
  4. 知识库检索:关键词/标签筛选、分页展示正常。

🚨 高效排错技巧(按症状)

问题症状 排查步骤
路由空白页 检查 RouterProvider 挂载逻辑,核对路由配置文件路径
编译失败 定位报错文件,修复 Vue 源码的语法/类型问题后重新编译
watch 模式不同步 确认 npm run vr:watch 正在运行,检查文件监听范围
业务联动不触发 检查 mock-api 调用(如 claimTicket),确认 appendActivity 执行

通用排错命令

# 重新全量编译
npm run vr:build

# 清空产物缓存后重新编译
rm -rf .vureact
npm run vr:build
cd .vureact/react-app && npm install && npm run dev

📌 核心能力覆盖(本案例)

技术维度 适配能力
模板语法 常用指令、事件、插槽全适配
组件系统 defineProps/defineEmits/slot 完全兼容
响应式 ref/computed/watch 编译为 React Hook
UI 库 Ant Design 表格/表单/抽屉/看板全量支持
状态管理 Zustand 跨页面状态同步、订阅更新
路由 守卫、嵌套路由、动态路由、重定向
样式 scoped 样式、Sass 语法适配
业务 工单流转、SLA 风险、客户评分、知识库检索

📚 后续学习导航

🎯 总结

VuReact 解决了 Vue 到 React 迁移的核心痛点:通过语义级编译将 Vue 源码转化为纯净的 React 工程,既复用了存量业务代码,又摆脱了框架耦合,最终产出可独立维护的 React 资产。

这套“可控混写迁移”方案,既降低了直接重写的成本与风险,又能平滑接入 React 生态,适合有存量 Vue 项目、想逐步迁移到 React 的团队落地。

🔗 相关资源

如果这个工具对你有帮助,欢迎给 GitHub 仓库点个 Star ✨,也欢迎在评论区交流迁移过程中遇到的问题~

详解 Nuxt 4 ,快速上手使用!

一、Nuxt 4 适用前提

Nuxt 4 适合的,不只是“想写 Vue 项目”的场景,而是“希望在 Vue 之上直接获得一整套成熟应用能力”的场景。它解决的核心问题不是单纯把页面跑起来,而是把路由、数据获取、服务端渲染、服务端接口、部署形态和工程组织一起收进同一个框架里。

以下场景通常很适合选择 Nuxt 4:

  • 你要做的是内容站、官网、博客、文档站、电商前台、中后台、SaaS 前端这类真正的网站或 Web 应用,而不是只做几个纯前端页面。
  • 你希望默认就具备 SSR、SEO、文件路由、布局系统、数据获取、服务端接口等能力,而不是自己从 Vue + Vite 一项项拼出来。
  • 你希望前后端边界更顺滑,例如前端页面和服务端 API 放在同一个仓库中协作。
  • 你需要根据不同页面选择 SSR、预渲染、缓存、重定向等渲染策略。
  • 你更看重“约定优于配置”的开发效率,希望团队新成员进入项目后能更快读懂结构。

以下场景则建议先评估:

  • 项目只是一个非常轻量的纯前端单页应用,没有 SEO、SSR、服务端逻辑需求,用 Vue + Vite 往往更直接。
  • 团队已经有成熟的纯前端架构和配套基础设施,Nuxt 带来的约定反而可能束缚既有体系。
  • 项目需要极度特殊的路由、渲染或服务端组织方式,而你不想遵循 Nuxt 的目录约定。

一句话概括:Nuxt 4 不是“Vue 的脚手架”,而是 Vue 生态里的全栈应用框架。

二、Nuxt 4 简介

Nuxt 4 是构建在 Vue 3 之上的全栈框架。它把现代 Web 应用里常见但又重复的能力预先组织好了,例如:

  • 文件系统路由
  • 布局系统
  • 自动导入
  • SSR 与 CSR
  • 服务端 API
  • 预渲染与混合渲染
  • 模块化扩展

Nuxt 4 的核心价值,在于它把这些能力整合成一个统一的开发体验。你写页面、写组件、写接口、写配置、写部署策略,都不再是彼此割裂的几套工具链,而是在同一个框架中完成。

从官方文档当前 4.x 版本可以明确确认几件很重要的事:

  • 新项目要求 Node.js 20.x 或更高版本
  • Nuxt 4 默认将应用源码放在 app/ 目录下。
  • 服务端能力由 Nitro 提供。
  • 数据获取、状态共享、自动导入、路由生成等能力是框架级设计,不是后期再补的插件习惯。

Nuxt 4 的核心能力

mindmap
  root((Nuxt 4))
    Vue 应用层
      页面路由
      布局系统
      组件自动导入
      中间件
    全栈能力
      SSR
      Server API
      数据获取
      useState
    服务端引擎
      Nitro
      routeRules
      prerender
      cache
    工程体验
      零碎配置更少
      目录约定清晰
      模块生态
      多种部署形态

三、Nuxt 4 与 Vue 的关系

Nuxt 经常被拿来和 Vue + Vite 一起讨论,但它们不是同一层次的工具。

方案 定位 优势 适合场景
Vue + Vite 前端应用基础组合 轻量、自由、上手快 纯前端 SPA、小型项目、已有成熟工程体系
Nuxt 4 Vue 全栈应用框架 路由、SSR、服务端、数据获取、部署策略一体化 官网、内容站、SaaS、需要 SEO 或 SSR 的 Vue 应用

如果你已经确定技术栈是 Vue,那么思考:

  • 你是否只需要 Vue + Vite 这样的基础前端组合。
  • 还是你已经进入“需要框架级约定和全栈能力”的阶段。

当项目出现下面这些需求时,Nuxt 的优势会非常明显:

  • 需要 SEO。
  • 需要 SSR 或预渲染。
  • 需要文件路由。
  • 需要服务端 API。
  • 需要更细粒度的页面级渲染控制。

四、Nuxt 4 快速上手

这一部分先解决“怎么把项目跑起来”,同时把几个最常见的理解误区顺手讲清楚。

1. 前置准备

根据 Nuxt 4 官方文档,建议准备:

  • Node.js 20.x 或更高版本,优先使用当前 LTS。
  • 一个具备 Vue 语言服务支持的编辑器。
  • 包管理器保持团队一致,本文以 pnpm 为例。
# 查看 Node.js 版本
node -v

# 查看 pnpm 版本
pnpm -v

如果你在 Windows 环境下感觉本地开发响应偏慢,官方文档也特别提醒了两点:

  • 可考虑使用 WSL 改善 HMR 和文件监听体验。
  • 浏览器访问本地开发服务时,使用 127.0.0.1:3000 往往会比 localhost:3000 更快。

2. 创建项目

# 创建 Nuxt 4 项目
pnpm create nuxt@latest my-nuxt-app

创建完成后进入目录:

cd my-nuxt-app

Nuxt 会根据模板生成基础项目结构。和很多旧教程不同,Nuxt 4 默认不是把页面代码全放在根目录,而是默认使用 app/ 目录作为应用源码目录。

3. 启动开发服务

pnpm dev -o

默认开发地址通常是:

http://localhost:3000

启动后你会立刻感受到 Nuxt 的几个默认体验:

  • 页面路由会根据目录自动生成。
  • 组件、组合式函数、工具函数有不少可以自动导入。
  • 页面切换和数据获取已经考虑了 SSR/CSR 之间的衔接。

4. 构建与预览

# 生产构建
pnpm build

# 本地预览构建结果
pnpm preview

和纯前端项目相比,Nuxt 的“构建结果”不只是静态资源这么简单。根据渲染模式不同,它可能包含:

  • 服务器运行所需产物
  • 预渲染页面
  • 客户端资源
  • payload 数据

因此,pnpm preview 比“只是看看页面能不能打开”更重要,它能帮助你提前发现渲染模式、资源路径和运行时配置相关的问题。

五、Nuxt 4 项目结构与目录认知

Nuxt 4 最大的学习成本,不在 API 本身,而在于你要先接受它的目录约定。目录一旦理解顺了,后面的很多能力都会自然变得清晰。

1. 一个典型的 Nuxt 4 目录结构

my-nuxt-app/
├── app/
│   ├── assets/              # 会进入构建流程的资源
│   ├── components/          # 组件
│   ├── composables/         # 组合式函数
│   ├── layouts/             # 布局
│   ├── middleware/          # 路由中间件
│   ├── pages/               # 页面路由
│   ├── plugins/             # Nuxt 插件
│   ├── utils/               # 工具函数
│   ├── app.config.ts        # 应用级公开配置
│   └── app.vue              # 应用根组件
├── public/                  # 原样公开的静态资源
├── server/
│   ├── api/                 # /api/* 接口
│   ├── middleware/          # 服务端中间件
│   ├── plugins/             # Nitro 插件
│   └── routes/              # 非 /api 前缀服务端路由
├── nuxt.config.ts           # Nuxt 核心配置
├── .env                     # Nuxt 读取的环境变量
├── package.json
└── tsconfig.json

这里最容易理解错的,是 app/public/server/ 三者的边界:

  • app/ 放的是 Vue 应用层代码。
  • public/ 放的是原样对外提供的静态资源。
  • server/ 放的是 Nitro 服务端逻辑。

如果把这三个目录的职责混在一起,后面几乎所有问题都会开始变得难排查。

2. app/ 是 Nuxt 4 的前台应用层

Nuxt 4 默认的 srcDirapp/。这意味着页面、组件、布局、组合式函数等前台应用代码,默认都应该往这里放。

可以简单理解为:

  • app/pages/ 决定页面路由。
  • app/layouts/ 决定页面外壳。
  • app/components/ 决定可复用视图单元。
  • app/composables/ 决定通用逻辑复用。
  • app/plugins/ 决定应用级注入与初始化。

3. server/ 是 Nuxt 的服务端能力入口

Nuxt 的服务端能力不是“顺手加了个 API 目录”,而是由 Nitro 提供的正式能力。

例如:

  • server/api/hello.ts 会生成 /api/hello
  • server/routes/health.ts 会生成 /health
  • server/middleware/log.ts 会在请求进入时执行

这意味着 Nuxt 项目天然就可以既写页面,又写服务端接口,不需要额外再搭一个独立的 Node 服务才能开始工作。

4. public/ 与 app/assets/ 的区别

这两个目录在所有前端框架里都容易让人混淆,在 Nuxt 中也一样:

  • public/ 中的资源不会经过构建转换,适合 favicon、robots.txt、静态下载文件这类稳定资源。
  • app/assets/ 中的资源会进入构建流程,更适合业务图片、样式资源、字体等。

如果一个资源你希望它保持稳定 URL,优先考虑 public/;如果你希望它参与构建优化、哈希命名、依赖分析,优先考虑 app/assets/

六、Nuxt 4 核心配置文件介绍

Nuxt 4 的配置理解难点,不在于“配置项很多”,而在于它有几层配置分别面向不同用途。

graph LR
    A[nuxt.config.ts] --> B[框架级配置]
    C[app/app.config.ts] --> D[应用公开配置]
    E[.env] --> F[runtimeConfig 环境变量注入]
    G[tsconfig.json] --> H[类型系统与编辑器体验]
    I[app/app.vue] --> J[应用根结构]

1. 文件 .env:环境变量

.env 文件本质上就是一个“给项目提供变量值”的配置文件。

mindmap
  root((环境变量))
    最常见的例子
      接口基础地址
      第三方服务密钥
      站点标题
      功能开关
    最适合放
      不能写死在代码里的值
      随环境变化的值
      多环境可切换的值

先看一个最简单的例子:

NUXT_API_SECRET=super-secret
NUXT_PUBLIC_API_BASE=https://api.example.com
NUXT_PUBLIC_SITE_NAME=我的 Nuxt 网站

你可以先把 .env 理解成“变量值仓库”。它只负责提供值,本身不负责告诉 Nuxt“这些值该怎么安全地在项目里使用”。这也是为什么后面还需要 runtimeConfig

值会怎么进入项目?

最常见的链路是这样的:

flowchart LR
    A[".env 文件"] -->|Nuxt 启动时把变量放到| B["process.env"]
    B -->|变量配置分类| D["nuxt.config.ts<br><b>runtimeConfig</b>"]
    D -->|业务代码读取| E["useRuntimeConfig"]

下面这句代码意思就是:

process.env.NUXT_API_SECRET
  • 去当前运行环境里读取名为 NUXT_API_SECRET 的环境变量
  • 这个值通常来自 .env,也可能来自系统环境变量或部署平台配置

为什么变量名前面经常有 NUXT_PUBLIC_

这是 Nuxt 用来区分“这个值能不能给前端看到”的重要约定。

你现在先记住最实用的一层就够了:

  • NUXT_PUBLIC_ 开头的,通常是准备给前端也能访问的值
  • 不带 NUXT_PUBLIC_ 的,通常更适合服务端私有使用

2. 文件 nuxt.config.ts:Nuxt 的总开关

这是 Nuxt 项目的核心配置入口。绝大部分全局能力,都应该优先从这里理解。

先看一个比前文更完整、也更接近真实项目的示例:

export default defineNuxtConfig({
  devtools: { enabled: true },
  modules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'],
  css: ['~/assets/styles/main.scss'],
  app: {
    head: {
      title: 'Nuxt 4 Demo',
      meta: [
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
        { name: 'description', content: 'Nuxt 4 快速上手示例项目' }
      ]
    }
  },
  runtimeConfig: {
    apiSecret: process.env.NUXT_API_SECRET,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
    }
  },
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { prerender: true },
    '/admin/**': { ssr: false },
    '/api/**': { cache: { maxAge: 60 * 5 } }
  },
  vite: {
    server: {
      port: 3000
    }
  },
  nitro: {
    compressPublicAssets: true
  }
})
mindmap
  root((Config))
    modules  接入生态能力
    css      全局样式引入
    runtimeConfig  运行时参数管理
    routeRules     路由渲染与缓存
    vite           前端构建配置
    nitro          服务端引擎配置

(1)modules:接入 Nuxt 模块生态

它的作用很直接:给 Nuxt 安装并启用框架级能力扩展。

可以把它理解成“Nuxt 官方推荐的扩展入口”

  • 作用:一键集成第三方扩展包 / 插件,自动为项目注入功能、简化配置、拓展 Nuxt 核心能力,不用你手动写繁琐的初始化、注册、兼容代码。
  • 举例说明:
    1. @pinia/nuxt 自动在 Nuxt 中注册 Pinia,无需手动写 createPinia() + app.use();直接在项目任意组件 / 页面中使用 useStore,无需重复引入。
    2. @nuxtjs/tailwindcss 自动配置 Tailwind 依赖、PostCSS、样式注入;自动识别项目中的 Tailwind 类名,无需手动创建 tailwind.config.js 基础配置;

(2)css:注册全局样式

这里注册的是整个应用都会生效的全局样式文件。

如果只是某个组件自己的样式,依然优先放回组件内部;css 更适合“全项目共享”的样式入口。

(4)app:全局基础配置

它管的是整个网站通用、所有页面都生效的设置,不是某一个页面,是全站统一的规则。

这里只写了head,对应网页源码里的 <head> 标签,全站统一配置网页头部,不用每个页面单独写。

(3)runtimeConfig:管理运行时配置与环境变量

这项配置是 Nuxt 里“怎么安全、统一地读取环境配置”的标准入口;public 里的给前后端共用,外面的只留给服务端。专门存放不能写死在代码里、会随环境变化、敏感保密的配置(比如接口密钥、接口地址),运行时自动加载,不用改代码。

很多新手困惑:不是已经有 .env 了吗,为什么还要多这一层?

这里最容易踩的坑是:把敏感信息放进 public,或者误以为 .env 本身就是配置系统。

最简单的理解是:.env:负责“提供原始变量值”,runtimeConfig:负责“把变量按 Nuxt 的规则组织起来,供项目读取”;

也就是说,你可以把 .env 里的值先交给 runtimeConfig,然后在项目里统一通过 useRuntimeConfig() 去读取,而不是到处直接写 process.env.xxx

先看一个最常见、也最实用的例子:

NUXT_API_SECRET=super-secret
NUXT_PUBLIC_API_BASE=https://api.example.com
// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: process.env.NUXT_API_SECRET,
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
    }
  }
})

这段配置的意思其实很简单:

  • apiSecret 是服务端私有配置,例如第三方服务密钥。
  • public.apiBase 是公开配置,例如前端请求接口时要用的基础地址。

在代码里这样读取:

const config = useRuntimeConfig()

console.log(config.public.apiBase)

如果是在服务端代码里,还可以读取私有配置:

export default defineEventHandler(() => {
  const config = useRuntimeConfig()

  return {
    apiBase: config.public.apiBase,
    hasSecret: Boolean(config.apiSecret)
  }
})
graph LR
    A[".env"] --> C["nuxt.config.ts<br><b>runtimeConfig</b>"]
    
    C --> D["public"]
    C --> E["private"]
    
    D --> F["前端可读"]
    D --> G["服务端也可读"]
    D --> H["例子:api基础网址 / 网站标题"]
    
    E --> I["仅服务端可读"]
    E --> J["浏览器不可见"]
    E --> K["例子:api密码 / token / 数据库配置"]

(4)routeRules:控制页面级渲染与缓存策略

这项配置让你可以按路由粒度决定页面接口的行为,给网站里不同的网址路径,单独设置「怎么渲染、要不要缓存、能不能访问」的规则,不用全站统一设置,精准优化每个页面。

上面这个例子表达的意思分别是:

  • 首页构建时预渲染
  • 博客文章页也走预渲染
  • 后台页面关闭 SSR,走客户端渲染
  • 接口增加短时缓存

如果你在学 Nuxt 时只记住一个“和部署形态高度相关”的配置,那通常就是它。

(5)vite:向底层 Vite 传递配置

Nuxt 底层使用 Vite 作为开发和构建能力的一部分,因此当你确实需要改 Vite 行为时,可以从这里传配置。

常见场景包括:

  • 调整开发端口
  • 配置样式预处理器
  • 传递一部分 Vite 构建或开发参数

更稳妥的原则是:

  • 先找 Nuxt 自己有没有对应配置。
  • 只有 Nuxt 层没有、而底层 Vite 层确实需要改时,再用 vite

(6)nitro:向服务端引擎传递配置

nitro 是面向服务端引擎这一层的配置入口,常见用途包括:

  • 调整服务端输出行为
  • 配置压缩、缓存、部署相关细节
  • 对 Nitro 运行时做更底层的控制

它和 vite 的区别可以直接这样记:

  • vite 偏前端构建与开发链路
  • nitro 偏服务端运行与输出链路

对于刚接触 Nuxt 的读者来说,不需要一开始就深入 nitro 的所有细节,但至少要知道:Nuxt 不是只有 Vue 应用层配置,它还有服务端这一层。

如果想把这一节收束成最实用的判断原则,可以记住:

  • 和 Nuxt 整体行为相关的,先看 nuxt.config.ts
  • 和应用运行中要读到的公开配置相关的,再看 app.config.tsruntimeConfig

3. app/app.config.ts:公开且偏静态的应用配置

Nuxt 官方文档明确区分了 runtimeConfigapp.config。如果配置是:

  • 公开的,不敏感的
  • 更偏应用展示层
  • 更适合在构建期确定

那么它通常更适合放在 app/app.config.ts 里。

示例:

export default defineAppConfig({
  siteName: 'Nuxt 4 Demo',
  theme: {
    primaryColor: '#0ea5e9'
  }
})

在代码中读取:

const appConfig = useAppConfig()

如果你拿不准该放哪,可以这样判断:

  • 和密钥、环境变量、部署环境相关的,先想 runtimeConfig
  • 和站点标题、主题、公开开关相关的,先想 app.config

七、Nuxt 4 核心能力实操

这一部分不追求列全,而是优先讲 Nuxt 最有代表性的能力。

1. 文件系统路由

Nuxt 的页面路由来自 app/pages/ 目录。例如:

app/pages/
├── index.vue
├── about.vue
└── posts/
    └── [id].vue

它大致会生成这样的路由:

graph TD
    A["app/pages/index.vue"] --> B["/"]
    C["app/pages/about.vue"] --> D["/about"]
    E["app/pages/posts/[id].vue"] --> F["/posts/:id"]

这套规则的价值不只是“少写路由表”,而是让页面结构和 URL 结构天然对齐,项目越大越能体会到这种可读性。

2. 布局系统

布局放在 app/layouts/ 中,适合承载:

  • 顶部导航
  • 侧边栏
  • 页脚
  • 页面公共壳层

然后在 app.vue 中配合 <NuxtLayout> 使用。

如果某个页面需要特殊布局,也可以在页面中通过 definePageMeta 指定。这样做比“在每个页面里重复写头尾结构”更清晰,也更符合 Nuxt 的组织方式。

3. 自动导入

Nuxt 的自动导入是它最能提升开发手感的能力之一。根据官方文档,以下目录默认就有明显的自动导入能力:

  • app/components/
  • app/composables/
  • app/utils/

这意味着很多时候你不需要手动 import

<script setup lang="ts">
const count = useState('count', () => 0)
const doubled = computed(() => count.value * 2)
</script>

如果你更希望显式导入,Nuxt 也提供了 #imports 别名。

自动导入的好处很明显,但也要保持清醒:

  • 它提升了开发效率。
  • 但也会让“这个函数到底来自哪里”变得没那么直观。

所以团队协作里,通常建议对公共逻辑命名保持克制,不要让自动导入把语义搞得太散。

4. 数据获取

这一节是 Nuxt 最容易“看起来会用、实际上没用明白”的部分。因为在普通 Vue 项目里,大家很容易形成一种习惯:哪里要数据,就直接 fetch 一下。
但在 Nuxt 里,这样想往往不够,因为页面首屏数据获取要同时考虑:

  • 服务端渲染
  • payload 传递
  • hydration 复用
  • 避免客户端再次请求同一份数据
  1. $fetch 是基础能力,本质上是 Nuxt 中一个很强的同构请求工具,底层来自 ofetch。它能在服务端和客户端两边工作。你可以把它理解成:

    • 适合在服务端路由、插件、事件处理函数里直接使用

      • 但它本身不负责把 SSR 数据自动安全地传给客户端复用
  2. useAsyncData 是核心的 SSR 友好数据获取,是 Nuxt 数据获取体系里更底层、也更核心的组合式函数。它做的事情不是“帮你请求”,而是:

    • 包裹一个异步函数,在服务端执行它

    • 把结果放进 Nuxt 的数据传递链路

    • 在客户端 hydration 时复用这份结果,防止“二次获取”

    它有一个非常重要的点:需要一个唯一的key 来去重。

  3. useFetch 是最常用的便利层,可以理解成:useAsyncData + $fetch 的常用封装。当在页面或组件里“从某个 API 地址拿数据”时,它通常就是最自然的首选。

    它之所以常用,是因为:

    • 写法短

    • 自带 pendingerrorrefresh

    • 会根据 URL 和选项自动生成 key

    如果你只是想在页面里拿 /api/products 这种接口数据,先用 useFetch,通常就是最对路的选择。

SSR 友好的数据获取:Nuxt 怎么防止二次获取

Nuxt 通过“水合” (hydration) 过程来解决二次获取问题:数据在服务器上获取,页面被渲染成 HTML,获取到的数据被序列化并嵌入到 HTML 载荷(payload)中。在客户端,Nuxt 读取这个载荷并“水合”应用状态,从而避免了重新请求相同的数据。

flowchart LR
    A["服务端执行<br>useFetch/useAsyncData<br>获取数据"] --> C["渲染 HTML"]
    A --> D["写入 payload"]
    C --> E["返回浏览器"]
    D --> E
    E --> F["浏览器 hydration"]
    F --> G["复用 payload"]

这就是为什么 Nuxt 官方文档一直强调:页面初始化数据,不要在 <script setup> 里直接裸用 $fetch,否则很可能服务端请求一次,客户端 hydration 时又请求一次。

所以这一句一定要记住:$fetch 能请求数据,但不自动帮你解决 SSR 首屏复用;useFetchuseAsyncData 才会把数据接入 Nuxt 的 SSR 链路。

如果你想快速做判断,可以直接按这个规则记:

  • 页面首屏数据、希望 SSR 参与并防止二次获取:优先 useFetch
  • 页面首屏需要执行更灵活的异步逻辑:优先 useAsyncData
  • 点击按钮、提交表单、手动触发请求:直接用 $fetch
  • 页面初始化阶段,不要直接裸用 $fetch
  • useAsyncDatakey 一定要唯一

代码片段:最常见的 useFetch

<script setup lang="ts">
const { data: products, pending, error, refresh } = await useFetch('/api/products')
</script>

<template>
  <div v-if="pending">加载中...</div>
  <div v-else-if="error">错误: {{ error.message }}</div>
  <ul v-else>
    <li v-for="product in products" :key="product.id">{{ product.name }}</li>
  </ul>
  <button @click="refresh">刷新数据</button>
</template>

这个例子很典型,因为它基本覆盖了页面首屏取数最常见的需求:

  • data:取到的数据
  • pending:加载状态
  • error:错误状态
  • refresh:主动刷新

代码片段:useAsyncData 与唯一 key

<script setup lang="ts">
const route = useRoute()

const { data: post } = await useAsyncData(
  `post-${route.params.slug}`,
  () => $fetch(`/api/posts/${route.params.slug}`)
)
</script>

这段代码里最重要的不是 $fetch,而是前面的这个 key:

`post-${route.params.slug}`

虽然 useFetch 往往会自动生成 key,但对 useAsyncData,或者对 useFetch 那些 URL 本身不够独特的场景,手动提供清晰且唯一的 key 非常重要。

5. 状态共享

useState 本质上也是响应式状态,但它比普通 ref 多了一层 Nuxt 的 SSR 友好能力。可以把它理解成:

  • 它像 ref,会在使用相同 key 的地方共享状态
  • 但它会参与 Nuxt 的服务端到客户端状态传递
  • 它能在 hydration 时复用服务端已经生成好的状态,解决“水合不匹配”

所以它解决的根本问题,不只是“共享”,而是:让客户端以和服务端渲染时完全一致的初始状态启动。

如果没有这层机制,就很容易出现“服务端渲染出的是 A,客户端接管时算出来的是 B”,最终引发 hydration 不匹配。

最佳实践:把 useState 包装成组合式函数

而是把它包进一个组合式函数里。这样做的好处是:key 集中不易乱、类型更稳定、更容易复用

// app/composables/useCounter.ts
export const useCounter = () => useState<number>('counter', () => 0)
// 创建一个名为 `counter` 的共享状态,用 `0` 作为初始值
// 之后其他地方只要使用同样的 key,就会拿到同一份状态

然后在组件里这样使用:

<!-- app/components/TheCounter.vue -->
<script setup lang="ts">
const counter = useCounter()
</script>

<template>
  <div>
    <p>计数器: {{ counter }}</p>
    <button @click="counter++">+</button>
  </div>
</template>

代码注意事项

  • 不要在文件顶层直接用 ref 做全局状态

    这一点非常重要,尤其是对刚从普通 Vue 项目切过来的同学。下面这种写法在 Nuxt 的通用渲染应用里是有风险的:

    const counter = ref(0)
    

    如果它出现在文件顶层作用域,就可能变成服务端进程里的单例状态。这样一来,不同用户请求之间就有机会共享同一份状态,严重时甚至可能导致数据泄露。

  • useState 里的值要可序列化

    因为 useState 的值需要从服务端传到客户端,所以它本质上也会进入序列化流程。

    这意味着你放进去的数据最好是可序列化的,例如:字符串、数字、布尔值、数组、普通对象

    不推荐直接放进去的有:函数、类实例、带复杂原型链的对象

    可以简单理解成:能安全“打包后再还原”的数据,更适合放进 useState

扩展:何时以及为何集成 Pinia

useState 非常适合处理简单到中等复杂度的状态。对于复杂的全局状态管理,特别是当需要 actions、getters 以及 Vue DevTools 的时间旅行调试等高级功能时,那么官方推荐的方向就是 Pinia。Nuxt 也提供了专门的 Pinia 模块来帮你处理 SSR 集成。

你可以直接把两者理解成:

  • useState:轻量、直接、SSR 友好,适合大多数简单共享状态
  • Pinia:更完整的状态管理方案,适合复杂全局状态

6. 服务端 API

Nuxt 项目可以直接在 server/ 目录中写接口,通过文件命名约定来处理不同的 HTTP 方法。

  • 基本的 GET 端点:

    // server/api/hello.get.ts
    export default defineEventHandler(() => {
      return {message: 'hello from server'}
    })
    

    页面中直接调用:

    <script setup lang="ts">
    const { data } = await useFetch('/api/hello')
    </script>
    
  • 基本的 POST 端点:

    // server/api/users.post.ts
    export default defineEventHandler(async (event) => {
      const body = await readBody(event);
      // 创建新用户的逻辑...
      console.log('新用户:', body);
      setResponseStatus(event, 201); // 设置 HTTP 状态码
      return { success: true, user: body };
    });
    

这套体验的价值在于:

  • 前端页面和接口距离更近。
  • 本地联调成本更低。
  • 对中小型项目尤其友好。

很多人会说 “Nitro 就是 Nuxt 的服务端引擎”,这句话没错,但还不够完整。更准确地说,Nitro 是 Nuxt 的服务端运行时,负责接收请求、执行服务端逻辑、处理接口、参与 SSR 渲染、应用 routeRules,并生成可部署的服务端运行结果。

它主要承担这些核心能力:server/api/server/routes/ 路由处理、server/middleware/ 中间件、server/plugins/ 插件、SSR 服务端渲染执行、缓存、预渲染、重定向等路由规则,以及最终 .output/server 运行时产物的构建。

可以简单理解为:Nuxt 负责应用框架层面,Nitro 负责服务端执行层面,Nuxt 将 Vue 应用层与 Nitro 服务端层整合在了同一套工作流中。

Nitro 其一大核心优势便是同构 fetch 优化。在 SSR 渲染阶段调用内部 API 路由(如 useFetch('/api/hello'))时,Nuxt 并不会发起真实的 HTTP 网络请求,而是直接在当前进程内调用对应的事件处理函数。

这种机制彻底消除了网络开销与延迟,带来了显著的性能提升,这是传统前后端分离部署难以实现的。因此,在 Nuxt 中将 API 与前端代码同构存放,不只是开发上的便捷性,更是为了在服务端渲染阶段获得实打实的性能增益,也是 Nuxt 全栈方案的核心竞争力之一。

7. 服务端中间件

如果你需要在所有请求进入前做日志、鉴权上下文注入等处理,还可以使用 server/middleware/

// server/middleware/logger.ts
export default defineEventHandler((event) => {
  console.log(`[${event.method}] 新请求: ${getRequestURL(event).pathname}`);
});

代码注意事项:服务器中间件不应返回值或结束响应,其职责是修改 event 上下文或执行副作用操作。若返回值会导致请求短路,使其无法到达目标处理器。

8. 客户端路由中间件

对新手而言,服务器中间件(server/middleware/)和路由中间件(app/middleware/)的区别很容易混淆。

服务器中间件运行在 Nitro 服务端层面,处理原始 HTTP 请求,对所有请求生效,包括 API 与静态资源请求。

路由中间件则基于 Vue 和 vue-router 运行,在客户端或服务端页面导航时执行,不会作用于直接的 API 调用。

例如,路由中间件一个简单的登录保护:

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  const user = useState('user')
  if (!user.value) {
    return navigateTo('/login')
  }
})

页面中使用:

<script setup lang="ts">
definePageMeta({
  middleware: 'auth'
})
</script>

9. Head 管理基础

在 Nuxt 里,页面 <head> 相关内容不是后期随便拼接的,而是框架级能力。
从使用角度看,可以先分成两层:

  • 全局层:例如 nuxt.config.ts 里的 app.head,适合放全站通用的标题模板、基础 meta、favicon。
  • 页面层:例如在页面或组件里使用 useHead / useSeoMeta,适合根据当前页面数据动态设置标题和描述。

可以简单理解成:

  • 全局层解决“整个站点默认长什么样”
  • 页面层解决“当前这个页面要展示什么头部信息”

如果只写全局 app.head,当然能让站点具备基础头部信息;但如果你要真正做好 SEO,尤其是商品页、文章页、详情页这种“每页内容都不同”的页面,就必须进入页面级动态管理。

10. SEO 与元数据管理

Nuxt 的 SSR 基础天然对 SEO 友好,因为搜索引擎拿到的不是一个空壳 HTML,而是已经包含页面内容的首屏结构。
但 SSR 只是基础,真正把 SEO 做细,还需要把标题、描述、Open Graph、Twitter Card 这些元数据管起来。

这里最值得先掌握的三个工具是:

  • useHead
  • useSeoMeta
  • useHeadSafe

useHead:通用的 Head 管理工具

useHead 是最基础、也最通用的组合式函数。只要是合法的 head 标签内容,它基本都能管理,例如:titlemetalinkscripthtmlAttrsbodyAttrs

比如:

<script setup lang="ts">
useHead({
  title: '商品详情页',
  meta: [
    { name: 'description', content: '这是商品详情页' }
  ]
})
</script>

你可以把它理解成:

  • 能力最全
  • 适合需要自己精细控制 head 结构时使用
  • 但写法也相对更底层、更自由

useSeoMeta:更推荐的 SEO 写法

如果你的目标主要是 SEO,而不是任意 head 标签管理,那么更推荐优先使用 useSeoMeta

它的特点是:

  • 更贴近 SEO 场景
  • 类型更清晰
  • 能减少常见拼写错误

例如你不用自己纠结:

  • 这里应该写 name
  • 还是应该写 property

而是直接写更语义化的字段:titledescriptionogTitleogDescriptionogImagetwitterCard

所以从教程角度,可以直接给一个很实用的结论:

  • 想通用控制 head:用 useHead
  • 想专门做 SEO 元数据:优先 useSeoMeta

动态 SEO:产品页是最典型的场景

Nuxt 真正体现优势的地方,不是“能写一个静态 title”,而是可以结合页面数据,动态生成每个页面自己的 SEO 信息。

例如商品详情页:

<script setup lang="ts">
const { data: product } = await useFetch('/api/products/some-product')

useSeoMeta({
  title: () => `${product.value?.name} - 我的商店`,
  description: () => product.value?.description,
  ogTitle: () => `${product.value?.name} - 我的商店`,
  ogDescription: () => product.value?.description,
  ogImage: () => product.value?.imageUrl,
  twitterCard: 'summary_large_image'
})
</script>

这段代码最重要的不是 API 写法,而是它体现出的思路:

  • 页面数据先通过 useFetch 获取
  • SEO 标签再根据这份数据动态生成
  • 因为 Nuxt 支持 SSR,所以搜索引擎拿到的首屏里就已经包含这些元数据

这正是 Nuxt 对 SEO 友好的关键原因之一。

什么时候要用 useHeadSafe

如果你处理的是用户生成内容,或者来源不完全可控的数据,例如:

  • 用户输入的标题
  • CMS 后台可编辑的描述
  • 外部系统返回的富文本摘要

那么在把这些内容放进 head 时,要特别注意安全问题。

这时更适合使用 useHeadSafe,因为它会对输入内容做更安全的处理,避免把危险属性或值直接渲染进页面头部,从而降低 XSS 风险。

可以简单理解成:

  • 普通可控内容:useHead / useSeoMeta
  • 用户生成或不完全可信内容:优先考虑 useHeadSafe

实战里的最实用建议

如果你希望这部分先能落地,而不是一下子记一堆 API,可以先记住下面几条:

  • 全站默认头部放 app.head
  • 页面级动态 SEO 优先用 useSeoMeta
  • 需要更细粒度 head 控制时再用 useHead
  • 页面 SEO 最好绑定真实页面数据,而不是写死模板文本
  • 用户生成内容进入 head 时,优先考虑 useHeadSafe

八、Nuxt 4 渲染模式与部署思路

Nuxt 真正拉开和普通前端项目差距的地方,就在这里。你不只是“构建一个站点”,而是在决定每条路由应该怎样被渲染。

1. 默认并不只是 SPA

Nuxt 的默认优势之一,就是它天然适合 SSR。你不用先把 Vue 项目搭起来,再额外拼接 SSR 方案。

但 Nuxt 也不只支持 SSR,它可以在同一个项目里组合多种策略:

  • SSR(Server-Side Rendering,服务端渲染)
  • CSR(Client-Side Rendering,客户端渲染)
  • 预渲染
  • 混合渲染
  • Edge 部署

2. routeRules 是理解 Nuxt 渲染能力的关键

Nuxt 官方文档明确指出,Nitro 的 routeRules 可以对不同路径设置不同规则。

示例:

export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { prerender: true },
    '/api/**': { cache: { maxAge: 60 * 60 } },
    '/old-page': {
      redirect: {
        to: '/new-page',
        statusCode: 302
      }
    }
  }
})

这意味着同一个 Nuxt 项目里,你完全可以让:

  • 首页在构建时预渲染
  • 博客页按规则预渲染
  • 某些接口带缓存
  • 某些旧地址自动重定向

这就是 Nuxt 的“混合渲染”思路,它比“整个站点只有 SSR 或只有 SPA”灵活得多。

3. 预渲染不只是“导出静态 HTML”

根据官方文档,Nuxt 在预渲染时还会生成 _payload.json,其中包含 useAsyncDatauseFetch 产生的序列化数据。客户端导航时可以直接读取这些 payload,而不是重复请求。

这也是为什么前面一直强调:

  • 数据获取方式和渲染模式是连在一起的。
  • 不能把 Nuxt 中的数据获取简单当作普通前端里的异步请求。

九、Nuxt 4 核心架构与工作流程

这一章不再按“功能清单”来讲,而是按 Nuxt 真正的工作方式来拆。重点是把 Nuxt 的生成层、运行层、服务端层、客户端接管层,以及这些层之间怎么衔接讲清楚,尤其补清楚两个经常被讲虚的点:Nuxt 到底扫描了什么,以及应用真正的入口链路是什么。

1. Nuxt 到底是什么架构,不只是“Vue + SSR”

一个比较完整、能拿分的回答应该是:

Nuxt 不是简单把 Vue 套上 SSR,而是把 Vue 应用层、服务端引擎、构建生成层和部署产物层组织成统一工作流的全栈框架。

它至少可以拆成 4 层:

  • app/:Vue 应用层,放页面、布局、组件、composables、插件
  • server/:Nitro 服务端层,放 API、server middleware、server plugins、routes
  • .nuxt/:生成层,把约定式源码整理成可运行的应用骨架
  • .output/:部署层,生产环境真正运行的产物
graph TD
    A["源码"] --> B["app/"]
    A --> C["server/"]
    A --> D["nuxt.config.ts"]
    B --> E["Vue 应用层"]
    C --> F["Nitro 服务端层"]
    D --> G["全局配置层"]
    E --> H["Nuxt 生成层 .nuxt"]
    F --> H
    G --> H
    H --> I["开发运行"]
    H --> J["生产构建"]
    J --> K["部署产物 .output"]

2. Nuxt 到底扫描了什么:扫描对象、规则和结果

很多教程会说“Nuxt 会扫描目录”,但如果不继续说清楚“扫描什么、按什么规则扫描、扫描后拿这些结果做什么”,这一句其实帮助不大。

Nuxt 扫描的不是整个项目里所有文件,而是被框架约定为有特殊语义的目录和文件。最重要的几类包括:

  • app/pages/:扫描后生成页面路由
  • app/layouts/:扫描后生成布局映射
  • app/middleware/:扫描后生成客户端路由中间件映射
  • app/plugins/:扫描并自动注册 Nuxt 插件
  • app/components/:扫描后生成组件自动导入能力
  • app/composables/app/utils/:扫描后生成自动导入声明
  • server/api/:扫描后生成 /api/* 服务端路由
  • server/routes/:扫描后生成普通服务端路由
  • server/middleware/:扫描后挂载到 Nitro 请求链路
  • server/plugins/:扫描后在 Nitro 启动时执行

这里最关键的细节是:不同目录的扫描规则并不完全一样。

例如 app/plugins/ 并不是“递归扫描一切文件并全部注册”。官方文档明确说明:

  • 默认自动注册的是顶层插件文件
  • 子目录下的 index 文件目前也会被扫描,但这种形式已经不推荐长期依赖
  • 也就是说,自动注册是有边界和规则的,不是无差别递归

所以更准确地说,Nuxt 的扫描是:

  • 以目录约定为边界
  • 以文件位置和类型为规则
  • 以生成 .nuxt 中间结果为目的
flowchart LR
    A["app/ + server/ + nuxt.config.ts"] --> B["Nuxt 扫描器"]
    B --> C["路由记录"]
    B --> D["布局与中间件映射"]
    B --> E["插件注册表"]
    B --> F["自动导入声明"]
    B --> G["Nitro 路由与处理器"]
    C --> H["写入 .nuxt"]
    D --> H
    E --> H
    F --> H
    G --> H

3. 应用真正的入口是什么

“Nuxt 的入口是什么”这个问题特别容易回答得似是而非。

从应用视图树的角度看,app/app.vue 是 Nuxt 应用的根组件入口。官方文档也明确把 app.vue 定义为 Nuxt application 的 main component。

但这里一定要分清两个层次:

视图根入口:app.vue

app.vue 决定的是:

  • 整个应用最外层长什么样
  • 布局体系如何包裹页面
  • 当前页面最终渲染到哪里

最常见的写法是:

<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

这里三者职责分别是:

  • app.vue:应用根组件
  • <NuxtLayout>:当前页面外面套哪层布局
  • <NuxtPage>:当前路由对应的页面组件渲染到哪里

这也解释了一个很容易忽略的点:

  • app/pages/ 只是被扫描并生成路由记录
  • 真正把当前页面显示出来的,是 app.vue 里的 <NuxtPage />

框架运行入口:.nuxt 里的生成入口

Nuxt 框架当然不是“从你手写的 app.vue 文件直接启动”的。更准确的说法是:

  • Nuxt 先生成自己的运行入口
  • 这个入口会把 app.vue 接成根组件
  • 然后再由 Nuxt / Vue 创建应用实例

所以要把这两层区分开:

  • app.vue 是应用视图树入口
  • .nuxt 里的生成入口是框架真正的运行入口

4. 执行 pnpm dev 后,Nuxt 内部到底发生了什么

这题不要回答成“就启动开发服务器”。更完整的回答是:

  1. 读取 nuxt.config.ts
  2. 扫描 app/server/
  3. 生成 .nuxt 与类型
  4. 启动开发服务器
  5. 等待请求进入
  6. 请求进入后,由 Nitro 和 Nuxt 一起处理 SSR/CSR 链路

更直观的流程可以看这张图:

flowchart TD
    subgraph 启动阶段
        direction LR
        A["执行 pnpm dev"] --> B["读取<br>nuxt.config.ts"]
        B --> C["扫描 app/ 和 server/"]
        C --> D["生成 .nuxt 与类型"]
        D --> E["启动 Nuxt Dev Server"]
    end

    subgraph 请求阶段
        direction LR
        F["浏览器请求页面"] --> G["Nitro<br>接收请求"]
        G --> H["创建<br>Nuxt实例<br>Vue 实例"]
        H --> I["执行<br>app<br>plugins<br>服务端相关逻辑"]
        I --> J["执行<br>页面校验<br>app middleware"]
        J --> K["渲染页面组件"]
        K --> L["执行<br>useFetch<br>useAsyncData"]
        L --> M["生成<br>HTML<br>payload"]
    end

    启动阶段 --> 请求阶段
    请求阶段 --> N["返回内容到浏览器"]
    N--> O["浏览器 hydration"]

这里最值得抓住的两个点通常是:

  • Nuxt 开发时不是直接跑源码,而是先生成 .nuxt
  • 请求进来不是直接进 Vue 组件,而是先过 Nitro,再进入 Nuxt 应用渲染链路

5. 一次页面请求进来以后,Nuxt 内部的执行顺序

按官方 Lifecycle 文档,可以整理成更适合理解框架执行链路的顺序:

服务启动时执行的部分

  • Nitro 启动
  • 执行 server/plugins/
  • 注册服务端钩子和运行时扩展

这里容易被问到一个细节:

  • server/plugins/ 更接近服务端启动初始化
  • 不是每个页面请求都重新执行一次的页面逻辑

每个请求都会走的部分

  1. 请求进入 Nitro
  2. 执行 server/middleware/
  3. 创建 Nuxt 与 Vue 应用实例
  4. 执行 app/plugins/
  5. 执行页面 validate
  6. 执行 app/middleware/
  7. 匹配布局、页面与组件树
  8. 执行 useFetch / useAsyncData
  9. 生成 HTML
  10. 把 HTML、payload、资源信息返回浏览器

这套顺序非常值得记,因为它能帮你回答很多追问:

  • server/middleware/app/middleware/ 有什么区别
  • 为什么插件比页面先执行
  • 为什么 useFetch 能参与 SSR

6. .nuxt 是什么:先生成再运行

官方文档明确说明,Nuxt 会生成 .nuxt/ 目录,而 nuxt prepare 也会专门创建 .nuxt 并生成类型。
这说明 .nuxt 不是无意义缓存,而是 Nuxt 的中间生成层

你可以这样答:

.nuxt 是 Nuxt 根据 app/server/nuxt.config.ts 等约定式源码,自动生成出来的可运行应用骨架。

它通常承载这些东西:

  • 路由生成结果
  • 自动导入声明
  • 类型文件
  • 插件注册结果
  • 应用运行入口
flowchart LR
    A["app/<br>server/<br>nuxt.config.ts"] --> B["Nuxt 扫描"]
    B --> C["生成 .nuxt"]
    C --> D["路由定义"]
    C --> E["自动导入声明"]
    C --> F["类型文件"]
    C --> G["插件注册和运行入口"]

换成更底层的理解方式,可以直接这样记:

  • 因为 Nuxt 先扫描目录约定
  • 再把结果生成进 .nuxt
  • 最后运行的其实是生成后的应用结构

7. .nuxt 和 .output 有什么区别

这题很经典,因为它能测出你有没有真正理解生成层和部署层。

标准区分方式是:

  • .nuxt:开发期 / 生成期的中间结果
  • .output:生产构建后真正部署和运行的最终结果
flowchart LR
    A["源码与配置"] --> B["nuxt build"]
    B --> C["生成客户端资源"]
    B --> D["生成 Nitro 服务端产物"]
    B --> E["应用 routeRules / prerender"]
    C --> F[".output"]
    D --> F
    E --> F

所以:

  • .nuxt 更像“运行前整理好的应用骨架”
  • .output 更像“真正拿去上线部署的产物”

官方部署文档也直接给出运行方式,例如:

node .output/server/index.mjs

这说明生产环境真正跑的不是源码目录,而是 .output

React&Vue知识点汇总

Vue

1. 声明式渲染

  • 模板语法{{ }} 文本插值、v-html 输出 HTML

  • 指令

    • 内置指令:v-bindv-onv-modelv-if/v-else-if/v-elsev-showv-forv-prev-cloakv-once
    • 自定义指令:全局 Vue.directive(Vue 2)/ app.directive(Vue 3),局部 directives 选项

2. 响应式系统

  • Vue 2:基于 Object.defineProperty,递归遍历对象属性,无法检测

    • 对象属性的添加/删除(需用 Vue.set / this.$set
    • 数组索引修改(需用变异方法或 Vue.set
    • 数组长度变化(需用 splice 等)
  • Vue 3:基于 Proxy,可监听动态添加属性、数组索引修改,性能更优,支持 MapSet 等原生集合的响应式

  • 响应式原理

    • 数据劫持:Vue 对 data 中的属性进行响应式处理(Vue 2 用 Object.defineProperty,Vue 3 用 Proxy)。
    • 依赖收集:当组件渲染或计算属性等执行时,会访问响应式数据,此时将当前正在执行的 Watcher(观察者)添加到该数据的依赖列表(Dep)中。
    • 派发更新:当数据被修改时,Dep 通知所有依赖它的 Watcher 执行更新。
    • 异步更新队列Watcher 更新时并不立即执行 DOM 操作,而是将自身推入一个队列,在下一个事件循环(microtask)中统一执行,并利用 nextTick 提供更新后的回调。

3. vue3 API

API 说明
ref() 声明任意类型的响应式数据,需通过 .value 访问。
reactive() 声明对象/数组类型的响应式数据,可直接访问属性。
computed() 定义计算属性,基于响应式依赖缓存结果。
watch() 监听特定数据源,在数据变化时执行副作用。
watchEffect() 自动追踪其内部使用的响应式数据,并在数据变化时立即重新运行。
onMounted()onUpdated()onUnmounted() 等 组件生命周期不同阶段执行的钩子函数,用法与选项式 API 对应。
provide()inject() 用于跨层级组件通信,祖先组件提供数据,后代组件注入使用。
toRefs()toRef()isRef()unref() 等 用于处理 ref / reactive 对象的辅助函数,帮助进行响应式转换和判断。
defineProps()defineEmits() 在 <script setup> 中声明组件的 props 和 emits,享受完整类型推导。
defineExpose() 在 <script setup> 中声明当前组件暴露给父组件的属性或方法。
defineOptions() 在 <script setup> 中声明组件名 (name) 或 inheritAttrs 等选项。

内置组件

组件 说明
<component> 用于动态渲染不同组件的“元组件”,通过 is 属性决定
<transition> 为单个元素或组件添加进入/离开过渡动画
<transition-group> 为列表中的多个元素或组件添加过渡动画
<keep-alive> 缓存动态组件,避免重复渲染和状态丢失
<teleport> 将组件模板的一部分渲染到 DOM 树中的指定位置
<suspense> 管理异步组件或依赖异步数据的组件,在等待时显示后备内容
<slot> 作为组件模板中的插槽出口,接收父组件分发的内容

内置指令

指令 说明
v-model 在表单元素或组件上创建双向绑定。
v-ifv-else-ifv-else 条件渲染,为 false 时不渲染元素。
v-show 条件渲染,通过 CSS 的 display 属性切换。
v-for 基于源数据多次渲染元素或模板块。
v-on (@) 绑定事件监听器。
v-bind (:) 动态地绑定一个或多个属性。
v-slot (#) 用于声明具名插槽或作用域插槽。

4. 生命周期钩子

阶段 Vue 2 钩子 Vue 3 钩子(Options API) Vue 3 钩子(Composition API)
初始化 beforeCreate, created setup() 代替 beforeCreate/created
挂载 beforeMount, mounted onBeforeMount, onMounted
更新 beforeUpdate, updated onBeforeUpdate, onUpdated
卸载 beforeDestroy, destroyed beforeUnmount, unmounted onBeforeUnmount, onUnmounted
错误捕获 errorCaptured onErrorCaptured
其他 activated, deactivated(keep-alive) onActivated, onDeactivated
调试 renderTracked, renderTriggered(开发) onRenderTracked, onRenderTriggered

5. 插槽

  • 默认插槽、具名插槽、作用域插槽(slot-scope in Vue 2,v-slot in Vue 2.6+ & Vue 3)
  • Vue 3 中 v-slot 统一为指令语法,slot 和 slot-scope 被废弃

6. 混入(Mixin)

  • 全局混入、局部混入
  • 合并策略:数据递归合并,同名钩子合并为数组,方法/组件/指令等直接覆盖
  • 缺点:命名冲突、隐式依赖、代码不直观 → 推荐组合式 API 替代

7. 自定义指令

  • 钩子函数:

    • Vue 2:bindinsertedupdatecomponentUpdatedunbind
    • Vue 3:beforeMountmountedbeforeUpdateupdatedbeforeUnmountunmounted
  • 参数:elbindingvnodeprevVnode

8. 过滤器(Filters)

  • Vue 2 支持模板内过滤器({{ msg | filter }})及全局/局部定义
  • Vue 3 中移除,推荐用计算属性或方法替代

9. 动画与过渡

  • <transition> 单元素过渡
  • <transition-group> 多元素/列表过渡
  • 类名约定:v-enter-from/v-enter-to 等(Vue 3 命名变化)
  • JavaScript 钩子:@before-enter@enter@after-enter 等

10、组件通信方式(详细对比)

方式 Vue 2 Vue 3
props / $emit 支持 支持,emit 需在 setup 中声明
v-model 单个,value + input 可多个,modelValue + update:modelValue,支持自定义修饰符
refs/refs / parent / $children $children 存在 移除 $children,推荐 ref + $parent 或组合式 API
provide / inject 默认非响应式,可传递响应式对象 支持响应式传递,可提供 ref/reactive
event bus new Vue() 作为总线 推荐用 mitt 等第三方库
Vuex Vuex 3 Vuex 4 / Pinia
slot 作用域 slot-scope v-slot 统一语法
组合式 API 可直接使用 ref 传递,逻辑复用更灵活

11、Vue Router 对比(3.x vs 4.x)

特性 Vue Router 3(Vue 2) Vue Router 4(Vue 3)
创建方式 new VueRouter(...) createRouter({ ... })
模式 mode: 'history' / 'hash' history: createWebHistory() / createWebHashHistory()
路由守卫 beforeEach / beforeResolve / afterEach 同,但支持组合式 API 中的 onBeforeRouteUpdate 等
路由元信息 meta
动态路由 addRoutes addRoute,且支持动态删除
导航故障 NavigationFailureType 更完善的类型
组合式 API 不支持 useRouteruseRoute

12、状态管理:Vuex vs Pinia

特性 Vuex 3/4 Pinia
设计理念 基于 Flux,强调 mutations / actions / getters 更简洁,直接修改 state,支持组合式 API
类型推断 需要额外处理 原生 TypeScript 支持
模块化 通过 modules 通过多个 store 自然分割
异步处理 actions 中 actions 中,可直接使用 async/await
代码量 较多模板代码 更少,更直观
热更新 有限支持 支持 store 热更新
Vue 3 推荐 可用,但官方转向 Pinia 官方推荐,轻量且强大

13、构建工具:Vue CLI vs Vite

特性 Vue CLI(基于 webpack) Vite
启动速度 慢(打包后启动) 极快(按需编译,原生 ES modules)
生产构建 基于 webpack,配置灵活但复杂 基于 Rollup,预配置更简单
插件生态 丰富的 webpack 插件 插件系统兼容 Rollup 插件,且提供 Vite 插件
配置方式 vue.config.js vite.config.js
开发环境 HMR 较慢(大规模项目) HMR 快速,保留状态
环境变量 VUE_APP_* VITE_*

14、响应式原理(Vue 2 vs Vue 3)

Vue 2 响应式

  • 遍历 data 对象,对每个属性递归调用 defineReactive,为每个属性创建 Dep(依赖收集器)。

  • 每个属性对应一个 Watcher(观察者),在渲染时收集依赖。

  • 缺点:

    • 无法检测对象属性的新增/删除(需用 Vue.set / this.$set)。
    • 无法直接通过索引修改数组(arr[0] = xx 不触发更新,需用变异方法如 pushsplice)。
    • 初始化时需要递归遍历,性能略差。

Vue 3 响应式

  • 基于 Proxy 代理整个对象,可拦截 getsetdeleteProperty 等操作。

  • 优点:

    • 动态添加/删除属性自动响应。
    • 数组索引修改和 length 变化自动响应。
    • 支持 MapSet 等原生集合。
    • 惰性响应式:只有访问到属性时才会递归代理,性能更好。

总结

Vue 2 通过 Object.defineProperty 劫持对象属性的 getter/setter 来实现响应式,但存在局限性,比如无法监听动态添加的属性,需要通过 Vue.set 处理。Vue 3 改用 Proxy,可以代理整个对象,支持多种操作拦截,解决了上述问题,同时性能更优。响应式核心是依赖收集和派发更新,在 getter 中收集依赖,在 setter 中触发更新,并通过异步队列实现批量更新

15、虚拟 DOM 与 diff 算法

diff 策略

  • 同层比较:只比较同一层节点,不跨层。
  • 双端比较(Vue 2):新旧 VNode 的 children 数组通过头尾交叉比较,找到可复用的节点。
  • 静态提升(Vue 3):编译时标记静态节点,更新时跳过它们。
  • Patch flag(Vue 3):标记动态节点,只更新变化的部分

总结

虚拟 DOM 是一种用 JS 对象模拟真实 DOM 的结构,通过 diff 算法对比新旧 VNode,找出差异并批量更新真实 DOM,减少了直接操作 DOM 的性能开销。Vue 2 的 diff 采用双端比较,Vue 3 则引入了静态提升和 patch flags,进一步优化了更新效率。key 是 diff 过程中识别节点的重要依据,使用稳定的 key 可以保证节点复用,避免渲染错误。

16、 生命周期钩子(执行顺序、使用场景)

父子组件生命周期顺序

  • 创建:父 beforeCreate → 父 created → 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted → 父 mounted
  • 更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated
  • 销毁:父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed

常用钩子作用

  • beforeCreate:实例初始化后,数据观测和事件配置之前。无法访问 data、props。
  • created:可访问数据,但 DOM 未挂载,适合异步请求、初始化数据。
  • mounted:DOM 已挂载,可操作 DOM,适合第三方库初始化。
  • beforeDestroy:销毁前,适合清除定时器、取消订阅。
  • activated / deactivatedkeep-alive 组件激活/停用。

17、$nextTick 原理及使用场景

原理

Vue 的异步更新队列。数据变化后,Vue 将开启一个队列,把同一个事件循环内的所有数据变化缓存起来,然后在下一个事件循环(microtask)统一执行 DOM 更新。$nextTick 的回调会在 DOM 更新完成后执行。

使用场景

  • 在数据变化后,需要获取更新后的 DOM 结构。
  • 需要在 mounted 钩子中确保子组件渲染完成。
  • 异步操作后需要等待 DOM 同步。

面试回答

$nextTick 利用 Promise 或 MutationObserver 等微任务机制,将回调延迟到下次 DOM 更新循环之后执行。我们常用来解决数据变化后立即操作 DOM 的问题,比如滚动到底部、获取元素宽高等。Vue 3 中同样有 nextTick 函数,可在组合式 API 中使用。

18、 keep-alive 实现原理及生命周期

作用

缓存不活动的组件实例,避免反复渲染。

原理

内部维护一个缓存对象(键是组件的 key 或自身),当组件切换时,将被移除的组件实例保留在缓存中,而不是销毁。再次激活时从缓存取出复用,触发 activated 和 deactivated 钩子。

相关属性

  • include / exclude:正则或数组,指定要缓存/不缓存的组件。
  • max:最大缓存数,超出时根据 LRU 策略删除。

生命周期

  • 首次进入:created → mounted → activated
  • 缓存后再次进入:activated(不会重新执行 created / mounted
  • 离开时:deactivated

面试回答

keep-alive 是一个抽象组件,它通过缓存 VNode 来保留组件状态,避免重复渲染。内部使用 LRU 算法管理缓存,可以通过 include 和 max 控制缓存策略。被缓存的组件会多出 activated 和 deactivated 钩子,用于在激活/停用时执行逻辑。

19、 组合式 API 与选项式 API 的优缺点

选项式 API(Vue 2 主流):

  • 优点:结构清晰(data、methods、computed 分块),适合初学者。
  • 缺点:逻辑分散,复杂组件难以维护;复用逻辑需借助 mixin,存在缺陷。

组合式 API(Vue 3 引入):

  • 优点:

    • 逻辑集中,按功能组织代码,可读性和可维护性高。
    • 逻辑复用简单,通过组合函数(hooks)实现,无命名冲突。
    • 更好的 TypeScript 类型推断。
  • 缺点:学习曲线稍陡,对初学者不够直观。

面试回答

“选项式 API 将组件选项按类型划分,代码直观但逻辑分散。组合式 API 将相关逻辑聚合在 setup 中,通过组合函数实现复用,尤其适合大型复杂组件。Vue 3 并未废弃选项式 API,两者可混用,但组合式 API 提供了更好的逻辑复用能力和类型支持,是未来的推荐写法。”

20、SSR 原理及优缺点

原理

  • 服务端运行 Vue 应用,生成 HTML 字符串直接返回给浏览器,客户端再“激活”(hydrate)为可交互应用。
  • 同构:同一份代码在服务端和客户端均可运行。

优点

  • 更好的 SEO:搜索引擎能抓取完整 HTML。
  • 更快的首屏加载:用户无需等待 JS 下载即可看到内容。

缺点

  • 开发复杂度高:需考虑 Node.js 环境兼容性。
  • 服务器负载大:每个请求都重新渲染,需注意缓存策略。
  • 部分 API 在服务端不可用(如 window、document),需条件判断。

面试回答

“SSR 在服务端将 Vue 组件渲染成 HTML,发送给客户端,然后客户端进行激活。它主要解决 SPA 的 SEO 问题和首屏加载速度。但实现成本较高,需要处理服务端和客户端环境的差异,并关注服务器性能。通常我们会借助 Nuxt.js(Vue 2)或 Nuxt 3(Vue 3)这样的框架来简化 SSR 开发。”

21、Vue 3 新特性及与 Vue 2 的区别

核心新特性

  • 组合式 API:更好的逻辑复用和代码组织。
  • Proxy 响应式:解决 Vue 2 的响应式局限,性能更优。
  • Teleport:将组件内容渲染到任意 DOM 位置。
  • Fragment:组件支持多个根节点。
  • Suspense:用于异步组件加载时的占位。
  • 全局 API 改造createApp 替代 new Vue,全局配置隔离。
  • 更好的 TypeScript 支持:源码用 TS 重写,类型更完善。
  • 性能提升:编译优化(静态提升、patch flag),打包体积更小。
  • Vite 官方构建工具:开发体验极大提升。

破坏性变更

  • 移除过滤器、$children$on/$once/$offv-on.native 等。
  • v-model 默认 prop 和事件变化,支持多个绑定。
  • v-if 与 v-for 优先级改变。

面试回答

“Vue 3 相比 Vue 2 在响应式系统、组合式 API、性能、TypeScript 支持等方面有重大改进。它引入了 Teleport、Suspense 等内置组件,并用 createApp 创建应用,避免全局污染。虽然有一些破坏性变更,但官方提供了迁移构建和工具帮助升级。Vue 3 也带来了更现代的构建工具 Vite,提升了开发体验。”

22、常见API使用方式 defineEmits、defineExpose、defineOptions、defineProps、

1. defineProps – 接收父组件传递的数据

作用:声明组件的 props(属性),代替传统的 props 选项。

基本用法

<script setup>
// 运行时声明(自动推断类型)
const props = defineProps(['title', 'count'])

// 带类型的声明(TypeScript)
const props = defineProps<{
  title: string
  count?: number   // 可选
}>()
</script>

父组件使用

<MyComponent title="Hello" :count="10" />

2. defineEmits – 向父组件发送事件

作用:声明组件可以触发的事件。

<script setup>
// 简单声明
const emit = defineEmits(['update', 'delete'])

// 带参数验证(TypeScript)
const emit = defineEmits<{
  (e: 'update', id: number): void
  (e: 'delete', name: string): void
}>()

// 触发事件
emit('update', 123)
</script>

父组件监听

<MyComponent @update="handleUpdate" @delete="handleDelete" />

3. defineExpose – 暴露组件内部属性/方法给父组件(通过 ref)

作用:默认 <script setup> 下的组件是关闭的,父组件无法通过 ref 访问其内部成员。使用 defineExpose 明确暴露。

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

const count = ref(0)
const increment = () => count.value++

// 只暴露 increment 和 count,其他不暴露
defineExpose({
  increment,
  count
})
</script>

父组件访问

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

<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref()

onMounted(() => {
  childRef.value.increment()   // 调用子组件方法
  console.log(childRef.value.count) // 读取子组件数据
})
</script>

4. defineOptions – 设置组件选项(Vue 3.3+)

作用:在 <script setup> 中声明组件名、继承属性、自定义选项等,无需单独的 <script> 块。

<script setup>
defineOptions({
  name: 'MyCustomName',      // 组件名称
  inheritAttrs: false,       // 是否继承非 prop 属性
  // 其他选项(如 components、directives 一般不在这里,但可自定义)
})
</script>

典型场景

  • 设置组件名(方便 Vue Devtools 识别)
  • 关闭属性继承(手动控制 $attrs

23、wacth & watchEffect 区别

特性 watch watchEffect
依赖收集 显式指定要监听的数据源(ref、reactive 属性、getter 函数) 自动收集回调函数中使用的所有响应式数据
初始执行 默认不执行,数据第一次变化时才执行(可配置 immediate: true 立即执行一次,同时收集依赖
访问新旧值 回调中提供旧值和新值 只能访问新值(无法直接获取旧值)
监听多个源 支持同时监听多个数据源(数组形式) 自动收集多个依赖,无需显式指定
精准控制 可以配置 deepflushimmediate 等选项 只有 flush 选项(以及 onTrack/onTrigger 调试)
停止监听 调用返回的函数 同样返回停止函数
适用场景 需要知道具体哪个数据变化、需要旧值、需要惰性执行 简单副作用,自动跟踪依赖,不需要旧值

1、watch 基础用法

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

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 监听单个 ref
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`)
})

// 监听 getter 函数
watch(
  () => state.age,
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

// 监听多个源(数组)
watch([count, () => state.age], ([newCount, newAge], [oldCount, oldAge]) => {
  console.log(`count: ${oldCount}->${newCount}, age: ${oldAge}->${newAge}`)
})

// 立即执行 + 深度监听
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('state 变化了', newVal)
  },
  { immediate: true, deep: true }
)
</script>

2、watchEffect 基础用法

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

const count = ref(0)
const state = reactive({ name: 'Vue', age: 3 })

// 自动收集依赖:count 和 state.age
watchEffect(() => {
  console.log(`count: ${count.value}, age: ${state.age}`)
})
// 初始立即输出:count: 0, age: 3
// 之后任何依赖变化都会重新执行

// 停止监听
const stop = watchEffect(() => { /* ... */ })
stop() // 手动停止
</script>

24、provide() 和 inject() 跨层级组件通信例子

<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from 'vue'

// 提供普通值
provide('theme', 'dark')

// 提供响应式数据(推荐)
const count = ref(0)
const updateCount = () => count.value++
provide('count', count)
provide('updateCount', updateCount)
</script>
<!-- Descendant.vue -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme', 'light') // 默认值 'light'
const count = inject('count')
const updateCount = inject('updateCount')

// 使用
console.log(theme)   // 'dark'
count.value++        // 响应式更新
updateCount()        // 调用方法
</script>

25、toRefs、toRef、isRef、unref 响应式引用工具

1. toRefs – 将响应式对象转换为普通对象,每个属性都是 ref

作用:解构 reactive 对象时保持响应性。

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })

// ❌ 直接解构会丢失响应性
let { count, name } = state
count++  // 不会触发视图更新

// ✅ 使用 toRefs 包装
const stateRefs = toRefs(state)
const { count, name } = stateRefs
count.value++ // 响应式生效

原理toRefs 为每个属性创建一个 ref 链接到原对象的对应属性。

2. toRef – 为响应式对象的单个属性创建 ref

作用:保持对源对象属性的响应式引用,常用于将 props 的某个属性转为 ref 以便传递。

import { reactive, toRef } from 'vue'

const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')

countRef.value++   // 同时修改 state.count
console.log(state.count) // 1

典型场景:组合函数接收 props 中的某个属性并保持响应性。

// useFeature.js
import { toRef, watchEffect } from 'vue'
export function useFeature(propRef) {
  const propVal = toRef(propRef)  // 确保是 ref
  watchEffect(() => {
    console.log(propVal.value)
  })
}

3. isRef – 判断某个值是否为 ref

import { ref, reactive, isRef } from 'vue'

const count = ref(0)
const state = reactive({})
console.log(isRef(count)) // true
console.log(isRef(state)) // false

4. unref – 如果参数是 ref 则返回其 value,否则返回参数本身

作用:方便地获取值,无需手动判断 .value

import { ref, unref } from 'vue'

const count = ref(0)
const plain = 42

console.log(unref(count)) // 0
console.log(unref(plain)) // 42

// 等价于
function myUnref(val) {
  return isRef(val) ? val.value : val
}

常用场景:在组合函数中,参数可能是 ref 也可能是普通值,使用 unref 统一处理。

React

1. React 是什么?核心特点

React 是 Meta 开源的 JavaScript UI 库,专注于构建用户界面。核心特点

  • 声明式编程:描述 UI 状态,React 自动处理 DOM 更新。
  • 组件化:UI 拆分为独立可复用的组件。
  • 单向数据流:数据从父组件流向子组件,可预测、易调试。
  • 虚拟 DOM + Fiber:高效更新,可中断渲染。
  • JSX:JavaScript 语法扩展,允许在 JS 中写类似 HTML 的标记。

2. JSX

JSX 是 React.createElement 的语法糖

// JSX 写法
const element = <h1 className="title">Hello React</h1>;

// 编译后
const element = React.createElement('h1', { className: 'title' }, 'Hello React');

浏览器无法识别 JSX,需要通过 Babel 编译为普通 JS 代码才能执行

3、组件间通信

React 组件间通信方式取决于组件关系,主要方式如下

方式 适用场景 示例
Props 父→子传递数据 <Child message={msg} />
回调函数 子→父传递数据 父传函数给子,子调用 props.onChildData(data)
Context API 跨层级组件通信(避免 props 逐层传递) createContext + useContext
状态管理库 大型应用全局状态 Redux Toolkit、Zustand、Jotai
Refs 直接访问 DOM 或子组件实例 useRef + ref 属性
自定义 Hooks 复用状态逻辑 useAuthuseFetch 等
高阶组件(HOC) 共享逻辑(较少使用,Hooks 更优) 接收组件返回增强组件
Event Bus(事件总线) 任意组件通信(非 React 原生) mitt 等第三方库

4、API

4.1 内置 Hooks

Hook 说明
useState 在函数组件中添加和管理局部状态,返回当前状态和更新函数
useReducer 用于管理包含多个子值或依赖先前状态的复杂组件逻辑,基于 reducer 模式
useContext 读取并订阅组件中的 Context 值,避免 props 逐层传递
useRef 声明一个可变引用,可以保存任何可变值,最常见的用途是访问 DOM 元素
useImperativeHandle 自定义通过 ref 暴露给父组件的实例值,通常与 forwardRef 配合使用
useEffect 将组件连接到外部系统并处理副作用,如数据获取、订阅、手动 DOM 操作,在渲染后执行
useLayoutEffect 在浏览器重新绘制屏幕之前同步触发,用法与 useEffect 相同,但会阻塞视觉更新
useInsertionEffect 在 DOM 变异之前触发,专为 CSS-in-JS 库注入样式而设计
useMemo 缓存昂贵计算的结果,避免在每次渲染时重复计算,仅在依赖项变化时重新计算
useCallback 缓存函数定义,防止因函数重新创建导致的子组件不必要重新渲染
useTransition 将状态更新标记为"过渡",这种更新可以被中断,以避免阻塞用户界面
useDeferredValue 延迟更新 UI 的某一部分,以优先响应用户输入
useId 生成在客户端和服务器上保持稳定的唯一 ID,主要用于可访问性属性
useDebugValue 在 React DevTools 中为自定义 Hook 添加标签,用于调试
useSyncExternalStore 允许函数组件订阅外部 store(如第三方状态管理库或浏览器 API)
useOptimistic 允许在后台操作完成前乐观地更新 UI,提供即时反馈
useActionState 管理表单 action 的状态,包括 pending 状态和返回数据
use 通用的资源读取 API,用于读取 Promise 或 Context 等资源的值,可以在条件语句中调用

4.2 内置组件

这些是可以在 JSX 中使用的 React 内置组件,以 Symbol 常量形式导出

组件 说明
<Fragment> 让你无需向 DOM 添加额外节点即可对子元素列表进行分组,支持简写 <>...</>
<Profiler> 用于编程式测量 React 应用的渲染性能
<StrictMode> 用于检测应用中潜在问题的工具,不会渲染任何可见 UI
<Suspense> 允许在子组件完成加载前显示一个回退 UI
<Activity> React 19 新增 API,用于隐藏和恢复其子组件的 UI 和内部状态

4.3 工具类 API

API 说明
createContext 创建一个 Context 对象,可供组件向其子组件提供数据,搭配 useContext 使用
forwardRef 允许组件将 DOM 节点作为 ref 暴露给父组件,搭配 useRef 使用
lazy 允许在组件第一次被渲染前延迟加载其代码,实现代码分割
memo 允许组件在 props 未发生变化时跳过重新渲染,搭配 useMemo 和 useCallback 使用
startTransition 允许将状态更新标记为非紧急的,与 useTransition 类似但用于非 Hook 场景
act 在测试中用于包裹渲染和交互,确保在断言前所有更新已处理完毕
createElement 创建 React 元素,通常用 JSX 替代,但可在非 JSX 环境中使用
cloneElement 克隆并返回一个新的 React 元素,可覆盖原元素的 props
isValidElement 检查某个值是否为 React 元素
Children 提供 mapforEachcountonlytoArray 等工具方法,用于处理 props.children 不透明数据结构
Component 定义类组件的基类
PureComponent 与 Component 类似,但自带 shouldComponentUpdate 浅比较实现
createRef 创建 ref 对象,类组件中用于访问 DOM 元素

4.4 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

4.5 资源预加载 API

这些 API 用于预加载脚本、样式表、字体等资源,从而让应用更快。基于 React 的框架通常会自动处理资源加载

API 说明
prefetchDNS 预解析 DNS 域名,提前获取 IP 地址,减少后续请求的 DNS 查询时间
preconnect 提前连接到预计请求资源的服务器,建立 TCP 连接和 TLS 握手,即使尚不确定具体需要哪些资源
preload 预获取并缓存预计要使用的资源(如样式表、字体、图片、外部脚本),但不执行,可节省时间
preloadModule 预获取预计要使用的 ESM 模块,但不执行
preinit 预获取并执行外部脚本,或预获取并插入样式表
preinitModule 预获取并执行一个 ESM 模块

4.6 通用 DOM API

API 说明
createPortal 允许将子组件渲染到 DOM 树中父组件 DOM 层次之外的不同位置,常用于模态框、全局提示等
flushSync 强制 React 同步执行状态更新并立即刷新 DOM

5、React Router(路由)

React Router v6 完全利用 Hooks 重构。

核心组件

组件 作用
BrowserRouter history 模式路由容器
HashRouter hash 模式路由容器
Routes / Route 定义路由规则
Link / NavLink 声明式导航
Outlet 嵌套路由占位符

核心 Hooks

Hook 作用
useParams 获取路由参数
useLocation 获取当前 location 对象
useNavigate 程序化导航
useRoutes 配置式路由(替代 Routes + Route

6、React-Redux

API 说明
<Provider store> 顶层组件,使 store 对下层组件可用。
connect(mapStateToProps, mapDispatchToProps, mergeProps, options) 高阶组件(HOC),将 store 中的 state 和 dispatch 映射到组件的 props。
useSelector(selector) Hook,从 store 中提取数据,当数据变化时强制组件重新渲染。
useDispatch() Hook,返回 store 的 dispatch 函数。
useStore() Hook,返回 store 实例本身(不常用)。

Redux 现代推荐: (Redux Toolkit + React-Redux Hooks )

必须掌握的核心 API

API 作用 一句话说明
configureStore 创建 store 像 createStore 但更智能,自动加 thunk 和 DevTools
createSlice 同时生成 reducer 和 action creators 传入 name、initialState、reducers 对象,自动生成
createAsyncThunk 处理异步 action 自动生成 pending/fulfilled/rejected 三个 action,并在 extraReducers 中处理
// store.js
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// 1. 同步 slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1 },     // 直接“修改”
    decrement: state => { state.value -= 1 },
    incrementByAmount: (state, action) => { state.value += action.payload }
  }
});

// 2. 异步 thunk
export const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
  const res = await fetch(`/api/user/${userId}`);
  return res.json();
});

// 3. 异步 slice (处理 thunk 的三种状态)
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null, loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = true })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  }
});

// 导出 action creators 和 reducer
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    user: userSlice.reducer
  }
});

export default store;

React-Redux Hooks —— 在组件里用状态

两个核心 Hook

Hook 作用 类比
useSelector 从 store 中读取数据 类似 mapStateToProps
useDispatch 拿到 dispatch 函数 类似 mapDispatchToProps

使用步骤(配合上面的 store)

1. 顶层用 Provider 包裹

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

2. 组件内读取和派发

// Counter.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './store';

function Counter() {
  // 读取状态
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

3. 异步 thunk 的派发

import { fetchUser } from './store';

function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { data, loading, error } = useSelector(state => state.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{data?.name}</div>;
}

7、React 底层原理

1. 虚拟 DOM

虚拟 DOM 是用 JS 对象描述真实 DOM 结构

好处

  • 减少频繁的真实 DOM 操作
  • 跨平台(React Native)
  • 便于 diff 算法计算差异

2. Diff 算法

核心策略

  • 同层比较,不跨层级
  • 类型不同 → 直接销毁重建
  • 类型相同 → 对比属性
  • 列表靠 key 识别节点复用,复杂度优化到 O(n)

为什么 key 不能用 index?
数组增删前置元素会导致 index 错乱,引发组件状态错位、DOM 复用错误。应使用唯一稳定的业务 id

3. Fiber 架构(React 16+)

解决旧 React 一次性渲染卡死主线程的问题

两个阶段

  • Render 阶段:可中断、分片、优先级调度,遍历构建 Fiber 树
  • Commit 阶段:不可中断,一次性更新 DOM、布局、绘制

4. React 更新流程

触发 setState → 生成更新任务 → Fiber 调和(可中断)→ 收集 DOM 变更 → Commit 一次性渲染 → 浏览器绘制

5. 合成事件

React 自己实现了一套事件系统(事件委托到 root 节点),性能高,与原生事件混用时,原生先执行 → 合成后执行,阻止冒泡互不生效

vue3响应式机制的理解

深入理解 Vue3 响应式机制

1. 为什么需要响应式?

在传统的 jQuery 开发中,数据变化后需要手动操作 DOM 更新视图:

let count = 0
$('#btn').click(() => {
  count++
  $('#count').text(count)   // 手动更新
})

这样做的问题:显然代码繁琐,逻辑分散,难以维护

Vue 的响应式系统解决了这个问题:数据变化 → 视图自动更新。
开发者只需要关注数据,剩下的交给 Vue。

2.从 vue2 的响应式原理开始

2.1 核心:Object.defineProperty

Vue2 通过 Object.defineProperty 劫持对象的属性读写。

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      console.log(`读取 ${key}:`, val)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        console.log(`设置 ${key}:`, newVal)
        val = newVal
        // 触发视图更新
      }
    }
  })
}

const obj = { name: '张三' }
defineReactive(obj, 'name', obj.name)
obj.name = '李四'   // 触发 set
console.log(obj.name) // 触发 get

2.2 Vue2 的痛点

问题 原因 解决方案
无法监听新增属性 defineProperty 需要预先定义属性 Vue.set(obj, 'newProp', value)
无法监听删除属性 没有 delete 拦截 Vue.delete(obj, 'prop')
数组索引赋值不更新 arr[0] = 1 不会触发 setter 使用 $set 或重写的数组方法
修改数组 length 不更新 arr.length = 0 无拦截 使用 arr.splice(0)
初始化性能差 需要递归遍历所有属性 无解,Vue3 用 Proxy 解决

2.3 Vue2 如何处理数组?

Vue2 重写了数组的 7 个变异方法:push, pop, shift, unshift, splice, sort, reverse
当你调用这些方法时,Vue 能感知到变化并更新视图。但直接通过索引修改或修改 length 就无法检测。

// Vue2 中
this.arr[0] = 1      // 不更新
this.arr.length = 0  // 不更新
this.arr.push(1)     // 更新
this.$set(this.arr, 0, 1)  // 更新

3. Vue3 的响应式原理:Proxy 全面升级

3.1 核心流程(一句话概括)

用 Proxy 代理数据,读取时收集依赖(track),修改时派发更新(trigger)。

整个流程拆解为 4 步:

  1. reactive() 将普通对象包装成 Proxy 代理对象
  2. 当读取属性时,get 拦截器调用 track,记录“当前正在执行的副作用函数(effect)”
  3. 当修改属性时,set 拦截器调用 trigger,找出所有依赖该属性的 effect,逐个执行
  4. 执行 effect 时重新读取属性,再次触发 track,形成闭环

流程图:

3.3 track与trigger 的最小实现(理解依赖收集的核心)

javascript

let activeEffect = null                 // 当前正在执行的副作用函数
const targetMap = new WeakMap()         // 存储所有对象的依赖关系

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

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (dep) dep.forEach(effect => effect())
}

4. Vue2 vs Vue3 响应式对比

对比维度 Vue2 (Object.defineProperty) Vue3 (Proxy)
拦截能力 只能拦截 get / set 可拦截 13 种操作(get, set, delete, has, ownKeys...)
新增属性 无法监听,需 $set 直接赋值 obj.newProp = 1 即可
删除属性 无法监听,需 $delete 直接 delete obj.prop 即可
数组索引修改 arr[0]=1 不更新 可更新
数组 length 修改 arr.length=0 不更新 可更新
初始化性能 递归遍历所有属性,对象越大越慢 惰性代理,访问到才处理,初始化快
支持数据结构 普通对象、数组(需 hack) 对象、数组、Map、Set、WeakMap 等
代码复杂度 需要递归、重写数组方法、单独处理新增/删除 逻辑统一在 Proxy handler 中

5. ref 与 reactive 详解

5.1 核心困惑:为什么不能只用 reactive

直接原因Proxy 只能代理对象,不能代理基本类型(数字、字符串、布尔、null、undefined)。这是因为 Proxy 的设计本质是拦截对象的属性访问、修改等行为,而基本类型是“值类型”,不是对象,没有任何可拦截的属性,无法完成代理逻辑。
如果你写 reactive(0),Vue 会报错。

实际开发场景:我们经常需要管理一个计数器、一个开关状态,这些是基本类型。所以必须有一个方案来处理基本类型的响应式。

5.2 ref 的本质:单值响应式包装器

ref`的核心作用:把任意类型的值(基本类型 / 对象)包装成一个带 value访问器的响应式对象。

真实简化原理(接近 Vue 源码)
class RefImpl {
  constructor(rawValue) {
    this._rawValue = rawValue // 原始值
    this._value = rawValue    // 响应式值
    this.__v_isRef = true     // 标记是 ref
  }

  get value() {
    // 收集依赖
    track(this, 'value')
    return this._value
  }

  set value(newVal) {
    // 更新 + 触发更新
    this._rawValue = newVal
    this._value = toReactive(newVal)
    trigger(this, 'value')
  }
}

  function ref(value) {
    return new RefImpl(value)
  }

5.3 对比表格

特性 reactive ref
支持数据类型 对象、数组 任意类型(基本类型 + 对象)
返回结构 Proxy 代理对象 RefImpl 实例(带 .value)
访问方式 直接访问属性 state.xxx 必须用 .value
底层实现 ES6 Proxy class + getter/setter
响应式范围 深度响应式 单层响应式,对象自动走 reactive
解构丢失响应 否(因为始终是同一个 ref 对象)

5.5 常见误区与正确理解

误区1: ref 是专门给基本类型用的,对象必须用 reactive
事实: ref 也可以接收对象,内部会调用 reactive。所以你可以全程用 ref,只是要写很多 .value

误区2: reactive 返回的对象和原对象不一样,ref 返回的对象和原值也不一样。
事实: 两者都返回代理对象。reactive 代理原对象;ref 代理包装对象。

误区3: ref.value 是多余的。
事实: 因为 ref 的本质是 { value } 对象的代理,所以必须通过 .value 访问包装对象内部的属性。这是语法代价,换来了对基本类型的支持。

6. 常用响应式 API 速查表

API 用途 示例
reactive 创建响应式对象/数组 const state = reactive({ count: 0 })
ref 创建响应式基本类型(或对象) const count = ref(0)count.value++
computed 计算属性(缓存) const double = computed(() => state.count * 2)
watch 监听指定数据源 watch(() => state.count, (val) => {...})
watchEffect 自动收集依赖,立即执行 watchEffect(() => console.log(state.count))
toRefs 解构时保持响应式 const { name } = toRefs(state)

6.1 computed和watch的区别

  • computed:懒加载,产生新值,有缓存,如果依赖不变调用缓存不重新计算,适用于过滤列表
  • watch:执行副作用,无缓存,可以获取新旧值,适用于异步请求

6.2 watch和watchEffect

  • watch:手动指定监听源,懒执行,除非immediate:true
  • watchEffect:函数内所有的响应式数据都被自动收集,立即执行,不能获取旧值

7. 经典面试题

7.1 为什么 Vue2 不能检测数组索引和 length 变化?

因为 Object.defineProperty 无法拦截这些操作。Vue2 只能通过重写数组方法(push/pop 等)来 hack,但直接 arr[0]=1arr.length=0 无法检测到。

7.2 Vue3 如何解决数组问题?

Proxyset 拦截器可以捕获所有属性设置,包括数字索引和 length。所以直接修改即可触发更新。

7.3 ref 为什么需要 .value?能去掉吗?

不能去掉。因为 ref 返回的是一个包装对象 { value } 的代理,要访问内部的值就必须通过 .value。模板中不需要是因为编译器自动添加了 .value

7.4 下面代码中,修改 state.count 会触发视图更新吗?

const state = reactive({ count: 0 })
let { count } = state
count = 1

不会。因为解构后的 count 是普通数字,不再响应式。需要使用 toRefs

const { count } = toRefs(state)
count.value = 1   // 正确触发更新

8. 总结一句话

Vue2 用 defineProperty 劫持属性,有诸多限制;Vue3 用 Proxy 全面代理,配合 track/trigger 实现响应式。reactive 直接代理对象,ref 包装基本类型后再代理,两者本质相通。记住:对象用 reactive,基本类型用 ref,解构用 toRefs

Vue3.5设计理念和响应式原理(下)

computed 实现原理

// 实例
const state = reactive({ name: "zoyi" });

const aliasName = computed(() => {
  console.log("getter 执行");
  return "**" + state.name;
});

effect(() => {
  console.log("外层 effect 执行");
  console.log(aliasName.value);
});

state.name = "star zoyi";

初始化

  1. 执行到 computed(getter) 时,返回ComputedRefImpl(getter)实例 aliasName:创建内部的 ReactiveEffect(getter, scheduler);实例(aliasName).value 是可 get/set 的响应式。
export class ComputedRefImpl {
  constructor(getter: () => any) {
    this.effect = new ReactiveEffect(getter, () => {
      //...
    });
  }
}
  1. 执行 effect(fn),创建外层 effect 实例,将 fn 添加至 schedule 中并执行。
  2. 打印 外层 effect 执行。 执行 aliasName.value ===> 触发内部 effect 的 get value。
  3. 在 getter 若有 activeEffect(外部 effect.run() 时保存的 activeEffect),把外层 effect 记进 aliasName.dep。
get value() {
  // 外层 effect 读取计算属性时,把外层 effect 记到本 ref 的 dep 上
  this.trackComputed();
  if (this._dirty === DirtyLevels.Dirty) {
    this._dirty = DirtyLevels.NoDirty;
    this._value = this.effect.run();
  }
  return this._value;
}

/** 收集「谁依赖了这个计算属性」 */
  private trackComputed() {
    if (!activeEffect) {
      return;
    }
    this.dep ??= createDep(() => {
      this.dep = undefined;
    }, "computed");
    trackEffect(activeEffect, this.dep);
  }
  1. 第一次 _dirty 默认是脏,改为不脏,并执行 内部 effect.run()(即包含 computed 的 getter方法的运行器)。
  2. 更新 activeEffect 为内部 effect,执行 getter,打印 getter 执行return 中执行 state.name 触发 name 属性的 get,将此时 activeEffect = 内层effect,收集为依赖。返回 name = zoyi
  3. getter 中 return 计算后属性 @zoyi,将值缓存到 aliasName._value 上,aliasName.value 的 get value 执行完毕,并返回 _value
  4. 打印 @zoyi,外层 effect.run() 执行完毕。

此时关系是

state.name 的 dep → 内层 ReactiveEffect(计算属性的 scheduler)。 aliasName.dep → 外层 effect(读了 .value)。

更新阶段(Vue 3.4)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。

    set(target, key, value, recevier) {
     let oldValue = target[key];
     let result = Reflect.set(target, key, value, recevier);
    
     // 只有新旧值不一样才会触发更新
     if (oldValue !== value) {
       trigger(target, key, value, oldValue);
     }
    
     return result;
    }
    
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。

    • 但默认不会执行 run,只把 _dirty 设置为脏。
    • triggerEffects(aliasName.dep) → 外层 effect 的 scheduler 执行 → 外层 effect 再次 run()。
constructor(getter: () => any) {
  // 不在此构造函数里立即 run:首次访问 .value 时再求值,实现惰性。
  // scheduler:依赖变更时不立刻重算,只标脏并通知「读过我的人」去更新。
  this.effect = new ReactiveEffect(getter, () => {
    if (this._dirty === DirtyLevels.NoDirty) {
      this._dirty = DirtyLevels.Dirty;
    }
    if (this.dep) {
      triggerEffects(this.dep); // aliasName.dep
    }
  });
}
  1. 打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 发现 _dirty 为脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value,再标不脏。
  2. 打印 @star zoyi,结束更新

3.4版本

注意:在 Vue 3.5 中 computed 的更新阶段稍微有些变化

更新阶段(Vue 3.5)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。
  3. 此时发生了变化: 执行 refreshComputed -> 发现 _dirty 为脏,先清脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value
constructor(getter: () => any) {
  this.effect = new ReactiveEffect(getter, () => {
    // 3.5 风格:先置脏并同步重算,再通知下游(顺序与官方包一致)
    this._dirty = DirtyLevels.Dirty;
    this.refreshComputed();
    if (this.dep) {
      triggerEffects(this.dep); // 再执行外层 effect.run
    }
  });
}

/**
 * 若当前为脏,则执行内层 effect(getter),更新 _value 并清脏。
 */
private refreshComputed() {
  if (this._dirty !== DirtyLevels.Dirty) {
    return;
  }
  this._dirty = DirtyLevels.NoDirty;
  this._value = this.effect.run(); // 先执行 getter
}
  1. 再执行外层 effect.run,打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 已经计算过新的属性了,直接从 _value 中获取并返回。
  2. 打印 @star zoyi,结束更新。
get value() {
  // 收集计算属性(aliasName)的依赖,再保证缓存最新
  this.trackComputed();
  this.refreshComputed(); // _dirty 为不脏直接返回
  return this._value; // 已经计算过新的属性了,直接从_value中获取
}

3.5版本

watch 实现原理

watch(
  { state.name }, // source
  (prev, next, onCleanup) => { //cb
    console.log("触发回调函数")

    onCleanup(() => {
      console.log("清理副作用函数");
    });
  },
  {
    immediate: false, // 立即执行一次
    deep: false // 是否深度监听
  });

source 发生变化,触发 cb 的执行

即 watch 需要实现:完成 source (必须是响应式)对某个 effect 进行收集,在触发 scheduler 时,将 cb 加入到其中,将新旧值传入 cb 中。

function watch(source, cb, options?) {
  const { immediate = false, deep = false } = options;
  const getter = createWatchGetter(source, deep);

  let oldValue;
  let cleanup;

  // 初始化 effect,值变化时进行更新操作
  const _effect = new ReactiveEffect(getter, () => {
    const newValue = _effect.run(); // 获得最新的值

    if (cleanup) {
      cleanup();
      cleanup = undefined;
    }

    cb(newValue, oldValue, (fn) => {
      cleanup = fn;
    });

    oldValue = newValue;
  });

  oldValue = _effect.run();

  // 立马执行一次 cb
  if (immediate) {
    cb(oldValue, undefined, (fn) => {
      cleanup = fn;
    });
  }

  return () => {
    if (cleanup) {
      cleanup();
      cleanup = undefined;
    }
    stopEffect(_effect);
  };
}

createWatchGetter:将 source 变为可执行的 getter,支持对 source 中的响应式属性进行依赖收集

source 支持的类型:ref,reactive、数组(进行遍历)、函数

function createWatchGetter(source: unknown, deep: boolean): () => unknown {
  if (isRef(source)) {
    return () => (source as { value: unknown }).value;
  }
  if (typeof source === "function") {
    return source as () => unknown;
  }
  if (isArray(source)) {
    return () =>
      (source as unknown[]).map((s) => {
        if (isRef(s)) {
          return (s as { value: unknown }).value;
        }
        if (typeof s === "function") {
          return (s as () => unknown)();
        }
        return s;
      });
  }
  if (isReactive(source)) {
    // deep 为 true 则深度监听,否则只监听一层
    const maxDepth = deep ? undefined : 1;
    return () => traverse(source, maxDepth);
  }
  return () => source;
}

清理函数:onCleanup 是回调的第三个参数,用来注册「下一次将要执行回调之前」或「停止监听时」会先执行的清理函数。

// 示例
watch(
  () => state.id,
  (id, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });

    fetch(`/api/user/${id}`).then((res) => {
      if (!cancelled) {
        state.user = res;
      }
    });
  },
);

停止监听:watch 的返回值可以返回 stopEffect

/**
 * 停止副作用:从各 dep 中移除并清空依赖列表,之后不再被 trigger。
 */
export function stopEffect(effect: ReactiveEffect) {
  if (!effect.active) {
    return;
  }
  effect.active = false; // 激活状态改为 false
  const deps = effect.deps;
  for (let i = 0; i < deps.length; i++) { // 并清理 effect 上的 deps
    cleanDepEffect(deps[i], effect);
  }
  effect.deps.length = 0;
}

选项api:flush

  • pre(默认):在同一轮事件里稍后跑(通常仍在微任务里),多在组件重新渲染之前调度,方便你在 DOM 还没更新时读旧 DOM、或先改别的状态。
  • post:DOM 更新之后再跑,适合依赖已更新后的 DOM(例如 ref 量尺寸)。
  • sync:一触发依赖更新,就同步、立刻执行回调,不排到微任务、也不等组件更新阶段。

Vue3.5设计理念和响应式原理(上)

VUE 设计理念

  1. 声明式框架

    • 描述组件该长什么样子,不用关心具体怎么实现。
  2. 采用虚拟 DOM

    • 使用虚拟 DOM 作为声明式渲染到真实 DOM 的中间层
    • 直接操作真实 DOM 非常昂贵(性能开销大),而虚拟 DOM 是在 JS 层面进行计算和比较,再将批量更新应用回 DOM。它让 Vue 能以声明式的方式实现高效的 UI 更新,同时为跨平台(如 Weex、NativeScript)提供了可能。
  3. 编译时和运行时

    • 编译时: 工程化中使用 @vue/compiler-sfc 调用 @vue/compiler-dom 模块,将 SFC 中的模板编译为渲染函数。

    • 运行时:(@vue/runtime-core)负责创建组件实例、执行渲染函数、生成虚拟 DOM、对比并更新真实 DOM。

    在 Vue 3 中,运行时和编译时是解耦的:你可以直接手写渲染函数(不经过模板编译),也可以使用 JSX(通过插件编译)。但官方推荐的模板 + 编译时优化,能让运行时更轻量、更快速。

响应式实现方式的改变

Object.defineProperty:

  • 用于精确控制对象属性行为的方法。它可以定义一个新属性,或者修改一个已有属性,并允许设置该属性的描述符(如可枚举、可配置、可写等),其中最关键的存取描述符(get / set)正是实现对象劫持的基础
  • 直接修改原来对象,给对象的属性都添加 getter/setter 方法,进行读写时的劫持;
    • vue2 将一个普通 data 对象传入 Vue 实例时,Vue 会递归遍历该对象的所有属性(包括嵌套对象)。
    • 对每个属性调用 Object.defineProperty,替换其原有的属性描述符,加上自定义的 get 和 set。
    • 动态添加的属性不会自动劫持(需用 Vue.set)。
    • 对象属性删除(delete)无法被检测(需用 Vue.delete)。
  • 对数组操作
    1. 可以捕获到
      • 通过索引访问/赋值(如果预先为索引定义了 getter/setter) 数组索引本质上就是对象的属性名("0"、"1"等)。你可以用 Object.defineProperty 为某个索引添加存取描述符:
      • 劫持已有索引的赋值行为(包括通过原生方法隐式赋值) 如果某个索引已经定义了 setter,那么任何改变该索引值的操作(包括 arr[0]=x、arr.splice(0,1,10) 等)都会触发 setter。因为 splice 内部最终也是通过属性赋值修改索引。
    2. 不能捕获到
      • 数组的变异方法(push, pop, shift, unshift 等)

      • 修改 length 属性 数组的 length 属性默认是 不可配置(configurable: false) 且 不可枚举,因此无法通过 Object.defineProperty 重新定义它的 getter/setter

      • 动态新增的索引

      • 删除属性(delete arr[0])

    vue2 考虑到性能问题(数组可能很大,一个一个劫持索引有很大消耗),就不做监听,但是对数组中对象的属性会对它内部属性进行监听。 vue2 中重写了 push/pop 等 7 个数组方法,手动触发响应式。

Proxy

  • Proxy 是 ES6 引入的一个新特性,可以拦截并重新定义对象的基本操作(如属性读写、增删、读写原型、函数调用、描述符相关等)
  • 原生 Proxy 对数组没有特殊分支:数组只是 target;读写下标和 length、以及方法触发的多次内部赋值,都会按你实现的 trap 规则执行;
    • 一次方法调用 ≠ 一次 trap,push/splice 等会在引擎内部触发多次 set
    • Vue 3 在数组上的补丁,核心是 7 个变异方法(统一触发与避免误 track)+ includes / indexOf / lastIndexOf(补全依赖与修正比较),都通过 Proxy get 分发到 instrumentations,而不是污染全局 Array。
  • 在 vue3 中
    • 保持代理的引用:在整个应用中,应始终使用由 reactive 或 ref 返回的代理对象进行数据操作,而不是操作原始对象,否则响应性会丢失。
    • 解构会丢失响应性:直接解构 reactive 对象会使其失去响应式能力。可以使用 toRefs 或 toRef API 将其转换为 ref 来保持响应性。

get 中为什么不要使用 target[key]receiver[key] 要用 Reflect

const obj = {
  a: 1,
  get b() {
    return this.a;
  },
};
  • 如果用 target[key] 取 b,this 指向原对象 obj,内部访问 this.a 会绕过代理,可能导致依赖收集不完整。
  • receiver 通常就是 Proxy 实例本身。当你读取 receiver[key] 时,会再次触发当前 Proxy 的 get 陷阱,导致无限递归,最终栈溢出。
  • Reflect.get(..., receiver),内部实现区分了“读取属性”和“调用 getter”这两个步骤,this 绑定到代理对象 receiver,this.a 会再次走代理 get,依赖才能正确追踪。

响应式实现原理

reactive:定义响应式对象

  • 将数据变为响应式的,数据修改后检测到数据发生改变,从而让页面重新渲染
  • 每一个由 reactive 包裹的对象,都返回一个 proxy 对象,对 get/set 进行拦截。
export function reactive(target) {
  return createReactiveObject(target);
}

function createReactiveObject(target) {
  // 检测target是否为对象
  if (!isObject(target)) {
    return target;
  }

  // 放置代理过的对象重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 优化:同一个对象只能代理一次
  const existProxy = reactiveMap.get(target);
  if (existProxy) {
    return existProxy;
  }

  let proxy = new Proxy(target, mutableHandlers);
  reactiveMap.set(target, proxy);
  return proxy;
}

// in mutableHandlers
export const mutableHandlers: ProxyHandler<any> = {
  /**
   *
   * @param target 代理目标对象
   * @param key 获取的哪个属性
   * @param recevier 返回的代理对象
   * @returns
   */
  get(target, key, recevier) {
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true; // 响应式 get 的结果
    }
    // Reflect 让this指向Proxy对象(recevier),避免重复触发get,导致死循环。
    let res = Reflect.get(target, key, recevier);

    // *当取得的值也是对象的时候,对这个对象进行递归代理
    if (isObject(res)) {
      return reactive(res);
    }
    return res;
  },

  set(target, key, value, recevier) {
    let result = Reflect.set(target, key, value, recevier);
    return result;
  }
}

effect:副作用函数

  • 数据变化后 可以让 effect 重新执行,组件,watch、computed、都是基于 effect 来实现的
  • 在 Vue3 中,每个组件的模板编译成的渲染函数,会被一个内部的 effect(称为“渲染 effect”或“组件更新 effect”)自动包裹。
  • 属于底层API,编写 Vue 插件或构建自定义响应式系统,作为框架底层使用。普通业务开发基本用不到。
  • effect 会将里面的响应式数据进行关联
// state 为响应式数据
// effect1
effect(() => {
  app.innerHTML = `姓名${state.name} 年龄${state.age}`;
});
// effect2
effect(() => {
  main.innerHTML = `姓名${state.age}`;
});

state.age++;

步骤:

  1. 执行 effect 函数,会生成一个 effect 实例,运行 effect.run()。
  2. run(): 会将 effect 实例放入到全局,并调用 fn(effect 中的回调)执行。
  3. 执行到 state.name 触发 name 的 get。完成依赖收集器 dep(name)依赖(effect1) 的收集。
  4. 同理,后面 dep(age)会对 effect1 和 effect2 进行挨个收集。
// 依赖收集的数据结构(三 Map 结构)
targetMap (WeakMap) : {
  // 原始对象
  '{name: '', age: ''}' : {
    // 依赖收集器 dep(name)
    'name':{
      effect1: effect1._trackId
    },
    // 依赖收集器 dep(age)
    'age': {
      effect1: effect1._trackId,
      effect2: effect2._trackId
    }
  }
}

effect1._trackId:指的是当前 effect 的执行次数,相同 effect 中 trackId 的值相同

  1. 并将 dep 添加到 effect 上的 deps 数组,实现 响应式 和 依赖 的双向收集(循环引用)
effect.deps[effect._depsLength++] = dep;
  1. 在 执行到 state.age++ 后,触发代理对象 age 的 set,并执行 trigger,将 age 的依赖(effect1、effect2)取出依次执行。
// 触发更新
export function triggerEffects(dep) {
  // 将映射表中的effect拿出来依次执行
  for (const effect of dep.keys()) {
    if (effect.scheduler && effect._runner === 0) {
      effect.scheduler(); // -> _effect.run() -> 重新执行 fn
    }
  }
}

effect._runner: 是防止 effet 中触发响应式set的标识,为 0 表示没有 effect 在执行中。可以进行触发依赖的执行更新。

其他问题

1. 条件渲染

// state 为响应式数据 flag = true
effect(() => {
  app.innerHTML = state.flag ? state.name : state.age;
});

state.flag = false;
  1. effect 执行前的前置清理
function preCleanEffect(effect) {
  effect._depsLength = 0; // 身上的依赖收集器数组的长度置空
  effect._trackId++; // 每次执行前 trackId 都加1,如果同一个 effect 执行,trackId 就是相同的
}
  1. 在第一次执行挨个添加依赖收集器 dep(flag,name),并将其保存到 effect.deps
  2. flag 发生改变,触发 set 重新执行 effect。
  3. 先添加收集器 flag,与之前保存的deps中的第一个dep进行比对,发现相同,则复用。
  4. 再添加收集器 age,与之前第二个 dep 进行对比,发现不同,删除老dep(name)中的此次依赖(effect),删除后若发现 dep(name)为空,则删除dep(name)。并将新的dep(age),放到depsLength = 2 的位置。

    [flag, name] ===> [flag, age]

export function trackEffect(effect, dep) {
  // 相同 trackId 则跳过收集
  if (dep.get(effect) !== effect._trackId) {
    // 收集到相同的依赖,只更新 trackId 的次数
    dep.set(effect, effect._trackId);

    let oldDep = effect.deps[effect._depsLength]; // 获取上次的旧 dep

    if (oldDep !== dep) {
      if (oldDep) {
        // 删除老的
        cleanDepEffect(oldDep, effect);
      }
      effect.deps[effect._depsLength++] = dep; // 永远按照本次**最新**的来存
    } else {
      effect._depsLength++;
    }
  }
}

function cleanDepEffect(dep, effect) {
  dep.delete(effect);
  if (dep.size === 0) {
    dep.cleanup(); // 如果map为空,则删除这个属性
  }
}
  1. 执行完 effect 后的清理,以维护的 _depsLength 为准,清理掉多余的 dep。
function postCleanEffect(effect) {
  if (effect.deps.length > effect._depsLength) {
    for (let i = effect._depsLength; i < effect.deps.length; i++) {
      cleanDepEffect(effect.deps[i], effect); // 删除映射表中对应的effect
    }
    effect.deps.length = effect._depsLength; // 更新依赖列表的长度
  }
}

2. 嵌套 effect 的依赖收集的实现

// 实例:effect1
effect(() => {
  effect(() => {}); // effect2
});
// -------------------

// 全局上保存当前执行的 effect
let activeEffect;

// run方法
run() {
  let lastEffect = activeEffect; // *
  try {
    this._runner ++;
    activeEffect = this;
    preCleanEffect(this);
    return this.fn();
  } finally {
    postCleanEffect(this);
    activeEffect = lastEffect;
    this._runner --;
  }
}
  1. 老的版本,使用来实现,执行 effect1 进栈,执行 effect2 进栈,收集完毕挨个出栈,栈顶则是当前的 activeEffect。
  2. 新版本,用 lastEffect 记录上一次的 effect 实例,结束后再重新复制给当前 activeEffect。

3. effect 的调度执行

  • effect 可以传入 scheduler 选项,控制响应式数据变化时是 立即执行 fn 还是走 自定义调度(如 watch 的 flush)
// 做法
const runner = effect(
  () => {
    app.innerHTML = `姓名${state.name} 年龄${state.age}`;
  },
  {
    scheduler: () => {
      console.log("触发了更新,暂时不做处理"); // 切片编程思想,首先覆盖掉默认的 scheduler 执行,加上自己逻辑
      runner(); // 拿到暴露出来的runner后,某个时刻触发更新
    },
  },
);

// in effect
export function effect (fn, options?) {
  // 创建一个effect 实例,只要依赖的属性发生变化就要执行回调scheduler,就是 run() 方法
  const _effect = new ReactiveEffect(fn, () => {
    // 默认 scheduler 调度器,run 方法中执行 fn()
    _effect.run();
  });

  _effect.run();

  if (options) {
    Object.assign(_effect, options); // 将用户定义的scheduler覆盖掉内置的
  }

  const runner = _effect.run.bind(_effect);
  runner.effect = _effect;
  return runner; // 外面可以拿到调度执行 effect 的方法。
}
❌