likes
comments
collection
share

不手撸一个极简版Vue响应式系统,记不住实现原理呀

作者站长头像
站长
· 阅读数 23

前言

对Vue响应式系统的实现原理,只知道整体流程,对于关键细节,不清不楚。为了消除这个知识点模糊地带,以前就有通过手撕代码,一步一步实现一个可用的极简版Vue响应式系统的想法。可是太懒,有拖延症,一拖就拖了大半年。物极必反,心里想的事情一直不去做的话,也是如鲠在喉,这几天身心状态较好,决定一鼓作气,把这个事情了解了,免得一直占用心智空间。废话说完了,现在我们进入正题。最终实现的效果如下所示:

不手撸一个极简版Vue响应式系统,记不住实现原理呀

本文将讲解如何一步步的实现一个极简的Vue响应式系统。

极简版Vue响应式实现

先用最朴素的思想,实现一个视图和模型可以双向绑定的响应式功能。

朴素的双向绑定

在视图部分, 定义一个输入框,用于和模型数据进行互动。

<div id="app">
    <input id='name' type="text" />
</div>

在模型部分, 先实现模型到视图的同步,用Proxy对模型数据进行劫持,监听模型数据的修改。这里有两点要说明一下:

const reactive = (obj) => {
  return Proxy(obj, {
    get: function (target, key, receiver) {
      return Reflect.get(target, key, receiver);
    },
    set: function (target, key, value, receiver) {
      document.querySelector('#name').value=value;
      return Reflect.set(target, key, value, receiver);
    },
  });
};
const data=reactive({name:'张三'});
// 初始化赋值
document.querySelector('#name').value=data.name;
// 后面的赋值就有数据劫持效果
setTimeout(() => {
  data.name = "李四";
}, 1000);

再实现视图到模型的同步,通过监听输入事件,很容易做到将视图数据同步到模型。

document.querySelector('#name').addEventListener("input", (e)=>{
  data.name = e.target.value;
});

目前,我们已经实现了一个最简单的数据双向绑定。这个例子中有一个隐含的前提条件,就是我们预设data是视图中依赖的响应式变量,那如何用程序自动判断视图中用到的响应式变量呢? 因为Vue中视图中需要添加响应式功能的数据都有一些典型的特征,如{{xxx}},v-属性名=xxx等, 可以递归扫描根容器下的所有子节点元素,依据这些特征,识别出视图中用到的响应式变量,监听这些变量的改变。这就需要一个编译的过程。

引入编译

假设视图中有如下三种类型响应式变量写法,现在我们要实现一个编译类,从视图中找到这三种类型的响应式写法对应的变量。

    <div id="app">
      <div>
        <span class="label">双括号变量:</span>
        <span>{{data.name}}</span>
      </div>
      <div>
        <span>v-model变量:</span>
        <input id="name" v-model="data.name" type="text" />
      </div>
      <div>
        <span class="label">v-text变量:</span>
        <span v-text="data.name"></span>
      </div>
    </div>

先定义一个编译类,这个类的构造器,接受两个参数,一个是视图根容器元素,用于遍历搜寻视图中使用的响应式变量,一个是模型中定义的响应式数据,用于视图中的数据初始化赋值。还需要一个编译方法。

export default class Compiler {
  constructor(el, data) {    
    this.data = data;
    this.el = el;
    // 编译模板
    this.compile(this.el);
  }
}

在编译方法中,遍历根元素下的子节点,由于通过childNodes属性获取的子元素中有大量的换行元素,遍历时需要剔除。遍历时先判断节点的类型,是文本节点还是元素节点,{{}}这种形式的响应式变量一般存在于文本节点中,v-xxx指令式的响应式变量一般存在于元素节点中,不同类型的节点走不同的处理流程。因为子节点下面可能还有子节点,所以要进行递归遍历。

  // 编译模板
  compile(el) {
    // 获取子节点 
    let childNodes = [...el.childNodes].filter((node) => {
      // 过滤掉换行和空的文本节点
      return !(node.nodeType == "3" && node.nodeName == "#text" && /\s/.test(node.nodeValue));
    });
    childNodes.forEach((node) => {
      // 根据不同的节点类型进行编译
      // 文本类型的节点
      if (this.isTextNode(node)) {
        // 编译文本节点
        this.compileText(node);
      } else if (this.isElementNode(node)) {
        //元素节点
        this.compileElement(node);
      }
      // 判断是否还存在子节点考虑递归
      if (node.childNodes && node.childNodes.length) {
        // 继续递归编译模板
        this.compile(node);
      }
    });
  }
  // 判断是否是元素节点
  isElementNode(node) {
    return node.nodeType === 1;
  }
  // 判断是否是 文本 节点
  isTextNode(node) {
    return node.nodeType === 3;
  }

先看文本节点的处理流程:核心思想是利用把正则表达式,提取出{{}}里面的响应式变量,并把响应式变量的值赋值给当前节点。由于提取出的变量是data.name这种多级属性的格式,无法通过this.data[key]这种写法获取到属性值。所以需要用处理一下。我们写个getNestedProperty方法处理一下。

  // 编译文本节点(简单的实现)
  compileText(node) {
    // 再去Vue找这个变量赋值给node.textContent
    let reg = /\{\{(.+?)\}\}/;
    // 获取节点的文本内容
    let val = node.textContent;
    // 判断是否有 {{}}
    if (reg.test(val)) {
      // 获取分组一  也就是 {{}} 里面的内容 去除前后空格
      let key = RegExp.$1.trim();
      let value = getNestedProperty(this.data, key);
      // 进行替换再赋值给node
      node.textContent = val.replace(reg, value);
      
      // 这里的逻辑后面再说 ...
    }
  }

getNestedProperty函数中,在reduce方法中对传入的初始值obj,依次逐级查找切割出来的属性值,最终达到通过obj['a.b.b']这种书写方式, 获取obj.a.b.c的值的效果。

export function getNestedProperty(obj, key) {
  // 将key按照点号分隔成数组
  let keys = key.split(".");

  // 遍历数组,逐层深入对象
  return keys.reduce((acc, cur) => {
    // 如果acc不是对象,或者对象中不含有cur属性,返回undefined
    if (acc && acc.hasOwnProperty(cur)) {
      return acc[cur];
    } else {
      return undefined;
    }
  }, obj);
}

接着在说说元素节点的处理流程,遍历节点属性名称,查找有没有以v-开头的属性,如果有,提取出指令类型以及绑定的响应式变量。不同的指令走不同的处理流程。为了避免大量的判断,不同的指令执行的方法名借鉴了策略模式的思想。以指令名+固定后缀进行命名。

  // 编译元素节点这里只处理指令
  compileElement(node) {
    // 获取到元素节点上面的所有属性进行遍历
    [...node.attributes].forEach((attr) => {
      // 获取属性名
      let attrName = attr.name;
      // 判断是否是 v- 开头的指令
      if (this.isDirective(attrName)) {
        // 除去 v- 方便操作
        attrName = attrName.substr(2);
        // 获取 指令的值就是  v-text = "data.name"  中data.name
        let key = attr.value.trim();
        // 找到指令后执行指令操作
        // vue指令为了避免大量判断,使用了策略模式
        this.update(node, key, attrName);
      }
    });
  }
   // 添加指令方法 并且执行
  update(node, key, attrName) {
    // 比如添加 textUpdater 就是用来处理 v-text 方法
    // 我们应该就内置一个 textUpdater 方法进行调用
    // 使用了策略模式思想
    let updateFn = this[attrName + "Updater"];
    // 如果存在这个内置方法 就可以调用了
    updateFn && updateFn.call(this, node, key, getNestedProperty(this.data, key));
  }
  // 判断元素的属性是否是 vue 指令
  isDirective(attr) {
    return attr.startsWith("v-");
  }

v-text指令执行textUpdater方法,v-model指令执行modelUpdater方法, 都会将响应式变量的值赋值给节点,不同的是v-model指令需要添加dom事件,这样才能将视图数据同步给响应式变量。dom事件中的赋值操作需要处理一下,setNestedPropertygetNestedProperty是对称操作,内部逻辑参照getNestedProperty理解,就不详细展开了。

  textUpdater(node, key, value) {
    node.textContent = value;
    // 这里的功能后面再讲
  }
  modelUpdater(node, key, value) {
    node.value = value;
     
    // 这里实现双向绑定
    node.addEventListener("input", () => {
      // 由于key是data.name的形式,不能直接通过this.data['data.name']这种方式赋值,需要用setNestedProperty处理一下
      setNestedProperty(this.data, key, node.value);
    });
    
    // 这里的功能后面再讲
  }
function setNestedProperty(obj, key, value) {
  // 将key按照点号分隔成数组
  let keys = key.split(".");
  // 最后一个属性名
  let lastKey = keys.pop();
  // 遍历数组,逐层深入对象
  let nested = keys.reduce((acc, cur) => {
    if (!acc[cur]) {
      acc[cur] = {}; // 如果不存在该属性则创建空对象
    }
    return acc[cur];
  }, obj);

  // 设置最后一级的属性值
  nested[lastKey] = value;
}

现在已经能自动扫描出视图中用到的响应式变量了,并给相应的dom节点赋初始值。可是又面临下一个问题,当响应式数据和视图元素是一对一时,当响应式变量发生改变时,我们可以这样更新视图:

const reactive = (obj) => {
  return Proxy(obj, {
    set: function (target, key, value, receiver) {
      document.querySelector('#name').value=value;
      return Reflect.set(target, key, value, receiver);
    },
  });
};

可是实际应用场景中响应式变量和视图元素往往是是多对多的关系,如果还是按照原来的思路去编码。就像下面这样:

const reactive = (obj) => {
  return Proxy(obj, {
    set: function (target, key, value, receiver) {
      // 
      if (key === "x1") {
        domA.value = value;
        domB.textContent = value;
        // ...
      } else if (key === "x2" || key === "x3") {
        if(key === "x2"){
            domC.value =value+target.x3;
        }else if(key === "x3"){
            domC.value =value+target.x2;
        }
        // ...
      }
      // ...
      return Reflect.set(target, key, value, receiver);
    },
  });
};

当视图和模型数据都很多的时候,它们之间的依赖关系会变得难以维护。面对这个问题,得考虑使用设计模式来破局。那应该使用哪种设计模式呢?根据使用场景,你应该很容易想到观察者模式,对,就得用它,解决视图和模型之间依赖关系的维护问题。

引入观察者模式

使用观察者模式时,首先要清楚谁是被观察者,谁是观察者。很明显,在响应式系统这种场景中,模型数据是被观察者,视图是观察者。观察者模式的运作方式是被观察者把观察者添加到消息通知队列,当被观察对象属性发生变化时,通知所有的观察者。观察者需要实现一个接收通知方法,当收到新的消息时,执行自定义逻辑,一般多为更新数据操作。思路清晰了,现在我们理论结合一下实践,先实现被观察者的功能。

被观察者至少应该有两个方法,一个方法用来添加观察者(如addSub),另外一个方法用来通知观察者队列(如notify),

/* dep.js */
export default class Dep {
  constructor() {
    // 存储观察者
    this.subs = [];
  }
  // 添加观察者
  addSub(sub) {
    // 判断观察者是否存在 和 是否拥有update方法
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }
  // 通知方法
  notify() {
    // 触发每个观察者的更新方法
    this.subs.forEach((sub) => {
      sub.update();
    });
  }
}

观察者至少要实现一个接收通知的方法(如update),当被观察对象变化时,供被观察者通知调用。

/* watcher.js */
import Dep from "./dep.js";
import { getNestedProperty } from "./compiler.js";
export default class Watcher {
  constructor(data, key, cb) {
    this.data = data;
    // key 是 data 中的属性
    this.key = key;
    // cb 回调函数 更新视图的具体方法
    this.cb = cb;
    // 把观察者的存放在 Dep.target
    Dep.target = this;
    // 旧数据 更新视图的时候要进行比较
    // 在get 方法中把 观察者 通过dep.addSub(Dep.target) 添加到了依赖通知队列 dep.subs中
    this.oldValue = getNestedProperty(data, key);
    // Dep.target 就不用存在了 因为上面的操作已经存好了
    Dep.target = null;
  }
  // 观察者中的必备方法 用来更新视图
  update() {
    // 获取新值
    let newValue = getNestedProperty(this.data, this.key);
    // 比较旧值和新值
    if (newValue === this.oldValue) return;
    this.oldValue = newValue;
    // 调用具体的更新方法
    this.cb(newValue);
  }
}

何时添加观察者呢?应该在数据劫持的get方法中,将观察者添加到被观察者维护的观察者队列。那怎样才能触发get方法执行这个流程呢?

import Dep from "./dep.js";
export const reactive = (obj) => {
  let dep = new Dep();
  return Proxy(obj, {
    get: function (target, key, receiver) {
      // 在这里添加观察者对象 Dep.target 表示观察者
      Dep.target && dep.addSub(Dep.target);
      return Reflect.get(target, key, receiver);
    },
    // ...
  });
};

肯定是应该在compile函数中,第一次将响应式变量赋值给dom节点的时候,收集观察者比较合理。给视图中的dom元素赋值的时候,创建对应响应式变量的观察者,执行观察者类的构造器逻辑。

/* compiler.js */
import Watcher from "./watcher.js";
export default class Compiler {
  // ...
  // 编译文本节点(简单的实现)
  compileText(node) {
      // ...
      // 进行替换再赋值给node
      node.textContent = val.replace(reg, value);
      // 创建观察者
      new Watcher(this.data, key, (newValue) => {
        node.textContent = newValue;
      });
    }
  }

  // 提前写好 相应的指定方法比如这个 v-text
  textUpdater(node, key, value) {
    node.textContent = value;
    // 创建观察者
    new Watcher(this.data, key, (newValue) => {
      node.textContent = newValue;
    });
  }
  // v-model
  modelUpdater(node, key, value) {
    node.value = value;
    // 创建观察者
    new Watcher(this.data, key, (newValue) => {
      node.value = newValue;
    });
    // ...
}
// ...

当执行到观察者构造器方法中的 this.oldValue = getNestedProperty(data, key);这一句时,就会触发劫持数据中的get方法。

export default class Watcher {
  constructor(data, key, cb) {
    // ...
    // 把观察者的存放在 Dep.target
    Dep.target = this;

    // 触发响应式变量的get方法,在get方法中把观察者通过dep.addSub(Dep.target) 添加到了依赖通知队列dep.subs中
    this.oldValue = getNestedProperty(data, key);
    // Dep.target 就不用存在了 因为上面的操作已经存好了
    Dep.target = null;
  }

此时Dep.target在前一步已经赋值,所以Dep.target && dep.addSub(Dep.target);会得到执行,当劫持数据的get方法逻辑执行完成之后,接着执行会观察者构造器方法中的Dep.target = null;,不再符合将观察者添加到观察者列表的条件。

何时通知观察者,被观察者对象有变化? 应该在劫持数据的set方法中,通知观察者队列,被观察对象有更新。通过调用被观察者的notify,遍历执行每个观察者的update方法,通知相应的观察者,数据有更新。

import Dep from "./dep.js";
export const reactive = (obj) => {
  let dep = new Dep();
  return Proxy(obj, {
    // ...
    set: function (target, key, value, receiver) {
      const ret = Reflect.set(target, key, value, receiver);
      // 触发通知 更新视图
      dep.notify();
      return ret;
    },
  });
};

每个观察者执行视图数据更新的回调,就实现了模型数据更新,依赖这个模型数据的所有视图随之更新的效果。

不手撸一个极简版Vue响应式系统,记不住实现原理呀

现在我们把整个流程串一下:先定义响应式数据,对数据进行代理,在get方法中,收集观察者。在set方法中,通知观察者队列,数据有更新。 接着执行编译流程,递归遍历传入的根容器元素下面的所有子元素,识别出响应式变量并创建观察者,此外,对于需要进行双向数据绑定的变量,还需要创建响应的dom事件,在dom事件中完成视图数据改变到模型数据的更新操作。到此,就能实现本文开头的效果了,一个极简版的Vue响应式系统,已经被我们实现了。

<html>
  <head>
    <style>
      .label {
        display: inline-block;
        padding-left: 2px;
        width: 110px;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div>
        <span class="label">双括号变量:</span>
        <span>{{data.name}}</span>
      </div>
      <div>
        <span>v-model变量:</span>
        <input id="name" v-model="data.name" type="text" />
      </div>
      <div>
        <span class="label">v-text变量:</span>
        <span v-text="data.name"></span>
      </div>
    </div>
  </body>
  <script type="module">
    import { reactive } from "./reactive.js";
    import Compiler from "./compiler.js";

    let data = reactive({ name: "张三" });

    new Compiler(document.querySelector("#app"), { data });
  </script>
</html>

另外,reactive的实现方法需要改进一下,因为Proxy只能监听到对象第一层属性的变化,如果要支持对更深层次的属性的数据劫持,就需要做如下修改。

import Dep from "./dep.js";
export const reactive = (obj) => {
  let dep = new Dep();
  return deepProxy(obj, {
    get: function (target, key, receiver) {
      // 在这里添加观察者对象 Dep.target 表示观察者
      Dep.target && dep.addSub(Dep.target);
      return Reflect.get(target, key, receiver);
    },
    set: function (target, key, value, receiver) {
      // 判断旧值和新值是否相等
      if (target[key] === value) return true;

      const ret = Reflect.set(target, key, value, receiver);
      // 触发通知 更新视图
      dep.notify();
      return ret;
    },
  });
};

function deepProxy(target, handler) {
  if (typeof target !== "object" || target === null) {
    throw new Error("Target must be an object");
  }

  if (Array.isArray(target)) {
    target = target.slice();
  } else {
    target = { ...target };
  }

  for (let key in target) {
    if (typeof target[key] === "object" && target[key] !== null) {
      target[key] = deepProxy(target[key], handler);
    }
  }

  return new Proxy(target, handler);
}

最后

没动手亲自实现Vue的响应式系统之前,一直对数据劫持get方法中,观察者被添加到观察者队列的时机不是很清楚。对编译函数具体做了什么事情,也不是特别清楚,亲自手敲了一遍之后,才感觉豁然开朗。感觉很有收获。希望你看完此文,也能对Vue响应式的实现拨云见雾。本文的代码已上传到码云,欢迎点击下载,学习,探讨交流。

转载自:https://juejin.cn/post/7388347353407537189
评论
请登录