Javascript 继承

date
Mar 13, 2019
slug
javascript-extend
status
Published
tags
Javascript
summary
通过一个例子加深对 Javascript 中继承的理解🤠
type
Post
Javascript的继承是基于原型的,之前理解的比较简单,直到最近在用 es5 写星级评分组件的时候遇到了些问题,解决之后理解深了一些。总结归纳,以便后用。

星级评分

原本的需求有俩个:
  1. 根据后台返回的评价分数单纯进行5颗星的展示
  1. 用户可以在5颗星上为商品进行打分,单击后上传
开始用最笨的方法,从业务出发很快速的把俩个需求都实现了。最后想着能不能利用继承来优化,方便后期扩展可以进行半星或者四分之一星评分。
具体效果如下:
notion image

利用原型分别实现满星与半星

这里的星星使用 iconfont 的字体图标

Dom结构

<section class="star-rate">
    <ul class="stars">
        <li class="star iconfont icon-star-off" title="wtf"></li>
        <li class="star iconfont icon-star-off" title="bad"></li>
        <li class="star iconfont icon-star-off" title="not bad"></li>
        <li class="star iconfont icon-star-off" title="good"></li>
        <li class="star iconfont icon-star-off" title="perfect"></li>
    </ul>
</section>

满星&半星JS代码

var starRating = (function() {
    // 整颗
    var lightenFullStar = function(el, opts) {
        this.$el = $(el)
        this.$stars = this.$el.find('.item')
        this.opts = opts
    }
    lightenFullStar.prototype.lighten = function(num) {
        num = parseInt(num)
        this.$stars.each(function(i) {
            if(i < num ) {
                $(this).removeClass('icon-star-off').addClass('icon-star-on')
            }else{
                $(this).removeClass('icon-star-on').addClass('icon-star-off')
            }
        })
    }
    lightenFullStar.prototype.bindEvent = function() {
        var self = this
        self.$el.on('click', '.item', function() {
            var selectedStar = $(this).index() + 1
            self.opts.num = selectedStar
            typeof self.opts.select === 'function' && self.opts.select.call(this, selectedStar, self.$stars.length)
        }).on('mouseover', '.item', function() {
            self.lighten($(this).index() + 1)
        }).on('mouseout', function() {
            self.lighten(self.opts.num)
        })
    }
    lightenFullStar.prototype.init = function() {
        this.lighten(this.opts.num)
        if(!this.opts.readOnly){
            this.bindEvent()
        }
    }

    // 半颗
    var lightenHalfStar = function(el, opts) {
        this.$el = $(el)
        this.$stars = this.$el.find('.item')
        this.opts = opts
        this.patch = 1
    }
    lightenHalfStar.prototype.lighten = function(num) {
        var integer = parseInt(num),
            hasDecimals = integer !== num
        
        this.$stars.each(function(i) {
            if(i < num ) {
                $(this).removeClass('icon-star-off icon-star-half').addClass('icon-star-on')
            }else{
                $(this).removeClass('icon-star-on icon-star-half').addClass('icon-star-off')
            }
        })
        // 半颗的点亮
        if(hasDecimals) {
            this.$stars.eq(integer).removeClass('icon-star-off icon-star-on').addClass('icon-star-half')
        }
    }
    lightenHalfStar.prototype.bindEvent = function() {
        var self = this
        self.$el.on('mousemove', '.item', function(e) {
            // 这是理想状态如果设置padingleft/right 还没到目标dom星星就产生变化
            // 星星的间距最好设置为margin
            if(e.pageX - $(this).offset().left < $(this).width() / 2) {
                self.patch = 0.5
            }else{
                self.patch = 1
            }
            var selectedStar = $(this).index() + self.patch
            self.lighten(selectedStar)
        }).on('click', '.item', function() {
            self.opts.num = $(this).index() + self.patch
            typeof self.opts.select === 'function' && self.opts.select.call(this, self.opts.num, self.$stars.length)
        }).on('mouseout', function() {
            self.lighten(self.opts.num)
        })
    }
    lightenHalfStar.prototype.init = function() {
        this.lighten(this.opts.num)
        if(!this.opts.readOnly){
            this.bindEvent()
        }
    }


    // default
    var defaults = {
        mode: 'full',
        num: 0,
        readOnly: false,
        select: function() {}
    }

    var reflect = {
        'full': lightenFullStar,
        'half': lightenHalfStar
    }

    var _init = function(el, opts) {
        opts = $.extend({}, defaults, opts)

        if(!reflect[opts.mode]) opts.mode = 'full'
        new reflect[opts.mode](el, opts).init()
    }

    return {
        init: _init
    }

})()

starRating.init('.star-list', {
    mode:'full',
    num: 4.6,
    readOnly: false,
    select: function(selected, total) {
        console.log(selected + '/' + total)
    }
})
简单来说一下,这里主要运用的事件有 mouseover(mousemove)、mouseout、click。为什么不用 mouseenter&mouseleave 具体可以参考mdn。满星利用 mouseover 监听鼠标移入当前 dom 的操作,半星则将 mouseover 替换为 mousemove ,因为此时我们的鼠标移入时在星星的右侧也就是整颗星,此时在星星内部鼠标从右向左移动 mouseover 是无法触发的,所以不能选择半星。其他的实现就是按照业务一步步来即可,这俩段代码写完之后,发现其实很像。ok,下来利用继承与多态来改写。

利用模版方法改写代码

模版方法是什么?简单来说,抽离出公共逻辑作为父类,子类继承父类,对于特有的逻辑或方法对父类中的逻辑或方法进行重写。具体可以参考设计模式。

提取公共逻辑设置父类 Lighten

var Lighten = function(el, opts) {
    this.$el = $(el)
    this.$stars = this.$el.find('.item')
    this.opts = opts
    this.patch = 1
    this.selectEvent = 'mouseover'
}
Lighten.prototype.lighten = function(num) {
    num = parseInt(num)

    this.$stars.each(function(i) {
        if(i < num ) {
            $(this).removeClass('icon-star-off icon-star-half').addClass('icon-star-on')
        }else{
            $(this).removeClass('icon-star-on icon-star-half').addClass('icon-star-off')
        }
    })
}
Lighten.prototype.bindEvent = function() {
    var self = this
    self.$el.on(self.selectEvent, '.item', function(e) {
        self.calculateRange(e, $(this))
        self.lighten($(this).index() + self.patch)
    }).on('click', '.item', function() {
        var selectedStar = $(this).index() + self.patch
        self.opts.num = selectedStar
        typeof self.opts.select === 'function' && self.opts.select.call(this, selectedStar, self.$stars.length)
    }).on('mouseout', function() {
        self.lighten(self.opts.num)
    })
}
Lighten.prototype.calculateRange = function() {
    // 子类必须重写该方法
    console.warn('Subclasses must be overridden')
}
Lighten.prototype.init = function() {
    this.lighten(this.opts.num)
    if(!this.opts.readOnly){
        this.bindEvent()
    }
}

子类 lightenFullStar & lightenHalfStar

// full star
var lightenFullStar = function(el, opts) {
    Lighten.call(this, el, opts)
    this.selectEvent = 'mouseover'
}
lightenFullStar.prototype.lighten = function(num) {
    Lighten.prototype.lighten.call(this, num)
}
lightenFullStar.prototype.calculateRange = function() {}
lightenFullStar.prototype = new Lighten()
lightenFullStar.prototype.construtor = lightenFullStar

// half star
var lightenHalfStar = function(el, opts) {
    Lighten.call(this, el, opts)
    this.selectEvent = 'mousemove'
}
lightenHalfStar.prototype.lighten = function(num) {
    var integer = parseInt(num),
        hasDecimals = integer !== num
    
    Lighten.prototype.lighten.call(this, integer)
    // 半颗的点亮
    if(hasDecimals) {
        this.$stars.eq(integer).removeClass('icon-star-off icon-star-on').addClass('icon-star-half')
    }
}
lightenHalfStar.prototype.calculateRange = function(e, $this) {
    if(e.pageX - $this.offset().left < $this.width() / 2) {
        this.patch = 0.5
    }else{
        this.patch = 1
    }
}
lightenHalfStar.prototype = new Lighten()
lightenHalfStar.prototype.construtor = lightenHalfStar
看起来不错,感觉万事大吉了。跑起来试试,发现报错:
notion image
看起来还不错的代码竟然跑挂了,根据报错信息很明显可以分析出没有执行子类重写的 lighten 方法。这是什么原因导致的呢?
lightenHalfStar.prototype = new Lighten()
这行代码是将构造函数 Lighten 的原型对象赋值给 lightenHalfStar 的原型对象。这本身是没什么错的,原型继承的关键也是这句话。将此时的lightenHalfStar.prototype打印出来如下:
notion image
在执行lightenHalfStar.prototype = new Lighten()之前lightenHalfStar.prototype中的 lighten确实是自己重写的(可以看 FunctionLocation:120)。让我再看看执行之后的结果。如下:
notion image
可以看到 FunctionLocation:41 (即指向了父类的 lighten 方法所在文件中的位置)为什么会这样?答案其实很简单,将 lightenHalfStar.prototype = new Lighten() 的顺序放在 lightenHalfStar 构造函数之后、声明 lightenHalfStar 原型方法之前即可。

总结&改进

对于实现重写方法来说,继承语句的位置是相当关键的。一不注意就进了坑。
注意原型继承中子类的构造器需要手动置回。如下:
lightenHalfStar.prototype = new Lighten()

lightenHalfStar.prototype.constructor = lightenHalfStar
原型继承若子类与父类构造器中变量同名则子类的变量会被覆盖,要想重写,就得在构造器中改变 this 指向。如代码中的:Lighten.call(this, el, opts)
通过上图我们也可以看到实例化之后父类构造函数的值也会被带进来。如果值很多的话也会浪费资源,这并不是比较好的结果。优化方法:
var extend = function(sub, sup) {
    var F = function() {}
    F.prototype = sup.prototype
    sub.prototype = new F()
    sub.prototype.construtor = sub
}
利用空的构造函数做中间人(代理)即可解决。

参考资料


© i7eo 2017 - 2022