实现简单版Vue

283 阅读4分钟

这是我参与8月更文挑战的第8天,活动详情查看: 8月更文挑战” juejin.cn/post/698796…

介绍

本文目的是实现一个简易版本的Vue用以学习,也是对从网课学习的总结和复习。其中内容仅为简易实现,多有不足之处,请多多交流。

内容拆分

  • 数据响应式处理

    • 数据拦截
    • 数组和对象的区分处理
    • 数据代理
  • 模板编译

    • 对文本的处理
    • 对元素特性(即指令)的处理
  • 页面渲染

    • 依赖收集
    • 创建watcher实例
    • 触发更新

项目测试

本文目的仅为简单版Vue实现,用于学习总结。所以采用html引入js方式进行测试即可。

<!--主要代码-->
<body>
  <div id="app">
    <p>{{counter}}</p>
    <p k-text="counter"></p>
    <p k-html="desc"></p>
  </div>
  <script src="./kvue.js"></script>
  <script>
    const app = new KVue({
      el: '#app',
      data: {
        counter: 1,
        desc: '<p>村长<span style="color:red">真棒</span></p>'
      },
      methods: {
        onclick() {
          console.log(this);
        }
      },
    })
    // 暂时可不放开
    /*setInterval(() => {
       app.counter++
    }, 1000);*/
  </script>
</body>

vue实现

数据响应式处理

数据拦截

有了解过Vue框架原理的同学都知道,vue2.x中数据拦截使用的是Object.defineProperty方法,代码如下:

function defineReactive(obj,key,val){
  Object.defineProperty(obj,key,{
    get(){
      return val;
    },
    set(newVal){
      if(newVal != val){
        val = newVal;
      }
    }
  }}
}

属性拦截

Object.defineProperty方法的三个参数:

  • obj 要拦截的数据对象

  • key 要给数据对象添加的属性

  • {xxx} 属性描述器

    • 属性描述器分为两种:存取描述器和数据描述器,都是对象,此处为存取描述器。
configurableenumerablevaluewritablegetset
数据描述符可以可以可以可以不可以不可以
存取描述符可以可以不可以不可以可以可以
  • 此处最重要的属性为get和set属性。相当于给obj设置了key属性,当访问obj.key时触发拦截操作,执行的是get方法;obj.key的值发生变化时执行的是set操作。
  • 因为知道了数据被读取和变化时的地方,所以我们可以在这些地方加入一些其他的操作。

闭包写法

在此处利用了闭包的写法。通过函数作用域内的某个函数将局部变量保留出去,即为闭包。

  • 局部作用域:val被保存在defineReactive函数中作用域
  • 通过defineProperty的get方法将val暴露出去

闭包中的局部变量不会被释放,一直保存在内存中。所以如果对值做了修改就会发生变化。

数组和对象的区分处理

相对于普通类型数据,进行拦截时不需要过多考虑,直接返回即可。而对于数组和对象的数据,依据不同的处理方法,需要做不同的处理。

class Observer {
  constructor(value) {
    if (Array.isArray(value)) {
      // xxx
    } else {
      this.walk(value);
    }
  }
  walk(obj) {
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])
    })
  }
}
function observe(obj){
  if (typeof obj != 'object') {
      return obj;
  }
  new Observer(obj);
}

对象嵌套问题

考虑到数据的值可能是嵌套对象,所以需要在数据拦截时进行递归处理。

function defineReactive(obj,key,val){
  observe(obj);
  Object.defineProperty(obj,key,{
    get(){
      return val;
    },
    set(newVal){
      if(newVal != val){
        observe(newVal);
        val = newVal;
      }
    }
  }}
}
  • observe(obj):当数据不为对象类型时,observe方法直接将数据返回,继而执行下面的数据拦截操作;当数据是对象类型时,会执行Observer类的操作去区分数组和对象,分别进行处理
  • observe(newVal):此处的设置主要是考虑到给数据直接赋值对象的操作,需要将新对象进行数据响应式之后赋给数据。

数据代理

这一步的操作主要是用于可以通过Vue实例直接访问data中的数据,形如:this.xxx。经过了上一步observe的操作之后,访问data中数据需要通过this.$data.xxx形式访问,比较麻烦。

function proxy(vm) {
  Object.keys(vm.$data).forEach(key => {
    Object.defineProperty(vm, key, {
      get() {
        return vm.$data[key]
      },
      set(v) {
        vm.$data[key] = v;
      }
    })
  })
}

模板编译

对于vue模板语法和指令等,很多人都是了解的,但是对于背后的逻辑实现,却是一知半解。对于模板的编译需要实现一个编译器来进行这些操作。

class Compile {
  constructor(el, vm) {
    this.$vm = vm;
    this.$el = document.querySelector(el);
    // 执行编译
    this.compile(this.$el);
  }
  compile(el) {
    el.childNodes.forEach(node => {
      if (node.nodeType === 1) {
        // element元素
        // 遍历元素特性
        this.compileElement(node);
        // 递归
        if (node.childNodes.length > 0) {
          this.compile(node);
        }
      } else if (this.isInter(node)) {
        // text文本
        // 插值表达式
        this.compileText(node)
      }
    })
  }

对文本的处理

对文本的处理,首先需要辨别插值表达式,可以通过一个正则来区分。

isInter(node) {
    return node.nodeType === 3 && /{{(.*)}}/.test(node.textContent);
  }

之后对于插值表达式的文本替换,基于上文正则表达式可以获得插值表达式中的变量名为RegExp.$1。

compileText(node) {
    node.textContent = this.$vm[RegExp.$1];
}

对元素特性(即指令)的处理

对元素的处理,主要是对元素特性的处理。对特性attributes进行遍历。

compileElement(node) {
  const attrs = node.attributes;
  Array.from(attrs).forEach(attr => {
    const attrName = attr.name;
    const attrExp = attr.value;
    if (attrName.startsWith('k-')) {
      // 对指令的处理
      const dir = attrName.substring(2);
      this[dir] && this[dir](node, attrExp);
    }
  })
}
// k-text
text(node, exp) {
  node.textContent = this.$vm[exp]
}
// k-html
html(node, exp) {
  node.innerHTML = this.$vm[exp];
}

两步结合

将前两步结合一起。

class KVue {
  constructor(options) {
    this.$options = options;
    this.$data = options.data;
    // 1.响应式
    observe(this.$data);
    // 1.1  代理:用户可以通过KVue实例直接访问data中数据
    proxy(this)
    // 2.编译:传入宿主元素el和组件实例this
    new Compile(options.el, this)
  }
}

页面渲染

在Vue中数据与Dep、Watcher的对应关系是:1个数据 => 1个Dep => n个Watcher

依赖收集

根据上文的对应关系,Dep中存储着多个Watcher实例,所以应该是数组形式。同时Dep应该有一个添加方法和触发按更新的方法。

class Dep {
  constructor() {
    // 存储所有的watcher
    this.deps = [];
  }
  addDep(watcher) {
    this.deps.push(watcher);
  }
  notify() {
    this.deps.forEach(dep => dep.update())
  }
}

创建watcher实例

Watcher中需要有一个更新方法,另外需要注意的就是触发依赖筹集的地方,在下一步综合述说。

class Watcher {
  constructor(vm, key, fn) {
    // fn : 更新函数
    this.vm = vm;
    this.key = key;
    this.fn = fn;

    // 触发依赖收集:读取一次key
    Dep.target = this;  // 保存当前实例
    this.vm[this.key];  // 读取一次key,触发getter
    Dep.target = null;
  }
  update() {
    this.fn.call(this.vm, this.vm[this.key])
  }
}

触发更新

这一步的实质就是对Dep和Watcher的应用。

Dep应用

首先需要确定dep实例应该被创建的位子在哪里,由 1个数据 => 1个Dep 的关系可以确定,dep创建应与数据拦截在一处。

unction defineReactive(obj, key, val) {
  observe(val);
  //  创建对应的Dep实例
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    get() {
      console.log('get', key, val);
      //建立映射关系
      // Dep.target就是watcher实例,自行触发getter并将自身填入dep中
      Dep.target && dep.addDep(Dep.target);
      return val;
    },
    set(newVal) {
      console.log('set', key, newVal);
      if (newVal != val) {
        // 新设置的值也可能是对象:解决直接给已有属性赋值对象问题
        observe(newVal)
        val = newVal;
        dep.notify();
      }
    }
  })
}
  • const dep = new Dep()

创建和数据对应的dep实例

  • dep.notify()

这里是对数据的属性值进行修改后,需要对应的dep实例通知相关Watcher实例进行更新。

  • Dep.target && dep.addDep(Dep.target)

这里是需要配合Watcher中的一段代码进行解释。

// 触发依赖收集:读取一次key
Dep.target = this;  // 保存当前实例
this.vm[this.key];  // 读取一次key,触发getter
Dep.target = null;

Dep.target即为Watcher实例,下一步读取数据的key时会触发数据拦截的get方法,所以在这里通过dep实例的addDep方法将Watcher实例填充进入数据对应的dep数组中。之后再讲Dep.target清空为null。

Watcher应用

Watcher和页面上应用的动态数据一一对应,所以创建Watcher实例的地方最好是在编译模板里。结合上文中模板的编译的处理步骤,我们可以建立一个统一的update方法。

update(node, exp, dir) {
  // 获取实操函数
  const fn = this[dir + 'Updater'];
  // 初始化
  fn && fn(node, this.$vm[exp]);
  // 更新
  new Watcher(this.$vm, exp, function (val) {
    fn && fn(node, val);
  });
}

// k-text
text(node, exp) {
  this.update(node, exp, 'text')
}
textUpdater(node, val) {
  node.textContent = val;
}
// k-html
html(node, exp) {
  this.update(node, exp, 'html')
}
htmlUpdater(node, val) {
  node.innerHTML = val;
}
// 将插值表达式编译为文本
compileText(node) {
  this.update(node, RegExp.$1, 'text')
} 

至此,可以进行项目的调试,基本可以实现效果。