稍微学一下Vue的数据响应式(Vue2及Vue3区别)


Posted in Javascript onNovember 21, 2019

什么是数据响应式

从一开始使用 Vue 时,对于之前的 jq 开发而言,一个很大的区别就是基本不用手动操作 dom,data 中声明的数据状态改变后会自动重新渲染相关的 dom。
换句话说就是 Vue 自己知道哪个数据状态发生了变化及哪里有用到这个数据需要随之修改。

因此实现数据响应式有两个重点问题:

  1. 如何知道数据发生了变化?
  2. 如何知道数据变化后哪里需要修改?

对于第一个问题,如何知道数据发生了变化,Vue3 之前使用了 ES5 的一个 API Object.defineProperty Vue3 中使用了 ES6 的 Proxy,都是对需要侦测的数据进行 变化侦测 ,添加 getter 和 setter ,这样就可以知道数据何时被读取和修改。

第二个问题,如何知道数据变化后哪里需要修改,Vue 对于每个数据都收集了与之相关的 依赖 ,这里的依赖其实就是一个对象,保存有该数据的旧值及数据变化后需要执行的函数。每个响应式的数据变化时会遍历通知其对应的每个依赖,依赖收到通知后会判断一下新旧值有没有发生变化,如果变化则执行回调函数响应数据变化(比如修改 dom)。

下面详细分别介绍 Vue2 及 Vue3 的数据变化侦测及依赖收集。

Vue2

变化侦测

Object 的变化侦测

转化响应式数据需要将 Vue 实例上 data 属性中定义的数据通过递归将所有属性都转化为 getter/setter 的形式,Vue 中定义了一个 Observer 类来做这个事情。

function def(obj, key, val, enumerable) {
 Object.defineProperty(obj, key, {
  value: val,
  enumerable: !!enumerable,
  writable: true,
  configurable: true
 })
}

class Observer {
 constructor(value) {
  this.value = value;
  def(value, '__ob__', this);
  if (!Array.isArray(value)) {
   this.walk(value);
  }
 }
 walk(obj) {
  for (const [key, value] of Object.entries(obj)) {
   defineReactive(obj, key, value);
  }
 }
}

直接将一个对象传入 new Observer() 后就对每项属性都调用 defineReactive 函数添加变化侦测,下面定义这个函数:

function defineReactive(data, key, val) {
 let childOb = observe(val);
 Object.defineProperty(data, key, {
  enumerable: true,
  configurable: true,
  get: function () {
   // 读取 data[key] 时触发
   console.log('getter', val);
   return val;
  },
  set: function (newVal) {
   // 修改 data[key] 时触发
   console.log('setter', newVal);
   if (val === newVal) {
    return;
   }
   val = newVal;
  }
 })
}

function observe(value, asRootData) {
 if (typeof val !== 'object') {
  return;
 }
 let ob;
 if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
  ob = value.__ob__;
 } else {
  ob = new Observer(val);
 }
 return ob;
}

函数中判断如果是对象则递归调用 Observer 来实现所有属性的变化侦测,根据 __ob__ 属性判断是否已处理过,防止多次重复处理,Observer 处理过后会给数据添加这个属性,下面写一个对象试一下:

const people = {
 name: 'c',
 age: 12,
 parents: {
  dad: 'a',
  mom: 'b'
 },
 mates: ['d', 'e']
};
new Observer(people);
people.name; // getter c
people.age++; // getter 12 setter 13
people.parents.dad; // getter {} getter a

打印 people 可以看到所有属性添加了 getter/setter 方法,读取 name 属性时打印了 people.age++ 修改 age 时打印了 getter 12 setter 13 说明 people 的属性已经被全部成功代理监听。

Array 的变化侦测

可以看到前面 Observer 中仅对 Object 类型个数据做了处理,为每个属性添加了 getter/setter,处理后如果属性值中有数组,通过 属性名 + 索引 的方式(如:this.people.mates[0])获取也是会触发 getter 的。但是如果通过数组原型方法修改数组的值,如 this.people.mates.push('f'),这样是无法通过 setter 侦测到的,因此,在 Observer 中需要对 Object 和 Array 分别进行单独的处理。

为侦测到数组原型方法的操作,Vue 中是通过创建一个拦截器 arrayMethods,并将拦截器重新挂载到数组的原型对象上。

下面是拦截器的定义:

const ArrayProto = Array.prototype;
const arrayMethods = Object.create(ArrayProto);
;[
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
].forEach(method => {
 const original = ArrayProto[method];
 Object.defineProperty(arrayMethods, method, {
  value: function mutator(...args) {
   console.log('mutator:', this, args);
   return original.apply(this, args);
  },
  enumerable: false,
  writable: true,
  configurable: true
 })
})

这里 arrayMethods 继承了 Array 的原型对象 Array.prototype,并给它添加了 push pop shift unshift splice sort reverse 这些方法,因为数组是可以通过这些方法进行修改的。添加的 push pop... 方法中重新调用 original(缓存的数组原型方法),这样就不会影响数组本身的操作。

最后给 Observer 中添加数组的修改:直接将拦截器挂载到数组原型对象上

class Observer {
 constructor(value) {
  this.value = value;
  def(value, '__ob__', this);
  if (Array.isArray(value)) {
   value.__proto__ = arrayMethods;
  } else {
   this.walk(value);
  }
 }
 walk(obj) {
  for (const [key, value] of Object.entries(obj)) {
   defineReactive(obj, key, value);
  }
 }
}

再来验证一下:

const people = {
 name: 'c',
 age: 12,
 parents: {
  dad: 'a',
  mom: 'b'
 },
 mates: ['d', 'e']
};
new Observer(people);
people.mates[0]; // getter (2) ["d", "e"]
people.mates.push('f'); // mutator: (2) ["d", "e"] ["f"]

现在数组的修改也能被侦测到了。

依赖收集

目前已经可以对 Object 及 Array 数据的变化进行截获,那么开始考虑一开始提到的 Vue 响应式数据的第二个问题:如何知道数据变化后哪里需要修改?

最开始已经说过,Vue 中每个数据都需要收集与之相关的依赖,用来表示该数据变化时需要进行的操作行为。

通过数据的变化侦测我们可以知道数据何时被读取或修改,因此可以在数据读取时收集依赖,修改时通知依赖更新,这样就可以实现数据响应式了。

依赖收集在哪

为每个数据都创建一个收集依赖的对象 dep,对外暴露 depend(收集依赖)、notify(通知依赖更新)的两个方法,内部维护了一个数组用来保存该数据的每项依赖。

对于 Object,可以在 getter 中收集,setter 中通知更新,对 defineReactive 函数修改如下:

function defineReactive(data, key, val) {
 let childOb = observe(val);
 // 处理每个响应式数据时都创建一个对象用来收集依赖
 let dep = new Dep();
 Object.defineProperty(data, key, {
  enumerable: true,
  configurable: true,
  get: function () {
   // 收集依赖
   dep.depend();
   return val;
  },
  set: function (newVal) {
   if (val === newVal) {
    return;
   }
   val = newVal;
   // 通知依赖更新
   dep.notify();
  }
 })
}

上面代码中依赖是收集在一个 Dep 实例对象上的,下面看一下 Dep 这个类。

class Dep {
 constructor() {
  this.subs = [];
 }
 addSub(sub) {
  this.subs.push(sub);
 }
 removeSub(sub) {
  if (this.subs.length) {
   const index = this.subs.indexOf(sub);
   this.subs.splice(index, 1);
  }
 }
 depend() {
  if (window.target) {
   this.addSub(window.target);
  }
 }
 notify() {
  const subs = this.subs.slice();
  for (let i = 0; i < subs.length; i++) {
   subs[i].update();
  }
 }
}

Dep 的每个实例都有一个保存依赖的数组 subs,收集依赖时是从全局的一个变量上获取到并插入 subs,通知依赖时就遍历所有 subs 成员并调用其 update 方法。

Object 的依赖收集和触发都是在 defineProperty 中进行的,因此 Dep 实例定义在 defineReactive 函数中就可以让 getter 和 setter 都拿到。

而对于 Array 来说,依赖可以在 getter 中收集,但触发却是在拦截器中,为了保证 getter 和 拦截器中都能访问到 Dep 实例,Vue 中给 Observer 实例上添加了 dep 属性。

class Observer {
 constructor(value) {
  this.value = value;
  this.dep = new Dep();
  def(value, '__ob__', this);
  if (Array.isArray(value)) {
   value.__proto__ = arrayMethods;
  } else {
   this.walk(value);
  }
 }
 walk(obj) {
  for (const [key, value] of Object.entries(obj)) {
   defineReactive(obj, key, value);
  }
 }
}

Observer 在处理数据响应式时也将自身实例添加到了数据的 __ob__ 属性上,因此在 getter 和拦截器中都能通过响应式数据本身的  __ob__.dep 拿到其对应的依赖。修改 defineReactive 和 拦截器如下:

function defineReactive(data, key, val) {
 let childOb = observe(val);
 let dep = new Dep();
 Object.defineProperty(data, key, {
  enumerable: true,
  configurable: true,
  get: function () {
   dep.depend();
   // 给 Observer 实例上的 dep 属性收集依赖
   if (childOb) {
    childOb.dep.depend();
   }
   return val;
  },
  ...
 })
}

;[
 'push',
 'pop',
 'shift',
 'unshift',
 'splice',
 'sort',
 'reverse'
].forEach(method => {
 const original = ArrayProto[method];
 def(arrayMethods, method, (...args) => {
  const result = original.apply(this, args);
  const ob = this.__ob__;
  ob.dep.notify();
  return result;
 })
})

依赖长什么样

现在已经知道了依赖保存在每个响应式数据对应的 Dep 实例中的 subs 中,通过上面 Dep 的代码可以知道,收集的依赖是一个全局对象,且该对象对外暴露了一个 update 方法,记录了数据变化时需要进行的更新操作(如修改 dom 或 Vue 的 Watch)。

首先这个依赖对象的功能主要有两点:

  1. 需要主动将自己收集到对应响应式数据的 Dep 实例中;
  2. 保存数据变化时要进行的操作并在 update 方法中调用;

其实就是一个中介角色,Vue 中起名为 Watcher。

class Watcher {
 constructor(vm, expOrFn, cb) {
  this.vm = vm;
  // 保存通过表达式获取数据的方法
  this.getter = parsePath(expOrFn);
  this.cb = cb;
  this.value = this.get();
 }
 get() {
  // 将自身 Watcher 实例挂到全局对象上
  window.target = this;
  // 获取表达式对应的数据
  // 会自动触发该数据的 getter
  // getter 中收集依赖时从全局对象上拿到这个 Watcher 实例
  let value = this.getter.call(this.vm, this.vm);
  window.target = undefined;
  return value;
 }
 update() {
  const oldValue = this.value;
  this.value = this.get();
  // 将旧值与新值传递给回调函数
  this.cb.call(this.vm, this.value, oldValue);
 }
}

对于第一点,主动将自己收集到 Dep 实例中,Watcher 中设计的非常巧妙,在 get 中将自身 Watcher 实例挂到全局对象上,然后通过获取数据触发 getter 来实现依赖收集。

第二点实现很简单,只需要将构造函数参数中的回调函数保存并在 update 方法中调用即可。

构造函数中的 parsePath 方法就是从 Vue 实例的 data 上通过表达式获取数据,比如表达式为 "user.name" 则需要解析该字符串然后获取 data.user.name 数据。

总结

  • 数据先通过调用 new Observer() 为每项属性添加变化侦测,并创建一个 Dep 实例用来保存相关依赖。在读取属性值时保存依赖,修改属性值时通知依赖;
  • Dep 实例的 subs 属性为一个数组,保存依赖是向数组中添加,通知依赖时遍历数组一次调用依赖的 update 方法;
  • 依赖是一个 Watcher 实例,保存了数据变化时需要进行的操作,并将实例自身放到全局的一个位置,然后读取数据触发数据的 getter,getter 中从全局指定的位置获取到该 Watcher 实例并收集在 Dep 实例中。

稍微学一下Vue的数据响应式(Vue2及Vue3区别)

以上就是 Vue2 中的响应式原理,在 Observer 处理完后,外界只需要通过创建 Watcher 传入需要监听的数据及数据变化时的响应回调函数即可。

Vue3

Vue3 中每个功能单独为一个模块,并可以单独打包使用,本文仅简单讨论 Vue3 中与数据响应式相关的 Reactive 模块,了解其内部原理,与 Vue2 相比又有何不同。

因为该模块可以单独使用,先来看一下这个模块的用法示例:

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>vue3 demo</title>
</head>
<body>
 <div id="app">
  <div id="count"></div>
  <button id="btn">+1</button>
 </div>
 <script src="./vue3.js"></script>
 <script>
  const countEl = document.querySelector('#count')
  const btnEl = document.querySelector('#btn')
  // 定义响应式数据
  const state = reactive({
   count: 0,
   man: {
    name: 'pan'
   }
  })
  // 定义计算属性
  let double = computed(() => {
   return state.count * 2
  })
  // 回调函数立即执行一次,内部使用到的数据更新时会重新执行回调函数
  effect(() => {
   countEl.innerHTML = `count is ${state.count}, double is ${double.value}, man's name is ${state.man.name}`
  })
  // 修改响应式数据触发更新
  btnEl.addEventListener('click', () => {
   state.count++
  }, false)
 </script>
</body>
</html>

通过示例可以看到实现 Vue3 这个数据响应式需要有 reactive、computed、effect 这几个函数,下面仍然通过从变化侦测及依赖收集两个方面介绍,简单实现这几个函数。

变化侦测

示例中的 reactive 函数是对数据进行响应式化的,因此该函数的功能就类似于 Vue2 中的 defineReactive 函数的 getter/setter 处理,处理后能够对数据的获取及修改操作进行捕获。

const toProxy = new WeakMap()
const toRaw = new WeakMap()

const baseHandler = {
 get(target, key) {
  console.log('Get', target, key)
  const res = Reflect.get(target, key)
  // 递归寻找
  return typeof res == 'object' ? reactive(res) : res
 },
 set(target, key, val) {
  console.log('Set', target, key, val)
  const res = Reflect.set(target, key, val)
  return res
 }
}
function reactive(target) {
 console.log('reactive', target)
 // 查询缓存
 let observed = toProxy.get(target)
 if (observed) {
  return observed
 }
 if (toRaw.get(target)) {
  return target
 }
 observed = new Proxy(target, baseHandler)
 // 设置缓存
 toProxy.set(target, observed)
 toRaw.set(observed, target)
 return observed
}

reactive 中使用 Proxy 对目标进行代理,代理的行为是 baseHander ,然后对目标对象及代理后的对象进行缓存,防止多次代理。

baseHandler 中就是对数据的获取及修改进行拦截,并通过 Reflect 执行 get/set 的原本操作,并在获取值为 Object 时递归进行响应式处理。很简单地就完成了数据的响应式处理。

依赖收集

依赖收集与 Vue2 类似,在 getter 中收集依赖,setter 中触发依赖,修改 baseHandler 如下:

const baseHandler = {
 get(target, key) {
  const res = Reflect.get(target, key)
  // 收集依赖
  track(target, key)
  return typeof res == 'object' ? reactive(res) : res
 },
 set(target, key, val) {
  const info = {
   oldValue: target[key],
   newValue: val
  }
  const res = Reflect.set(target, key, val)
  // 触发更新
  trigger(target, key, info)
  return res
 }
}

track 函数收集依赖,trigger 函数触发依赖更新。

首先需要两个全局变量,用于保存当前待收集的依赖对象的 effectStack 及一个记录所有数据及其对应依赖的表 targetMap 。

const effectStack = []
const targetMap = new WeakMap()

接下来定义这收集依赖及触发依赖更新这两个函数:

function track(target, key) {
 // 从栈中拿到待收集的依赖对象
 let effect = effectStack[effectStack.length - 1]
 if (effect) {
  // 通过 target 及 key 从依赖映射表中拿到对应的依赖列表(Set类型)
  // 首次需要对依赖映射表初始化
  let depsMap = targetMap.get(target)
  if (depsMap === undefined) {
   depsMap = new Map()
   targetMap.set(target, depsMap)
  }
  let dep = depsMap.get(key)
  if (dep === undefined) {
   dep = new Set()
   depsMap.set(key, dep)
  }
  // 若 target.key 对应的依赖列表中不存在该依赖则收集
  if (!dep.has(effect)) {
   dep.add(effect)
  }
 }
}
function trigger(target, key, info) {
 // 依赖映射表中取出 target 相关数据
 const depsMap = targetMap.get(target)
 if (depsMap === undefined) {
  return
 }
 // 普通依赖对象的列表
 const effects = new Set()
 // 计算属性依赖对象的列表
 const computedRunners = new Set()
 if (key) {
  // 取出 key 相关的依赖列表遍历分类存入 effects 及 computedRunners
  let deps = depsMap.get(key)
  deps.forEach(effect => {
   if (effect.computed) {
    computedRunners.add(effect)
   } else {
    effects.add(effect)
   }
  })
 }
 // 遍历执行所有依赖对象
 const run = effect=> effect()
 effects.forEach(run)
 computedRunners.forEach(run)
}

track 及 trigger 的大致代码也很简单,track 是拿到待收集的依赖对象 effect 后收集到 effectStack,trigger 是从 effectStack 拿到对应的依赖列表遍历执行。

到现在就差这个依赖对象了,根据上面 trigger 函数可以知道,这个依赖 effect 首先是个函数可以执行,并且还有自身属性,如 computed 表示其为一个计算属性的依赖,有时会根据该标识进行写特殊处理。

下面开始介绍这个依赖对象是如何产生的:

// 创建依赖对象
function createReactiveEffect(fn, options) {
 const effect = function effect(...args) {
  return run(effect, fn, args)
 }
 effect.computed = options.computed
 effect.lazy = options.lazy
 return effect
}

function run(effect, fn, args) {
 if (!effectStack.includes(effect)) {
  try {
   effectStack.push(effect)
   return fn(...args)
  } finally {
   effectStack.pop()
  }
 }
}

createReactiveEffect 是一个高阶函数,内部创建了一个名为 effect 的函数,函数内部返回的是一个 run 函数,run 函数中将依赖 effect 对象存入全局的待收集依赖栈 effectStack 中,并执行传入的回调函数,该回调函数其实就是一开始示例中 effect 函数传入的修改 Dom 的函数。也就是说依赖对象作为函数直接执行就会添加依赖到全局栈并执行回调函数。

回调函数中如果有读取了响应式数据的话则会触发 proxy 的 get 收集依赖,这时就能从 effectStack 上拿到该依赖对象了。

然后给 effect 增加了 computed lazy 属性后返回。

最后就是对外暴露的 effect 及 computed 函数了:

// 创建依赖对象并判断非计算属性则立即执行
function effect(fn, options = {}) {
 let e = createReactiveEffect(fn, options)
 if (!options.lazy) {
  e()
 }
 return e
}

// computed 内部调用 effect 并添加计算属性相关的 options
function computed(fn) {
 const runner = effect(fn, {
  computed: true,
  lazy: true
 })
 return {
  effect: runner,
  get value() {
   return runner()
  }
 }
}

computed 就不多说了,effect 就是将传入的回调函数传给 createReactiveEffect 创建依赖对象,然后执行依赖对象就会执行回调函数并收集该依赖对象。

总结

  • reactive 将传入的数据对象使用 proxy 包装,通过 proxy 的 get set 拦截数据的获取及修改,与 Vue2 的 defineProperty 一样,在 get 中收集依赖,在 set 中触发依赖;
  • effect 函数接受一个回调函数作为参数,将回调函数包装一下作为依赖对象后执行回调函数,回调函数执行时触发相关数据的 get 后进行依赖收集;

稍微学一下Vue的数据响应式(Vue2及Vue3区别)

到此 Vue2 及 Vue3 中的数据响应式原理都分析完了。

Vue2 及 Vue3 数据响应式的对比

本次 Vue 对于数据响应式的升级主要在变化侦测部分。

Vue2 中的变化侦测实现对 Object 及 Array 分别进行了不同的处理,Objcet 使用了
Object.defineProperty API ,Array 使用了拦截器对 Array 原型上的能够改变数据的方法进行拦截。虽然也实现了数据的变化侦测,但存在很多局限 ,比如对象新增属性无法被侦测,以及通过数组下边修改数组内容,也因此在 Vue2 中经常会使用到 $set 这个方法对数据修改,以保证依赖更新。

Vue3 中使用了 es6 的 Proxy API 对数据代理,没有像 Vue2 中对原数据进行修改,只是加了代理包装,因此首先性能上会有所改善。其次解决了 Vue2 中变化侦测的局限性,可以不使用 $set 新增的对象属性及通过下标修改数组都能被侦测到。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
js中函数声明与函数表达式
Jun 03 Javascript
JavaScript用select实现日期控件
Jul 17 Javascript
高性能JavaScript 重排与重绘(2)
Aug 11 Javascript
JavaScript学习小结(7)之JS RegExp
Nov 29 Javascript
javascript返回顶部的按钮实现方法
Jan 09 Javascript
js实现百度搜索提示框
Feb 05 Javascript
BootStrap modal实现拖拽功能
Dec 01 Javascript
JS中使用new Option()实现时间联动效果
Dec 10 Javascript
解决layui数据表格table的横向滚动条显示问题
Sep 04 Javascript
解决vue里a标签值解析变量,跳转页面,前面加默认域名端口的问题
Jul 22 Javascript
vue中使用echarts的示例
Jan 03 Vue.js
JS ES6异步解决方案
Apr 29 Javascript
Vue实现按钮级权限方案
Nov 21 #Javascript
微信小程序实现星级评价
Nov 20 #Javascript
微信小程序音乐播放器开发
Nov 20 #Javascript
微信小程序实现音乐播放器
Nov 20 #Javascript
vue移动端模态框(可传参)的实现
Nov 20 #Javascript
微信小程序实现上拉加载功能
Nov 20 #Javascript
微信小程序实现锚点功能
Nov 20 #Javascript
You might like
YII2框架中excel表格导出的方法详解
2017/07/21 PHP
jQuery AnythingSlider滑动效果插件
2010/02/07 Javascript
js封装的textarea操作方法集合(兼容很好)
2010/11/16 Javascript
Wordpress ThickBox 点击图片显示下一张图的修改方法
2010/12/11 Javascript
js不完美解决click和dblclick事件冲突问题
2012/07/16 Javascript
js处理json以及字符串的比较等常用操作
2013/09/08 Javascript
jQuery实现鼠标悬停背景翻转的黑色导航菜单代码
2015/09/14 Javascript
用js读写cookie的简单方法(推荐)
2016/08/08 Javascript
JavaScript中${pageContext.request.contextPath}取值问题及解决方案
2016/12/08 Javascript
React简单介绍
2017/05/24 Javascript
javascript+css3开发打气球小游戏完整代码
2017/11/28 Javascript
微信小程序picker组件简单用法示例【附demo源码下载】
2017/12/05 Javascript
vue中改变选中当前项的显示隐藏或者状态的实现方法
2018/02/08 Javascript
jquery UI实现autocomplete在获取焦点时得到显示列表功能示例
2019/06/04 jQuery
layUI使用layer.open,在content打开数据表格,获取值并返回的方法
2019/09/26 Javascript
浅谈vue-router路由切换 组件重用挖下的坑
2019/11/01 Javascript
[02:27]2014DOTA2国际邀请赛 VG赛后采访:更大的挑战在等着我们
2014/07/13 DOTA
[01:10]DOTA2英雄背景故事第四期之混沌法则混沌骑士
2020/07/16 DOTA
Python内置的字符串处理函数详细整理(覆盖日常所用)
2014/08/19 Python
在arcgis使用python脚本进行字段计算时是如何解决中文问题的
2015/10/18 Python
利用selenium 3.7和python3添加cookie模拟登陆的实现
2017/11/20 Python
简单实现python画圆功能
2018/01/25 Python
Python3最长回文子串算法示例
2019/03/04 Python
pip安装python库的方法总结
2019/08/02 Python
详解python对象之间的交互
2020/09/29 Python
CSS3 background-image颜色渐变的实现代码
2018/09/13 HTML / CSS
canvas 绘图时位置偏离的问题解决
2020/09/16 HTML / CSS
英国领先的家庭时尚品牌:Peacocks
2018/01/11 全球购物
质检的岗位职责
2013/11/17 职场文书
小学生检讨书大全
2014/02/06 职场文书
十佳护士先进事迹
2014/05/08 职场文书
教师节大会主持词
2015/07/06 职场文书
小学主题班会教案
2015/08/17 职场文书
小学大队长竞选稿
2015/11/20 职场文书
六年级数学教学反思
2016/02/16 职场文书
Win11右下角图标点了没反应怎么办?Win11点击右下角图标无反应解决方法汇总
2022/07/07 数码科技