Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
youngwind opened this issue Sep 11, 2016 · 1 comment
Open
Labels

Comments

@youngwind
Copy link
Owner

youngwind commented Sep 11, 2016

前言

相信大家都用过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的方法无法解决列表渲染的作用域问题,所以用原型链的方式重写了这一部分,可以参考下一篇中的具体解释。

@youzaiyouzai666
Copy link

只需要if.js中

exports.build = function () {
    this.childBM = new _.Bue({
        el: this.el,
        data: this.vm.$data//添加,只需要添加这句,就可以解决
    });
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants