vue早期源码学习系列之七:如何实现"v-if"条件渲染

@youngwind 2016-09-11 07:30:10发表于 youngwind/blog Vue

前言

相信大家都用过vue非常好用的v-if功能,那么它是如何实现的呢?回顾一下之前我们已经实现的动态数据绑定 #87 ,我们动态绑定的是一个普通文本节点和一个数据之间的关系。当数据发生改变时,修改文本节点值。
但是,我们现在要做的是,当数据发生改变时,渲染插入某个节点或者把某个节点从DOM中移除,而且这个节点不是普通的文本节点。
所以,我们不能照搬之前的那一套,需要做一些改动。

问题具象化

考虑下面的例子
这个例子是较为简单的例子,因为b-if里面不包含user.name这样的变量,我称它为不带变量的条件渲染

// html
<div id="app">
    <p>姓名:{{user.name}}</p>
    <p>年龄:{{user.age}}</p>
    <div b-if="show" id="#sub_app">
        <h1>如果show为真,我们就显示</h1>
        <h1>如果show为假,我们就不显示</h1>
    </div>
</div>
const app = new Bue({
    el: '#app',
    data: {
        show: true,
        user: {
            name: 'youngwind',
            age: 24
        }
    }
});

问题是:如何做到,当show为true时,渲染整个div。当show为false,不渲染整个div。

不带变量的条件渲染

首先,b-if指令对应的div结构内部可能是一个很复杂的DOM结构(比如上面的例子,b-if指令内部就包含两个h1标签),所以,我们更应该把"b-if"对应的DOM结构看成是一个新的vue实例,而非一个普通的Directive。我们将要实现的是:一个vue实例嵌套另一个vue实例,父实例是#app,子实例是#sub_app。
如何做到呢?我们从修改渲染节点函数入手:

/**
 * 渲染节点
 * @param node {Element}
 * @private
 */
exports._compileElement = function (node) {
    let hasAttributes = node.hasAttributes();

   // 添加了这个判断,如果包含b-if指令,那么就做特殊处理,不走原先的DOM遍历了
    if (hasAttributes && this._checkPriorityDirs(node)) {
        return;
    }

    if (node.hasChildNodes()) {
        Array.from(node.childNodes).forEach(this._compileNode, this);
    }
};

代码写到这儿,我们就可以看到_directive数组中就多了一个show的Directive了,如下图所示。
demo1

// 这里定义了一些特殊的指令,如v-if,碰到他们就做特殊处理
const priorityDirs = [
    'if'
];

/**
 * 检查node节点是否包含某些如 "v-if" 这样的高优先级指令
 * 如果包含,那么就不用走原先的DOM遍历了, 直接走指令绑定
 * @param node {Element}
 * @private
 */
exports._checkPriorityDirs = function (node) {
    priorityDirs.forEach((dir) => {
        let value = _.attr(node, dir);  // 获取b-if指令的值,此为"show"
        if (value) {
           // _bindDirective是我们在动态绑定的时候就做好的, 如果不明白这一块,请往前面的文章翻
            this._bindDirective(dir, value, node);  
            return true;
        }
    });
};

然后,接下来是重点。
对于一个受指令控制的DOM节点,如例子中的b-if,它其实至少有两个生命周期:一个是初始化,第一次解析DOM的时候,我们称之为bind;另一个是当数据变化时,DOM节点会更新,我们称之为update。
回想一下,我们之前构造Directive的时候其实就已经隐含这样的思想,如下面代码所示(这是之前就有的代码)

Directive.prototype._bind = function () {
    if (!this.expression) return;

   // 这里执行初始化
    this.bind && this.bind();

    this._watcher = new Watcher(
        this.vm,
        this.expression,
        this._update,  // 回调函数,目前是唯一的,就是更新DOM
        this           // 上下文
    );

    // 这里执行更新
    this.update(this._watcher.value);
};

所以,得出的结论是:我们需要为b-if指令也定义这样的bind和update方法,分别完成初始化和更新的动作。
所以就有了下面的代码:

// if.js

/**
 * 此函数在初次解析v-if节点的时候执行
 * 作用是用一个注释节点占据原先的v-if节点位置
 * (其实就差不多相当于:对于文本节点,就用一个空的文本节点代替他一样。
 */
exports.bind = function () {
    let el = this.el;
    // 这个注释节点就是用来占位的,好让我们记住原先的b-if指令DOM结构在哪儿
    this.ref = document.createComment(`${config.prefix}-if`);
    _.after(this.ref, el);
    _.remove(el);
    this.inserted = false;
};

/**
 * 当v-if指令依赖的数据发生变化时触发此更新函数
 * @param value {Boolean} true/false 表示显示还是不显示该节点
 */
exports.update = function (value) {
    if (value) {
        // 挂载子实例
        if (!this.inserted) {
            if (!this.childBM) {
                this.build();
            }
            this.childBM.$before(this.ref);  // 这里其实就是将子实例插入DOM
            this.inserted = true;
        }
    } else {
        // 卸载子实例
        if (this.inserted) {
            this.childBM.$remove();        // 这里其实就是将子实例移出DOM
            this.inserted = false;
        }
    }
};

/**
 * 这个build比较吊
 * 因为对于一个 "v-if" 结构来说, 远比一个普通的文本节点要复杂。
 * 所以对弈v-if节点不能当成普通的节点来处理, 它更像是一个子的vue实例
 * 所以我们将整个v-if节点当成是另外一个vue实例, 然后实例化它
 */
exports.build = function () {
    this.childBM = new _.Bue({
        el: this.el   // 这个this.el就是#sub_app
    });
};

实现效果如下,这个版本的代码在这儿

demo1

Bug

然而,我们可以发现上面的做法存在一个很重大的bug。
考虑如下情况:

<div id="app">
    <p>姓名:{{user.name}}</p>
    <p>年龄:{{user.age}}</p>
    <div b-if="show" id="sub_app">
        <h1>如果show为真,我们就显示{{user.name}}</h1>
        <h1>如果show为真,我们就显示{{user.age}}</h1>
    </div>
</div>

实际效果却是如下图所示(直接报错):

bug

问题:对于b-if条件渲染,为什么加入了user.name和user.age之后,程序就报错呢?
这显然是不合理的,因为我们知道:必须做到条件渲染里面也可以渲染变量,我们看看如何解决这个问题。

带变量的条件渲染

为什么上面的程序会出现这样的bug呢?
通过debug代码我们发现了核心原因:因为实例化子实例#sub_app的时候我们压根没给它传data数据,所以子实例本身并没有自己的数据,所以根本拿不到user,更别说是user.name了。
但是按照我们正常的想法,即便这是一个条件渲染,也应该能够访问父实例所有的变量才对啊!
so,这就引出了一个重要的概念:作用域
在我们探索v-if指令之前,一直都只有一个vue实例,它有自己的数据,所以不存在作用域的问题。但是,当一个实例嵌套另外一个实例的时候,子实例的的作用域又是什么呢? 其实这是一个非常宽泛的问题,包括后期我们想实现组件化的时候,这个问题肯定是绕不过去的。
但是,目前组件化作用域这个问题对于我来说太难了。假如我们现在把问题简单化一些,只考虑实现v-if呢?
我们发现一个非常便利的地方:v-if的子实例的作用域完全等价于父实例的作用域。所以,我们通过下面的代码,将父实例的作用域传递到子实例。

// init.js
if (this.$parent) {
        this.$data = options.parent.$data;
} else {
        this.$data = options.data || {};
}

解决了作用域的问题,那么在子实例中就可以访问父实例的数据了。(喜大普奔~~)
但是,我们还有一个大问题:**设想以下情景:当修改父实例的数据user.name时,父实例的observer能监听到,然后就会触发父实例的_updateBindingAt,然后就会将一系列watcher放到bathcer队列中去,最后父实例中的DOM元素就得到了更新。但是子实例中的user.name没有跟着更新啊!!**为毛?因为子实例自己的observer为空啊!!
所以我们需要将父实例中的observer对象也一并传过来!!

if (this.$parent) {
        this.observer = this.$parent.observer;
    } else {
        this.observer = Observer.create(data);
    }

至此,我们就实现了带变量的条件渲染了。具体的效果如下图所示,这个版本的代码在这里
demo2

后话

在写这个v-if条件渲染的时候,我参考的vue版本是这个。然而,在这个版本中,其实作者只实现了不带变量的情况,并没有实现带变量的情况。对于带变量的实现方法,是我自己想的,所以显得非常简单粗暴。
做到这儿,我能感觉到,之后作用域问题将会是一个非常核心的问题,需要好好思考思考。

更新

时间:2016/9/22
内容:当我去实现v-repeat列表渲染的时候,发现本篇采用的直接传递$data和observer的方法无法解决列表渲染的作用域问题,所以用原型链的方式重写了这一部分,可以参考下一篇中的具体解释。