《源码系列》助你理解vue响应式源码——实现Compile编译器

335 阅读4分钟

前言

接下来的几篇文章都是关于手撕vue响应式源码的,我会尽量写得通俗易懂一点,毕竟有的东西确实不太好以这样的形式来表达,然后也是想把自己学到的一些东西形成文字记录下来并分享出来帮助一些有需要的同学。由于篇幅较长将会按顺序分为上、中、下三章分别对应实现Compile编译器实现Observer数据劫持实现Watcher观察者,这篇文章就先从Compile说起。

先来看一张图

image.png

这是vue实现响应式的几个关键步骤,vue 采用数据劫持结合发布订阅模式,通过Object.defineProperty()来劫持各个属性的gettersetter,在数据发生变动的时候发布消息给订阅者然后触发相应的监听回调。结合上图,首先把所有传递进来的数据通过Observer来进行劫持监听,每一个属性都需要对应一个观察者Watcher然后通过Dep订阅器容器进行收集,一旦数据发生变化会立即触发setter,然后在订阅器中找到对应的WatcherWatcher就会去通知视图更新。Watcher要在什么时候进行绑定呢?在Compile解析指令的时候会去对节点进行更新,当订阅数据变化时就应该绑定对应的更新函数。下面将会围绕这张图进行展开。

实现解析器Compile

作用

  1. 初始化视图,能够对元素中的指令以及mustache语法进行解析
  2. 订阅数据变化并绑定更新函数(后面章节中会体现)

实现

实现目标:

  1. v-text
  2. v-html
  3. v-model
  4. v-bind
  5. v-on
  6. {{ }}语法

首先新建一个vue.html和MVue.js文件:

  // vue.html 创建模板
  // 写上我们平时使用的指令以及mustache语法,接下来就是对它们进行解析、匹配以及替换
  <div id="app">
    <h1>{{person.name}}</h1>
    <h2>{{person.age}}</h2>
    <h3>{{msg}}</h3>
    <p v-text="msg"></p>
    <p v-text="person.name"></p>
    <p v-html="htmlStr" v-on:click="handleClick"></p>
    <input type="text" v-model="msg">
    <button v-on:click="handleClick">AI</button>
    <img v-bind:src="url" alt="">
  </div>

编写入口函数:

   // MVue.js
   // 在vue.html文件中引入我们自定义的vue
  <script src="./MVue.js"></script>
  <script>
    let vm = new MVue({
      el: "#app",
      data: {
        person: {
          name: "Joker",
          age: 20,
        },
        msg: "贪念表现恰当 就像索要嫁妆",
        htmlStr: "<h1>失去你以来 万物在摇摆</h1>",
        url: 'https://img2.baidu.com/it/u=4043708940,4270266102&fm=253&fmt=auto&app=120&f=JPEG?w=801&h=500'
      },
      methods: {
        handleClick() {
          console.log(this);
        }
      }
    })
  </script>

image.png

可以看到现在页面上并没有按照我们预期的那样显示,如何在页面中显示出我们定义好的数据呢?接下来就要开始在MVue文件中实现MVue这个类。以下代码都是在MVue.js文件中编写

声明 MVue 类

// 声明MVue类
class MVue {
    constructor(options){ // 我们传递过来的options对象
        this.$el = options.el; // 保存根元素节点
        this.$data = options.data; // 保存定义的数据信息
        this.$options = options;
        
        // 实现一个解析器
        if(this.$el){ // 如果传递了根元素节点才会进行解析
            new Compile(this.$el,this) 
        }
    }
}

new Compile()方法中传递根元素是因为我们需要对根元素下的所有子节点进行编译,还需要通过this实例对象中的属性获取到我们定义的数据。

声明 Compile 类

class Compile {
    constructor(el,vm){
       // 实现根元素既可以传入一个字符串也可以传入一个dom元素
       this.el = this.isElementNode(el) ? el : document.querySelector(el)
    }
    
    /**判断是否为元素节点
     * 1表示元素节点、2表示属性节点、3表示文本节点
     */
    isElementNode(node) {
        return node.nodeType == 1;
    }
}

接下来就需要编译根节点下的每个子节点,然后解析出来对应的语法后对其进行替换,这时候就涉及到一个性能的问题,每次匹配对应的的语法就进行一次替换会导致页面的回流和重绘,为了提高性能我们可以使用fragment文档碎片来实现。这也是在源码内部使用的一种方式。

Tips 文档碎片
文档碎片是什么

文档碎片独立于DOM树之外存储于内存中,可以理解成是一个容器,用于暂时存放一些dom元素。可以使用document.createDocumentFragment()来进行创建。

文档碎片的作用

在操作dom的时候其实是一个很消耗性能的过程,每次改变dom结构浏览器都需要重新进行计算,引入文档碎片的目的就是将我们每次对dom结构的修改先存储到文档碎片中,然后再将文档碎片添加到需要插入的位置,这样一来就只改变了一次dom结构,大大减少了性能的消耗。

For example:

// 普通方式 操作100次dom
for(var i = 100; i < 0; i--){
    const ele = document.createElement('div');
    document.body.appendChild(ele)
}

// 文档碎片方式 操作1次dom
let fragment = document.createDocumentFragment();
for(var i = 100; i < 0; i--){
    const ele = document.createElement('div');
    fragment.appendChild(ele)
}
document.body.appendChild(fragment)

一、获取文档碎片对象

使用文档碎片对象减少页面的回流和重绘。

class Compile {
    constructor(el,vm){
       this.el = this.isElementNode(el) ? el : document.querySelector(el);
       // 获取文档碎片对象
       const fragment = this.nodeFragment(this.el);
       console.log(fragment);
    }
    
    // 创建文档碎片对象
    nodeFragment(el) {
        const f = document.createDocumentFragment();
        let firstChild;
        // 将根节点下的子节点全部添加到文档碎片中
        while (firstChild = el.firstChild) {
            f.appendChild(firstChild)
        }
        return f;
    }
}

image.png

此时页面中没有任何显示是正常的,因为全部添加到了文档碎片中,为了便于我们实时查看效果,我们先把文档碎片插入到dom树中。

class Compile {
    constructor(el,vm){
       this.el = this.isElementNode(el) ? el : document.querySelector(el);
       // 获取文档碎片对象
       const fragment = this.nodeFragment(this.el);
       // 追加到根元素中
       this.el.appendChild(fragment)
    }
}

二、编译模板

接下来开始对它进行编译,主要分为对元素节点中的指令和文本节点中的mustache语法进行编译。

class Compile {
    compile(fragment) {
       // 获取子节点
       const childNodes = fragment.childNodes;
       // 转为数组后循环遍历每一个子节点
       [...childNodes].forEach(child => {
           // 判断是否是元素节点
           if (this.isElementNode(child)) { // 元素节点
               this.compileElement(child)
           } else {  // 文本节点
               this.compileText(child);
           }
           // 递归遍历子节点中的节点
           if (child.childNodes && child.childNodes.length) {
               this.compile(child)
           }
       })
   }
}
编译元素节点

先编写compileElement方法吧,在这个方法中我们需要获取元素的属性信息,区分出不同的属性名和属性值,然后根据属性名去调用不同的更新视图的方法,根据属性值从定义的data数据中匹配出定义的原始数据,即最终页面中要显示的值。

    const compileUtil = {
        text(node, expr, vm) {
       
        },
        html(node, expr, vm) {
          
        },
        model(node, expr, vm) {
          
        },
        on(node, expr, vm, eventName) {
        
        },
        bind(node, expr, vm, eventName) {
            
        },
        updater: {
        
        }
    }

    // 编译元素节点
    compileElement(node) {
        // 获取节点的属性信息 结果是一个对象 即:{0: v-text}  {0: v-html}
        const attrs = node.attributes;
        [...attrs].forEach(attr => {
            const { name, value } = attr;
            if (this.isDirective(name)) {
                const [, directive] = name.split("-"); // text html model
                // 编译并更新数据
                compileUtil[dirName](node, value, this.vm, eventName);
                // 递归遍历子节点中的节点
                if (child.childNodes && child.childNodes.length) {
                    this.compile(child)
                }
            }
        })
    }
    // 判断属性是指令即是否以v-开头
    isDirective(str) {
        return str.startsWith('v-');
    }

思路分析:在上述代码中,获取每个元素节点中的属性信息后,可以使用策略模式来进行更新视图的操作,具体做法是声明一个对象里面存放不同属性对应的不同更新视图的方法,比如使用了v-text指令并且已经匹配到了text属性,那么就执行compileUtil对象中的text对应的逻辑。若子节点中还有元素节点则进行递归操作,例如ul标签下还有li标签。

先来写compileUtil对象中的text方法,因为每个方法都需要进行更新操作所以我们可以写一个方法专门用来执行更新视图的方法updater

const compileUtil = {
    text(node, expr, vm) {
        const value = vm.$data[expr];
        this.updater.textUpdater(node, value);
    },
     updater: {
        textUpdater(node, value) {
            node.textContent = value;
        }
    }
 }

这时页面中的使用v-text指令的元素就会生效显示出我们定义好的原始数据,但是遇到v-text='person.name'这种有对象存在该怎么办呢?我们这样进行处理:

  getVal(expr, vm) {
        // 兼容属性值为对象的情况
        return expr.split(".").reduce((pre, cur) => {
            return pre[cur];
        }, vm.$data)
    },

通过在compileUtil对象中创建getVal方法来统一对属性值进行处理,这样不管属性值是单纯的一个属性还是一个对象中的属性都可以完美的获取到其值。这个方法接收两个参数,一个是属性值,一个是实例对象,以.进行分割,这样不是对象形式的属性值不会收到影响,是对象形式的话就会被分割成存在由属性元素组成的数组,然后通过reduce方法逐级进行查找知道查找到对象的最后一个属性并返回其值。

那么以上代码就可以进行改造:

const compileUtil = {
   getVal(expr, vm) {
        return expr.split(".").reduce((pre, cur) => {
            return pre[cur];
        }, vm.$data)
    },
    text(node, expr, vm) {
        const value = this.getVal(expr,vm);
        this.updater.textUpdater(node, value);
    },
     updater: {
        textUpdater(node, value) {
            node.textContent = value;
        }
    }
 }

以此类推,v-html v-model v-bind就都能被解析出来了。

const compileUtil = {
   getVal(expr, vm) {
        return expr.split(".").reduce((pre, cur) => {
            return pre[cur];
        }, vm.$data)
    },
    text(node, expr, vm) {
        const value = this.getVal(expr,vm);
        this.updater.textUpdater(node, value);
    },
    html(node, expr, vm) {
        const value = this.getVal(expr, vm);
        this.updater.htmlUpdater(node, value);
    },
    model(node, expr, vm) {
        const value = this.getVal(expr, vm);
        this.updater.modelUpdater(node, value)
    },
    bind(node, expr, vm, eventName) {
        const value = this.getVal(expr, vm);
        this.updater.bindUpdater(node, eventName, value)
    },
    updater: {
        textUpdater(node, value) {
            node.textContent = value;
        },
        htmlUpdater(node, value) {
            node.innerHTML = value;
        },
        modelUpdater(node, value) {
            node.value = value;
        },
        bindUpdater(node, prop, value) {
            node.setAttribute(prop, value)
        }
    }
 }

再说一下v-on指令,compileUtil[dirName](node, value, this.vm, eventName)中的eventName参数就是用来判断是什么事件类型的,于是在compileUtil对象中增加on方法,里面的逻辑就是匹配到v-on指令后会从methods属性中查找对应的事件处理函数并修改this执行为当前实例对象。

   on(node, expr, vm, eventName) {
        let fn = vm.$options.methods && vm.$options.methods[expr];
        node.addEventListener(eventName, fn.bind(vm), false)
    },

到此v-on也已经实现了,我们知道平时开发中为了方便会用@代替v-on指令 @click="handleClick", 实现这个功能其实也简单,只需要对@进行匹配,匹配到后逻辑与v-on指令的处理逻辑是一样的只是换了一种书写方式,直接调用on方法即可。

    compileElement(node) {
        const attrs = node.attributes;
        [...attrs].forEach(attr => {
            const { name, value } = attr;
            if (this.isDirective(name)) {
                const [, directive] = name.split("-"); // text html model
                const [dirName, eventName] = directive.split(':')
                compileUtil[dirName](node, value, this.vm, eventName);
                // 删除有指令的标签上的属性
                node.removeAttribute("v-" + directive)
            } else if (this.isElementName(name)) {
                const [, eventName] = name.split("@");
                compileUtil['on'](node, value, this.vm, eventName)
            }
        })
    }
    // 事件监听是否使用的是 @ 
    isElementName(str) {
        return str.startsWith('@');
    }

image.png

检查元素样式会发现标签上还会带着指令的样式

image.png

只需要对节点上属性进行删除就行了 node.removeAttribute("v-" + directive)

编译文本节点

接下来对文本节点中的mustache语法进行编译,声明一个createText方法,在里面进行双打括号的匹配然后手动调用v-text的更新方法即可。

    // 编译文本节点
    compileText(node) {
        const content = node.textContent;
        if (/\{\{(.+?)\}\}/.test(content)) {
            compileUtil["text"](node, content, this.vm);
        }
    }

现在compileUtil对象中的text方法只针对带有v-的指令做了处理,所以我们还需要对此方法进行丰富,判断传递进来的值是双大括号还是指令,如果是双大括号的话就将其替换。

    text(node, expr, vm) {
        let value;
        if (expr.indexOf("{{") !== -1) {
            // 全局匹配{{}}
            value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
                return this.getVal(args[1], vm);
            })
        } else { // 指令形式
            value = this.getVal(expr, vm)
        }
        this.updater.textUpdater(node, value)
    },

1709216629698.png

到现在编译阶段的工作就算完成了!下一步要做的就是对数据的监听即Observer传递门

完整代码