Vue3源码之响应系统

前言

实际上在Vue3项目未发布之前,作者就说明了使用Proxy来替换Object.defineProperty来重构Vue的响应系统,Proxy具有更高的控制粒度,可以解决Object.defineProperty存在的一些问题,例如动态属性添加、数组length设置等带来的响应性问题。

Vue2中采用发布订阅模式(通过Dep对象与Watcher对象建立联系)构建整个响应系统,而Vue3这个过程是如何建立的呢?本文旨在理清整个过程,同时聊聊官方提供的不同的响应性API(Vue 3.1.1版本)。

data选项的响应处理

通过下面这个简单的实例来看响应处理的过程:

  <div id="counter">
    Counter: {{ counter }}
    <button @click="handleClick">点击</button>
  </div>

    const Counter = {
      data() {
        return {
          counter: 0
        }
      },
      methods: {
        handleClick() {
          this.counter++;
        }
      }
    }
    const app = Vue.createApp(Counter);
    app.mount('#counter');

实际上在之前的mount挂载文章中就提到过关于data选项的处理,其相关处理是在setup函数处理之后,即finishComponentSetup中调用applyOptions来处理的,具体的处理逻辑如下:

if (dataOptions) {
	if (!isFunction(dataOptions)) {
     	warn(...);
    }
    const data = dataOptions.call(publicThis, publicThis);
    if (isPromise(data)) { warn(); }
    if (!isObject(data)) {
     	warn(`data() should return an object.`);
    } else {
    	instance.data = reactive(data);
        {
        	for (const key in data) {
           		checkDuplicateProperties("Data" /* DATA */, key);
                // expose data on ctx during dev
             	if (key[0] !== '$' && key[0] !== '_') {
                	Object.defineProperty(ctx, key, {
                    	configurable: true,
                        enumerable: true,
                        get: () => data[key],
                        set: NOOP
                     });
                }
         	}
      	}
   }
}

实际上对于data选项的处理主要就是下面几点:

  • data必须是函数的校验
  • 调用data函数,得到返回值。对于返回值非Promise对象、非Object,会抛出警告
  • 只有对于返回值是Object,才会调用reactive函数来对返回值进行响应处理
  • 响应式副本保存在实例对象instance的data属性

从data选项的处理逻辑中可知,data的响应性是通过reactive函数来实现的。

reactive

reactive实际上还是Vue3暴露出来的响应性API,该API的作用就是:

返回对象的响应式副本,需要注意的是该函数的响应式转换是“深层”的——它影响所有嵌套 property

在基于 ES2015 Proxy 的实现中,返回的 proxy 是不等于原始对象的。官方建议只使用响应式 proxy,避免依赖原始对象。即:

const obj = {};
const copy = reactive(obj);
obj === copy; // false

reactive函数的逻辑如下:

function reactive(target) {
	// 只读proxy直接返回
    if (target && target["__v_isReadonly" /* IS_READONLY */]) {
    	return target;
    }
    return createReactiveObject(target, false,
    	mutableHandlers, mutableCollectionHandlers, reactiveMap);
    }

针对于只读proxy的对象就不需要处理了,这涉及到Vue3提供的其他响应性API,此处暂不细究后面会单独讨论。从上面代码逻辑中可知,reactive的背后实际上是调用createReactiveObject函数,该函数的具体逻辑如下:

function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
	// 判断是否是对象
	if (!isObject(target)) { return target; }
	// 判断是否已被proxy
    if (target["__v_raw" /* RAW */] &&
    	!(isReadonly && target["__v_isReactive" /* IS_REACTIVE */])) {
    	return target;
    }
    // 通过缓存机制避免相同的对象再次被proxy
    const existingProxy = proxyMap.get(target);
    if (existingProxy) {
    	return existingProxy;
    }
    // only a whitelist of value types can be observed.
    const targetType = getTargetType(target);
    if (targetType === 0 /* INVALID */) {
    	return target;
    }
    const proxy = new Proxy(target, targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers);
    proxyMap.set(target, proxy);
    return proxy;
}

总结createReactiveObject函数的逻辑实际上就是下面几点:

  • 要proxy的target必须是对象类型,而且不能是相关类型的已被proxy的对象

  • 检查proxy的target相关类型服务后续逻辑,通过getTargetType函数来判断,具体逻辑如下:

    function targetTypeMap(rawType) {
        switch (rawType) {
            case 'Object':
            case 'Array':
                return 1 /* COMMON */;
            case 'Map':
            case 'Set':
            case 'WeakMap':
            case 'WeakSet':
                return 2 /* COLLECTION */;
            default:
                return 0 /* INVALID */;
        }
    }
    function getTargetType(value) {
        return
        	// __v__skip是用于标记对象永远不能被proxy
        	value["__v_skip" /* SKIP */]
        	// 对象是否可扩展
        	|| !Object.isExtensible(value)
            	? 0 /* INVALID */
            	: targetTypeMap(toRawType(value));
    }
    
  • 调用new Proxy实现代理:Proxy用于创建一个对象的代理,实现基本操作的拦截和自定义,例如属性查找、函数调用等

  • 设置缓存机制,对应每次proxy的对象都缓存其结果,避免再次被proxy

实际上面的前两条都是针对target合法性的判断依据,这其中涉及到一些内部的标记,例如:__v_raw、__v_isReactive。后面会具体总结下,而真正响应逻辑的实现是外部传入的baseHandlers、collectionHandlers这两个参数决定的,即:

const proxy = new Proxy(target,
	targetType === 2 /* COLLECTION */
		? collectionHandlers
		: baseHandlers
);

其中targetType为2是处理Map、set相关的对象,这里看看reactive函数中传入的这两个参数值是:mutableHandlers 和 mutableCollectionHandlers。

mutableHandlers

reactive函数中针对于普通对象和数组,proxy代理了五种操作,分别是:

  • get:属性获取
  • set:赋值
  • deleteProperty:删除对应属性 即delete
  • has:针对in操作符的处理
  • ownKeys:拦截Reflect.ownKeys、Object.keys等操作,就是获取目标自身的属性,包括属性本身是Symbol值的

可以主要关心set和get的处理逻辑,即赋值和获取。在Vue2的响应系统中就是在get时收集依赖,在set时触发视图更新,Vue3中应该也不例外。实际上Vue3提供了多种不同的响应API,为了满足不同的功能相关的set和get都是通过工厂模式来创建。

reactive对应的proxy get

下面是主要的逻辑处理,省略了一些细节处理:

function get(target, key, receiver) {
	...
	const res = Reflect.get(target, key, receiver);
	...
    if (!isReadonly) {
    	track(target, "get" /* GET */, key);
    }
    if (isObject(res)) {
    	return isReadonly ? readonly(res) : reactive(res);
    }
    return res;
};

依据上面的逻辑,当数据可以响应时会调用track函数。track本身含义是追踪,顾名思义就是收集依赖,建立与视图更新相关的逻辑。除了track函数,这里还有一个有意思的处理逻辑:

对于获取的结果数据如果是对象类型,会再次调用reactive函数进行响应性处理,即将结果也转换成代理

我们知道Proxy的优点之一就是对对象整体进行代理在,这里为什么会需要对结果再次代理呢?在源码中有相关解释:

Convert returned value into a proxy as well. we do the isObject check here to avoid invalid value warning. Also need to lazy access readonly and reactive here to avoid circular dependency.

大概意思就是将返回值转换成代理以避免无效的警告和循环依赖。有些不太明白这里的无效警告和循环依赖的具体意思和场景,但是存在这样下面这种情况:

const obj = reactive({ a: { b: 1 }});
// 将内部子属性对象重新赋值给其他变量
const value = obj.a;
// 如果reactive中不将返回值进行代理,下面的value.b++是不会被追踪到,也不会触发视图的更新即响应性丢失
value.b++;

track函数因为其中涉及到的一些关键细节点比较多,具体处理逻辑会放在后面单独来看,

reactive对应的proxy set

下面是主要的逻辑处理,省略了一些细节处理:

function set(target, key, value, receiver) {
	...
	value = toRaw(value);
	...
    const result = Reflect.set(target, key, value, receiver);
    // don't trigger if target is something up in the prototype chain of original
    if (target === toRaw(receiver)) {
    	if (!hadKey) {
        	trigger(target, "add" /* ADD */, key, value);
        }
        else if (hasChanged(value, oldValue)) {
        	trigger(target, "set" /* SET */, key, value, oldValue);
        }
     }
     return result;
 };

实际上上面代码省略了一些细节逻辑,主要关心两点操作逻辑:

  • Reflect.set:设置属性
  • receiver 和 trigger函数

    receiver表示是被调用的对象,通常是proxy对象本身,即target === toRaw(receiver),其中toRaw函数的功能是返回代理的原始对象,但set 方法也有可能在原型链上或以其他方式被间接地调用,因此不一定是 proxy 本身

所以trigger函数在正常情况下总是会被执行,trigger本身是含义是触发,顾名思义应该就是触发视图更新的相关逻辑。

mutableHandlers的set和get总结

在Vue2中Object.defineProperty的响应是在get时收集依赖,set时触发视图更新,虽然Vue3的响应性原理实现不同了,但是相关的处理时机是没有变的。

mutableCollectionHandlers

reactive函数针对Map、Set等对象,只实现了对象的属性获取的拦截定义,具体代码如下:

const mutableCollectionHandlers = {
	// 形参对应是isReadonly shallow
	get: createInstrumentationGetter(false, false)
};

从上面代码中可知,对于Map、Set等对象只拦截了get操作,这是为什么呢?实际上这里涉及到ES规范中涉及到的内部方法和内部插槽等概念,例如[[Call]]、[[Construct]]、[[Get]]、[[MapData]]等,这里简单列举几个:

  • 属性和方法的获取实际上是调用内部方法[[Get]]来获取的
  • new实例实际上是调用内部方法[[Construct]]来处理的
  • 函数的执行实际上是调用内部方法[[Call]]来处理的

JavaScript是通过原型链实现继承的,原型本身也是对象,实例方法都是原型对象的属性。Proxy代理存在多个捕获器,其中get是属性读取操作的捕获器,那是不是只要是属性读取不论是属性还是方法都应该被捕获,通过下面两个实例来讲具体看看:

const obj = new Proxy(Object.prototype, {
	get: function(target, key) {
		console.log(key);
		// return Reflect.get(target, key);
	}
});
// 会触发get操作,key值就是方法名toString,如果没有返回Reflect结果是找不到toString方法的
obj.toString();

const map = new Proxy(new Map(), {
	get: function(target, key) {
		console.log(key);
	},
	set: function(target,key) {
		console.log(key);
	}
});
// 会触发get而不会触发set拦截(set拦截是属性设置),key值是'set'
map.set(0, 1);

从上面两个实例以及ES规范的可以总结下面2点:

  • 属性和方法等获取操作都会触发proxy的get捕获器,即get捕获所有属性获取操作
  • proxy拦截的是基本操作,而这些基本操作背后都是调用相关的内部方法,即proxy的捕获器可看成是对语言层次背后内部方法的拦截和自定义

Map、Set等对象比较特殊,其相关实例方法没有提供相关的捕获器,但是因为方法调用属于属性获取操作,所以可以通过捕获get操作来实现。实际上Vue3之所以只定义了get拦截,就是利用这个特性。实际上Vue3内部也是如此处理的,具体代码如下:

const mutableInstrumentations = {
	get(key) {
    	return get$1(this, key);
    },
    get size() {
    	return size(this);
    },
    has: has$1,
    add,
    set: set$1,
    delete: deleteEntry,
    clear,
    forEach: createForEach(false, false)
};
const iteratorMethods = ['keys', 'values', 'entries', Symbol.iterator];
iteratorMethods.forEach(method => {
	mutableInstrumentations[method] = createIterableMethod(method, false, false);
});

function createInstrumentationGetter(isReadonly, shallow) {
	const instrumentations = mutableInstrumentations;
    return (target, key, receiver) => {
    	...
        return Reflect.get(
        	hasOwn(instrumentations, key) && key in target ? instrumentations : target,
        	key,
        	receiver
        );
    };
}

从上面逻辑可知通过捕获get操作从而对Map、Set等对象的实例方法都进行了处理,而相关的实例方法对应的处理,这里就不细究了,实际上背后的响应处理实际上都是通过track和trigger来建立的。

track和trigger

在分析data选项处理过程中,发现reactive背后的proxy的set和get主要调用的就是trigger函数和track函数。实际上类比Vue2,trigger函数触发通知视图更新,track来实现依赖收集。

track

track函数的主要逻辑如下:

function track(target, type, key) {
	if (!shouldTrack || activeEffect === undefined) {
    	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()));
    }
    
    if (!dep.has(activeEffect)) {
    	dep.add(activeEffect);
        activeEffect.deps.push(dep);
        if (activeEffect.options.onTrack) {
        	activeEffect.options.onTrack({
            	effect: activeEffect,
                target,
                type,
                key
             });
        }
    }
}

先抛开细节的处理逻辑不管,通过一个实例更清晰的说明上面的联系:

const obj = reactive({ a: 0, b: 1 });
/*
	obj -> a -> Set<effect>,而每一个effect.deps中又存储Set<effect>
		-> b -> Set<effect>
*/
obj.a,obj.b

从上面实例知道effect与所谓的Dep对象建立彼此的联系,那么effect是什么呢?排除其他响应性API只考虑本文实例data选项,该effect实际上就是mount挂载文章中提及到的通过effect函数创建的reactiveEffect函数,即所谓的effect就是一个函数,而该函数的逻辑实际上就是传递给effect的fn函数。effect相关代码逻辑如下:

// effect内部调用createReactiveEffect函数
function effect(fn) {
	...
	const effect = createReactiveEffect(fn, options);
	// 非懒处理的直接执行
    if (!options.lazy) {
    	effect();
    }
    ...
}

function createReactiveEffect(fn, options) {
	const effect = function reactiveEffect() {
		...
        if (!effectStack.includes(effect)) {
        ...
        	try {
            	enableTracking();
                effectStack.push(effect);
                activeEffect = effect;
                return fn();
            } finally {
            	effectStack.pop();
                resetTracking();
                activeEffect = effectStack[effectStack.length - 1];
            }
       }
    };
    ...
    return effect;
}

从上面的逻辑可以看出effect函数的主要逻辑就是执行传入的fn函数,而fn函数的具体功能是什么?就需要根据调用位置来看了。

通过在源码全局查找,effect函数被调用处就是下面几个地方:

  • watch API相关,例如watch、watchEffect、$watch
  • computed相关
  • mountComponent中

而mountComponent中传入effect中fn函数在之前mount挂载文章中就有相关说明,即执行相关mount生命周期函数以及执行template生成的render函数。

Vue2响应系统的中最为核心的一个对象就是Watcher,通过Dep对象与Wacther对象建立彼此的联系。而Watcher对象的创建是在computed、watch以及mountComponent中全局render相关的watcher,是不是跟Vue3这里是相同的逻辑。

track函数最主要的逻辑就是建立当前的调用属性数据与effect的关联,可以猜测effect必然是Vue3响应系统的核心点

trigger

trigger函数的核心逻辑如下:

function trigger(target, type, key, newValue, oldValue, oldTarget) {
	const depsMap = targetMap.get(target);
    if (!depsMap) { return; }
    const effects = new Set();
    
    // 相关细节,实际上都是依据type向effects中添加内容
    ...
    
    effects.forEach(function(effect) {
    	if (effect.options.scheduler) {
        	effect.options.scheduler(effect);
        } else {
        	effect();
        }
    });
}

实际上从上面的逻辑可以看出,对于存在存在scheduler的effect,会直接调用scheduler,对不存在scheduler的直接执行effect函数本身。

scheduler是effect.options的属性,而effect.options是effect函数的属性,而effect只会通过effect函数被创建处理。所以scheduler是什么?scheduler是否存在?都要看effect函数被调用的位置,这里总结对应位置的scheduler:

  • watch API相关effect调用,其scheduler始终存在,具体逻辑就暂时不细究
  • computed相关effect调用,其scheduler始终存在(不过effect选项存在lazy属性,所以会延迟处理)
  • mountComponent中,其scheduler是通过createDevEffectOptions来创建的,而其scheduler就是queueJob函数

那queueJob函数的逻辑是什么呢?其相关逻辑具体分析如下:

queueJob

queueJob函数核心逻辑整理如下:

function queueJob(job) {
	// 1.相关条件判断
   	// 2.设置job在 queue数组的位置
   	Promise.resolve().then(function(seen) {
   		queue.sort((a, b) => getId(a) - getId(b));
   		try {
   		...
   		for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
        	const job = queue[flushIndex];
            if (job && job.active !== false) {
            	job();
            }
        }
	   	} finally {
	   		...
	   	}
   	})
}

实际上还存在非常多的细节,这里不关心这些,实际上queueJob的核心逻辑就是批量执行queue中的job即effect。在Vue2中异步更新对应queue存储的是Watcher对象,从前面整体分析的逻辑可以看出Vue3中effect实际上就是Vue2中Watcher所承担的功能,可以近似看成相同的。

响应性API说明

除了上面说的reactive,还存在shallowReactive、readonly、shallowReadonly、ref、computed、watch、watchEffect。这里只看下面三个响应性基本API的处理(其他响应性API背后处理相对复杂后续会专门分析):

  • shallowReactive
  • readonly
  • shallowReadonly
shallowReactive:浅层响应代理

创建一个响应式代理,它跟踪其自身 property 的响应性,但不执行嵌套对象的深层响应式转换 (暴露原始值)。

function shallowReactive(target) {
	return createReactiveObject(target, false, shallowReactiveHandlers, shallowCollectionHandlers, shallowReactiveMap);
}

从上面逻辑中可以看出shallowReactive背后还是调用createReactiveObject,与响应API reactive是相同,不同在于其get和set操作拦截的处理不同。

在前面reactive的分析中实际上也做过说明,Vue3源码中get和set的逻辑定义都是通过工厂来得到的,即createGetter和createSetter。这里只关心所谓的浅层响应代理背后的逻辑。

createGetter函数中涉及到shallow的处理逻辑主要如下:

function createGetter(isReadonly = false, shallow = false) {
	return function getter(target, key, receiver) {
		...
        if (shallow) {
        	return res;
        }
        ...
        if (isObject(res)) {
        	return isReadonly ? readonly(res) : reactive(res);
        }
        ...
	};
}

所谓的浅层响应代理,实际上就是对于获取的对象结果不在调用reactive做响应处理,而是直接返回。而reactive完全代理是对获取的结果也做响应处理,就是这样简单清晰的逻辑。

readonly:完全只读代理

readonly返回一个对象的原始代理的只读代理,只读代理是深层的:访问的任何嵌套 property 也是只读的。

function readonly(target) {
	return createReactiveObject(target, true, readonlyHandlers, readonlyCollectionHandlers, readonlyMap);
}

从上面逻辑中可以看出readonly背后还是调用createReactiveObject,其相关readonlyHandlers定义如下:

const readonlyHandlers = {
	get: readonlyGet,
    set(target, key) {
   		{
        	console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`, target);
        }
        return true;
    },
    deleteProperty(target, key) {
    	{
         	console.warn(`Delete operation on key "${String(key)}" failed: target is readonly.`, target);
        }
        return true;
    }
};

而readonlyGet依旧是通过createGetter创建的,对于只读功能的逻辑主要如下:

function createGetter(isReadonly = false, shallow = false) {
	return function get(target, key, receiver) {
		...
    	const res = Reflect.get(target, key, receiver);
    	...
    	// 只读的目标是不会追踪
        if (!isReadonly) {
        	track(target, "get" /* GET */, key);
        }
        // 对于获取的结果值只读的进行响应处理,以此实现深层只读
        if (isObject(res)) {
        	return isReadonly ? readonly(res) : reactive(res);
        }
        return res;
    };
}

readonly内部不会调用track进行追踪。

shallowReadonly:浅层只读代理

创建一个响应式代理,该代理跟踪其自身 property 的响应性,但不执行嵌套对象的深度响应式转换 (暴露原始值)。

function shallowReadonly(target) {
	return createReactiveObject(target, true, shallowReadonlyHandlers, shallowReadonlyCollectionHandlers, shallowReadonlyMap);
}

从上面逻辑中可以看出shallowReadonly背后还是调用createReactiveObject,其相关shallowReadonlyHandlers定义如下:

const shallowReadonlyHandlers = extend({}, readonlyHandlers, {
	get: shallowReadonlyGet
});

readonlyHandlers在上面的readonly对应的拦截处理已经知道,shadowReadonly覆盖了get拦截操作。其主要处理逻辑如下:

function createGetter(isReadonly = false, shallow = false) {
	return function get(target, key, receiver) {
		...
    	const res = Reflect.get(target, key, receiver);
    	...
    	// 只读的目标是不会追踪
        if (!isReadonly) {
        	track(target, "get" /* GET */, key);
        }
        // 浅层处理直接退出并返回对应值
        if (shallow) {
        	return res;
        }
        ...
        return res;
    };
}

响应过程中相关内部标记

在Vue3源码中存在这一些内部相关标记,这些标记都是跟整个响应处理相关的,其中包括:

  • __v_skip:跳过
  • __v_raw:原始对象
  • __v_isReadonly:只读
  • __v_isReactive:可响应

这些标记都是用于Proxy代理时的判断条件,根据不同的条件执行不同的功能。同时也暴露了一些API,例如markRaw、toRaw、isReactive、isReadonly等。这里就拿markRaw与__v_skip为例来说明。

markRaw API的功能是标记一个对象,该对象永远不会被代理,实际上该函数的逻辑非常简单,就是在对象上定义__v_skip属性,即:

function markRaw(value) {
	def(value, "__v_skip" /* SKIP */, true);
    return value;
}

对对象进行标记后,之后执行相关响应性API都会被特殊处理,实际上从前面对响应性API的说明,其背后都是调用createReactiveObject,其中涉及__v_skip标记的处理逻辑具体如下:

function getTargetType(value) {
	return value["__v_skip" /* SKIP */] || !Object.isExtensible(value)
    	? 0 /* INVALID */
        : targetTypeMap(toRawType(value));
}
    
function createReactiveObject() {
	...
	const targetType = getTargetType(target);
	// 
    if (targetType === 0 /* INVALID */) {
    	return target;
    }
    const proxy = new Proxy(...);
    return proxy;
}

逻辑就非常清晰了,存在__v_skip标记的其type会返回为0,直接退出不会执行代理。

其他标记涉及到的处理就不一一说明了,都是跟响应系统的功能性API有关的处理。

总结

本文主要以data选项为出发点梳理下Vue3背后的响应系统的主体逻辑,有很多细节没有去关注,实际上有些细节背后非常复杂。这里对Vue3的响应系统做下总结,有些点需要类比Vue2可以加强理解:

  • Vue2响应系统中对象的每一个属性在get拦截中都会生成一个Dep对象,而Dep对象会与Watcher对象进行关联,通过发布订阅模式来实现整个响应系统;Vue3实际上原理类型,不同在于因为使用Proxy,实际上建立关联的是访问的属性主体与effect,effect可以将其看成Vue2中的Watcher对象,其创建的时机和功能都与Vue2中的Watcher高度相似

  • Vue3中effect只有通过effect函数来创建,Vue3将该函数也暴露出来了,实际上effect概念上来表示副作用(引申自函数式编程中纯函数等概念)本质上就是一个函数,这个函数内部会调用真正副作用逻辑的函数。effect函数存在很多属性,参入的相关逻辑比较复杂。而且effect函数调用只在三个地方computed相关、watch相关、mountComponent,与Vue2中Watcher实例创建的时机非常相似

  • Vue3中通过track来实现依赖收集即建立调用属性与effect联系,trigger来触发视图更新即遍历执行effect队列,执行effect自带的调度器scheduler或者执行自身(mount挂载阶段创建的一个effect实际上其调度器就是queueJob函数,该函数就是将job推入队列指定位置并遍历所有队列,所谓的job就是处理effect)

  • Vue3中深度代理以及浅层代理的改变都是判断相关返回值是否再次代理来实现的,而这个过程中Proxy代理相关的缓存机制会优化这个过程

  • Vue3支持对Map、Set等对象的代理,实际上都是只拦截get操作来实现的,在其自定义处理中会针对不同的实例方法调用分别处理,相关原理实际上涉及到ES规范中提及的内部方法和内部插糟等相关说明

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
【资源说明】 1、基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 2、该资源包括项目的全部源码,下载可以直接使用! 3、本项目适合作为计算机、数学、电子信息等专业的课程设计、期末大作业和毕设项目,作为参考资料学习借鉴。 4、本资源作为“参考资料”如果需要实现其他功能,需要能看懂代码,并且热爱钻研,自行调试。 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip 基于SpringBoot+Vue3的博客系统源码+项目说明(适合课程设计和毕业设计).zip

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值