all articles

Thoughts about front-end dev by implementing a ToDo App(12) -- Getter/Setter

2017-01-18 @sunderls

A_ToDo_App js

首先,同志们先打开Chrome devTool,然后试着添加和删除ToDo,可以看见不停的有dijest,所谓的dirty-check就是在数据可能发生变化的时候自己进行检测,在最开始「angular scope 诞生」的时候,这一招是为了减少人工操作,避免手动指定dom更新,但是现在有了一个更聪明的办法。

声明: 这个方法是最近看Vue.js源码学习到的,非原创。

1. 分析

目前的watcher

目前的watcher 格式如下:

{
    node,                   // 对应的 dom node
    component,              // 对应的 component
    expression,             // 用来求值的表达式 (实际上没怎么用,不过暂时留在了这里)
    val(),                  // 求值的function,这个function将上述component和expression包在了一起
    update(oldV, newV),     // 更新dom node的方法,比如事更新text还是attribute,还是类似for一样的增删dom
    isModel,                // 是不是model,用于form field
    isArray,                // 是不是Array,比如for
    childs[]                // 子watcher
}

这是一种被动的计算,但是实际上可能有更简单的理解方式,比如:

{props.todo.name}

比如上面这种expression,比如parse过后得到的是watcherA,那么如果把props.todo.name更换为getter/setter的话,当修改name如props.todo.name = 'test'的时候,setter可以反过来调用watcherA,而不用被动的digest。

另外非常不好意思,目前代码中 digest给拼错了,拼成了 dijest。。。。。

具体想法

  1. props和scope的数据需要全部更换为getter/setter。
  2. 当一个watcherA计算val的时候,调用到所有的getter都是这个watcher的依赖。所以getter中需要绑定watcher和setter的关系。
  3. 当setter被调用时,触发所有和setter绑定的watcher。

如此一来,digest()就不必要了。

嗯,感觉迷迷糊糊地。总之先试一试,先实现 {scope.todos.length}

2. 代码

2.1 首先做基础的修改

把dijest 改名为 digest,然后注释掉,这个体力活,不赘述。

2.2 dom渲染的时机

目前在component render的时候,调用了第一次digest。 新的情况下,干脆在bindNode里面加入初始化好了。

bindNode.js

...
newWatcher.parent = parentWatcher;
parentWatcher.childs.push(newWatcher);

// run watcher the first time
let newV = newWatcher.val();
if (newWatcher.isModel){
    newWatcher.update(undefined, newV || '');
    newWatcher.oldV = newV;
} else if (!newWatcher.isArray && (0 !== newV)){
    newWatcher.update(0, newV);
    newWatcher.oldV = newV;
} else if (newWatcher.isArray){
    let oldV = [];
    diff(oldV, newV, newWatcher.update.add, newWatcher.update.remove)
    newWatcher.oldV = newV.slice(0);
}

以上代码从digest那里挪过来的。 目前的代码修改: github

2.3 修改scope为getter/setter

scope.js 已经没有用了,删除掉(实际上是之前忘了删),新建 getterSetter.js:

// change all properties to getter/setter
function defineGetterSetter(data){
    let val = data;
    let type = typeof data;

    if (['object'].includes(type) && data !== null){
        if (Array.isArray(data)){
            data.forEach(defineGetterSetter);
        } else {
            Object.keys(data).forEach(key => {
                let val = data[key];
                Object.defineProperty(data, key, {
                    get(){
                        console.log('get', key, bindNode.currentWatcher);
                        return val;
                    },
                    set(newV){
                        console.log('set', key);
                        val = newV;
                    },
                    enumerable : true,
                    configurable : true
                });

                if (typeof val === 'object' && data !== null){
                    defineGetterSetter(val);
                }
            });
        }
    }
}

bindNode.currentWatcher是一个全局参数,代表当前调用的watcher是谁。

Component.js 中将scope进行转换。

class Component {
    constructor(options){

        Object.assign(this, options);
        // transfer scope to Scope
        defineGetterSetter(this.scope);

        // debug
        Component.instances.push(this);
    }

app 代码中的component定义稍作调整,把参数作为options传入。

demo/index.js

class TodoApp extends Component {
    constructor(){
        let options = {
            tmpl: `<div>
                <h1>{'To' + 'DO'}: {scope.todos.length}</h1>
                <ul>
                    <li style="{display: scope.todos.length > 0 ? 'none' : 'inherit'}">no item</li>
                    <todo-item for="item in scope.todos" todo="item" remove="remove"></todo-item>
                </ul>
                <p><add-todo todos="scope.todos" addItem="add"></add-todo></p>
            </div>`,
            scope: {
                todos: [{name: 'a'}],
            }
        }

        super(options);
    }
}

TodoApp中初始状态时默认一个item {name: 'a'},这样一来,scope的属性值都成了getter/setter。

最后,在bindNode中的初始化时,设置currentWatcher:

...
bindNode.currentWatcher = newWatcher;
let newV = newWatcher.val();
    if (newWatcher.isModel){
    ...

打开 console,可以看到 get todosget name的log,嗯,看来没啥问题。

2.4 关联watcher和getter

根据前面说明,我们需要在getter中关联,然后在setter中触发。具体以 props.todo.name为例:

  1. props.todo.name的getter在bindNode的初始化阶段,todo的setter和name的setter 都与watcher关联。
  2. props.todo变化时,触发watcher更新。
  3. props.todo.name变化时,触发watcher更新。

所以,直接在getter/setter中闭包一个watchers数组好了。

getterSetter.js

// change all properties to getter/setter
function defineGetterSetter(data){
    ...
    let watchers = new Set();

    ...
                get(){
                    console.log('get', key);
                    if (bindNode.currentWatcher){
                        watchers.add(bindNode.currentWatcher);
                    }

                    return val;
                },
                set(newV){
                    console.log('set', key);
                    val = newV;
                    watchers.forEach(triggerWatcher);
                },
    ...
}

接下来测试一下,打卡demo html,可以看到第一个item显示为a,然后在console中,直接修改第一个item的name。

> Component.instances[0].scope.todos[0].name = '11';
get todos
set name
get todos
get todos
get name
get newItemName
'11'

可以看到,第一个item顺利被更新! oh yeah。 也就是说,dirty-check的土办法不用了。

目前的代码更新: github

2.5 处理item的增

又回到了for。哎。

目前for对应的watcher的expression是 scope.todos,todos的增删会用到 todos.push, todos.splice,实际还可能用到shift,unshift等,这怎么搞? 这里就还没有来得及看vue.js的实现办法了,先自己想想。

首先todos.push还可能被Function.prototype.call的方式调用,所以感觉能做的,只有覆盖掉prototype的方法在其中触发setter了,这是常用的inject办法。 为了触发setter,需要知道todos的上一级对象是谁,所以在defineGetter中遇到array的时候,增加一个__parent

getterSetter.js

//override Array.prototype.push
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(function(method){
    let originalMethod = Array.prototype[method];
    Array.prototype[method] = function(){
        originalMethod.apply(this, arguments);
        if (this.__parent){
            this.__parent[this.__key] = this;
        }
    }
});

// change all properties to getter/setter
function defineGetterSetter(data, __parent, __key){
    let val = data;
    let type = typeof data;
    let watchers = new Set();

    if (['object'].includes(type) && data !== null){
        if (Array.isArray(data)){
            data.forEach((item, i) => {
                defineGetterSetter(item, data, i);
            });
        } else {
            Object.keys(data).forEach(key => {
                let val = data[key];
                Object.defineProperty(data, key, {
                    get(){
                        console.log('get', key);
                        if (bindNode.currentWatcher){
                            watchers.add(bindNode.currentWatcher);
                        }

                        return val;
                    },
                    set(newV){
                        console.log('set', key);
                        val = newV;
                        watchers.forEach(triggerWatcher);
                    },
                    enumerable : true,
                    configurable : true
                });

                Object.defineProperty(data, '__update', {
                    enumerable : true,
                    configurable : true
                });

                if (typeof val === 'object' && data !== null){
                    defineGetterSetter(val, data, key);
                }
            });
        }

        if (__parent) {
            data.__parent = __parent;
            data.__key = __key;
        }
    }
}

另外 triggerWatcher() 里面array的oldV写死成了空数组,这里一并改之: bindNode.js

function triggerWatcher(watcher){
    let newV = watcher.val();
    if (watcher.isModel){
        watcher.update(undefined, newV || '');
        watcher.oldV = newV;
    } else if (!watcher.isArray && (0 !== newV)){
        watcher.update(0, newV);
        watcher.oldV = newV;
    } else if (watcher.isArray){
        // before: let oldV = [];
        let oldV =  watcher.oldV || [];
        diff(oldV, newV, watcher.update.add, watcher.update.remove)
        watcher.oldV = newV.slice(0);
    }
}

2.6 处理item的删

增加貌似ok了,剩下就是删除了。删除的时候看似没有问题,console里面出现了Cannot read property 'name' of undefined的错误。这个估计又是之前的那种错误,因为上面的代码实际上调用了scope.todos = scope.todos,所以scop.todos以下的所有watcher都被触发了。 所以,需要在删除的时候,动态删除getter/setter上的watcher。所以,不但getter/setter上有到watcher的引用,反过来也需要有。

为了实现这个目的,需要在gettersetter中添加watcher的时候,修改watcher中的关系数组,假定名称为locations。

为了简单,暂时不用class封装,而是用全局方法来实现。 另外,digest已经不用了,所以删除digest的内容,digest.js重命名为watcher.js

watcher.js

/**
 * all watchers, to be dirty-checked every time
 */
const watchers = {};

/**
 * check watcher value change and update
 */
function triggerWatcher(watcher){
    console.log('triggerWatcher', watcher.expression);
    let newV = watcher.val();
    if (watcher.isModel){
        watcher.update(undefined, newV || '');
        watcher.oldV = newV;
    } else if (!watcher.isArray && (0 !== newV)){
        watcher.update(0, newV);
        watcher.oldV = newV;
    } else if (watcher.isArray){
        let oldV =  watcher.oldV || [];
        diff(oldV, newV, watcher.update.add, watcher.update.remove)
        watcher.oldV = newV.slice(0);
    }
}

/**
 * add setter watchers
 * @param {Watcher} watcher - watcher to bind
 * @param {Array} location - watchers array in setter
 */
function bindWatcherOnSetter(watcher, setterLocation){
    watcher.locations = watcher.locations || new Set();
    if (!watcher.locations.has(setterLocation)){
        watcher.locations.add(setterLocation);
    }

    if (!setterLocation.has(watcher)){
        setterLocation.add(watcher);
    }
}

/**
 * remove setter watchers
 * @param {Watcher} watcher - watcher to bind
 */
function unwatch(watcher){
    let list = watcher.parent.childs;
    list.splice(list.indexOf(watcher), 1);

    watcher.locations.forEach(loc => {
        loc.delete(watcher);
    });
}

相应的 getterSetter里面更新为'bindWatcherOnSetter'。 另外,bind.js中有个bug,在初始化triggerWatcher过后,需要把currentWatcher设空。

如此这般,终于把bug修好了。现在的查分在github

2.7 优化for loop

打开console,发现triggerWatcher props.todo.name在增加删除到时候都被触发,这个可以理解。因为从下往上看,TodoItem中的模版是:

props.todo.name (TodoItem) => item.name (intermediate component) => todos[i].name (TodoApp)

所以scope.todos触发更新的时候,基本上全部watcher都会被触发。 这个如果是assign新的todos的话还好说,如果是原来的数组删减的话就有点说不过去了。 之所以原来的优化失效,是因为scope.todos对应了两个watcher,scope.todos(for)和scope.todo.item(如上): 在第一个watcher中,已经把该调整的调整好了,所以第二个watcher的触发是不必要的。 怎么实现呢。。。。

首先修复一个bug,在for的remove中,删除dom的处理有bug,单个删除虽然没啥问题,但是多个的时候顺序有错,改为:

// delete dom & unwatch
i = to;
while (i >= from){
    console.log('remove dom', i);
    parentNode.removeChild(parentNode.childNodes[i]);
    console.log('unwatch', i);
    unwatch(newWatcher.childs[i]);
    i--;
}

如何不触发第二个watcher也就是要想办法避免scope.todos包含for以外的watcher。 每一个watcher都对应dom中可能变化的点,而getter/setter是指component里面的数据,每一个watcher第一次计算的时候,会出发getter然后注册依赖。 具体来看一个上面的例子:

对于props.todo.name => todos[i].name: 在todos的getter中,会遇到for 和 name的watcher。 所以似乎可以假定如果data本身是数组,而且watcher是在某个for之下的话,就不注册依赖,也就是说:

getterSetter.js

// if data is array, then closestArrayWatcher must be the For
// and prevent gettersetter under For
if (Array.isArray(val) && closestArrayWatcher && closestArrayWatcher !== currWatcher){
} else {
    console.log('bind ', currWatcher, ' to ', key);
    bindWatcherOnSetter(currWatcher, watchers);
}

这个如果是for for嵌套呢? 比如 todos[i].items[j].name,这种情况下按照↑代码的逻辑,todos和items都不会注册name,嗯,感觉可行,所以,需要让watcher新增一个closestArrayWatcher字段。

2.8 新增closestArrayWatcher

首先在bindNode中,新增一个closestArrayWatcher bindNode.js

const bindNode = (node, type, component, parsed, extra) => {
    let parentWatcher = extra.parentWatcher || watchers;
    let closestArrayWatcher = extra.parentWatcher && extra.parentWatcher.isArray ? extra.parentWatcher : extra.closestArrayWatcher;
    let newWatcher = null;
    ...

另外watcher的初始化是深度优先的,全局的currentWatcher肯定不行,需要改为一个stack。 把这个放在root watcher中,原来的watchers放在watchers.root当中, bindNode中做相应的调整。

bindNode.js

...
Watchers.currentWatcherStack.unshift(newWatcher);

triggerWatcher(newWatcher);

Watchers.currentWatcherStack.shift();
...

watcher.js

/**
 * all watchers, to be dirty-checked every time
 */
const Watchers = {
    root: {},
    currentWatcherStack: []
};

getterSetter中也进行命名调整增加一点点可读性,另外在for的dom删减的时候有一些bug进行了修复,具体不赘述。到此为止的commit在github

2.8 结果

打开console,可以看到新增item的时候,todos没有触发多余的props.todo.name,for接管了更新,而且删除的时候也没有问题。也就是for优化完成了。 yeah。

3. 总结

改为getter/setter的目的是什么呢?时为了避免不必要的dirty-check,老实说,这其中有多少的差其实我也没有测量过,记得Angular 1.x的文档曾说这没有多大的消耗,只是求值比较而已。 更换为getter/setter过后,多产生的getter/setter会不会造成性能影响,我也没有测量过,不过这么做有一点好处是,模版的parse变得简单了,依赖关系的处理着实非常聪明。

在console输入以下命令:

> Component.instances[0].scope.todos[0].name = '123'            // 可以看到只触发了一个watcher
> Component.instances[0].scope.todos = [{name:4},{name:5}]      // 数据正确被更新
> Component.instances[0].scope.todos = [{name:4},{name:588}]    // 数据正确被更新
> Component.instances[0].scope.todos.splice(0,1)                // 删除第一个数据
> Component.instances[0].scope.todos.splice(0,1)                // 再次删除,显示no item

再删除或者指值的时候可以看出getter/setter方式的好处,只触发了需要的watcher,达到了dom的最小更新。嗯,这次就到这里,仅对内部dom修改逻辑进行了调整, 使用方式没有做修改。 敬请期待下一次更新。