Skip to content

Latest commit

 

History

History
702 lines (595 loc) · 23 KB

JavaScript中的组件开发.md

File metadata and controls

702 lines (595 loc) · 23 KB

比如我们要实现这样一个组件, 就是在输入框里面字数的计数. 图片名称

使用jQuery作为基础语言库

最简陋写法

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>demo</title>
    <script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
</head>
<body>
    <script>
        $(function() {

            var input = $('input');

            // 用来获取字数
            function getNum(){
                return input.val().length;
            };

            // 渲染元素
            function render() {
                var num = getNum();

                // 没有字数的容器就新建一个
                if ($('#count').length === 0) {
                    input.after('<span id="count"></span>');
                };

                $('#count').html(num+'个字');
            };

            // 监听事件
            input.on('keyup',function(){
                render();
            });

            // 初始化,第一次渲染
            render();
        })
    </script>
    <input type="text"/>
</body>
</html>

这里估计很多新手都会这样写, 执行起来没有什么问题, 但是各种全局变量, 再加入新的东西的时候就有各种问题, 变量名重复啊等等... 当然这里也可以用自执行函数来处理全局变量, 再想拓展组件各种局部变量也很麻烦.

作用于隔离

单个变量模拟命名空间

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>demo</title>
    <script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
</head>
<body>
    <script>
        var textCount = {
            input: null,
            init: function(config) {
                this.input = $(config.id);
                this.bind();
                // 返回this, 实现链式调用
                return this;
            },
            bind: function() {
                var self = this;
                this.input.on('keyup', function() {
                    self.render();
                });
            },
            getNum: function() {
                return this.input.val().length;
            },
            render: function() {
                var num = this.getNum();

                // 没有字数的容器就新建一个
                if ($('#count').length === 0) {
                    this.input.after('<span id="count"></span>');
                };

                $('#count').html(num+'个字');
            }
        };

        $(function() {
            textCount.init({id: 'input'}).render();
        })
    </script>
    <input type="text" id="input" />
</body>
</html>

这样改造一番, 立马清晰了很多, 所有功能都在一个变量下面. 代码清晰, 有统一的入口调用.

但是还是有瑕疵, 没有私有的概念, 比如上面的getNum()bind()应该都是私有的方法. 但是其它的代码可以随意的改动这些代码. 当代码量特别多时, 很容易出现变量重复或被修改的问题.

于是乎出现下面的写法

函数闭包写法

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>demo</title>
    <script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
</head>
<body>
    <script>
        var TextCount = (function() {
            // 私有方法, 外部无法访问
            var _bind = function(that) {
                that.input.on('keyup', function() {
                    that.render();
                });
            };

            var _getNum = function(that) {
                return that.input.val().length;
            };

            var TextCountFun = function(config) {};

            TextCountFun.prototype.init = function(config) {
                this.input = $(config.id);
                _bind(this);

                return this;
            };

            TextCountFun.prototype.render = function() {
                var num = _getNum(this);

                // 没有字数的容器就新建一个
                if ($('#count').length === 0) {
                    this.input.after('<span id="count"></span>');
                };

                $('#count').html(num+'个字');
            };
            
            // 返回构造函数
            return TextCountFun;
        })();

        $(function() {
            new TextCount().init({id: 'input'}).render();
        })
    </script>
    <input type="text" id="input" />
</body>
</html>

这种写法, 把所有的东西都包在了一个自动执行的闭包里面, 所以不会受到外面的影响, 并且只对外公开了TextCountFun构造函数, 生成的对象只能访问到init,render方法. 大部分jQuery插件都是这种写法.

面向对象

上面的写法以及可以满足绝大多数的需求了. 但是当一个页面特别复杂, 当我们需要的组件越来越多, 当我们需要做一套组件. 仅仅用这个就不行了. 首先就是这种写法太灵活, 写单个组件还可以. 如果我们需要做一套风格相近的组件, 多人同时开发. 噩梦级! ES6中已有class类, 这里我们用原型模拟.

简单的js类:

var Class = (function() {
    var _mix = function(r, s) {
        for(var p in s) {
            if(s.hasOwnProperty(p)) {
                r[p] = s[p]
            }
        }
    };

    var _extend = function() {
        // 开关 用来使生成原型时, 不调用真正的构成流程init
        this.initPrototype = true;
        var prototype = new this();
        this.initPrototype = false;

        var items = Array.prototype.slice.call(arguments) || [];
        var item;

        // 支持混入多个属性, 并且支持{} 也支持 Function
        while (item = items.shift()) {
            _mix(prototype, item.prototype || item);
        };

        // 这边是返回的类, 其实也就是我们返回的子类
        function SubClass() {
            if(!SubClass.initPrototype && this.init) {
                this.init.apply(this, arguments); // 调用init真正的构造函数
            };
        };

        // 赋值原型链, 完成继承
        SubClass.prototype = prototype;

        // 改变constructor引用
        SubClass.prototype.constructor = SubClass;

        // 为子类也添加extend方法
        SubClass.extend = _extend;

        return SubClass;
    };

    // 超级父类
    var Class = function() {};

    // 为超级父类添加extend方法
    Class.extend = _extend;

    return Class;
})();

// 继承超级父类, 生成个子类Animal, 并且混入一些方法. 这些方法会到Animal的原型上.
// 另外这边不仅支持滚入{}, 还支持混入 Function
var Animal = Class.extend({
    init: function(opts) {
        this.msg = opts.msg;
        this.type = 'animal';
    },
    say: function() {
        alert(this.msg + ': 我是 ' + this.type)
    }
});

// 继承Animal, 并且混入一些方法
var Dog = Animal.extend({
    init: function(opts) {
        // 并未实现super方法, 直接简单实用父类原型调用即可
        Animal.prototype.init.call(this, opts);
        this.type = 'dog';
    }
});

new Dog({msg: 'hi'}).say();

实用很简单, 超级父类具有extend方法, 可以继承一个子类. 子类也具有extend方法.

这里要注意一下, 继承的父类都是一个, 也就是单继承. 但是可以通过extend实现多重混入.

有了这个类的扩展, 我们可以这么编写代码:

var TextCount = Class.extend({
    init: function(config) {
        this.input = $(config.id);
        this._bind();
        this.render();
    },
    render: function() {
        var num = this._getNum();

        if($('#count').length == 0) {
            this.input.after('<span id="count"></span>');
        };

        $('#count').html(num + '个字');
    },
    _getNum: function() {
        return this.input.val().length;
    },
    _bind: function() {
        var self = this;
        self.input.on('keyup', function() {
            self.render();
        });
    }
});

$(function() {
    new TextCount({
        id: '#input'
    })
});

这里我们还未看到class的真正好处, 继续往下.

抽象出base

可以看到, 我们的组件有些方法, 是大不部分组价都会有的.

  • init 用来初始化属性
  • render 用来渲染
  • bind 用来处理时间绑定 当然这也是一种约定俗成的规范了. 如果大家全部按照这种风格来写代码, 开发大规模组件库就变得更加规范, 相互之间配合也更容易.

这时候面向对象好处就来了, 我们抽象出一个 Base 类. 其它组件编写时都继承它.

var Base = Class.extend({
    init: function(config) {
        // 自动保存配置项
        this.__config = config;
        this.bind();
        this.render();
    },
    // 可以使用get来获取配置项
    get: function(key) {
        return this.__config[key];
    },
    // 可以使用set来设置配置项
    set: function(key, value) {
        this.__config[key] = value;
    },
    bind: function() {},
    render: function() {},
    // 定义销毁的方法, 一些收尾工作都应该在这里
    destory: function() {},
});

base类主要把组件的一般性内容都提取出来, 这样我们编写组件时可以直接继承base类, 覆盖里面的bindrender方法.

于是我们这样写:

var TextCount = Base.extend({
    _getNum: function() {
        return this.get('input').val().length;
    },
    bind: function() {
        var self = this;
        self.get('input').on('keyup', function() {
            self.render();
        });
    },
    render: function() {
        var num = this._getNum();

        if($('#count').length == 0) {
            this.input.after('<span id="count"></span>');
        };

        $('#count').html(num + '个字');
    }
});

$(function() {
    new TextCount({
        // 直接传入input节点, 因为属性的赋值都是自动的.
        input: $('#input')
    });
});

可以看到我们直接实现一些固定的方法, bind, render就行了. 其它的base会自动处理(这里只是简单处理了配置属性的赋值).

事实上, 这边的init, bind以及render已经有了点生命周期的影子了, 但凡是组件都会具有这几个阶段, 初始化, 绑定事件, 以及渲染. 当然这边还可以加入一个destory销毁的方法, 用来清理现场.

此外为了方便, 这边直接变成了传递input的节点. 因为属性赋值自动化了, 一般来说这种情况都是使用getter, setter来处理. 这里不做详细说明.

引入事件机制(观察者模式)

有了base应该说我们编写组件更加规范化, 体系化. 继续深挖.

还是上面那个例子, 如果我们希望输入的字数超过5个就弹出警告. 如果改进.

小白可能会说: 简单啊直接改下bind方法:

bind: function() {
    var self = this;
    self.get('input').on('keyup', function() {
        if(self._getNum() > 5) {
            alert('超过五个字...');
        };
        self.render();
    })
}

这虽然也是一种解决方案, 但是代码严重耦合. 当这种需求特别多时, 代码将臃肿, 混乱不堪.

这个时候就要引入事件机制, 也就是经常说的观察者模式.

注意这里的时间机制和平时浏览器那些事件不是一回事, 要分开看.

什么是观察者模式? 观察者模式又被称为发布订阅模式(Publish/Subscribe), 它定义了一种 一对多的关系, 让观察者对象同时监听某一个主题对象, 这个主题对象的状态发生变化时就会通知所有的观察者对象, 使得他们能够自动更新自己. 好处:

  • 支持简单的广播通信, 自动通知所有已经订阅过的对象.
  • 页面载入后目标对象很容易与观察者存在一种动态关联, 增加灵活性.
  • 目标对象与观察者之间的抽象耦合关系能够单独扩展以及重用.

想象一下base是个机器人会说话, 他会一直监听输入的字数并且汇报出去(通知). 而你可以把耳朵凑上去, 听他汇报(监听). 发现字数超过5个字, 你就做些操作.

所以分为两个部分, 一个是通知, 一个是监听.

假设通知是fire方法, 监听是on. 我们这样写:

var TextCount = Base.extend({
    ...
    bind: function() {
        var self = this;
        self.get('input').on('keyup', function() {
            // 通知, 每当有输入的时候, 就报告
            self.fire('Text.input', self._getNum());
            self.render();
        });
    },
    ...
});

$(function() {
    var t = new TextCount({
        input: $('#input');
    });

    // 监听整个输入事件
    t.on('Text.input', function(num) {
        // 可以获取传递过来的字数
        if(num > 5) {
            alert('超过5个字....')
        };
    });
});

fire用来触发一个事件, 可以传递数据. 而on则用来添加一个监听. 这样组件里面只负责把一些关键的事件抛出来, 值具体的业务逻辑都可以添加监听来实现. 没有事件的组件是不完整的.

下面我们来实现这套事件机制.

抛开base, 想象如何实现具有这套机制的类.

// 辅助函数, 获取数组里某一个元素的索引 index
var _indexOf = function(array, key) {
    if(array === null) return -1;
    var i = 0, length = array.length;
    for(; i < length; i++) {
        if(array[i] === key) {
            return i
        };
    };
    return -1;
};

var Event = Class.extend({
    // 添加监听
    on: function(key, listener) {
        // this.__events存储所有的处理函数
        if(!this.__events) {
            this.__events = {};
        };
        if(!this.__events[key]) {
            this.__events[key] = [];
        };
        if(_indexOf(this.__events, listener) === -1 && typeof listener === 'function') {
            this.__events[key].push(listener);
        };

        return this
    },
    // 通知
    fire: function(key) {
        if(!this.__events || !this.__events[key]) return;

        var args = Array.prototype.slice.call(arguments, 1) || [];

        var listeners = this.__events[key];
        for(var i = 0; i < listeners.length; i++) {
            listeners[i].apply(this, args);
        };

        return this;
    },
    // 取消监听
    off: function(key, listener) {
        if(!key && !listener) {
            this.__events = {};
        };

        // 不传监听函数, 就去掉当前key下面所有的监听函数
        if(key && !listener) {
            delete this.__events[key];
        };

        if(key && listener) {
            var listeners = this.__events[key];
            var index = _indexOf(listeners, listener);

            if(index > -1) {
                listeners.splice(index, 1);
            };            
        };

        return this;
    }
});

var test = new Event();
// 添加test监听事件
test.on('test', function(msg) {
    console.log(msg)
});

// 通知
test.fire('test', '第一次触发');
test.fire('test', '第二次触发');

test.off('test');

test.fire('test', '这里已经注销 test 触发不了');

实现起来并不复杂, 只要使用this.__events存储所有的监听函数. 在fire的时候去找到并且执行就可以了.

这个时候面向对象的好处就来了, 如果我们希望base拥有事件机制. 只需要这样写:

var Base = Class.extend(Event, {
    ...
    destroy: function() {
        // 去掉所有的事件监听
        this.off();
    }
});

主需要在extend的时候多混入一个Event, 这样Base或者它的子类生成的对象都会自动具有事件机制.

有了事件机制我们可以把组件内部很多状态暴露出来, 比如我们可以在set方法中抛出一个事件, 这样每次属性变更的时候我们就可以监听到.

这里为止, 我们的base类已经像模像样了, 具有init, bind, render, destory方法来表示组件的各个关键过程, 并且具有了事件机制. 基本上已经可以很好的来开发组件了.

richbase

我们还可以继续深挖. 看看我们的base, 还差些什么. 首先浏览器的事件监听还很落后, 需要用户自己再bind里面绑定, 再然后现在的TextCount里面还存在dom操作, 也没有自己的模板机制. 这都是需要拓展的, 于是我们在base的基础上再继承出一个richbase用来实现更完备的组件基类.

功能点:

  • 事件代理: 不需要用户自己去找dom元素绑定监听, 也不需要用户去关心什么时候销毁.
  • 模板渲染: 用户不需要覆盖render方法, 而是覆盖实现setUp方法. 可以通过在setUp里面调用render来达到渲染对应html的目的.
  • 单向绑定: 通过setChuckdata方法, 更新数据, 同时会更新html内容, 不需要操作dom

看一下我们实现richbase后组件如何写:

var TextCount = RichBase.extend({
   // 事件直接在这里注册, 会代理到parentNode节点, parentNode节点在下面指定
   EVENTS: {
       // 选择器字符串, 支持所有jQuery风格的选择器
       'input': {
           // 注册keyup事件
           keyup: function(self, e) {
               // 单向绑定, 修改数据直接更新对应模板
               self.setChuckdata('count', self._getNum());
           }
       }
   },
   // 指定当前组件的模板
   template: '<span id="count"><%= count %>个字</span>',
   //私有方法
   _getNum: function() {
       return this.get('input').val().length || 0;
   },
   // 覆盖实现setUp方法, 所有逻辑写在这里. 最后可以使用render来决定需不需要渲染模板
   // 模板渲染后会append到parentNode节点下面, 如果未指定, 会append到document.body
   setUp: function() {
       var self = this;

       var input = this.get('parentNode').find('#input');
       self.set('input', input);

       var num = this._getNum();
       // 赋值数据, 渲染模板, 
       self.render({
           count: num
       })
   }
});

$(function() {
   // 传入parentNode节点, 组件会挂载到这个节点上. 所有事件都会代理到这个上面
   new TextCount({
       parentNode: $('app')
   });
});

由上面的用法入手 就变得简单清晰了:

  • 事件不需要自己绑定, 直接注册在EVENTS属性上. 程序会自动将事件代理到parentNode上.
  • 引入了模板机制, 使用template规定组件的模板, 然后再setUp里面使用render(data)的方式渲染模板, 程序会自动帮你appendparentNode下面.
  • 单向绑定, 无序操作dom, 后面要改动内容, 不需要操作dom, 只需要调用setChuckdata(key, newValue), 选择性的更新某个数据, 响应的html会自动渲染.

下面我们看richbase的实现:

var RichBase = Base.extend({
    EVENTS: {},
    template: '',
    init: function(config) {
        this.__config = config;
        // 解析代理事件
        this._delegateEvent();
        this.setUp();
    },
    // 循环遍历EVENTS, 使用jQuery的delegate代理到parentNode
    _delegateEvent: function() {
        var self = this;
        var events = this.EVENTS || {};
        var eventObjs, fn, select, type;
        var parentNode = this.get('parentNode') || $(document.body)

        for(select in events) {
            eventObjs = events[select];

            for(type in eventObjs) {
                fn = eventObjs[type];
                parentNode.delegate(select, type, function(e) {
                    fn.call(null, self, e);
                });
            };
        };
    },
    // 支持underscore的极简模板语法
    // 用来渲染模板, 这边抄underscore的. 非常简单的模板引擎, 支持元素js语法
    _parseTemplate: function(str, data) {
        var fn = new Function('obj',
            'var p=[],print=function(){p.push.apply(p,arguments);};' +
            'with(obj){p.push(\'' + str
                .replace(/[\r\t\n]/g, " ")
                .split("<%").join("\t")
                .replace(/((^|%>)[^\t]*)'/g, "$1\r")
                .replace(/\t=(.*?)%>/g, "',$1,'")
                .split("\t").join("');")
                .split("%>").join("p.push('")
                .split("\r").join("\\'") +
            "');}return p.join('');");
        return data ? fn(data) : fn;
    },
    // 提供给子类覆盖实现
    setUp: function() {
        this.render();
    },
    // 用来实现刷新, 只需要传入之前render时的数据里的key还有更新值, 就可以自动刷新模板
    setChuckdata: function(key, value) {
        var self = this;
        var data = self.get('__renderData');

        // 更新对应的值
        data[key] = value;

        if(!this.template) return;

        // 重新渲染
        var newHtmlNode = $(self._parseTemplate(this.template, data));

        // 拿到存储的渲染后的节点
        var currentNode = self.get('__currentNode');
        if(!currentNode) return;

        // 替换内容
        currentNode.replaceWith(newHtmlNode);

        self.set('__currentNode', newHtmlNode);
    }, 
    // 使用data来渲染模板并且append到parentNode下面
    render: function(data) {
        var self = this;
        // 先存储起来渲染的data, 方便后面的setChuckdata获取使用
        self.set('__renderData', data);

        if(!this.template) return;

        // 使用_parseTemplate解析渲染模板生成html
        // 子类可以覆盖这个方法使用其它模板引擎解析
        var html = self._parseTemplate(this.template, data);
        var parentNode = this.get('parentNode') || $(document.body);

        var currentNode = $(html);

        // 保留下来留待后面的区域刷新
        // 存储起来, 方便后面setChuckdata获取使用
        self.set('__currentNode', currentNode);
        parentNode.append(currentNode);
    },
    destory: function() {
        var self = this;

        // 去掉自身的事件监听
        self.off();

        // 删除渲染好的dom节点
        self.get('__currentNode').remove();

        // 去掉绑定的代理事件
        var events = self.EVENTS || {};
        var eventObjs, fn, select, type;
        var parentNode = self.get('parentNode');

        for(select in events) {
            eventObjs = events[select];

            for(type in eventObjs) {
                fn = eventObjs[type];
                parentNode.undelegate(select, type, fn);
            };
        };
    }
});

主要做了两件事, 一个就是事情的解析跟代理, 全部代理到parentNode上面. 另外就是把render抽出来, 用户只需要实现setUp方法. 如果需要模板支持就在setUp里面调用render来渲染模板, 并且可以通过setChuckdata来刷新模板, 实现带向绑定.

最终代码
原文