vue源码学习系列之十:组件化原理探索(动态props)

@youngwind 2016-10-04 02:15:13发表于 youngwind/blog Vue

前言

在上一篇 #92 中,我们已经实现了通过静态props传递数据,今天我们来看看,如何实现动态props传递数据

问题具象

考虑下面的情况

<div id="app">
    <my-component :name="user.name1" message="近况如何?"></my-component>
    <my-component :name="user.name2" message="How are you?"></my-component>
</div>
import Bue from 'Bue'
var MyComponent = Bue.extend({
    template: '<div>' +
                '<p>Hello,{{name}}</p>' +
                '<p>{{message}}</p>' +
              '</div>',
    props: {
       // 对props的声明,本来应该写一些prop验证之类的,不过尚未实现这个功能
        name: {},
        message: {}
    }
});

Bue.component('my-component', MyComponent);

const app = new Bue({
    el: '#app',
    data: {
        user: {
            name1: '梁少峰',
            name2: 'youngwind'
        }
    }
});

注意:组件<my-component>有两个prop,其中name是动态prop,message是静态prop。
我们的目标是:在正确渲染组件的前提下,当#app的user.name1或者user.name2发生改变的时候,<my-component>实例对应地发生改变。

思路

我们把上面的大目标分解成下面两个小目标。

  1. 无论是动态prop还是静态prop,都是prop,都要像处理静态prop那样,把它们解析出来,然后塞到$data当中去。这一点好办,因为我们在上一篇 #92 中已经实现了静态prop的解析和渲染。
  2. 对于动态prop,需要特殊处理。要做到当父实例的数据发生改变的时候,子组件也跟着改变。

要实现第二点,又有两种思路:

  1. 当父实例的数据发生改变时,将改变传导到子组件(就像处理条件渲染 #90 那样)。子组件接收到变化信号之后,跑到父实例去拿新的数据,然后更新自己本身的数据,然后触发notify,然后更新DOM。
  2. 当父实例的数据发生改变时,父实例直接修改子组件对应的数据,然后触发notify,然后更新DOM。

显然,第二种方式更为简洁,父子实例之间只需要进行一次通信。
但是第二种方式有一个关键点还没想通:程序如何知道,当父实例的哪个数据发生改变时,要修改子组件对应的数据呢?也就是说,如何将父实例的数据与子组件的动态prop一一映射起来?
这个问题似曾相识,因为我们曾经解决过:

只更新数据变动相关的DOM,必须有个这样的对象,将DOM节点和对应的数据一一映射起来,这里引入Directive(指令)的概念

没错,我们采取的的思路跟如何实现动态数据绑定#87 一模一样,所以可以直接复用Directive、Watcher这一套东西。

ok,思路理清之后,开始敲代码。先从解析props(包括动态和静态的)开始。

解析props

我们从改造之前写好的_initProps方法入手。

/**
 * 初始化组件的props,将props解析并且填充到$data中去
 * 在这个过程中,如果是动态属性, 那么会在父实例生成对应的directive和watcher,用于prop的动态更新
 * @private
 */
exports._initProps = function () {
    let {el, props, isComponent} = this.$options;
    if (!isComponent || !props) return;
    let compiledProps = this.compileProps(el, props);  // 解析props
    this.applyProps(compiledProps);                              // 应用props
};
/**
 * 解析props参数, 包括动态属性和静态属性
 * @param el {Element} 组件节点,比如: <my-component b-bind:name="user.name" message="hello"></my-component>
 * @param propOptions {Object} Vue.extend的时候传进来的prop对象参数, 形如 {name:{}, message:{}}
 * @returns {Array} 解析之后的props数组,
 * 形如: [
 *          {
 *              "name":"name",     // 动态prop
 *              "options":{},      // 原先Vue.extend传过来的属性对应的参数, 暂时未空, 之后会放一些参数校验之类的
 *              "raw":"user.name", // 属性对应的值
 *              "dynamic":true,    // true代表是动态属性,也就是从父实例/组件那里获取值
 *              "parentPath":"user.name"   // 属性值在父实例/组件中的路径
 *          },
 *          {
 *              "name":"message",   // 静态prop
 *              "options":{},
 *              "raw":"hello"
 *          }
 *     ]
 */
exports.compileProps = function (el, propOptions) {
    let names = Object.keys(propOptions);
    let props = [];
    names.forEach((name) => {
        let options = propOptions[name] || {};
        let prop = {
            name,
            options,
            raw: null
        };

        let value;

        if ((value = _.getBindAttr(el, name))) {
            // 动态props
            prop.raw = value;
            prop.dynamic = true;
            prop.parentPath = value;
        } else if ((value = _.getAttr(el, name))) {
            // 静态props
            prop.raw = value;
        }
        props.push(prop);
    });
    return props;
};

其中的getBindAttr函数是为了获取动态prop的值,无论是b-bind:name="user.name"还是:name="user.name"都会被当做动态prop,这跟vue的缩写处理是一样的。

/**
 * 获取动态数据绑定属性值,
 * 比如 b-bind:name="user.name" 和 :name="user.name"
 * @param node {Element}
 * @param name {String} 属性名称 比如"name"
 * @returns {string} 属性值
 */
exports.getBindAttr = function (node, name) {
    return exports.getAttr(node, `:${name}`) || exports.getAttr(node, `${config.prefix}bind:${name}`);
};

/**
 * 获取节点属性值,并且移除该属性
 * @param node {Element}
 * @param attr {String}
 * @returns {string}
 */
exports.getAttr = function (node, attr) {
    let val = node.getAttribute(attr);
    if (val) {
        node.removeAttribute(attr);
    }
    return val;
};

应用props

上面我们已经成功将所有的prop(包括静态prop和动态prop)都从<my-component b-bind:name="user.name" message="hello"></my-component>上面解析出来了,解析的结果是一个props数组。接下来我们来看看如何应用这个props数组
再次明确一下思路,无论是静态还是动态prop,都需要直接将属性塞到组件的$data当中去。如果是动态属性,还需要走Directive、Watcher那一套。

/**
 * 应用props
 * 如果是动态属性, 需要额外走Directive、Watcher那一套流程
 * 因为只有这样,当父实例/组件的属性发生变化时,才能将变化传导到子组件
 * @param props {Array} 解析之后的props数组
 */
exports.applyProps = function (props) {
    props.forEach((prop) => {
        if (prop.dynamic) {
            // 动态prop
            let dirs = this.$parent._directives;
            dirs.push(
                new Directive('prop', null, this, {
                    expression: prop.raw,  // prop对应的父实例/组件的哪个数据, 如:user.name
                    arg: prop.name          // prop在当前组件中的属性键值, 如:name
                })
            );
        } else {
            // 静态prop
            this.initProp(prop.name, prop.raw, prop.dynamic);
        }
    });
};
/**
 * 将prop设置到当前组件实例的$data中去, 这样一会儿initData的时候才能监听到这些数据
 * 如果是动态属性, 还需要跑到父实例/组件那里去取值
 * @param path {String} 组件prop键值,如"name"
 * @param val {String} 组件prop值,如果是静态prop,那么直接是"How are you"这种。
                 如果是动态prop,那么是"user.name"这种,需要从父实例那里去获取实际值
 * @param dynamic {Boolean} true代表是动态prop, false代表是静态prop
 */
exports.initProp = function (path, val, dynamic) {
    if (!dynamic) {
        // 静态prop
        this.$data[path] = val;
    } else {
        // 动态prop
        this.$data[path] = compileGetter(val)(this.$parent.$data);
    }
};

请注意,这里的compileGetter是之前已经实现的,目的是**根据给出的path路径,从数据对象中解析出对应的数据。**在实现计算属性的文章有提到过。 #89

prop指令

既然prop要走指令那一套,那么就得实现prop指令的bind和update方法。

// directives/prop.js
module.exports = {
    bind: function () {
        // this.arg == "name"; this.expression == "user.name", true代表是动态prop
        // 对于动态prop,在bind方法中完成**把prop塞到$data中的任务**
        this.vm.initProp(this.arg, this.expression, true);
    },

    update: function (value) {
        // 当父实例对应的数据放生改变时,就会执行这里的方法
        // 将新的数据设置到组件的$data中, 从而会引发组件数据的更新
        this.vm.$set(this.arg, value);
    }
};

实现效果

至此,我们已经基本实现了组件动态props传递数据,参考的依然是vue的1.0.26版本,实现的完整代码在这里,实现的效果如下图所示。
demo

下图说明动态prop真的被当成了指令来处理。
prop-directive

后话

在实现组件动态props的过程中,我遇到了一个隐藏得很深的问题:**在目前bathcer实现异步批处理的前提之下,如果在执行某些异步任务的过程中,产生了新的异步任务,该如何处理?**Debug了好一阵了才发现这个微博图,后来我自己想了一个办法临时处理了一下,不过还没完全想明白这样做到底好不好,所以就不在本文展开说了,之后有时间要好好想想。