Vue源码-响应式原理

·  阅读 367

当影响页面的数据发生改变,以往我们需要手动操作DOM来显示最新视图。通过Vue编程,我们只需重点关注数据状态的逻辑处理,Vue会帮我们自动完成视图的渲染工作,这就是Vue的数据响应式机制。现在,我们通过源码来看看Vue的响应式原理。

整体流程

在看源码之前,应该要对Vue的响应式原理有个大概了解。当我们把一个普通的对象传入作为实例的data选项,Vue会遍历这个对象的所有属性,利用Object.defineProperty把这些属性全部转为getter/setter

在获取对象属性值的时候,会触发该属性的getter,这时候的工作是收集该属性的依赖,这里的依赖可以理解为用到这个属性的地方,可以是视图模版,监听属性或者计算属性,在Vue中抽象成一个Watcher类来维护,所以我们说的依赖就是Watcher实例。

在对象属性赋值的时候,会触发该属性的setter,这时候的工作是通知该属性的依赖,执行watcher的相关处理,比如组件重新渲染或者watch的回调函数。

在Vue的响应式原理中也做了很多优化的处理,比如对数组的响应处理,执行watcher时机等等,我们稍后在源码中会做分析。所以,响应原理的流程可以总结成下图:

Observe

我们知道对data数据的处理是在vm._init入口的initState()方法,该方法是对实例状态的初始化工作,包括初始化propsmethoddata和监听watch等。我们重点关注初始化data的方法initData(),它定义在src/core/instance/state.js中:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      // 不是以$和_开头
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}
复制代码

这个方法首先判断data对象是否和propsmethods存在同名的key,然后把data代理到vm实例上,所有我们就可以通过vm.key访问data的数据。最后,调用observe()方法把data转为一个响应式数据。来看下observe方法的定义:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
复制代码

这个方法定义在src/core/observer/index.js。它主要通过新建一个Observe实例把value值转为一个响应的数据,并把实例存在对象的__ob__属性,我们后续的其他处理会用到。现在来看下Observe类:

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()   // 对象的依赖
    this.vmCount = 0   // 根实例数
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      // .. 数组方法的拦截处理
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  // 遍历对象属性,处理getter/setter
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  // 循环数组元素observe
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
复制代码

这个方法也很简单,如果对象是一个数组,则循环数组每个元素进行observe转为响应数据。如果是对象,则遍历属性利用defineReactive方法处理getter/setter。这里我们先省略对数组的拦截方法的处理,先来看下defineReactive方法:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 依赖管理类
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      
      // ..进行依赖收集
      
      return value
    },
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // ...通知依赖
    }
  })
}
复制代码

这个方法就是对Object.defineProperty方法设置对象属性的getter/setter的一个封装。需要考虑的点就是不要忽略对象属性原本的getter/setter。另外,方法一开始还新建了一个Dep类实例,它是对依赖进行管理的一个类。比如我们要记录每个属性的所有依赖,肯定要用一个数组来维护。所以Dep类的subs属性就是依赖的队列,我们来看下这个类几个重要的属性和方法:

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    // Dep.target 是指正在被收集的watcher
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}
复制代码

这个方法定义在src/core/observer/dep.jsdepend()方法是把依赖加入到subs数组,notify()方法是循环数组,执行依赖的update方法。我们上面说了依赖就是一个watcher实例,所以就是调用watcherupdate方法,其中Dep.target这个静态属性很重要,它表示此时正在执行get方法的watcher,为什么要这样记录。稍后我们在看依赖收集过程就会恍然大悟。

依赖收集

我们现在来看下defineReactive方法处理getter中的依赖收集过程:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  // ...

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    }
  })
}
复制代码

在设置getter前,如果属性值是一个对象,应该递归把对象也转为响应数据,并获取值的__ob__属性。然后判断Dep.target有值,就调用depend()方法进行依赖收集,注意的是属性值的Observe实例也要进行依赖收集,这是为我们后面数组的响应处理和全局set方法时能获取到依赖做铺垫。

那什么时候Dep.target有值呢,那就是当新建一个watcher并执行它的get方法的时候。我们先来简单看下Watcher类的定义:

export default class Watcher {
  vm: Component;
  expression: string;
  cb: Function;
  id: number;
  deep: boolean;
  user: boolean;
  lazy: boolean;
  sync: boolean;
  dirty: boolean;
  active: boolean;
  deps: Array<Dep>;
  newDeps: Array<Dep>;
  depIds: SimpleSet;
  newDepIds: SimpleSet;
  before: ?Function;
  getter: Function;
  value: any;

  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    // options
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      if (!this.getter) {
        this.getter = noop
        process.env.NODE_ENV !== 'production' && warn(
          `Failed watching path: "${expOrFn}" ` +
          'Watcher only accepts simple dot-delimited paths. ' +
          'For full control, use a function instead.',
          vm
        )
      }
    }
    this.value = this.lazy
      ? undefined
      : this.get()
  }

  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
       // 对deep深度的监听
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
}
复制代码

Watcher构造函数里面先对属性一些初始值,最后在一般情况都会直接调用this.get()。在get方法里面,先把当前watcher实例存到全局静态变量,然后执行getter方法。看到这里,在回到我们之前的组件渲染watcher的定义:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
复制代码

很明显,updateComponent会作为watchergetter被执行,并且在执行过程中,对于访问到的data属性,会触发相应的getter进行依赖收集,这时候的Dep.target就是当前的渲染watcher。所以在vue组件模版中用到的data属性的依赖列表中都会包含该渲染watcher

get方法的最后会把全局watcher进行恢复。什么意思?来看下pushTargetpopTarget的定义:

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}
复制代码

在处理watcher的时候,如果发现有新的watcher生成,会先把老的watcher押入栈,待新的watcher被收集完后,再进行恢复。最后执行cleanupDeps方法,维护该watcher最新的依赖情况,也就是watcher被收集进哪些Dep实例中:

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this)
    }
  }
  let tmp = this.depIds
  this.depIds = this.newDepIds
  this.newDepIds = tmp
  this.newDepIds.clear()
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}
复制代码

watcherdepIdsdeps记录上一次的收集情况,用newDepIdsnewDeps记录本次被收集的情况。这样做的目前为了处理v-if动态显示模版的情况:

<template>
  <div class="app">
    <div v-if="flag">
      {{ var1 }}
    </div>
    <div v-else>
      {{ var2 }}
    </div>
    <button @click="handleClick">click</button>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      flag: true,
      var1: 'fisrt',
      var2: 'second'
    };
  },
  methods: {
    handleClick() {
      this.flag = false;
      this.var1 = 'change';
    }
  }
};
</script>
复制代码

当组件初始化render的时候,渲染watcher会被flag和var1作为依赖收集,点击按钮后执行handleClick,flag状态发生改变,再次组件的render时候,var2会收集该渲染watcher。你会发现,如果不进行cleanupDeps的话,这个时候渲染watcher的会被3个状态收集,但是我们这时候无论怎么修改var1状态,都不会影响视图,所以没必要执行var1里的依赖。

所以,我们知道依赖收集的时机,那就是watcher实例执行自身的getter,会把自身缓存在一个全局变量Dep.target,然后触发属性的getter进行依赖收集。

派发更新

我们来看一下,当组件的状态发生时,会触发状态的setter,这时会进行组件的派发更新。我们看下setter的处理过程:

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
     // ...
  },
  set: function reactiveSetter (newVal) {
    const value = getter ? getter.call(obj) : val
    if (newVal === value || (newVal !== newVal && value !== value)) {
      return
    }
    if (process.env.NODE_ENV !== 'production' && customSetter) {
      customSetter()
    }
    if (getter && !setter) return
    if (setter) {
      setter.call(obj, newVal)
    } else {
      val = newVal
    }
    childOb = !shallow && observe(newVal)
    dep.notify()
  }
})
复制代码

setter先处理新值的判断,如果不变就直接返回,否则回调用依赖实例dep的notify方法,我们该方法就是循环调用依赖实例(watcher)的update方法。我们来下看watcherupdate方法:

update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}
复制代码

其他的条件逻辑先不管,对于渲染的watcher会执行queueWatcher方法:

const queue: Array<Watcher> = []
let has: { [key: number]: ?true } = {}
let waiting = false
let flushing = false
let index = 0

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 这里的处理是循环触发watcher的情况
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // 清空watcher队列,下个tick执行
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}
复制代码

这个是Vue的一个重要的更新优化手段。它会先把触发的watcher放到一个去重的队列中,然后调用nextTick(flushSchedulerQueue)在下个tick去执行flushSchedulerQueue方法,我们Vue中nextTick是优先采用Promise微任务的形式模拟异步,这样做的目前是为了在DOM更新前触发这个异步任务,因为DOM的更新是在微任务执行完后执行。如果放在宏任务去执行flushSchedulerQueue的话,就会浪费最近的一轮DOM更新。

现在,来看下flushSchedulerQueue的定义:

let circular: { [key: number]: number } = {}
export const MAX_UPDATE_COUNT = 100

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  // watcher按id排序,父watcher先调用
  queue.sort((a, b) => a.id - b.id)

  // 这是循环的queue每次都重新获取长度,是为了处理循环watcher的问题
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // 循环watcher
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }

  // ...
}
复制代码

这个方法先把队列的watcher按id小到大排序,然后执行watcherrun方法,执行外后把has的标记置空。但是如果出现循环watcher的情况,又会重新执行queueWatcher方法,并在else条件逻辑中把该watcher插入相同watcher的前面,所以这个has[id]就不是null了。比如下面情况的循环watch

watch: {
  msg(newVal) {
    this.msg = newVal
  }
}
复制代码

所以我们要规避这个情况的发生。在下一个tick中会调用watcherrun方法。看下这个方法:

run () {
  if (this.active) {
    // 获取新值
    const value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // set new value
      const oldValue = this.value
      this.value = value
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}
复制代码

run方法其实就是执行watcher的回调函数,并且要调用get方法获取最新的值。因此,对于组件的渲染的watcher,会在get中重新调用updateComponent,从而用最新的状态渲染视图。

到此,我们就走完了状态更新触发视图重新渲染的整个流程。

数组的处理

利用Object.defineProperty方法只能监测对象属性,并不能监测数组元素的修改或者添加,对于依赖数组渲染的视图就需要特殊处理了。Vue的做法就拦截响应数据中数组的原型对象,代理的对象自定义pushshift等修改数组元素的方法,并在方法通知依赖派发更新。

先来看下代理对象的定义,在src/core/observer/array.js中:

const arrayProto = Array.prototype
// 数组拦截对象
export const arrayMethods = Object.create(arrayProto)

// 数组侦测的方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    // 把数组新增的元素转为可侦测
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    // 通知依赖更新
    ob.dep.notify()
    return result
  })
})
复制代码

首先,我们肯定要保存本来原型的方法定义,然后在拦截对象中先调用原来对应方法,然后获取数组的依赖实例ob,这里就是之前在defineReactive方法中进行递归observe的保存结果:

let childOb = !shallow && observe(val)
复制代码

对于新增元素的方法比如pushsplice,还要获取对应的新增元素,循环进行observe。最后通知该数组的依赖进行更新。回到前面的Observe类对数组的处理:

const hasProto = '__proto__' in {}

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
    // 如果不存在_proto_,直接把方法挂载到数组上
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
复制代码

某些浏览器是不支持__proto__属性来设置原型的,这时候Vue的处理是直接把方法挂载到数组对象上。来看下相应的处理函数:

// 不存在__proto__
function protoAugment (target, src: Object) {
  target.__proto__ = src
}

function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}
复制代码

于是,我们就可以利用数组push等方法在修改数据的同时更新视图。

set和delete

上面是对于数组的响应处理,其实对于对象也有对应的问题。比如,我们无法增加一个对象属性使它的值变成可侦测,另外删除属性也是不会被拦截。于是Vue增加了全局和实例上的Vue.setVue.delete方法来增加和删除属性,并会触发视图更新。我们先来看下set的定义:

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 原来的key直接赋值
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // 不是响应式数据也直接赋值
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  // 通知订阅者
  ob.dep.notify()
  return val
}
复制代码

对于数组,调用splice方法插入,因为这个方法是响应方法,所以会触发更新。对于对象,如果不是响应的对象或者key本身就在对象上面直接赋值即可。如果不是上面两种情况,证明为新增的属性并且要使它可侦测,调用defineReactive方法进行设置即可,最后通知对象的依赖。

对于del也类似,我们来看看:

export function del (target: Array<any> | Object, key: any) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  if (!hasOwn(target, key)) {
    return
  }
  delete target[key]
  if (!ob) {
    return
  }
  ob.dep.notify()
}
复制代码

总结

Vue的响应原理其实理解起来并不复杂,它本后的设计思想就是典型的发布-订阅模式。我们数据状态可以看成是发布者,watcher可以看出是订阅者。当数据状态发生,发布者就会通知订阅者,执行订阅者相应的处理。

在触发更新的过程,Vue不会马上直接执行watcher的回调。而是把watcher压入到一个队列,在下个tick中再执行,这是一个很重要的优化。

>>>原文地址

分类:
前端
标签: