Vue指令 v-text、v-model 的实现

259 阅读7分钟

前言

数据变化反应到视图-指令操作,首先我们需要了解一下数据响应式,建议看之前先了解 Object.defineProperty属性;

实现数据响应式

  1. 对象属性拦截(vue2) Object.defineProperty(obj, key, {})// 原生js方法
  2. 对象整体代理(vue3) Proxy
  3. 注意: 对象属性拦截,不管使用哪种方式,道理都是相通的
  • Object.defineProperty 是劫持对象属性,Proxy 是劫持对象整体
  • 所以说 Proxy 比 Object.defineProperty 方便快捷的多,也是vue3性能提升的关键

实现 vue指令 v-text

v-text 声明式的指令版本实现:

目标: 一旦data中的属性发生变化之后,标记的v-text的dom标签的文本内容会立刻得到更新;

核心: 不管是指令也好还是插值表达式也好,它们都是数据和视图之间建立关联的“标识”,所以本质就是通过一定的手段找到符合标识的dom元素,然后把数据放上去,每当数据发生变化,就重新执行一遍放置数据的操作;

实现步骤:

  1. 先通过标识查找把数据放到对应的dom上显示出来;
  2. 数据变化之后再次执行将最新的值放到对应的dom上;

html代码:

<!-- v-text 声明式的指令版本实现 -->
  <div id="orange">
    <p v-text="name1" class="pppp"></p>
    <p v-text="name2" class="pppp"></p>
  </div>

js代码:

// 1. 通过标识查找把数据放到对应的dom上显示出来 
function init() { 
    let app = document.getElementById('orange'); // 根节点 
    // 拿到所有子节点 
    const childNodes = app.childNodes || []; // 所有类型的节点,包括文本节点和标签节点 
    console.log('childNodes: ', childNodes) 
    // 筛选标签(元素)节点,nodeType为1的元素节点; 
    childNodes.forEach(node => { 
        if (node.nodeType === 1) { // 进行筛选v-text 属性: 
            // 获取该节点的所有属性(是map结构) 
            const attrs = node.attributes; 
            console.log('attrs: ', attrs) // attrs: { 0: v-text, 1: class, v-text: v-text, class: class, length: 2 } 
            // 将类数组转换为真正的数组 Array.from(map) 
            Array.from(attrs).forEach(attr => { 
                console.log('attr: ', attr) // attr: v-text="name", attr: v-text="class="pppp" 
                const nodeName = attr.nodeName; 
                const nodeValue = attr.nodeValue; 
                // nodeName -> "v-text" 就是我们需要找的标识 
                // nodeValue -> "name" ,也就是data中对应的数据key 
                // 把data中的数据,放到满足标识上的dom上 
                if (nodeName === 'v-text') { 
                    node.innerText = data[nodeValue]; 
                } 
            }) 
         } 
    }) 
} 
let data = { name1: '小熊猫'name2: '小熊猫' };
init();

微信图片_20220825164021.png

到此我们就已经实现了第一步 v-text 初始赋值,所以接下来就是需要利用 Object.defineProperty 实现响应式;

js代码:

// 2. 数据变化之后再次执行将最新的值放到对应的dom上;
Object.keys(data).forEach(key => {
    let value = data[key]; // 利用value闭包变量存储值
    Object.defineProperty(data, key, { // 数据劫持
        get() {
          return value
        },
        set(newValue) {
          value = newValue;
          init(); // 每次set值的时候将会调用此函数
        }
    })
})

// 测试是否响应式
let num = 0;
var intervalId = window.setInterval(() => {
    if (++num === 100) {
        //清除Interval的定时器,传入变量名(创建Interval定时器时定义的变量名)
        clearInterval(intervalId);
    }
    data.name1 += `-${num}`;
    data.name2 += `-${num}`;
}, 1000)

微信图片_20220825165122.png

此时我们已经基本完成了vue的 v-text 的指令,其实主要是数据劫持,然后根据数据劫持去实现响应式操作(dom操作)。

最后,自己把代码整理封装了一遍,做了一些小优化,可以康康。

v-text 全部代码

<!-- v-text 声明式的指令版本实现 -->
  <div id="orange">
    <p v-text="name1" class="pppp"></p>
    <p v-text="name2" class="pppp"></p>
  </div>
// 1. 初始化数据
let data = { name1: '小熊猫', name2: '狗汤圆' };

// 2. 查找v-text对应指令的全部节点
const instructionNodes  = findIdentityNode('#orange', 'v-text'); // 查找标识节点

// 3. 数据响应式 VM, 数据绑定
Object.keys(data).forEach(key => {
  compile(instructionNodes, key, data[key]); // 编译数据到dom上

  // 数据响应式,数据劫持
  hijackObjectProperties(data, key, data[key], (newVale) => {
    compile(instructionNodes, key, newVale); // 编译数据到dom上
  })
})

// 测试是否响应式
let num = 0;
var intervalId = window.setInterval(() => {
  if (++num === 100) {
    //清除Interval的定时器,传入变量名(创建Interval定时器时定义的变量名)
    clearInterval(intervalId);
  }
  data.name1 += `-${num}`;
  data.name2 += `-${num}`;
}, 1000)

/**
 * @description: 查找标识(指令)节点
 * @param {*} el 父节点 例如 #app
 * @param {*} attName 元素属性名称(标识、指令) 例如v-text
 * @return {Array} 有此标识节点
 */    
function findIdentityNode(el, attName) {
  const instructionNodes = []; // 查找有该指令的元素
  let app = document.querySelector(el); // 根节点
  // 拿到所有子节点
  const childNodes = app.childNodes || []; // 所有类型的节点,包括文本节点和标签节点
  console.log('childNodes: ', childNodes)

  // 筛选标签(元素)节点,nodeType为1的元素节点;
  childNodes.forEach(node => {
    if (node.nodeType === 1) {
      // 进行筛选v-text 属性:
      // 获取该节点的所有属性(是map结构) 
      //例如: attrs: { 0: v-text, 1: class, v-text: v-text, class: class, length: 2 }
      const attrs = node.attributes; 
      // console.log('attrs: ', attrs)

      // 将类数组转换为真正的数组 Array.from(map)
      Array.from(attrs).forEach(attr => {
        // attr 的值例如 v-text="name" class="pppp"
        // console.log('attr: ', attr)

        const nodeName = attr.nodeName;
        const nodeValue = attr.nodeValue;
        // nodeName -> "v-text" 就是我们需要找的标识
        // nodeValue -> "name" ,也就是data中对应的数据key
        // 把data中的数据,放到满足标识上的dom上
        if (nodeName === attName) {
          instructionNodes.push({node, attName, attValue: nodeValue});
        }
      })
    }
  })
  return instructionNodes
}

/**
 * @description: 劫持对象属性
 * @param {*} obj 对象
 * @param {*} key 属性名称
 * @param {*} value 属性值
 * @param {*} setCallbackFn set函数触发的的回调函数
 * @param {*} getCallbackFn get函数触发时的回调函数
 * @return {undefined}
 */    
function hijackObjectProperties(obj, key, value, setCallbackFn, getCallbackFn) {
  // 利用闭包去存储值 value
  Object.defineProperty(obj, key, {
    get() {
      getCallbackFn && getCallbackFn(value); // 回调函数,去执行要做的事情
      return value
    },
    set(newValue) {
      if (value === newValue) { // 此处作一个优化,相同的值不重复触发setCallbackFn回调函数
        return
      }
      value = newValue;
      setCallbackFn && setCallbackFn(value); // 回调函数,去执行要做的事情
    }
  })
}

/**
 * @description: 编译数据到dom
 * @param {*} nodes 要编译dom节点
 * @param {*} key 属性名,相当于变量名称
 * @param {*} value 属性名称对应的属性值
 * @return {*}
 */    
function compile(nodes, key, value) {
  nodes.filter(item => item.attValue === key).forEach(item => (item.node.innerText = value ));
}

实现 vue指令 v-model

v-model 声明式的指令版本实现:

v-model 双向绑定实现思路:

  • M -> V 在v-text 中我们已经实现了,所以就只是把指令名替换以及操作dem的api换一下,因为v-model的实现是使用的input;
  • V -> M 监听input事件,事件触发时的回调函数拿到当前最新输入框的值,直接赋值即可触发数据响应;

html代码:

  <!-- v-text\ v-model 声明式的指令版本实现 -->
  <div id="orange">
    <p v-text="name1" class="pppp"></p>
    <p v-text="name2" class="pppp"></p>
    <input type="text" v-model="name1">
    <input type="text" v-model="name2">
  </div>

js代码:

与之前v-text代码有些改动:

  1. 查找标识(指令)节点 findIdentityNode(el, attrNames) 函数将单个指令参数改为数组,改为支持多个指令的查找;
  2. 编译指令数据到dom instructCompile(nodes, key, value, data)函数增加对v-model的处理,增加了data参数,用于在v-model指令中 (V -> M)处理的赋值;
// 1. 初始化数据
let data = { name1: '小熊猫', name2: '狗汤圆' };

// 2. 查找v-text\v-model对应指令的全部节点
const instructionNodes  = findIdentityNode('#orange', ['v-text', 'v-model']); // 查找标识节点

// 3. 数据响应式 VM, 数据绑定
Object.keys(data).forEach(key => {
  instructCompile(instructionNodes, key, data[key], data); // 首次编译数据到dom

  // 数据响应式,数据劫持
  hijackObjectProperties(data, key, data[key], (newVale) => {
    instructCompile(instructionNodes, key, data[key], data); // 数据更改编译数据到dom
  })
})

/**
 * @description: 查找标识(指令)节点
 * @param {*} el 父节点 例如 #app
 * @param {Array} attrNames 元素属性名称(标识、指令) 例如v-text
 * @return {Array} 有此标识节点
 */    
function findIdentityNode(el, attrNames) {
  const instructionNodes = []; // 查找有该指令的元素
  let app = document.querySelector(el); // 根节点
  // 拿到所有子节点
  const childNodes = app.childNodes || []; // 所有类型的节点,包括文本节点和标签节点
  // console.log('childNodes: ', childNodes)

  // 筛选标签(元素)节点,nodeType为1的元素节点;
  childNodes.forEach(node => {
    if (node.nodeType === 1) {
      // 进行筛选v-text 属性:
      // 获取该节点的所有属性(是map结构) 
      //例如: attrs: { 0: v-text, 1: class, v-text: v-text, class: class, length: 2 }
      const attrs = node.attributes; 

      // 将类数组转换为真正的数组 Array.from(map)
      Array.from(attrs).forEach(attr => {
        const nodeName = attr.nodeName;
        const nodeValue = attr.nodeValue;
        // nodeName -> "v-text" 就是我们需要找的标识
        // nodeValue -> "name" ,也就是data中对应的数据key
        // 把data中的数据,放到满足标识上的dom上
        if (attrNames.includes(nodeName)) {
          instructionNodes.push({node, nodeName, attValue: nodeValue});
        }
      })
    }
  })
  return instructionNodes
}

/**
 * @description: 劫持对象属性
 * @param {*} obj 对象
 * @param {*} key 属性名称
 * @param {*} value 属性值
 * @param {*} setCallbackFn set函数触发的的回调函数
 * @param {*} getCallbackFn get函数触发时的回调函数
 * @return {undefined}
 */    
function hijackObjectProperties(obj, key, value, setCallbackFn, getCallbackFn) {
  // 利用闭包去存储值 value
  Object.defineProperty(obj, key, {
    get() {
      getCallbackFn && getCallbackFn(value); // 回调函数,去执行要做的事情
      return value
    },
    set(newValue) {
      if (value === newValue) { // 此处作一个优化,相同的值不重复触发setCallbackFn回调函数
        return
      }
      value = newValue;
      setCallbackFn && setCallbackFn(value); // 回调函数,去执行要做的事情
    }
  })
}

/**
 * @description: 编译指令数据到dom
 * @param {*} nodes 要编译dom节点
 * @param {*} key 属性名,相当于变量名称
 * @param {*} value 属性名称对应的属性值
 * @param {*} data 数据
 * @return {*}
 */    
function instructCompile(nodes, key, value, data) {
  nodes.filter(item => item.attValue === key).forEach(item => {
    const nodeName = item.nodeName; // 指令名称
    if (nodeName === 'v-text') {
      item.node.innerText = value;
    }
    if (nodeName === 'v-model') {
      // 调用dom操作给input绑定数据
      item.node.value = value;
      const boundEventListenered = item.node['_boundEventListenered']; // 是否已绑定监听事件
      if (!boundEventListenered) {
        // 监听input事件,在事件回调中拿到最新的输入值,赋值给绑定的属性
        item.node.addEventListener('input', (e) => {
          data[key] = e.target.value;
        })
        item.node['_boundEventListenered'] = true; // 标记已绑定事件
      }
    }
  });
}

最终实现:

微信图片_20220826101834.png