Vue双向绑定原理

大家都知道,关于Vue的双向绑定,核心是Object.defineProperty()方法,那接下来我们就简单介绍一下!

Object.defineProperty()

语法:Object.defineProperty(obj,prop,descriptor)

  • obj——要在其上定义属性的对象。
  • prop——要定义或修改的属性的名称。
  • descriptor——将被定义或修改的属性描述符。

其实,简单点来说,就是通过此方法来定义一个值。调用,使用到了get方法,赋值,使用到了set方法。
例子:

let obj = {}
Object.defineProperty(obj, 'name', {
  get: function() {
    console.log('调用了get方法')
  },
  set: function(newVal) { 
    console.log('调用了set方法,方法的值是:' + newVal)
  }
})
obj.name // 打印:调用了get方法
obj.name = '吕小布' // 打印:调用了set方法,方法的值是:吕小布

当我们调用时候,就会自动打印出两行文字。注意:get 和 set 方法内部的 this 都指向 obj,这意味着 get 和 set 方法可以操作对象内部的值。另外,访问器属性(也就是Object.defineProperty()中的属性)的会”覆盖”同名的普通属性,因为访问器属性会被优先访问,与其同名的普通属性则会被忽略。

实现JS双向绑定

既然我们已经知道了,每当有改变的时候都会调用到set方法,我们可以根据此来实现一个双向绑定!

<body>
  <div id="app">
    <input id="a" type="text">
    <h1 id="b"></h1>
  </div>
  <script>
    const domA = document.getElementById('a')
    const domB = document.getElementById('b')
    let obj = {}
    let val = '吕'
    Object.defineProperty(obj, 'name', {
      get: function() {
        console.log('get val:' + val)
        return val
      },
      set: function(newVal) {
        val = newVal
        console.log('set val:' + val)
        domA.value = val
        domB.innerHTML = val
      }
    })
    domA.addEventListener('keyup', function(e) {
      obj.name = e.target.value
    })
  </script>
</body>

效果图:
在这里插入图片描述
在这里插入图片描述
此例实现的效果是:随文本框输入文字的变化,h1中会同步显示相同的文字内容;在js或控制台显式的修改 obj.name 的值,视图会同步更新。这样就实现了 model => view 以及 view => model 的双向绑定。通过添加事件监听keyup来触发调用set方法,而set在修改了访问器属性的同时,既改变了文本框的内容,也改变了h1标签内的文本。

实现Vue双向绑定

一、实现效果

我们真正想实现的双向绑定是这样的:
view:

<div id="app">
    <input v-model="text" type="text">
    {{text}}
</div>

model:

let vm = new Vue({
  el: 'app',
  data: {
    text: 'lvxiaobu'
  }
})

我们的工作:

  1. 将vm实例中的data中的内容绑定到输入框以及文本节点当中
  2. 当输入框改变时,vm实例中的data的内容也跟着改变,实现 【view => model】
  3. 当data中的内容发生变化的时候,输入框的内容以及文本节点的内容也发生变化,实现 【model=> view】
二、内容绑定原理

先来了解一下DocumentFragment。
说到内容绑定,我们不得不来介绍DocuemntFragment(碎片化文档)这个概念,简单的来讲,你可以把它认为是一个dom节点的容器,当你创造了10个节点,当每个节点都插入到文档当中都会引发一次浏览器的回流,也就是说浏览器要回流10次,十分消耗资源。而使用碎片化文档,也就是说我把10个节点都先放入到一个容器当中,最后我再把容器直接插入到文档就可以了!浏览器只回流了1次。

注意:还有一个很重要的特性是,如果使用appendChid方法将原dom树中的节点添加到DocumentFragment中时,会删除原来的节点。
举个例子,使用console.log(document.getElementById(‘app’))
在这里插入图片描述
可以看到,我的app中有两个子节点,一个元素节点,一个文本节点 ;但是,当我通过DocumentFragment 劫持数据一下后:

const app = document.getElementById('app')
// 把app的所有子节点都劫持过来
function nodeToFragment(node) {
  let fragment = document.createDocumentFragment()
  let child
  // 每次循环都是先把node.firstChild赋值给child,然后当child非空时,才会进入循环,并不是根据两者是否相等
  while (child = node.firstChild) {
    fragment.appendChild(child)
  }
  return fragment
}
let dom = nodeToFragment(app)
console.log(dom) // 看一下碎片文档
console.log(app) // 看一下被劫持后的app

在这里插入图片描述
注意:我的碎片化文档是将子节点都劫持了过来,而我的id为app的div内已经没有内容了。

同时要主要我while的判断条件。判断是否有子节点,因为我每次appendChild都把node中的第一个子节点劫持走了,node中就会少一个,直到没有的时候,child也就变成了undefined,也就终止了循环。

三、实现内容绑定(任务一)

我们要考虑两个问题,一个是如何绑定到input上,另一个是如何绑定到文本节点中。

这样思路就来了,我们已经获取到了div的所有子节点了,就在DocumentFragment里面,然后对每一个节点进行处理,看是不是有跟vm实例中有关联的内容,如果有,修改这个节点的内容。然后重新添加入DocumentFragment中。

首先,我们写一个处理每一个节点的编译方法,如果有input绑定v-model属性或者有{{ xxx }}的文本节点出现,就进行内容替换,替换为vm实例中的data中的内容 :

// 编译方法
function compile(node, vm) {
  let reg = /\{\{(.*)\}\}/ // 匹配{{ xxx }}中的xxx
  // 如果是元素节点
  if (node.nodeType === 1) {
    // attributes包含了元素的所有属性
    let attr = node.attributes
    // 解析元素节点的所有属性
    for (let i = 0;i < attr.length;i++) {
      if (attr[i].nodeName == 'v-model') {
        let name = attr[i].nodeValue // 看看是与哪一个数据绑定
        node.value = vm.data[name] // 将data的值赋给该节点
        node.removeAttribute('v-model')
      }
    }
  }
  // 如果是文本节点
  if (node.nodeType === 3) {
    if (reg.test(node.nodeValue)) {
      // 获取到匹配的字符串xxx
      let name = RegExp.$1
      name = name.trim()
      node.nodeValue = vm.data[name]
    }
  }
}

然后,在向碎片化文档中添加节点时,每个节点都要处理一下:

// 把app的所有子节点都劫持过来,在向碎片化文档中添加节点时,每个节点都处理一下
function nodeToFragment(node, vm) {
  let fragment = document.createDocumentFragment()
  let child
  // 每次循环都是先把node.firstChild赋值给child,然后当child非空时,才会进入循环,并不是根据两者是否相等
  while (child = node.firstChild) {
    compile(child, vm)
    fragment.appendChild(child)
  }
  return fragment
}

创建Vue的构造函数:

// Vue构造函数
function Vue(options) {
  this.data = options.data
  let id = options.el
  // 这个this也就是实例化对象本身
  let dom = nodeToFragment(document.getElementById(id), this)
  // 处理完所有DOM节点后,重新将内容添加回去
  document.getElementById(id).appendChild(dom)
}
// 实例化Vue
let vm = new Vue({
  el: 'app',
  data: {
    text: 'lvxiaobu'
  }
})

以上完整代码:

<body>
  <div id="app">
    <input v-model="text" type="text">
    {{text}}
  </div>
  <script>
    // 编译方法
    function compile(node, vm) {
      let reg = /\{\{(.*)\}\}/ // 匹配{{ xxx }}中的xxx
      // 如果是元素节点
      if (node.nodeType === 1) {
        // attributes包含了元素的所有属性
        let attr = node.attributes
        // 解析元素节点的所有属性
        for (let i = 0;i < attr.length;i++) {
          if (attr[i].nodeName == 'v-model') {
            let name = attr[i].nodeValue // 看看是与哪一个数据绑定
            node.value = vm.data[name] // 将data的值赋给该节点
            node.removeAttribute('v-model')
          }
        }
      }
      // 如果是文本节点
      if (node.nodeType === 3) {
        if (reg.test(node.nodeValue)) {
          // 获取到匹配的字符串xxx
          let name = RegExp.$1
          name = name.trim()
          node.nodeValue = vm.data[name]
        }
      }
    }
    // 把app的所有子节点都劫持过来,在向碎片化文档中添加节点时,每个节点都处理一下
    function nodeToFragment(node, vm) {
      let fragment = document.createDocumentFragment()
      let child
      // 每次循环都是先把node.firstChild赋值给child,然后当child非空时,才会进入循环,并不是根据两者是否相等
      while (child = node.firstChild) {
        compile(child, vm)
        fragment.appendChild(child)
      }
      return fragment
    }
    // Vue构造函数
    function Vue(options) {
      this.data = options.data
      let id = options.el
      // 这个this也就是实例化对象本身
      let dom = nodeToFragment(document.getElementById(id), this)
      // 处理完所有DOM节点后,重新将内容添加回去
      document.getElementById(id).appendChild(dom)
    }
    // 实例化Vue
    let vm = new Vue({
      el: 'app',
      data: {
        text: 'lvxiaobu'
      }
    })
  </script>
</body>

效果图:
在这里插入图片描述
我们成功将内容都绑定到了输入框与文本节点上!

四、view => model(任务二)

对于此任务,我们思考一下,输入框如何改变data。我们通过事件监听器keyup,input等,来获取到最新的value,然后通过Object.defineProperty将获取的最新的value,赋值给实例vm的text,我们把vm实例中的data下的text通过Object.defineProperty设置为访问器属性,这样给vm.text赋值,就触发了set。set方法的作用一个是更新data中的text,另一个等到下一个任务再说。

首先实现一个响应式监听属性的方法。一旦有赋新值就发生变化 :

// 响应式监听属性的方法
function defindeReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get: function() {
      return val
    },
    set: function(newVal) {
      if (newVal === val) return
      val = newVal
      console.log(`新值:${val}`)
    }
  })
}

然后,实现一个观察者,对于一个实例每一个属性值都进行观察:

// 观察者方法
function observe(obj, vm) {
  for (let key of Object.keys(obj)) {
    defindeReactive(vm, key, obj[key])
  }
}

改写编译方法,注意由于改成了访问器属性,访问的方法也产生变化(vm.data[name]=>vm[name]),同时添加了事件监听器,把实例的text值随时更新(实时更新的是vm的访问器属性text,vm.data[text]并不会更新):

// 编译方法
function compile(node, vm) {
  let reg = /\{\{(.*)\}\}/ // 匹配{{ xxx }}中的xxx
  // 如果是元素节点
  if (node.nodeType === 1) {
    // attributes包含了元素的所有属性
    let attr = node.attributes
    // 解析元素节点的所有属性
    for (let i = 0;i < attr.length;i++) {
      if (attr[i].nodeName == 'v-model') {
        let name = attr[i].nodeValue // 看看是与哪一个数据绑定
        node.addEventListener('input', function(e) {
          vm[name] = e.target.value // 将实例的text修改为最新值
          // 实时更新的是vm的访问器属性text,vm.data[text]并不会更新
          console.log(vm)
        })
        node.value = vm[name] // 将data的值赋给该节点
        node.removeAttribute('v-model')
      }
    }
  }
  // 如果是文本节点
  if (node.nodeType === 3) {
    if (reg.test(node.nodeValue)) {
      // 获取到匹配的字符串xxx
      let name = RegExp.$1
      name = name.trim()
      node.nodeValue = vm[name]
    }
  }
}

构造函数中,观察data中的所有属性值,注意增加了observe:

// Vue构造函数
function Vue(options) {
  this.data = options.data
  let data = this.data
  // console.log(this) // this指向vm,vm = {data: {text: 'lvxiaobu'}},仅有data一个属性,vm != options
  // vm还有额外的隐式属性值为options.data里面的所有属性值text: 'lvxiaobu',方便get和set方法进行双向绑定
  observe(data, this)
  let id = options.el
  // 这个this也就是实例化对象本身
  let dom = nodeToFragment(document.getElementById(id), this)
  // 处理完所有DOM节点后,重新将内容添加回去
  document.getElementById(id).appendChild(dom)
}

以上完整代码:

<body>
  <div id="app">
    <input v-model="text" type="text">
    {{text}}
  </div>
  <script>
    // 编译方法
    function compile(node, vm) {
      let reg = /\{\{(.*)\}\}/ // 匹配{{ xxx }}中的xxx
      // 如果是元素节点
      if (node.nodeType === 1) {
        // attributes包含了元素的所有属性
        let attr = node.attributes
        // 解析元素节点的所有属性
        for (let i = 0;i < attr.length;i++) {
          if (attr[i].nodeName == 'v-model') {
            let name = attr[i].nodeValue // 看看是与哪一个数据绑定
            node.addEventListener('input', function(e) {
              vm[name] = e.target.value // 将实例的text修改为最新值
              // 实时更新的是vm的访问器属性text,vm.data[text]并不会更新
              console.log(vm)
            })
            node.value = vm[name] // 将data的值赋给该节点
            node.removeAttribute('v-model')
          }
        }
      }
      // 如果是文本节点
      if (node.nodeType === 3) {
        if (reg.test(node.nodeValue)) {
          // 获取到匹配的字符串xxx
          let name = RegExp.$1
          name = name.trim()
          node.nodeValue = vm[name]
        }
      }
    }
    // 把app的所有子节点都劫持过来,在向碎片化文档中添加节点时,每个节点都处理一下
    function nodeToFragment(node, vm) {
      let fragment = document.createDocumentFragment()
      let child
      // 每次循环都是先把node.firstChild赋值给child,然后当child非空时,才会进入循环,并不是根据两者是否相等
      while (child = node.firstChild) {
        compile(child, vm)
        fragment.appendChild(child)
      }
      return fragment
    }
    // 响应式监听属性的方法
    function defindeReactive(obj, key, val) {
      Object.defineProperty(obj, key, {
        get: function() {
          return val
        },
        set: function(newVal) {
          if (newVal === val) return
          val = newVal
          console.log(`新值:${val}`)
        }
      })
    }
    // 观察者方法
    function observe(obj, vm) {
      for (let key of Object.keys(obj)) {
        defindeReactive(vm, key, obj[key])
      }
    }
    // Vue构造函数
    function Vue(options) {
      this.data = options.data
      let data = this.data
      // console.log(this) // this指向vm,vm = {data: {text: 'lvxiaobu'}},仅有data一个属性,vm != options
      // vm还有额外的隐式属性值为options.data里面的所有属性值text: 'lvxiaobu',方便get和set方法进行双向绑定
      observe(data, this)
      let id = options.el
      // 这个this也就是实例化对象本身
      let dom = nodeToFragment(document.getElementById(id), this)
      // 处理完所有DOM节点后,重新将内容添加回去
      document.getElementById(id).appendChild(dom)
    }
    // 实例化Vue
    let vm = new Vue({
      el: 'app',
      data: {
        text: 'lvxiaobu'
      }
    })
  </script>
</body>

效果图:
在这里插入图片描述
效果实现了,任务二也完成了,view => model 通过修改输入框 vm实例中的属性也跟着变化了!

五、model => view(任务三)

通过修改vm实例的属性,该改变输入框的内容与文本节点的内容。

这里涉及到一个问题 需要我们注意,当我们修改输入框,改变了vm实例的属性,这是1对1的。

但是,我们可能在页面中多处用到 data中的属性,这是1对多的。也就是说,改变1个model的值可以改变多个view中的值。

这就需要我们引入一个新的知识点:

订阅/发布者模式

订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。

发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作

举个例子:

// 一个发布者publisher
let pub = {
  publish: function() {
    dep.notify()
  }
}
// 三个订阅者subscribers
let sub1 = { update: function() { console.log(sub1) } } // sub1
let sub2 = { update: function() { console.log(sub2) } } // sub2
let sub3 = { update: function() { console.log(sub3) } } // sub3
// 一个消息中心
function Dep () {
  this.subs = [sub1, sub2, sub3]
}
Dep.prototype.notify = function() {
  this.subs.map(item => {
    item.update()
  })
}
// 发布者发布消息,消息中心执行notify方法,触发订阅者执行update方法
let dep = new Dep()
pub.publish()

之前提到的set函数的第二个作用 就是来提醒订阅者 进行noticy操作,告诉他们:“我的text变了!” 文本节点变成了订阅者,接到消息后,立马进行update操作

回顾一下,每当 new 一个 Vue,主要做了两件事:

第一个是监听数据:observe(data),

第二个是编译 HTML:nodeToFragement(id)。

在监听数据的过程中,我们会为 data 中的每一个属性生成一个主题对象 dep。

在编译 HTML 的过程中,会为每个与数据绑定相关的节点生成一个订阅者 watcher,watcher 会将自己添加到相应属性的 dep 容器中。

我们已经实现:修改输入框内容 => 在事件回调函数中修改属性值 => 触发属性的 set 方法。

接下来我们要实现的是:发出通知 dep.notify() => 触发订阅者的 update 方法 => 更新视图。

这里的关键逻辑是:如何将 watcher 添加到关联属性的 dep 中。

注意:我把直接赋值的操作改为了添加一个 Watcher 订阅者:

// 编译方法
function compile(node, vm) {
  let reg = /\{\{(.*)\}\}/ // 匹配{{ xxx }}中的xxx
  // 如果是元素节点
  if (node.nodeType === 1) {
    // attributes包含了元素的所有属性
    let attr = node.attributes
    // 解析元素节点的所有属性
    for (let i = 0;i < attr.length;i++) {
      if (attr[i].nodeName == 'v-model') {
        let name = attr[i].nodeValue // 看看是与哪一个数据绑定
        node.addEventListener('input', function(e) {
          vm[name] = e.target.value // 将实例的text修改为最新值
          // 实时更新的是vm的访问器属性text,vm.data[text]并不会更新
          console.log(vm)
        })
        // node.value = vm[name] // 将data的值赋给该节点
        new Watcher(vm, node, name) // 不直接赋值,而是通过绑定一个订阅者。vm变,输入框数据跟着变
        node.removeAttribute('v-model')
      }
    }
  }
  // 如果是文本节点
  if (node.nodeType === 3) {
    if (reg.test(node.nodeValue)) {
      // 获取到匹配的字符串xxx
      let name = RegExp.$1
      name = name.trim()
      // node.nodeValue = vm[name] // 将vm[text]的值赋给该节点
      new Watcher(vm, node, name) // 不直接赋值,而是通过绑定一个订阅者
      // console.log(new Watcher(vm, node, name))
    }
  }
}

那么,Watcher又该做些什么呢?

// Watcher构造函数
function Watcher(vm, node, name) {
  Dep.target = this // Dep.target是一个全局变量
  this.vm = vm
  this.node = node
  this.name = name
  this.update()
  Dep.target = null
}
Watcher.prototype = {
  update() {
    this.get()
    this.node.nodeValue = this.value // 注意,这是更新节点内容的关键
    this.node.value = this.value // 更新输入框数据的关键。vm变,输入框数据跟着变
  },
  get() {
    this.value = this.vm[this.name] // 触发相应的get
  }
}

首先在页面首次渲染的时候,将自己赋给了一个全局变量 Dep.target;其次,执行了 update 方法,进而执行了 get 方法,get 的方法读取了 vm 的访问器属性,从而触发了访问器属性的 get 方法,get 方法中将该 watcher 添加到了对应访问器属性的 dep 中;

再次,获取属性的值,然后更新视图。

最后,将 Dep.target 设为空。因为它是全局变量,也是 watcher 与 dep 关联的唯一桥梁,任何时刻都必须保证 Dep.target 只有一个值。

// 响应式监听属性的方法
function defindeReactive(obj, key, val) {
  let dep = new Dep()
  Object.defineProperty(obj, key, {
    get: function() {
      if (Dep.target) {
        dep.addSub(Dep.target)
      }
      return val
    },
    set: function(newVal) {
      if (newVal === val) return
      val = newVal
      console.log(`新值:${val}`)
      // 一旦更新马上发布消息
      dep.notify()
    }
  })
}
// Dep构造函数
function Dep() {
  this.subs = []
}
Dep.prototype = {
  addSub(sub) {
    this.subs.push(sub)
  },
  notify() {
    // 执行所有订阅者的回调函数update
    this.subs.map(item => {
      item.update()
    })
  }
}

Vue双向绑定原理完整代码:

<body>
  <div id="app">
    <input v-model="text" type="text">
    {{text}}
  </div>
  <script>
    // 编译方法
    function compile(node, vm) {
      let reg = /\{\{(.*)\}\}/ // 匹配{{ xxx }}中的xxx
      // 如果是元素节点
      if (node.nodeType === 1) {
        // attributes包含了元素的所有属性
        let attr = node.attributes
        // 解析元素节点的所有属性
        for (let i = 0;i < attr.length;i++) {
          if (attr[i].nodeName == 'v-model') {
            let name = attr[i].nodeValue // 看看是与哪一个数据绑定
            node.addEventListener('input', function(e) {
              vm[name] = e.target.value // 将实例的text修改为最新值
              // 实时更新的是vm的访问器属性text,vm.data[text]并不会更新
              console.log(vm)
            })
            // node.value = vm[name] // 将data的值赋给该节点
            new Watcher(vm, node, name) // 不直接赋值,而是通过绑定一个订阅者。vm变,输入框数据跟着变
            node.removeAttribute('v-model')
          }
        }
      }
      // 如果是文本节点
      if (node.nodeType === 3) {
        if (reg.test(node.nodeValue)) {
          // 获取到匹配的字符串xxx
          let name = RegExp.$1
          name = name.trim()
          // node.nodeValue = vm[name] // 将vm[text]的值赋给该节点
          new Watcher(vm, node, name) // 不直接赋值,而是通过绑定一个订阅者
          // console.log(new Watcher(vm, node, name))
        }
      }
    }
    // 把app的所有子节点都劫持过来,在向碎片化文档中添加节点时,每个节点都处理一下
    function nodeToFragment(node, vm) {
      let fragment = document.createDocumentFragment()
      let child
      // 每次循环都是先把node.firstChild赋值给child,然后当child非空时,才会进入循环,并不是根据两者是否相等
      while (child = node.firstChild) {
        compile(child, vm)
        fragment.appendChild(child)
      }
      return fragment
    }
    // 响应式监听属性的方法
    function defindeReactive(obj, key, val) {
      let dep = new Dep()
      Object.defineProperty(obj, key, {
        get: function() {
          if (Dep.target) {
            dep.addSub(Dep.target)
          }
          return val
        },
        set: function(newVal) {
          if (newVal === val) return
          val = newVal
          console.log(`新值:${val}`)
          // 一旦更新马上发布消息
          dep.notify()
        }
      })
    }
    // 观察者方法
    function observe(obj, vm) {
      for (let key of Object.keys(obj)) {
        defindeReactive(vm, key, obj[key])
      }
    }
    // Watcher构造函数
    function Watcher(vm, node, name) {
      Dep.target = this // Dep.target是一个全局变量
      this.vm = vm
      this.node = node
      this.name = name
      this.update()
      Dep.target = null
    }
    Watcher.prototype = {
      update() {
        this.get()
        this.node.nodeValue = this.value // 注意,这是更新节点内容的关键
        this.node.value = this.value // 更新输入框数据的关键。vm变,输入框数据跟着变
      },
      get() {
        this.value = this.vm[this.name] // 触发相应的get
      }
    }
    // Dep构造函数
    function Dep() {
      this.subs = []
    }
    Dep.prototype = {
      addSub(sub) {
        this.subs.push(sub)
      },
      notify() {
        // 执行所有订阅者的回调函数update
        this.subs.map(item => {
          item.update()
        })
      }
    }
    // Vue构造函数
    function Vue(options) {
      this.data = options.data
      let data = this.data
      // console.log(this) // this指向vm,vm = {data: {text: 'lvxiaobu'}},仅有data一个属性,vm != options
      // vm还有额外的隐式属性值为options.data里面的所有属性值text: 'lvxiaobu',方便get和set方法进行双向绑定
      observe(data, this)
      let id = options.el
      // 这个this也就是实例化对象本身
      let dom = nodeToFragment(document.getElementById(id), this)
      // 处理完所有DOM节点后,重新将内容添加回去
      document.getElementById(id).appendChild(dom)
    }
    // 实例化Vue
    let vm = new Vue({
      el: 'app',
      data: {
        text: 'lvxiaobu'
      }
    })
  </script>
</body>

效果图:
1、
在这里插入图片描述
2、
在这里插入图片描述
3、
在这里插入图片描述
至此,就实现了一个Vue双向绑定的机制!

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端吕小布

您的鼓励将是我创作的最大动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值