Vue底层学习5——插值文本编译与依赖收集

全手打原创,转载请标明出处:https://www.cnblogs.com/dreamsqin/p/15044033.html, 多谢,=。=~(如果对你有帮助的话请帮我点个赞啦)

作为一个Web前端开发人员,使用Vue框架进行项目开发已经有一阵子,掐指一算,是时候认真探索一下Vue的底层了,以前的了解比较偏理论,这一次打算在弄清基本原理的前提下自己手写Vue中的核心部分,也许这样我才敢说自己“深入理解”了Vue。上篇简述了整个编译器原理并拟定了三项编译目标,完成编译器框架搭建,在遍历Dom子节点时实现分流处理,本篇主要实现第一个目标插值文本编译和依赖收集~

插值文本编译

由上一篇提供的demo2可以得到如下的运行结果:

但实际上我们想要展示的是各个变量对应的值,而不是变量名,所以需要编译Dom中的插值变量,并将其替换为对应的值,这里新建一个compileText方法实现:

/*** compile.js ***/
// new Compile(el, vm)

class Compile{
  constructor(el, vm) {
    // 需要遍历的Dom节点
    this.$el = document.querySelector(el);
    // 数据缓存
    this.$vm = vm;

    // 编译
    if (this.$el) {
      // 提取指定节点中的内容,提高效率,减少Dom操作
      this.$fragment = this.node2Fragment(this.$el);
      // 执行编译
      this.compile(this.$fragment);
      // 将编译完的html追加至$el
      this.$el.appendChild(this.$fragment);
    }
  }

  // 提取指定Dom节点中的代码片段
  node2Fragment(el) {
    const fragment = document.createDocumentFragment();
    // 将el中的所有子元素移动至fragment中
    let child = null;
    while(child = el.firstChild) {
      fragment.appendChild(child);
    }
    return fragment;
  }

  // 编译过程
  compile(el) {
    const childNodes = el.childNodes;
    Array.from(childNodes).forEach(node => {
      // 类型判断
      if (this.isElement(node)) {
        // 节点
        console.log('编译节点' + node.nodeName);
      } else if(this.isInterpolation(node)) {
        // 编译插值文本
        this.compileText(node);
      }

      // 递归子节点
      if (node.childNodes && node.childNodes.length > 0) {
        this.compile(node);
      }
    })
  }

  isElement(node) {
    return node.nodeType === 1;
  }

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

  // 插值文本编译
  compileText(node) {
    node.textContent = this.$vm.$data[RegExp.$1];
  }
}

需要特别注意的是RegExp.$1的巧用,在做子节点分流时我们通过正则表达式对插值文本进行了匹配分组,所以在执行compileText方法时我们可以通过RegExp.$1获取到分组中的内容,也就是插值括号{{}}中的变量,例如namelocationlocationAgain,然后通过传递的Vue实例this.$vm获取到$data中的属性变量值,再对节点内容进行替换操作,最终运行结果如下:

可以看到页面中的变量成功被替换,但这种方式只会初始化一次,当变量值发生改变时,页面中展示的内容是不会同步变更的,可以利用demo2(源码可参见《Vue底层学习4——编译器框架搭建》)中created方法的延迟赋值操作测试一下,我们在MVue的构造函数中执行一下created方法:

/*** MVue.js ***/
// new MVue({ data: {...} })

class MVue {
  constructor(options) {
    // 数据缓存
    this.$options = options;
    this.$data = options.data;

    // 数据遍历
    this.observe(this.$data);

    new Compile(options.el, this);

    // created执行
    if (options.created) {
      options.created.call(this);
    }
  }
}

调用时使用call绑定this指向是为了方便在Vue实例的created方法中轻松使用this访问当前的Vue实例对象,例如我们日常用this.data去访问实例的数据属性。created执行后结果如下:

开始啦成功打印,但name的重新赋值并没有同步更新至页面,与上面的猜想一致。其主要原因是没有做依赖收集,也就是之前MVue.jsconstructor 中模拟Watcher 激活getter的部分,除此之外,我们编译器中还需要一个更新函数,之前Watcherupdate方法都是通过console实现视图更新的预留,这些事还是得编译器来完成。

更新函数

触发更新的操作有很多,视图中不仅仅只有插值文本,还有一系列的v-指令或者事件,所以我们需要抽象出一个更新函数供所有的触发调用,在编译器中定义一个更新函数update,它接收4个参数,分别表示需要更新的节点当前的Vue实例属性标识触发更新的指令标识

/*** compile.js ***/
// new Compile(el, vm)

class Compile{
  // 更新函数
  update(node, vm, exp, dir) {
    const updateFn = this[dir + 'Updater'];
    // 如果存在就执行,实现初始化
    updateFn && updateFn(node, vm.$data[exp]);
  }
}

updateFn的执行只能达到初始化的作用,跟上述compileText函数实现的效果一致,但当数据变更时想要同步更新,就需要做依赖收集,跟之前模拟的一样,我们需要创建一个Watcher实例,接收3个参数,分别表示当前的Vue实例属性标识当属性变更时执行的更新回调函数

/*** compile.js ***/
// new Compile(el, vm)

class Compile{
  // 更新函数
  update(node, vm, exp, dir) {
    const updateFn = this[dir + 'Updater'];
    // 如果存在就执行,实现初始化
    updateFn && updateFn(node, vm.$data[exp]);
    
    // 依赖收集
    new Watcher(vm, exp, function(value) {
      updateFn && updateFn(node, value);
    });
  }
}

那么对于插值文本的更新我们就需要创建一个对应的更新函数textUpdater,并且之前用于插值文本编译的compileText函数就需要做对应的变更:

/*** compile.js ***/
// new Compile(el, vm)

class Compile{
  // 更新函数
  update(node, vm, exp, dir) {
    const updateFn = this[dir + 'Updater'];
    // 如果存在就执行,实现初始化
    updateFn && updateFn(node, vm.$data[exp]);
    
    // 依赖收集
    new Watcher(vm, exp, function(value) {
      updateFn && updateFn(node, value);
    });
  }
  
  // 插值文本更新
  textUpdater(node, value) {
    node.textContent = value;
  }
  
  // 插值文本编译
  compileText(node) {
    this.update(node, this.$vm, RegExp.$1, 'text');
  }
}

可以看到以前我们在模拟依赖收集时,实例化Watcher时是不会传参的,但是现在接收了3个参数,所以需要同步修改MVue中的Watcher类,并通过Watcher拿到的Vue实例及属性标识激活getter实现依赖收集:

/*** MVue.js ***/
class Watcher {
  constructor(vm, exp, cb) {
    // 数据缓存
    this.$vm = vm;
    this.$key = exp;
    this.$cb = cb;

    // 将当前Watcher的实例指定到Dep静态属性target
    Dep.target = this;
    
    // 激活属性的getter,添加依赖
    this.$vm.$data[this.$key];
    // 置空,防止重复添加
    Dep.target = null;
  }

  update() {
    // 预留视图更新
    console.log('数据更新了,需要我们更新视图');
  }
}

那么现在预留的视图更新就可以直接执行传入的cb回调了,并绑定其中的this指向为当前的Vue实例,同时将修改后的值作为参数传递进去:

/*** MVue.js ***/
class Watcher {
  constructor(vm, exp, cb) {
    // 数据缓存
    this.$vm = vm;
    this.$key = exp;
    this.$cb = cb;

    // 将当前Watcher的实例指定到Dep静态属性target
    Dep.target = this;
    
    // 激活属性的getter,添加依赖
    this.$vm.$data[this.$key];
    // 置空,防止重复添加
    Dep.target = null;
  }

  update() {
    // 视图更新
    this.$cb.call(this.$vm, this.$vm.$data[this.$key]);
  }
}

为了方便我们获取和设置data中的属性,我们可以做一层代理,将data属性挂载到Vue的实例上,实现通过Vue实例就可以直接访问或设置data属性:

/*** MVue.js ***/
// new MVue({ data: {...} })

class MVue {
  constructor(options) {...}

  observe(data) {
    // 确定data存在并且为对象
    if (!data || typeof data !== 'object') {
      return;
    }

    // 遍历data对象
    Object.keys(data).forEach(key => {
        // 重写对象属性的getter和setter,实现数据的响应化
        this.defineReactive(data, key, data[key]);
        
        // 代理data中的属性到Vue实例上
        this.proxyData(key);
    })
  }

  defineReactive(obj, key, val) {...}

  proxyData(key) {
    Object.defineProperty(this, key, {
      get: function() {
        return this.$data[key];
      },
      set: function(newVal) {
        this.$data[key] = newVal;
      }
    })
  }
}

接下来就可以把代码中通过this.$vm.$data访问或设置data中属性的操作修改为this.$vm直接进行访问和设置,修改后的代码就不贴出来了,全局搜索一下~

下面就是见证奇迹的时刻,再次运行一下demo2,效果如下,1.5s左右后视图被同步更新了:

参考资料

1、Vue源码:https://github.com/vuejs/vue

posted @ 2021-07-22 14:42  Dreamsqin  阅读(193)  评论(0编辑  收藏  举报